feat: ship deterministic parser and lifecycle release

This commit is contained in:
inman committed 2026-08-28 17:13:58 +08:00
1 parent 208c434b31
commit 7b5d855b09
176 files changed
+20248 -1362

No files matched your search

+32 -13
View File
@@ -116,8 +116,8 @@ const minimumLabels = {
独立团批量下单: ['发团日期', '预订客户', '产品搜索', '预估人数', '用房数量', '发团周期'],
散拼团新增计划: ['发团日期', '产品搜索', '计划收客数', '用房数量'],
散拼团单个新增子单: ['预订客户', '出发日期', '人数'],
导入独立团名单: ['预订客户', '出发日期', '名单内容'],
导入散拼子单名单: ['预订客户', '出发日期', '名单内容'],
导入独立团名单: ['预订客户', '出发日期'],
导入散拼子单名单: ['预订客户', '出发日期'],
安排导游: ['团号', '导游搜索'],
安排用车: ['团号', '结算单位搜索', '用车日期', '数量'],
安排酒店: ['团号', '入住日期', '离店日期', '酒店搜索', '间数'],
@@ -200,7 +200,7 @@ test('catalog has exactly 18 ordered operations with adjacent template and examp
});
test('main prompt remains a compact Skill router', () => {
assert.match(mainPrompt, /prompt_version: ltjt-agent-prompt-v1\.5-next-date-rollover/);
assert.match(mainPrompt, /prompt_version: ltjt-agent-prompt-v1\.8-independent-headcount-categories/);
for (const action of [
'team_order_create',
'team_order_batch_create',
@@ -233,8 +233,8 @@ test('main prompt remains a compact Skill router', () => {
receivedAt: '2026-08-12T08:00:00.000Z'
});
assert.match(runtimeMessage, /只解析用户输入,不查询对象、不执行任务、不判断结果/);
assert.match(runtimeMessage, /新建业务中,用户只提供月日且原文没有年份时/);
assert.match(runtimeMessage, /修改、查询、取消和恢复类业务不使用新建业务的自动补年规则/);
assert.match(runtimeMessage, /新建业务、订单\/安排修改以及取消\/恢复订单中,用户只提供月日且原文没有年份时/);
assert.match(runtimeMessage, /取消\/恢复的定位出发日期均执行上述规则/);
assert.doesNotMatch(runtimeMessage, /标准指令路由/);
assert.doesNotMatch(runtimeMessage, /插件|ERP|门禁|TEST-202609|test_context|server_response|native_request|requery|fresh|write_attempted|DoInfo|allowlist|\b(?:tid|ddid|row_id)\b/);
});
@@ -372,22 +372,22 @@ test('the four detailed business pages use adjacent template and example blocks'
}
});
test('the two agreed examples remain exactly copyable', () => {
test('the two desktop-approved examples remain exactly copyable', () => {
const independentSingle = [
'独立团单个下单',
'发团日期:09-03',
'预订客户:老挝联泰人名币',
'发团日期:11-01',
'预订客户:老挝联泰人民币',
'产品搜索:遇见老挝',
'预估人数:15+1',
'用房数量:8标1单'
].join('\n');
const sharedPlan = [
'散拼团新增计划',
'发团日期:09-01到09-30',
'发团日期:11-01到11-30',
'产品搜索:广东衡阳6D',
'计划收客数:40',
'用房数量:8标1单',
'发团周期:09.07/09.15/09.26'
'发团周期:11.01/11.15/11.26'
].join('\n');
assert.equal(labeledBlock(sectionByDirective.get('独立团单个下单'), '示例'), independentSingle);
@@ -402,9 +402,17 @@ test('every operation preserves its explicit minimum-required field set', () =>
.map((line) => line.slice(0, line.indexOf('(必填):')));
assert.deepEqual(actualLabels, expectedLabels, `${directive} minimum-required fields changed`);
}
for (const directive of ['导入独立团名单', '导入散拼子单名单']) {
const section = sectionByDirective.get(directive);
assert.match(section.body, /名单附件(必填):[\s\S]*`\.xls` 或 `\.xlsx`/);
assert.doesNotMatch(labeledBlock(section, '模板'), /名单内容/);
}
});
test('modification input asks only for targets and export stays minimal', () => {
const controlPlaneReadme = read('control-plane/README.md');
const releaseGate = read('agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md');
assert.doesNotMatch(catalog, /清理测试订单/);
assert.doesNotMatch(registry, /\| `清理测试订单` \|/);
@@ -421,8 +429,10 @@ test('modification input asks only for targets and export stays minimal', () =>
assert.deepEqual(minimumLabels['导出团队文件'], ['预订客户', '出发日期', '文件类型']);
assert.doesNotMatch(catalog, /^### \d+\. 读取团队文件源响应$/m);
assert.doesNotMatch(catalog, /^读取团队文件源响应$/m);
assert.match(catalog, /游客信息保留原始 ERP `\.xls` 作为平台源归档,并由平台生成真实 `\.xlsx` 后保存任务附件,AgentBus\/微信只返回 `\.xlsx`;其余类型由平台转换为 PDF/);
assert.match(catalog, /系统必须先查询当前值并形成 `before_snapshot`/);
assert.match(catalog, /游客名单返回 `\.xlsx`;其他文件通常返回 PDF/);
assert.doesNotMatch(catalog, /before_snapshot|AgentBus|OSS|object key|updates\.actions/);
assert.match(controlPlaneReadme, /游客信息保留原始 ERP `\.xls` 作为源归档,同时由平台通过 LibreOffice 生成真实 `\.xlsx`/);
assert.match(releaseGate, /系统自行读取当前值并形成 before\/after/);
});
test('team-file export uses the business name while keeping the internal action stable', () => {
@@ -436,6 +446,12 @@ test('team-file export uses the business name while keeping the internal action
assert.ok(registry.includes('| `导出团队文件` | `lwlt-confirmation` | `confirmation_export` |'));
assert.match(platformUi, /confirmation_export: '导出团队文件'/);
assert.match(pluginPlanner, /confirmation_export:\s*{[\s\S]*?label: '导出团队文件'/);
assert.match(catalog, /文件类型(必填):[\s\S]*整团游客信息/);
assert.match(catalog, /整团游客信息.*散拼母团/);
assert.match(catalog, /游客名单.*独立团.*散拼具体子单/);
assert.match(confirmationContract, /`整团游客信息`.*散拼母团/);
assert.match(confirmationContract, /`游客名单`.*独立团.*散拼具体子单/);
assert.match(pluginPlanner, /validateSharedPlanVisitorExport/);
assert.doesNotMatch(platformUi, /确认件源读取\/恢复/);
assert.doesNotMatch(pluginPlanner, /label: '确认件源读取\/恢复'/);
});
@@ -458,7 +474,10 @@ test('independent-order updates preserve broad parser targets while arrangement
assert.match(parser, /updates\.actions 包含多个动作/);
assert.match(parser, /new Set\(\['end_date', 'room_count'\]\)/);
assert.match(labeledBlock(sectionByDirective.get('独立团信息修改'), '示例'), /目标修改:人数改为8\+1(占)\+1/);
assert.match(labeledBlock(sectionByDirective.get('安排变更'), '示例'), /离店日期改为09-13;房间数改为9间/);
assert.match(updatingSkill, /兼容旧三段 `人数改为8\+1(占)\+1`/);
assert.match(updatingSkill, /pax\.child_no_bed=1/);
assert.match(sectionByDirective.get('独立团信息修改')?.body ?? '', /未出现的类别保持原值/);
assert.match(labeledBlock(sectionByDirective.get('安排变更'), '示例'), /离店日期改为11-05;房间数改为15间/);
assert.doesNotMatch(catalog, /需求登记:/);
});
+49
View File
@@ -29,3 +29,52 @@ test('failures after the write boundary become uncertain', () => {
assert.equal(guard.failureState({ state: 'submitted' }), 'uncertain');
assert.equal(guard.rootTaskId('TASK-1#2'), 'TASK-1');
});
test('an explicit network-before-submit block is safe to classify as no ERP write', () => {
const report = {
status: 'live_submit_blocked',
execution_phase: 'prewrite_blocked',
write_attempted: false,
no_erp_write: true,
submit_safety: { live_submit_attempted: false },
native_request: null,
server_response: null,
requery: null,
side_effects: [],
ajax_records: [{ prevented_from_network: true, network_request_attempted: false }]
};
assert.equal(guard.confirmedPrewriteNoErpWrite(report), true);
assert.equal(guard.confirmedPrewriteNoErpWrite({ ...report, write_attempted: true }), false);
assert.equal(guard.confirmedPrewriteNoErpWrite({
...report,
ajax_records: [{ prevented_from_network: false, network_request_attempted: true }]
}), false);
assert.equal(guard.confirmedPrewriteNoErpWrite({ ...report, side_effects: { unknown: true } }), false);
});
test('local extension history stays bounded without pruning active or uncertain roots', () => {
const tasks = {};
const results = {};
const executions = {};
for (let index = 1; index <= 6; index += 1) {
const taskId = `TASK-${index}`;
const updatedAt = `2026-08-${String(index).padStart(2, '0')}T00:00:00.000Z`;
tasks[taskId] = { task_id: taskId, updated_at: updatedAt };
results[taskId] = { status: 'completed', updated_at: updatedAt };
executions[taskId] = { state: 'completed', updated_at: updatedAt };
}
executions['TASK-1'] = { state: 'running', updated_at: '2026-08-01T00:00:00.000Z' };
results['TASK-2'] = { status: 'execution_uncertain', updated_at: '2026-08-02T00:00:00.000Z' };
tasks['TASK-6#1'] = { task_id: 'TASK-6#1', updated_at: '2026-08-06T00:00:01.000Z' };
results['TASK-6#1'] = { status: 'completed', updated_at: '2026-08-06T00:00:01.000Z' };
const pruned = guard.pruneLocalTaskHistory(
{ tasks, results, executions, timingStates: {} },
{ currentTaskId: 'TASK-3', terminalRootLimit: 2 }
);
assert.deepEqual(Object.keys(pruned.results).sort(), ['TASK-1', 'TASK-2', 'TASK-3', 'TASK-5', 'TASK-6', 'TASK-6#1']);
assert.equal(pruned.counters.protected_root_count, 3);
assert.equal(pruned.counters.removed_root_count, 1);
assert.equal(pruned.counters.removed_result_count, 1);
});
+476 -18
View File
@@ -41,6 +41,12 @@ const passengerRows = Array.from({ length: 16 }, (_, index) => ({
电话: `+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 };
@@ -119,6 +125,27 @@ test('shared-child adapter waits for the async parent row and cross-checks the e
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(
@@ -299,6 +326,148 @@ test('restore lookup explicitly visits the canceled native status tab', async ()
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: {} };
@@ -677,13 +846,17 @@ test('passenger lifecycle prepares the exact native import route before adapter
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 = \{\}\)/);
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\)/);
@@ -721,7 +894,7 @@ test('passenger lifecycle prepares the exact native import route before adapter
assert.match(adapter, /const stableReferenceMatched = tidControlMatched && ddidControlMatched/);
});
test('passenger rows normalize to canonical TSV and allow a bounded partial input', () => {
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',
@@ -733,14 +906,24 @@ test('passenger rows normalize to canonical TSV and allow a bounded partial inpu
owner_account: 'AI_TEST_ACCOUNT',
marker: 'TEST-202609'
},
passenger_list: { operation: 'first_import', row_count: 16, rows: passengerRows, 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 = passengerRows.slice(0, 2);
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), []);
@@ -748,7 +931,11 @@ test('passenger rows normalize to canonical TSV and allow a bounded partial inpu
const overflow = structuredClone(operation);
overflow.data.passenger_list.rows.push({ ...passengerRows[0], 序号: 17 });
overflow.data.passenger_list.row_count = 17;
assert.match(plans.validateOperation(overflow).blockers.join('\n'), /游客位/);
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';
@@ -766,8 +953,10 @@ test('passenger merge fills explicit blank rows, preserves others, and protects
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,
@@ -792,6 +981,11 @@ test('passenger merge fills explicit blank rows, preserves others, and protects
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),
@@ -816,18 +1010,80 @@ test('passenger merge fills explicit blank rows, preserves others, and protects
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], 序号: 17 }]
rows: [{ ...passengerRows[0], 序号: 25 }]
}, 16);
assert.match(outside.blockers.join('\n'), /passenger_sequence_out_of_range:17: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 () => {
@@ -845,14 +1101,44 @@ test('passenger adapter mirrors only native persisted fields and reports implici
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('uncertain lifecycle writes can only converge through a manual read-only plugin requery', async () => {
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'),
@@ -862,12 +1148,18 @@ test('uncertain lifecycle writes can only converge through a manual read-only pl
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, /'verifyLifecycleOperation'/);
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'/);
@@ -905,6 +1197,107 @@ test('ERP business-rule rejections return the native message and stay out of rec
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'),
@@ -936,6 +1329,11 @@ test('lifecycle execution keeps the MV3 worker alive until the durable task sett
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/);
@@ -1139,6 +1537,7 @@ test('update actions expose validated writes and preserve unvalidated fields for
{ 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 }
] }
});
@@ -1163,13 +1562,20 @@ test('update actions expose validated writes and preserve unvalidated fields for
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);
@@ -1359,11 +1765,35 @@ test('delete guard distinguishes independent, shared child, and shared parent ro
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.140');
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/);
@@ -1482,7 +1912,7 @@ test('platform runner independently blocks a tampered multi-date split-order pro
}
});
test('confirmation export is backend-attached, route-aware, and rejects a shared parent', () => {
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',
@@ -1513,8 +1943,22 @@ test('confirmation export is backend-attached, route-aware, and rejects a shared
assert.ok(plan.artifacts.every((artifact) => artifact.scheduled === false));
const parent = structuredClone(operation);
parent.data.existing_refs = { kind: 'shared_plan', parent_group_no: 'LW-260922A-A', tid: '14384' };
assert.match(plans.validateOperation(parent).blockers.join('\n'), /母团不能直接导出/);
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;
@@ -1533,6 +1977,8 @@ test('confirmation export keeps shared-child source lookup independent of lifecy
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', () => {
@@ -1692,16 +2138,19 @@ test('schema and browser adapters contain the v2 safety fields and no confirm ov
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.140');
assert.match(inpage, /version: '0\.5\.140'/);
assert.match(teamBatchInpage, /version: '0\.5\.140'/);
assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.140'/);
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'\)\)/);
@@ -1865,9 +2314,18 @@ test('JSON Schema compiles and separates production ERP resolution from historic
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: passengerRows, 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'));
+41 -3
View File
@@ -98,7 +98,7 @@ test('order updates require an identifier and only run a no-write browser previe
assert.equal(result.identifier, 'LW-270520A-A');
});
test('parent confirmation export is blocked before any browser operation', () => {
test('untyped parent confirmation export is blocked before any browser operation', () => {
const result = plans.validateOperation({
action: 'confirmation_export',
order_nature: 'formal',
@@ -106,7 +106,7 @@ test('parent confirmation export is blocked before any browser operation', () =>
data: { existing_refs: { parent_group_no: 'PARENT-EXAMPLE' } },
});
assert.equal(result.ok, false);
assert.match(result.blockers.join('\n'), /母团/);
assert.match(result.blockers.join('\n'), /existing_refs\.kind|缺少标准确认件类型|唯一解析/);
assert.equal(result.execution, 'blocked');
});
@@ -524,7 +524,7 @@ test('allows multi-date split facts only inside the dedicated TEST-202609 probe'
assert.equal(result.ok, true, result.blockers.join('; '));
assert.match(result.warnings.join('\n'), /测试态事实探针/);
const publicPlan = plans.publicPlan(result);
assert.equal(publicPlan.contract_version, 'ltjt-lifecycle-v2.8-updating-wide-parse-2026-08');
assert.equal(publicPlan.contract_version, 'ltjt-lifecycle-v2.9-roster-leader-contact-2026-08');
assert.equal(publicPlan.capability_state, 'validated_parent_create_batch_split_order_probe_only');
});
@@ -599,6 +599,44 @@ test('accepts canonical confirmation export types without alias normalization',
assert.deepEqual(result.exportTypes, ['liantai-confirm']);
});
test('shared-plan visitor export uses tid only and rejects child-scoped or mixed sources', () => {
const operation = {
action: 'confirmation_export',
order_nature: 'formal',
submit_mode: 'dry_run',
data: {
existing_refs: {
kind: 'shared_plan', identifier: 'LW-261101A-A', parent_group_no: 'LW-261101A-A',
plan_no: 'LW-261101A-A', tid: '456', resolved: true, resolution_source: 'erp_unique_match'
},
confirmation: { type: 'visitor-list' }
}
};
const result = plans.validateOperation(operation);
assert.equal(result.ok, true, result.blockers.join('; '));
assert.deepEqual(result.exportTypes, ['visitor-list']);
const plan = plans.buildConfirmationExportPlan(operation, {});
assert.equal(plan.target_kind, 'shared_plan');
assert.deepEqual(plan.artifacts, [{
type: 'visitor-list',
path: '/System/Business/orders_Visitor.asp',
params: { tid: '456' },
downloaded: false,
converted: false,
scheduled: false,
sent: false
}]);
const wrongType = structuredClone(operation);
wrongType.data.confirmation = { type: 'liantai-confirm' };
assert.match(plans.validateOperation(wrongType).blockers.join('\n'), /散拼母团.*整团游客信息/);
const leakedChildRef = structuredClone(operation);
leakedChildRef.data.existing_refs.ddid = '789';
assert.match(plans.validateOperation(leakedChildRef).blockers.join('\n'), /散拼母团.*子单.*ddid/);
});
test('rejects requested confirmation formats because platform processing is separate', () => {
const result = plans.validateOperation({
action: 'confirmation_export',
+245
View File
@@ -0,0 +1,245 @@
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const require = createRequire(import.meta.url);
const timing = require('../chrome-extension/ltjt-order-assistant/operation-timing.js');
test('operation timing closes the previous stage and attributes buffered page work to it', () => {
let state = timing.createState({
now_ms: 1_000,
started_at_ms: 1_000,
stage: 'open_order_form',
status: 'running'
});
const advanced = timing.advance(state, {
now_ms: 2_500,
stage: 'wait_order_form',
status: 'running',
details: [
{ step: 'script_injection', status: 'completed', duration_ms: 300, counters: { call_count: 1 } },
{ step: 'action_runtime.open_order_form', status: 'completed', duration_ms: 700, counters: { frame_count: 1 } }
]
});
state = advanced.state;
assert.equal(advanced.snapshot.total_ms, 1_500);
assert.deepEqual(advanced.snapshot.stages.map((stage) => stage.stage), ['open_order_form', 'wait_order_form']);
assert.equal(advanced.snapshot.stages[0].duration_ms, 1_500);
assert.deepEqual(
advanced.snapshot.stages[0].details.map((detail) => detail.step),
['script_injection', 'action_runtime.open_order_form']
);
assert.equal(advanced.snapshot.stages[1].duration_ms, 0);
assert.equal(state.active_stage, 'wait_order_form');
});
test('operation timing survives state loss without exposing absolute timestamps', () => {
const state = timing.createState({
now_ms: 10_000,
started_at_ms: 10_000,
stage: 'preflight',
status: 'running'
});
const beforeSuspend = timing.snapshot(state, 12_000);
const restored = timing.advance(null, {
now_ms: 15_000,
updated_at_ms: 12_000,
existing_timing: beforeSuspend,
stage: 'live_submit',
status: 'running'
});
assert.equal(restored.snapshot.total_ms, 5_000);
assert.equal(restored.snapshot.stages[0].stage, 'preflight');
assert.equal(restored.snapshot.stages[0].duration_ms, 5_000);
assert.equal(JSON.stringify(restored.snapshot).includes('started_at'), false);
assert.equal(JSON.stringify(restored.snapshot).includes('updated_at'), false);
});
test('operation timing terminal snapshot is bounded and strips arbitrary values', () => {
let state = timing.createState({ now_ms: 1_000, stage: 'verification', status: 'running' });
const completed = timing.advance(state, {
now_ms: 2_000,
stage: 'verification',
status: 'completed',
details: [{
step: 'network_wait.verify_order_marker',
status: 'completed',
duration_ms: 450.4,
counters: { request_count: 1, customer_name: '测试客户', negative: -1 },
url: 'https://example.invalid/customer',
response: 'secret'
}]
});
state = completed.state;
assert.equal(completed.terminal, true);
assert.equal(completed.snapshot.status, 'completed');
assert.equal(completed.snapshot.total_ms, 1_000);
assert.deepEqual(completed.snapshot.stages[0].details[0], {
step: 'network_wait.verify_order_marker',
status: 'completed',
duration_ms: 450,
counters: { request_count: 1 }
});
assert.equal(Object.hasOwn(state, 'url'), false);
});
test('a later reconciliation continuation does not count the human idle gap', () => {
const terminal = {
schema_version: timing.SCHEMA_VERSION,
status: 'execution_uncertain',
total_ms: 8_000,
stage_count: 1,
stages: [{ stage: 'lifecycle_reconciliation', status: 'execution_uncertain', duration_ms: 8_000 }]
};
const resumed = timing.advance(null, {
now_ms: 1_000_000,
updated_at_ms: 1_000_000,
existing_timing: terminal,
stage: 'lifecycle_reconciliation',
status: 'running'
});
const reconciled = timing.advance(resumed.state, {
now_ms: 1_001_200,
stage: 'lifecycle_reconciliation',
status: 'completed',
details: [{ step: 'action_roundtrip.verify_lifecycle_operation', duration_ms: 900 }]
});
assert.equal(reconciled.snapshot.total_ms, 9_200);
assert.equal(reconciled.snapshot.stage_count, 2);
assert.equal(reconciled.snapshot.stages[1].duration_ms, 1_200);
assert.equal(reconciled.snapshot.stages[1].details[0].duration_ms, 900);
});
test('background timing hook covers every executor through the shared result and page-action boundaries', async () => {
const background = await readFile(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8');
assert.match(background, /importScripts\([^\n]*'operation-timing\.js'/);
assert.match(background, /async function setResult[\s\S]*operationTiming\.advance/);
assert.match(background, /async function runPageAction[\s\S]*action_roundtrip\.\$\{actionCode\}/);
assert.match(background, /network_wait\.\$\{actionCode\}/);
assert.match(background, /businessTaskTimingStates/);
assert.match(background, /async function runInlinePageAction/);
assert.match(background, /async function canReusePageActionScripts/);
assert.match(background, /assistant\.version === expectedVersion/);
assert.match(background, /status: scriptsReused \? 'skipped' : 'completed'/);
assert.match(background, /expectedFormProtectedFieldsSha256: preflight\.final_form\?\.post_intercept_protected_fields_sha256/);
assert.match(background, /expectedRequestProtectedFieldsSha256: preflight\.final_form\?\.submit_protected_fields_sha256/);
assert.match(background, /confirmedPrewriteNoErpWrite\(liveSubmit\)/);
assert.match(background, /execution_phase: liveSubmit\.status === 'live_submit_completed'[\s\S]*'prewrite_blocked'/);
assert.match(background, /if \(action === 'inspectOperationContext'\) return \['inpage\.js'\]/);
assert.match(background, /if \(TEAM_SINGLE_PAGE_ACTIONS\.has\(action\)\) return \['source-region\.js', 'inpage\.js'\]/);
assert.match(background, /storage_prune\.local_history/);
assert.match(
background,
/async function reconcileLifecycleTask[\s\S]*resumeOperationTiming\(rootId, 'lifecycle_reconciliation'[\s\S]*finally \{\s*await clearOperationTimingContinuation\(rootId\);/
);
const actionAllowlist = background.match(/const TIMED_PAGE_ACTIONS = new Set\(\[([\s\S]*?)\]\);/)?.[1] || '';
for (const action of [
'openOrderForm',
'pingOrderFrame',
'openTeamBatchForm',
'pingTeamBatchFrame',
'inspectOperationContext',
'preflightLifecycleOperation',
'resolveLifecycleOperation',
'prepareLifecycleOperation',
'liveSubmitLifecycleOperation',
'cleanupCompletedLifecycleDialog',
'verifyLifecycleOperation',
'createSplitParentLive',
'createSplitChildLive',
'exportConfirmationSources',
'preflightRawInstruction',
'liveSubmitApproved',
'verifyOrderMarker',
'returnToOrderList',
'preflightTeamBatchNative',
'liveSubmitTeamBatchApproved',
'verifyTeamBatchReceipts'
]) {
assert.match(actionAllowlist, new RegExp(`['"]${action}['"]`), `${action} 缺少计时白名单`);
}
});
test('team-order preflight waits on ERP completion events without page-timer polling', async () => {
const inpage = await readFile(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8');
const preflight = inpage.slice(
inpage.indexOf('async function preflightRawInstruction'),
inpage.indexOf('async function liveSubmitApproved')
);
const liveSubmit = inpage.slice(
inpage.indexOf('async function liveSubmitApproved'),
inpage.indexOf('async function verifyOrderMarker')
);
assert.match(preflight, /Promise\.all\(endpointEntries\.map/);
assert.match(preflight, /productEffectCompletion/);
assert.match(preflight, /await productEffectCompletion/);
assert.match(preflight, /preflight_lookup\.parallel_wall/);
assert.match(preflight, /preflight_product_linkage/);
assert.match(preflight, /preflight_customer_restore/);
assert.match(preflight, /dispatchNativeSideEffect: false/);
assert.match(preflight, /customerRestoreReadiness/);
assert.match(preflight, /customer_final_guard/);
assert.match(preflight, /__ltjt_preflight_guard = true/);
assert.match(preflight, /__ltjt_restore_timer/);
assert.match(preflight, /intercept_guard_active/);
assert.match(preflight, /postInterceptSerializedForm = serializeForm\(form\)/);
assert.match(preflight, /post_intercept_protected_fields_sha256/);
assert.ok(
preflight.indexOf('submitIntercept = await interceptSubmit') < preflight.indexOf('postInterceptSerializedForm = serializeForm(form)'),
'post-intercept browser hash must be captured after SubmitInfoForm returns'
);
assert.doesNotMatch(preflight, /waitForStableSignature/);
assert.doesNotMatch(preflight, /quietWaitStartedAt|await sleep\(/);
assert.match(liveSubmit, /submitCompletion/);
assert.match(liveSubmit, /await submitCompletion/);
assert.match(liveSubmit, /approvedFormProtectedFieldsSha256/);
assert.match(liveSubmit, /approvedRequestProtectedFieldsSha256/);
assert.match(liveSubmit, /network_request_attempted/);
assert.match(liveSubmit, /liveSubmitPrewriteBlocked/);
assert.match(liveSubmit, /Expected exactly one DoInfoJH ajax submit attempt/);
assert.doesNotMatch(liveSubmit, /while \(Date\.now\(\) - started < 30000\)|await sleep\(250\)/);
});
test('team-order frame discovery targets the exact ERP iframe instead of injecting into every frame', async () => {
const [background, manifestText] = 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/manifest.json', import.meta.url), 'utf8')
]);
const frameWait = background.slice(
background.indexOf('async function waitForOrderFrame'),
background.indexOf('async function openTeamBatchForm')
);
assert.match(background, /chrome\.webNavigation\.getAllFrames/);
assert.match(frameWait, /frameIds/);
assert.doesNotMatch(frameWait, /allFrames: true/);
assert.ok(JSON.parse(manifestText).permissions.includes('webNavigation'));
});
test('extension result changes push immediately while polling remains as a fallback', async () => {
const bridge = await readFile(new URL('../chrome-extension/ltjt-order-assistant/business-bridge.js', import.meta.url), 'utf8');
const app = await readFile(new URL('../LianSyn-platform/app.js', import.meta.url), 'utf8');
assert.match(bridge, /changes\.businessTaskResults/);
assert.match(bridge, /type: 'TASK_RESULT_CHANGED'/);
assert.match(app, /message\.type === 'TASK_RESULT_CHANGED'/);
assert.match(app, /pendingExtensionResults\.set/);
assert.match(app, /while \(pendingExtensionResults\.has/);
assert.match(app, /setInterval\([\s\S]*pollAllTaskResults/);
});
test('platform exposes the sanitized timing breakdown without rendering raw network data', async () => {
const app = await readFile(new URL('../LianSyn-platform/app.js', import.meta.url), 'utf8');
const styles = await readFile(new URL('../LianSyn-platform/styles.css', import.meta.url), 'utf8');
assert.match(app, /function renderOperationTiming\(task\)/);
assert.match(app, /task\?\.result\?\.operation_timing/);
assert.match(app, /'storage_write\.result_state': '保存任务结果状态'/);
assert.match(app, /'frame_discovery\.web_navigation': '定位目标 ERP Frame'/);
assert.match(app, /'preflight_lookup\.parallel_wall': '四项资料并行查询'/);
assert.match(app, /不记录 URL、表单值或响应正文/);
assert.match(styles, /\.task-operation-timing-panel/);
assert.doesNotMatch(app, /timing\.stages[\s\S]{0,200}(?:url|response_text|form_value)/);
});
+2 -1
View File
@@ -34,6 +34,7 @@ const ROOT_ALLOWLIST = new Set([
'node_modules',
'package-lock.json',
'package.json',
'pnpm-lock.yaml',
'progress.md',
'quarantine',
'reports',
@@ -275,7 +276,7 @@ test('single-source and generated-output boundaries remain explicit', async () =
const compose = await readFile(path.join(ROOT, 'docker-compose.yml'), 'utf8');
const gitignore = await readFile(path.join(ROOT, '.gitignore'), 'utf8');
assert.match(template, /^交付版本:0\.5\.118\s*$/mu);
assert.match(template, /^交付版本:0\.5\.123\s*$/mu);
assert.match(template, /^适用范围:当前 18 项运营业务输入;执行能力以生命周期发布门槛为准。\s*$/mu);
assert.doesNotMatch(template, /^事实基线:/mu);
assert.equal(tsconfig.compilerOptions.outDir, '.build');