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