2492 lines
133 KiB
JavaScript
2492 lines
133 KiB
JavaScript
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'));
|
||
});
|