/* * 선언 적합성(경계 편) — **원본이 말한 사실은 모델까지 간다.** * * ── 왜 필요한가 ───────────────────────────────────────────────────────────── * 커널 쪽에는 같은 이름의 하네스가 있다(kernel/test/declaration-in-state: 선언 ⊆ 상태). 그런데 사실이 * 새던 자리는 그 앞에도 있었다: **레퍼런스 마스터 → 커널 모델** 이라는 문 하나(`masterToTwin`). * * 여기서 새면 아무도 모른다. 원본은 말했고, 화면은 알리지 않고 id 를 보인다. 실제로 그렇게 잃었다 — * 자리의 이름(화면이 `cut-station` 을 찍었다), 사람의 이름, GS1 참조, 자원 속성, 시험 명세 참조, * 사람·설비의 시험 결과. 하루 사이에 여섯 번이고, 전부 **한 줄에서 필드 하나가 빠진** 것이었다. * * 타입 검사는 못 잡는다: 필드가 거의 다 선택(optional)이라 **안 옮기는 것이 합법**이다. * * ── 무엇을 검사하나 ───────────────────────────────────────────────────────── * `RefLocation`·`RefEquipment`·`RefPerson`·`RefAsset` 의 **모든 필드**가 셋 중 하나여야 한다: * * 1. 같은 이름으로 모델에 있다(대부분). * 2. `RENAMED` 에 적힌 다른 이름으로 모델에 있다(`homeLocationId → homeLocation`). * 3. `ELSEWHERE` 에 **다른 길이 무엇인지 적혀** 있다(좌표는 `layout` 으로, 역할은 분기에 쓰인다). * * 셋 중 어느 것도 아니면 실패한다 — 새 필드를 추가한 사람이 그때 분류해야 한다. 분류를 강제하는 것이 * 이 검사의 전부다: 잊는 것이 기본값이면 잊는다. * * 필드 목록은 **소스에서 읽는다**(손으로 적지 않는다). 손으로 적으면 새 필드가 알리지 않고 빠지고, 그것이 * 바로 이 검사가 막으려는 실패다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { masterToTwin } from '../server/service/reference/reference-master.ts' /* * 주석을 먼저 걷어낸다 — **주석 속 예시가 필드로 세어졌다.** * 문서에 `effectivity: 'not-yet' | 'expired'` 라고 적어 둔 한 줄 때문에 없는 필드가 검사 대상에 들어왔다. * 검사가 없는 것을 요구하면 그 검사는 유지되지 않는다(사람이 예외를 늘려 입을 막는다). */ const SOURCE = readFileSync(new URL('../server/service/reference/reference-master.ts', import.meta.url), 'utf8') .replace(/\/\*[\s\S]*?\*\//g, '') .replace(/\/\/[^\n]*/g, '') /** `export interface X { … }` 의 깊이 1 필드 이름 — 중첩 객체의 안쪽은 세지 않는다. */ function fieldsOf(name: string): string[] { const start = SOURCE.indexOf(`export interface ${name}`) assert.ok(start >= 0, `선언 인터페이스를 못 찾았다: ${name} — 모양이 바뀌면 이 하네스도 따라가야 한다`) const body = SOURCE.slice(SOURCE.indexOf('{', start)) const block = body.slice(0, body.indexOf('\n}') + 2) const out: string[] = [] let depth = 0 let token = '' for (const ch of block) { if (ch === '{') { depth++; token = ''; continue } if (ch === '}') { depth--; token = ''; continue } if (depth === 1 && /[A-Za-z0-9_?]/.test(ch)) { token += ch; continue } if (depth === 1 && ch === ':') { if (token) out.push(token.replace('?', '')); token = ''; continue } token = '' } return out } /** 경계에서 이름이 바뀌는 필드 — 바뀌는 이유는 커널 어휘를 따르기 때문이다(호스트 어휘를 밀어넣지 않는다). */ const RENAMED: Record = { homeLocationId: 'homeLocation' } /** * **다른 길로 가는 사실** — 모델의 그 자원 줄에는 없지만 잃지도 않는다. 길을 여기 적는다. * 적지 않고 넘기면 "원래 안 가는 것" 과 "새는 것" 이 같아 보인다. */ const ELSEWHERE: Record = { role: '자리인가 구역인가를 가르는 입력 — 모델의 locations 와 공간의 areas 로 갈라진다(값 자체는 나를 필요가 없다).', footprint: '좌표는 `model.layout`(중심점)과 구역 기하로 간다 — 자리 줄에 실리는 값이 아니다.', system: '이 렌즈의 자리인가 참조인가를 가르는 입력 — 다른 영역이면 `model.referencedLocations` 로 간다(ADR-0081 보탬, lens-own-places 시험).' } /** 모든 필드에 값을 실은 원본 — 값을 모르는 필드가 나오면 이 하네스는 실패한다(알리지 않고 건너뛰지 않는다). */ const VALUE: Record = { id: '__ID__', type: 'cut-station', kind: 'welder', name: '절단 스테이션', description: '강판을 자르는 자리', role: 'location', /* 픽스처 원본의 렌즈와 같은 영역 — 이 자리는 자기 자리로 선다(다른 영역이면 참조로 간다). */ system: 'mes', parentId: 'zone-a', /* 전기 상류 — 공간 부모(`parentId`)와 다른 관계다. 값이 모델까지 가는지 이 하네스가 지킨다. */ upstreamId: 'incoming-1', capacity: 40, parallelism: 2, level: 'WorkCenter', footprint: { x: 1, y: 2, w: 3, h: 4 }, gs1Id: 'urn:epc:id:sgln:0614141.00001.0', /* 설비의 정체성 — **GS1 키가 아닌 것이 정상이다**(실측: 인버터 하드웨어 식별자). */ identity: 'ES100000007d8d061d-1', /* 설비 등급 소속 — 2026-09-04 에 이 칸이 접수면에 없어서 원본이 채운 등급 22대가 문턱에서 버려졌다(인티그레이션 레인 실측). 사람·자산은 받는데 설비만 없었다. */ equipmentClassIds: ['welding-robot'], homeLocationId: 'cut-station', mtbfMs: 600000, mttrMs: 60000, window: { startHour: 6, endHour: 22 }, workCalendar: [{ id: 'day', entryType: 'production', startTime: '06:00', endTime: '22:00' }], properties: [{ id: 'speed', value: '1.2', uom: 'MTS' }], testSpecificationIds: ['weld-cert'], testResults: [{ specId: 'weld-cert', result: 'pass', expiresAt: '2099-01-01T00:00:00.000Z' }], effectiveStart: '2020-01-01T00:00:00.000Z', effectiveEnd: '2099-01-01T00:00:00.000Z', personnelClassIds: ['welder-op'], assetClassIds: ['euro-pallet'] } interface Case { /** 선언 인터페이스 이름 */ iface: string /** 모델의 배열 이름 */ bucket: 'locations' | 'equipment' | 'persons' | 'assets' id: string } const CASES: Case[] = [ { iface: 'RefLocation', bucket: 'locations', id: 'cut-station' }, { iface: 'RefEquipment', bucket: 'equipment', id: 'cutter-1' }, { iface: 'RefPerson', bucket: 'persons', id: 'cutter-op-1' }, { iface: 'RefAsset', bucket: 'assets', id: 'pallet-1' } ] /** 그 인터페이스의 모든 필드에 값을 실은 항목. */ function build(c: Case): any { const o: any = {} for (const f of fieldsOf(c.iface)) { assert.ok( f in VALUE, `픽스처가 "${c.iface}.${f}" 에 실을 값을 모른다 — VALUE 에 하나 적어라.\n` + '알리지 않고 건너뛰면 그 필드는 아무도 안 보는 필드가 되고, 그것이 이 하네스가 막으려는 실패다.' ) o[f] = f === 'id' ? c.id : VALUE[f] } return o } function master(): any { return { source: 'decl-conformance', system: 'mes' as const, siteName: '선언 적합성 공장', space: { spaceId: 'decl-site', width: 100, depth: 60, unit: 'm' }, locations: [build(CASES[0])], equipment: [build(CASES[1])], persons: [build(CASES[2])], assets: [build(CASES[3])] } } test('검사할 필드가 비어 있지 않다 — 비면 이 하네스는 아무것도 지키지 않는다', () => { for (const c of CASES) assert.ok(fieldsOf(c.iface).length > 2, `${c.iface}: 필드를 못 읽었다(파서가 낡았다)`) }) for (const c of CASES) { test(`${c.iface} 의 사실이 하나도 잃지 않고 모델까지 간다`, () => { const src = build(c) const { model } = masterToTwin(master()) const bucket = (model as any)[c.bucket] assert.ok(Array.isArray(bucket), `모델에 ${c.bucket} 이 없다`) const got = bucket.find((x: any) => x?.id === c.id) assert.ok(got, `선언한 ${c.iface} "${c.id}" 이 모델에 없다 — 사실 하나가 아니라 개체가 사라졌다`) const lost: string[] = [] const mangled: string[] = [] for (const f of Object.keys(src)) { if (f in ELSEWHERE) continue const key = RENAMED[f] ?? f if (got[key] === undefined) { lost.push(f); continue } try { assert.deepEqual(got[key], src[f]) } catch { mangled.push(`${f}→${key}`) } } assert.deepEqual( { lost, mangled }, { lost: [], mangled: [] }, `${c.iface}: 잃은 사실=[${lost}] · 옮기다 값이 바뀐 사실=[${mangled}]\n` + '원본이 말한 것을 문에서 버리면 아무도 모른다 — 화면은 알리지 않고 id 를 보인다.\n' + '일부러 다른 길로 보내는 것이면 ELSEWHERE 에 그 길을 적어라(이름만 바뀌는 것은 RENAMED).' ) }) } /* * ── 공간의 시각 기준도 모델까지 간다 (2026-09-07) ──────────────────────────── * * 위 검사는 **자원**의 필드만 본다. `space` 는 안 보고 있었고, 그래서 실제로 하나가 새고 있었다 — * `space.timezone` 이 오프셋으로만 풀려 모델에 실리고 **이름은 버려졌다.** * * 이름이 없으면 소비처가 순간마다 다시 풀 수 없다. 지난 구간을 교대별로 접을 때 그 구간 안의 * 일광절약시간 전환이 사실을 한 시간 밀어 **다른 교대로** 세게 하고, 오류는 나지 않는다. * `America/Tijuana`(PST −480 / PDT −480 아닌 −420)가 이미 자료에 있다. * * 그리고 이 새는 자리를 잡은 것은 타입 검사도, 위 하네스도 아니라 **다른 레인의 물음**이었다 * ("자료를 채운 뒤 이미 세워진 모델이 따라가나"). 그래서 여기 검사로 남긴다. */ const withSpace = (space: Record) => masterToTwin({ ...master(), space: { ...master().space, ...space } }) test('공간의 시간대 이름이 모델까지 간다 — 오프셋만 실으면 계절이 바뀔 때 틀린다', () => { const { model } = withSpace({ timezone: 'America/Tijuana' }) assert.equal((model as any).timezone, 'America/Tijuana', '이름이 모델에 없다 — 소비처가 순간마다 풀 수 없다') assert.equal(typeof (model as any).utcOffsetMinutes, 'number', '오프셋도 함께 실려야 한다 — 이름을 안 보는 소비처가 그 값으로 돈다') }) test('시간대를 선언하지 않으면 두 칸 다 만들지 않는다 — 0 은 「UTC 라고 선언했다」는 주장이다', () => { const { model } = withSpace({}) assert.equal((model as any).timezone, undefined) assert.equal((model as any).utcOffsetMinutes, undefined) }) test('풀리지 않는 이름은 싣지 않고 경고로 말한다 — 실어 두면 소비처가 믿고 매번 실패한다', () => { const { model, warnings } = withSpace({ timezone: 'Mars/Olympus_Mons' }) assert.equal((model as any).timezone, undefined) assert.equal((model as any).utcOffsetMinutes, undefined) assert.ok( (warnings ?? []).some((w: any) => JSON.stringify(w).includes('Mars/Olympus_Mons')), '모르는 시간대를 조용히 버리지 않는다' ) })