import { describe, expect, it } from 'vitest'; import { buildPier } from '../src/core/geometry'; import { buildPierScene } from '../src/view/scene'; import { buildRebar3D } from '../src/view/rebar3d'; import { rebarSummary } from '../src/core/rebar/summary'; import { buildQuantity, quantityRows } from '../src/core/rebar/quantity'; import { DEFAULT_MATERIAL } from '../src/core/rebar/common'; import { castSettingsIssues, type CastInPlaceRebar } from '../src/core/rebar/cast-in-place'; import { castRebarLayout } from '../src/core/rebar/cast-layout'; import { parseProject, serializeProject, SCHEMA_VERSION, type ProjectFile } from '../src/core/project'; import { changedKeys } from '../src/core/changes'; import { validateInput } from '../src/core/validate'; import type { Issue, PierInput } from '../src/core/types'; import { loadP1 } from './helpers'; const settings: CastInPlaceRebar = { column: { mainDiameterMm: 32, mainCount: 46, coverMm: 100, mainGradeMpa: 500 }, pile: { mainDiameterMm: 32, mainCount: 46, cageLengthMm: 12000, tieKind: 'spiral' }, connection: { mode: 'continuous' } }; function project(input: PierInput): ProjectFile { return { schemaVersion: SCHEMA_VERSION, project: { name: '', bridge: '', client: '', author: '', crs: '', memo: '' }, source: { fileName: '', sheets: [] }, history: [], piers: [{ id: 'one', input, defaults: { ...input, castInPlaceRebar: undefined }, draft: { ...input, castInPlaceRebar: settings } }] }; } describe('cast-in-place independent rendering and incomplete quantities', () => { for (const [type, columnCount] of [['T형', 1], ['2주형', 2], ['3주형', 3]] as const) { for (const foundationType of ['직접기초', '말뚝기초', '현장타설말뚝'] as const) { it(`${type} / ${foundationType}: concrete, rebar and summary remain usable`, () => { // 3주형 needs a coping long enough for the 오목형 spans (RB-M01 otherwise). const m = buildPier({ ...loadP1(), ...(columnCount === 3 ? { copingLengthMm: 20000 } : {}), type, columnCount, foundationType }); const concrete = buildPierScene(m); expect(concrete.parts.coping.children.length).toBeGreaterThan(0); const rebar = buildRebar3D(m); const result = rebarSummary(m); expect(rebar.byName.get('C1')!.length).toBeGreaterThan(0); const q = buildQuantity(result.rows, DEFAULT_MATERIAL, result.incomplete); if (foundationType === '현장타설말뚝') { expect(m.footings).toHaveLength(0); expect(m.piles).toHaveLength(columnCount); m.columns.forEach((c, i) => expect(c.bottom).toBe(m.piles[i].top)); expect(rebar.byName.get('기둥-P1')!.length).toBeGreaterThan(0); expect(rebar.byName.get('말뚝-P1')!.length).toBeGreaterThan(0); expect(rebar.byName.get('기둥-P3')!.length).toBeGreaterThan(0); expect(rebar.byName.get('말뚝-P3')!.length).toBeGreaterThan(0); expect(rebar.byName.has('B1')).toBe(false); expect(result.rows.some(r => r.member === 'column')).toBe(true); expect(result.rows.some(r => r.member === 'pile')).toBe(true); expect(result.rows.some(r => r.member === 'footing')).toBe(false); expect(q.missing).toEqual(result.incomplete); expect(q.missing).toEqual(expect.arrayContaining(castRebarLayout(m).missing)); expect(q.missing).toContain('말뚝 저부 P6 검토안 미적용'); expect(quantityRows(q).filter(r => r[0] === '미계산 · 합계 미완료')).toHaveLength(q.missing.length); expect(q.kg).toBeGreaterThan(0); } else { expect(rebar.byName.get('P1')!.length).toBeGreaterThan(0); if (columnCount === 1) expect(result.incomplete).toEqual([]); else expect(result.incomplete).toContain('다주형 철근 번호 대응·cycle별 이음 위치·수평철근 갈고리 상세 확인 필요'); expect(q.missing).toEqual(result.incomplete); } rebar.group.traverse(o => { const mesh = o as import('three').Mesh; mesh.geometry?.dispose(); }); }); } } }); describe('cast-in-place storage and validation', () => { it('round-trips dedicated settings in generated/default/draft inputs without merging legacy settings', () => { const input = { ...loadP1(), foundationType: '현장타설말뚝' as const, castInPlaceRebar: settings }; const p = project(input); expect(parseProject(serializeProject(p))).toEqual(p); expect(changedKeys({ ...input, castInPlaceRebar: undefined }, input)).toContain('castInPlaceRebar'); }); it('legacy projects keep absent settings absent rather than inventing defaults', () => { const p = project(loadP1()); delete p.piers[0].draft; expect(parseProject(serializeProject(p)).piers[0].input.castInPlaceRebar).toBeUndefined(); }); for (const bad of [null, [], { pile: [] }, { column: { mainCount: 2.5 } }, { pile: { coverMm: -1 } }, { connection: { mode: ['continuous'] } }, { column: { tieKind: 'unknown' } }, { column: { mainCount: '46' } }, { pile: { extra: 1 } }]) { it(`rejects malformed settings ${JSON.stringify(bad)} in saved draft`, () => { const p = project(loadP1()); p.piers[0].draft!.castInPlaceRebar = bad as CastInPlaceRebar; expect(() => parseProject(serializeProject(p))).toThrow('현장타설'); }); } it('geometry length changes produce a warning without destroying concrete or preventing reopening', () => { const input = { ...loadP1(), foundationType: '현장타설말뚝' as const, pileLengthMm: 10000, castInPlaceRebar: settings }; const issues: Issue[] = []; expect(validateInput(input, issues)).toBe(true); expect(issues.some(i => i.code === 'CIP01')).toBe(true); expect(() => parseProject(serializeProject(project(input)))).not.toThrow(); }); it('has no numeric defaults and checks cage length and cover geometry', () => { expect(castSettingsIssues({})).toEqual([]); expect(castSettingsIssues({ pile: { cageLengthMm: 15000 } }, 10000)).toHaveLength(1); expect(castSettingsIssues({ column: { coverMm: 1000 } }, 10000, 2000)).toHaveLength(1); expect(castSettingsIssues({ pile: { mainCount: Infinity } })).toHaveLength(1); }); });