import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { buildReplay, createFreshOutputDirectory, validateReplayOperations, validateReplayState } from './build_lifecycle_replay.mjs'; const fixtureRoot = new URL('../agent设计规范/test-fixtures/lwlt-lifecycle/', import.meta.url); async function inputs() { const state = JSON.parse(await readFile(new URL('replay-state.template.json', fixtureRoot), 'utf8')); const passengers = await readFile(new URL('synthetic-passengers.tsv', fixtureRoot), 'utf8'); return { state, passengers }; } test('fresh replay output creates missing parents and refuses to overwrite a run', async () => { const root = await mkdtemp(join(tmpdir(), 'ltjt-replay-output-')); const out = join(root, 'missing-parent', 'run-01'); try { await createFreshOutputDirectory(out); assert.equal((await stat(out)).isDirectory(), true); await assert.rejects( () => createFreshOutputDirectory(out), (error) => error?.code === 'EEXIST' ); } finally { await rm(root, { recursive: true, force: true }); } }); function completeState(template) { const state = structuredClone(template); state.run.allow_live_write = true; state.objects.independent_order = { kind: 'independent_order', identifier: 'LW-260912-TEST-202609-R0511', tid: '15101', ddid: '15201', departure_date: '2026-09-12', owner_account: '测试ai员工账号', marker: 'TEST-202609', run_id: state.run.run_id, creation_slot: 'independent_single:2026-09-12' }; state.objects.shared_plan = { kind: 'shared_plan', parent_group_no: 'LW-260916-TEST-202609-R0511', tid: '15102', departure_date: '2026-09-16', owner_account: '测试ai员工账号', marker: 'TEST-202609', run_id: state.run.run_id, creation_slot: 'shared_single:2026-09-16' }; state.objects.shared_child_order = { kind: 'shared_child_order', parent_group_no: 'LW-260916-TEST-202609-R0511', child_order_no: 'D15202', tid: '15102', ddid: '15202', departure_date: '2026-09-16', owner_account: '测试ai员工账号', marker: 'TEST-202609', run_id: state.run.run_id, creation_slot: 'shared_child:2026-09-16' }; state.resources = { guide: { name: 'ERP 唯一测试导游候选', id: '11', resolved: true }, vehicle: { supplier: { name: 'ERP 唯一车队候选', id: '386', resolved: true }, item: 'ERP 唯一车型候选' }, hotel: { resource: { name: 'ERP 唯一酒店候选', id: '182', resolved: true }, room_type: '标准间' }, transport: { supplier: { name: 'ERP 唯一票务候选', id: '1220', resolved: true }, item: 'ERP 唯一航段候选' }, other: { supplier: { name: 'ERP 唯一其他供应商候选', id: '1347', resolved: true }, item: '测试备案成本' } }; state.arrangement_parameters.independent_order.filing.number = '99091251'; state.arrangement_parameters.shared_plan.filing.number = '99091652'; state.receivables.native_baseline_verified = true; return state; } test('template generates the four creation routes and all contracts agree', async () => { const { state, passengers } = await inputs(); assert.deepEqual(validateReplayState(state), []); const built = buildReplay(state, passengers); assert.deepEqual(built.blockers, []); assert.deepEqual(built.entries.map((entry) => entry.operation.action), [ 'team_order_create', 'team_order_batch_create', 'shared_plan_create', 'shared_plan_create' ]); const validation = await validateReplayOperations(built.entries); assert.ok(validation.every((item) => item.ok), JSON.stringify(validation, null, 2)); assert.ok(built.entries.every((entry) => entry.operation.data.order_number.suffix.includes('TEST-202609'))); assert.ok(built.entries.every((entry) => Object.keys(entry.operation.source).join(',') === 'test_context')); assert.ok(built.entries.every((entry) => entry.operation.source.test_context.run_id === state.run.run_id)); }); test('dedicated third-run shared batch probe carries customer and 15+1 facts through all three contracts', async () => { const { state, passengers } = await inputs(); state.creation.shared.batch_split_order_probe = true; const built = buildReplay(state, passengers); assert.deepEqual(built.blockers, []); const probe = built.entries.find((entry) => entry.id === 'C04-shared-batch-plan'); assert.ok(probe); assert.deepEqual(probe.operation.data.split_order.passenger_counts, { adult: 15, leader: 1, expected_total: 16 }); assert.equal(probe.operation.data.split_order.customer.name, state.creation.shared.customer.name); assert.equal(probe.operation.source.test_context.phase, 'shared_batch_split_order_probe'); assert.deepEqual(probe.operation.source.test_context.target_dates, [...state.creation.shared.batch_dates].sort()); assert.equal(Object.hasOwn(probe.operation.source.test_context, 'allow_live_write'), false); assert.equal(Object.hasOwn(probe.operation.source.test_context, 'created_refs'), false); const validation = await validateReplayOperations(built.entries); assert.ok(validation.every((item) => item.ok), JSON.stringify(validation.filter((item) => !item.ok), null, 2)); }); test('resolved state generates passenger, supported narrow updates, arrangement and source phases', async () => { const { state: template, passengers } = await inputs(); const state = completeState(template); const built = buildReplay(state, passengers); assert.deepEqual(built.blockers, []); assert.equal(built.entries.filter((entry) => entry.phase === 'passenger_import').length, 2); assert.equal(built.entries.filter((entry) => entry.phase === 'business_update_pre_arrangement_probe').length, 0); assert.equal(built.entries.filter((entry) => entry.phase === 'business_updates').length, 2); assert.equal(built.entries.filter((entry) => entry.phase === 'arrangement_create').length, 10); assert.equal(built.entries.filter((entry) => entry.phase === 'source_export_arranged').length, 2); assert.equal(built.entries.filter((entry) => entry.phase === 'receivable_plugin_replay').length, 1); const receivableAdd = built.entries.find((entry) => entry.phase === 'receivable_plugin_replay').operation; assert.equal(receivableAdd.data.receivable_fixture.operation, 'add'); assert.equal(Object.hasOwn(receivableAdd.data, 'updates'), false); assert.equal(built.entries.filter((entry) => entry.phase === 'arrangement_clear').length, 0); const lastArrangementIndex = built.entries.map((entry) => entry.phase).lastIndexOf('arrangement_create'); const sharedPlanUpdateIndex = built.entries.findIndex((entry) => entry.id === 'U02-update-shared-plan-after-arrangement'); const firstExportIndex = built.entries.findIndex((entry) => entry.phase === 'source_export_arranged'); assert.equal(built.entries.some((entry) => entry.operation.action === 'order_update_independent' && entry.operation.data.updates), false); assert.ok(sharedPlanUpdateIndex > lastArrangementIndex); assert.ok(sharedPlanUpdateIndex < firstExportIndex); const validation = await validateReplayOperations(built.entries); assert.ok(validation.every((item) => item.ok), JSON.stringify(validation.filter((item) => !item.ok), null, 2)); const refs = built.entries.map((entry) => entry.operation.data.existing_refs).filter(Boolean); assert.ok(refs.every((item) => !Object.hasOwn(item, 'run_id') && !Object.hasOwn(item, 'creation_slot'))); }); test('clear, transition and delete phases require progressively stronger evidence gates', async () => { const { state: template, passengers } = await inputs(); const state = completeState(template); state.arrangement_row_ids = { independent_order: { arrangement_vehicle: '91001', arrangement_hotel: '91002', arrangement_transport: '91003', arrangement_other: '91004' }, shared_plan: { arrangement_vehicle: '92001', arrangement_hotel: '92002', arrangement_transport: '92003', arrangement_other: '92004' } }; let built = buildReplay(state, passengers); assert.equal(built.entries.filter((entry) => entry.phase === 'arrangement_clear').length, 0); const receivableAdd = built.entries.find((entry) => entry.phase === 'receivable_plugin_replay').operation; assert.equal(receivableAdd.data.receivable_fixture.operation, 'add'); assert.equal(Object.hasOwn(receivableAdd.data, 'updates'), false); state.receivables.plugin_add_verified = true; state.receivables.plugin_row_id = '31559'; built = buildReplay(state, passengers); assert.equal(built.entries.filter((entry) => entry.phase === 'arrangement_clear').length, 0); const receivableClear = built.entries.find((entry) => entry.phase === 'receivable_plugin_replay').operation; assert.equal(receivableClear.data.receivable_fixture.operation, 'clear'); assert.equal(Object.hasOwn(receivableClear.data, 'updates'), false); state.receivables.plugin_clear_verified = true; built = buildReplay(state, passengers); assert.equal(built.entries.filter((entry) => entry.phase === 'arrangement_clear').length, 10); assert.equal(built.entries.filter((entry) => entry.phase === 'cancel_restore').length, 0); state.gates.arrangements_cleared = true; state.gates.receivables_cleared = true; built = buildReplay(state, passengers); assert.equal(built.entries.filter((entry) => entry.phase === 'cancel_restore').length, 7); assert.equal(built.entries.filter((entry) => entry.phase === 'source_export_final').length, 0); state.gates.transitions_verified = true; state.cleanup.allowlist_id = 'ALLOW-REPLAY-001'; state.cleanup.export_evidence_frozen = true; state.cleanup.delete_authorized = true; built = buildReplay(state, passengers); assert.equal(built.entries.filter((entry) => entry.phase === 'source_export_final').length, 2); let deletes = built.entries.filter((entry) => entry.phase === 'delete_cleanup'); assert.equal(deletes.length, 1); assert.equal(deletes[0].operation.data.existing_refs.kind, 'shared_child_order'); state.cleanup.deleted_refs.push({ kind: 'shared_child_order', parent_group_no: state.objects.shared_child_order.parent_group_no, child_order_no: state.objects.shared_child_order.child_order_no, tid: '15102', ddid: '15202', marker: 'TEST-202609' }); state.cleanup.shared_plans_with_no_children.push('15102'); built = buildReplay(state, passengers); deletes = built.entries.filter((entry) => entry.phase === 'delete_cleanup'); assert.equal(deletes.length, 1); assert.equal(deletes[0].operation.data.existing_refs.kind, 'shared_plan'); state.cleanup.deleted_refs.push({ kind: 'shared_plan', parent_group_no: state.objects.shared_plan.parent_group_no, tid: '15102', marker: 'TEST-202609' }); built = buildReplay(state, passengers); deletes = built.entries.filter((entry) => entry.phase === 'delete_cleanup'); assert.equal(deletes.length, 1); assert.equal(deletes[0].operation.data.existing_refs.kind, 'independent_order'); const validation = await validateReplayOperations(built.entries); assert.ok(validation.every((item) => item.ok), JSON.stringify(validation.filter((item) => !item.ok), null, 2)); }); test('dynamic C04 children are deleted before every parent and every parent needs an explicit child-free proof', async () => { const { state: template, passengers } = await inputs(); const state = completeState(template); const dynamicPlans = state.creation.shared.batch_dates.map((departureDate, index) => { const tid = String(17101 + index); const groupNo = `LW-${departureDate.replaceAll('-', '').slice(2)}-TEST-202609-R0511`; return { identifier: groupNo, kind: 'shared_plan', group_no: groupNo, plan_no: groupNo, tid, departure_date: departureDate, owner_account: state.run.account, marker: 'TEST-202609' }; }); const dynamicChildren = dynamicPlans.map((plan, index) => { const ddid = String(18101 + index); return { identifier: `D${ddid}`, kind: 'shared_child_order', child_order_no: `D${ddid}`, ddid, tid: plan.tid, parent_group_no: plan.group_no, plan_no: plan.plan_no, departure_date: plan.departure_date, owner_account: state.run.account, marker: 'TEST-202609' }; }); state.cleanup.objects.push(...dynamicPlans, ...dynamicChildren); state.cleanup.allowlist_id = 'ALLOW-REPLAY-DYNAMIC-C04'; state.cleanup.export_evidence_frozen = true; state.cleanup.delete_authorized = true; let built = buildReplay(state, passengers); let deletes = built.entries.filter((entry) => entry.phase === 'delete_cleanup'); assert.equal(deletes.length, 4); assert.ok(deletes.every((entry) => entry.operation.data.existing_refs.kind === 'shared_child_order')); assert.deepEqual( new Set(deletes.map((entry) => entry.operation.data.existing_refs.child_order_no)), new Set([state.objects.shared_child_order.child_order_no, ...dynamicChildren.map((ref) => ref.child_order_no)]) ); state.cleanup.deleted_refs.push(state.objects.shared_child_order, ...dynamicChildren); state.cleanup.shared_plans_with_no_children.push(state.objects.shared_plan.tid); built = buildReplay(state, passengers); deletes = built.entries.filter((entry) => entry.phase === 'delete_cleanup'); assert.equal(deletes.length, 0); state.cleanup.shared_plans_with_no_children.push(...dynamicPlans.map((ref) => ref.tid)); built = buildReplay(state, passengers); deletes = built.entries.filter((entry) => entry.phase === 'delete_cleanup'); assert.equal(deletes.length, 4); assert.ok(deletes.every((entry) => entry.operation.data.existing_refs.kind === 'shared_plan')); state.cleanup.deleted_refs.push(state.objects.shared_plan, ...dynamicPlans); built = buildReplay(state, passengers); deletes = built.entries.filter((entry) => entry.phase === 'delete_cleanup'); assert.equal(deletes.length, 1); assert.equal(deletes[0].operation.data.existing_refs.kind, 'independent_order'); }); test('state validation rejects wrong owners, non-September targets and premature delete authorization', async () => { const { state: template } = await inputs(); const state = completeState(template); state.objects.independent_order.owner_account = '其他账号'; state.objects.shared_plan.departure_date = '2026-10-01'; state.cleanup.delete_authorized = true; const blockers = validateReplayState(state).join('\n'); assert.match(blockers, /owner mismatch/); assert.match(blockers, /incomplete lifecycle refs|target date missing/); assert.match(blockers, /allowlist_id/); assert.match(blockers, /export_evidence_frozen/); }); test('schema permits ERP-derived group prefix with an explicit suffix-only order number', async () => { const { state, passengers } = await inputs(); const built = buildReplay(state, passengers); const validation = await validateReplayOperations(built.entries); assert.ok(validation.every((item) => item.ok), JSON.stringify(validation, null, 2)); const schema = JSON.parse(await readFile(new URL('../schemas/standard_system_operation.schema.json', import.meta.url), 'utf8')); assert.deepEqual(schema.$defs.orderNumber.anyOf[0].required, ['suffix']); const independentRuntime = await readFile(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8'); const batchRuntime = await readFile(new URL('../chrome-extension/ltjt-order-assistant/team-batch-inpage.js', import.meta.url), 'utf8'); assert.match(independentRuntime, /operation_order_number_suffix/); assert.match(independentRuntime, /split_parent_order_number_suffix/); assert.match(batchRuntime, /order_number\?\.suffix/); });