/* * masterToTwin — 모델→model 토폴로지 변환 시임. 템플릿/레퍼런스 master → model{locations,equipment,layout,spaceId}+space. * 스트림라인의 "모델링→board" 연결부: 템플릿(build)이 낸 master 를 커널이 소비할 TwinModelDef 로 바꾼다. * role(location/area/both) 분기·footprint→layout·spaceId 파생·미지 타입 경고를 고정. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { masterToTwin } from '../server/service/reference/reference-master.ts' import { DOMAIN_CATALOG } from '@operato/ops-contract' /** WMS 단위적재 템플릿이 내는 것과 동형인 현실적 master(coordinate-free). */ const wmsMaster = () => ({ source: 'wh-seoul', system: 'wms' as const, siteName: '서울 창고', space: { width: 90, depth: 40, unit: 'm', geo: { lat: 37.5, lon: 127.0, rotation: 0 } }, locations: [ { id: 'a-dc', type: 'warehouse', role: 'area' as const, name: '창고' }, { id: 'dock-recv', type: 'dock', role: 'location' as const, name: '입고 도크', capacity: 100 }, { id: 'rack-1', type: 'storage', role: 'location' as const, name: '랙1', parentId: 'a-dc', capacity: 60 }, { id: 'rack-2', type: 'storage', role: 'location' as const, name: '랙2', parentId: 'a-dc', capacity: 60 }, { id: 'dock-ship', type: 'dock-ship', role: 'location' as const, name: '출고 도크', capacity: 100 } ], equipment: [{ id: 'fk-1', kind: 'forklift', homeLocationId: 'dock-recv' }] }) test('locations role → 노드/영역 분기, equipment → 무버', () => { const { model, spaceContent } = masterToTwin(wmsMaster()) assert.deepEqual(model.locations.map((n: any) => n.id).sort(), ['dock-recv', 'dock-ship', 'rack-1', 'rack-2'], '노드=role location') assert.equal(spaceContent.areas.length, 1, '영역=role area') assert.equal(spaceContent.areas[0].id, 'a-dc') assert.deepEqual(model.equipment, [{ id: 'fk-1', kind: 'forklift', homeLocation: 'dock-recv', mtbfMs: undefined, mttrMs: undefined }], '마스터 equipment→보드 equipment(homeLocationId→homeLocation, identity 통과)') assert.equal(model.locations.find((n: any) => n.id === 'rack-1').capacity, 60, 'capacity 전달') assert.equal(model.locations.find((n: any) => n.id === 'dock-recv').parentId, undefined, 'parentId 없으면 undefined') }) test('coordinate-free: footprint 없으면 layout 비어있음(미배치 → A2)', () => { const { model } = masterToTwin(wmsMaster()) assert.deepEqual(model.layout, {}, '좌표 없는 master → layout {}') }) test('footprint 있으면 layout = 중심좌표', () => { const m = wmsMaster() ;(m.locations[1] as any).footprint = { x: 10, y: 20, w: 4, h: 6 } // dock-recv const { model } = masterToTwin(m) assert.deepEqual(model.layout['dock-recv'], { x: 12, y: 23 }, '중심 = x+w/2, y+h/2') assert.equal(model.layout['rack-1'], undefined, 'footprint 없는 노드는 layout 없음') }) test('spaceId: 명시 시 참조, 없으면 source 파생 (공동배치 시임)', () => { assert.equal(masterToTwin(wmsMaster()).model.spaceId, 'wh-seoul-site', '없으면 {source}-site') const shared = wmsMaster() ;(shared.space as any).spaceId = 'rosarito-site' assert.equal(masterToTwin(shared).model.spaceId, 'rosarito-site', '명시 시 공유(다시스템 co-location)') }) test('미지 노드 타입 경고 — 카탈로그 있으면 검증(버리진 않음)', () => { const m = wmsMaster() ;(m.locations as any).push({ id: 'weird', type: '__unknown__', role: 'location', capacity: 1 }) const withCat = masterToTwin(m, DOMAIN_CATALOG) /* 경고는 프로즈가 아니라 **코드+파라미터**다(화면이 다섯 언어로 렌더한다) — 문구가 아니라 계약을 본다. */ assert.ok( withCat.warnings.some(w => w.code === 'unknown-location-type' && w.params?.type === '__unknown__'), `미지 타입 경고: ${JSON.stringify(withCat.warnings)}` ) assert.ok(withCat.model.locations.some((n: any) => n.id === 'weird'), '경고해도 노드는 유지(반영 우선)') // 정상 타입만이면 경고 0 assert.equal(masterToTwin(wmsMaster(), DOMAIN_CATALOG).warnings.length, 0, '알려진 타입만 → 경고 0') // 카탈로그 없으면 검증 생략 assert.equal(masterToTwin(m).warnings.length, 0, '카탈로그 미제공 → 경고 생략') }) test("role 'both' 는 노드·영역 양쪽에 등장(도크 도어 등)", () => { const m = wmsMaster() ;(m.locations as any).push({ id: 'dock-door-1', type: 'dock-door', role: 'both', name: '도어1', capacity: 1 }) const { model, spaceContent } = masterToTwin(m) assert.ok(model.locations.some((n: any) => n.id === 'dock-door-1'), 'both → 노드') assert.ok(spaceContent.areas.some((a: any) => a.id === 'dock-door-1'), 'both → 영역') }) test('space content: 크기·geo·표현 전달', () => { const { spaceContent } = masterToTwin(wmsMaster()) assert.equal(spaceContent.width, 90) assert.equal(spaceContent.depth, 40) assert.equal(spaceContent.unit, 'm') assert.equal(spaceContent.geo.lat, 37.5) }) test('현장 물질화(#4): 표현 없고 geo 있으면 기본 map 표현 합성 — "알아볼 현장" 보장', () => { const { spaceContent } = masterToTwin(wmsMaster()) // wmsMaster = geo 있고 representations 없음 assert.equal(spaceContent.representations.length, 1, '기본 표현 1개 합성') const r = spaceContent.representations[0] assert.equal(r.type, 'map', 'map 표현') assert.equal(r.primary, true) assert.deepEqual(r.center, { lat: 37.5, lon: 127.0 }, 'geo 로 센터') assert.equal(spaceContent.primaryId, r.id, 'primaryId = 합성 표현') }) test('현장 물질화(#4): master 가 표현을 주면 보존(합성 안 함)', () => { const m: any = wmsMaster() m.space.representations = [{ id: 'r-board', type: 'board', primary: true, boardId: 'b1' }] const { spaceContent } = masterToTwin(m) assert.equal(spaceContent.representations.length, 1) assert.equal(spaceContent.representations[0].type, 'board', '주어진 표현 보존(덮어쓰지 않음)') }) test('현장 물질화(#4): 표현도 geo 도 없으면 빈 표현 + 경고(정직)', () => { const m: any = wmsMaster() m.space.geo = undefined const { spaceContent, warnings } = masterToTwin(m) assert.equal(spaceContent.representations.length, 0, '합성 불가') assert.ok( warnings.some(w => w.code === 'no-viewable-representation'), '뷰 없는 공간 경고 표면화' ) }) /* ── 공정 명세 통과 (ISA-95 OperationsSegment) ────────────────────────────── * * 시뮬레이션의 소요·수율이 커널 상수였던 것을 데이터로 옮기는 경로의 마지막 한 칸: 마스터가 명세를 * 말하면 model 를 통해 커널까지 **손대지 않고** 간다. 기간은 표준 표기(ISO 8601)로 그대로 흐른다 — * 여기서 밀리초로 바꾸면 표준과 어긋나고, 커널이 다시 해석할 기회를 잃는다. */ const mesMaster = () => ({ source: 'plant-1', system: 'mes' as const, siteName: '공장', space: { width: 100, depth: 50, geo: { lat: 32.3, lon: -117.0 } }, locations: [ { id: 'a-prod', type: 'factory', role: 'area' as const, name: '생산' }, { id: 'cut-1', type: 'cut-station', role: 'location' as const, name: '절단', capacity: 4 } ], equipment: [{ id: 'mch-1', kind: 'cutter', homeLocationId: 'cut-1' }], operations: [ { key: 'cut', label: 'cutting', intent: 'process' as const, locationType: 'cut-station', resourceType: 'cutter', duration: 'PT25M', variability: { distribution: 'triangular' as const, min: 'PT20M', max: 'PT35M', mode: 'PT24M' }, parameters: [{ id: 'yield', value: '0.995' }] } ] }) test('공정 명세가 model 로 그대로 통과한다 — 표준 표기 유지, 값 변형 없음', () => { const { model, warnings } = masterToTwin(mesMaster(), DOMAIN_CATALOG) assert.equal((model as any).operations.length, 1) const op = (model as any).operations[0] assert.equal(op.duration, 'PT25M', 'ISO 8601 기간을 밀리초로 바꾸지 않는다') assert.deepEqual(op.variability, { distribution: 'triangular', min: 'PT20M', max: 'PT35M', mode: 'PT24M' }) assert.deepEqual(op.parameters, [{ id: 'yield', value: '0.995' }]) assert.equal(warnings.length, 0) }) test('명세를 전달하지 않으면 키를 만들지 않는다 — 빈 배열을 "선언했다" 로 보고하지 않는다', () => { const { model } = masterToTwin(wmsMaster(), DOMAIN_CATALOG) assert.equal('operations' in (model as any), false) }) test('내용 없는 명세는 경고한다 — 선언했는데 아무 말도 하지 않는 명세는 침묵하지 않는다', () => { const m: any = mesMaster() m.operations = [{ key: 'cut', label: 'cutting', intent: 'process' }] const { warnings } = masterToTwin(m, DOMAIN_CATALOG) assert.ok( warnings.some(w => w.code === 'operation-without-duration' && w.params?.operation === 'cut'), `기본값으로 굴러갈 것을 알린다: ${JSON.stringify(warnings)}` ) }) /* ── 처리 능력·교대 통과 ───────────────────────────────────────────────── * * `capacity` 한 필드가 저장 용량과 처리 능력을 겸하던 것을 갈랐다. 인제스트도 두 축을 따로 실어야 * 커널이 "20개를 쌓아 두지만 한 번에 한 대만 깎는다" 를 표현할 수 있다. 교대도 같다. */ test('처리 능력과 교대가 model 로 통과한다 — 저장 용량과 섞이지 않는다', () => { const m: any = mesMaster() m.locations[1].parallelism = 1 // cut-1: 저장 4, 동시 처리 1 m.equipment[0].window = { startHour: 8, endHour: 18 } const { model } = masterToTwin(m, DOMAIN_CATALOG) const location = (model as any).locations.find((n: any) => n.id === 'cut-1') assert.equal(location.capacity, 4, '저장 용량') assert.equal(location.parallelism, 1, '처리 능력 — 다른 축') assert.deepEqual((model as any).equipment[0].window, { startHour: 8, endHour: 18 }) }) test('선언하지 않으면 키를 만들지 않는다 — 미지정과 0 을 같게 다루지 않는다', () => { const { model } = masterToTwin(mesMaster(), DOMAIN_CATALOG) const location = (model as any).locations.find((n: any) => n.id === 'cut-1') assert.equal('parallelism' in location, false) assert.equal('window' in (model as any).equipment[0], false) }) /* * ★ **이름이 첫 문에서 사라지고 있었다.** * * 인제스트가 자리·설비·사람·자산을 보드로 옮길 때 `name` 을 빼고 있었다. 그러면 모델에도 없고, * 모델에서 그리는 투영 행에도 없으므로 **어떤 화면도 이름을 만들 수 없다** — 목록·표·관계 그림이 * 전부 `cut-station` 같은 id 를 찍었다. 표면마다 따로 나타나지만 결함은 이 한 자리다. * * 업무키를 따로 두는 설계(식별자는 원본이 정한다)에서는 **이름을 함께 싣는 것이 그 설계의 절반**이다. * 나누기만 하고 이름을 빼면 화면에 남는 것은 id 뿐이다. */ test('이름이 보드로 통과한다 — 업무키를 나눈 설계의 나머지 절반', () => { const m: any = mesMaster() m.locations[1].name = '프레임 절단' m.equipment[0].name = '절단기 1호' const { model } = masterToTwin(m, DOMAIN_CATALOG) const location = (model as any).locations.find((n: any) => n.id === 'cut-1') assert.equal(location.name, '프레임 절단', '자리 이름이 모델에 실려야 한다') assert.equal((model as any).equipment[0].name, '절단기 1호', '설비 이름이 모델에 실려야 한다') }) test('이름 없는 원본은 이름 없이 통과한다 — id 를 이름으로 베끼지 않는다', () => { const m: any = mesMaster() delete m.locations[1].name // 이름을 말하지 않는 원본 const { model } = masterToTwin(m, DOMAIN_CATALOG) const location = (model as any).locations.find((n: any) => n.id === 'cut-1') /* 키 자체를 만들지 않는다: id 를 넣어 두면 "이름이 있다" 가 거짓이 되고, 화면은 이름 없음을 알아볼 수 없게 된다(그러면 "이름을 지어 주세요" 라고 말할 수도 없다). */ assert.equal('name' in location, false, '원본이 이름을 말하지 않았으면 키가 없어야 한다') }) /* * ★ 검사 명세가 모델로 통과한다 — **참조가 가리킬 대상이 실린다.** * * 자원은 예전부터 `testSpecificationIds` 로 시험을 가리킬 수 있었지만 가리켜지는 대상이 없었다. 인제스트가 * 명세를 흘리면 축이 서 있어도 여전히 허공을 가리킨다 — 그래서 이 문에서 통과를 못 박는다. * * 표시명은 `name` 으로 옮긴다(등급과 같은 규칙) — 그러지 않으면 화면의 "설명" 열에 이름이 앉는다. */ test('검사 명세가 모델로 통과한다 — 표시명은 name 으로', () => { const m: any = mesMaster() m.testSpecifications = [{ id: 'weld-cert', description: 'twin.x.test.weld', version: '2026.1' }] const { model } = masterToTwin(m, DOMAIN_CATALOG) as any const specs = (model as any).testSpecifications assert.equal(specs?.length, 1, '명세가 모델에 실려야 한다') assert.equal(specs[0].id, 'weld-cert') assert.equal(specs[0].name, 'twin.x.test.weld', '표시명은 name 자리에 온다(어휘를 하나로)') assert.equal('description' in specs[0], false, '두 자리에 같은 값을 두지 않는다') assert.equal(specs[0].version, '2026.1', '버전이 남아야 한다 — 어느 버전으로 검증했나') }) test('선언하지 않으면 키를 만들지 않는다 — 빈 배열을 "선언했다" 로 보고하지 않는다', () => { const { model } = masterToTwin(mesMaster(), DOMAIN_CATALOG) as any assert.equal('testSpecifications' in (model as any), false) }) /* * ── 자리의 속성이 커널까지 간다 (2026-08-14, EMS 4단계) ───────────────────── * * 에너지 커널은 계약전력을 **자리 속성**(`contract.kW`)에서 읽고, 없으면 계약 대비 판정을 아예 * 하지 않는다. 그래서 이 통과가 끊기면 결과가 「오류 없이 판정만 사라지는」 모양이 된다 — 화면에는 * 트윈이 정상으로 보이고 피크 경고만 영원히 오지 않는다. 눈으로는 보이지 않는 부류다. * * 실제로 처음에는 끊겨 있었다(타입만 열고 변환을 잇지 않았다). */ test('자리의 속성을 모델로 통과시킨다 — 계약전력이 커널에 닿는 유일한 길', () => { const m = { source: 'ems-1', system: 'ems' as const, siteName: '공장', space: { width: 10, depth: 10 }, locations: [ { id: 'incoming-1', type: 'incoming', role: 'location' as const, capacity: 0, properties: [{ id: 'contract.kW', value: '4000', dataType: 'xs:double', uom: 'KWT' }] }, { id: 'feeder-1', type: 'feeder', role: 'location' as const, capacity: 0 } ] } const { model } = masterToTwin(m as any) const incoming = model.locations.find((l: any) => l.id === 'incoming-1') as any assert.deepEqual(incoming.properties, m.locations[0].properties, '손대지 않고 나른다') const feeder = model.locations.find((l: any) => l.id === 'feeder-1') as any assert.equal('properties' in feeder, false, '없는 것을 빈 배열로 만들지 않는다(모름과 없음을 섞지 않는다)') })