import assert from 'node:assert/strict'; import test from 'node:test'; import vm from 'node:vm'; import { readFileSync } from 'node:fs'; import { webcrypto } from 'node:crypto'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const plans = require('../chrome-extension/ltjt-order-assistant/operation-plans.js'); const hotelBatch = require('../chrome-extension/ltjt-order-assistant/hotel-batch.js'); 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.testProjection = indexedCreateRequeryProjection; 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({ occupied = 1, rows = occupied + 2, onSelection = () => {}, onValidate = () => {}, sort = false, onPost = () => {}, linkageDelays = [], onLinkageTick = () => {}, onFieldEvent = () => {}, crypto = webcrypto } = {}) { 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_jiudian.asp?tdID=123', pathname: '/System/Business/teams_jiudian.asp' }; const window = { document, crypto, 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() { onFieldEvent(fields, name); }, getAttribute: (key) => key.toLowerCase() === 'data' ? '270◆测试新酒店◆TWN H◆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 : '', fangxing: existing ? 'TWN H' : '', riqi: existing ? '2026-9-4' : '', riqis: existing ? '2026-9-5' : '', jianshu: existing ? 12 : '', wanshu: existing ? 1 : '', 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); 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, fangxing: roomType, danjia: '120' })) fields.get(`${key}${index}`).value = value; onSelection(fields, index); } }; window.testPost = (body) => { posts++; assert.match(body, /Act=DoInfo_jiudian/); 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); 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; if (selections) onLinkageTick(fields, ++linkageTicks); callback(); } }; window.setTimeout = sandbox.setTimeout; vm.runInNewContext(inpage, sandbox); vm.runInNewContext(adapter, sandbox); const operation = { action: 'arrangement_hotel', data: { existing_refs: { identifier: 'LW-TEST-HOTEL', kind: 'independent_order', tid: '123', ddid: '456', resolved: true, resolution_source: 'erp_unique_match' }, arrangement: { mode: 'create', start_date: '2026-09-05', end_date: '2026-09-06', room_count: 12, resource: { id: '270', name: '测试新酒店', keyword: '测试新酒店TWN', resolved: true }, room_type: 'TWN H', status: '未安排', remark: '', side_effect_policy: 'no_external' } } }; return { api: window.LTJTOrderAssistant, operation, fields, form, posts: () => posts, selections: () => selections, savedForm: () => savedForm }; } test('hotel create binds the first empty row through preflight, native linkage, POST and fresh requery', async () => { for (const occupied of [0, 1, 10]) { const f = fixture({ occupied }); const before = [...f.fields].map(([name, control]) => [name, control.value]); const preflight = await f.api.preflightLifecycleOperation(f.operation); assert.equal(preflight.status, 'lifecycle_preflight_ready', JSON.stringify(preflight)); assert.equal(preflight.preflight.arrangement_slot.slot_index, occupied); const result = await f.api.liveSubmitLifecycleOperation(f.operation, { expected_before_hash: preflight.preflight.form_payload_sha256 }); 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(`riqis${occupied}`).value, '2026-09-06'); assert.equal(f.fields.get(`jianshu${occupied}`).value, '12'); assert.equal(f.fields.get(`danweiid${occupied}`).value, '270'); 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.operation.data.arrangement.target, undefined, 'public create input is not mutated'); } }); test('6/12-room hotel create recovers after delayed sampling, with only one native selection and POST', async () => { for (const roomCount of [6, 12]) for (const linkageDelays of [[], [6000]]) { const f = fixture({ linkageDelays }); f.operation.data.arrangement.room_count = roomCount; const result = await f.api.liveSubmitLifecycleOperation(f.operation); assert.equal(result.status, 'lifecycle_completed', JSON.stringify(result)); assert.equal(f.fields.get('jianshu1').value, String(roomCount)); assert.equal(f.posts(), 1); assert.equal(f.selections(), 1); assert.equal(result.preflight.arrangement_linkage.elapsed_ms, linkageDelays.length ? 7500 : 1500); assert.equal(result.preflight.arrangement_linkage.ready, true); } }); test('unstable or persistently delayed hotel linkage records diagnostics and never saves', async () => { const changing = fixture({ onLinkageTick(fields, tick) { fields.get('jine1').value = String(tick); } }); const paused = fixture({ linkageDelays: Array(6).fill(6000) }); for (const [f, reason] of [[changing, 'fields_changing'], [paused, 'sampling_delayed']]) { const result = await f.api.liveSubmitLifecycleOperation(f.operation); assert.equal(result.status, 'lifecycle_apply_blocked', JSON.stringify(result)); assert.equal(result.write_attempted, false); assert.equal(result.no_erp_write, true); assert.equal(f.posts(), 0); assert.equal(f.selections(), 1); assert.equal(f.fields.get('zt1').value, '', 'do not continue filling after failed observation'); assert.equal(result.preflight.arrangement_linkage.reason, reason); assert.ok(result.blockers.includes('arrangement_create_linked_fields_not_stable')); assert.ok(result.blockers.some(value => value.startsWith(`arrangement_linkage_wait:${reason}:`))); assert.equal(JSON.stringify(result.preflight.arrangement_linkage).includes('测试新酒店'), false); } }); test('hotel create matches the new record after ERP sorts rows and uses persisted evidence on read-only reconciliation', async () => { const f = fixture({ 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); const verified = await f.api.verifyLifecycleOperation(f.operation, { hotel_create: result.preflight.hotel_create }); assert.equal(verified.status, 'lifecycle_requery_matched', JSON.stringify(verified)); assert.equal(f.posts(), 1); const missing = await f.api.verifyLifecycleOperation(f.operation); assert.equal(missing.status, 'lifecycle_requery_not_matched'); }); test('no free row, unsaved drafts, financial state and incomplete rows fail before any POST', async () => { const cases = [ fixture({ occupied: 3, rows: 3 }), (() => { const f = fixture(); f.fields.get('riqi1').value = f.fields.get('chufari').value; return f; })(), ...['danwei1', 'riqi1', 'jianshu1', 'yf1', 'sh1', 'beizhu1', 'danjia1'].map((name) => { const f = fixture(); f.fields.get(name).value = 'unexpected'; return f; }), (() => { const f = fixture(); f.form.elements = f.form.elements.filter((control) => control.name !== 'id1'); return f; })() ]; 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('a caller cannot force a create target, and a changed preflight cannot silently choose another row', async () => { const forced = fixture(); forced.operation.data.arrangement.target = { slot_index: 1 }; assert.equal((await forced.api.liveSubmitLifecycleOperation(forced.operation)).status, 'lifecycle_preflight_blocked'); const f = fixture(); const preflight = await f.api.preflightLifecycleOperation(f.operation); f.fields.get('id1').value = '888'; f.fields.get('danwei1').value = '另一个安排'; const result = await f.api.liveSubmitLifecycleOperation(f.operation, { expected_before_hash: preflight.preflight.form_payload_sha256 }); assert.equal(result.status, 'lifecycle_preflight_changed', JSON.stringify(result)); assert.equal(f.posts(), 0); assert.equal(f.selections(), 0); }); test('native callbacks cannot modify other rows, selected record id, or financial state', async () => { for (const name of ['danwei0', 'id1', 'yf1', 'riqi1']) { const f = fixture({ onSelection(fields) { fields.get(name).value = 'unexpected'; } }); const result = await f.api.liveSubmitLifecycleOperation(f.operation); // Requested date fields are deliberately filled after resource selection. if (name === 'riqi1') assert.equal(result.status, 'lifecycle_completed', JSON.stringify(result)); else { assert.equal(result.status, 'lifecycle_apply_blocked', JSON.stringify(result)); assert.equal(f.posts(), 0); } } for (const name of ['danwei0', 'id1', 'yf1', 'riqi1', 'jianshu1']) { const f = fixture({ onValidate(fields) { fields.get(name).value = 'unexpected'; } }); const result = await f.api.liveSubmitLifecycleOperation(f.operation); assert.equal(result.status, 'lifecycle_apply_blocked', JSON.stringify(result)); assert.equal(result.write_attempted, false); assert.equal(result.no_erp_write, true); assert.equal(f.posts(), 0); } }); test('requery cannot report an old row, duplicate new rows, changed existing rows or wrong quantities as success', async () => { for (const mutate of [ (form) => { form.elements.find((control) => control.name === 'id1').value = '0'; }, (form) => { form.elements.find((control) => control.name === 'id2').value = '998'; }, (form) => { form.elements.find((control) => control.name === 'danwei0').value = 'changed'; }, (form) => { form.elements.find((control) => control.name === 'jianshu1').value = '1'; }, (form) => { form.elements.find((control) => control.name === 'id1').value = '800'; } ]) { const f = fixture({ onPost: mutate }); const result = await f.api.liveSubmitLifecycleOperation(f.operation); assert.equal(result.status, 'lifecycle_execution_uncertain', JSON.stringify(result)); assert.equal(f.posts(), 1); } }); test('omitted hotel remarks survive selection/date callbacks, save and read-only requery', async () => { for (const omitted of [undefined, '']) for (const crypto of [webcrypto, null]) { const f = fixture({ sort: true, crypto, onSelection(fields, index) { fields.get(`beizhu${index}`).value = '6月21号起 按照包房12间\n有房损另计'; }, onFieldEvent(fields, name) { if (/^(danwei|riqi|riqis|jianshu)1$/.test(name)) fields.get('beizhu1').value = ''; } }); if (omitted === undefined) delete f.operation.data.arrangement.remark; const before = JSON.stringify(f.operation); const result = await f.api.liveSubmitLifecycleOperation(f.operation); assert.equal(result.status, 'lifecycle_completed', JSON.stringify(result)); assert.equal(f.fields.get('beizhu1').value, '6月21号起 按照包房12间\n有房损另计'); assert.equal(f.fields.get('beizhu0').value, '保留备注0'); assert.equal(JSON.stringify(f.operation), before); assert.equal(result.preflight.hotel_create.version, 2); const checked = result.requery.checks.find(check => check.field === 'beizhu0'); assert.equal(checked.actual, f.fields.get('beizhu1').value); assert.equal(checked.matched, true); const verified = await f.api.verifyLifecycleOperation(f.operation, { hotel_create: result.preflight.hotel_create }); assert.equal(verified.status, 'lifecycle_requery_matched', JSON.stringify(verified)); assert.equal(f.posts(), 1); } }); test('explicit hotel remark overrides the native default, while missing native notes remain empty', async () => { const f = fixture({ onSelection(fields, index) { fields.get(`beizhu${index}`).value = '酒店默认备注'; } }); f.operation.data.arrangement.remark = '用户备注:晚到保留'; const result = await f.api.liveSubmitLifecycleOperation(f.operation); assert.equal(result.status, 'lifecycle_completed', JSON.stringify(result)); assert.equal(f.fields.get('beizhu1').value, '用户备注:晚到保留'); assert.equal(result.preflight.hotel_create.version, 1); const empty = fixture(); const blank = await empty.api.liveSubmitLifecycleOperation(empty.operation); assert.equal(blank.status, 'lifecycle_completed', JSON.stringify(blank)); assert.equal(empty.fields.get('beizhu1').value, ''); // Old executions stored v1 and intentionally wrote the omitted note as ''. const oldProof = { ...blank.preflight.hotel_create, version: 1 }; delete oldProof.native_remark_sha256; assert.equal((await empty.api.verifyLifecycleOperation(empty.operation, { hotel_create: oldProof })).status, 'lifecycle_requery_matched'); }); test('native remark changes at validation block POST; missing or changed saved evidence cannot report success', async () => { const selection = (fields, index) => { fields.get(`beizhu${index}`).value = '酒店默认备注'; }; const f = fixture({ onSelection: selection, onValidate(fields) { fields.get('beizhu1').value = ''; } }); const blocked = await f.api.liveSubmitLifecycleOperation(f.operation); assert.equal(blocked.status, 'lifecycle_apply_blocked', JSON.stringify(blocked)); assert.equal(f.posts(), 0); const changed = fixture({ onSelection: selection, onPost(form) { form.elements.find(control => control.name === 'beizhu1').value = ''; } }); const uncertain = await changed.api.liveSubmitLifecycleOperation(changed.operation); assert.equal(uncertain.status, 'lifecycle_execution_uncertain'); assert.ok(uncertain.requery.blockers.includes('arrangement_create_native_remark_changed_after_save')); const good = fixture({ onSelection: selection }); const result = await good.api.liveSubmitLifecycleOperation(good.operation); const proof = structuredClone(result.preflight.hotel_create); delete proof.native_remark_sha256; assert.equal((await good.api.verifyLifecycleOperation(good.operation, { hotel_create: proof })).status, 'lifecycle_requery_not_matched'); good.savedForm().elements.find(control => control.name === 'beizhu1').value = 'ERP后续变动'; assert.equal((await good.api.verifyLifecycleOperation(good.operation, { hotel_create: result.preflight.hotel_create })).status, 'lifecycle_requery_not_matched'); assert.equal(good.posts(), 1); }); test('batch orchestration preserves each hotel native or explicit remark through actual lifecycle execution', async () => { const base = fixture().operation; const arrangements = [5, 6, 7].map(day => ({ mode: 'create', resource: { name: '测试新酒店TWN', keyword: '测试新酒店TWN' }, start_date: `2026-09-0${day}`, end_date: `2026-09-0${day + 1}`, room_count: 12, ...(day === 6 ? { remark: '用户独立备注' } : {}) })); const operation = { action: 'arrangement_hotel', order_nature: 'formal', submit_mode: 'dry_run', data: { existing_refs: { identifier: 'LW-TEST-HOTEL' }, arrangements } }; const before = JSON.stringify(operation); const results = {}, reports = []; let final; const context = { operationPlans: plans, hotelBatch, throwIfTaskCancelled() {}, publishHotelBatch: async (_id, _op, batch) => { final = batch; }, chrome: { storage: { local: { get: async () => ({ businessTaskResults: results }) } } }, executeLifecycleOperation: async (id, child) => { const index = reports.length; const f = fixture({ onSelection(fields, slot) { fields.get(`beizhu${slot}`).value = `酒店备注${index}`; }, onFieldEvent(fields, name) { if (/^(riqi|riqis|jianshu)1$/.test(name)) fields.get('beizhu1').value = ''; }, onPost(form) { form.elements.find(control => control.name === 'id1').value = String(999 + index); } }); f.operation.data.arrangement = { ...base.data.arrangement, ...child.data.arrangement, resource: base.data.arrangement.resource }; const report = await f.api.liveSubmitLifecycleOperation(f.operation); assert.equal(report.status, 'lifecycle_completed', JSON.stringify(report)); report.requery = { matched: report.requery.matched, subpage: report.requery }; reports.push(report); results[id] = { status: 'completed', report, write_attempted: true, no_erp_write: false }; } }; const background = readFileSync(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8'); vm.createContext(context); vm.runInContext(background.slice(background.indexOf('async function executeHotelBatch('), background.indexOf('async function runAutoTask(')), context); await context.executeHotelBatch('TASK', operation); assert.equal(hotelBatch.inspect(final).allCompleted, true); assert.deepEqual(reports.map(report => report.requery.subpage.checks.find(check => /^beizhu\d+$/.test(check.field)).actual), ['酒店备注0', '用户独立备注', '酒店备注2']); assert.equal(JSON.stringify(operation), before); });