Files
LWLT-AIBOT/tools/hotel-update-selection.test.mjs
2026-09-14 16:34:36 +08:00

207 lines
12 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { loadPageActionRealm } from './page-action-test-realm.mjs';
const inpage = readFileSync(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8')
.replace('inspectArrangementLookupReadiness(form,', 'testResolveHotel: 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 = applyArrangement;
assistant.testOperationBlockers = operationBlockers;
assistant.testExpected = arrangementExpectedFields;
assistant.testResource = arrangementResourceProof;
assistant.preflightLifecycleOperation = preflightLifecycleOperation;`);
function fixture({ duplicate = false, slotIndex = 1, onSelection = () => {}, omitDateMatcher = false } = {}) {
let now = 10000;
let nativeSelections = 0;
const form = { elements: [], id: 'InfoForm1' };
const fields = new Map();
const document = { querySelector: () => form, querySelectorAll: () => [] };
const window = { document, Event: class {}, location: { href: 'https://erp.invalid/System/Business/teams_jiudian.asp?tdID=123', pathname: '/System/Business/teams_jiudian.asp' } };
document.defaultView = window;
form.ownerDocument = document;
const add = (name, value) => {
const control = {
name, id: name, value: String(value), type: 'text', tagName: 'INPUT', disabled: false,
ownerDocument: document, dispatchEvent() {},
getAttribute: (key) => key.toLowerCase() === 'data' ? '270◆测试新酒店◆TWN◆CNY◆100' : key.toLowerCase() === 'setval' ? name : null
};
fields.set(name, control); form.elements.push(control);
};
for (const index of [...new Set([0, slotIndex, slotIndex + 1])]) {
const dated = index === slotIndex || duplicate;
const values = {
id: 800 + index, danwei: `原酒店${index}`, danweiid: 100 + index, fangxing: 'DBL',
riqi: dated ? '2026-9-15' : '2026-9-20', riqis: dated ? '2026-9-16' : '2026-9-21',
jianshu: 8, zt: '未确认', beizhu: `保留备注${index}`, yf: 0, sh: 0, shenhe: 0,
diqu: '旧地区', dianhua: '旧电话', bizhong: 'CNY', danjia: 0, fangshi: '公司现付'
};
for (const [name, value] of Object.entries(values)) add(`${name}${index}`, value);
}
add('tdID', 123); add('ddID', 0);
form.elements.namedItem = (name) => fields.get(name);
form.querySelector = (selector) => fields.get(/name="([^"]+)"/.exec(selector)?.[1]);
window.SelectBox = {
SetVal: '',
SetValToObj(raw) {
nativeSelections++;
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, fangxing: roomType, danjia: '100' })) fields.get(`${key}${index}`).value = value;
onSelection(fields, index);
}
};
const sandbox = {
window, document, Event: window.Event, URL, URLSearchParams, TextEncoder,
Date: class extends Date { static now() { return now; } },
setTimeout(callback, ms) { now += ms; callback(); }
};
window.setTimeout = sandbox.setTimeout;
loadPageActionRealm(window, sandbox, { 'inpage.js': inpage, 'lifecycle-adapters.js': adapter }, omitDateMatcher ? ['operation-plans.js'] : []);
const api = window.LTJTOrderAssistant;
const input = {
action: 'arrangement_hotel', data: {
existing_refs: { identifier: 'LW-TEST-HOTEL', kind: 'shared_plan' },
arrangement: {
mode: 'update', selection: { start_date: '2026-09-15', end_date: '2026-09-16' },
changes: { resource: { name: '测试新酒店TWN', keyword: '测试新酒店TWN' }, room_count: 9 }
}
}
};
return { api, form, fields, input, nativeSelections: () => nativeSelections };
}
test('hotel replacement resolves a dated existing row and uses native linked selection on that row only', async () => {
for (const slotIndex of [0, 1, 10]) {
const f = fixture({ slotIndex });
const resolved = f.api.testResolveHotel(f.form, f.input);
assert.equal(resolved.ok, true, JSON.stringify(resolved));
const arrangement = resolved.operation.data.arrangement;
assert.equal(arrangement.target.slot_index, slotIndex);
assert.equal(arrangement.resource.name, `原酒店${slotIndex}`);
assert.equal(arrangement.changes.resource.id, '270');
assert.equal(arrangement.changes.room_type, 'TWN');
assert.equal(arrangement.selection, undefined);
assert.equal(f.api.testSlot(f.form, resolved.operation).ready, true);
assert.equal(f.api.testResource(f.form, resolved.operation).matched, true);
assert.equal(f.api.testOperationBlockers(resolved.operation).length, 0);
const before = new Map([...f.fields].map(([key, control]) => [key, control.value]));
const result = await f.api.testApply(f.form, resolved.operation);
assert.equal(result.blockers.length, 0, JSON.stringify(result));
assert.equal(f.nativeSelections(), 1);
assert.equal(f.fields.get(`danwei${slotIndex}`).value, '测试新酒店');
assert.equal(f.fields.get(`fangxing${slotIndex}`).value, 'TWN');
assert.equal(f.fields.get(`jianshu${slotIndex}`).value, '9');
for (const name of ['id', 'beizhu', 'riqi', 'riqis', 'zt', 'yf', 'sh']) assert.equal(f.fields.get(`${name}${slotIndex}`).value, before.get(`${name}${slotIndex}`));
for (const [name, value] of before) if (!new RegExp(`^[a-zA-Z_]+${slotIndex}$`).test(name)) assert.equal(f.fields.get(name).value, value, name);
const expected = f.api.testExpected(f.form, resolved.operation);
assert.equal(expected.blockers.length, 0);
assert.ok(expected.fields.some((field) => field.name === `id${slotIndex}` && field.expected === String(800 + slotIndex)));
assert.ok(expected.fields.some((field) => field.name === `danweiid${slotIndex}` && field.expected === '270'));
assert.ok(expected.fields.some((field) => field.name === `fangxing${slotIndex}` && field.expected === 'TWN'));
}
});
test('fresh ERP MAIN world resolves the reported 2027 hotel update using the actual injection list', async () => {
const f = fixture();
f.fields.get('riqi1').value = '2027-9-12';
f.fields.get('riqis1').value = '2027-9-13';
f.input.data.existing_refs.identifier = 'LLW-270911A-A';
f.input.data.arrangement.selection = { start_date: '09-12', end_date: '09-13' };
f.input.data.arrangement.changes.room_count = 10;
const resolved = f.api.testResolveHotel(f.form, f.input);
assert.equal(resolved.ok, true, JSON.stringify(resolved));
assert.equal(resolved.operation.data.arrangement.start_date, '2027-09-12');
assert.equal(resolved.operation.data.arrangement.end_date, '2027-09-13');
const result = await f.api.testApply(f.form, resolved.operation);
assert.equal(result.blockers.length, 0, JSON.stringify(result));
assert.equal(f.fields.get('jianshu1').value, '10');
assert.equal(f.fields.get('riqi1').value, '2027-9-12');
assert.equal(f.fields.get('riqis1').value, '2027-9-13');
});
test('missing page date dependency is diagnosed before matching and never reported as zero ERP records', () => {
const f = fixture({ omitDateMatcher: true });
const before = [...f.fields].map(([key, control]) => [key, control.value]);
const resolved = f.api.testResolveHotel(f.form, f.input);
assert.equal(resolved.ok, false);
assert.deepEqual(Array.from(resolved.blockers), ['arrangement_date_matcher_unavailable']);
assert.equal(f.nativeSelections(), 0);
assert.deepEqual([...f.fields].map(([key, control]) => [key, control.value]), before);
});
test('hotel replacement stops on ambiguous or missing dated rows before selecting a hotel', () => {
const duplicate = fixture({ duplicate: true });
const result = duplicate.api.testResolveHotel(duplicate.form, duplicate.input);
assert.equal(result.ok, false);
assert.match(result.blockers.join(), /target_candidate_count:3/);
const missing = fixture(); missing.input.data.arrangement.selection.start_date = '2026-09-14';
assert.equal(missing.api.testResolveHotel(missing.form, missing.input).ok, false);
assert.equal(duplicate.nativeSelections(), 0);
});
test('hotel replacement blocks stale row identity, payment, audit and confirmed status', async () => {
for (const field of ['id1', 'yf1', 'sh1', 'zt1']) {
const f = fixture();
const resolved = f.api.testResolveHotel(f.form, f.input).operation;
f.fields.get(field).value = field === 'zt1' ? '已确认' : '999';
const result = await f.api.testApply(f.form, resolved);
assert.ok(result.blockers.length, field);
assert.equal(f.nativeSelections(), 0);
}
});
test('hotel replacement rejects native callbacks that change another row or change the record id', async () => {
for (const field of ['danwei0', 'id1', 'riqi1']) {
const f = fixture({ onSelection(fields) { fields.get(field).value = 'unexpected'; } });
const resolved = f.api.testResolveHotel(f.form, f.input).operation;
const result = await f.api.testApply(f.form, resolved);
assert.ok(result.blockers.length, field);
}
});
test('month/day hotel selector freezes the unique actual ERP year and preserves native dates on replacement', async () => {
for (const year of [2026, 2027]) {
const f = fixture();
f.fields.get('riqi1').value = `${year}-9-4`;
f.fields.get('riqis1').value = `${year}-9-5`;
f.input.data.arrangement.selection = { start_date: '09-04', end_date: '09-05' };
const resolved = f.api.testResolveHotel(f.form, f.input);
assert.equal(resolved.ok, true, JSON.stringify(resolved));
assert.equal(resolved.operation.data.arrangement.start_date, `${year}-09-04`);
assert.equal(resolved.operation.data.arrangement.end_date, `${year}-09-05`);
assert.equal(resolved.operation.data.arrangement.selection, undefined);
const applied = await f.api.testApply(f.form, resolved.operation);
assert.equal(applied.blockers.length, 0, JSON.stringify(applied));
assert.equal(f.fields.get('riqi1').value, `${year}-9-4`);
assert.equal(f.fields.get('riqis1').value, `${year}-9-5`);
}
});
test('cross-year duplicate month/day rows remain ambiguous and report original dates before any mutation', () => {
const f = fixture();
for (const [index, year] of [[0,2026],[1,2027]]) {
f.fields.get(`riqi${index}`).value = `${year}-12-31`;
f.fields.get(`riqis${index}`).value = `${year+1}-1-2`;
}
f.input.data.arrangement.selection = { start_date: '12-31', end_date: '01-02' };
let resolved = f.api.testResolveHotel(f.form, f.input);
assert.equal(resolved.ok, false);
assert.match(resolved.blockers.join(), /target_candidate_count:2/);
assert.equal(resolved.evidence.candidates[0].date, '2026-12-31');
assert.equal(resolved.evidence.candidates[1].end_date, '2028-01-02');
assert.equal(f.nativeSelections(), 0);
f.input.data.arrangement.selection.start_date = '2027-12-31';
resolved = f.api.testResolveHotel(f.form, f.input);
assert.equal(resolved.ok, true);
assert.equal(resolved.operation.data.arrangement.target.slot_index, 1);
f.input.data.arrangement.selection.end_date = '2027-01-02';
assert.equal(f.api.testResolveHotel(f.form, f.input).ok, false, 'explicit wrong year must not fall back to month/day');
f.input.data.arrangement.selection = { start_date: '02-30', end_date: '03-01' };
f.fields.get('riqi1').value = '2026-02-30';
assert.equal(f.api.testResolveHotel(f.form, f.input).ok, false, 'invalid actual dates never match');
});