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