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
@@ -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'));
|
||||
|
||||
Reference in new issue
Block a user