import { describe, expect, it } from 'vitest'; import { groundElFor, parseGroundModel, strataRowsFor } from '../src/core/ground-model'; import { changedKeys, overrideKeys, revertDraft, type PierValues } from '../src/core/changes'; import { parseProject, serializeProject, SCHEMA_VERSION, type ProjectFile } from '../src/core/project'; import { loadP1 } from './helpers'; /** The shape written by RAG/pierzainer_ground (schema 1). */ const SAMPLE = JSON.stringify({ schema: 1, generated: '2026-10-23', coord: '측량 X(북) · Y(동), m. 치수 mm', piers: [ { id: 'P1', ground: { center: 29.355, excel: 28.931, used: 29.355, source: '계산' }, footing: { cover: 0.972 } }, { id: 'P2', ground: { center: null, excel: null, used: null, source: '없음' } }, ], }); describe('지반 모델 읽기 (ground_model.json, schema 1)', () => { it('reads the pier-centre ground EL and the excel value', () => { const m = parseGroundModel(SAMPLE); expect(m.piers[0]).toEqual({ id: 'P1', centerEl: 29.355, excelEl: 28.931 }); expect(groundElFor(m, 'P1')).toBe(29.355); }); it('a pier outside the terrain has no value, and an unknown pier has none either', () => { const m = parseGroundModel(SAMPLE); expect(groundElFor(m, 'P2')).toBeUndefined(); expect(groundElFor(m, 'P9')).toBeUndefined(); }); it('refuses another schema number instead of guessing the layout', () => { expect(() => parseGroundModel(SAMPLE.replace('"schema":1', '"schema":3'))).toThrow('형식 번호 3'); }); it('refuses a file without a pier list or without pier names', () => { expect(() => parseGroundModel('{"schema": 1}')).toThrow('교각 목록'); expect(() => parseGroundModel('{"schema": 1, "piers": [{"ground": {}}]}')).toThrow('이름(id)'); }); it('refuses text that is not JSON', () => { expect(() => parseGroundModel('not json')).toThrow('JSON으로 읽히지 않습니다'); }); }); const LEGACY_SAMPLE = JSON.stringify({ schema: 1, piers: [{ id: 'P1', ground: { center: 29.355, excel: 28.931 } }] }); describe('지형 격자 (schema 2)', () => { const WITH_GRID = JSON.stringify({ schema: 2, generated: '2026-10-23', piers: [{ id: 'P1', ground: { center: 29.355, excel: 28.931 } }], terrain_grid: { origin: [259815.676, 179088.094], step: 5, nx: 2, ny: 2, z: [28, null, 29, 30] }, }); it('reads the terrain grid as terrainGrid', () => { const m = parseGroundModel(WITH_GRID); expect(m.schema).toBe(2); expect(m.terrainGrid).toEqual({ origin: [259815.676, 179088.094], step: 5, nx: 2, ny: 2, z: [28, null, 29, 30] }); }); it('reads both embedded map images and their survey-coordinate corners', () => { const raw = JSON.parse(WITH_GRID) as Record; const image = 'data:image/jpeg;base64,/9j/2Q=='; raw.visualization = { layers: [ { id: 'satellite', name: '위성사진', mime_type: 'image/jpeg', image, survey_corners: [[1, 2], [1, 3], [4, 2], [4, 3]] }, { id: 'ground', name: '2D 지도', mime_type: 'image/jpeg', image, survey_corners: [[1, 2], [1, 3], [4, 2], [4, 3]] }, ], }; const model = parseGroundModel(JSON.stringify(raw)); expect(model.mapLayers?.map((layer) => layer.id)).toEqual(['satellite', 'ground']); expect(model.mapLayers?.[0].survey_corners[0]).toEqual([1, 2]); }); it('ignores legacy map layers without coordinate-matched image corners', () => { const raw = JSON.parse(WITH_GRID) as Record; raw.visualization = { layers: [ { id: 'satellite', mime_type: 'image/jpeg', image: 'data:image/jpeg;base64,/9j/2Q==' }, { id: 'ground', mime_type: 'image/jpeg', image: 'data:image/jpeg;base64,/9j/2Q==' }, ], }; expect(parseGroundModel(JSON.stringify(raw)).mapLayers).toEqual([]); }); it('a schema 1 file has no terrain grid', () => { expect(parseGroundModel(LEGACY_SAMPLE).terrainGrid).toBeUndefined(); }); it('refuses a grid whose value count is not nx × ny', () => { expect(() => parseGroundModel(WITH_GRID.replace('"z":[28,null,29,30]', '"z":[28,29]'))).toThrow('nx × ny'); }); it('refuses a grid value that is not a number or null', () => { expect(() => parseGroundModel(WITH_GRID.replace('"z":[28,null,29,30]', '"z":[28,"x",29,30]'))).toThrow('숫자가 아닌'); }); it('refuses an unknown schema number', () => { expect(() => parseGroundModel(WITH_GRID.replace('"schema":2', '"schema":3'))).toThrow('형식 번호 3'); }); it('keeps the grid in a saved project', () => { const m = parseGroundModel(WITH_GRID); const text = serializeProject({ schemaVersion: SCHEMA_VERSION, project: { name: 'x', bridge: '운곡교', client: '', author: '', crs: 'KATEC', memo: '' }, source: { fileName: 'x.xlsx', sheets: [] }, piers: [{ id: 'P1', input: loadP1() }], history: [], groundModel: m, }); expect(parseProject(text).groundModel?.terrainGrid).toEqual(m.terrainGrid); }); }); describe('형식 번호 검사', () => { it('accepts a legacy file without a schema number when it has the standard pier structure', () => { const m = parseGroundModel(JSON.stringify({ piers: [{ id: 'P1', ground: { center: 29.355, excel: 28.931 } }] })); expect(m.schema).toBe(2); expect(m.piers[0].id).toBe('P1'); }); it('refuses a null schema number', () => { expect(() => parseGroundModel('{"schema": null, "piers": []}')).toThrow('형식 번호 null'); }); it('refuses a pier that has no ground object', () => { expect(() => parseGroundModel(JSON.stringify({ schema: 1, piers: [{ id: 'P2' }] }))).toThrow('P2: 지반 값(ground)이 없습니다'); }); }); describe('지반고 적용 (사용자가 누를 때만, 변경으로 기록)', () => { const base = loadP1(); const pier: PierValues = { defaults: base, input: base, draft: { ...base, groundEl: 29.355 } }; it('an applied value counts as an override and can be reverted to the excel value', () => { expect(overrideKeys(pier)).toContain('groundEl'); expect(revertDraft(pier, 'groundEl').groundEl).toBe(base.groundEl); }); it('the draft and the generated input are compared, so the change waits for regeneration', () => { expect(changedKeys(pier.input, pier.draft)).toEqual(['groundEl']); }); }); describe('프로젝트 파일의 지반 모델', () => { function project(extra: Partial): ProjectFile { const p1 = loadP1(); return { schemaVersion: SCHEMA_VERSION, project: { name: 'x', bridge: '운곡교', client: '', author: '', crs: 'KATEC', memo: '' }, source: { fileName: 'x.xlsx', sheets: [] }, piers: [{ id: 'P1', input: p1 }], history: [], ...extra, }; } it('keeps the imported ground model across save and open', () => { const m = parseGroundModel(SAMPLE); const reopened = parseProject(serializeProject(project({ groundModel: m }))); expect(reopened.groundModel).toEqual(m); }); it('refuses a damaged ground model inside the project file', () => { const text = serializeProject(project({})).replace('"history": []', '"history": [], "groundModel": {"schema": 1}'); 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(); }); }); describe('지반 모델의 지층 기둥 (soil.layers)', () => { const WITH_SOIL = JSON.stringify({ schema: 2, piers: [{ id: 'P1', ground: { center: 29.355, excel: 28.931 }, soil: { layers: [{ name: '매립토', top_el: 29.355, bottom_el: 27.5 }, { name: '연암', top_el: 27.5, bottom_el: 10 }] }, }], }); it('reads the layers as strata rows (name and bottom EL, top to bottom)', () => { const m = parseGroundModel(WITH_SOIL); expect(strataRowsFor(m, 'P1')).toEqual([{ name: '매립토', bottomEl: 27.5 }, { name: '연암', bottomEl: 10 }]); expect(strataRowsFor(m, 'P9')).toBeUndefined(); }); it('a pier without a soil column has no layers', () => { expect(strataRowsFor(parseGroundModel(SAMPLE), 'P1')).toBeUndefined(); }); it('refuses a soil layers entry that is not a list', () => { const data = JSON.parse(WITH_SOIL); data.piers[0].soil.layers = { x: 1 }; expect(() => parseGroundModel(JSON.stringify(data))).toThrow('지층(soil.layers) 형식'); }); it('keeps the layers in a saved project and refuses a damaged layer', () => { const m = parseGroundModel(WITH_SOIL); const text = serializeProject({ schemaVersion: SCHEMA_VERSION, project: { name: 'x', bridge: '운곡교', client: '', author: '', crs: 'KATEC', memo: '' }, source: { fileName: 'x.xlsx', sheets: [] }, piers: [{ id: 'P1', input: loadP1() }], history: [], groundModel: m, }); expect(strataRowsFor(parseProject(text).groundModel!, 'P1')).toEqual(strataRowsFor(m, 'P1')); const bad = text.replace('"bottomEl": 10', '"bottomEl": "10"'); expect(() => parseProject(bad)).toThrow('지층 값이 맞지 않습니다'); }); });