import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import vm from 'node:vm'; import Ajv2020 from 'ajv/dist/2020.js'; import addFormats from 'ajv-formats'; import { validateStandardOperation } from '../LianSyn-platform/external-agent-client.mjs'; const require = createRequire(import.meta.url); const plans = require('../chrome-extension/ltjt-order-assistant/operation-plans.js'); const lifecycleResult = require('../chrome-extension/ltjt-order-assistant/lifecycle-result.js'); const executionGuard = require('../chrome-extension/ltjt-order-assistant/execution-guard.js'); const context = { run_id: 'sept-2026-lifecycle-01', marker: 'TEST-202609', account: 'AI_TEST_ACCOUNT', native_baseline_id: 'baseline-2026-09', live_window: '2026-09', target_dates: ['2026-09-20', '2026-09-22'] }; const passengerRows = Array.from({ length: 16 }, (_, index) => ({ 序号: index + 1, 姓名: `测试游客${String(index + 1).padStart(2, '0')}`, NAME: `TEST GUEST ${String(index + 1).padStart(2, '0')}`, 性别: index % 2 ? '女' : '男', 出生日期: '1990-01-01', 出生地: '合成城市', 证件类型: '护照', 证件号码: `TEST-PASS-${String(index + 1).padStart(4, '0')}`, 签发地: '合成城市', 签发日: '2026-08-01', 有效期: '2036-08-01', 电话: `+86-000000000${String(index + 1).padStart(2, '0')}`, 备注: 'TEST-202609' })); const passengerRowsWithLeader = passengerRows.map((row, index) => index === 0 ? { ...row, 姓名: '测试领队', 电话: '13800000001', 备注: '领队' } : row); function base(action, data, overrides = {}) { const testContext = Object.hasOwn(overrides, 'context') ? overrides.context : { ...context }; return { action, order_nature: 'formal', submit_mode: 'dry_run', source: { test_context: testContext }, data }; } function arrangement(action, value) { return base(action, { existing_refs: { kind: 'independent_order', identifier: 'LW-260920A-A', tid: '14379', ddid: '14447', departure_date: '2026-09-20', owner_account: 'AI_TEST_ACCOUNT', marker: 'TEST-202609' }, arrangement: { mode: 'create', status: '未确认', remark: `TEST-202609 ${action}`, side_effect_policy: 'no_external', ...value } }); } function deleteGuard(createdRefs, extra = {}) { return { marker: 'TEST-202609', allowlist_id: 'ALLOW-001', account: 'AI_TEST_ACCOUNT', run_id: 'sept-2026-lifecycle-01', created_refs: createdRefs, post_delete_requery: true, export_evidence_frozen: true, delete_authorized: true, ...extra }; } test('fixture uses the exact ERP TSV headers and 16 synthetic rows', async () => { const tsv = await readFile(new URL('../agent设计规范/test-fixtures/lwlt-lifecycle/synthetic-passengers.tsv', import.meta.url), 'utf8'); const lines = tsv.trim().split(/\r?\n/); assert.equal(lines.length, 17); assert.deepEqual(lines[0].split('\t'), plans.PASSENGER_HEADERS); assert.equal(tsv.match(/TEST-202609/g).length, 16); assert.doesNotMatch(lines[0], /证件号\t|签发日期|英文名/); }); test('extension execution guard exposes the two queue maintenance deadlines', () => { assert.equal(executionGuard.RESULT_TIMEOUT_MS, 10 * 60 * 1000); assert.equal(executionGuard.HISTORY_MAINTENANCE_TIMEOUT_MS, 30 * 60 * 1000); assert.deepEqual(executionGuard.timeoutDisposition({ state: 'running' }), { state: 'blocked', result_status: 'failed', write_attempted: false, no_erp_write: true, reconciliation_required: false }); assert.deepEqual(executionGuard.timeoutDisposition({ state: 'write_started' }), { state: 'uncertain', result_status: 'execution_uncertain', write_attempted: true, no_erp_write: false, reconciliation_required: true }); }); test('shared-child adapter waits for the async parent row and cross-checks the exact tid', async () => { const inpage = await readFile(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8'); assert.match(inpage, /const parentRow = await waitForCondition\(\(\) =>/); assert.match(inpage, /parent group not found on split plan list after stable requery/); assert.match(inpage, /parent group tid mismatch: expected/); assert.match(inpage, /const refreshedParentRow = await waitForCondition\(\(\) =>/); }); test('team-order customer restore accepts exact final values and rejects any protected-field drift', async () => { const inpage = await readFile(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8'); const sandbox = { window: {} }; vm.runInNewContext(inpage, sandbox); const inspect = sandbox.window.LTJTOrderAssistant.inspectCustomerRestoreReadiness; const expected = ['客户甲', '101', '', '12', 'CNY']; assert.deepEqual({ ...inspect([...expected], expected) }, { ready: true, value_count: 5, expected_count: 5, matched_count: 5 }); assert.deepEqual({ ...inspect(['客户乙', '101', '', '12', 'CNY'], expected) }, { ready: false, value_count: 5, expected_count: 5, matched_count: 4 }); }); test('team-order protected-field drift matches the 0.5.156 core-field scope', async () => { const inpage = await readFile(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8'); const sandbox = { window: {}, URLSearchParams }; vm.runInNewContext(inpage, sandbox); const inspect = sandbox.window.LTJTOrderAssistant.inspectProtectedSubmitFieldChanges; const approved = 'chufa_ri=2026-11-08&darenshu=15&ys_danwei1=customer-a&ys_danweiid1=101&ys_jine0=1500&xiadanbeizhu=approved-note'; const current = 'chufa_ri=2026-11-8&darenshu=16&ys_danwei1=customer-b&ys_danweiid1=202&ys_jine0=1600&xiadanbeizhu=current-note'; assert.deepEqual(Array.from(inspect(approved, current)), ['darenshu']); assert.match(inpage, /__ltjt_approved_form_protected_projection = postInterceptProtectedProjection/); }); test('team-order live prewrite blocker exposes only changed protected field names', async () => { const source = await readFile(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8'); const inpage = source.replace( 'inspectProtectedSubmitFieldChanges: protectedSubmitFieldChanges,', 'inspectProtectedSubmitProjection: protectedSubmitProjection,\n inspectProtectedSubmitFieldChanges: protectedSubmitFieldChanges,' ); let serialized = 'chufa_ri=2026-11-8&darenshu=16&quanrenshu=1&frenshu1=8'; let nativeAjaxCalls = 0; const form = { elements: { length: 822 } }; const jq = () => ({ serialize: () => serialized }); jq.ajax = () => { nativeAjaxCalls += 1; }; jq.ajax.__ltjt_preflight_guard = true; jq.ajax.__ltjt_native_ajax = () => { nativeAjaxCalls += 1; }; jq.ajax.__ltjt_intercepted_submits = [{}]; const pageWindow = { jQuery: jq, $: jq, SubmitInfoForm() {}, setTimeout, clearTimeout, location: { href: 'https://lwlt.hisy.cc/System/Business/orders_add.asp' } }; pageWindow.window = pageWindow; const sandbox = { window: pageWindow, document: { querySelector: (selector) => selector === '#ListForm' ? form : null }, location: pageWindow.location, crypto: globalThis.crypto, TextEncoder, URLSearchParams, Blob, FormData, performance, setTimeout, clearTimeout, console }; vm.runInNewContext(inpage, sandbox); const assistant = pageWindow.LTJTOrderAssistant; const approved = 'chufa_ri=2026-11-8&darenshu=15&quanrenshu=1&frenshu1=8'; const approvedProjection = assistant.inspectProtectedSubmitProjection(approved); const approvedHashBytes = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(approvedProjection)); const approvedHash = Array.from(new Uint8Array(approvedHashBytes), (byte) => byte.toString(16).padStart(2, '0')).join(''); jq.ajax.__ltjt_approved_form_protected_projection = approvedProjection; const result = await assistant.liveSubmitApproved({ expectedPayloadSha256: 'approved-payload', expectedFormProtectedFieldsSha256: approvedHash, expectedRequestProtectedFieldsSha256: approvedHash, expectedFormFieldCount: 4, expectedRequestFieldCount: 4 }); assert.equal(result.status, 'live_submit_blocked'); assert.equal(result.write_attempted, false); assert.equal(result.no_erp_write, true); assert.equal(nativeAjaxCalls, 0); assert.deepEqual(JSON.parse(JSON.stringify(result.protected_field_drift)), { baseline_available: true, baseline_matches_approved_hash: true, changed_field_count: 1, changed_field_names: ['darenshu'], values_redacted: true }); assert.deepEqual(Array.from(result.blockers), [ 'current protected business fields do not match the approved preflight (changed fields: darenshu)' ]); }); test('team-order live gate allows numbered receivable drift like 0.5.156', async () => { const source = await readFile(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8'); const inpage = source.replace( 'inspectProtectedSubmitFieldChanges: protectedSubmitFieldChanges,', 'inspectProtectedSubmitProjection: protectedSubmitProjection,\n inspectProtectedSubmitFieldChanges: protectedSubmitFieldChanges,' ); const approved = 'chufa_ri=2026-11-8&darenshu=15&quanrenshu=1&frenshu1=8&ys_danwei1=customer-a&ys_danweiid1=101'; const current = 'chufa_ri=2026-11-8&darenshu=15&quanrenshu=1&frenshu1=8&ys_danwei1=customer-b&ys_danweiid1=202'; let serialized = current; let nativeAjaxCalls = 0; const form = { elements: { length: 822 } }; const jq = () => ({ serialize: () => serialized }); jq.ajax = () => { throw new Error('preflight guard should be replaced during live submit'); }; jq.ajax.__ltjt_preflight_guard = true; jq.ajax.__ltjt_native_ajax = (config) => { nativeAjaxCalls += 1; config.success?.('下单成功\n团号: TEST-ORDER', 'success'); config.complete?.({}, 'success'); return { readyState: 4, status: 200, abort() {} }; }; jq.ajax.__ltjt_intercepted_submits = [{}]; const pageWindow = { jQuery: jq, $: jq, SubmitInfoForm() { jq.ajax({ url: '/System/DAT/orders.asp', type: 'POST', data: `Act=DoInfoJH&${serialized}` }); }, setTimeout, clearTimeout, location: { href: 'https://lwlt.hisy.cc/System/Business/orders_add.asp' }, alert() {} }; pageWindow.window = pageWindow; const sandbox = { window: pageWindow, document: { title: '计划下单/修改', querySelector: (selector) => selector === '#ListForm' ? form : null, querySelectorAll: () => [] }, location: pageWindow.location, crypto: globalThis.crypto, TextEncoder, URLSearchParams, Blob, FormData, performance, setTimeout, clearTimeout, console }; vm.runInNewContext(inpage, sandbox); const assistant = pageWindow.LTJTOrderAssistant; const approvedProjection = assistant.inspectProtectedSubmitProjection(approved); const approvedHashBytes = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(approvedProjection)); const approvedHash = Array.from(new Uint8Array(approvedHashBytes), (byte) => byte.toString(16).padStart(2, '0')).join(''); jq.ajax.__ltjt_approved_form_protected_projection = approvedProjection; const result = await assistant.liveSubmitApproved({ expectedPayloadSha256: 'approved-payload-before-native-receivable-drift', expectedFormProtectedFieldsSha256: approvedHash, expectedRequestProtectedFieldsSha256: approvedHash, expectedFormFieldCount: 6, expectedRequestFieldCount: 6 }); assert.equal(result.status, 'live_submit_completed'); assert.equal(result.write_attempted, true); assert.equal(nativeAjaxCalls, 1); assert.deepEqual(Array.from(result.blockers), []); assert.equal(result.submit_safety.approved_form_protected_fields_sha256_matched, true); assert.equal(result.submit_safety.approved_request_protected_fields_sha256_matched, true); clearTimeout(jq.ajax.__ltjt_restore_timer); }); test('split-parent post-write verification retries read-only and falls back after any missed response group', async () => { const inpage = await readFile(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8'); const createBody = inpage.slice( inpage.indexOf('async function createSplitParentLive'), inpage.indexOf('async function createSplitChildLive') ); assert.match(inpage, /SPLIT_PARENT_RESPONSE_REQUERY_DELAYS_MS = Object\.freeze\(\[0, 750, 2000\]\)/); assert.match(createBody, /verifySplitParentReturnedGroups/); assert.match(createBody, /shouldReconcileSplitParent/); assert.match(createBody, /verification_complete: responseVerification\.complete === true/); assert.match(createBody, /reconcileSplitParentByRequest/); assert.match(createBody, /未重新提交创建请求/); assert.doesNotMatch(createBody, /groupNumbers\.length < dates\.length \|\| splitOrderProbe\.enabled/); assert.equal((createBody.match(/captureAjaxSubmit\(/g) || []).length, 1); }); test('identifier-only native list searches receive a calendar-safe plus/minus one-year window', async () => { const [inpage, adapter] = await Promise.all([ readFile(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8'), readFile(new URL('../chrome-extension/ltjt-order-assistant/lifecycle-adapters.js', import.meta.url), 'utf8') ]); const sandbox = { window: {} }; vm.runInNewContext(inpage, sandbox); const resolveValues = sandbox.window.LTJTOrderAssistant.resolveNativeListSearchValues; const fallback = resolveValues({ S_chufariqi: '', S_chufarizhi: '', S_tuanxuhao: 'LW-260907A-B' }, new Date(2026, 7, 13)); assert.equal(fallback.fallback_applied, true); assert.equal(fallback.date_strategy, 'identifier_year_window'); assert.equal(fallback.values.S_chufariqi, '2025-8-13'); assert.equal(fallback.values.S_chufarizhi, '2027-8-13'); const exactDate = resolveValues({ S_chufariqi: '2026-9-7', S_chufarizhi: '2026-9-7', S_tuanxuhao: 'LW-260907A-B' }, new Date(2026, 7, 13)); assert.equal(exactDate.fallback_applied, false); assert.equal(exactDate.values.S_chufariqi, '2026-9-7'); assert.equal(exactDate.values.S_chufarizhi, '2026-9-7'); const noIdentifier = resolveValues({ S_chufariqi: '', S_chufarizhi: '', S_tuanxuhao: '' }, new Date(2026, 7, 13)); assert.equal(noIdentifier.fallback_applied, false); assert.equal(noIdentifier.values.S_chufariqi, ''); assert.equal(noIdentifier.values.S_chufarizhi, ''); const leapDay = resolveValues({ S_chufariqi: '', S_chufarizhi: '', S_tuanxuhao: 'D12345' }, new Date(2024, 1, 29)); assert.equal(leapDay.values.S_chufariqi, '2023-2-28'); assert.equal(leapDay.values.S_chufarizhi, '2025-2-28'); assert.match(inpage, /scopeDocument\.defaultView\.AjaxLoadData\(true\)/); assert.match(inpage, /ajaxSend\$\{eventNamespace\}/); assert.match(inpage, /ajaxComplete\$\{eventNamespace\}/); assert.match(inpage, /native_list_search_request_not_started/); assert.match(inpage, /native_list_search_result_timeout/); assert.match(inpage, /native_list_search_prior_ajax_not_idle/); assert.match(inpage, /Number\(jq\.active \|\| 0\) === 0/); assert.match(inpage, /native_list_search_http_\$\{httpStatus \|\| 'error'\}/); assert.doesNotMatch(inpage, /if \(button && !menuTriggered\) button\.click\(\)/); assert.match(adapter, /assistant\.resolveNativeListSearchValues\(\{/); assert.match(adapter, /S_chufariqi: String\(searchResolution\.values\?\.S_chufariqi \|\| ''\)/); assert.match(adapter, /date_fallback_applied: searchResolution\.fallback_applied === true/); assert.match(adapter, /const searchDateFrom = String\(searchValues\.S_chufariqi \|\| ''\)/); assert.match(adapter, /S_chufariqi: searchDateFrom/); assert.match(adapter, /S_chufarizhi: searchDateTo/); assert.match(adapter, /date_strategy: searchResolution\.date_strategy/); assert.match(adapter, /listExpectedPresent && present && !markerMatched/); assert.match(adapter, /listExpectedPresent && present && !accountMatched/); assert.match(adapter, /listExpectedPresent && present && !statusMatched/); assert.match(adapter, /listExpectedPresent && present && !tidMatched/); assert.match(adapter, /listExpectedPresent && present && !ddidMatched/); }); test('business lookup uses customer/date as the native primary lookup and keeps optional facts supplemental', async () => { const inpage = await readFile(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8'); const sandbox = { window: {} }; vm.runInNewContext(inpage, sandbox); const searchCriteria = sandbox.window.LTJTOrderAssistant.inspectLifecycleSearchCriteria({ action: 'passenger_list_import', data: { existing_refs: { kind: 'shared_child_order' }, customer: { keyword: '衡阳国旅广东' }, departure_dates: ['2026-09-07'], product: { keyword: '衡阳' }, leader: { keyword: '张三' } } }); const searchValues = sandbox.window.LTJTOrderAssistant.lifecycleSearchValues; assert.equal(searchCriteria.target_kind, 'shared_child_order'); assert.equal(searchValues(searchCriteria, 'plan').S_kehuming, ''); const criteria = { action: 'order_cancel', departure_date: '2026-09-15', customer: '辽宁康辉', product: '老挝广东8D', leader: '张三', identifier: '' }; assert.deepEqual({ ...searchValues(criteria, 'orders') }, { S_chufariqi: '2026-9-15', S_chufarizhi: '2026-9-15', S_tuanxuhao: '', S_kehuming: '辽宁康辉', S_chanpinming: '', S_youkexinxi: '', S_lingdui: '', S_zhuangtai: '' }); assert.deepEqual({ ...searchValues(criteria, 'plan') }, { S_chufariqi: '2026-9-15', S_chufarizhi: '2026-9-15', S_tuanxuhao: '', S_kehuming: '辽宁康辉', S_chanpinming: '', S_youkexinxi: '', S_lingdui: '', S_zhuangtai: '' }); assert.equal(searchValues({ ...criteria, action: 'shared_child_order_create' }, 'plan').S_kehuming, ''); assert.equal(searchValues({ ...criteria, action: 'shared_child_order_create' }, 'plan').S_chanpinming, ''); assert.equal(searchValues({ ...criteria, action: 'shared_child_order_create' }, 'plan').S_youkexinxi, ''); assert.equal(searchValues({ ...criteria, action: 'passenger_list_import', target_kind: 'shared_child_order' }, 'plan').S_kehuming, ''); assert.equal(searchValues({ ...criteria, action: 'passenger_list_import', target_kind: 'independent_order' }, 'plan').S_kehuming, '辽宁康辉'); assert.equal(searchValues({ ...criteria, action: 'passenger_list_import' }, 'plan').S_chanpinming, ''); assert.equal(searchValues({ ...criteria, action: 'passenger_list_import' }, 'plan').S_youkexinxi, ''); assert.equal(searchValues({ ...criteria, action: 'passenger_list_import' }, 'orders').S_chanpinming, ''); assert.equal(searchValues({ ...criteria, action: 'passenger_list_import' }, 'orders').S_lingdui, ''); const sharedChildValues = searchValues({ ...criteria, action: 'order_update_shared_child', target_kind: 'shared_child_order' }, 'plan'); assert.equal(sharedChildValues.S_kehuming, ''); assert.equal(sharedChildValues.S_chanpinming, ''); assert.equal(sharedChildValues.S_youkexinxi, ''); const independentUpdateValues = searchValues({ ...criteria, action: 'order_update_independent', target_kind: 'independent_order' }, 'orders'); assert.equal(independentUpdateValues.S_kehuming, '辽宁康辉'); assert.equal(independentUpdateValues.S_chanpinming, ''); assert.equal(independentUpdateValues.S_lingdui, ''); const sharedPlanValues = searchValues({ ...criteria, action: 'order_update_shared_plan', target_kind: 'shared_plan' }, 'plan'); assert.equal(sharedPlanValues.S_kehuming, ''); assert.equal(sharedPlanValues.S_chanpinming, ''); assert.equal(sharedPlanValues.S_youkexinxi, ''); assert.deepEqual({ ...searchValues({ action: 'order_update_shared_plan', departure_date: '2026-09-15', customer: '', product: '', leader: '', identifier: '' }, 'plan') }, { S_chufariqi: '2026-9-15', S_chufarizhi: '2026-9-15', S_tuanxuhao: '', S_kehuming: '', S_chanpinming: '', S_youkexinxi: '', S_lingdui: '', S_zhuangtai: '' }); assert.match(inpage, /native_list_search_filter_missing:/); assert.match(inpage, /'S_kehuming',\s*'S_chanpinming',\s*'S_youkexinxi',\s*'S_lingdui'/); }); test('restore lookup explicitly visits the canceled native status tab', async () => { const inpage = await readFile(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8'); const sandbox = { window: {} }; vm.runInNewContext(inpage, sandbox); const statuses = sandbox.window.LTJTOrderAssistant.lifecycleSearchStatuses; const searchValues = sandbox.window.LTJTOrderAssistant.lifecycleSearchValues; assert.deepEqual(Array.from(statuses({ action: 'order_restore', target_kind: 'independent_order' }, 'orders')), ['已取消']); assert.deepEqual(Array.from(statuses({ action: 'order_restore', target_kind: 'shared_plan' }, 'plan')), ['已取消']); assert.deepEqual(Array.from(statuses({ action: 'order_restore', target_kind: 'shared_child_order' }, 'plan')), ['收客中']); assert.deepEqual(Array.from(statuses({ action: 'order_restore' }, 'plan')), ['已取消', '收客中']); assert.equal(searchValues({ action: 'order_restore', target_kind: 'independent_order', departure_date: '2026-09-30', customer: '衡阳国旅' }, 'orders', '已取消').S_zhuangtai, '已取消'); assert.equal(searchValues({ action: 'order_restore', target_kind: 'shared_child_order', departure_date: '2026-09-30', customer: '衡阳国旅' }, 'plan', '收客中').S_zhuangtai, '收客中'); assert.match(inpage, /statusOption\.click\(\)/); assert.match(inpage, /status_filter_interaction: status \? 'native_menu_click' : 'hidden_field_reset'/); }); test('resolved cancellation uses verified list routes and derives one exact current status', async () => { const inpage = await readFile(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8'); const sandbox = { window: {}, URL, URLSearchParams }; vm.runInNewContext(inpage, sandbox); const modeFor = sandbox.window.LTJTOrderAssistant.inspectResolvedLifecycleTransitionMode; const resolveListTransition = sandbox.window.LTJTOrderAssistant.inspectLifecycleListTransition; assert.match(inpage, /mode: resolvedLifecycleTransitionMode\(resolved\.action, candidate\.kind\)/); assert.match(inpage, /const listTransition = resolveLifecycleListTransition\(operation, list\)/); assert.equal(modeFor('order_cancel', 'independent_order'), 'list'); assert.equal(modeFor('order_cancel', 'shared_plan'), 'list'); assert.equal(modeFor('order_cancel', 'shared_child_order'), 'edit'); assert.equal(modeFor('order_restore', 'independent_order'), 'edit'); const independent = { action: 'order_cancel', data: { existing_refs: { kind: 'independent_order', identifier: 'LW-TEST-001', tid: '100', ddid: '200' }, transition: { mode: 'list', target_kind: 'independent_order', from_status: '', to_status: '已取消' } } }; const independentResolution = resolveListTransition(independent, { kind: 'independent_order', row: { innerText: 'LW-TEST-001 已确认', textContent: 'LW-TEST-001 已确认' } }); assert.equal(independentResolution.ok, true); assert.equal(independentResolution.operation.data.transition.mode, 'list'); assert.equal(independentResolution.operation.data.transition.from_status, '已确认'); assert.equal(independent.data.transition.from_status, ''); const sharedPlanResolution = resolveListTransition({ action: 'order_cancel', data: { existing_refs: { kind: 'shared_plan', identifier: 'LW-PLAN-001', tid: '300' }, transition: { mode: 'list', target_kind: 'shared_plan', from_status: '', to_status: '已取消' } } }, { kind: 'shared_plan', row: { innerText: 'LW-PLAN-001 收客中 子单预订', textContent: 'LW-PLAN-001 收客中 子单预订' } }); assert.equal(sharedPlanResolution.ok, true); assert.equal(sharedPlanResolution.operation.data.transition.from_status, '收客中'); const ambiguous = resolveListTransition(independent, { kind: 'independent_order', row: { innerText: 'LW-TEST-001 预订 已确认', textContent: 'LW-TEST-001 预订 已确认' } }); assert.equal(ambiguous.ok, false); assert.equal(ambiguous.blocker, 'lifecycle_transition_current_status_candidate_count:2'); const noOp = resolveListTransition(independent, { kind: 'independent_order', row: { innerText: 'LW-TEST-001 已取消', textContent: 'LW-TEST-001 已取消' } }); assert.equal(noOp.ok, false); assert.equal(noOp.blocker, 'lifecycle_transition_target_already_current:已取消'); }); test('exact lifecycle edit identity accepts authoritative form refs and query-key variants', async () => { const inpage = await readFile(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8'); const sandbox = { window: {}, URL, URLSearchParams }; vm.runInNewContext(inpage, sandbox); const inspect = sandbox.window.LTJTOrderAssistant.inspectLifecycleEditDocumentReadiness; const expected = { kind: 'shared_child_order', route: /\/system\/business\/plan_order\.asp$/i, queryTid: '14389', queryDdid: '14457' }; const form = (fields = {}) => ({ elements: Object.entries(fields).map(([name, value]) => ({ name, id: name, value })) }); const candidate = (href, fields = {}, readyState = 'complete') => ({ readyState, location: { href, pathname: new URL(href).pathname }, querySelector: () => form(fields) }); const mixedQuery = inspect(candidate( 'https://lwlt.example/System/Business/plan_order.asp?TDID=14389&DID=14457' ), expected); assert.equal(mixedQuery.matched, true); const authoritativeForm = inspect(candidate( 'https://lwlt.example/System/Business/plan_order.asp', { oldtdid: '14389', dID: '14457' } ), expected); assert.equal(authoritativeForm.matched, true); const conflictingForm = inspect(candidate( 'https://lwlt.example/System/Business/plan_order.asp?tdid=14389&ddid=14457', { oldtdid: '14389', dID: '99999' } ), expected); assert.equal(conflictingForm.matched, false); assert.equal(conflictingForm.tid_matched, true); assert.equal(conflictingForm.ddid_matched, false); const incomplete = inspect(candidate( 'https://lwlt.example/System/Business/plan_order.asp?tdid=14389&ddid=14457', {}, 'interactive' ), expected); assert.equal(incomplete.matched, false); assert.equal(incomplete.document_complete, false); }); test('route preparation binds one opaque token only to the exact selected lifecycle document', async () => { const inpage = await readFile(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8'); const sandbox = { window: {}, URL, URLSearchParams }; vm.runInNewContext(inpage, sandbox); const bind = sandbox.window.LTJTOrderAssistant.inspectLifecycleRouteDocumentBinding; const attribute = 'data-ltjt-lifecycle-route-token'; const documentFixture = (children = []) => { const attributes = new Map(); return { documentElement: { setAttribute(name, value) { attributes.set(name, String(value)); }, getAttribute(name) { return attributes.get(name) ?? null; }, removeAttribute(name) { attributes.delete(name); } }, querySelectorAll() { return children.map((contentDocument) => ({ contentDocument })); } }; }; const stale = documentFixture(); const target = documentFixture(); const root = documentFixture([stale, target]); root.documentElement.setAttribute(attribute, 'b'.repeat(32)); stale.documentElement.setAttribute(attribute, 'b'.repeat(32)); target.documentElement.setAttribute(attribute, 'b'.repeat(32)); assert.equal(bind(target, 'A'.repeat(32), root), true); assert.equal(root.documentElement.getAttribute(attribute), null); assert.equal(stale.documentElement.getAttribute(attribute), null); assert.equal(target.documentElement.getAttribute(attribute), 'a'.repeat(32)); assert.equal(bind(stale, 'not-a-valid-route-token', root), false); assert.equal(target.documentElement.getAttribute(attribute), 'a'.repeat(32)); assert.equal(stale.documentElement.getAttribute(attribute), null); }); test('shared-child leader uses the ERP transfer SelectBox, not the mother-plan filter', async () => { const inpage = await readFile(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8'); const sandbox = { window: {} }; vm.runInNewContext(inpage, sandbox); const fillTransfer = sandbox.window.LTJTOrderAssistant.fillSplitChildLeaderTransfer; assert.equal(typeof fillTransfer, 'function'); const fields = new Map(); const ownerWindow = { Event: class Event {}, fetch: async () => ({ ok: true, status: 200, text: async () => '0◆机场到达口◆◆◆张三◆13800138000◆◆◆◆◆◇0◆机场出发口◆◆◆张三◆13800138001◆◆◆◆◆' }), SelectBox: { SetVal: '', set(config) { this.SetVal = config.SetVal; }, SetValToObj(raw) { const columns = String(raw).split('◆'); fields.get('jj_didian').value = columns[1] || ''; fields.get('jj_lianxiren').value = columns[4] || ''; fields.get('jj_dianhua').value = columns[5] || ''; } } }; const scopeDocument = { defaultView: ownerWindow, querySelector(selector) { const id = String(selector).match(/^#([^,]+)/)?.[1] || String(selector).match(/name="([^"]+)"/)?.[1] || ''; return fields.get(id) || null; } }; ['jj_didian', 'jj_lianxiren', 'jj_dianhua'].forEach((id) => fields.set(id, { id, value: '', ownerDocument: scopeDocument, dispatchEvent() {} })); fields.set('fabudanwei', { value: '老挝联泰', ownerDocument: scopeDocument, dispatchEvent() {} }); const result = await fillTransfer(scopeDocument, { name: '张三', keyword: '张三' }); assert.equal(result.ok, true); assert.equal(result.selection, 'first_erp_dropdown_row'); assert.equal(result.match_count, 2); assert.equal(fields.get('jj_didian').value, '机场到达口'); assert.equal(fields.get('jj_lianxiren').value, '张三'); assert.equal(fields.get('jj_dianhua').value, '13800138000'); }); test('team arrangement list search accepts the native PrintGridLists request', async () => { const inpage = await readFile(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8'); const sandbox = { window: {}, URL, URLSearchParams }; vm.runInNewContext(inpage, sandbox); const matches = sandbox.window.LTJTOrderAssistant.inspectNativeListRequestMatches; assert.equal(typeof matches, 'function'); const fields = new Map([ ['S_chufariqi', '2026-9-15'], ['S_chufarizhi', '2026-9-15'], ['S_tuanxuhao', 'LW-260915A-A'], ['S_zhuangtai', ''] ]); const scopeDocument = { location: { href: 'https://lwlt.hisy.cc/System/Business/teams.asp' }, querySelector(selector) { const match = String(selector).match(/#([^, ]+)|\[name="([^"]+)"\]/); const name = match?.[1] || match?.[2] || ''; return fields.has(name) ? { value: fields.get(name) } : null; } }; assert.equal(matches({ url: 'https://lwlt.hisy.cc/System/DAT/team.asp?Act=PrintGridLists', data: 'S_chufariqi=2026-9-15&S_chufarizhi=2026-9-15&S_tuanxuhao=LW-260915A-A&S_zhuangtai=' }, scopeDocument), true); }); test('guide arrangement waits for async guide linkage and coordinator lookup hydration', async () => { const inpage = await readFile(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8'); const sandbox = { window: {}, URL, URLSearchParams }; vm.runInNewContext(inpage, sandbox); const readiness = sandbox.window.LTJTOrderAssistant.inspectArrangementLookupReadiness; assert.equal(typeof readiness, 'function'); const controls = new Map([ ['daoyou0', { value: '', getAttribute: () => '' }], ['daoguan', { value: '', getAttribute: () => 'AI_TEST_ACCOUNT◆测试账号' }] ]); const scopeDocument = { body: { textContent: '当前登录用户 AI_TEST_ACCOUNT' }, documentElement: { textContent: '当前登录用户 AI_TEST_ACCOUNT' } }; const form = { elements: { namedItem: (name) => controls.get(name) || null }, querySelector(selector) { const match = String(selector).match(/#([^, ]+)|\[name="([^"]+)"\]/); const name = match?.[1] || match?.[2] || ''; return controls.get(name) || null; } }; const operation = { data: { arrangement: { resource: { id: '11', name: '测试导游', resolved: true } } } }; assert.equal(readiness(form, 'arrangement_guide', scopeDocument, operation).ready, false); controls.get('daoyou0').getAttribute = () => '11◆11◆测试导游◆020◆A级◆AI_TEST_ACCOUNT◇12◆12◆另一导游◆021◆B级◆OTHER_ACCOUNT'; controls.get('daoguan').value = ''; assert.equal(readiness(form, 'arrangement_guide', scopeDocument, operation).ready, true); assert.equal(readiness(form, 'arrangement_guide', scopeDocument, operation).coordinator_source, 'guide_candidate_linked_field'); assert.equal(readiness(form, 'arrangement_guide', scopeDocument, operation).candidate_row_count, 2); }); test('guide arrangement derives daoguan from the selected guide candidate, not the current operator', async () => { const inpage = await readFile(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8'); const sandbox = { window: {}, URL, URLSearchParams }; vm.runInNewContext(inpage, sandbox); const readiness = sandbox.window.LTJTOrderAssistant.inspectArrangementLookupReadiness; const controls = new Map([ ['daoyou0', { value: '', getAttribute: () => 'CNY◆11◆测试导游◆020◆A级◆GUIDE_COORDINATOR' }], ['daoguan', { value: '', getAttribute: () => 'GUIDE_COORDINATOR◆导管账号◇OTHER_ACCOUNT◆其他账号' }], ['czr', { value: 'OTHER_ACCOUNT', getAttribute: () => '' }] ]); const form = { elements: { namedItem: (name) => controls.get(name) || null }, querySelector(selector) { const match = String(selector).match(/#([^, ]+)|\[name="([^"]+)"\]/); const name = match?.[1] || match?.[2] || ''; return controls.get(name) || null; } }; const scopeDocument = { body: { textContent: '' }, documentElement: { textContent: '' } }; const operation = { data: { arrangement: { resource: { id: '11', name: '测试导游', resolved: true } } } }; const result = readiness(form, 'arrangement_guide', scopeDocument, operation); assert.equal(result.ready, true); assert.equal(result.coordinator_source, 'guide_candidate_linked_field'); assert.equal(result.coordinator_candidate_count, 1); }); test('guide arrangement blocks when the selected guide has no linked daoguan', async () => { const inpage = await readFile(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8'); const sandbox = { window: {}, URL, URLSearchParams }; vm.runInNewContext(inpage, sandbox); const readiness = sandbox.window.LTJTOrderAssistant.inspectArrangementLookupReadiness; const controls = new Map([ ['daoyou0', { value: '', getAttribute: () => 'CNY◆11◆测试导游◆020◆A级' }], ['daoguan', { value: '', getAttribute: () => 'AI_TEST_ACCOUNT◆测试账号' }] ]); const form = { elements: { namedItem: (name) => controls.get(name) || null }, querySelector(selector) { const match = String(selector).match(/#([^, ]+)|\[name="([^"]+)"\]/); const name = match?.[1] || match?.[2] || ''; return controls.get(name) || null; } }; const operation = { data: { arrangement: { resource: { id: '11', name: '测试导游', resolved: true } } } }; const result = readiness(form, 'arrangement_guide', { body: { textContent: '' }, documentElement: { textContent: '' } }, operation); assert.equal(result.ready, false); assert.equal(result.coordinator_source, 'unresolved'); assert.equal(result.coordinator_candidate_count, 1); }); test('shared-child passenger lookup keeps a visible customer result when ERP nests child rows', async () => { const inpage = await readFile(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8'); const sandbox = { window: {} }; vm.runInNewContext(inpage, sandbox); const inspectCandidates = sandbox.window.LTJTOrderAssistant.inspectSharedChildCandidates; assert.equal(typeof inspectCandidates, 'function'); const link = (text, onclick) => ({ innerText: text, textContent: text, getAttribute(name) { return name === 'onclick' ? onclick : ''; } }); const row = (id, text, links, parentElement = null) => ({ id, innerText: text, textContent: text, tagName: 'TR', parentElement, querySelectorAll(selector) { return selector === 'a' ? links : []; } }); const parentLink = link('D14452', 'OPEN_update(14384,14452)'); const parent = row('tr_14384', 'LW-260922A-A 广东测试客户 2026-9-22 老挝广东8D', [parentLink]); const child = row('tr_child_14452', 'D14452', [parentLink]); const document = { getElementById(id) { return id === 'tr_14384' ? parent : null; } }; const candidates = inspectCandidates([parent, child], { customer: '广东测试客户', departure_date: '2026-09-22' }, document); assert.equal(candidates.length, 1); assert.deepEqual({ identifier: candidates[0].identifier, parent_group_no: candidates[0].parent_group_no, tid: candidates[0].tid, ddid: candidates[0].ddid, internal_ref_complete: candidates[0].internal_ref_complete }, { identifier: 'D14452', parent_group_no: 'LW-260922A-A', tid: '14384', ddid: '14452', internal_ref_complete: true }); }); test('independent lookup keeps ERP rows whose update action is attached to the row', async () => { const inpage = await readFile(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8'); const sandbox = { window: {} }; vm.runInNewContext(inpage, sandbox); const inspectCandidates = sandbox.window.LTJTOrderAssistant.inspectIndependentCandidates; assert.equal(typeof inspectCandidates, 'function'); const nestedRow = { id: 'order_row_14384', innerText: 'LW-260903A-A LW衡阳国旅云南分社(广东市场) 2026-9-3 老挝好时光', textContent: 'LW-260903A-A LW衡阳国旅云南分社(广东市场) 2026-9-3 老挝好时光', getAttribute(name) { return name === 'ondblclick' ? 'OPEN_update(14453,0)' : ''; }, querySelectorAll(selector) { return selector === 'a' ? [] : selector === 'tr' ? [{ id: 'nested_cell_row' }] : []; } }; const otherRow = { ...nestedRow, id: 'order_row_14385', innerText: 'LW-260903A-B LW衡阳国旅云南分社(广西市场) 2026-9-3 老挝好时光', textContent: 'LW-260903A-B LW衡阳国旅云南分社(广西市场) 2026-9-3 老挝好时光', getAttribute(name) { return name === 'ondblclick' ? 'OPEN_update(14454,0)' : ''; } }; const candidates = inspectCandidates([nestedRow, otherRow], { action: 'order_update_independent', target_kind: 'independent_order', departure_date: '2026-09-03', customer: '衡阳国旅广东', product: '老挝好时光' }); assert.equal(candidates.length, 1); assert.deepEqual({ identifier: candidates[0].identifier, tid: candidates[0].tid, ddid: candidates[0].ddid }, { identifier: 'LW-260903A-A', tid: '14384', ddid: '14453' }); }); test('customer lifecycle lookup matches non-contiguous Chinese keyword tokens', async () => { const inpage = await readFile(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8'); const sandbox = { window: {} }; vm.runInNewContext(inpage, sandbox); const matches = sandbox.window.LTJTOrderAssistant.lookupKeywordMatchesText; assert.equal(typeof matches, 'function'); assert.equal(matches('LW衡阳国旅云南分社(广东市场)', '衡阳国旅广东'), true); assert.equal(matches('LW衡阳国旅云南分社(广西市场)', '衡阳国旅广东'), false); assert.equal(matches('老挝好时光(广东)', '老挝好时光'), true); }); test('shared-child passenger lookup does not attach a parent customer to every nested child', async () => { const inpage = await readFile(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8'); const sandbox = { window: {} }; vm.runInNewContext(inpage, sandbox); const inspectCandidates = sandbox.window.LTJTOrderAssistant.inspectSharedChildCandidates; const link = (text, onclick) => ({ innerText: text, textContent: text, getAttribute(name) { return name === 'onclick' ? onclick : ''; } }); const row = (id, text, links) => ({ id, innerText: text, textContent: text, tagName: 'TR', parentElement: null, querySelectorAll(selector) { return selector === 'a' ? links : []; } }); const firstLink = link('D14453', 'OPEN_update(14384,14453)'); const secondLink = link('D14454', 'OPEN_update(14384,14454)'); const parent = row('tr_14384', 'LW-260922A-A 广东测试客户 2026-9-22', [firstLink, secondLink]); const firstChild = row('tr_child_14453', 'D14453', [firstLink]); const secondChild = row('tr_child_14454', 'D14454 广东测试客户', [secondLink]); const document = { getElementById: (id) => id === 'tr_14384' ? parent : null }; const candidates = inspectCandidates([parent, firstChild, secondChild], { customer: '广东测试客户', departure_date: '2026-09-22' }, document); assert.equal(candidates.length, 1); assert.equal(candidates[0].identifier, 'D14454'); assert.equal(candidates[0].parent_group_no, 'LW-260922A-A'); }); test('optional lookup facts narrow visible candidates without hard-filtering sparse rows', async () => { const inpage = await readFile(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8'); const sandbox = { window: {} }; vm.runInNewContext(inpage, sandbox); const check = sandbox.window.LTJTOrderAssistant.inspectSupplementalCandidateChecks; assert.equal(typeof check, 'function'); const candidates = [ { identifier: 'D14461', row_text: 'LW-260922A-A 广东直飞衡阳 张三' }, { identifier: 'D14462', row_text: 'LW-260922A-A 其他产品' } ]; const matched = check(candidates, { action: 'passenger_list_import', product: '衡阳', leader: '张三' }); assert.deepEqual(matched.map((candidate) => candidate.identifier), ['D14461']); const sparse = check([ { identifier: 'D14463', row_text: 'D14463' }, { identifier: 'D14464', row_text: 'D14464' } ], { action: 'passenger_list_import', product: '衡阳', leader: '张三' }); assert.deepEqual(sparse.map((candidate) => candidate.identifier), ['D14463', 'D14464']); const sharedChildMatched = check(candidates, { action: 'order_update_shared_child', target_kind: 'shared_child_order', product: '衡阳', leader: '张三' }); assert.deepEqual(sharedChildMatched.map((candidate) => candidate.identifier), ['D14461']); const independentMatched = check(candidates, { action: 'order_update_independent', target_kind: 'independent_order', product: '衡阳', leader: '张三' }); assert.deepEqual(independentMatched.map((candidate) => candidate.identifier), ['D14461']); const crossListMatched = check([ { identifier: 'LW-20260930-A', kind: 'independent_order', row_text: 'LW-20260930-A 衡阳国旅 老挝好时光' }, { identifier: 'D20260930-B', kind: 'shared_child_order', row_text: 'D20260930-B 衡阳国旅 其他产品' } ], { action: 'order_cancel', product: '老挝好时光', customer: '衡阳国旅' }); assert.deepEqual(crossListMatched.map((candidate) => candidate.identifier), ['LW-20260930-A']); const independentSparse = check([ { identifier: 'LW-260903A-A', row_text: 'LW-260903A-A' }, { identifier: 'LW-260903A-B', row_text: 'LW-260903A-B' } ], { action: 'order_update_independent', target_kind: 'independent_order', product: '衡阳', leader: '张三' }); assert.deepEqual(independentSparse.map((candidate) => candidate.identifier), ['LW-260903A-A', 'LW-260903A-B']); const sharedChildCreateMatched = check(candidates, { action: 'shared_child_order_create', target_kind: 'shared_plan', product: '衡阳', leader: '不会参与母团匹配' }); assert.deepEqual(sharedChildCreateMatched.map((candidate) => candidate.identifier), ['D14461']); assert.match(inpage, /const narrowedCandidates = applySupplementalCandidateChecks\(candidates, criteria\)/); assert.match(inpage, /candidate_count_before_supplemental: candidates\.length/); }); test('extension reload reconnects the current platform port and missing ERP identifiers return an actionable error', async () => { const background = await readFile(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8'); assert.match(background, /const LOCAL_PLATFORM_PORTS = new Set\(\['', '8765', '8786'\]\)/); assert.match(background, /LOCAL_PLATFORM_PORTS\.has\(parsed\.port\)/); assert.match(background, /errorCode: 'erp_target_not_found'/); assert.match(background, /ERP 中未找到\$\{targetLabel\},请核对完整单号后重试。/); assert.match(background, /请补充产品名称或领队/); assert.equal((background.match(/const failure = erpResolutionFailure\(executionOperation, resolution\.report\)/g) || []).length, 2); assert.equal((background.match(/failure_message: failure\.message/g) || []).length, 3); }); test('passenger lifecycle prepares the exact native import route before adapter preflight', async () => { const [inpage, background, adapter] = await Promise.all([ readFile(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8'), readFile(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8'), readFile(new URL('../chrome-extension/ltjt-order-assistant/lifecycle-adapters.js', import.meta.url), 'utf8') ]); assert.match(inpage, /async function preparePassengerImportRoute\(operation = \{\}, routeToken = ''\)/); assert.match(inpage, /passenger_independent_edit_link_count/); assert.match(inpage, /passenger_shared_child_edit_link_count/); assert.match(inpage, /function exactSharedChildLinkText\(value, identifier\)/); assert.match(inpage, /new RegExp\(`\^\$\{escaped\}\(\?:-\|\$\)`/); assert.equal((inpage.match(/exactSharedChildLinkText\(textOf\(link\),/g) || []).length, 2); assert.match(inpage, /passenger_import_dialog_opener_mismatch/); assert.match(inpage, /const priorEditDocuments = routeToken/); assert.match(inpage, /passenger_prior_exact_dialog_not_closeable/); assert.match(inpage, /bindLifecycleRouteDocument\(importDocument, routeToken\)/); assert.match(inpage, /preparePassengerImportRoute\(operation, routeToken\)/); assert.match(inpage, /const passengerSlotCount = editDocument\.querySelectorAll\('tr\[id\^="youke"\]'\)\.length/); assert.match(inpage, /expected_passenger_count: passengerSlotCount/); assert.doesNotMatch(inpage, /refs\.expected_passenger_count = Number\(data\.passenger_list\?\.row_count\)/); assert.match(inpage, /team_list_exact_row_to_native_arrangement_page/); assert.match(inpage, /exact_business_list_row_to_native_edit_form/); assert.match(inpage, /lifecycle_shared_plan_edit_entry_mismatch/); assert.match(inpage, /prepareLifecycleOperation/); assert.match(inpage, /async function waitForExactLifecycleEdit\(operation, list, priorEditDocuments = new Set\(\)\)/); assert.match(inpage, /!priorEditDocuments\.has\(candidate\) && exactLifecycleEditDocument\(candidate, expected\)/); assert.match(inpage, /const routeDocuments = sameOriginDocuments\(document\)\.filter/); assert.match(inpage, /const priorFormDocuments = new Set\(routeDocuments\.filter/); assert.match(inpage, /if \(priorFormDocuments\.has\(candidate\)\) return false/); assert.equal((inpage.match(/fresh_edit_session: true/g) || []).length, 2); assert.match(background, /stage: 'lifecycle_route_preparation'/); assert.match(background, /runPageAction\(tab\.id, 'prepareLifecycleOperation'/); const executionStart = background.indexOf('async function executeLifecycleOperation'); const executionBody = background.slice(executionStart, background.indexOf('\nasync function ', executionStart + 1)); assert.ok(executionBody.indexOf('resolveOperationInErp') < executionBody.indexOf("'prepareLifecycleOperation'")); assert.ok(executionBody.indexOf("'prepareLifecycleOperation'") < executionBody.indexOf('preflightLifecycleAfterRoute')); assert.match(adapter, /\.filter\(\(node\) => node\.nodeType === 3\)/); assert.match(adapter, /const stableReferenceProven = Boolean\(expectedTid\) && tidProven && ddidProven/); assert.match(adapter, /!exactIdentifierInText\(targetText, identifier\) && !stableReferenceProven/); assert.match(adapter, /requestUrl\.searchParams\.set\('Act', action\)/); assert.match(adapter, /S_fabudanwei: String\(operation\.data\?\.system_defaults\?\.fabudanwei \|\| ''\)/); assert.doesNotMatch(adapter, /^\s*wode:\s*'0'/m); assert.match(adapter, /function parseListFragment\(text\)/); assert.match(adapter, /\$\{String\(text \|\| ''\)\}<\/tbody><\/table>/); assert.match(adapter, /function exactLifecycleRowIdentifierInText\(value, identifier\)/); assert.equal((adapter.match(/exactLifecycleRowIdentifierInText\(textOf\(/g) || []).length, 1); assert.equal((adapter.match(/exactLifecycleRowIdentifierInText\(rowEvidenceText\(row\), identifier\)/g) || []).length, 1); assert.match(inpage, /function isLifecycleBusinessListRow\(row\)/); assert.match(inpage, /\^\(\?:tr_\|data_tr_\)\\d\+\$/); assert.match(inpage, /if \(!isLifecycleBusinessListRow\(row\)\) return false/); assert.match(adapter, /function documentReferenceEvidence\(operation, targetDocument, rawText = '', targetUrl = ''\)/); assert.match(adapter, /const stableReferenceMatched = tidControlMatched && ddidControlMatched/); }); test('passenger rows normalize to canonical TSV and treat current ERP rows as an initial baseline, not an import limit', () => { const operation = base('passenger_list_import', { existing_refs: { kind: 'shared_child_order', child_order_no: 'D14452', ddid: '14452', tid: '14384', departure_date: '2026-09-22', expected_passenger_count: 16, owner_account: 'AI_TEST_ACCOUNT', marker: 'TEST-202609' }, passenger_list: { operation: 'first_import', row_count: 16, rows: passengerRowsWithLeader, leader_contact: { sequence: 1, name: '测试领队', phone: '13800000001' }, marker: 'TEST-202609' } }); const result = plans.validateOperation(operation); assert.equal(result.ok, true, result.blockers.join('; ')); assert.deepEqual(plans.buildPassengerTsv(operation.data.passenger_list).split('\n')[0].split('\t'), plans.PASSENGER_HEADERS); const missingLeaderPhone = structuredClone(operation); delete missingLeaderPhone.data.passenger_list.leader_contact.phone; assert.match(plans.validateOperation(missingLeaderPhone).blockers.join('\n'), /leader_contact.*phone/); const partial = structuredClone(operation); partial.data.passenger_list.rows = passengerRowsWithLeader.slice(0, 2); partial.data.passenger_list.row_count = 2; assert.equal(plans.validateOperation(partial).ok, true, plans.validateOperation(partial).blockers.join('; ')); assert.deepEqual(validateStandardOperation(partial), []); const overflow = structuredClone(operation); overflow.data.passenger_list.rows.push({ ...passengerRows[0], 序号: 17 }); overflow.data.passenger_list.row_count = 17; assert.equal(plans.validateOperation(overflow).ok, true, plans.validateOperation(overflow).blockers.join('; ')); const technicalOverflow = structuredClone(partial); technicalOverflow.data.passenger_list.rows[0].序号 = 5001; assert.match(plans.validateOperation(technicalOverflow).blockers.join('\n'), /5000 行技术安全上限/); const append = structuredClone(operation); append.data.passenger_list.operation = 'append'; assert.match(plans.validateOperation(append).blockers.join('\n'), /只接受 first_import/); const unsupportedDocumentType = structuredClone(operation); unsupportedDocumentType.data.passenger_list.rows[0].证件类型 = '身份证'; assert.match(plans.validateOperation(unsupportedDocumentType).blockers.join('\n'), /只允许每行证件类型严格为“护照”/); assert.ok(validateStandardOperation(unsupportedDocumentType).some((error) => /只允许每行证件类型严格为“护照”/.test(error))); }); test('passenger merge fills explicit blank rows, preserves others, and protects occupied rows', async () => { const adapter = await readFile(new URL('../chrome-extension/ltjt-order-assistant/lifecycle-adapters.js', import.meta.url), 'utf8'); const sandbox = { window: { LTJTOrderAssistant: {} } }; vm.runInNewContext(adapter, sandbox); const planPassengerMerge = sandbox.window.LTJTOrderAssistant.planPassengerMerge; const comparePassengerRequeryProjection = sandbox.window.LTJTOrderAssistant.comparePassengerRequeryProjection; const passengerRequeryMinimumSlotCount = sandbox.window.LTJTOrderAssistant.passengerRequeryMinimumSlotCount; assert.equal(typeof planPassengerMerge, 'function'); assert.equal(typeof comparePassengerRequeryProjection, 'function'); assert.equal(typeof passengerRequeryMinimumSlotCount, 'function'); const blankRows = Array.from({ length: 16 }, (_, index) => Object.fromEntries(plans.PASSENGER_HEADERS.map((header) => [ header, header === '序号' ? index + 1 : header === '证件类型' ? '护照' : '' ]))); const partialList = { operation: 'first_import', row_count: 2, rows: passengerRows.slice(0, 2) }; const partial = planPassengerMerge(blankRows, partialList, 16); assert.deepEqual([...partial.blockers], []); assert.deepEqual([...partial.target_sequences], [1, 2]); assert.deepEqual([...partial.changed_sequences], [1, 2]); assert.equal(partial.merged_rows.length, 16); assert.equal(partial.merged_rows[0].姓名, passengerRows[0].姓名); assert.equal(partial.merged_rows[2].姓名, ''); const compacted = comparePassengerRequeryProjection( partial.expected_projection, partial.expected_projection.slice(0, 2), { comparisonScope: 'full_post_merge_projection' } ); assert.equal(compacted.matched, true); assert.equal(compacted.expected_persisted_count, 2); assert.equal(compacted.actual_persisted_count, 2); assert.equal(compacted.comparison_scope, 'full_nonblank_post_merge_projection'); assert.equal(passengerRequeryMinimumSlotCount([1, 2], 16, partial.expected_projection), 2); assert.equal(passengerRequeryMinimumSlotCount(Array.from({ length: 25 }, (_, index) => index + 1), 31, Array.from({ length: 31 })), 25); assert.equal(passengerRequeryMinimumSlotCount([40], 31, Array.from({ length: 40 })), 40); assert.equal(passengerRequeryMinimumSlotCount([], 31, Array.from({ length: 31 })), 0); const missingPersistedRow = comparePassengerRequeryProjection( partial.expected_projection, partial.expected_projection.slice(0, 1), { comparisonScope: 'full_post_merge_projection' } ); assert.equal(missingPersistedRow.matched, false); assert.equal(missingPersistedRow.actual_persisted_count, 1); const compactedTargetRows = comparePassengerRequeryProjection( partial.expected_projection.slice(0, 2), [partial.expected_projection[1], partial.expected_projection[0]], { comparisonScope: 'target_rows', targetSequences: [15, 16] } ); assert.equal(compactedTargetRows.matched, true); assert.equal(compactedTargetRows.matching_strategy, 'content_multiset_after_blank_compaction'); assert.equal(compactedTargetRows.matched_count, 2); const occupiedRows = structuredClone(blankRows); occupiedRows[0].姓名 = '现有游客'; occupiedRows[0].证件号码 = 'EXISTING-001'; const protectedPlan = planPassengerMerge(occupiedRows, partialList, 16); assert.match(protectedPlan.blockers.join('\n'), /passenger_row_overwrite_confirmation_required:1/); assert.equal(protectedPlan.merged_rows[0].姓名, '现有游客'); const identicalUnconfirmedRows = structuredClone(blankRows); identicalUnconfirmedRows[0] = { ...passengerRows[0] }; identicalUnconfirmedRows[1] = { ...passengerRows[1] }; const identicalUnconfirmed = planPassengerMerge(identicalUnconfirmedRows, partialList, 16); assert.match(identicalUnconfirmed.blockers.join('\n'), /passenger_row_overwrite_confirmation_required:1,2/); assert.deepEqual([...identicalUnconfirmed.changed_sequences], []); const confirmed = planPassengerMerge(occupiedRows, { ...partialList, operation: 'full_replace', confirmed: true }, 16); assert.deepEqual([...confirmed.blockers], []); assert.deepEqual([...confirmed.changed_sequences], [1, 2]); assert.equal(confirmed.merged_rows[0].姓名, passengerRows[0].姓名); assert.equal(confirmed.merged_rows[2].姓名, ''); const confirmedIdentical = planPassengerMerge( identicalUnconfirmedRows, { ...partialList, operation: 'full_replace', confirmed: true }, 16 ); assert.deepEqual([...confirmedIdentical.blockers], []); assert.deepEqual([...confirmedIdentical.changed_sequences], [1, 2]); assert.deepEqual([...confirmedIdentical.idempotent_sequences], []); const outside = planPassengerMerge(blankRows, { operation: 'first_import', row_count: 1, rows: [{ ...passengerRows[0], 序号: 25 }] }, 16); assert.deepEqual([...outside.blockers], []); assert.equal(outside.initial_count, 16); assert.equal(outside.target_count, 25); assert.equal(outside.merged_rows.length, 25); assert.equal(outside.expected_projection.length, 25); assert.equal(outside.merged_rows[16].序号, 17); assert.equal(outside.merged_rows[16].姓名, ''); assert.equal(outside.merged_rows[23].序号, 24); assert.equal(outside.merged_rows[23].证件类型, '护照'); assert.equal(outside.merged_rows[24].姓名, passengerRows[0].姓名); const exactDynamicRequery = comparePassengerRequeryProjection( outside.expected_projection, outside.expected_projection, { comparisonScope: 'full_post_merge_projection', targetSequences: [25], minimumSlotCount: 25, requireDirectSequences: true } ); assert.equal(exactDynamicRequery.matched, true); assert.equal(exactDynamicRequery.minimum_slot_count, 25); assert.equal(exactDynamicRequery.minimum_slot_count_matched, true); assert.equal(exactDynamicRequery.direct_sequence_matched, true); const compactedDynamicRequery = comparePassengerRequeryProjection( outside.expected_projection, [outside.expected_projection[24]], { comparisonScope: 'full_post_merge_projection', targetSequences: [25], minimumSlotCount: 25, requireDirectSequences: true } ); assert.equal(compactedDynamicRequery.matched, false); assert.equal(compactedDynamicRequery.minimum_slot_count_matched, false); assert.equal(compactedDynamicRequery.direct_sequence_matched, false); const technicalOverflow = planPassengerMerge(blankRows, { operation: 'first_import', row_count: 1, rows: [{ ...passengerRows[0], 序号: 5001 }] }, 16); assert.match(technicalOverflow.blockers.join('\n'), /passenger_target_row_limit_exceeded:5001:5000/); assert.equal(technicalOverflow.merged_rows.length, 16); }); test('passenger adapter mirrors only native persisted fields and reports implicit passport semantics', async () => { const adapter = await readFile(new URL('../chrome-extension/ltjt-order-assistant/lifecycle-adapters.js', import.meta.url), 'utf8'); assert.match(adapter, /english_name: \{ header: 'NAME', tokens: \['pinyinxm'/); assert.match(adapter, /document_no: \{ header: '证件号码', tokens: \['zjhaoma', 'haoma'/); assert.match(adapter, /issue_date: \{ header: '签发日', tokens: \['fazhengri'/); assert.match(adapter, /expiry_date: \{ header: '有效期', tokens: \['youxiaori'/); assert.doesNotMatch(adapter, /^\s*document_type:\s*\{/m); assert.match(adapter, /const normalized = normalize\(value\)\.replace\(\/\\s\+\/g, ''\)/); assert.match(adapter, /\['birth_date', 'issue_date', 'expiry_date'\]\.includes\(field\).*dateYyyyMD\(normalized\)/); assert.match(adapter, /implicit_document_type: '护照'/); assert.match(adapter, /non_persisted_source_fields: \['证件类型'\]/); assert.match(adapter, /passenger_document_type_must_be_passport/); assert.match(adapter, /operation\.action === 'passenger_list_import'[\s\S]*planPassengerMerge\(/); assert.match(adapter, /const changedSequenceSet = new Set\(merge\.changed_sequences\)/); assert.match(adapter, /filter\(\(sequence\) => !changedSequenceSet\.has\(sequence\)\)/); assert.match(adapter, /const englishName = rowControl\(row, PASSENGER_PROJECTION\.english_name\.tokens\)/); assert.match(adapter, /setControl\(englishName, 'set', normalizePassengerValue\('english_name', passengerCell\(inputRow, 'NAME'\)\)\)/); assert.match(adapter, /getControl\(parentForm, 'jj_lianxiren'\)/); assert.match(adapter, /getControl\(parentForm, 'jj_dianhua'\)/); assert.match(adapter, /setControl\(leaderNameControl, 'set', leaderContact\.name\)/); assert.match(adapter, /setControl\(leaderPhoneControl, 'set', leaderContact\.phone\)/); assert.match(adapter, /passenger_leader_contact_projection_mismatch/); assert.match(adapter, /leader_contact_requery/); assert.match(adapter, /comparison_scope: targetOnly \? 'target_rows_only' : 'full_nonblank_post_merge_projection'/); assert.match(adapter, /persistence_rule: 'compare_nonblank_passenger_rows'/); assert.match(adapter, /pageSource: 'hydrated_fresh_route'/); assert.match(adapter, /requery_source: page\.source/); assert.match(adapter, /function hydratedFreshRoutePage\(targetWindow\)/); assert.doesNotMatch(adapter, /actualProjection\.length === expectedCount/); assert.match(adapter, /passenger_requery_context_mismatch/); assert.match(adapter, /daoru\\\.asp\$\/i\.test\(pathName\(window\)\)/); }); test('passenger explicit server success is terminal without a post-save row requery', async () => { const [adapter, background, platformApp] = await Promise.all([ readFile(new URL('../chrome-extension/ltjt-order-assistant/lifecycle-adapters.js', import.meta.url), 'utf8'), readFile(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8'), readFile(new URL('../LianSyn-platform/app.js', import.meta.url), 'utf8') ]); const passengerBranchStart = adapter.indexOf("else if (operation.action === 'passenger_list_import')", adapter.indexOf('const saved = await saveForm')); const passengerBranchEnd = adapter.indexOf('\n else {', passengerBranchStart); const passengerBranch = adapter.slice(passengerBranchStart, passengerBranchEnd); assert.match(passengerBranch, /matched: true/); assert.match(passengerBranch, /required: false/); assert.match(passengerBranch, /skipped: true/); assert.match(passengerBranch, /completion_policy: 'passenger_list_explicit_server_success'/); assert.doesNotMatch(passengerBranch, /passengerRequery|verifyLifecycleOperation/); assert.doesNotMatch(background, /attemptAutomaticPassengerReconciliation/); assert.match(background, /名单已取得 ERP 明确成功响应,按业务规则确认录入成功/); assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.165'/); }); test('uncertain lifecycle writes can only converge through a read-only plugin requery', async () => { const [background, bridge, platform] = await Promise.all([ readFile(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8'), readFile(new URL('../chrome-extension/ltjt-order-assistant/business-bridge.js', import.meta.url), 'utf8'), readFile(new URL('../LianSyn-platform/app.js', import.meta.url), 'utf8') ]); const reconcileBody = background.slice( background.indexOf('async function reconcileLifecycleTask'), background.indexOf('chrome.runtime.onMessage.addListener') ); const readOnlyBody = background.slice( background.indexOf('async function runLifecycleReadOnlyVerification'), background.indexOf('async function reconcileLifecycleTask') ); assert.match(reconcileBody, /execution\.state !== 'uncertain'/); assert.match(reconcileBody, /priorReport\.server_response\?\.completed !== true/); assert.match(reconcileBody, /runLifecycleReadOnlyVerification/); assert.match(readOnlyBody, /'verifyLifecycleOperation'/); assert.match(reconcileBody, /reconciliation_resolved: true/); assert.match(reconcileBody, /no_additional_erp_write: true/); assert.doesNotMatch(reconcileBody, /liveSubmitLifecycleOperation/); assert.doesNotMatch(readOnlyBody, /liveSubmitLifecycleOperation/); assert.match(background, /message\?\.type === 'LTJT_RECONCILE_TASK'/); assert.match(background, /resolvesReconciliation/); assert.match(bridge, /message\.type === 'RECONCILE_TASK'/); assert.match(bridge, /type: 'LTJT_RECONCILE_TASK'/); assert.match(platform, /function canReconcileTask/); const canReconcileBody = platform.slice( platform.indexOf('function canReconcileTask'), platform.indexOf('function canResumePrewriteTask') ); assert.match(canReconcileBody, /isLifecycleOperationView\(selectedOperation\(task\)\)/); assert.doesNotMatch(canReconcileBody, /hasLifecycleTestContextView/); assert.match(platform, /sendToExtension\('RECONCILE_TASK'/); assert.match(platform, /只读回查 ERP 现有结果/); }); test('ERP business-rule rejections return the native message and stay out of reconciliation', async () => { const [adapter, background] = await Promise.all([ readFile(new URL('../chrome-extension/ltjt-order-assistant/lifecycle-adapters.js', import.meta.url), 'utf8'), readFile(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8') ]); assert.match(adapter, /business_blocked: businessBlocker/); assert.match(adapter, /function responseBlocker\(response\)/); assert.match(adapter, /businessBlocked \? 'lifecycle_business_blocked'/); assert.match(adapter, /business_error: \{[\s\S]*code: 'erp_business_rule_blocked'/); const businessBranch = background.slice( background.indexOf('const businessMessage = String('), background.indexOf('if (lifecycleResult?.isVerifiedResult?.(live) === true)') ); assert.match(background, /'lifecycle_business_blocked'/); assert.match(businessBranch, /live\.business_error\?\.message/); assert.match(businessBranch, /live\.server_response\?\.response_message/); assert.match(businessBranch, /error_code: 'erp_business_rule_blocked'/); assert.match(businessBranch, /failure_message: message/); assert.match(businessBranch, /message,/); assert.doesNotMatch(businessBranch, /execution_uncertain|reconciliation/); }); test('childless shared plans receive a precise hotel-arrangement ERP blocker', async () => { const [adapter, background] = await Promise.all([ readFile(new URL('../chrome-extension/ltjt-order-assistant/lifecycle-adapters.js', import.meta.url), 'utf8'), readFile(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8') ]); const sandbox = { window: { LTJTOrderAssistant: {} } }; vm.runInNewContext(adapter, sandbox); const inspectBusinessError = sandbox.window.LTJTOrderAssistant.inspectArrangementPageBusinessError; assert.equal(typeof inspectBusinessError, 'function'); const sharedPlanHotel = { action: 'arrangement_hotel', data: { existing_refs: { kind: 'shared_plan' } } }; const failure = inspectBusinessError(sharedPlanHotel, '没有子团数据!', false); assert.equal(failure?.blocker, 'arrangement_hotel_shared_plan_child_required'); assert.equal(failure?.code, 'erp_shared_plan_child_required_for_hotel'); assert.match(failure?.message || '', /请先新增散拼子单/); assert.equal(inspectBusinessError(sharedPlanHotel, '没有子团数据!', true), null); assert.equal(inspectBusinessError(sharedPlanHotel, '酒店安排页面已加载', false), null); assert.equal(inspectBusinessError({ action: 'arrangement_hotel', data: { existing_refs: { kind: 'independent_order' } } }, '没有子团数据!', false), null); assert.equal(inspectBusinessError({ action: 'arrangement_vehicle', data: { existing_refs: { kind: 'shared_plan' } } }, '没有子团数据!', false), null); assert.match(adapter, /sameOriginAncestorText\(window\)/); assert.match(adapter, /business_error: \{[\s\S]*pageBusinessError\.code[\s\S]*pageBusinessError\.message/); const preflightFailureBranch = background.slice( background.indexOf('function lifecyclePreflightBusinessFailure'), background.indexOf('function lifecycleFrameSpec') ); assert.match(preflightFailureBranch, /erp_shared_plan_child_required_for_hotel/); assert.match(preflightFailureBranch, /arrangement_hotel_shared_plan_child_required/); assert.match(background, /failure_source: 'erp'/); assert.match(background, /散拼母团尚未创建子单,ERP 不允许安排酒店/); }); test('passenger native row capacity failures receive a precise business-facing ERP limit message', async () => { const background = await readFile( new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8' ); const failureBranch = background.slice( background.indexOf('function lifecycleLiveBusinessFailure'), background.indexOf('function lifecycleFrameSpec') ); assert.match(failureBranch, /native_target_row_count_not_reached/); assert.match(failureBranch, /dom_target_row_count_too_small/); assert.match(failureBranch, /erp_passenger_count_limit_exceeded/); assert.match(failureBranch, /本次名单共 \$\{suppliedRowCount\} 人/); assert.match(failureBranch, /超过\$\{targetLabel\}在 ERP 中最多可录入的 \$\{systemLimit\} 人/); const sandbox = {}; vm.runInNewContext( `${failureBranch}\nglobalThis.inspectPassengerCapacityFailure = lifecycleLiveBusinessFailure;`, sandbox ); const inspectFailure = sandbox.inspectPassengerCapacityFailure; const independentFailure = inspectFailure({ action: 'passenger_list_import', data: { existing_refs: { kind: 'independent_order' }, passenger_list: { row_count: 25 } } }, { blockers: [ 'passenger_native_target_row_count_not_reached:16:25', 'passenger_native_target_row_missing:17', 'passenger_dom_target_row_count_too_small:16:25' ] }); assert.equal(independentFailure.errorCode, 'erp_passenger_count_limit_exceeded'); assert.equal(independentFailure.systemLimit, 16); assert.equal(independentFailure.requestedSlotCount, 25); assert.equal( independentFailure.message, '本次名单共 25 人,超过该独立团在 ERP 中最多可录入的 16 人。请调整名单人数后重新提交。' ); const sparseFailure = inspectFailure({ action: 'passenger_list_import', data: { existing_refs: { kind: 'shared_child_order' }, passenger_list: { row_count: 1 } } }, { blockers: ['passenger_dom_target_row_count_too_small:31:40'] }); assert.equal( sparseFailure.message, '本次名单需要写入至第 40 个游客位,超过该散拼子单在 ERP 中最多可录入的 31 人。请调整名单人数后重新提交。' ); assert.equal(inspectFailure({ action: 'order_cancel' }, { blockers: ['passenger_native_target_row_count_not_reached:16:25'] }), null); assert.match(background, /lifecycleLiveBusinessFailure\(executionOperation, live\)/); assert.match(background, /failure_stage: 'lifecycle_live'/); assert.match(background, /failure_source: 'erp'/); }); test('lifecycle execution keeps the MV3 worker alive until the durable task settles', async () => { const [background, bridge] = await Promise.all([ readFile(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8'), readFile(new URL('../chrome-extension/ltjt-order-assistant/business-bridge.js', import.meta.url), 'utf8') ]); assert.match(bridge, /EXECUTION_KEEPALIVE_PORT = 'LTJT_EXECUTION_KEEPALIVE'/); assert.match(bridge, /window\.setInterval[\s\S]*type: 'KEEPALIVE'/); assert.match(bridge, /dispatchAutoExecutionWithKeepalive/); assert.match(bridge, /port\.postMessage\(\{ \.\.\.message, type: 'EXECUTE' \}\)/); assert.match(bridge, /Missing task\.task_id/); assert.doesNotMatch(bridge, /operation\.source\s*=/); assert.doesNotMatch(bridge, /operation\?\.source\?\.task_id/); assert.match(background, /chrome\.runtime\.onConnect\.addListener/); assert.match(background, /port\.name !== 'LTJT_EXECUTION_KEEPALIVE'/); assert.match(background, /type: 'EXECUTION_ACCEPTED'/); assert.match(background, /currentTaskAt/); assert.match(background, /return await runAutoTaskWithTimeout\(taskId, executionId\)/); assert.match(background, /type: 'EXECUTION_SETTLED'/); assert.match(background, /function startExecutionWorkerKeepalive/); assert.match(background, /chrome\.runtime\.getPlatformInfo\(\)/); assert.match(background, /10_000/); assert.match(background, /function startErpTabExecutionKeepalive/); assert.match(background, /__LTJT_ERP_EXECUTION_KEEPALIVES__/); assert.match(background, /globalThis\.setInterval\(heartbeat, 5_000\)/); assert.match(background, /function runManagedAutoTask/); assert.match(background, /function resumePrewriteTask/); assert.match(background, /message\?\.type === 'LTJT_RESUME_PREWRITE_TASK'/); assert.match(background, /result\?\.write_attempted === true \|\| result\?\.no_erp_write !== true/); assert.match(background, /return await executeLifecycleOperation/); assert.match(background, /function discoverExactLifecycleFrame/); assert.match(background, /lifecycle_exact_frame_ambiguous/); assert.match(background, /function createLifecycleRouteToken/); assert.match(background, /new Uint8Array\(16\)/); assert.match(background, /route_token: routeToken/); assert.match(background, /probe\.route_token_matched === true/); assert.match(background, /lifecycle_exact_route_document_missing/); assert.match(background, /auxiliaryBlankFrame/); assert.match(background, /declared_frame_source/); assert.match(background, /probe\.frame_eligible === true/); assert.match(background, /verificationTarget = discovery\.frame_id/); assert.match(background, /function lifecyclePreflightRetryable/); assert.match(background, /arrangement_resource_candidate_data_missing/); }); test('ERP browser injection is host-permission gated and fail-closed with diagnostics', async () => { const [manifestText, background, platform] = await Promise.all([ readFile(new URL('../chrome-extension/ltjt-order-assistant/manifest.json', import.meta.url), 'utf8'), readFile(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8'), readFile(new URL('../LianSyn-platform/app.js', import.meta.url), 'utf8') ]); const manifest = JSON.parse(manifestText); assert.ok(manifest.host_permissions.includes('https://lwlt.hisy.cc/*')); assert.match(background, /async function requireErpHostPermission\(taskId = ''\)/); assert.match(background, /erp_host_permission_missing/); assert.match(background, /erp_host_not_allowlisted/); assert.match(background, /erp_page_access_denied/); assert.match(background, /await requireErpHostPermission\(taskId\)/); const actionBody = background.slice( background.indexOf('async function runPageAction'), background.indexOf('function pickResult') ); assert.doesNotMatch(actionBody, /chrome\.scripting\.executeScript/); assert.match(actionBody, /executeErpScript\(tabId/); assert.match(platform, /erpHostAccessReady/); assert.match(platform, /ERP 页面权限/); }); test('five arrangement actions require exact hidden-id references and action-specific fields', () => { const operations = [ arrangement('arrangement_guide', { resource: { name: '测试导游', id: '11', resolved: true } }), arrangement('arrangement_vehicle', { supplier: { name: '测试车队', id: '386', resolved: true }, item: '测试车型', start_date: '2026-09-22', end_date: '2026-09-24', quantity: 1 }), arrangement('arrangement_hotel', { resource: { name: '测试酒店', id: '126', resolved: true }, room_type: '标准间', start_date: '2026-09-22', end_date: '2026-09-29', room_count: 8 }), arrangement('arrangement_transport', { supplier: { name: '测试票务', id: '1220', resolved: true }, item: '测试航段', date: '2026-09-22', quantity: 16 }), arrangement('arrangement_other', { supplier: { name: '测试供应商', id: '1347', resolved: true }, item: '测试备案成本', date: '2026-09-22', quantity: 1, filing: { number: '99092284', date: '2026-09-22', entry_port: '磨丁', exit_port: '万象' } }) ]; for (const operation of operations) { const result = plans.validateOperation(operation); assert.equal(result.ok, true, `${operation.action}: ${result.blockers.join('; ')}`); assert.equal(result.execution, 'browser_lifecycle'); assert.equal(result.test_only, false); } const unresolved = arrangement('arrangement_vehicle', { supplier: { name: '多候选车队', resolved: true }, item: '测试车型', start_date: '2026-09-22', end_date: '2026-09-24', quantity: 1 }); assert.match(plans.validateOperation(unresolved).blockers.join('\n'), /id 或 ltjt_id/); const zeroQuantity = arrangement('arrangement_vehicle', { supplier: { name: '测试车队', id: '386', resolved: true }, item: '测试车型', start_date: '2026-09-22', end_date: '2026-09-24', quantity: 0 }); assert.match(plans.validateOperation(zeroQuantity).blockers.join('\n'), /正整数/); assert.ok(validateStandardOperation(zeroQuantity).some((error) => /正整数/.test(error))); const outsideWindow = arrangement('arrangement_transport', { supplier: { name: '测试票务', id: '1220', resolved: true }, item: '测试航段', date: '2026-10-01', quantity: 16 }); assert.match(plans.validateOperation(outsideWindow).blockers.join('\n'), /2026 年 9 月/); assert.ok(validateStandardOperation(outsideWindow).some((error) => /2026 年 9 月/.test(error))); const reversedHotel = arrangement('arrangement_hotel', { resource: { name: '测试酒店', id: '126', resolved: true }, room_type: '标准间', start_date: '2026-09-29', end_date: '2026-09-22', room_count: 8 }); assert.match(plans.validateOperation(reversedHotel).blockers.join('\n'), /晚于 start_date/); assert.ok(validateStandardOperation(reversedHotel).some((error) => /晚于 start_date/.test(error))); }); test('arrangement browser safety uses real form completeness and exact business-page candidate tuples', async () => { const adapter = await readFile(new URL('../chrome-extension/ltjt-order-assistant/lifecycle-adapters.js', import.meta.url), 'utf8'); assert.match(adapter, /DoInfo_cheliang: 90/); assert.match(adapter, /DoInfo_piao: 120/); assert.match(adapter, /DoInfo_qita: 120/); assert.match(adapter, /FORM_READINESS_REQUIRED_FIELDS/); assert.match(adapter, /arrangementResourceProof/); assert.match(adapter, /arrangement_resource_candidate_count/); assert.match(adapter, /match_key: guide \? 'id\+name' : 'id\+name\+item'/); assert.match(adapter, /arrangement_guide_coordinator_candidate_count/); assert.match(adapter, /coordinator_match_key: guide \? 'guide_candidate\[5\]\+exact_page_candidate'/); assert.match(adapter, /const coordinator = normalize\(linkedCandidate\[5\]\)/); assert.match(adapter, /applyExact\(form, 'daoguan', 'set', coordinator/); assert.match(adapter, /requireControlValue\(form, 'daoguan', coordinator/); assert.match(adapter, /arrangement_guide_linked_candidate_count/); assert.match(adapter, /applyExact\(form, 'dianhua0', 'set', linkedCandidate\[3\]/); assert.match(adapter, /applyExact\(form, 'dengji0', 'set', linkedCandidate\[4\]/); assert.match(adapter, /Tpage: '1'/); assert.match(adapter, /P_Size: '20'/); assert.match(adapter, /S_chufariqi: departureDate/); assert.match(adapter, /S_fabudanwei: publicationUnit/); assert.match(adapter, /exactLifecycleRowIdentifierInText\(rowEvidenceText\(row\), identifier\)/); assert.match(adapter, /function arrangementExpectedFields/); assert.match(adapter, /arrangementExpectedFields\(form, operation, targetWindow\.document\)/); assert.match(adapter, /candidateDocument\?\.querySelector/); assert.match(adapter, /async function arrangementCreateRequery/); assert.match(adapter, /arrangement_requery_guide_candidate_count/); assert.match(adapter, /await arrangementCreateRequery\(operation, targetWindow\)/); assert.match(adapter, /await arrangementCreateRequery\(operation, window\)/); }); test('arrangement clear reuses the five native actions but requires an exact current-run row target', async () => { const vehicle = arrangement('arrangement_vehicle', { supplier: { name: '测试车队', id: '386', resolved: true }, item: '测试车型', start_date: '2026-09-22', end_date: '2026-09-24', quantity: 1 }); vehicle.data.arrangement.mode = 'clear'; vehicle.data.arrangement.target = { slot_index: 0, row_id: '88436' }; assert.equal(plans.validateOperation(vehicle).ok, true, plans.validateOperation(vehicle).blockers.join('; ')); assert.deepEqual(validateStandardOperation(vehicle), []); const missingRow = structuredClone(vehicle); delete missingRow.data.arrangement.target.row_id; assert.match(plans.validateOperation(missingRow).blockers.join('\n'), /row_id/); assert.ok(validateStandardOperation(missingRow).some((error) => /row_id/.test(error))); const guide = arrangement('arrangement_guide', { resource: { name: '测试导游', id: '11', resolved: true } }); guide.data.arrangement.mode = 'clear'; guide.data.arrangement.target = { slot_index: 0 }; assert.equal(plans.validateOperation(guide).ok, true, plans.validateOperation(guide).blockers.join('; ')); const other = arrangement('arrangement_other', { supplier: { name: '测试供应商', id: '1347', resolved: true }, item: '测试备案成本', date: '2026-09-22', quantity: 1, filing: { number: '99092284', date: '2026-09-22', entry_port: '磨丁', exit_port: '万象' } }); other.data.arrangement.mode = 'clear'; other.data.arrangement.target = { slot_index: 0, row_id: '88439' }; other.data.arrangement.preserve_filing = true; assert.equal(plans.validateOperation(other).ok, true, plans.validateOperation(other).blockers.join('; ')); const unsafeOther = structuredClone(other); unsafeOther.data.arrangement.preserve_filing = false; assert.match(plans.validateOperation(unsafeOther).blockers.join('\n'), /preserve_filing/); assert.ok(validateStandardOperation(unsafeOther).some((error) => /preserve_filing/.test(error))); const hotelUpdate = arrangement('arrangement_hotel', { resource: { name: '测试酒店', id: '126', resolved: true }, room_type: '标准间', start_date: '2026-09-22', end_date: '2026-09-29', room_count: 8 }); hotelUpdate.data.arrangement.mode = 'update'; hotelUpdate.data.arrangement.target = { slot_index: 0, row_id: '88437' }; hotelUpdate.data.arrangement.changes = { end_date: '2026-09-30', room_count: 10 }; assert.equal(plans.validateOperation(hotelUpdate).ok, true, plans.validateOperation(hotelUpdate).blockers.join('; ')); assert.deepEqual(validateStandardOperation(hotelUpdate), []); const unsupportedUpdate = structuredClone(hotelUpdate); unsupportedUpdate.data.arrangement.changes = { status: '已确认' }; assert.match(plans.validateOperation(unsupportedUpdate).blockers.join('\n'), /尚未实测开放安排变更字段:status/); assert.ok(validateStandardOperation(unsupportedUpdate).some((error) => /尚未实测开放安排变更字段:status/.test(error))); for (const [field, value] of [['start_date', '2026-09-23'], ['room_type', '大床房'], ['remark', 'TEST-202609 changed']]) { const unsupportedHotelField = structuredClone(hotelUpdate); unsupportedHotelField.data.arrangement.changes = { [field]: value }; assert.match(plans.validateOperation(unsupportedHotelField).blockers.join('\n'), new RegExp(`尚未实测开放安排变更字段:${field}`)); assert.ok(validateStandardOperation(unsupportedHotelField).some((error) => error.includes(`尚未实测开放安排变更字段:${field}`))); } const vehicleUpdate = arrangement('arrangement_vehicle', { supplier: { name: '测试车队', id: '386', resolved: true }, item: '测试车型', start_date: '2026-09-22', end_date: '2026-09-24', quantity: 1 }); vehicleUpdate.data.arrangement.mode = 'update'; vehicleUpdate.data.arrangement.target = { slot_index: 0, row_id: '88436' }; vehicleUpdate.data.arrangement.changes = { quantity: 2 }; assert.match(plans.validateOperation(vehicleUpdate).blockers.join('\n'), /arrangement_vehicle 尚未实测开放 update/); assert.ok(validateStandardOperation(vehicleUpdate).some((error) => /arrangement_vehicle 尚未实测开放 update/.test(error))); const adapter = await readFile(new URL('../chrome-extension/ltjt-order-assistant/lifecycle-adapters.js', import.meta.url), 'utf8'); assert.match(adapter, /`arrangement_\$\{mode\}_target_mismatch:\$\{name\}`/); assert.match(adapter, /async function applyArrangementUpdate/); assert.match(adapter, /arrangement_update_no_effect/); assert.match(adapter, /const alreadyCurrent = control && \(date/); assert.match(adapter, /if \(alreadyCurrent\) return/); assert.match(adapter, /if \(current\.control && controlMatches\(current\.control, value\)\) return/); assert.match(adapter, /function controlMatchesIncludingEmpty\(control, expected\)/); assert.match(adapter, /controlMatchesIncludingEmpty\(getControl\(form, 'beizhu0'\), arrangement\.remark\)/); assert.match(adapter, /controlMatchesIncludingEmpty\(getControl\(form, 'beizhu0'\), finalState\.remark\)/); assert.match(adapter, /arrangement_update_remark_restore_failed/); assert.match(adapter, /Native update_CZR\(\) clears the existing remark/); assert.match(adapter, /function dateControlMatches\(control, expected\)/); assert.match(adapter, /dateYyyyMD\(existing\) === dateYyyyMD\(arrangement\.filing\[key\]\)/); assert.match(adapter, /dateYyyyMD\(current\) === dateYyyyMD\(arrangement\.filing\[key\]\)/); assert.match(adapter, /dateYyyyMD\(actual\) === dateYyyyMD\(arrangement\.filing\[key\]\)/); assert.match(adapter, /arrangementClearRequery/); assert.match(adapter, /arrangement_filing_changed_during_clear/); }); test('update actions expose validated writes and preserve unvalidated fields for manual review', () => { assert.equal(plans.OPERATION_DEFINITIONS.order_update_independent.label, '独立团信息修改'); assert.equal(plans.OPERATION_DEFINITIONS.order_update_independent.capabilityState, 'parsed_wide_execute_validated_subset'); assert.equal(plans.OPERATION_DEFINITIONS.arrangement_hotel.capabilityState, 'validated_unassigned_create_update_clear_end_date_room_count'); const independent = base('order_update_independent', { existing_refs: { kind: 'independent_order', order_no: 'LW-TEST', tid: '14379', ddid: '14447', arrangement_history: false, departure_date: '2026-09-20', owner_account: 'AI_TEST_ACCOUNT', marker: 'TEST-202609' }, updates: { actions: [ { target: 'twin_room_count', operation: 'set', value: 9 }, { target: 'rooms.SGL', operation: 'set', value: 3 }, { target: 'rooms.TWN', operation: 'set', value: 2 }, { target: 'pax.adult', operation: 'set', value: 8 }, { target: 'pax.child_bed', operation: 'set', value: 1 }, { target: 'pax.child_no_bed', operation: 'set', value: 1 }, { target: 'pax.leader', operation: 'set', value: 1 } ] } }); const plan = base('order_update_shared_plan', { existing_refs: { kind: 'shared_plan', plan_no: 'LW-260922A-A', tid: '14384', departure_date: '2026-09-22', owner_account: 'AI_TEST_ACCOUNT', marker: 'TEST-202609' }, updates: { actions: [{ target: 'planned_capacity', operation: 'set', value: 17 }] } }); const child = base('order_update_shared_child', { existing_refs: { kind: 'shared_child_order', child_order_no: 'D14452', ddid: '14452', tid: '14384', departure_date: '2026-09-22', owner_account: 'AI_TEST_ACCOUNT', marker: 'TEST-202609' }, updates: { actions: [{ target: 'lodging_note', operation: 'append', value: 'TEST-202609 MOD-C09' }] } }); for (const operation of [independent, plan, child]) { assert.equal(plans.validateOperation(operation).ok, true, plans.validateOperation(operation).blockers.join('; ')); assert.deepEqual(validateStandardOperation(operation), []); } const adapter = readFileSync(new URL('../chrome-extension/ltjt-order-assistant/lifecycle-adapters.js', import.meta.url), 'utf8'); assert.match(adapter, /'rooms\.SGL': 'frenshu0'/); assert.match(adapter, /'rooms\.TWN': 'frenshu1'/); assert.match(adapter, /ownerWindow\?\.sum_fang/); assert.match(adapter, /frenshu6/); assert.match(adapter, /independent_room_total_mismatch/); assert.match(adapter, /'pax\.adult': 'darenshu'/); assert.match(adapter, /'pax\.child_bed': 'xiaorenshu'/); assert.match(adapter, /'pax\.child_no_bed': 'ertrenshu'/); assert.match(adapter, /'pax\.leader': 'quanrenshu'/); assert.match(adapter, /independent_room_count_invalid/); assert.doesNotMatch(plans.validateOperation(independent).warnings.join('\n'), /rooms\.SGL|rooms\.TWN/); assert.doesNotMatch(plans.validateOperation(independent).warnings.join('\n'), /pax\.adult|pax\.child_bed|pax\.child_no_bed|pax\.leader/); const negativeRoomCount = structuredClone(independent); negativeRoomCount.data.updates.actions = [{ target: 'rooms.SGL', operation: 'set', value: -1 }]; assert.match(plans.validateOperation(negativeRoomCount).blockers.join('\n'), /大于等于 0|rooms\.SGL/); const negativeChildNoBed = structuredClone(independent); negativeChildNoBed.data.updates.actions = [{ target: 'pax.child_no_bed', operation: 'set', value: -1 }]; assert.match(plans.validateOperation(negativeChildNoBed).blockers.join('\n'), /大于等于 0|pax\.child_no_bed/); assert.ok(validateStandardOperation(negativeChildNoBed).some((error) => /大于等于 0|pax\.child_no_bed/.test(error))); assert.ok(validateStandardOperation(negativeRoomCount).some((error) => /大于等于 0|rooms\.SGL/.test(error))); const unvalidatedIndependent = structuredClone(independent); unvalidatedIndependent.data.updates.actions[0] = { target: 'booking_note', operation: 'append', value: 'TEST-202609' }; const unvalidatedPlan = plans.validateOperation(unvalidatedIndependent); assert.equal(unvalidatedPlan.ok, true); assert.match(unvalidatedPlan.warnings.join('\n'), /人工复核/); assert.deepEqual(validateStandardOperation(unvalidatedIndependent), []); const nonPersistent = structuredClone(child); nonPersistent.data.updates.actions[0].target = 'xiadanbeizhu'; assert.equal(plans.validateOperation(nonPersistent).ok, true); const overlongLodging = structuredClone(child); overlongLodging.data.updates.actions[0].value = `TEST-202609 ${'X'.repeat(50)}`; assert.match(plans.validateOperation(overlongLodging).blockers.join('\n'), /不得超过 50/); assert.ok(validateStandardOperation(overlongLodging).some((error) => /不得超过 50/.test(error))); }); test('update adapters stop target-equals-current operations before native submit', async () => { const adapter = await readFile(new URL('../chrome-extension/ltjt-order-assistant/lifecycle-adapters.js', import.meta.url), 'utf8'); assert.match(adapter, /item\.operation === 'set' && currentControl && controlMatches\(currentControl, item\.value\)/); assert.match(adapter, /lifecycle_update_no_effect/); assert.match(adapter, /if \(applyBlockers\.length\) return outcome\('lifecycle_apply_blocked'/); assert.ok( adapter.indexOf("applyBlockers.push('lifecycle_update_no_effect')") < adapter.indexOf("if (applyBlockers.length) return outcome('lifecycle_apply_blocked'"), 'no-effect gate must run before native form submission' ); }); test('receivable fixture is restricted to the verified shared-child 0.01 add/clear contract', async () => { const operation = base('order_update_shared_child', { existing_refs: { kind: 'shared_child_order', child_order_no: 'D14452', ddid: '14452', tid: '14384', departure_date: '2026-09-22', owner_account: 'AI_TEST_ACCOUNT', marker: 'TEST-202609' }, receivable_fixture: { operation: 'add', name: '其他费用', quantity: 1, unit_price: 0.01, currency: 'CNY', remark: 'TEST-202609 R03', paid: false, settled: false, marker: 'TEST-202609' } }); assert.equal(plans.validateOperation(operation).ok, true, plans.validateOperation(operation).blockers.join('; ')); assert.deepEqual(validateStandardOperation(operation), []); assert.equal(Object.hasOwn(operation.data, 'updates'), false); const mixedBusinessUpdate = structuredClone(operation); mixedBusinessUpdate.data.updates = { actions: [{ target: 'lodging_note', operation: 'append', value: 'TEST-202609 unrelated mutation' }] }; assert.match(plans.validateOperation(mixedBusinessUpdate).blockers.join('\n'), /独立操作.*不得同时修改住宿备注/); assert.ok(validateStandardOperation(mixedBusinessUpdate).some((error) => /独立操作.*不得同时修改住宿备注/.test(error))); const clear = structuredClone(operation); clear.data.receivable_fixture = { ...clear.data.receivable_fixture, operation: 'clear', row_id: '31559' }; assert.equal(plans.validateOperation(clear).ok, true, plans.validateOperation(clear).blockers.join('; ')); assert.deepEqual(validateStandardOperation(clear), []); const unboundClear = structuredClone(clear); delete unboundClear.data.receivable_fixture.row_id; assert.match(plans.validateOperation(unboundClear).blockers.join('\n'), /row_id/); assert.ok(validateStandardOperation(unboundClear).some((error) => /row_id/.test(error))); const unsafeAmount = structuredClone(operation); unsafeAmount.data.receivable_fixture.unit_price = 1; assert.match(plans.validateOperation(unsafeAmount).blockers.join('\n'), /0\.01/); assert.ok(validateStandardOperation(unsafeAmount).some((error) => /0\.01/.test(error))); const wrongRoute = base('order_update_independent', { existing_refs: { kind: 'independent_order', order_no: 'LW-TEST', tid: '14379', ddid: '14447', arrangement_history: false, departure_date: '2026-09-20', owner_account: 'AI_TEST_ACCOUNT', marker: 'TEST-202609' }, updates: { actions: [{ target: 'booking_note', operation: 'append', value: 'TEST-202609' }] }, receivable_fixture: structuredClone(operation.data.receivable_fixture) }); assert.match(plans.validateOperation(wrongRoute).blockers.join('\n'), /order_update_shared_child/); assert.ok(validateStandardOperation(wrongRoute).some((error) => /order_update_shared_child/.test(error))); const none = structuredClone(operation); none.data.updates = { actions: [{ target: 'lodging_note', operation: 'append', value: 'TEST-202609 fixture none' }] }; none.data.receivable_fixture = { operation: 'none', marker: 'TEST-202609' }; assert.equal(plans.validateOperation(none).ok, true, plans.validateOperation(none).blockers.join('; ')); assert.deepEqual(validateStandardOperation(none), []); const adapter = await readFile(new URL('../chrome-extension/ltjt-order-assistant/lifecycle-adapters.js', import.meta.url), 'utf8'); assert.doesNotMatch(adapter, /receivable_fixture_live_adapter_not_yet_replayed/); assert.match(adapter, /function receivableFixtureEvidence\(form, operation\)/); assert.match(adapter, /ownerWindow\.GetYSHtml\('ys', String\(slotIndex\)\)/); assert.match(adapter, /function receivableRequeryEvidence\(form, operation\)/); }); test('cancel requires cleared receivables and arrangements', () => { const cancel = base('order_cancel', { existing_refs: { kind: 'shared_plan', parent_group_no: 'LW-260922A-A', tid: '14384', departure_date: '2026-09-22', owner_account: 'AI_TEST_ACCOUNT', marker: 'TEST-202609' }, transition: { mode: 'list', target_kind: 'shared_plan', from_status: '收客中', to_status: '已取消', receivables_cleared: true, arrangements_cleared: true, marker: 'TEST-202609' } }); assert.equal(plans.validateOperation(cancel).ok, true); const blocked = structuredClone(cancel); blocked.data.transition.arrangements_cleared = false; assert.match(plans.validateOperation(blocked).blockers.join('\n'), /零金额安排|arrangements_cleared/); }); test('list restore is disabled and shared-plan edit restore requires 收客中', () => { const listRestore = base('order_restore', { existing_refs: { kind: 'independent_order', order_no: 'LW-TEST', tid: '1', ddid: '2', departure_date: '2026-09-20', owner_account: 'AI_TEST_ACCOUNT', marker: 'TEST-202609' }, transition: { mode: 'list', target_kind: 'independent_order', from_status: '已取消', to_status: '预订', marker: 'TEST-202609' } }); assert.match(plans.validateOperation(listRestore).blockers.join('\n'), /cz=0|mode=edit/); const planRestore = base('order_restore', { existing_refs: { kind: 'shared_plan', parent_group_no: 'LW-260922A-A', tid: '14384', departure_date: '2026-09-22', owner_account: 'AI_TEST_ACCOUNT', marker: 'TEST-202609' }, transition: { mode: 'edit', target_kind: 'shared_plan', from_status: '已取消', to_status: '收客中', marker: 'TEST-202609' } }); assert.equal(plans.validateOperation(planRestore).ok, true); const wrong = structuredClone(planRestore); wrong.data.transition.to_status = '预订'; assert.match(plans.validateOperation(wrong).blockers.join('\n'), /收客中/); const noOpRestore = structuredClone(planRestore); noOpRestore.data.transition.from_status = '收客中'; assert.match(plans.validateOperation(noOpRestore).blockers.join('\n'), /当前无需执行状态迁移/); assert.ok(validateStandardOperation(noOpRestore).some((error) => /当前无需执行状态迁移/.test(error))); }); test('restore route reports no-op status before the ERP write gate', async () => { const [inpage, background] = await Promise.all([ readFile(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8'), readFile(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8') ]); assert.match(inpage, /lifecycle_transition_target_already_current/); assert.match(inpage, /current_status: currentStatus/); assert.match(inpage, /target_status: targetStatus/); assert.match(background, /function lifecycleRouteFailure\(operation, report = \{\}\)/); assert.match(background, /errorCode: 'lifecycle_transition_noop'/); assert.match(background, /无需\$\{actionLabel\}/); }); test('edit-mode transitions select one native radio option without mutating its value', async () => { const adapter = await readFile(new URL('../chrome-extension/ltjt-order-assistant/lifecycle-adapters.js', import.meta.url), 'utf8'); assert.match(adapter, /controls\.length > 1 && controls\.every\(\(item\) => item\.type === 'radio'\)/); assert.match(adapter, /const matches = controls\.filter\(\(item\) => normalize\(item\.value\) === expected\)/); assert.match(adapter, /item\.checked = next/); }); test('live write uses target September dates and does not depend on the current calendar month', () => { const operation = arrangement('arrangement_guide', { resource: { name: '测试导游', id: '11', resolved: true } }); operation.source.test_context.allow_live_write = true; operation.source.test_context.created_refs = [{ kind: operation.data.existing_refs.kind, identifier: operation.data.existing_refs.identifier, tid: operation.data.existing_refs.tid, ddid: operation.data.existing_refs.ddid, marker: 'TEST-202609' }]; const valid = plans.validateOperation(operation); assert.equal(valid.ok, true, valid.blockers.join('; ')); assert.equal(valid.noErpWrite, false); const outside = structuredClone(operation); outside.source.test_context.target_dates = ['2026-10-01']; outside.data.existing_refs.departure_date = '2026-10-01'; assert.match(plans.validateOperation(outside).blockers.join('\n'), /2026 年 9 月/); const wrongCreatedRef = structuredClone(operation); wrongCreatedRef.source.test_context.created_refs[0].tid = 'OTHER-TID'; assert.match(plans.validateOperation(wrongCreatedRef).blockers.join('\n'), /created_refs|精确命中/); assert.ok(validateStandardOperation(wrongCreatedRef).some((error) => /created_refs|精确命中/.test(error))); }); test('delete guard distinguishes independent, shared child, and shared parent routes', async () => { const deleteContext = { ...context, allowlist_id: 'ALLOW-001' }; const independentRefs = { kind: 'independent_order', identifier: 'LW-260920A-A', tid: '14379', ddid: '14447', departure_date: '2026-09-20', owner_account: 'AI_TEST_ACCOUNT', marker: 'TEST-202609' }; const childRefs = { kind: 'shared_child_order', child_order_no: 'D14452', tid: '14384', ddid: '14452', departure_date: '2026-09-22', owner_account: 'AI_TEST_ACCOUNT', marker: 'TEST-202609' }; const parentRefs = { kind: 'shared_plan', parent_group_no: 'LW-260922A-A', tid: '14384', departure_date: '2026-09-22', owner_account: 'AI_TEST_ACCOUNT', marker: 'TEST-202609' }; const operations = [ base('order_delete', { existing_refs: independentRefs, delete_guard: deleteGuard([{ ...independentRefs, marker: 'TEST-202609' }]) }, { context: deleteContext }), base('order_delete', { existing_refs: childRefs, delete_guard: deleteGuard([{ ...childRefs, marker: 'TEST-202609' }]) }, { context: deleteContext }), base('order_delete', { existing_refs: parentRefs, delete_guard: deleteGuard([{ ...parentRefs, marker: 'TEST-202609' }], { child_refs_deleted: true, delete_sequence: 'child_before_parent' }) }, { context: deleteContext }) ]; for (const operation of operations) assert.equal(plans.validateOperation(operation).ok, true, plans.validateOperation(operation).blockers.join('; ')); const mapping = JSON.parse(await readFile(new URL('../mappings/lifecycle.mapping.json', import.meta.url), 'utf8')); assert.equal(mapping.current_extension_version, '0.5.165'); assert.equal( mapping.updates.order_update_independent.field_mapped_pending_live_validation['pax.child_no_bed'], 'ertrenshu' ); assert.deepEqual(mapping.transitions.order_cancel.resolved_route_policy, { independent_order: 'list', shared_plan: 'list', shared_child_order: 'edit', current_status_gate: 'exact list row or exact edit form must expose exactly one allowed current status; missing, conflicting, or target-equals-current status blocks before write' }); assert.match(mapping.transitions.order_cancel.edit_route_identity, /form oldtdid\/tdID\/tid/); assert.equal(mapping.operation_timing.schema_version, 'ltjt-operation-timing-v1'); assert.equal( mapping.arrangements.common.business_preflight_rules.arrangement_hotel_shared_plan_child_required.error_code, 'erp_shared_plan_child_required_for_hotel' ); assert.equal(mapping.delete.routes.independent_order.action, 'DelRecord'); assert.equal(mapping.delete.routes.shared_child_order.action, 'Del_order'); assert.equal(mapping.delete.routes.shared_plan.action, 'DelRecord'); assert.ok(mapping.confirmation_export.artifact_result.includes('scheduled=false')); assert.equal( mapping.confirmation_export.sources['visitor-list'].shared_plan, '/System/Business/orders_Visitor.asp?tid={tid}' ); assert.equal( mapping.confirmation_export.sources['visitor-list'].shared_child_order, '/System/Business/orders_Visitor.asp?did={ddid}&tid={tid}' ); const unsafeParent = structuredClone(operations[2]); unsafeParent.data.delete_guard.child_refs_deleted = false; assert.match(plans.validateOperation(unsafeParent).blockers.join('\n'), /child_refs_deleted/); const partialChild = structuredClone(operations[1]); partialChild.data.delete_guard.created_refs[0].ddid = 'WRONG-DDID'; assert.match(plans.validateOperation(partialChild).blockers.join('\n'), /精确引用/); }); test('lifecycle ownership, transition boundary, and duplicate update rules fail closed', () => { const owned = arrangement('arrangement_guide', { resource: { name: '测试导游', id: '11', resolved: true } }); const wrongOwner = structuredClone(owned); wrongOwner.data.existing_refs.owner_account = 'OTHER_ACCOUNT'; assert.match(plans.validateOperation(wrongOwner).blockers.join('\n'), /owner_account/); const update = base('order_update_shared_child', { existing_refs: { kind: 'shared_child_order', child_order_no: 'D14452', ddid: '14452', tid: '14384', departure_date: '2026-09-22', owner_account: 'AI_TEST_ACCOUNT', marker: 'TEST-202609' }, updates: { actions: [ { target: 'lodging_note', operation: 'append', value: 'TEST-202609 A' }, { target: 'lodging_note', operation: 'append', value: 'TEST-202609 B' } ] } }); assert.match(plans.validateOperation(update).blockers.join('\n'), /目标不能重复/); const restore = base('order_restore', { existing_refs: { kind: 'shared_plan', parent_group_no: 'LW-260922A-A', tid: '14384', departure_date: '2026-09-22', owner_account: 'AI_TEST_ACCOUNT', marker: 'TEST-202609' }, transition: { mode: 'edit', target_kind: 'shared_plan', to_status: '收客中', marker: 'TEST-202609' } }); assert.match(plans.validateOperation(restore).blockers.join('\n'), /from_status/); }); test('external parser validator accepts recognized lifecycle targets for later capability review', () => { const valid = base('order_update_shared_child', { existing_refs: { kind: 'shared_child_order', child_order_no: 'D14452', ddid: '14452', tid: '14384', departure_date: '2026-09-22', owner_account: 'AI_TEST_ACCOUNT', marker: 'TEST-202609' }, updates: { actions: [{ target: 'lodging_note', operation: 'append', value: 'TEST-202609 MOD-C09' }] } }); assert.deepEqual(validateStandardOperation(valid), []); const invalid = structuredClone(valid); invalid.data.updates.actions[0].target = 'xiadanbeizhu'; assert.deepEqual(validateStandardOperation(invalid), []); }); test('planner and external validator share the dedicated multi-date split-order probe gate', () => { const dates = ['2026-09-20', '2026-09-22']; const operation = base('shared_plan_create', { product: { name: '老挝广东8D', source_region: '广东' }, departure_dates: dates, planned_capacity: 30, room_counts: { TWN: 8 }, split_order: { customer: { name: '广东测试客户', source_region: '广东' }, passenger_counts: { adult: 15, leader: 1 } } }, { context: { ...context, target_dates: dates, phase: 'shared_batch_split_order_probe' } }); assert.equal(plans.validateOperation(operation).ok, true, plans.validateOperation(operation).blockers.join('; ')); assert.deepEqual(validateStandardOperation(operation), []); const ordinary = structuredClone(operation); delete ordinary.source.test_context; assert.equal(plans.validateOperation(ordinary).ok, false); assert.ok(validateStandardOperation(ordinary).some((error) => /事实探针|逐日期真实验证/.test(error))); }); test('platform runner independently blocks a tampered multi-date split-order probe before browser access', async () => { const dates = ['2026-09-02', '2026-09-15', '2026-09-28']; const runId = 'sept-2026-lifecycle-plugin-e2e3-01'; const operation = base('shared_plan_create', { product: { name: '老挝行程--广东 8D7N', keyword: '老挝行程--广东 8D7N' }, departure_dates: dates, recurrence: { start_date: dates[0], end_date: dates.at(-1), pattern: 'specified_dates' }, planned_capacity: 16, room_counts: { TWN: 8 }, groups_per_date: 1, order_number: { suffix: 'TEST-202609-E2E3' }, split_order: { customer: { name: 'LW衡阳国旅云南分社(广东市场)', keyword: 'LW衡阳国旅云南分社(广东市场)' }, passenger_counts: { adult: 15, leader: 1, expected_total: 16 } } }, { context: { ...context, run_id: runId, target_dates: dates, phase: 'shared_batch_split_order_probe' } }); operation.source.test_context.phase = 'plugin_replay'; const temporaryRoot = await mkdtemp(join(tmpdir(), 'ltjt-runner-safety-')); const fixturePath = join(temporaryRoot, 'tampered-c04.json'); try { await writeFile(fixturePath, JSON.stringify(operation), 'utf8'); const runnerPath = new URL('./run_lifecycle_fixture_via_platform.mjs', import.meta.url); const result = spawnSync(process.execPath, [ fileURLToPath(runnerPath), '--fixture', fixturePath, '--expected-run-id', runId, '--cdp', '/bin/false' ], { encoding: 'utf8' }); assert.notEqual(result.status, 0); assert.match(result.stderr, /shared_batch_probe_phase_mismatch/); } finally { await rm(temporaryRoot, { recursive: true, force: true }); } }); test('confirmation export is backend-attached and route-aware for child and narrow shared-parent visitor sources', () => { const types = [ 'xingyou-confirm', 'liantai-confirm', 'job-order', 'visitor-list', 'guide-confirm', 'hotel-preorder', 'transport-preorder', 'filing-current', 'filing-history', 'pickup-sign' ]; const operation = { action: 'confirmation_export', order_nature: 'formal', submit_mode: 'dry_run', data: { existing_refs: { kind: 'shared_child_order', identifier: 'D14452', parent_group_no: 'LW-260922A-A', child_order_no: 'D14452', ddid: '14452', tid: '14384', resolved: true, resolution_source: 'erp_unique_match' }, export_types: types, visitor_name: '测试领队' } }; const result = plans.validateOperation(operation); assert.equal(result.ok, true, result.blockers.join('; ')); const plan = plans.buildConfirmationExportPlan(operation, {}); assert.equal(plan.target_kind, 'shared_child_order'); assert.equal(plan.identifier, 'D14452'); assert.equal(plan.no_erp_write, true); assert.equal(plan.delivery_scope, 'backend_task_attachment'); assert.equal(plan.scheduled, false); assert.ok(plan.artifacts.every((artifact) => artifact.path)); assert.ok(plan.artifacts.every((artifact) => artifact.scheduled === false)); const parent = structuredClone(operation); parent.data.existing_refs = { kind: 'shared_plan', identifier: 'LW-260922A-A', parent_group_no: 'LW-260922A-A', plan_no: 'LW-260922A-A', tid: '14384', resolved: true, resolution_source: 'erp_unique_match' }; delete parent.data.export_types; delete parent.data.visitor_name; parent.data.confirmation = { type: 'visitor-list' }; assert.equal(plans.validateOperation(parent).ok, true, plans.validateOperation(parent).blockers.join('; ')); assert.deepEqual(validateStandardOperation(parent), []); const parentPlan = plans.buildConfirmationExportPlan(parent, {}); assert.deepEqual(parentPlan.artifacts[0].params, { tid: '14384' }); const parentWrongType = structuredClone(parent); parentWrongType.data.confirmation = { type: 'guide-confirm' }; assert.match(plans.validateOperation(parentWrongType).blockers.join('\n'), /散拼母团.*整团游客信息/); assert.ok(validateStandardOperation(parentWrongType).length > 0); const missingParent = structuredClone(operation); delete missingParent.data.existing_refs.parent_group_no; assert.match(plans.validateOperation(missingParent).blockers.join('\n'), /母团号\/计划号/); assert.ok(validateStandardOperation(missingParent).some((error) => /母团号\/计划号/.test(error))); }); test('confirmation export keeps shared-child source lookup independent of lifecycle status', async () => { const inpage = await readFile(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8'); const start = inpage.indexOf('async function exportConfirmationSources'); const end = inpage.indexOf('\n function getOrderForm', start); assert.ok(start >= 0 && end > start, 'exportConfirmationSources source boundary must remain discoverable'); const exportSource = inpage.slice(start, end); assert.doesNotMatch(exportSource, /S_zhuangtai\s*=\s*['"]收客中['"]/); assert.match(exportSource, /S_tuanxuhao: listIdentifier/); assert.match(exportSource, /uniqueLeafRowForIdentifier\(current, listIdentifier\)/); assert.match(exportSource, /uniqueLeafRowForDdid\(current, suppliedDdid\)/); assert.match(exportSource, /childLinkMismatch/); assert.match(exportSource, /targetKind === 'shared_plan'/); assert.match(exportSource, /orders_Visitor\.asp\?tid=/); }); test('unified lifecycle evidence requires explicit response and matching requery', () => { const empty = lifecycleResult.lifecycleResult('lifecycle_completed'); assert.equal(lifecycleResult.isVerifiedResult(empty), false); const verified = lifecycleResult.lifecycleResult('lifecycle_completed', { server_response: { http_status: 200, completed: true }, requery: { matched: true } }); assert.equal(lifecycleResult.isVerifiedResult(verified), true); const ambiguous = lifecycleResult.lifecycleResult('lifecycle_completed', { server_response: { http_status: 200, completed: false }, requery: { matched: true } }); assert.equal(lifecycleResult.isVerifiedResult(ambiguous), false); const exportResult = { status: 'export_source_completed', no_erp_write: true, write_attempted: false, server_response: { http_status: 200, completed: true }, requery: { matched: true, hashes_frozen: true }, artifacts: [{ ok: true, http_status: 200, bytes: 128, content_type: 'application/msword', sha256: 'a'.repeat(64), login_timeout: false, permission_error: false, downloaded: false, converted: false, scheduled: false, sent: false }], downloaded: false, converted: false, scheduled: false, sent: false, manual_review_required: false }; assert.equal(lifecycleResult.isVerifiedExportResult(exportResult), true); exportResult.artifacts[0].downloaded = true; assert.equal(lifecycleResult.isVerifiedExportResult(exportResult), false); exportResult.artifacts[0].downloaded = false; exportResult.artifacts[0].scheduled = true; assert.equal(lifecycleResult.isVerifiedExportResult(exportResult), false); assert.deepEqual(Object.keys(empty).sort(), [ 'before_snapshot', 'manual_review_required', 'native_request', 'preflight', 'requery', 'resolved_refs', 'server_response', 'side_effects', 'status' ]); }); test('schema and browser adapters contain the v2 safety fields and no confirm override', async () => { const schema = JSON.parse(await readFile(new URL('../schemas/standard_system_operation.schema.json', import.meta.url), 'utf8')); const adapter = await readFile(new URL('../chrome-extension/ltjt-order-assistant/lifecycle-adapters.js', import.meta.url), 'utf8'); const inpage = await readFile(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8'); const teamBatchInpage = await readFile(new URL('../chrome-extension/ltjt-order-assistant/team-batch-inpage.js', import.meta.url), 'utf8'); const background = await readFile(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8'); const extensionManifest = JSON.parse(await readFile(new URL('../chrome-extension/ltjt-order-assistant/manifest.json', import.meta.url), 'utf8')); const platformApp = await readFile(new URL('../LianSyn-platform/app.js', import.meta.url), 'utf8'); const replayRunner = await readFile(new URL('./run_lifecycle_fixture_via_platform.mjs', import.meta.url), 'utf8'); const splitProbeRecorder = await readFile(new URL('./record_split_probe_result.mjs', import.meta.url), 'utf8'); assert.deepEqual(schema.$defs.existingRefs.properties.kind.enum, ['independent_order', 'shared_plan', 'shared_child_order']); assert.equal(schema.$defs.existingRefs.properties.resolution_source.enum[0], 'erp_unique_match'); assert.equal(schema.$defs.fieldResolution.properties.stage.const, 'erp_readonly'); assert.equal(schema.$defs.deleteGuard.properties.export_evidence_frozen.const, true); assert.match(schema.$defs.deleteGuard.description, /independently prove child_count=0/); assert.ok(schema.$defs.transition.properties.to_status.enum.includes('收客中')); assert.equal(schema.$defs.canonicalPassengerRow.properties.证件类型.const, '护照'); assert.doesNotMatch(adapter, /window\.confirm\s*=|targetWindow\.confirm\s*=/); assert.match(adapter, /nativeAction: 'Del_order'/); assert.match(adapter, /nativeAction: 'DelRecord'/); assert.match(adapter, /list_restore_disabled/); assert.match(adapter, /passenger_native_projection_mismatch/); assert.match(adapter, /exactCreatedReferenceMatch|createdIdentifier !== targetIdentifier/); assert.match(adapter, /FORM_READINESS_MINIMUMS/); assert.match(adapter, /ARRANGEMENT_CLEAR_READINESS_MINIMUMS/); assert.match(adapter, /arrangement_hotel: 150/); assert.match(adapter, /DoInfo_daoyou: 12/); assert.match(adapter, /arrangement_guide: 12/); assert.match(adapter, /rect\.right > 0/); assert.match(adapter, /rect\.left < targetWindow\.innerWidth/); assert.match(adapter, /ownershipProof = accountRequiredOnSubpage/); assert.match(adapter, /ownershipProof\?\.marker_matched === true/); assert.match(adapter, /!ownershipProof \|\| ownershipProof\.matched === true/); assert.match(adapter, /async function sharedChildDetailProof/); assert.match(adapter, /const listIdentifier = sharedChild \? searchIdentifier : identifier/); assert.match(adapter, /matched: listMatched && detail\.matched/); assert.match(adapter, /shared_child_detail_status_mismatch/); assert.match(adapter, /const absenceProven = !form && !markerPresent/); assert.match(adapter, /NON_SERIALIZABLE_CONTROL_TYPES = new Set\(\['submit', 'button', 'image', 'reset', 'file'\]\)/); assert.match(adapter, /ownerWindow\.jQuery\(form\)\.serialize\(\)/); assert.match(adapter, /return `Act=\$\{action\}\$\{serialized \? `&\$\{serialized\}` : ''\}`/); assert.match(adapter, /nativePost\(contract, nativeFormBody\(contract, form\)\)/); assert.match(adapter, /shared_child_lodging_note_length_exceeds_\$\{SHARED_CHILD_LODGING_NOTE_MAX_LENGTH\}/); assert.match(adapter, /const formDdidRequired = Boolean\(expectedDdid\) && !ARRANGEMENT_ACTIONS\.has\(operation\.action\)/); assert.match(adapter, /const nativeDataRows = rows\.filter/); assert.match(adapter, /input\[name="xuanzeid"\], input#xuanzeid/); assert.match(adapter, /nativeDataRows\.length \? nativeDataRows/); assert.match(adapter, /clear_generated_zero/); assert.match(adapter, /receivableRowMatchesGeneratedZero/); assert.match(adapter, /const businessChecksMatched = checks\.length > 0/); assert.match(adapter, /arrangement_slot_occupied/); assert.match(adapter, /arrangement_slot_financial_or_audit_state/); assert.match(inpage, /uniqueLeafRowForDdid/); assert.match(inpage, /if \(kind === 'shared_child_order'\) searchValues\.S_zhuangtai = '收客中'/); assert.match(inpage, /else if \(transitionFromStatus\) searchValues\.S_zhuangtai = transitionFromStatus/); assert.match(inpage, /\.Menu2\[setval\]/); assert.match(inpage, /native_status_filter_candidate_count/); assert.match(inpage, /native_list_search_not_ready/); assert.match(inpage, /native_status_filter_not_applied/); assert.match(inpage, /scopeDocument\.defaultView\.AjaxLoadData\(true\)/); assert.match(inpage, /priorEditDocuments\.has\(candidate\)/); assert.doesNotMatch(inpage, /priorEditUrls\.has\(candidate\.location\.href\)/); assert.match(inpage, /priorFormDocuments\.has\(candidate\)/); assert.doesNotMatch(inpage, /priorFormUrls\.has\(candidate\.location\.href\)/); assert.match(inpage, /arrangement_prior_exact_dialog_not_closeable/); assert.match(inpage, /dialogApi\.close\(\)/); assert.match(inpage, /arrangement_conflicting_route_dialog_count/); assert.match(inpage, /lifecycle_prior_exact_dialog_scope_mismatch/); assert.match(inpage, /const priorFormValueText = Array\.from\(priorForm\?\.elements \|\| \[\]\)/); assert.match(inpage, /lifecycle_prior_exact_dialog_not_closeable/); assert.match(inpage, /lifecycle_prior_exact_dialog_close_timeout/); assert.match(inpage, /lifecycle_conflicting_edit_dialog_count/); assert.match(inpage, /source_parent_ref_blocked/); assert.match(inpage, /const stableSourceDocument = await waitForCondition/); assert.match(inpage, /currentParent\.count >= 1 && currentRow\.count === 1/); assert.match(inpage, /targetKind === 'shared_child_order'\s*\? uniqueLeafRowForDdid\(listDocument, suppliedDdid\)/); assert.match(inpage, /exactSharedChildLinkText\(link\.text, identifier\)/); assert.match(inpage, /childLinkMismatch/); assert.match(inpage, /supplied export refs do not match the unique ERP list row/); assert.match(inpage, /delivery_scope: 'backend_task_attachment_pending'/); assert.match(inpage, /content_base64: base64FromArrayBuffer\(buffer\)/); assert.match(inpage, /backend_stored: false/); assert.match(inpage, /scheduled: false/); assert.match(inpage, /LTJTPlanReconciliation\?\.extractDdidFromAction/); assert.match(inpage, /function splitOrderProbeContext/); assert.match(inpage, /extractSharedChildRefs\?\.\(links, tid\)/); assert.match(inpage, /responseGroupNumbers\.length === dates\.length/); assert.match(inpage, /facts_status === 'all_dates_facts_determined'/); assert.match(inpage, /parent_groups_and_split_order_facts_determined/); assert.match(inpage, /manual_review_required: probeNeedsReview \|\| !verified/); assert.match(inpage, /split_order_probe_target_dates_mismatch/); assert.match(background, /lifecycleResult\?\.isVerifiedResult\?\.\(live\) === true/); assert.match(background, /isVerifiedExportResult/); assert.match(background, /if \(report\.manual_review_required === true\) return 'execution_uncertain'/); assert.match(background, /async function readErpSessionStatus/); assert.match(background, /message\?\.type === 'LTJT_ERP_SESSION_STATUS'/); assert.match(background, /account_matched: Boolean\(expectedAccount\) && bodyText\.includes\(expectedAccount\)/); assert.doesNotMatch(background, /bodyText\.includes\('测试ai员工账号'\)/); assert.match(background, /lifecycle_preflight_ambiguous/); assert.match(background, /lifecycle_frame_discovery_retry_exhausted/); assert.match(background, /lifecycleFrameDiscoveryOnly/); assert.match(background, /operationTiming\.advance/); assert.match(background, /action_roundtrip\.\$\{actionCode\}/); assert.match(background, /network_wait\.\$\{actionCode\}/); assert.match(inpage, /rule: 'unique_source_region_tokens'/); assert.match(teamBatchInpage, /rule: 'unique_source_region_tokens'/); assert.match(inpage, /`ys_danwei\$\{index\}`, resolvedCustomerName/); assert.match(inpage, /`ys_danweiid\$\{index\}`, resolvedCustomerId/); assert.doesNotMatch(inpage, /product_customer_source_region|sourceRegionCheck|source_reference/); assert.doesNotMatch(teamBatchInpage, /product_customer_source_region|sourceRegionCheck/); assert.equal(extensionManifest.version, '0.5.165'); assert.match(inpage, /version: '0\.5\.165'/); assert.match(teamBatchInpage, /version: '0\.5\.165'/); assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.165'/); assert.match(inpage, /function strictIsoDate\(value\)/); assert.match(inpage, /const startDate = strictIsoDate\(controlCanonicalValue\(form, 'riqi0'\)\)/); assert.match(inpage, /const endDate = strictIsoDate\(controlCanonicalValue\(form, 'riqis0'\)\)/); assert.match(inpage, /unique_source_region_tokens/); assert.match(inpage, /resolved\.action === 'arrangement_hotel' \? '未安排' : '未确认'/); assert.match(inpage, /searchColumns: action === 'arrangement_vehicle' \? \[1, 2, 3\] : \[\]/); assert.match(inpage, /const HOTEL_ROOM_TYPE_SUFFIX/); assert.match(inpage, /function compactHotelLookupValue/); assert.match(inpage, /function hotelSearchTokens/); assert.match(inpage, /first_erp_dropdown_row/); assert.match(adapter, /SetValToObj/); assert.match(adapter, /arrangement_hotel_native_selectbox/); assert.match(inpage, /const transport = action === 'arrangement_transport'/); assert.match(inpage, /const other = action === 'arrangement_other'/); assert.match(adapter, /arrangement_transport_native_selectbox/); assert.match(adapter, /arrangement_other_native_selectbox/); assert.match(adapter, /Do not write shuoming0 directly from parser text/); assert.match(inpage, /Never require a parser-provided room_type/); { const sandbox = { window: {}, URL, URLSearchParams }; vm.runInNewContext(inpage, sandbox); const inspectCandidate = sandbox.window.LTJTOrderAssistant.inspectArrangementLookupCandidate; assert.equal(typeof inspectCandidate, 'function'); const hotelRows = [ ['947', '万荣龙吟阁-私屋', '【TWN H】'], ['948', '万荣龙吟阁-私屋', '【高级房大床】'], ['949', '另一家酒店', '【TWN】'] ]; const nonContiguousHotelKeyword = inspectCandidate( hotelRows, { name: '万荣 龙吟阁', keyword: '万荣-龙吟阁' }, { hotel: true, searchColumns: [1] } ); assert.equal(nonContiguousHotelKeyword.match_count, 2); assert.equal(nonContiguousHotelKeyword.candidate.id, '947'); assert.equal(nonContiguousHotelKeyword.selection, 'first_erp_dropdown_row'); const legacyCompositeKeyword = inspectCandidate( hotelRows, { name: '万荣龙吟阁TWN', keyword: 'TWN' }, { hotel: true, searchColumns: [1, 2] } ); assert.equal(legacyCompositeKeyword.match_count, 1); assert.equal(legacyCompositeKeyword.candidate.id, '947'); assert.equal(legacyCompositeKeyword.strategy, 'unique_tokenized'); assert.equal(legacyCompositeKeyword.selection, 'unique'); const splitByLinkedLabel = inspectCandidate( [ ['947', '万荣龙吟阁私屋', '【TWN GS全房损半价】'], ['948', '万荣龙吟阁私屋', '【高级房大床 含早|SupDBL BF】'] ], { name: '万荣龙吟阁TWN', keyword: '万荣 龙吟阁 TWN' }, { hotel: true } ); assert.equal(splitByLinkedLabel.match_count, 1); assert.equal(splitByLinkedLabel.candidate.id, '947'); assert.equal(splitByLinkedLabel.strategy, 'unique_tokenized'); const legacyUniqueHotelKeyword = inspectCandidate( [['947', '万荣龙吟阁-私屋', '【TWN H】']], { name: '万荣龙吟阁TWN', keyword: 'TWN' }, { hotel: true, searchColumns: [1, 2] } ); assert.equal(legacyUniqueHotelKeyword.match_count, 1); assert.equal(legacyUniqueHotelKeyword.candidate.id, '947'); const uniqueHotelKeyword = inspectCandidate( [['947', '万荣龙吟阁-私屋', '【TWN H】'], ['949', '另一家酒店', '【TWN】']], { name: '万荣龙吟阁', keyword: '万荣 龙吟阁' }, { hotel: true, searchColumns: [1] } ); assert.equal(uniqueHotelKeyword.match_count, 1); assert.equal(uniqueHotelKeyword.candidate.id, '947'); assert.equal(uniqueHotelKeyword.candidate.item, '【TWN H】'); const transportRows = [ ['1220', '老挝航空', '昆明至万象团队机票', '公司现付'], ['1221', '老挝航空', '昆明至万象散客票', '公司现付'], ['1222', '另一家航空', '昆明至万象团队机票', '公司现付'] ]; const transportLookup = inspectCandidate( transportRows, { name: '老挝航空', keyword: '老挝 航空' }, { transport: true } ); assert.equal(transportLookup.match_count, 2); assert.equal(transportLookup.candidate.id, '1220'); assert.equal(transportLookup.candidate.item, '昆明至万象团队机票'); assert.equal(transportLookup.selection, 'first_erp_dropdown_row'); const otherLookup = inspectCandidate( [ ['1347', '美女', '备案服务项目', '公司现付'], ['1348', '美女', '另一备案项目', '公司现付'] ], { name: '美 女', keyword: '美女' }, { other: true } ); assert.equal(otherLookup.match_count, 2); assert.equal(otherLookup.candidate.id, '1347'); assert.equal(otherLookup.candidate.item, '备案服务项目'); assert.equal(otherLookup.selection, 'first_erp_dropdown_row'); } assert.match(adapter, /child_status_propagation_matched/); assert.match(adapter, /shared_plan_cancel_propagates_to_children/); assert.match(background, /passenger_list_import: \{ pathname: '\/system\/business\/daoru\.asp', marker: 'DaoruText' \}/); assert.match(adapter, /function sharedChildReferencesForParent/); assert.match(adapter, /shared_plan_child_absence_not_proven/); assert.match(adapter, /shared_plan_children_still_present_at_write_boundary/); assert.match(adapter, /delete_write_guard/); assert.match(adapter, /function closeCompletedArrangementDialog/); assert.match(adapter, /completed_exact_arrangement_dialog_close_scheduled/); assert.match(adapter, /function ownershipProofMatchesOperation/); assert.match(adapter, /function exactLifecycleDialogScope/); assert.match(adapter, /function lifecycleDialogCandidates/); assert.match(adapter, /async function cleanupCompletedLifecycleDialog/); assert.match(adapter, /const formValueText = Array\.from\(form\.elements \|\| \[\]\)/); assert.match(adapter, /fresh_ownership_requery/); assert.match(adapter, /completed_exact_lifecycle_dialog_close_verified/); assert.match(adapter, /for \(let attempt = 1; attempt <= 3; attempt \+= 1\)/); assert.match(adapter, /await new Promise\(\(resolve\) => window\.setTimeout\(resolve, 250\)\)/); assert.match(adapter, /background_exact_cleanup_pending/); assert.match(background, /LIFECYCLE_EDIT_DIALOG_ACTIONS/); assert.match(background, /'cleanupCompletedLifecycleDialog'/); assert.match(background, /dialog_cleanup_not_verified/); assert.match(adapter, /const stableParentValues = referenceValuesFromDocument\(targetDocument, \['oldtdid'\]\)/); assert.match(adapter, /tid_reference_source: tidReference\.source/); assert.match(replayRunner, /else if \(value === '--allow-live-write'\) args\.allowLiveWrite = true/); assert.match(replayRunner, /--expected-run-id is required/); assert.match(replayRunner, /fetch\('\/api\/tasks\/'\+encodeURIComponent/); assert.match(replayRunner, /void confirmAndSubmitToErpPlugin\(t\)\.catch/); assert.doesNotMatch(replayRunner, /await confirmAndSubmitToErpPlugin\(t\)/); assert.match(replayRunner, /void createTask\(\)\.catch/); assert.doesNotMatch(replayRunner, /await createTask\(\)/); assert.match(replayRunner, /waitForCreatedTaskId/); assert.match(replayRunner, /expectedPreConfirmationOperation/); assert.match(replayRunner, /!CREATE_ACTIONS\.has\(copy\?\.action\)/); assert.match(replayRunner, /delete_fixture_allow_live_write_must_be_true/); assert.match(replayRunner, /non_delete_live_write_requires_allow_live_write_flag/); assert.match(replayRunner, /allow_live_write_flag_requires_fixture_authorization/); assert.match(replayRunner, /shared_batch_probe_phase_mismatch/); assert.match(replayRunner, /context\.phase !== 'shared_batch_split_order_probe'/); assert.match(replayRunner, /creation_target_dates_mismatch/); assert.match(replayRunner, /creation_must_not_self_authorize_live_write/); assert.match(replayRunner, /guard\.extension\?\.erp_session/); assert.match(replayRunner, /erpGuard\.account_matched/); assert.match(replayRunner, /erp_receipt: result\?\.erp_receipt \|\| report\.erp_receipt \|\| null/); assert.match(replayRunner, /verification: result\?\.verification \|\| report\.verification \|\| null/); assert.match(replayRunner, /reconciliation: result\?\.reconciliation \|\| report\.reconciliation \|\| null/); assert.match(splitProbeRecorder, /delete_must_remain_disabled_while_recording/); assert.match(splitProbeRecorder, /probe_facts_not_complete/); assert.match(splitProbeRecorder, /allowlist\.created_refs = mergeRefs/); assert.match(splitProbeRecorder, /state\.cleanup\.objects = mergeRefs/); assert.doesNotMatch(splitProbeRecorder, /shared_plans_with_no_children\s*=\s*/); assert.match(splitProbeRecorder, /if \(args\.apply\) await writePreparedFiles/); }); test('JSON Schema compiles and separates production ERP resolution from historical test context', async () => { const schema = JSON.parse(await readFile(new URL('../schemas/standard_system_operation.schema.json', import.meta.url), 'utf8')); const ajv = new Ajv2020({ allErrors: true, strict: false }); addFormats(ajv); const validate = ajv.compile(schema); const passengerOperation = base('passenger_list_import', { existing_refs: { kind: 'shared_child_order', child_order_no: 'D14452', ddid: '14452', tid: '14384', departure_date: '2026-09-22', expected_passenger_count: 16, owner_account: 'AI_TEST_ACCOUNT', marker: 'TEST-202609' }, passenger_list: { operation: 'first_import', row_count: 16, rows: passengerRowsWithLeader, leader_contact: { sequence: 1, name: '测试领队', phone: '13800000001' }, marker: 'TEST-202609' } }); assert.equal(validate(passengerOperation), true, ajv.errorsText(validate.errors)); const invalidLeaderContact = structuredClone(passengerOperation); delete invalidLeaderContact.data.passenger_list.leader_contact.phone; assert.equal(validate(invalidLeaderContact), false); passengerOperation.data.passenger_list.rows[0].证件类型 = '身份证'; assert.equal(validate(passengerOperation), false); assert.ok(validate.errors.some((error) => error.instancePath.endsWith('/证件类型') && error.keyword === 'const')); const valid = arrangement('arrangement_vehicle', { supplier: { name: '测试车队', id: '386', resolved: true }, item: '测试车型', start_date: '2026-09-22', end_date: '2026-09-24', quantity: 1 }); assert.equal(validate(valid), true, ajv.errorsText(validate.errors)); const production = structuredClone(valid); delete production.source; delete production.data.existing_refs.owner_account; delete production.data.existing_refs.marker; production.data.existing_refs.resolved = true; production.data.existing_refs.resolution_source = 'erp_unique_match'; production.data.existing_refs.departure_date = '2026-10-01'; production.data.arrangement.start_date = '2026-10-01'; production.data.arrangement.end_date = '2026-10-03'; production.data.arrangement.remark = ''; assert.equal(validate(production), true, ajv.errorsText(validate.errors)); delete production.data.existing_refs.resolution_source; assert.equal(validate(production), false); const clear = structuredClone(valid); clear.data.arrangement.mode = 'clear'; clear.data.arrangement.target = { slot_index: 0, row_id: '88436' }; assert.equal(validate(clear), true, ajv.errorsText(validate.errors)); delete clear.data.arrangement.target.row_id; assert.equal(validate(clear), false); assert.ok(validate.errors.some((error) => error.keyword === 'required' && /target/.test(error.instancePath))); const forbiddenVehicleUpdate = structuredClone(valid); forbiddenVehicleUpdate.data.arrangement.mode = 'update'; forbiddenVehicleUpdate.data.arrangement.target = { slot_index: 0, row_id: '88436' }; forbiddenVehicleUpdate.data.arrangement.changes = { quantity: 2 }; assert.equal(validate(forbiddenVehicleUpdate), false); assert.ok(validate.errors.some((error) => error.instancePath.endsWith('/arrangement/mode') && error.keyword === 'enum')); const validHotelUpdate = arrangement('arrangement_hotel', { resource: { name: '测试酒店', id: '126', resolved: true }, room_type: '标准间', start_date: '2026-09-22', end_date: '2026-09-29', room_count: 8 }); validHotelUpdate.data.arrangement.mode = 'update'; validHotelUpdate.data.arrangement.target = { slot_index: 0, row_id: '88437' }; validHotelUpdate.data.arrangement.changes = { end_date: '2026-09-30', room_count: 9 }; assert.equal(validate(validHotelUpdate), true, ajv.errorsText(validate.errors)); validHotelUpdate.data.arrangement.changes = { start_date: '2026-09-23' }; assert.equal(validate(validHotelUpdate), false); assert.ok(validate.errors.some((error) => error.instancePath.endsWith('/arrangement/changes') && error.keyword === 'additionalProperties' && error.params.additionalProperty === 'start_date')); const live = structuredClone(valid); live.source.test_context.allow_live_write = true; live.source.test_context.created_refs = [{ kind: live.data.existing_refs.kind, identifier: live.data.existing_refs.identifier, tid: live.data.existing_refs.tid, ddid: live.data.existing_refs.ddid, marker: 'TEST-202609' }]; assert.equal(validate(live), true, ajv.errorsText(validate.errors)); delete live.source.test_context.created_refs; assert.equal(validate(live), false); assert.ok(validate.errors.some((error) => error.instancePath.endsWith('/test_context') && error.keyword === 'required')); const sharedBatchDates = ['2026-09-20', '2026-09-22']; const sharedBatchProbe = base('shared_plan_create', { product: { name: '老挝广东8D', source_region: '广东' }, departure_dates: sharedBatchDates, planned_capacity: 30, room_counts: { TWN: 8 }, split_order: { customer: { name: '广东测试客户', source_region: '广东' }, passenger_counts: { adult: 15, leader: 1 } } }, { context: { ...context, target_dates: sharedBatchDates, phase: 'shared_batch_split_order_probe' } }); assert.equal(validate(sharedBatchProbe), true, ajv.errorsText(validate.errors)); const unguardedSharedBatch = structuredClone(sharedBatchProbe); delete unguardedSharedBatch.source.test_context; assert.equal(validate(unguardedSharedBatch), false); const zeroQuantity = structuredClone(valid); zeroQuantity.data.arrangement.quantity = 0; assert.equal(validate(zeroQuantity), false); const outsideWindow = structuredClone(valid); outsideWindow.data.arrangement.date = '2026-10-01'; assert.equal(validate(outsideWindow), false); const invalid = structuredClone(valid); delete invalid.data.existing_refs.owner_account; assert.equal(validate(invalid), false); assert.ok(validate.errors.some((error) => error.instancePath.endsWith('/existing_refs') && error.keyword === 'required')); const standaloneReceivable = base('order_update_shared_child', { existing_refs: { kind: 'shared_child_order', child_order_no: 'D14452', ddid: '14452', tid: '14384', departure_date: '2026-09-22', owner_account: 'AI_TEST_ACCOUNT', marker: 'TEST-202609' }, receivable_fixture: { operation: 'add', name: '其他费用', quantity: 1, unit_price: 0.01, currency: 'CNY', remark: 'TEST-202609 R03', paid: false, settled: false, marker: 'TEST-202609' } }); assert.equal(validate(standaloneReceivable), true, ajv.errorsText(validate.errors)); const mixedReceivable = structuredClone(standaloneReceivable); mixedReceivable.data.updates = { actions: [{ target: 'lodging_note', operation: 'append', value: 'TEST-202609 unrelated mutation' }] }; assert.equal(validate(mixedReceivable), false); const independentZeroCleanup = base('order_update_independent', { existing_refs: { kind: 'independent_order', identifier: 'LW-260920A-A', tid: '14379', ddid: '14447', departure_date: '2026-09-20', owner_account: 'AI_TEST_ACCOUNT', marker: 'TEST-202609' }, receivable_fixture: { operation: 'clear_generated_zero', marker: 'TEST-202609', rows: [ { row_id: '31560', name: '成人团费', quantity: 15, unit_price: 0, amount: 0, currency: 'CNY', paid: false, settled: false }, { row_id: '31561', name: '单人房差', quantity: 1, unit_price: 0, amount: 0, currency: 'CNY', paid: false, settled: false } ] } }); assert.equal(validate(independentZeroCleanup), true, ajv.errorsText(validate.errors)); assert.equal(plans.validateOperation(independentZeroCleanup).ok, true, plans.validateOperation(independentZeroCleanup).blockers.join('; ')); assert.deepEqual(validateStandardOperation(independentZeroCleanup), []); const unsafeIndependentUpdate = structuredClone(independentZeroCleanup); unsafeIndependentUpdate.data.updates = { actions: [{ target: 'booking_note', operation: 'append', value: 'TEST-202609' }] }; assert.equal(validate(unsafeIndependentUpdate), false); assert.equal(plans.validateOperation(unsafeIndependentUpdate).ok, false); assert.ok(validateStandardOperation(unsafeIndependentUpdate).length > 0); const exportOperation = { action: 'confirmation_export', order_nature: 'formal', submit_mode: 'dry_run', data: { existing_refs: { kind: 'shared_child_order', parent_group_no: 'LW-260922A-A', child_order_no: 'D14452', ddid: '14452', tid: '14384', resolved: true, resolution_source: 'erp_unique_match' }, confirmation: { type: 'xingyou-confirm' } } }; assert.equal(validate(exportOperation), true, ajv.errorsText(validate.errors)); assert.deepEqual(validateStandardOperation(exportOperation), []); const formattedExport = structuredClone(exportOperation); formattedExport.data.confirmation.format = 'docx'; assert.equal(validate(formattedExport), false); const localizedExport = structuredClone(exportOperation); localizedExport.data.confirmation.language = 'zh-CN'; assert.equal(validate(localizedExport), false); delete exportOperation.data.existing_refs.parent_group_no; assert.equal(validate(exportOperation), false); assert.ok(validate.errors.some((error) => /existing_refs/.test(error.instancePath) || error.keyword === 'anyOf')); });