/* * 적합성 패널이 **그 트윈이 기대는 표준들**을 낸다 — 하나만 말하지 않는다. * * ── 무엇이 부족했나 (2026-08-15) ──────────────────────────────────────────── * 패널이 트윈 종류와 무관하게 ISA-95 하나만 말했다. 에너지 트윈에서 그것은 거짓은 아니지만 * (설비·계층·속성은 에너지에서도 ISA-95 자원이다) **부족하다**: 계량기·차단기·태양광·축전지의 어휘를 * 주는 것은 IEC 61850 이고, 무엇이 비었는지도 그 목록으로만 셀 수 있다. * * 그리고 주장 수준을 못 박는다 — 우리가 재는 것은 **어휘와 모델**이지 프로토콜 구현이 아니다. */ import { test } from 'node:test' import assert from 'node:assert/strict' /* * **빌드 산출물을 부른다** — 소스를 직접 실행하지 않는다. * * 이 모듈들은 서로를 값으로 참조한다(축 어긋남 판정을 한 곳에 두었다). 그 참조는 계약대로 `.js` 로 * 적히고, 타입만 지우고 소스를 실행하는 방식은 그 경로를 `.ts` 로 바꿔 주지 않는다. 그래서 소스를 * 부르면 「모듈을 찾을 수 없다」로 선다. * * **소스를 고친 뒤에는 `npm run build:server` 를 먼저 돌린다.** */ import { createRequire } from 'node:module' const req = createRequire(import.meta.url) const { standardsForKind } = req('../dist-server/service/twin-model/standard-coverage.js') const { iec61850Coverage } = req('../dist-server/service/twin-model/iec61850-coverage.js') const { epcisCoverage } = req('../dist-server/service/twin-model/epcis-coverage.js') const { isa95Coverage } = req('../dist-server/service/twin-model/isa95-coverage.js') import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' const query = readFileSync(fileURLToPath(new URL('../server/service/twin-model/twin-model-query.ts', import.meta.url)), 'utf-8') const AXES = ['equipment', 'demandWindows', 'locations', 'persons', 'assets', 'orders', 'tasks', 'operations'] test('ISA-95 는 종류와 상관없이 낸다 — 자원과 계층은 어느 도메인에서나 그 표준의 것이다', () => { for (const kind of ['wms', 'yms', 'mes', 'ems']) { assert.equal(standardsForKind(kind)[0], 'isa95', `${kind}: ISA-95 가 먼저다`) } }) test('에너지 트윈은 IEC 61850 을 함께 낸다 — 다른 종류에는 붙지 않는다', () => { assert.deepEqual(standardsForKind('ems'), ['isa95', 'iec61850']) for (const kind of ['wms', 'yms', 'mes']) { assert.deepEqual(standardsForKind(kind), ['isa95', 'epcis'], `${kind}: 물류·제조는 EPCIS 다`) } /* 에너지에 EPCIS 를 붙이지 않는다 — 오가는 물건이 없고, 계측은 사건이 아니라 표본으로 들어온다. */ assert.equal(standardsForKind('ems').includes('epcis' as any), false) assert.deepEqual(standardsForKind(undefined), ['isa95'], '종류를 모르면 공통 표준만') }) test('질의가 그 정책으로 조립한다 — 규칙이 두 벌이 되지 않게', () => { assert.match(query, /standardsForKind\(inst\.kind\)/, '질의가 정책 함수를 써야 한다') assert.match(query, /COVERAGE\[id\]/, '표는 id 로 찾는다(종류 분기를 다시 쓰지 않는다)') }) test('주장 수준을 이름에 적는다 — 프로토콜 적합성으로 읽히지 않게', () => { const iec = iec61850Coverage(AXES) assert.match(iec.model, /vocabulary/, '어휘를 쓴다는 뜻이지 MMS·GOOSE 를 구현했다는 뜻이 아니다') }) test('개폐·발전·저장이 세 층 모두 올라갔다 — 값이 먼저, 컴포넌트는 그 다음', () => { const iec = iec61850Coverage(AXES) const by = Object.fromEntries(iec.concepts.map(c => [c.std, c])) /* * 오래 「구조만 있음」이던 셋이다. 능력이 상태 필드를 선언해 두었는데 값을 받는 문이 없었다 — * 이제 커널이 `energy.equipment` 로 받아 설비에 적고, 씬 컴포넌트가 그린다. */ for (const std of ['XCBR', 'DPVA', 'ZBAT']) { assert.equal(by[std].behavior, 'full', `${std}: 값을 채우는 곳이 있다`) assert.equal(by[std].surface, 'full', `${std}: 그리는 컴포넌트가 있다`) assert.ok(by[std].note, `${std}: 근거를 한 줄로 적어야 한다`) } /* 채운 수는 `full` 만 센다 — 부분을 채운 것으로 세면 기준이 스스로를 속인다. */ assert.equal(iec.totals.behavior, 5, '다섯 논리 노드가 모두 실제로 돈다') }) test('커널 축이 사라지면 구조가 없는 것으로 떨어진다 — 오타·개명이 알리지 않고 통과하지 않게', () => { const iec = iec61850Coverage(['equipment']) // demandWindows 축이 없는 세상 const mmxu = iec.concepts.find(c => c.std === 'MMXU')! assert.equal(mmxu.structure, 'none') assert.equal((mmxu as any).axisMissing, true) }) test('EPCIS 표는 실제로 쓰는 것만 「있음」으로 센다 — 모양만 있는 필드는 구조만', () => { const epcis = epcisCoverage(['items', 'orders', 'locations', 'tasks', 'operations']) const by = Object.fromEntries(epcis.concepts.map(c => [c.std, c])) /* 커널이 실제로 만들어 내보내는 사건들. */ for (const std of ['ObjectEvent', 'AggregationEvent', 'TransactionEvent', 'TransformationEvent']) { assert.equal(by[std].behavior, 'full', `${std}: 커널이 실제로 낸다`) } /* 타입조차 없는 것은 없다고 적는다(EPCIS 2.0 이 더한 사건). */ assert.equal(by['AssociationEvent'].structure, 'none') /* 모양만 있고 아무도 채우지 않는 것들 — 「구조만 있음」이지 「있음」이 아니다. */ for (const std of ['sourceList / destinationList', 'persistentDisposition', 'sensorElementList']) { assert.equal(by[std].structure, 'partial', `${std}: 모양은 있다`) assert.equal(by[std].behavior, 'none', `${std}: 아무도 채우지 않는다`) assert.ok(by[std].note, `${std}: 왜 그 수준인지 적어야 한다`) } }) test('계측은 EPCIS 센서 필드로 오지 않는다 — 그 사실을 표가 말한다', () => { const sensor = epcisCoverage(['items']).concepts.find(c => c.std === 'sensorElementList')! assert.equal(sensor.behavior, 'none') assert.ok(sensor.note, '에너지가 다른 층으로 들어온다는 사실을 근거로 적는다') }) test('★ 자기 축이 없는 개념은 **어디에 속하는지** 말한다 — 「어디에도 없다」로 읽히지 않게', () => { /* * ── 사용자 지적 (2026-08-24) ────────────────────────────────────────────── * 화면이 그런 줄을 「개념 축이 없습니다」로만 냈다. 자재 로트·자원 속성·로케이션 관측 기록이 그렇게 * 보였는데, 셋 다 **어디엔가 속해 있다** — 로트는 물품 목록이고, 속성은 설비·사람·자산 안에 붙고, * 관측은 로케이션에 붙는다. 「축이 없다」는 사실이지만 사용자에게는 **없는 것**으로 읽힌다. * * 그래서 `shownOn` 의 뜻을 고쳤다 — 「어디서 보이나」가 아니라 **「어디에 속하나」**다. 화면에 * 그려지는지는 `surface` 가 따로 답하고, 둘은 다른 물음이다. * * (그리고 화면의 「해당 축 항목에 표시됩니다」 문장은 걷었다 — 칩이 이미 갈 곳을 보인다.) */ const AXES = [ 'items', 'locations', 'equipment', 'persons', 'assets', 'tasks', 'orders', 'operations', 'recipes', 'testSpecifications', 'materialDefinitions', 'materialClasses', 'personnelClasses', 'equipmentClasses', 'assetClasses', 'productionSpec', 'demandWindows', 'meters' ] const tables: [string, { concepts: any[] }][] = [ ['ISA-95', isa95Coverage(AXES)], ['EPCIS', epcisCoverage(AXES)], ['IEC 61850', iec61850Coverage(AXES)] ] /* * 규칙: **세 층 중 하나라도 있는 개념은 어디에 속하는지 말해야 한다.** 셋 다 없으면 진짜 빈칸이고, * 그때만 「축이 없다」가 정직한 답이다. */ const mute: string[] = [] for (const [name, table] of tables) { for (const c of table.concepts) { const lives = c.structure !== 'none' || c.behavior !== 'none' || c.surface !== 'none' if (lives && !c.axis && !c.shownOn?.length) mute.push(`${name}/${c.std}`) } } assert.deepEqual(mute, [], `있는데 어디에 속하는지 말하지 않는 줄 — 「어디에도 없다」로 읽힌다:\n${mute.join('\n')}`) /* 빈 비교로 통과하지 않게 — 실제로 그런 줄이 여럿 있다(자재 로트·서브로트·속성·관측·시험 결과). */ const attached = tables.flatMap(([, t]) => t.concepts).filter(c => !c.axis && c.shownOn?.length) assert.ok(attached.length >= 4, `속해 있다고 말하는 줄이 실제로 있어야 한다 (실제 ${attached.length})`) })