289 lines
15 KiB
JavaScript
289 lines
15 KiB
JavaScript
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/);
|
|
});
|