Files
LWLT-AIBOT/tools/arrangement-create-empty-slot.test.mjs
2026-09-22 18:11:28 +08:00

323 lines
21 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import vm from 'node:vm';
import { readFileSync } from 'node:fs';
const inpage = readFileSync(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8');
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.testChecks = arrangementProjectionChecks;
ownershipListProof = async () => ({ matched: true });
nativeFormValidation = () => { window.testValidate(); return { ok: true }; };
nativePost = async (contract, body) => window.testPost(body);
fetchDocument = async () => window.testFreshPage();
teamSummaryRequery = async (operation, subpage) => subpage;
assistant.preflightLifecycleOperation = preflightLifecycleOperation;`);
function fixture(type, { occupied = 1, rows = occupied + 2, onSelection = () => {}, onValidate = () => {}, sort = false, onPost = () => {}, linkageDelays = [] } = {}) {
let now = 10000;
let posts = 0;
let selections = 0;
let linkageTicks = 0;
let savedForm;
const form = { elements: [], id: 'InfoForm1', isConnected: true };
const fields = new Map();
const document = { querySelector: () => form, querySelectorAll: () => [], readyState: 'complete', body: { innerText: '' } };
const location = { href: `https://erp.invalid/System/Business/teams_${{vehicle: 'cheliang', transport: 'piao', other: 'qita'}[type]}.asp?tdID=123`, pathname: `/System/Business/teams_${{vehicle: 'cheliang', transport: 'piao', other: 'qita'}[type]}.asp` };
const window = { document, Event: class {}, location, testValidate: () => onValidate(fields), getComputedStyle: () => ({ display: 'block', visibility: 'visible', opacity: '1' }) };
window.parent = window;
document.defaultView = window;
form.ownerDocument = document;
const add = (name, value) => {
const control = { name, id: name, value: String(value), type: name === 'SubmitButton' ? 'submit' : 'text', tagName: 'INPUT', disabled: false,
ownerDocument: document, dispatchEvent() {},
getAttribute: (key) => key.toLowerCase() === 'data' ? '270◆测试资源◆测试项目◆CNY◆120' : key.toLowerCase() === 'setval' ? name : null };
fields.set(name, control); form.elements.push(control);
};
for (let index = 0; index < rows; index++) {
const existing = index < occupied;
const values = { id: existing ? 800 + index : 0, danwei: existing ? '原酒店' : '', danweiid: existing ? 100 : '', [type === 'vehicle' ? 'chehao' : 'shuoming']: existing ? '原项目' : '',
riqi: existing ? '2026-9-4' : '', riqis: existing ? '2026-9-5' : '', shuliang: existing ? 12 : 1, tianshu: existing ? 1 : '',
...(type === 'other' ? {} : { zt: existing ? '未确认' : '' }), beizhu: existing ? `保留备注${index}` : '', yf: 0, sh: 0, shenhe: 0,
diqu: existing ? '旧地区' : '', dianhua: existing ? '旧电话' : '', bizhong: 'CNY', danjia: existing ? 120 : 0, fangshi: '公司现付', jine: existing ? 1440 : 0 };
for (const [name, value] of Object.entries(values)) add(`${name}${index}`, value);
}
for (const [name, value] of Object.entries({ MaxI: rows, tdID: 123, ddID: 0, chufari: '2026-9-1', SubmitButton: '提交保存' })) add(name, value);
if (type === 'other') {
for (const [name, value] of Object.entries({ beianhao: '原备案', beianri: '2026-9-1', rujingkou: '原入境', chujingkou: '原出境', ap_qt: '' })) add(name, value);
fields.get('ap_qt').type = 'checkbox'; fields.get('ap_qt').checked = false;
}
form.elements.namedItem = (name) => fields.get(name);
window.SelectBox = { SetVal: '', SetValToObj(raw) {
selections++;
const index = /danwei(\d+)/.exec(this.SetVal)[1];
const [id, name, roomType] = raw.split('◆');
for (const [key, value] of Object.entries({ danwei: name, danweiid: id, [type === 'vehicle' ? 'chehao' : 'shuoming']: roomType, danjia: '120' })) fields.get(`${key}${index}`).value = value;
onSelection(fields, index);
} };
window.testPost = (body) => {
posts++;
assert.match(body, new RegExp(`Act=DoInfo_${{vehicle: 'cheliang', transport: 'piao', other: 'qita'}[type]}`));
savedForm = { elements: form.elements.map((control) => ({ ...control })) };
savedForm.elements.find((control) => control.name === `id${occupied}`).value = '999';
if (sort && occupied > 0) for (const control of savedForm.elements) {
const match = /^([a-zA-Z_]+)(\d+)$/.exec(control.name);
if (match && [0, occupied].includes(Number(match[2]))) control.name = control.id = `${match[1]}${Number(match[2]) === 0 ? occupied : 0}`;
}
onPost(savedForm, body);
return { completed: true, http_status: 200 };
};
window.testFreshPage = () => ({ document: { querySelector: (selector) => {
if (selector.includes('InfoForm1')) return savedForm;
const name = /name="([^"]+)"/.exec(selector)?.[1] || /#([a-zA-Z_]+)/.exec(selector)?.[1];
return savedForm.elements.find((control) => control.name === name) || null;
} }, text: 'LW-TEST-HOTEL', response: { ok: true, status: 200 } });
const sandbox = { window, document, location, Event: window.Event, URL, URLSearchParams, TextEncoder,
Date: class extends Date { static now() { return now; } }, setTimeout(callback, ms) {
now += selections ? linkageDelays[linkageTicks++] ?? ms : ms;
callback();
} };
window.setTimeout = sandbox.setTimeout;
vm.runInNewContext(inpage, sandbox);
vm.runInNewContext(adapter, sandbox);
const operation = { action: `arrangement_${type}`, data: {
existing_refs: { identifier: 'LW-TEST-HOTEL', kind: 'independent_order', tid: '123', ddid: '456', resolved: true, resolution_source: 'erp_unique_match' },
arrangement: { mode: 'create', ...(type === 'vehicle' ? { start_date: '2026-09-05', end_date: '2026-09-06' } : { date: '2026-09-05' }), quantity: 12,
supplier: { id: '270', name: '测试资源', keyword: '测试资源', resolved: true }, item: '测试项目', status: '未确认', remark: '', side_effect_policy: 'no_external' }
} };
return { api: window.LTJTOrderAssistant, operation, fields, form, posts: () => posts, selections: () => selections, savedForm: () => savedForm };
}
for (const type of ['vehicle', 'transport', 'other']) {
test(`${type} create recovers from a delayed linkage sample but never saves while sampling stays delayed`, async () => {
for (const [linkageDelays, completed] of [[[6000], true], [Array(6).fill(6000), false]]) {
const f = fixture(type, { linkageDelays });
const result = await f.api.liveSubmitLifecycleOperation(f.operation);
assert.equal(result.status, completed ? 'lifecycle_completed' : 'lifecycle_apply_blocked', JSON.stringify(result));
assert.equal(f.posts(), completed ? 1 : 0); assert.equal(f.selections(), 1);
assert.equal(result.preflight.arrangement_linkage.reason, completed ? 'stable' : 'sampling_delayed');
}
});
test(`${type} create preserves old rows and binds native selection, dates and quantity to empty rows 0/1/10`, async () => {
for (const occupied of [0, 1, 10]) {
const f = fixture(type, { occupied });
const before = [...f.fields].map(([name, control]) => [name, control.value]);
const ready = await f.api.preflightLifecycleOperation(f.operation);
assert.equal(ready.status, 'lifecycle_preflight_ready', JSON.stringify(ready));
assert.equal(ready.preflight.arrangement_slot.slot_index, occupied);
assert.equal(ready.preflight.form_readiness.strategy, 'native_arrangement_structure');
const result = await f.api.liveSubmitLifecycleOperation(f.operation, { expected_before_hash: ready.preflight.form_payload_sha256 });
assert.equal(result.status, 'lifecycle_completed', JSON.stringify(result));
assert.equal(f.posts(), 1); assert.equal(f.selections(), 1);
for (const [name, value] of before) if (!new RegExp(`^[a-zA-Z_]+${occupied}$`).test(name)) assert.equal(f.fields.get(name).value, value, name);
assert.equal(f.fields.get(`danweiid${occupied}`).value, '270');
assert.equal(f.fields.get(`shuliang${occupied}`).value, '12');
assert.equal(f.fields.get(`riqi${occupied}`).value, '2026-09-05');
assert.equal(f.operation.data.arrangement.target, undefined);
}
});
test(`${type} requery follows the new record id after sorting, including stored reconciliation evidence`, async () => {
const f = fixture(type, { sort: true });
const result = await f.api.liveSubmitLifecycleOperation(f.operation);
assert.equal(result.status, 'lifecycle_completed', JSON.stringify(result));
assert.equal(result.requery.arrangement_target.row_id, '999');
assert.equal(result.requery.arrangement_target.slot_index, 0);
assert.equal((await f.api.verifyLifecycleOperation(f.operation, { arrangement_create: result.preflight.arrangement_create })).status, 'lifecycle_requery_matched');
assert.equal((await f.api.verifyLifecycleOperation(f.operation)).status, 'lifecycle_requery_not_matched');
assert.equal(f.posts(), 1);
});
test(`${type} rejects no free row, drafts, financial state, missing controls and forced targets before POST`, async () => {
const cases = [fixture(type, { occupied: 3, rows: 3 })];
for (const name of ['danwei1', 'riqi1', 'beizhu1', 'yf1', 'sh1', 'danjia1']) {
const f = fixture(type); f.fields.get(name).value = 'unexpected'; cases.push(f);
}
const partial = fixture(type); partial.form.elements = partial.form.elements.filter((c) => c.name !== 'id1'); cases.push(partial);
const forced = fixture(type); forced.operation.data.arrangement.target = { slot_index: 1 }; cases.push(forced);
for (const f of cases) {
const result = await f.api.liveSubmitLifecycleOperation(f.operation);
assert.equal(result.status, 'lifecycle_preflight_blocked', JSON.stringify(result));
assert.equal(f.posts(), 0); assert.equal(f.selections(), 0);
}
});
test(`${type} checks existing rows and new values after native linkage and again after validation`, async () => {
for (const callback of ['onSelection', 'onValidate']) for (const name of ['danwei0', 'shuliang0', 'id1', 'yf1', 'sh1', 'tdID']) {
const f = fixture(type, { [callback]: (fields) => { fields.get(name).value = 'changed'; } });
const result = await f.api.liveSubmitLifecycleOperation(f.operation);
assert.notEqual(result.status, 'lifecycle_completed', JSON.stringify(result));
assert.equal(f.posts(), 0, `${callback}:${name}`);
assert.equal(result.no_erp_write, true);
}
});
test(`${type} cannot claim completion if ERP changes an old row, misses or duplicates the new row`, async () => {
for (const mutate of [
(form) => { form.elements.find((c) => c.name === 'shuliang0').value = '99'; },
(form) => { form.elements.find((c) => c.name === 'id1').value = '0'; },
(form) => { form.elements.find((c) => c.name === 'id2').value = '1000'; },
(form) => { form.elements.find((c) => c.name === 'shuliang1').value = '99'; }
]) {
const f = fixture(type, { onPost: mutate });
assert.equal((await f.api.liveSubmitLifecycleOperation(f.operation)).status, 'lifecycle_execution_uncertain');
assert.equal(f.posts(), 1);
}
});
}
for (const type of ['transport', 'other']) {
test(`${type} creates in a departure-default row and preserves existing rows, spare rows and filing`, async () => {
for (const occupied of [0, 1, 6, 10]) for (const quantity of ['0', '1']) {
let submitted;
const f = fixture(type, { occupied, rows: occupied + 5, sort: true, onPost(_form, body) { submitted = body; } });
for (let index = occupied; index < occupied + 5; index++) {
f.fields.get(`riqi${index}`).value = index % 2 ? '2026-09-01' : '2026-9-1';
f.fields.get(`shuliang${index}`).value = quantity;
}
const preserved = f.form.elements.filter(c => !new RegExp(`^[a-zA-Z_]+${occupied}$`).test(c.name))
.map(c => [c.name, c.value]);
const before = f.form.elements.map(c => c.value);
const preflight = await f.api.preflightLifecycleOperation(f.operation);
assert.equal(preflight.status, 'lifecycle_preflight_ready', JSON.stringify(preflight));
assert.deepEqual(f.form.elements.map(c => c.value), before, 'preflight must not clear default dates');
const result = await f.api.liveSubmitLifecycleOperation(f.operation);
assert.equal(result.status, 'lifecycle_completed', JSON.stringify(result));
assert.equal(f.posts(), 1); assert.equal(f.selections(), 1);
assert.equal(f.fields.get(`riqi${occupied}`).value, '2026-09-05');
assert.equal(f.fields.get(`shuliang${occupied}`).value, '12');
for (const [name, value] of preserved) assert.equal(f.fields.get(name).value, value, name);
const body = new URLSearchParams(submitted);
assert.equal(body.get(`riqi${occupied}`), '2026-09-05');
for (const [name, value] of preserved) {
if (!['SubmitButton', 'ap_qt'].includes(name)) assert.equal(body.get(name), value, `submitted ${name}`);
}
}
});
test(`${type} rejects drafts and unverified dates even with other default-date rows available`, async () => {
const changes = [
f => { f.fields.get('riqi2').value = '2026-09-02'; },
f => { f.fields.get('riqi2').value = 'invalid'; },
f => { f.fields.get('chufari').value = ''; },
f => { f.form.elements = f.form.elements.filter(c => c.name !== 'chufari'); },
f => { for (const name of ['chufari', 'riqi1', 'riqi2']) f.fields.get(name).value = '2026-2-30'; },
...['danwei2', 'danweiid2', 'shuoming2', 'beizhu2', 'danjia2', 'jine2', 'yf2', 'sh2', 'shenhe2', 'shuliang2'].map(name =>
f => { f.fields.get(name).value = '19'; })
];
for (const change of changes) {
const f = fixture(type);
f.fields.get('riqi1').value = f.fields.get('riqi2').value = '2026-9-1';
change(f);
const before = f.form.elements.map(c => c.value);
const result = await f.api.liveSubmitLifecycleOperation(f.operation);
assert.equal(result.status, 'lifecycle_preflight_blocked', JSON.stringify(result));
assert.equal(f.posts(), 0); assert.equal(f.selections(), 0);
assert.deepEqual(f.form.elements.map(c => c.value), before);
}
});
test(`${type} guards other default dates through native selection, validation and preflight changes`, async () => {
for (const callback of ['onSelection', 'onValidate']) {
const f = fixture(type, { [callback](fields) { fields.get('riqi2').value = '2026-09-02'; } });
f.fields.get('riqi1').value = f.fields.get('riqi2').value = '2026-9-1';
const result = await f.api.liveSubmitLifecycleOperation(f.operation);
assert.notEqual(result.status, 'lifecycle_completed', JSON.stringify(result));
assert.equal(f.posts(), 0);
}
const f = fixture(type);
f.fields.get('riqi1').value = f.fields.get('riqi2').value = '2026-9-1';
const preflight = await f.api.preflightLifecycleOperation(f.operation);
f.fields.get('riqi2').value = '2026-09-02';
const result = await f.api.liveSubmitLifecycleOperation(f.operation, { expected_before_hash: preflight.preflight.form_payload_sha256 });
assert.notEqual(result.status, 'lifecycle_completed');
assert.equal(f.posts(), 0); assert.equal(f.selections(), 0);
});
}
test('vehicle still rejects an unsaved date equal to departure', async () => {
const f = fixture('vehicle'); f.fields.get('riqi1').value = '2026-9-1';
const result = await f.api.liveSubmitLifecycleOperation(f.operation);
assert.equal(result.status, 'lifecycle_preflight_blocked');
assert.ok(result.blockers.includes('arrangement_slot_occupied:riqi0'));
assert.equal(f.posts(), 0); assert.equal(f.selections(), 0);
});
test('other create keeps omitted quantity and shared filing, and verifies them after sorted requery', async () => {
const f = fixture('other', { sort: true }); delete f.operation.data.arrangement.quantity;
f.fields.get('riqi1').value = f.fields.get('riqi2').value = '2026-9-1';
const result = await f.api.liveSubmitLifecycleOperation(f.operation);
assert.equal(result.status, 'lifecycle_completed', JSON.stringify(result));
assert.equal(f.fields.get('shuliang1').value, '1');
assert.equal(f.fields.get('beianhao').value, '原备案');
const proof = result.preflight.arrangement_create;
for (const name of ['beianhao', 'beianri', 'rujingkou', 'chujingkou', 'shuliang0']) {
const field = f.savedForm().elements.find((c) => c.name === name); const before = field.value; field.value = 'unexpected';
assert.equal((await f.api.verifyLifecycleOperation(f.operation, { arrangement_create: proof })).status, 'lifecycle_requery_not_matched', name);
field.value = before;
}
});
test('other preserves omitted zero quantity in a default-date row', async () => {
const f = fixture('other'); delete f.operation.data.arrangement.quantity;
f.fields.get('riqi1').value = f.fields.get('riqi2').value = '2026-9-1';
f.fields.get('shuliang1').value = '0';
const result = await f.api.liveSubmitLifecycleOperation(f.operation);
assert.equal(result.status, 'lifecycle_completed', JSON.stringify(result));
assert.equal(f.fields.get('shuliang1').value, '0');
assert.equal(f.fields.get('riqi1').value, '2026-09-05');
});
test('other create may fill blank filing fields, blocks conflicting filing and preserves realized state', async () => {
const f = fixture('other'); f.fields.get('beianhao').value = ''; f.operation.data.arrangement.filing = { number: 'NEW' };
assert.equal((await f.api.liveSubmitLifecycleOperation(f.operation)).status, 'lifecycle_completed');
assert.equal(f.fields.get('beianhao').value, 'NEW');
for (const change of [f => { f.operation.data.arrangement.filing = { number: 'CONFLICT' }; }, f => { f.fields.get('ap_qt').checked = true; f.fields.get('ap_qt').value = '1'; }]) {
const blocked = fixture('other'); change(blocked);
assert.equal((await blocked.api.liveSubmitLifecycleOperation(blocked.operation)).status, 'lifecycle_preflight_blocked');
assert.equal(blocked.posts(), 0);
}
});
test('other native callbacks cannot alter omitted shared filing or the realized flag', async () => {
for (const callback of ['onSelection', 'onValidate']) for (const name of ['beianhao', 'beianri', 'rujingkou', 'chujingkou', 'ap_qt']) {
const f = fixture('other', { [callback]: fields => { fields.get(name).value = '1'; if (name === 'ap_qt') fields.get(name).checked = true; } });
assert.notEqual((await f.api.liveSubmitLifecycleOperation(f.operation)).status, 'lifecycle_completed');
assert.equal(f.posts(), 0, `${callback}:${name}`);
}
});
test('guide remains single-person: an occupied guide gives the change-command message without selecting or saving', async () => {
const f = fixture('vehicle');
f.form.ownerDocument.defaultView.location.pathname = '/System/Business/teams_daoyou.asp';
f.form.ownerDocument.defaultView.location.href = 'https://erp.invalid/System/Business/teams_daoyou.asp?tdID=123';
f.form.elements.push({ name: 'daoyou0', value: '已有导游', type: 'text', tagName: 'INPUT' });
f.operation.action = 'arrangement_guide';
f.operation.data.arrangement = { mode: 'create', resource: { id: '270', name: '新导游', resolved: true }, status: '未确认', remark: '', side_effect_policy: 'no_external' };
const result = await f.api.liveSubmitLifecycleOperation(f.operation);
assert.equal(result.status, 'lifecycle_preflight_blocked', JSON.stringify(result));
assert.equal(result.business_error.code, 'erp_guide_already_arranged');
assert.match(result.business_error.message, /安排导游变更/);
assert.equal(f.posts(), 0); assert.equal(f.selections(), 0);
});
test('guide empty-slot guard still accepts the native single-row form', () => {
const f = fixture('vehicle');
for (const name of ['daoyou0', 'daoyouid0', 'daoguan']) f.form.elements.push({ name, value: '', type: 'text', tagName: 'INPUT' });
f.fields.get('beizhu0').value = '';
f.operation.action = 'arrangement_guide';
const slot = f.api.testSlot(f.form, f.operation);
assert.equal(slot.ready, true, JSON.stringify(slot));
});
test('background preserves both create evidence types and maps the guide business error', () => {
const background = readFileSync(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8');
assert.match(background, /hotel_create: priorPreflight\?\.hotel_create \|\| null,\s*arrangement_create: priorPreflight\?\.arrangement_create \|\| null/);
const branch = background.slice(background.indexOf('function lifecyclePreflightBusinessFailure'), background.indexOf('function lifecycleLiveBusinessFailure'));
const sandbox = {}; vm.runInNewContext(branch + '\nglobalThis.inspect = lifecyclePreflightBusinessFailure;', sandbox);
assert.equal(sandbox.inspect({ business_error: { code: 'erp_guide_already_arranged' }, blockers: ['erp_guide_already_arranged'] }).errorCode, 'erp_guide_already_arranged');
assert.equal(sandbox.inspect({ business_error: { code: 'erp_guide_already_arranged' }, blockers: [] }), null);
});