feat: ship deterministic parser and lifecycle release
This commit is contained in:
1 parent
208c434b31
commit
7b5d855b09
176 files changed
+20248
-1362
No files matched your search
@@ -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, /需求登记:/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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'));
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)/);
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user