From be76425c8fe20a40d2a55560653eabba13713ae1 Mon Sep 17 00:00:00 2001 From: ypshim Date: Fri, 23 Oct 2026 17:41:24 +0900 Subject: [PATCH] feat(ground): accept ground_model.json written before the schema number - A file with no `schema` key is read as schema 1, but every pier must have a `ground` object, so a renamed field is refused instead of loading as empty. - `schema` null or any other number is still refused. - The project file keeps the normalised model and checks it with its own check (checkStoredGroundModel), so a saved model reopens. - Temporary: remove the no-schema branch once examples/out is regenerated. Co-Authored-By: Claude Haiku 5.5 --- src/core/ground-model.ts | 31 +- src/core/project.ts | 4 +- tests/fixtures/ground-model-legacy.json | 393 ++++++++++++++++++++++++ tests/ground-model.test.ts | 31 ++ 4 files changed, 453 insertions(+), 6 deletions(-) create mode 100644 tests/fixtures/ground-model-legacy.json diff --git a/src/core/ground-model.ts b/src/core/ground-model.ts index 8ed7b0d..f3f21a2 100644 --- a/src/core/ground-model.ts +++ b/src/core/ground-model.ts @@ -25,21 +25,44 @@ function numOrNull(v: unknown): number | null { return typeof v === 'number' && Number.isFinite(v) ? v : null; } -/** Checks the shape of a parsed ground_model.json. Throws with the first problem named in Korean. */ +/** + * Checks the shape of a parsed ground_model.json. Throws with the first problem named in Korean. + * A file with no `schema` key is the layout written before the number was added: it is read as schema 1, and the + * `ground` object is required so that a renamed field is refused instead of loading as empty values. + * TODO: remove the no-schema branch once the 옛 파일 (examples/out) is regenerated with schema 1. + */ export function checkGroundModel(raw: unknown): GroundModelFile { const obj = raw as Partial | null; if (!obj || typeof obj !== 'object') throw new Error('지반 모델 파일 형식이 아닙니다.'); - if (obj.schema !== GROUND_MODEL_SCHEMA) { + if (obj.schema !== undefined && obj.schema !== GROUND_MODEL_SCHEMA) { throw new Error(`지반 모델 형식 번호 ${String(obj.schema)}을(를) 읽을 수 없습니다 (지원: ${GROUND_MODEL_SCHEMA}).`); } if (!Array.isArray(obj.piers)) throw new Error('지반 모델 파일에 교각 목록(piers)이 없습니다.'); const piers = obj.piers.map((p, i) => { const id = (p as { id?: unknown })?.id; if (typeof id !== 'string' || !id) throw new Error(`지반 모델 ${i + 1}번째 교각의 이름(id)이 없습니다.`); - const ground = (p as { ground?: { center?: unknown; excel?: unknown } }).ground ?? {}; + const ground = (p as { ground?: { center?: unknown; excel?: unknown } }).ground; + if (!ground || typeof ground !== 'object') throw new Error(`지반 모델 ${id}: 지반 값(ground)이 없습니다.`); return { id, centerEl: numOrNull(ground.center), excelEl: numOrNull(ground.excel) }; }); - return { schema: obj.schema, generated: typeof obj.generated === 'string' ? obj.generated : '', piers }; + return { schema: GROUND_MODEL_SCHEMA, generated: typeof obj.generated === 'string' ? obj.generated : '', piers }; +} + +/** + * Checks the normalised model stored in a project file (the form checkGroundModel returns). The project keeps this + * form, not the raw ground_model.json, so it is checked separately. + */ +export function checkStoredGroundModel(raw: unknown): GroundModelFile { + const obj = raw as Partial | null; + if (!obj || typeof obj !== 'object' || obj.schema !== GROUND_MODEL_SCHEMA) { + throw new Error('프로젝트 안의 지반 모델 형식 번호가 맞지 않습니다.'); + } + if (!Array.isArray(obj.piers)) throw new Error('프로젝트 안의 지반 모델에 교각 목록이 없습니다.'); + for (const p of obj.piers as Partial[]) { + const ok = typeof p?.id === 'string' && (p.centerEl === null || typeof p.centerEl === 'number') && (p.excelEl === null || typeof p.excelEl === 'number'); + if (!ok) throw new Error(`프로젝트 안의 지반 모델 교각 ${String(p?.id)}의 값이 맞지 않습니다.`); + } + return obj as GroundModelFile; } /** Parses the file text. Throws when it is not JSON or does not match the schema. */ diff --git a/src/core/project.ts b/src/core/project.ts index 5c28599..a0c0703 100644 --- a/src/core/project.ts +++ b/src/core/project.ts @@ -1,5 +1,5 @@ import type { FckByMember, Material } from './rebar/common'; -import { checkGroundModel, type GroundModelFile } from './ground-model'; +import { checkStoredGroundModel, type GroundModelFile } from './ground-model'; import type { PierInput } from './types'; /** Project file schema (spec §8). Bump only with a migration. */ @@ -71,7 +71,7 @@ export function parseProject(text: string): ProjectFile { } } } - if (obj.groundModel !== undefined) checkGroundModel(obj.groundModel); + if (obj.groundModel !== undefined) checkStoredGroundModel(obj.groundModel); obj.piers.forEach((p, i) => { if (!p || typeof p.id !== 'string' || !p.input) throw new Error(`교각 ${i + 1}: id 또는 input이 없습니다.`); const input = p.input; diff --git a/tests/fixtures/ground-model-legacy.json b/tests/fixtures/ground-model-legacy.json new file mode 100644 index 0000000..d489ead --- /dev/null +++ b/tests/fixtures/ground-model-legacy.json @@ -0,0 +1,393 @@ +{ + "generated": "2026-10-23", + "coord": "측량 X(북) · Y(동), m. 치수 mm", + "terrain": { + "file": "C0050202-001.dxf", + "spot_blocks": 433, + "spot_texts": 190, + "contours": 67, + "line3d_vertices": 0, + "points_used": 3429, + "by_source": { + "spot": 431, + "text": 5, + "contour": 2993, + "line3d": 0 + }, + "removed": 1, + "boreholes": 18, + "extent": [ + 259779.6342279386, + 179066.3432863709, + 260208.0132279157, + 179261.3784074617 + ] + }, + "removed_points": [ + { + "X": 260155.762358268, + "Y": 179189.228890184, + "Z": -18.115, + "source": "spot", + "reason": "EL 범위 밖 (0.0–200.0)" + } + ], + "boreholes_dxf": [ + { + "id": "BB-24", + "X": 260110.5200305235, + "Y": 179142.87077588285 + }, + { + "id": "BB-28", + "X": 259832.7395636612, + "Y": 179078.6772609205 + }, + { + "id": "NBB-21", + "X": 260169.7415555437, + "Y": 179217.2592038325 + }, + { + "id": "NBB-22", + "X": 260119.0843895569, + "Y": 179207.447663092 + }, + { + "id": "NBB-23", + "X": 260063.7209158014, + "Y": 179200.7170868495 + }, + { + "id": "NBB-24", + "X": 260013.7365954409, + "Y": 179193.2691158374 + }, + { + "id": "NBB-25", + "X": 259959.8230206913, + "Y": 179182.585023065 + }, + { + "id": "NBB-26", + "X": 259908.1691879086, + "Y": 179170.4141072329 + }, + { + "id": "NBB-27", + "X": 259862.0303893618, + "Y": 179162.6491102049 + }, + { + "id": "NBB-28", + "X": 259804.6978413376, + "Y": 179143.6022597326 + }, + { + "id": "NBB-29", + "X": 260162.3349768046, + "Y": 179177.2095564581 + }, + { + "id": "NBB-30", + "X": 260115.11201831407, + "Y": 179163.3224655297 + }, + { + "id": "NBB-31", + "X": 260052.92384203407, + "Y": 179154.4466218247 + }, + { + "id": "NBB-32", + "X": 260004.4464295526, + "Y": 179155.3905176037 + }, + { + "id": "NBB-33", + "X": 259953.9248062317, + "Y": 179147.6435384383 + }, + { + "id": "NBB-34", + "X": 259903.7958026133, + "Y": 179135.2767765673 + }, + { + "id": "NBB-35", + "X": 259855.9217242609, + "Y": 179124.5131579286 + }, + { + "id": "NBB-36", + "X": 259804.9274191266, + "Y": 179111.1866481418 + } + ], + "borehole_issues": [], + "piers": [ + { + "id": "P1", + "X": 259865.676, + "Y": 179138.094, + "axis_dir": [ + 0.16722, + -0.98592 + ], + "ground": { + "center": 29.355, + "corners": [ + 29.536, + 29.441, + 29.18, + 29.27 + ], + "min": 29.18, + "max": 29.536, + "used": 29.355, + "source": "계산", + "dtm_spacing": 19.0, + "excel": 28.931, + "diff_vs_excel": 0.424 + }, + "footing": { + "L_mm": 10400.0, + "W_mm": 8150.0, + "top_el": 28.208, + "bottom_el": 26.208, + "cover": 0.972 + }, + "pile": { + "tip_el": 2.208, + "d_mm": 609.6 + }, + "soil": null, + "alerts": [ + { + "code": "W09", + "level": "경고", + "pier": "P1", + "message": "엑셀 지반고 28.931와 지형 29.355의 차이 +0.424 m가 허용 0.3 m를 넘습니다." + }, + { + "code": "I05", + "level": "정보", + "pier": "P1", + "message": "시추 자료가 없어 지층을 판정하지 않았습니다." + } + ] + }, + { + "id": "P2", + "X": 259914.439, + "Y": 179149.112, + "axis_dir": [ + 0.167151, + -0.985931 + ], + "ground": { + "center": 28.167, + "corners": [], + "min": 28.167, + "max": 28.167, + "used": 28.167, + "source": "계산", + "dtm_spacing": 19.5, + "excel": null, + "diff_vs_excel": null + }, + "footing": { + "L_mm": null, + "W_mm": null, + "top_el": null, + "bottom_el": null, + "cover": null + }, + "pile": { + "tip_el": null, + "d_mm": null + }, + "soil": null, + "alerts": [ + { + "code": "I05", + "level": "정보", + "pier": "P2", + "message": "시추 자료가 없어 지층을 판정하지 않았습니다." + } + ] + }, + { + "id": "P3", + "X": 259963.44, + "Y": 179159.017, + "axis_dir": [ + 0.167117, + -0.985937 + ], + "ground": { + "center": 28.12, + "corners": [], + "min": 28.12, + "max": 28.12, + "used": 28.12, + "source": "계산", + "dtm_spacing": 48.6, + "excel": null, + "diff_vs_excel": null + }, + "footing": { + "L_mm": null, + "W_mm": null, + "top_el": null, + "bottom_el": null, + "cover": null + }, + "pile": { + "tip_el": null, + "d_mm": null + }, + "soil": null, + "alerts": [ + { + "code": "I07", + "level": "정보", + "pier": "P3", + "message": "지반고를 만든 지형 점이 최대 48.6 m 떨어져 있습니다 (기준 30 m). 현장 측량값을 확인하세요." + }, + { + "code": "I05", + "level": "정보", + "pier": "P3", + "message": "시추 자료가 없어 지층을 판정하지 않았습니다." + } + ] + }, + { + "id": "P4", + "X": 260012.654, + "Y": 179167.802, + "axis_dir": [ + 0.167117, + -0.985937 + ], + "ground": { + "center": 28.608, + "corners": [], + "min": 28.608, + "max": 28.608, + "used": 28.608, + "source": "계산", + "dtm_spacing": 44.3, + "excel": null, + "diff_vs_excel": null + }, + "footing": { + "L_mm": null, + "W_mm": null, + "top_el": null, + "bottom_el": null, + "cover": null + }, + "pile": { + "tip_el": null, + "d_mm": null + }, + "soil": null, + "alerts": [ + { + "code": "I07", + "level": "정보", + "pier": "P4", + "message": "지반고를 만든 지형 점이 최대 44.3 m 떨어져 있습니다 (기준 30 m). 현장 측량값을 확인하세요." + }, + { + "code": "I05", + "level": "정보", + "pier": "P4", + "message": "시추 자료가 없어 지층을 판정하지 않았습니다." + } + ] + }, + { + "id": "P5", + "X": 260062.055, + "Y": 179175.464, + "axis_dir": [ + 0.1671, + -0.98594 + ], + "ground": { + "center": 27.242, + "corners": [], + "min": 27.242, + "max": 27.242, + "used": 27.242, + "source": "계산", + "dtm_spacing": 2.7, + "excel": null, + "diff_vs_excel": null + }, + "footing": { + "L_mm": null, + "W_mm": null, + "top_el": null, + "bottom_el": null, + "cover": null + }, + "pile": { + "tip_el": null, + "d_mm": null + }, + "soil": null, + "alerts": [ + { + "code": "I05", + "level": "정보", + "pier": "P5", + "message": "시추 자료가 없어 지층을 판정하지 않았습니다." + } + ] + }, + { + "id": "P6", + "X": 260116.582, + "Y": 179182.59, + "axis_dir": [ + 0.167062, + -0.985946 + ], + "ground": { + "center": 28.766, + "corners": [], + "min": 28.766, + "max": 28.766, + "used": 28.766, + "source": "계산", + "dtm_spacing": 14.3, + "excel": null, + "diff_vs_excel": null + }, + "footing": { + "L_mm": null, + "W_mm": null, + "top_el": null, + "bottom_el": null, + "cover": null + }, + "pile": { + "tip_el": null, + "d_mm": null + }, + "soil": null, + "alerts": [ + { + "code": "I05", + "level": "정보", + "pier": "P6", + "message": "시추 자료가 없어 지층을 판정하지 않았습니다." + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/ground-model.test.ts b/tests/ground-model.test.ts index 52883bd..a4e076c 100644 --- a/tests/ground-model.test.ts +++ b/tests/ground-model.test.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; import { groundElFor, parseGroundModel } from '../src/core/ground-model'; import { changedKeys, overrideKeys, revertDraft, type PierValues } from '../src/core/changes'; @@ -42,6 +43,29 @@ describe('지반 모델 읽기 (ground_model.json, schema 1)', () => { }); }); +describe('번호 없는 옛 지반 모델 (B′: 임시 경로)', () => { + // examples/out/ground_model.json as written before the schema number existed (P1–P6, same values as the report). + const LEGACY = readFileSync(new URL('./fixtures/ground-model-legacy.json', import.meta.url), 'utf-8'); + + it('reads the unversioned file and reports it as schema 1', () => { + const m = parseGroundModel(LEGACY); + expect(m.schema).toBe(1); + expect(m.piers.map((p) => p.id)).toEqual(['P1', 'P2', 'P3', 'P4', 'P5', 'P6']); + expect(groundElFor(m, 'P1')).toBe(29.355); + expect(m.piers[0].excelEl).toBe(28.931); + }); + + it('refuses a pier that has no ground object (a renamed field must not load as empty values)', () => { + const data = JSON.parse(LEGACY); + delete data.piers[1].ground; + expect(() => parseGroundModel(JSON.stringify(data))).toThrow('P2: 지반 값(ground)이 없습니다'); + }); + + it('refuses a null schema number instead of treating it as missing', () => { + expect(() => parseGroundModel(LEGACY.replace('{', '{"schema": null,'))).toThrow('형식 번호 null'); + }); +}); + describe('지반고 적용 (사용자가 누를 때만, 변경으로 기록)', () => { const base = loadP1(); const pier: PierValues = { defaults: base, input: base, draft: { ...base, groundEl: 29.355 } }; @@ -80,6 +104,13 @@ describe('프로젝트 파일의 지반 모델', () => { expect(() => parseProject(text)).toThrow('교각 목록'); }); + it('refuses a stored model whose pier values are not numbers or null', () => { + const m = parseGroundModel(SAMPLE); + const bad = { ...m, piers: [{ id: 'P1', centerEl: '29.3', excelEl: null }] }; + const text = serializeProject(project({})).replace('"history": []', `"history": [], "groundModel": ${JSON.stringify(bad)}`); + expect(() => parseProject(text)).toThrow('P1의 값이 맞지 않습니다'); + }); + it('opens an older file that has no ground model', () => { expect(parseProject(serializeProject(project({}))).groundModel).toBeUndefined(); });