import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import vm from 'node:vm'; const adapterSource = readFileSync(new URL('../chrome-extension/ltjt-order-assistant/lifecycle-adapters.js', import.meta.url), 'utf8'); // Exercise the actual polling function without adding a production message/API. const testSource = adapterSource.replace( 'assistant.preflightLifecycleOperation = preflightLifecycleOperation;', 'assistant.testReadiness = (operation, form, timeout) => waitForFormReadiness(operation, contractFor(operation), form, timeout);\n' + 'assistant.testSlot = arrangementSlotEvidence;\n' + 'if (window.testProofs) { arrangementResourceProof = window.testProofs.resource; ownershipListProof = window.testProofs.ownership; }\n' + 'assistant.preflightLifecycleOperation = preflightLifecycleOperation;' ); const hotelFields = ['riqi0', 'riqis0', 'danwei0', 'danweiid0', 'fangxing0', 'jianshu0', 'zt0', 'beizhu0', 'id0', 'yf0', 'sh0', 'MaxI', 'tdID', 'ddID', 'chufari', 'SubmitButton']; function fixture({ count = 76, maxI = '2', rows = Number(maxI) || 2, mode = 'create', tick = () => {}, proofs } = {}) { let now = 10000; const initialTime = now; const values = { MaxI: maxI, tdID: '123', ddID: '0', chufari: '2026-09-15' }; const field = (name) => ({ name, id: name, tagName: 'INPUT', type: name === 'SubmitButton' ? 'submit' : 'text', disabled: false, value: values[name] ?? '' }); const form = { id: 'InfoForm1', isConnected: true, elements: hotelFields.map(field) }; for (let row = 1; row < rows; row += 1) { form.elements.push(...hotelFields.filter((name) => name.endsWith('0')).map((name) => field(`${name.slice(0, -1)}${row}`))); } for (let i = form.elements.length - 1; i < count; i += 1) form.elements.push(field(`extra${i}`)); const state = { loading: false, currentForm: form }; const loadingElement = { className: 'datagrid-mask', getBoundingClientRect: () => ({ width: 50, height: 50, left: 0, top: 0, right: 50, bottom: 50 }), getClientRects: () => [{}] }; const document = { querySelectorAll: (selector) => selector.startsWith('.datagrid-mask') && state.loading ? [loadingElement] : [], querySelector: () => state.currentForm, readyState: 'complete' }; const window = { document, innerWidth: 1024, innerHeight: 768, location: { pathname: '/System/Business/teams_jiudian.asp', href: 'https://erp.invalid/System/Business/teams_jiudian.asp?tdID=123' }, testProofs: proofs, getComputedStyle: () => ({ display: 'block', visibility: 'visible', opacity: '1' }) }; document.defaultView = window; window.parent = window; form.ownerDocument = document; const sandbox = { window, document, URL, URLSearchParams, TextEncoder, location: window.location, Date: class extends Date { static now() { return now; } }, setTimeout(callback, ms) { now += ms; tick({ elapsed: now - initialTime, form, state }); callback(); } }; window.setTimeout = sandbox.setTimeout; vm.runInNewContext(testSource, sandbox); const operation = { action: 'arrangement_hotel', data: { arrangement: { mode } } }; return { form, state, operation, api: window.LTJTOrderAssistant, elapsed: () => now - initialTime, run: (timeout = 2500) => window.LTJTOrderAssistant.testReadiness(operation, form, timeout) }; } test('hotel create accepts a complete two-row 76-field form after one stable second', async () => { const page = fixture(); const result = await page.run(); assert.equal(result.successful_control_count, 76); assert.equal(result.ready, true, JSON.stringify(result.blockers)); assert.equal(result.hotel_row_count, 2); assert.equal(result.native_max_i, 2); assert.ok(page.elapsed() >= 1000 && page.elapsed() < 2000); }); test('hotel create fails closed without a form', async () => { const page = fixture(); const missing = await page.api.testReadiness(page.operation, null, 2500); assert.equal(missing.ready, false); assert.ok(missing.blockers.includes('lifecycle_complete_form_missing')); }); test('hotel create accepts complete variable-size forms without a serialized count threshold', async () => { for (const [count, maxI] of [[76, '2'], [149, '5'], [150, '5'], [154, '5'], [195, '7'], [442, '16']]) { const result = await fixture({ count, maxI }).run(); assert.equal(result.successful_control_count, count); assert.equal(result.minimum_successful_controls, null); assert.equal(result.ready, true, `${count}: ${JSON.stringify(result.blockers)}`); } }); test('hotel create rejects every missing required field even with more than 195 fields', async () => { for (const name of hotelFields) { const page = fixture({ count: 442 }); page.form.elements = page.form.elements.filter((control) => control.name !== name); const result = await page.run(); assert.equal(result.ready, false, name); assert.ok(result.missing_required_fields.includes(name), name); } }); test('hotel create waits for visible loading to disappear and then stabilize', async () => { const page = fixture({ tick: ({ elapsed, state }) => { if (elapsed >= 750) state.loading = false; } }); page.state.loading = true; assert.equal((await page.run()).ready, true); assert.equal(page.elapsed(), 1750); const stuck = fixture(); stuck.state.loading = true; const result = await stuck.run(); assert.equal(result.ready, false); assert.ok(result.blockers.includes('lifecycle_loading_overlay_visible')); }); test('hotel create requires a full observed stable second, including the final timeout sample', async () => { const grow = (form) => form.elements.push({ name: `extra${form.elements.length}`, tagName: 'INPUT', type: 'text', value: '' }); const settling = fixture({ tick: ({ elapsed, form }) => { if (elapsed === 750) grow(form); } }); assert.equal((await settling.run()).ready, true); assert.equal(settling.elapsed(), 1750); const changing = fixture({ tick: ({ form }) => grow(form) }); const result = await changing.run(); assert.equal(result.ready, false); assert.ok(result.blockers.includes('lifecycle_form_not_stable')); assert.equal((await fixture().run(999)).ready, false); assert.equal((await fixture().run(1000)).ready, true); const lastSample = fixture({ tick: ({ elapsed, form }) => { if (elapsed === 1000) grow(form); } }); assert.equal((await lastSample.run(1000)).ready, false); }); test('hotel update/clear and unrelated lifecycle actions retain their current count gates', async () => { for (const mode of ['update', 'clear']) { const result = await fixture({ count: 149, mode }).run(); assert.equal(result.ready, false); assert.ok(result.blockers.includes('lifecycle_form_incomplete:149:150')); assert.equal((await fixture({ count: 154, mode }).run()).ready, true); } const page = fixture(); page.operation.action = 'arrangement_vehicle'; page.operation.data.arrangement.mode = 'update'; page.form.elements = page.form.elements.slice(0, 70); const result = await page.run(); assert.equal(result.ready, false); assert.equal(result.minimum_successful_controls, 90); }); test('76-field readiness still leaves occupied slots and payment/audit state blocked', async () => { for (const [name, value, blocker] of [ ['id0', '1234', 'arrangement_slot_occupied:id0'], ['danweiid0', '5678', 'arrangement_slot_occupied:danweiid0'], ['yf0', '1', 'arrangement_slot_financial_or_audit_state:yf0'], ['sh0', '1', 'arrangement_slot_financial_or_audit_state:sh0'] ]) { const page = fixture(); page.form.elements.find((item) => item.name === name).value = value; assert.equal((await page.run()).ready, true); const slot = page.api.testSlot(page.form, page.operation); assert.equal(slot.ready, false); assert.ok(slot.blockers.includes(blocker), JSON.stringify(slot.blockers)); } }); test('full hotel preflight preserves identity, resource and ownership gates after structural readiness', async () => { for (const failure of ['', 'identity', 'resource', 'ownership', 'slot']) { const calls = []; const page = fixture({ proofs: { resource: () => { calls.push('resource'); return { matched: failure !== 'resource', blockers: ['resource_mismatch'] }; }, ownership: async () => { calls.push('ownership'); return { matched: failure !== 'ownership', blockers: ['account_mismatch'] }; } } }); page.operation.data.existing_refs = { kind: 'independent_order', identifier: 'TEST-HOTEL-READINESS', tid: failure === 'identity' ? '999' : '123', ddid: '456', resolved: true, resolution_source: 'erp_unique_match' }; Object.assign(page.operation.data.arrangement, { start_date: '2026-09-16', end_date: '2026-09-18', room_count: 9, resource: { id: '789', name: '测试酒店' }, status: '未安排', side_effect_policy: 'no_external' }); if (failure === 'slot') page.form.elements.find((item) => item.name === 'yf0').value = '1'; const result = await page.api.preflightLifecycleOperation(page.operation); assert.equal(result.preflight.form_readiness.ready, true); assert.equal(result.status, failure ? 'lifecycle_preflight_blocked' : 'lifecycle_preflight_ready', JSON.stringify(result.blockers)); const expected = { identity: 'form_tid_mismatch:', resource: 'resource_mismatch', ownership: 'ownership_proof:account_mismatch', slot: 'arrangement_slot_financial_or_audit_state:yf0' }; if (failure) assert.ok(result.blockers.some((blocker) => blocker.startsWith(expected[failure])), JSON.stringify(result.blockers)); assert.deepEqual(calls, ['identity', 'slot'].includes(failure) ? [] : ['resource', ...(['', 'ownership'].includes(failure) ? ['ownership'] : [])]); assert.equal(result.no_erp_write, true); assert.equal(result.write_attempted, false); } }); test('hotel create rejects missing controls in later rows even on a large form', async () => { for (const name of hotelFields.filter((name) => name.endsWith('0')).map((name) => `${name.slice(0, -1)}1`)) { const page = fixture({ count: 442 }); page.form.elements = page.form.elements.filter((control) => control.name !== name); const result = await page.run(); assert.equal(result.ready, false, name); assert.ok(result.missing_required_fields.includes(name), name); } }); test('hotel create rejects invalid row markers, omitted rows and gaps', async () => { for (const maxI of ['', '0', '-1', '2.5', '2x', 'Infinity', '9007199254740992']) { const result = await fixture({ maxI, rows: 2 }).run(); assert.equal(result.ready, false, maxI); assert.ok(result.blockers.includes('lifecycle_hotel_max_i_invalid')); } for (const [maxI, rows] of [['3', 2], ['1', 2], ['999999999', 2]]) { const result = await fixture({ maxI, rows }).run(); assert.equal(result.ready, false); assert.ok(result.blockers.includes(`lifecycle_hotel_rows_incomplete:${rows}:${maxI}`)); } const gap = fixture({ maxI: '3' }); gap.form.elements = gap.form.elements.filter((control) => !/^(riqi|riqis|danwei|danweiid|fangxing|jianshu|zt|beizhu|id|yf|sh)1$/.test(control.name)); assert.equal((await gap.run()).ready, false); }); test('hotel create rejects disabled, duplicate and id-only required data controls', async () => { for (const change of [ (control) => { control.disabled = true; }, (control) => { control.matches = (selector) => selector === ':disabled'; }, (control, form) => { form.elements.push({ ...control }); }, (control) => { control.name = ''; }, (control) => { control.type = 'button'; }, (control) => { control.isConnected = false; } ]) { const page = fixture(); change(page.form.elements.find((control) => control.name === 'jianshu0'), page.form); const result = await page.run(); assert.equal(result.ready, false); assert.ok(result.unavailable_required_fields.includes('jianshu0')); } const readonly = fixture(); readonly.form.elements.find((control) => control.name === 'fangxing0').readOnly = true; assert.equal((await readonly.run()).ready, true); }); test('hotel create rejects detached or replaced forms and unfinished documents', async () => { for (const change of [ (page) => { page.form.isConnected = false; }, (page) => { page.state.currentForm = { ...page.form }; }, (page) => { page.form.ownerDocument.readyState = 'loading'; } ]) { const page = fixture(); change(page); assert.equal((await page.run()).ready, false); } }); test('same-count control replacement and select-option changes restart stability', async () => { for (const change of [ (form) => { form.elements[0] = { ...form.elements[0] }; }, (form) => { form.elements.at(-1).id += 'changed'; }, (form) => { form.elements.at(-1).options = [{}]; } ]) { const page = fixture({ tick: ({ elapsed, form }) => { if (elapsed === 750) change(form); } }); assert.equal((await page.run()).ready, true); assert.equal(page.elapsed(), 1750); } }); test('native pending ajax blocks hotel readiness even without a visible overlay', async () => { const page = fixture(); page.form.ownerDocument.defaultView.jQuery = { active: 1 }; const result = await page.run(); assert.equal(result.ready, false); assert.equal(result.native_ajax_active, true); });