교대 이름을 전체 명칭으로 해석하고, 엑셀 가져오기 문제를 목록으로 모은다. 교각 이름과의 중복을 저장 파일 검사에서도 막는다(checkStoredAbutments). 검사 스크립트 verify-abut-names · verify-abut-workflow 포함. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
129 lines
6.5 KiB
TypeScript
129 lines
6.5 KiB
TypeScript
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<unknown[]>(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<unknown[]>(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<unknown[]>(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');
|
|
});
|
|
});
|