Files
LWLT-AIBOT/tools/lifecycle-contract.test.mjs

2492 lines
133 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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('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, /<table><tbody>\$\{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\.157'/);
});
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.157');
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, [
runnerPath.pathname,
'--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: 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.157');
assert.match(inpage, /version: '0\.5\.157'/);
assert.match(teamBatchInpage, /version: '0\.5\.157'/);
assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.157'/);
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'));
});