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 { 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 { 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'); });