597 lines
38 KiB
JavaScript
597 lines
38 KiB
JavaScript
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/);
|
||
});
|