/* * 저장된 모델의 어휘 세대 흡수 — **빈 공장으로 읽히지 않게.** * * ── 계기 (2026-08-06, 사용자 신고) ─────────────────────────────────────────── * 모델러의 엔티티 목록이 통째로 비어 있었다. **오류는 없었다.** 저장된 모델가 옛 어휘(`nodes`/`movers`) * 인데 화면과 호스트가 새 어휘를 읽었기 때문이다. 같은 원인이 구조 서명(`N[]M[]` — 서로 다른 두 공장이 * 같아 보인다)·주행 추정기·설비 동기까지 알리지 않고 망가뜨리고 있었다. * * 그래서 **DB에서 읽는 순간** 한 번 정규화한다. 이 테스트가 지키는 것은 그 정규화가 ① 세 세대를 다 * 흡수하고 ② 나머지 속성을 하나도 잃지 않고 ③ 새 모델에는 아무 일도 하지 않는 것이다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { modelColumnTransformer, canonicalModel } from '../server/engine/model-vocabulary.ts' const LOC = [{ id: 'dock', type: 'dock', capacity: 10 }] const EQ = [{ id: 'fk-1', kind: 'forklift', homeLocation: 'dock' }] test('가장 오래된 세대(nodes·movers)를 흡수한다 — 사용자가 본 증상의 원인', () => { const out = canonicalModel({ nodes: LOC, movers: EQ, spaceId: 'site-1' }) assert.deepEqual(out.locations, LOC) assert.deepEqual(out.equipment, EQ) }) test('중간 세대(equipmentList)도 흡수한다 — 세 세대가 저장돼 있다', () => { const out = canonicalModel({ nodes: LOC, equipmentList: EQ }) assert.deepEqual(out.equipment, EQ) }) test('옛 키를 **내보내지 않는다** — 소비처가 두 어휘를 다시 보지 않게', () => { const out: any = canonicalModel({ nodes: LOC, movers: EQ, equipmentList: [] }) for (const k of ['nodes', 'movers', 'equipmentList']) assert.equal(k in out, false, `${k} 가 남았다`) }) test('나머지 속성을 하나도 잃지 않는다 — 곁가지로 보이는 값이 전제다', () => { /* 이 도메인에서 사소한 속성은 없다: 좌표·시각기준·명세는 소요시간·교대·생산의 전제다. */ const model = { nodes: LOC, movers: EQ, spaceId: 'site-1', areaId: 'zone-a', layout: { dock: { x: 1, y: 2 } }, utcOffsetMinutes: 540, unit: 'm', productionSpec: { definition: { operations: [] } }, operations: [{ id: 'op-1' }] } const out: any = canonicalModel(model) for (const k of ['spaceId', 'areaId', 'layout', 'utcOffsetMinutes', 'unit', 'productionSpec', 'operations']) { assert.deepEqual(out[k], (model as any)[k], `${k} 가 사라졌다`) } }) test('새 어휘 모델에는 아무 일도 하지 않는다 — 멱등', () => { const model = { locations: LOC, equipment: EQ, spaceId: 'site-1' } const once: any = canonicalModel(model) const twice: any = canonicalModel(once) assert.deepEqual(once, model) assert.deepEqual(twice, once) }) test('컬렉션이 없으면 빈 배열이 된다 — 소비처가 `?? []` 를 다시 적지 않게', () => { const out = canonicalModel({ spaceId: 'site-1' }) assert.deepEqual(out.locations, []) assert.deepEqual(out.equipment, []) }) test('모델이 아닌 것은 그대로 통과시킨다 — 모양을 짐작해 고치지 않는다', () => { for (const v of [null, undefined, 0, '', 'board', [1, 2]]) { assert.deepEqual(canonicalModel(v as any), v as any, JSON.stringify(v)) } }) test('변환기는 **읽을 때만** 정규화한다 — 쓰기는 준 그대로', () => { const old = { nodes: LOC, movers: EQ } assert.deepEqual(modelColumnTransformer.from(old).equipment, EQ) /* 쓰기를 손대면 "저장된 것" 과 "준 것" 이 달라져 추적이 어려워진다. 읽기가 정규화되므로 다음 저장에서 자연히 새 어휘가 된다. */ assert.equal(modelColumnTransformer.to(old), old) }) test('두 옛 모델이 구조적으로 구별된다 — 서명이 빈 문자열로 축약되지 않는다', () => { /* 정규화 전에는 둘 다 `locations: undefined` 라 서명이 `N[]M[]` 로 같았다(변경 감지가 죽는다). */ const a = canonicalModel({ nodes: [{ id: 'a', type: 'dock', capacity: 1 }], movers: [] }) const b = canonicalModel({ nodes: [{ id: 'b', type: 'storage', capacity: 9 }], movers: [] }) assert.notDeepEqual(a.locations, b.locations) assert.equal(a.locations.length, 1) assert.equal(b.locations.length, 1) }) test('보드를 저장하는 엔티티가 **변환기를 달고 있다** — 결선이 빠지면 순수 모듈은 그린인데 화면은 빈다', () => { /* 이 결함이 정확히 그랬다: 리더는 커널에 있었고 목록만 그것을 거쳤다. 결선은 알리지 않고 빠질 수 있으므로 기계가 지킨다. 새 엔티티가 보드를 담게 되면 여기 함께 올린다. */ const owners = ['server/service/twin-instance/twin-instance.ts', 'server/service/twin-structure/twin-structure.ts'] for (const rel of owners) { const src = readFileSync(new URL('../' + rel, import.meta.url).pathname, 'utf-8') const boardColumn = src.split('\n').find(l => l.includes('@Column') && l.includes('transformer')) assert.ok(boardColumn, `${rel}: model 컬럼에 transformer 가 없다`) assert.match(boardColumn as string, /modelColumnTransformer/, `${rel}: 다른 변환기가 달려 있다`) } })