/* * 공간 ↔ 트윈 ↔ 가시화 정합성 — 어긋난 모양마다 하나씩 세운다. * * 검사가 **무는지** 확인하는 것이 이 파일의 일이다: 정합한 입력에서는 아무것도 내지 않고, 어긋난 * 입력에서는 그 자리를 코드와 id 로 지목해야 한다. 지목하지 못하면 사람이 어디를 볼지 알 수 없다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { checkSpaceIntegrity, sortFindings, type SpaceIntegrityInput } from '../server/service/twin-space/space-integrity.ts' /** 정합한 한 현장 — 트윈 하나(창고), 지도 표현 하나, 구역 하나, 폴리곤이 그 구역과 로케이션을 가리킨다. */ const SOUND: SpaceIntegrityInput = { spaces: [{ spaceId: 'rosarito-site', primaryRepresentationId: 'rep-map' }], instances: [ { instanceId: 'wms-a', spaceId: 'rosarito-site', modelSpaceId: 'rosarito-site', locationIds: ['rack-1', 'dock-1'], equipmentIds: ['fork-1'], areaRefs: ['bldg-1'] } ], representations: [ { id: 'rep-map', spaceId: 'rosarito-site', type: 'map', polygons: [ { id: 'p1', groupId: 'bldg-1' }, { id: 'p2', targetKind: 'location', targetId: 'rack-1' }, { id: 'p3', targetKind: 'equipment', targetId: 'fork-1' } ] } ], areasBySpace: { 'rosarito-site': ['bldg-1'] }, boardIds: [] } test('정합하면 아무것도 내지 않는다 — 멀쩡한 현장을 시끄럽게 만들지 않는다', () => { assert.deepEqual(checkSpaceIntegrity(SOUND), []) }) test('없는 현장을 가리키는 트윈을 잡는다 — 무대가 사라진 트윈', () => { const f = checkSpaceIntegrity({ ...SOUND, instances: [{ ...SOUND.instances[0], spaceId: 'gone-site', modelSpaceId: 'gone-site' }] }) const hit = f.find(x => x.code === 'twin-space-missing') assert.ok(hit) assert.equal(hit.severity, 'broken') assert.equal(hit.instanceId, 'wms-a') assert.equal(hit.spaceId, 'gone-site') }) test('레지스트리와 model 의 현장이 다르면 잡는다 — 어느 쪽이 사실인지 알 수 없다', () => { const f = checkSpaceIntegrity({ ...SOUND, instances: [{ ...SOUND.instances[0], modelSpaceId: 'other-site' }] }) const hit = f.find(x => x.code === 'twin-space-mismatch') assert.ok(hit) assert.equal(hit.severity, 'broken') assert.equal(hit.params?.registry, 'rosarito-site') assert.equal(hit.params?.model, 'other-site') }) test('현장 없는 트윈은 경고로 낸다 — 뗀 결과일 수 있어 고장은 아니다', () => { const f = checkSpaceIntegrity({ ...SOUND, instances: [{ ...SOUND.instances[0], spaceId: undefined, modelSpaceId: undefined }] }) const hit = f.find(x => x.code === 'twin-without-space') assert.ok(hit) assert.equal(hit.severity, 'warn') }) test('로케이션이 이 현장에 없는 구역에 소속되어 있으면 잡는다', () => { const f = checkSpaceIntegrity({ ...SOUND, instances: [{ ...SOUND.instances[0], areaRefs: ['bldg-1', 'floor-9', 'floor-9'] }] }) const hit = f.find(x => x.code === 'location-area-missing') assert.ok(hit) assert.equal(hit.params?.count, 1, '중복은 한 번만 센다') assert.equal(hit.params?.areas, 'floor-9') }) test('폴리곤이 없는 구역을 가리키면 잡는다 — 그 도형은 아무것도 보여 주지 않는다', () => { const f = checkSpaceIntegrity({ ...SOUND, representations: [{ ...SOUND.representations[0], polygons: [{ id: 'p1', groupId: 'ghost-area' }] }] }) const hit = f.find(x => x.code === 'polygon-area-missing') assert.ok(hit) assert.equal(hit.severity, 'broken') assert.equal(hit.representationId, 'rep-map') assert.equal(hit.params?.areaId, 'ghost-area') }) test('폴리곤이 이 현장의 어느 트윈에도 없는 로케이션을 가리키면 잡는다 — 트윈을 옮긴 뒤 남는 모양', () => { const f = checkSpaceIntegrity({ ...SOUND, instances: [{ ...SOUND.instances[0], spaceId: 'other-site', modelSpaceId: 'other-site' }], spaces: [...SOUND.spaces, { spaceId: 'other-site' }] }) const hits = f.filter(x => x.code === 'polygon-target-missing') assert.equal(hits.length, 2, '로케이션과 설비 둘 다 끊긴다') assert.equal(hits[0].severity, 'broken') assert.equal(hits.find(h => h.params?.kind === 'equipment')?.params?.targetId, 'fork-1') }) test('설비 바인딩을 로케이션 목록으로 판정하지 않는다 — 종류를 섞으면 거짓 경보가 난다', () => { const f = checkSpaceIntegrity({ ...SOUND, representations: [ { ...SOUND.representations[0], polygons: [{ id: 'p3', targetKind: 'equipment', targetId: 'rack-1' }] } ] }) assert.ok(f.find(x => x.code === 'polygon-target-missing'), '로케이션 id 를 설비로 가리키면 끊긴 것이다') }) test('대표 표현이 이 현장의 것이 아니면 잡는다', () => { const f = checkSpaceIntegrity({ ...SOUND, spaces: [{ spaceId: 'rosarito-site', primaryRepresentationId: 'rep-elsewhere' }] }) const hit = f.find(x => x.code === 'primary-representation-missing') assert.ok(hit) assert.equal(hit.severity, 'broken') }) test('보드 표현이 보드를 가리키지 않으면 잡는다', () => { const f = checkSpaceIntegrity({ ...SOUND, representations: [{ id: 'rep-board', spaceId: 'rosarito-site', type: 'board', boardId: null, polygons: [] }], spaces: [{ spaceId: 'rosarito-site', primaryRepresentationId: 'rep-board' }] }) const hit = f.find(x => x.code === 'board-representation-unbound') assert.ok(hit) assert.equal(hit.params?.boardId, '—') }) test('없는 보드를 가리키는 보드 표현도 잡는다', () => { const f = checkSpaceIntegrity({ ...SOUND, representations: [{ id: 'rep-board', spaceId: 'rosarito-site', type: 'board', boardId: 'gone', polygons: [] }], spaces: [{ spaceId: 'rosarito-site', primaryRepresentationId: 'rep-board' }], boardIds: ['other-board'] }) assert.ok(f.find(x => x.code === 'board-representation-unbound')) }) test('트윈이 사는데 표현이 없으면 경고한다 — 볼 화면이 없다', () => { const f = checkSpaceIntegrity({ ...SOUND, representations: [], spaces: [{ spaceId: 'rosarito-site' }] }) const hit = f.find(x => x.code === 'space-without-representation') assert.ok(hit) assert.equal(hit.params?.twins, 1) }) test('트윈이 없는 빈 현장은 정상이다 — 만들어 두고 아직 안 쓴 현장', () => { const f = checkSpaceIntegrity({ spaces: [{ spaceId: 'empty-site' }], instances: [], representations: [], areasBySpace: {}, boardIds: [] }) assert.deepEqual(f, []) }) test('벤치 사본은 검사 대상이 아니다 — 잠깐 살고 지워지는 사본', () => { const f = checkSpaceIntegrity({ ...SOUND, instances: [ ...SOUND.instances, { instanceId: 'bench-1', spaceId: 'nowhere-site', modelSpaceId: 'else', purpose: 'bench' } ] }) assert.deepEqual(f, [], '사본의 어긋남은 세지 않는다') }) test('심각한 것이 먼저 온다 — 사람이 무엇부터 볼지 정하지 않아도 되게', () => { const f = sortFindings([ { code: 'twin-without-space', severity: 'warn', message: 'w' }, { code: 'twin-space-missing', severity: 'broken', message: 'b' } ]) assert.equal(f[0].severity, 'broken') }) /* * ── 어휘가 맞지 않는 트윈 (2026-08-14, EMS 를 붙이며) ──────────────────────── * * 커널은 모르는 `eventType` 을 버리고 종류별로 센다(`state.unhandled`). 그 수가 0이 아니면 저널은 * 들어오는데 상태가 채워지지 않는 것이고, 화면에는 「정상인데 빈」 트윈으로 보인다. * * 이 검사가 지키는 것은 **모른다와 없다의 구별**이다: 정지한 트윈은 확인한 바가 없으므로 세지 않는다. */ test('담을 줄 모르는 어휘가 들어오는 트윈을 잡는다 — 리비전만 오르는 빈 트윈', () => { const f = checkSpaceIntegrity({ ...SOUND, instances: [{ ...SOUND.instances[0], unhandled: [{ eventType: 'energy.measured', count: 96 }, { eventType: 'energy.peak', count: 4 }] }] }) const hit = f.find(x => x.code === 'twin-unhandled-vocabulary')! assert.ok(hit, '이것을 놓치면 사용자는 커넥터가 잘못 붙은 것을 알 수 없다') assert.equal(hit.severity, 'warn') assert.equal(hit.instanceId, 'wms-a', '어느 트윈인지 지목해야 한다') assert.equal(hit.params?.events, 100, '건수를 합쳐 낸다') assert.equal(hit.params?.kinds, 2) assert.match(String(hit.params?.types), /energy\.measured/, '많은 것부터 이름을 낸다') }) test('정지한 트윈은 세지 않는다 — 확인하지 않은 것을 정합으로도 결함으로도 세지 않는다', () => { /* `unhandled` 가 undefined 인 것은 「없다」가 아니라 「모른다」다(런타임이 없으면 알 수 없다). */ const f = checkSpaceIntegrity(SOUND) assert.equal(f.find(x => x.code === 'twin-unhandled-vocabulary'), undefined) }) test('빈 배열은 결함이 아니다 — 받은 것이 없으면 들어온 것이 없다', () => { const f = checkSpaceIntegrity({ ...SOUND, instances: [{ ...SOUND.instances[0], unhandled: [] }] }) assert.equal(f.find(x => x.code === 'twin-unhandled-vocabulary'), undefined) })