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

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