import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import * as XLSX from 'xlsx'; import { describe, expect, it } from 'vitest'; import { parseAbutments } from '../src/core/abut/parse'; import { checkStoredAbutments, parseProject, serializeProject, SCHEMA_VERSION } from '../src/core/project'; import { abutNameError } from '../src/core/abut/name'; import { groupAbutImportIssues } from '../src/core/abut/import-issues'; import { structureOrder } from '../src/core/abut/order'; import { loadP1 } from './helpers'; const file = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url)); function workbook(name: string) { const wb = XLSX.read(readFileSync(file), { type: 'buffer' }); for (const sheet of wb.SheetNames) { const rows = XLSX.utils.sheet_to_json(wb.Sheets[sheet], { header: 1, defval: null }); for (const row of rows) if (row[0] === 'A1') row[0] = name; wb.Sheets[sheet] = XLSX.utils.aoa_to_sheet(rows); } return wb; } describe('full abutment display names are preserved across import and project reopen', () => { it.each(['A1-완도', 'A2-완도', '완도 A1 (상행) #2'])('connects all IPs and bearings for %s', (name) => { const original = parseAbutments(workbook('A1')).abutments[0]; const result = parseAbutments(workbook(name)); expect(result.issues).toEqual([]); expect(result.abutments).toEqual([{ ...original, name }]); expect(result.abutments[0].type).toBe('A1'); // The name must not choose the structural type. }); it('uses the same trimmed full name in info, IP and bearing tables', () => { const result = parseAbutments(workbook(' A1-완도 ')); expect(result.issues).toEqual([]); expect(result.abutments[0].name).toBe('A1-완도'); expect(result.abutments[0].bearings).toHaveLength(5); }); it('reopens full names and imported defaults without losing identity', () => { const base = parseAbutments(workbook('A1')).abutments[0]; const records = [{ ...base, name: 'A1-완도' }, { ...base, name: 'A2-완도', type: 'A2' as const }]; const text = serializeProject({ schemaVersion: SCHEMA_VERSION, project: { name: '복합명', bridge: '완도', client: '', author: '', crs: 'KATEC', memo: '' }, source: { fileName: '입력.xlsx', sheets: ['Pier', 'IP', 'Bearing'] }, piers: [{ id: 'P1', input: loadP1() }], abutments: records, abutDefaults: records, history: [], }); const reopened = parseProject(text); expect(reopened.abutments).toEqual(records); expect(reopened.abutDefaults).toEqual(records); }); }); describe('ambiguous and damaged names remain errors', () => { it.each(['', ' ', 'A1\n완도', 'A1\u0000완도', null, 123])('rejects an empty, non-string or control-character name: %s', (name) => { expect(abutNameError(name)).not.toBeNull(); const rec = parseAbutments(workbook('A1')).abutments[0]; expect(() => checkStoredAbutments([{ ...rec, name }])).toThrow(); }); it('rejects duplicate info rows instead of connecting to the first definition', () => { const wb = workbook('A1-완도'); const rows = wb.SheetNames.flatMap((sheet) => XLSX.utils.sheet_to_json(wb.Sheets[sheet], { header: 1, defval: null })); const info = rows.find((row) => row[0] === 'A1-완도' && row[1] === 'A1')!; XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([['교대명', '형식'], info]), '중복정보'); const result = parseAbutments(wb); expect(result.abutments).toEqual([]); expect(result.issues.filter((issue) => issue.code === 'AP09')).toHaveLength(2); const rec = parseAbutments(workbook('A1')).abutments[0]; expect(() => checkStoredAbutments([{ ...rec, name: 'A1-완도' }, { ...rec, name: ' A1-완도 ' }])).toThrow('중복'); }); it('rejects collisions with pier names in input and project validation', () => { expect(parseAbutments(workbook('P1'), ['P1']).issues.some((issue) => issue.code === 'AP09')).toBe(true); const rec = parseAbutments(workbook('A1')).abutments[0]; expect(() => checkStoredAbutments([{ ...rec, name: 'P1' }], ['P1'])).toThrow('중복'); }); it('rejects invalid workbook names and groups every dependent row under the root', () => { const result = parseAbutments(workbook('A1\n완도')); expect(result.abutments).toEqual([]); const groups = groupAbutImportIssues(result.issues); expect(groups).toHaveLength(1); expect(groups[0].primary.map((issue) => issue.code)).toEqual(['AP01']); expect(groups[0].related).toHaveLength(8); // Three IPs and five bearings remain traceable. }); it('keeps genuine orphan references visible without dropping any rows', () => { const wb = workbook('A1-완도'); for (const sheet of wb.SheetNames) { const rows = XLSX.utils.sheet_to_json(wb.Sheets[sheet], { header: 1, defval: null }); for (const row of rows) if (row[0] === 'A1-완도' && row[1] === 'A1') row[0] = '다른 교대'; wb.Sheets[sheet] = XLSX.utils.aoa_to_sheet(rows); } const result = parseAbutments(wb); const groups = groupAbutImportIssues(result.issues); const missing = groups.find((group) => group.name === 'A1-완도')!; expect(missing.primary[0].code).toBe('AP04'); expect(missing.primary[0].message).toContain('8개 행'); expect(missing.related).toHaveLength(8); expect(groups.some((group) => group.name === '다른 교대' && group.primary.some((issue) => issue.code === 'AP05'))).toBe(true); }); it('does not merge errors for different names or promote warnings to errors', () => { const groups = groupAbutImportIssues([ { code: 'AP02', level: 'E', pier: 'A1-완도', message: '형식 오류' }, { code: 'AP04', level: 'E', pier: 'A1-완도', message: 'IP 3행' }, { code: 'AP04', level: 'E', pier: 'A2-완도', message: 'IP 4행' }, { code: 'AP07', level: 'W', pier: 'A3-완도', message: '높이 차이' }, ]); expect(groups.map((group) => group.name)).toEqual(['A1-완도', 'A2-완도', 'A3-완도']); expect(groups[2].primary[0].level).toBe('W'); expect(groups[2].related).toEqual([]); }); it('sorts full names naturally inside type groups, ignoring suffix numbers as pier indices', () => { const records = [ { name: 'A2-1', type: 'A1' as const }, { name: 'A1-20', type: 'A1' as const }, { name: '완도 종점', type: 'A2' as const }, ]; expect(structureOrder(records, ['P1']).map((entry) => entry.name)).toEqual(['A1-20', 'A2-1', 'P1', '완도 종점']); expect(records[0].name).toBe('A2-1'); }); });