diff --git a/src/core/project.ts b/src/core/project.ts new file mode 100644 index 0000000..1fdbe91 --- /dev/null +++ b/src/core/project.ts @@ -0,0 +1,59 @@ +import type { PierInput } from './types'; + +/** Project file schema (spec §8). Bump only with a migration. */ +export const SCHEMA_VERSION = 1; + +export interface ProjectInfo { + name: string; + bridge: string; + client: string; + author: string; + crs: string; + memo: string; +} + +export interface ProjectPier { + id: string; + /** Input as read from the spreadsheet. The 3D model is not stored; it is regenerated on open (spec §8). */ + input: PierInput; +} + +export interface ProjectFile { + schemaVersion: number; + project: ProjectInfo; + source: { fileName: string; sheets: string[] }; + piers: ProjectPier[]; + history: { version: string; note: string; at: string }[]; +} + +/** Serialises a project to the pretty-printed JSON stored in <교량명>_교각설계.pzr.json. */ +export function serializeProject(p: ProjectFile): string { + return `${JSON.stringify(p, null, 2)}\n`; +} + +/** + * Parses and checks a project file. Throws with a message that names the first problem, so a damaged file is + * refused instead of being half-loaded. + */ +export function parseProject(text: string): ProjectFile { + let raw: unknown; + try { + raw = JSON.parse(text); + } catch { + throw new Error('프로젝트 파일이 JSON으로 읽히지 않습니다.'); + } + const obj = raw as Partial; + if (!obj || typeof obj !== 'object') throw new Error('프로젝트 파일 형식이 아닙니다.'); + if (obj.schemaVersion !== SCHEMA_VERSION) { + throw new Error(`지원하지 않는 파일 버전입니다 (schemaVersion ${String(obj.schemaVersion)}, 지원: ${SCHEMA_VERSION}).`); + } + if (!Array.isArray(obj.piers)) throw new Error('piers 목록이 없습니다.'); + obj.piers.forEach((p, i) => { + if (!p || typeof p.id !== 'string' || !p.input) throw new Error(`교각 ${i + 1}: id 또는 input이 없습니다.`); + const input = p.input; + if (typeof input.name !== 'string' || !Array.isArray(input.ips) || !Array.isArray(input.bearings)) { + throw new Error(`교각 ${i + 1}(${String(input.name)}): 입력 형식이 맞지 않습니다.`); + } + }); + return obj as ProjectFile; +} diff --git a/tests/project.test.ts b/tests/project.test.ts new file mode 100644 index 0000000..3715474 --- /dev/null +++ b/tests/project.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { buildPier } from '../src/core/geometry'; +import { parseProject, serializeProject, SCHEMA_VERSION, type ProjectFile } from '../src/core/project'; +import { loadP1 } from './helpers'; + +function sampleProject(): ProjectFile { + const p1 = loadP1(); + // 20 piers: P1 plus 19 variants with different names and coping widths, so the file has a realistic size. + const piers = Array.from({ length: 20 }, (_, i) => { + const input = i === 0 ? p1 : { ...p1, name: `P${i + 1}`, copingWidthMm: 3100 + i * 10 }; + return { id: `pier-${i + 1}`, input }; + }); + return { + schemaVersion: SCHEMA_VERSION, + project: { name: '운곡교 교각', bridge: '운곡교', client: '', author: '', crs: 'KATEC', memo: '' }, + source: { fileName: '운곡교P1입력.xlsx', sheets: ['교각', 'IP', '받침'] }, + piers, + history: [{ version: '0.1.00', note: '최초 저장', at: '2026-10-22T00:00:00Z' }], + }; +} + +describe('project file round trip (spec §8, plan B3)', () => { + it('save → open gives the same inputs, without the source workbook', () => { + const original = sampleProject(); + const text = serializeProject(original); + const reopened = parseProject(text); + expect(reopened).toEqual(original); + }); + + it('regenerated models match the originals after reopening (20 piers)', () => { + const original = sampleProject(); + const reopened = parseProject(serializeProject(original)); + original.piers.forEach((p, i) => { + expect(buildPier(reopened.piers[i].input)).toEqual(buildPier(p.input)); + }); + }); + + it('refuses another schema version instead of loading it', () => { + const text = serializeProject({ ...sampleProject(), schemaVersion: 99 }); + expect(() => parseProject(text)).toThrow('지원하지 않는 파일 버전'); + }); + + it('refuses a damaged file and names the broken pier', () => { + const project = sampleProject(); + const broken = { ...project, piers: [project.piers[0], { id: 'pier-2', input: null }] }; + expect(() => parseProject(JSON.stringify(broken))).toThrow('교각 2'); + expect(() => parseProject('{ not json')).toThrow('JSON'); + }); +});