Files
LWLT-AIBOT/control-plane/test/passenger-roster-template-tail.test.ts
2026-09-11 12:08:51 +08:00

164 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import assert from 'node:assert/strict';
import test from 'node:test';
import ExcelJS from 'exceljs';
import { parseProgramInput } from '../src/program-parser.js';
import { normalizePassengerRosterWorkbook, PassengerRosterWorkbookError } from '../src/passenger-roster-workbook.js';
const headers = ['序号', '姓名', '英文姓名', '性别', '出生日期', '出生地', '护照号码', '签发地', '签发日期', '有效期', '电话', '备注'];
const genderFormula = 'IF(OR(LEN(#REF!)=15,LEN(#REF!)=18),IF(MOD(MID(#REF!,15,3)*1,2),"男","女"),NA())';
const birthFormula = 'DATE(MID(#REF!,7,VLOOKUP(LEN(#REF!),{15,2;18,4},2,0)),MID(#REF!,VLOOKUP(LEN(#REF!),{15,9;18,11},2,0),2),MID(#REF!,VLOOKUP(LEN(#REF!),{15,11;18,13},2,0),2))';
type Mutation = (sheet: ExcelJS.Worksheet) => void;
function addPlaceholder(sheet: ExcelJS.Worksheet, row: number, sequence: number): void {
sheet.getCell(`A${row}`).value = sequence;
sheet.getCell(`D${row}`).value = { formula: genderFormula, result: { error: '#REF!' } };
sheet.getCell(`E${row}`).value = { formula: birthFormula, result: { error: '#REF!' } };
sheet.getCell(`J${row}`).value = { formula: `EDATE(I${row},10*12)-1`, result: 36500 };
}
function addNote(sheet: ExcelJS.Worksheet, firstRow: number, lastRow = firstRow): void {
sheet.mergeCells(`A${firstRow}:L${lastRow}`);
sheet.getCell(`A${firstRow}`).value = { richText: [{ text: '备注:' }, { text: '合成模板用房说明' }] };
}
async function workbook(...mutations: Mutation[]): Promise<Buffer> {
const book = new ExcelJS.Workbook();
const sheet = book.addWorksheet('Sheet1');
sheet.addRow(['合成名单模板']);
sheet.addRow(headers);
for (let sequence = 1; sequence <= 28; sequence++) {
const row = sequence + 2;
sheet.addRow([sequence, `合成游客${sequence}`, `SYNTHETIC ${sequence}`, '男', '1990-01-02', '合成城市',
`SYNTH${sequence}`, '合成城市', '2025-01-02',
{ formula: `EDATE(I${row},10*12)-1`, result: new Date('2035-01-01T00:00:00Z') },
sequence === 1 ? '13800000001' : '', sequence === 1 ? '领队' : '']);
}
for (const mutate of mutations) mutate(sheet);
return Buffer.from(await book.xlsx.writeBuffer());
}
const normalize = (content: Buffer) => normalizePassengerRosterWorkbook({ content, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' });
async function rejects(content: Buffer, code: string, row?: number): Promise<void> {
await assert.rejects(() => normalize(content), (error: unknown) => {
assert.ok(error instanceof PassengerRosterWorkbookError);
assert.equal(error.code, code);
if (row !== undefined) assert.equal(error.row, row);
return true;
});
}
test('roster tail: 28 passengers survive stale template formulas and a merged footer unchanged', async () => {
const expected = await normalize(await workbook());
const content = await workbook(sheet => {
addPlaceholder(sheet, 31, 29);
addPlaceholder(sheet, 32, 30);
addNote(sheet, 33, 34);
});
const result = await normalize(content);
assert.equal(result.headerRow, 2);
assert.equal(result.rowCount, 28);
assert.equal(result.canonicalTsv, expected.canonicalTsv);
const parsed = await parseProgramInput({
rawText: `导入独立团名单\n单号:SYNTH-ORDER-001\n名单内容:\n${result.canonicalTsv}`,
receivedAt: '2026-09-11T03:21:08Z'
});
assert.equal(parsed.status, 'agent_parse_passed');
assert.equal((parsed.operation as any).data.passenger_list.row_count, 28);
assert.deepEqual((parsed.operation as any).data.passenger_list.leader_contact, { sequence: 1, name: '合成游客1', phone: '13800000001' });
});
test('roster tail: sequence-only placeholders and separated trailing notes are not passengers', async () => {
for (const mutate of [
(sheet: ExcelJS.Worksheet) => { sheet.getCell('A31').value = 29; sheet.getCell('A32').value = 30; },
(sheet: ExcelJS.Worksheet) => { addNote(sheet, 32, 33); },
(sheet: ExcelJS.Worksheet) => { addPlaceholder(sheet, 32, 29); addNote(sheet, 34); }
]) {
assert.equal((await normalize(await workbook(mutate))).rowCount, 28);
}
});
test('roster tail: recognition follows semantic columns after reordering', async () => {
const expected = await normalize(await workbook());
const result = await normalize(await workbook(sheet => {
addPlaceholder(sheet, 31, 29);
addNote(sheet, 32, 33);
for (let row = 2; row <= 31; row++) {
const first = sheet.getCell(`A${row}`).value;
sheet.getCell(`A${row}`).value = sheet.getCell(`L${row}`).value;
sheet.getCell(`L${row}`).value = first;
}
}));
assert.equal(result.canonicalTsv, expected.canonicalTsv);
});
test('roster tail: any supplied passenger field keeps a row subject to strict formula validation', async () => {
for (const [column, value] of [['B', '合成姓名'], ['C', 'SYNTH NAME'], ['F', '合成出生地'],
['G', 'SYNTH-PASSPORT'], ['H', '合成签发地'], ['I', '2025-01-01'], ['K', '13800000001'], ['L', '领队']]) {
await rejects(await workbook(sheet => {
addPlaceholder(sheet, 31, 29);
sheet.getCell(`${column}31`).value = value;
}), 'roster_workbook_formula_not_allowed', 31);
}
await rejects(await workbook(sheet => {
sheet.getCell('D30').value = { formula: genderFormula, result: { error: '#REF!' } };
addPlaceholder(sheet, 31, 29);
}), 'roster_workbook_formula_not_allowed', 30);
});
test('roster tail: unrecognized formulas and notes cannot be silently discarded', async () => {
for (const formula of ['"男"', 'IF(A3=1,"男","女")', 'IFERROR(#REF!,"男")']) {
await rejects(await workbook(sheet => {
addPlaceholder(sheet, 31, 29);
sheet.getCell('D31').value = { formula, result: '男' };
}), 'roster_workbook_formula_not_allowed', 31);
}
await rejects(await workbook(sheet => { sheet.getCell('A31').value = '未知尾部内容'; }), 'roster_workbook_invalid_sequence', 31);
await rejects(await workbook(sheet => {
addNote(sheet, 31);
sheet.getCell('M31').value = '附加实际内容';
}), 'roster_workbook_invalid_sequence', 31);
});
test('roster tail: placeholders or notes inside the roster never hide later passengers', async () => {
await rejects(await workbook(sheet => {
sheet.getRow(15).values = [];
addPlaceholder(sheet, 15, 13);
}), 'roster_workbook_formula_not_allowed', 15);
await rejects(await workbook(sheet => {
sheet.getRow(15).values = [13];
}), 'roster_workbook_incomplete_row', 15);
await rejects(await workbook(sheet => {
sheet.getRow(15).values = [];
}), 'roster_workbook_non_contiguous_rows', 16);
await rejects(await workbook(sheet => {
sheet.getRow(15).values = [];
addNote(sheet, 15);
}), 'roster_workbook_invalid_sequence', 15);
});
test('roster tail: empty templates do not create phantom passengers', async () => {
await rejects(await workbook(sheet => {
for (let row = 3; row <= 30; row++) {
sheet.getRow(row).values = [];
addPlaceholder(sheet, row, row - 2);
}
addNote(sheet, 31);
}), 'roster_workbook_no_data');
});
test('roster tail: active content and hidden required data remain blocked across the whole sheet', async () => {
for (const column of ['D', 'M']) {
await rejects(await workbook(sheet => {
addPlaceholder(sheet, 31, 29);
sheet.getCell(`${column}31`).value = { formula: 'WEBSERVICE("https://invalid.example")', result: '' };
}), 'roster_workbook_unsafe_content', 31);
}
await rejects(await workbook(sheet => {
addPlaceholder(sheet, 31, 29);
sheet.getCell('M31').value = { text: '链接', hyperlink: 'https://invalid.example' };
}), 'roster_workbook_unsafe_content');
await rejects(await workbook(sheet => { addPlaceholder(sheet, 31, 29); sheet.getRow(31).hidden = true; }), 'roster_workbook_hidden_data', 31);
await rejects(await workbook(sheet => { addPlaceholder(sheet, 31, 29); sheet.getColumn('D').hidden = true; }), 'roster_workbook_hidden_data');
});