Files
LWLT-AIBOT/tools/build-lifecycle-replay.test.mjs
2026-08-25 10:33:29 +08:00

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/);
});