import assert from 'node:assert/strict'; import test from 'node:test'; import vm from 'node:vm'; import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { validateStandardOperation, validateProgramOperation } from '../LianSyn-platform/external-agent-client.mjs'; import Ajv from 'ajv/dist/2020.js'; import addFormats from 'ajv-formats'; import { loadPageActionRealm } from './page-action-test-realm.mjs'; const plans = createRequire(import.meta.url)('../chrome-extension/ltjt-order-assistant/operation-plans.js'); const ajv = new Ajv({ strict: false }); addFormats(ajv); const parsedSchema = ajv.compile(JSON.parse(readFileSync(new URL('../schemas/agent_operation.schema.json', import.meta.url)))); const executionSchema = ajv.compile(JSON.parse(readFileSync(new URL('../schemas/standard_system_operation.schema.json', import.meta.url)))); const inpage = readFileSync(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8') .replace('inspectArrangementLookupReadiness(form,', 'testResolve: resolveArrangementExecutionOperation,\n inspectArrangementLookupReadiness(form,'); const adapter = readFileSync(new URL('../chrome-extension/ltjt-order-assistant/lifecycle-adapters.js', import.meta.url), 'utf8') .replace('assistant.preflightLifecycleOperation = preflightLifecycleOperation;', ` assistant.testSlot = arrangementSlotEvidence; assistant.testApply = (form, operation) => applyArrangement(form, isIndexedArrangementCreate(operation) ? arrangementCreateAtSlot(operation, 0) : operation); assistant.testOperationBlockers = operationBlockers; assistant.testExpected = arrangementExpectedFields; assistant.testResource = arrangementResourceProof; assistant.testNative = applyNativeSettlementSelection; assistant.preflightLifecycleOperation = preflightLifecycleOperation;`); function fixture(type, { index = type === 'guide' ? 0 : 1, duplicate = false, zeroQuantity = false, onSelection = () => {}, rows, omitDateMatcher = false } = {}) { let now = 10000, selections = 0; const fields = new Map(), form = { elements: [], id: 'InfoForm1', isConnected: true }; const document = { querySelector: () => form, querySelectorAll: () => [], readyState: 'complete' }; const window = { document, Event: class {}, location: { href: 'https://erp.invalid/System/Business/teams.asp?tdID=123', pathname: '/System/Business/teams.asp' } }; window.location.pathname = `/System/Business/teams_${{guide: 'daoyou', vehicle: 'cheliang', transport: 'piao', other: 'qita'}[type]}.asp`; window.location.href = `https://erp.invalid${window.location.pathname}?tdID=123`; document.defaultView = window; form.ownerDocument = document; const tuple = rows || (type === 'guide' ? '1◆270◆测试新资源◆新电话◆A级◆新导管' : '270◆测试新资源◆测试新项目◆CNY◆100'); const add = (name, value, checkbox = false) => { const c = { name, id: name, value: String(value), checked: false, type: checkbox ? 'checkbox' : 'text', tagName: 'INPUT', disabled: false, ownerDocument: document, dispatchEvent() {}, getAttribute: (k) => k.toLowerCase() === 'data' ? (name === 'daoguan' ? '新导管◆271' : tuple) : k.toLowerCase() === 'setval' ? name : null }; fields.set(name, c); form.elements.push(c); }; for (const n of type === 'guide' ? [0, 1] : [...new Set([0, index, index + 1])]) { const dated = n === index || duplicate; const values = type === 'guide' ? { daoyou: n === 0 ? '原导游' : '', daoyouid: n === 0 || duplicate ? 100 + n : '', dianhua: '旧电话', dengji: '旧等级', beizhu: '原备注' } : { id: 800 + n, danwei: `原资源${n}`, danweiid: 100 + n, chehao: '原项目', shuoming: '原项目', riqi: dated ? '2026-9-15' : '2026-9-20', riqis: dated ? '2026-9-16' : '2026-9-21', shuliang: zeroQuantity ? '' : 3, beizhu: '原备注', yf: 0, sh: 0, shenhe: 0, bizhong: 'CNY', danjia: 0, fangshi: '公司现付', ...(type === 'other' ? {} : { zt: '未确认' }) }; for (const [key, value] of Object.entries(values)) add(`${key}${n}`, value); add(`testcheck${n}`, '1', true); } add('tdID', 123); add('ddID', 0); add('MaxI', index + 2); add('chufari', '2026-09-01'); add('SubmitButton', '提交保存'); if (type === 'guide') { add('daoguan', '原导管'); add('ap_dy', '1', true); } if (type === 'other') { for (const [k, v] of Object.entries({ beianhao: 'OLD', beianri: '2026-9-14', rujingkou: '原入境', chujingkou: '原出境' })) add(k,v); add('ap_qt','1',true); } form.elements.namedItem = n => fields.get(n); form.querySelector = s => fields.get(/name="([^"]+)"/.exec(s)?.[1]); window.SelectBox = { SetVal: '', SetValToObj(raw) { selections++; const n = /danwei(\d+)/.exec(this.SetVal)[1]; const [id, name, item] = raw.split('◆'); for (const [k,v] of Object.entries({ danweiid: id, danwei: name, [type === 'vehicle' ? 'chehao' : 'shuoming']: item, danjia: '100' })) fields.get(`${k}${n}`).value = v; onSelection(fields,n); }}; const sandbox = { window, document, Event: window.Event, URL, URLSearchParams, TextEncoder, Date: class extends Date { static now() { return now; } }, setTimeout(cb,ms) { now += ms; cb(); } }; window.setTimeout = sandbox.setTimeout; loadPageActionRealm(window, sandbox, { 'inpage.js': inpage, 'lifecycle-adapters.js': adapter }, omitDateMatcher ? ['operation-plans.js'] : []); const changes = { [type === 'guide' ? 'resource' : 'supplier']: { name: '测试新资源', keyword: '测试新资源' }, ...(['vehicle','transport'].includes(type) ? { quantity: 2 } : {}) }; const selection = type === 'guide' ? undefined : type === 'vehicle' ? { start_date: '2026-09-15', end_date: '2026-09-16' } : { date: '2026-09-15' }; const input = { action: `arrangement_${type}`, order_nature: 'formal', submit_mode: 'dry_run', data: { existing_refs: { identifier: 'LW-TEST-UPDATE', kind: 'shared_plan' }, arrangement: { mode: 'update', ...(selection ? { selection } : {}), changes } } }; const api = window.LTJTOrderAssistant; const resolve = api.testResolve; api.testResolve = (form, operation) => resolve(form, { ...operation, data: { ...operation.data, existing_refs: { ...operation.data.existing_refs, tid: '123', resolved: true, resolution_source: 'erp_unique_match' } } }); return { api, form, fields, input, selections: () => selections }; } const types = ['guide','vehicle','transport','other']; test('dated updates report missing MAIN-world dependency without changing native fields; guide stays independent', () => { for (const type of types) { const f = fixture(type, { omitDateMatcher: true }); const before = [...f.fields].map(([key, control]) => [key, control.value]); const result = f.api.testResolve(f.form, f.input); if (type === 'guide') assert.equal(result.ok, true, JSON.stringify(result)); else { assert.equal(result.ok, false); assert.deepEqual(Array.from(result.blockers), ['arrangement_date_matcher_unavailable']); } assert.equal(f.selections(), 0); assert.deepEqual([...f.fields].map(([key, control]) => [key, control.value]), before); } }); for (const type of types) test(`${type} update resolves the original record and preserves dates and unspecified fields`, async () => { for (const index of type === 'guide' ? [0] : [0,1,10]) { const f = fixture(type,{index}); assert.deepEqual(validateProgramOperation(f.input), []); assert.equal(parsedSchema(f.input), true, JSON.stringify(parsedSchema.errors)); const r = f.api.testResolve(f.form,f.input); assert.equal(r.ok,true,JSON.stringify(r)); assert.equal(r.operation.data.arrangement.target.slot_index,index); const operation = JSON.parse(JSON.stringify(r.operation)); assert.deepEqual(validateStandardOperation(operation),[]); assert.equal(executionSchema(operation),true,JSON.stringify(executionSchema.errors)); assert.equal(plans.validateOperation(operation).ok,true,JSON.stringify(plans.validateOperation(operation))); assert.equal(f.api.testSlot(f.form,operation).ready,true,JSON.stringify(f.api.testSlot(f.form,operation))); assert.equal(f.api.testResource(f.form,operation).matched,true); assert.equal(f.api.testOperationBlockers(operation).length,0,JSON.stringify(f.api.testOperationBlockers(operation))); const old = new Map([...f.fields].map(([n,c])=>[n,c.value])); const applied = await f.api.testApply(f.form,operation); assert.equal(applied.blockers.length,0,JSON.stringify(applied)); if (type !== 'guide') assert.ok(applied.changes.some(change => change.target === `arrangement_${type}_native_selectbox`)); assert.equal(f.fields.get(`${type === 'guide' ? 'daoyou' : 'danwei'}${index}`).value,'测试新资源'); for (const [n,v] of old) if (/^(id|riqi|riqis|beizhu|beian|rujing|chujing)/.test(n)) assert.equal(f.fields.get(n).value,v,n); if(type === 'guide') assert.equal(f.fields.get('daoguan').value,'新导管'); const projection=f.api.testExpected(f.form,operation); assert.equal(projection.blockers.length,0,JSON.stringify(projection)); if(type !== 'guide') assert.ok(projection.fields.some(x=>x.name===`id${index}` && x.expected===String(800+index))); for(const field of projection.fields) { const actual=f.fields.get(field.name); assert.ok(actual,field.name); if(!field.date && !field.checkbox && !field.numeric) assert.equal(actual.value,String(field.expected ?? ''),field.name); } } }); test('missing or ambiguous original records never trigger a native selection',()=>{ for(const type of types){ const f=fixture(type,{duplicate:true}); assert.equal(f.api.testResolve(f.form,f.input).ok,false,type); assert.equal(f.selections(),0); const missing=fixture(type); if(type==='guide') missing.fields.get('daoyouid0').value=''; else missing.input.data.arrangement.selection[type==='vehicle'?'start_date':'date']='2026-09-01'; assert.equal(missing.api.testResolve(missing.form,missing.input).ok,false,type); } }); test('old row identity, audit, payment and realized flags stop the update before mutation',async()=>{ for(const type of types){ for(const field of type==='guide'?['daoyouid0','ap_dy']:type==='other'?['id1','yf1','sh1','ap_qt']:['id1','yf1','sh1','zt1']){ const f=fixture(type); const op=f.api.testResolve(f.form,f.input).operation; const c=f.fields.get(field); if(c.type==='checkbox') c.checked=true; else c.value=field==='zt1'?'已确认':'999'; const result=await f.api.testApply(f.form,op); assert.ok(result.blockers.length,`${type}:${field}`);assert.equal(f.selections(),0); } } }); test('other update merges filing changes, preserves filing date and omitted zero quantity',async()=>{ for(const zeroQuantity of [false,true]){ const f=fixture('other',{zeroQuantity,onSelection(fields,n){fields.get(`shuliang${n}`).value='99';fields.get(`beizhu${n}`).value='';}}); f.input.data.arrangement.selection.filing_date='2026-09-14'; f.input.data.arrangement.changes.filing={number:'NEW'}; const r=f.api.testResolve(f.form,f.input); assert.equal(r.ok,true,JSON.stringify(r)); assert.deepEqual(validateStandardOperation(JSON.parse(JSON.stringify(r.operation))),[]); assert.equal(plans.validateOperation(r.operation).ok,true,JSON.stringify(plans.validateOperation(r.operation))); assert.equal(executionSchema(r.operation),true,JSON.stringify(executionSchema.errors)); const result=await f.api.testApply(f.form,r.operation);assert.equal(result.blockers.length,0,JSON.stringify(result)); assert.equal(f.fields.get('beianhao').value,'NEW');assert.equal(f.fields.get('beianri').value,'2026-9-14'); assert.equal(f.fields.get('rujingkou').value,'原入境');assert.equal(Number(f.fields.get('shuliang1').value),zeroQuantity?0:3);assert.equal(f.fields.get('beizhu1').value,'原备注'); } }); test('date changes are rejected and native callbacks cannot change dates, IDs or other rows',async()=>{ for(const type of types){ const f=fixture(type); f.input.data.arrangement.changes.date='2026-09-22'; assert.ok(validateProgramOperation(f.input).length);assert.equal(parsedSchema(f.input),false); const good=fixture(type);const r=good.api.testResolve(good.form,good.input);r.operation.data.arrangement.changes.date='2026-09-22'; assert.ok(validateStandardOperation(JSON.parse(JSON.stringify(r.operation))).length);assert.equal(plans.validateOperation(r.operation).ok,false);assert.equal(executionSchema(r.operation),false); } for(const type of ['vehicle','transport','other']) for(const field of ['id1','riqi1','danwei0','testcheck0']){ const f=fixture(type,{onSelection(fields){const c=fields.get(field);if(c.type==='checkbox') c.checked=true;else c.value='unexpected';}}); const r=f.api.testResolve(f.form,f.input);const result=await f.api.testApply(f.form,r.operation);assert.ok(result.blockers.length,`${type}:${field}`); } }); const vehicleRows = [ '270◆测试新资源◆【15座】 VIP 120◆公司现付', '271◆测试新资源◆45座 120◆公司现付', '272◆另一车队◆15座 VIP 120◆公司现付', '273◆测试新资源◆115座 VIP 120◆公司现付' ].join('◇'); function filteredVehicleFixture(mode, rows = vehicleRows) { const f = fixture('vehicle', { index: mode === 'create' ? 0 : 1, rows }); if (mode === 'create') { f.input.data.arrangement = { mode, supplier: { name: '测试新资源', keyword: '测试新资源' }, start_date: '2026-09-15', end_date: '2026-09-16', quantity: 2 }; for (const key of ['id', 'danwei', 'danweiid', 'chehao', 'shuoming', 'riqi', 'riqis', 'shuliang', 'beizhu', 'zt']) f.fields.get(`${key}0`).value = ''; } f.input.data.arrangement.vehicle_type = '15 座'; return f; } for (const mode of ['create', 'update']) test(`vehicle ${mode} AND-filters type and supplier, preserves native description and original dates`, async () => { const f = filteredVehicleFixture(mode); assert.deepEqual(validateProgramOperation(f.input), []); assert.equal(parsedSchema(f.input), true, JSON.stringify(parsedSchema.errors)); assert.equal(plans.validateDispatchOperation(f.input).ok, true); const resolved = f.api.testResolve(f.form, f.input); assert.equal(resolved.ok, true, JSON.stringify(resolved)); const op = JSON.parse(JSON.stringify(resolved.operation)); const value = op.data.arrangement; const replacement = mode === 'update' ? value.changes : value; assert.equal(replacement.supplier.id, '270'); assert.equal(replacement.item, '【15座】 VIP 120'); assert.equal(value.vehicle_type, '15 座'); assert.deepEqual(validateStandardOperation(op), []); assert.equal(executionSchema(op), true, JSON.stringify(executionSchema.errors)); assert.equal(plans.validateOperation(op).ok, true); const result = await f.api.testApply(f.form, op); assert.deepEqual(Array.from(result.blockers), [], JSON.stringify(result)); const index = mode === 'create' ? 0 : 1; assert.equal(f.fields.get(`danweiid${index}`).value, '270'); assert.equal(f.fields.get(`chehao${index}`).value, '【15座】 VIP 120'); assert.equal(f.fields.get(`riqi${index}`).value.replace(/-0/g, '-'), '2026-9-15'); assert.equal(f.fields.get(`riqis${index}`).value.replace(/-0/g, '-'), '2026-9-16'); assert.equal(f.api.testExpected(f.form, op).blockers.length, 0); }); test('vehicle filter never drops an unmatched type, picks an ambiguous row, or selects a different supplier', () => { for (const mode of ['create', 'update']) { for (const [query, rows, count] of [ ['99座', vehicleRows, 0], ['15座', vehicleRows + '◇274◆测试新资源◆15座 VIP 240◆公司现付', 2], ['15座', '271◆测试新资源◆45座◆公司现付◇272◆另一车队◆15座◆公司现付', 0] ]) { const f = filteredVehicleFixture(mode, rows); f.input.data.arrangement.vehicle_type = query; const r = f.api.testResolve(f.form, f.input); assert.equal(r.ok, false); assert.ok(r.blockers.includes(`arrangement_resource_candidate_count:${count}`), JSON.stringify(r)); assert.equal(f.selections(), 0); } const old = filteredVehicleFixture(mode); delete old.input.data.arrangement.vehicle_type; assert.equal(old.api.testResolve(old.form, old.input).ok, false, 'omitted type keeps existing multiple-candidate blocking'); const single = filteredVehicleFixture(mode, vehicleRows.split('◇')[0]); delete single.input.data.arrangement.vehicle_type; assert.equal(single.api.testResolve(single.form, single.input).ok, true, 'old unique input still works'); } }); test('changed vehicle candidates or type cannot reach form mutation after resolution', async () => { for (const mode of ['create', 'update']) for (const mutation of ['type', 'candidate', 'duplicate']) { const f = filteredVehicleFixture(mode); const op = f.api.testResolve(f.form, f.input).operation; if (mutation === 'type') op.data.arrangement.vehicle_type = '45座'; else { const control = f.fields.get(`danwei${mode === 'create' ? 0 : 1}`), original = control.getAttribute; control.getAttribute = key => key === 'data' ? (mutation === 'candidate' ? vehicleRows.replace('【15座】 VIP 120', '45座 VIP 120') : vehicleRows + '◇274◆测试新资源◆15座 VIP 240◆公司现付') : original(key); } const before = [...f.fields.values()].map(c => c.value); const result = await f.api.testApply(f.form, op); assert.ok(result.blockers.length, `${mode}:${mutation}`); assert.equal(f.selections(), 0); assert.deepEqual([...f.fields.values()].map(c => c.value), before); } }); test('vehicle filter is nonempty, vehicle-only and outside date selectors or direct changes in every contract', () => { for (const mode of ['create', 'update']) { const f = filteredVehicleFixture(mode); for (const bad of ['', ' ', null, 15]) { const input = structuredClone(f.input); input.data.arrangement.vehicle_type = bad; assert.ok(validateProgramOperation(input).length); assert.equal(parsedSchema(input), false); assert.equal(plans.validateDispatchOperation(input).ok, false); const execution = JSON.parse(JSON.stringify(f.api.testResolve(f.form, f.input).operation)); execution.data.arrangement.vehicle_type = bad; assert.ok(validateStandardOperation(execution).length); assert.equal(executionSchema(execution), false); assert.equal(plans.validateOperation(execution).ok, false); } } for (const type of ['guide', 'transport', 'other']) { const f = fixture(type); const op = JSON.parse(JSON.stringify(f.api.testResolve(f.form, f.input).operation)); f.input.data.arrangement.vehicle_type = '15座'; op.data.arrangement.vehicle_type = '15座'; assert.ok(validateProgramOperation(f.input).length); assert.equal(parsedSchema(f.input), false); assert.equal(plans.validateDispatchOperation(f.input).ok, false); assert.ok(validateStandardOperation(op).length); assert.equal(executionSchema(op), false); assert.equal(plans.validateOperation(op).ok, false); } }); const ticketRows = [ '270◆测试新资源◆D87 二等座 08:00◆公司现付', '271◆测试新资源◆D88 【二等座】 18:00◆公司现付', '272◆另一票务◆D88 二等座 18:00◆公司现付', '273◆测试新资源◆D888 二等座 18:00◆公司现付' ].join('◇'); function filteredTransportFixture(mode, rows = ticketRows) { const f = fixture('transport', { index: mode === 'create' ? 0 : 1, rows }); if (mode === 'create') { f.input.data.arrangement = { mode, supplier: { name: '测试新资源', keyword: '测试新资源' }, date: '2026-09-15', quantity: 2 }; for (const key of ['id', 'danwei', 'danweiid', 'chehao', 'shuoming', 'riqi', 'riqis', 'shuliang', 'beizhu', 'zt']) f.fields.get(`${key}0`).value = ''; } f.input.data.arrangement.ticket_description = 'd88 二等座'; return f; } for (const mode of ['create', 'update']) test(`transport ${mode} selects the unique supplier+ticket row through native linkage`, async () => { const f = filteredTransportFixture(mode); assert.deepEqual(validateProgramOperation(f.input), []); assert.equal(parsedSchema(f.input), true, JSON.stringify(parsedSchema.errors)); assert.equal(plans.validateDispatchOperation(f.input).ok, true); const r = f.api.testResolve(f.form, f.input); assert.equal(r.ok, true, JSON.stringify(r)); const op = JSON.parse(JSON.stringify(r.operation)); const value = op.data.arrangement; const replacement = mode === 'update' ? value.changes : value; assert.equal(replacement.supplier.id, '271', 'must not select the first supplier row (D87)'); assert.equal(replacement.item, 'D88 【二等座】 18:00'); assert.equal(value.ticket_description, 'd88 二等座'); assert.deepEqual(validateStandardOperation(op), []); assert.equal(executionSchema(op), true, JSON.stringify(executionSchema.errors)); assert.equal(plans.validateOperation(op).ok, true); assert.equal(f.api.testOperationBlockers(op).length, 0); const before = new Map([...f.fields].map(([n,c]) => [n, c.value])); const result = await f.api.testApply(f.form, op); assert.deepEqual(Array.from(result.blockers), [], JSON.stringify(result)); assert.equal(f.selections(), 1); const index = mode === 'create' ? 0 : 1; assert.equal(f.fields.get(`danweiid${index}`).value, '271'); assert.equal(f.fields.get(`shuoming${index}`).value, 'D88 【二等座】 18:00'); assert.equal(f.fields.get(`danjia${index}`).value, '100', 'native callback supplies price'); assert.equal(f.fields.get(`riqi${index}`).value.replace(/-0/g, '-'), '2026-9-15'); if (mode === 'update') for (const key of ['id1', 'riqi1', 'riqis1', 'beizhu1', 'danwei0']) assert.equal(f.fields.get(key).value, before.get(key), key); assert.equal(f.api.testExpected(f.form, op).blockers.length, 0); }); test('explicit ticket filter never drops a keyword or defaults to the first ambiguous result', () => { for (const mode of ['create', 'update']) { for (const [query, rows, count] of [ ['D99', ticketRows, 0], ['D88', ticketRows + '◇274◆测试新资源◆D88 一等座◆公司现付', 2], ['D88', '270◆测试新资源◆D87 二等座◆公司现付◇272◆另一票务◆D88 二等座◆公司现付', 0], ['D88', '273◆测试新资源◆D888 二等座◆公司现付', 0] ]) { const f = filteredTransportFixture(mode, rows); f.input.data.arrangement.ticket_description = query; const r = f.api.testResolve(f.form, f.input); assert.equal(r.ok, false); assert.ok(r.blockers.includes(`arrangement_resource_candidate_count:${count}`), JSON.stringify(r)); assert.equal(f.selections(), 0); } const legacy = filteredTransportFixture(mode); delete legacy.input.data.arrangement.ticket_description; if (mode === 'create') legacy.input.data.arrangement.item = '旧兼容说明不参与筛选'; const resolved = legacy.api.testResolve(legacy.form, legacy.input); assert.equal(resolved.ok, true, 'omitted filter retains native first-row behavior'); const replacement = mode === 'update' ? resolved.operation.data.arrangement.changes : resolved.operation.data.arrangement; assert.equal(replacement.supplier.id, '270'); } const duplicate = fixture('transport', { duplicate: true, rows: ticketRows }); duplicate.input.data.arrangement.ticket_description = 'D88 二等座'; assert.equal(duplicate.api.testResolve(duplicate.form, duplicate.input).ok, false, 'replacement filter must not disambiguate original rows'); }); const timedTicketRows = [ '270◆测试新资源◆拉邦-万荣 D83 18:11-19:02◆公司现付', '271◆测试新资源◆拉邦-万荣【C91 12:52-13:44】◆公司现付', '272◆测试新资源◆拉邦-万象【C91 12:28-14:31】◆公司现付', '273◆测试新资源◆拉邦-万荣 C911 12:52-13:44◆公司现付', '274◆另一票务◆拉邦-万荣 C91 12:52-13:44◆公司现付' ].join('◇'); for (const mode of ['create', 'update']) test(`transport ${mode} distinguishes route and train before an adjacent timetable`, async () => { for (const [query, id] of [ ['拉邦-万荣 C91', '271'], ['拉邦-万象 C91', '272'], ['拉邦-万荣 c91', '271'], ['拉邦-万荣 C91 12:52-13:44', '271'], ['拉邦-万象 C91 12:28-14:31', '272'] ]) { const f = filteredTransportFixture(mode, timedTicketRows); f.input.data.arrangement.ticket_description = query; const resolved = f.api.testResolve(f.form, f.input); assert.equal(resolved.ok, true, JSON.stringify({ query, resolved })); const op = JSON.parse(JSON.stringify(resolved.operation)); const replacement = mode === 'update' ? op.data.arrangement.changes : op.data.arrangement; assert.equal(replacement.supplier.id, id); assert.equal(resolved.evidence.candidates, undefined, 'successful lookups do not add candidate details'); assert.equal(f.api.testResource(f.form, op).matched, true); const applied = await f.api.testApply(f.form, op); assert.deepEqual(Array.from(applied.blockers), [], JSON.stringify(applied)); assert.equal(f.selections(), 1); assert.equal(f.fields.get(`danweiid${mode === 'create' ? 0 : 1}`).value, id); assert.equal(f.api.testExpected(f.form, op).blockers.length, 0); } }); test('description normalization preserves original numeric boundaries and existing formatting compatibility', () => { const { api } = filteredTransportFixture('create'); for (const [item, query, expected] of [ ['C91 12:52-13:44', 'C91', true], ['【C91】12:52-13:44', 'c91', true], ['C91\t12:52-13:44', 'C91', true], ['C91\u00a012:52-13:44', 'C91', true], ['C911 12:52-13:44', 'C91', false], ['C9112:52-13:44', 'C91', false], ['D888 12:52-13:44', 'D88', false], ['115 120', '15', false], ['15 120', '15', true], ['【15座】 VIP 120', '15座vip', true], ['C91 12:52-13:44', '【】', false] ]) { assert.equal(api.matchesTicketDescription(item, query), expected, `${item} / ${query}`); assert.equal(api.matchesVehicleType(item, query), expected, `shared vehicle filter: ${item} / ${query}`); } }); test('route-only or train-only filters block with only the matching supplier candidates and no mutation', () => { for (const mode of ['create', 'update']) for (const [query, count] of [['拉邦-万荣', 3], ['C91', 2]]) { const f = filteredTransportFixture(mode, timedTicketRows); f.input.data.arrangement.ticket_description = query; const before = [...f.fields.values()].map(c => c.value); const resolved = f.api.testResolve(f.form, f.input); assert.equal(resolved.ok, false); assert.ok(resolved.blockers.includes(`arrangement_resource_candidate_count:${count}`)); assert.equal(resolved.evidence.candidates.length, count); assert.equal(resolved.evidence.value_redacted, false); for (const candidate of resolved.evidence.candidates) { assert.deepEqual(Object.keys(candidate).sort(), ['item', 'resource']); assert.equal(candidate.resource, '测试新资源'); if (query === 'C91') assert.doesNotMatch(candidate.item, /C911|D83/); } assert.equal(f.selections(), 0); assert.deepEqual([...f.fields.values()].map(c => c.value), before); } const single = filteredTransportFixture('create', '271◆测试新资源◆拉邦-万荣【C91 12:52-13:44】◆公司现付'); single.input.data.arrangement.ticket_description = '拉邦-万荣'; assert.equal(single.api.testResolve(single.form, single.input).ok, true, 'route alone is sufficient when unique'); }); test('ambiguous timetable candidates stay bounded and cannot default to an identical first row', () => { for (const mode of ['create', 'update']) { const rows = Array.from({ length: 7 }, (_, i) => `${800 + i}◆测试新资源◆C91 12:52-13:44 ${'长'.repeat(150)}\n◆私有列`).join('◇'); const f = filteredTransportFixture(mode, rows); f.input.data.arrangement.ticket_description = 'C91'; const resolved = f.api.testResolve(f.form, f.input); assert.equal(resolved.ok, false); assert.ok(resolved.blockers.includes('arrangement_resource_candidate_count:7')); assert.equal(resolved.evidence.candidates.length, 5); for (const row of resolved.evidence.candidates) assert.ok(row.item.length <= 120); assert.doesNotMatch(JSON.stringify(resolved.evidence.candidates), /私有列|800|801|\\n/); assert.equal(f.selections(), 0); } }); test('transport route failures explain zero matches, unavailable data and bounded ambiguous candidates', () => { const source = readFileSync(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8'); const start = source.indexOf('function lifecycleRouteFailure('); const end = source.indexOf('\nasync function executeLifecycleOperation', start); const failure = vm.runInNewContext(`(${source.slice(start, end).trim()})`); for (const mode of ['create', 'update']) { const f = filteredTransportFixture(mode, timedTicketRows); f.input.data.arrangement.ticket_description = 'C91'; const resolved = f.api.testResolve(f.form, f.input); const result = failure(f.input, { blockers: resolved.blockers, preflight: { resource_resolution: resolved.evidence } }); assert.equal(result.errorCode, 'arrangement_resource_not_unique'); assert.match(result.message, /2 条大交通候选/); assert.match(result.message, /拉邦-万荣.*12:52-13:44/); assert.match(result.message, /拉邦-万象.*12:28-14:31/); assert.match(result.message, /车票说明.*时刻/); assert.match(result.message, /未写入 ERP/); assert.doesNotMatch(result.message, /C911|另一票务|未能按精确引用|271|272/); const absent = failure(f.input, { blockers: ['arrangement_resource_candidate_count:0'] }); assert.equal(absent.errorCode, 'arrangement_resource_not_found'); assert.match(absent.message, /没有找到同时匹配结算单位和车票说明/); const unloaded = failure(f.input, { blockers: ['arrangement_resource_candidate_data_missing', 'arrangement_resource_candidate_count:0'] }); assert.equal(unloaded.errorCode, 'arrangement_resource_lookup_not_ready'); assert.doesNotMatch(unloaded.message, /没有找到/); } const report = { blockers: ['arrangement_resource_candidate_count:7'], preflight: { resource_resolution: { candidates: Array.from({length: 7}, (_, i) => ({ resource: '票务\n公司', item: `候选${i} ${'长'.repeat(150)}`, id: 'secret-id', raw: 'secret-raw' })) } } }; const bounded = failure({ action: 'arrangement_transport' }, report); assert.match(bounded.message, /仅显示前 5 条/); assert.match(bounded.message, /票务 公司/); assert.doesNotMatch(bounded.message, /候选5|候选6|secret/); assert.ok(bounded.message.length < 1500); }); test('changed ticket description or candidates block before any native call or mutation', async () => { for (const mode of ['create', 'update']) for (const mutation of ['description', 'candidate', 'duplicate']) { const f = filteredTransportFixture(mode); const op = f.api.testResolve(f.form, f.input).operation; if (mutation === 'description') op.data.arrangement.ticket_description = 'D87'; else { const control = f.fields.get(`danwei${mode === 'create' ? 0 : 1}`), original = control.getAttribute; control.getAttribute = key => key === 'data' ? (mutation === 'candidate' ? ticketRows.replace('D88 【二等座】', 'D89 【二等座】') : ticketRows + '◇274◆测试新资源◆D88 二等座 19:00◆公司现付') : original(key); } const before = [...f.fields.values()].map(c => c.value); const result = await f.api.testApply(f.form, op); assert.ok(result.blockers.length, `${mode}:${mutation}`); assert.equal(f.selections(), 0); assert.deepEqual([...f.fields.values()].map(c => c.value), before); } }); test('ticket filter is nonempty, transport-only, and never a date selector or direct item update', () => { for (const mode of ['create', 'update']) { const f = filteredTransportFixture(mode); for (const bad of ['', ' ', null, 88]) { const input = structuredClone(f.input); input.data.arrangement.ticket_description = bad; assert.ok(validateProgramOperation(input).length); assert.equal(parsedSchema(input), false); assert.equal(plans.validateDispatchOperation(input).ok, false); const execution = JSON.parse(JSON.stringify(f.api.testResolve(f.form, f.input).operation)); execution.data.arrangement.ticket_description = bad; assert.ok(validateStandardOperation(execution).length); assert.equal(executionSchema(execution), false); assert.equal(plans.validateOperation(execution).ok, false); assert.ok(f.api.testOperationBlockers(execution).length); } } for (const type of ['guide', 'vehicle', 'other']) { const f = fixture(type); const op = JSON.parse(JSON.stringify(f.api.testResolve(f.form, f.input).operation)); f.input.data.arrangement.ticket_description = 'D88'; op.data.arrangement.ticket_description = 'D88'; assert.ok(validateProgramOperation(f.input).length); assert.equal(parsedSchema(f.input), false); assert.equal(plans.validateDispatchOperation(f.input).ok, false); assert.ok(validateStandardOperation(op).length); assert.equal(executionSchema(op), false); assert.equal(plans.validateOperation(op).ok, false); } for (const key of ['selection', 'changes']) { const f = filteredTransportFixture('update'); f.input.data.arrangement[key].ticket_description = 'D88'; assert.ok(validateProgramOperation(f.input).length); assert.equal(parsedSchema(f.input), false); assert.equal(plans.validateDispatchOperation(f.input).ok, false); } }); test('native transport selection rechecks a filter even after preflight and never falls back to a different ID', () => { for (const mutation of ['duplicate', 'replacement']) { const f = filteredTransportFixture('update'); const op = f.api.testResolve(f.form, f.input).operation; assert.equal(f.api.testResource(f.form, op).matched, true); const control = f.fields.get('danwei1'), original = control.getAttribute; control.getAttribute = key => key === 'data' ? (mutation === 'duplicate' ? ticketRows + '◇274◆测试新资源◆D88 二等座 19:00◆公司现付' : ticketRows.replace('271◆', '274◆')) : original(key); const blockers = [], changes = [], before = [...f.fields.values()].map(c => c.value); f.api.testNative(f.form, op.data.arrangement.changes.supplier, changes, blockers, 'arrangement_transport', op); assert.ok(blockers.length, mutation); assert.equal(f.selections(), 0); assert.deepEqual(changes, []); assert.deepEqual([...f.fields.values()].map(c => c.value), before); } }); for (const type of ['vehicle','transport','other']) test(`${type} month/day dates pass parsed contracts and resolve to a full-year execution snapshot`, () => { const f = fixture(type); f.input.data.arrangement.selection = type === 'vehicle' ? { start_date:'09-15',end_date:'09-16' } : {date:'09-15', ...(type === 'other' ? {filing_date:'09-14'} : {})}; assert.equal(parsedSchema(f.input), true, JSON.stringify(parsedSchema.errors)); assert.deepEqual(validateProgramOperation(f.input), []); assert.equal(plans.validateDispatchOperation(f.input).blockers.length, 0); const resolved = f.api.testResolve(f.form, f.input); assert.equal(resolved.ok, true, JSON.stringify(resolved)); assert.equal(executionSchema(resolved.operation), true, JSON.stringify(executionSchema.errors)); assert.equal(resolved.operation.data.arrangement[type === 'vehicle' ? 'start_date' : 'date'], '2026-09-15'); assert.equal(resolved.operation.data.arrangement.selection, undefined); f.fields.get('riqi0').value = '2027-9-15'; f.fields.get('riqis0').value = '2027-9-16'; assert.equal(f.api.testResolve(f.form, f.input).ok, false, 'two years are ambiguous'); f.input.data.arrangement.selection[type === 'vehicle' ? 'start_date' : 'date'] = '2026-09-15'; assert.equal(f.api.testResolve(f.form, f.input).ok, true); if (type === 'other') { f.input.data.arrangement.selection.filing_date = '2027-09-14'; assert.equal(f.api.testResolve(f.form, f.input).ok, false, 'explicit filing year must match'); } }); test('parsed date-selector validators agree on invalid calendars while execution still requires a full year', () => { for (const invalid of ['02-30','13-01','2026-02-29','9-15','bad']) { const f = fixture('transport'); f.input.data.arrangement.selection.date = invalid; assert.equal(parsedSchema(f.input), false, invalid); assert.ok(validateProgramOperation(f.input).length, invalid); assert.ok(plans.validateDispatchOperation(f.input).blockers.length, invalid); } assert.equal(plans.matchesOriginalArrangementDate('2028-02-29', '02-29'), true); assert.equal(plans.matchesOriginalArrangementDate('2027-02-29', '02-29'), false); const f = fixture('transport'); const resolved = f.api.testResolve(f.form, f.input).operation; resolved.data.arrangement.date = '09-15'; assert.equal(executionSchema(resolved), false); assert.ok(validateStandardOperation(resolved).length); }); test('ambiguous original dates are surfaced in the route failure message without native ids', () => { const source = readFileSync(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8'); const start = source.indexOf('function lifecycleRouteFailure('); const end = source.indexOf('\nasync function executeLifecycleOperation', start); const failure = vm.runInNewContext(`(${source.slice(start,end).trim()})`); const unavailable = failure({action:'arrangement_hotel'}, {blockers:['arrangement_date_matcher_unavailable']}); assert.equal(unavailable.errorCode, 'arrangement_date_matcher_unavailable'); assert.match(unavailable.message, /插件日期匹配组件未加载/); assert.doesNotMatch(unavailable.message, /没有找到|核对原安排/); const absent = failure({action:'arrangement_hotel'}, {blockers:['arrangement_update_target_candidate_count:0']}); assert.equal(absent.errorCode, 'arrangement_update_target_not_unique'); assert.match(absent.message, /没有找到/); const result = failure({action:'arrangement_hotel'}, {blockers:['arrangement_update_target_candidate_count:2'], preflight:{resource_resolution:{candidates:[ {date:'2026-09-04',end_date:'2026-09-05',resource:'原酒店',item:'TWN'}, {date:'2027-09-04',end_date:'2027-09-05',resource:'原酒店',item:'TWN'} ]}}}); assert.match(result.message,/2026-09-04 至 2026-09-05/); assert.match(result.message,/2027-09-04 至 2027-09-05/); assert.match(result.message,/完整年月日重新提交/); assert.match(result.message,/未写入 ERP/); assert.match(inpage,/resource_resolution: arrangementResolution.evidence/); });