Files
LWLT-AIBOT/tools/arrangement-update-unified.test.mjs
2026-09-16 11:50:50 +08:00

597 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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/);
});