/* * 호스트 방출 매핑(buildEntityDeltas) 단위 검증 — 씬 컴포넌트가 소비하는 data(tag) payload 계약. * * 지키는 것(2026-08-01 재편): * ① **레퍼런스가 아는 것은 전부 나간다** — 필드를 손으로 골라 담지 않는다. 화이트리스트였을 때 * 노드 parentId·작업 전량·물품 전량이 오류 없이 빠졌다. 계약에 속성이 생기면 자동으로 도달해야 한다. * ② **표식은 도메인 낱말을 훔치지 않는다** — 엔티티 종류 표식은 `entity`, `kind` 는 도메인 것 * (무버 cutter · 작업 putaway · 오더 workorder). * ③ **알리지 않고 자르지 않는다** — 물품 집약이 상한에 걸리면 total·truncated 로 밝힌다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { buildEntityDeltas, ITEMS_AGGREGATE_LIMIT, itemsTag, ordersTag, personsTag, tasksTag, energyTag } from '../shared/entity-delta.ts' const byTag = (arr: any[], tag: string) => arr.find(x => x.tag === tag) test('노드: 계약의 속성이 전부 나간다 — parentId(소속 구역)를 포함해', () => { const d = buildEntityDeltas({ locations: [{ id: 'bin-1', occupancy: 3, capacity: 10, status: 'idle', type: 'storage', parentId: 'zone-a' }] }, 't1') assert.deepEqual(byTag(d, 'bin-1').data, { entity: 'location', occupancy: 3, capacity: 10, status: 'idle', type: 'storage', parentId: 'zone-a' }) }) test('표식은 entity, kind 는 도메인 것 — 무버 종류가 표식에 덮이지 않는다', () => { const d = buildEntityDeltas({ equipment: [{ id: 'cutter-1', kind: 'cutter', location: 'cut', status: 'down', held: true }] }, 't1') const m = byTag(d, 'cutter-1').data assert.equal(m.entity, 'equipment', '표식') assert.equal(m.kind, 'cutter', '도메인 속성 — 예전엔 표식에 덮여 사라졌다') assert.equal(m.status, 'down') assert.equal(m.held, true) }) test('무버: OEE 는 정수 퍼센트로 정형화하고 원시 누적은 빼낸다(churn 억제)', () => { const d = buildEntityDeltas({ equipment: [ { id: 'cutter-1', kind: 'cutter', status: 'down', oee: { overall: 0.8234, availability: 0.9, performance: 0.95, quality: 0.96, goodCount: 12, nonconformingCount: 3, idleMs: 99999 } } ] }, 't1') assert.deepEqual(byTag(d, 'cutter-1').data.oee, { overall: 82, availability: 90, performance: 95, quality: 96, good: 12, scrap: 3 }) }) test('무버: oee 미측정이면 실리지 않는다', () => { const d = buildEntityDeltas({ equipment: [{ id: 'fk-1', kind: 'forklift', status: 'idle' }] }, 't1') assert.equal(byTag(d, 'fk-1').data.oee, undefined) }) test('작업: 개별 + 집약으로 나간다 — 예전엔 아예 방출되지 않았다', () => { const d = buildEntityDeltas({ tasks: [ { id: 't1', kind: 'putaway', status: 'in-progress', fromNode: 'dock', toNode: 'rack-1', resourceRef: 'fk-1', orderId: 'o1', progress: 0.4 }, { id: 't2', kind: 'pick', status: 'created' } ] }, 't1') assert.deepEqual(byTag(d, 't1').data, { entity: 'task', kind: 'putaway', status: 'in-progress', fromNode: 'dock', toNode: 'rack-1', resourceRef: 'fk-1', orderId: 'o1', progress: 0.4 }) const agg = byTag(d, tasksTag('t1')).data assert.equal(agg.entity, 'tasks') assert.equal(agg.tasks.length, 2) assert.equal(agg.tasks[0].id, 't1', '집약에는 식별자가 있다') assert.equal(agg.tasks[0].resourceRef, 'fk-1', '집약도 속성을 다 담는다') }) test('물품: 개별(태그=epc) + 집약으로 나간다 — 수량·소속 팔레트·만료까지', () => { const d = buildEntityDeltas({ items: [{ epc: 'urn:epc:id:sgtin:1.1.1', gtin: 'g1', location: 'rack-1', disposition: 'sellable', parent: 'sscc-1', qty: 40, expiry: 1234 }] }, 't1') assert.deepEqual(byTag(d, 'urn:epc:id:sgtin:1.1.1').data, { entity: 'item', gtin: 'g1', location: 'rack-1', disposition: 'sellable', parent: 'sscc-1', qty: 40, expiry: 1234 }) const agg = byTag(d, itemsTag('t1')).data assert.equal(agg.entity, 'items') assert.equal(agg.items[0].epc, 'urn:epc:id:sgtin:1.1.1') assert.equal(agg.items[0].qty, 40) assert.equal(agg.total, 1) assert.equal(agg.truncated, undefined, '상한 안이면 truncated 없음') }) test('물품 집약: 상한을 넘으면 잘린 사실을 밝힌다 — 들어온 것이 없는 절단 금지', () => { const items = Array.from({ length: ITEMS_AGGREGATE_LIMIT + 7 }, (_, i) => ({ epc: `e${i}`, location: 'rack-1' })) const d = buildEntityDeltas({ items }, 't1') const agg = byTag(d, itemsTag('t1')).data assert.equal(agg.items.length, ITEMS_AGGREGATE_LIMIT) assert.equal(agg.total, ITEMS_AGGREGATE_LIMIT + 7) assert.equal(agg.truncated, 7) /* 개별 물품은 상한과 무관하게 전부 태그별로 나간다(시그니처 dedup 이 변한 것만 재브로드캐스팅한다). */ assert.equal(d.filter(x => x.data?.entity === 'item').length, ITEMS_AGGREGATE_LIMIT + 7) }) test('오더: 개별 + 집약, 도메인 kind 유지', () => { const d = buildEntityDeltas({ orders: [ { id: 'o1', kind: 'workorder', status: 'picking', progress: 0.5, held: false }, { id: 'o2', kind: 'outbound', status: 'created', progress: 0, held: true } ] }, 't1') assert.equal(byTag(d, 'o1').data.kind, 'workorder') const agg = byTag(d, ordersTag('t1')).data assert.equal(agg.entity, 'orders') assert.equal(agg.orders.length, 2) assert.equal(agg.orders[1].held, true, '집약에 held 반영') }) test('계약에 새 속성이 생기면 손대지 않아도 도달한다 — 화이트리스트 재발 방지', () => { /* 이 테스트가 실패하면 누군가 필드를 손으로 골라 담기 시작한 것이다. */ const d = buildEntityDeltas({ locations: [{ id: 'n1', occupancy: 0, temperatureC: -18, hazmatClass: '3' }] }, 't1') const n = byTag(d, 'n1').data assert.equal(n.temperatureC, -18, '알지 못하는 새 속성도 통과해야 한다') assert.equal(n.hazmatClass, '3') }) test('undefined 값은 싣지 않는다 — 키 유무가 시그니처를 흔들지 않게', () => { const d = buildEntityDeltas({ equipment: [{ id: 'fk-1', kind: 'forklift', location: undefined, status: 'idle' }] }, 't1') assert.equal('location' in byTag(d, 'fk-1').data, false) }) test('빈 상태 — 집약 태그 셋은 항상 방출(빈 배열)', () => { const d = buildEntityDeltas({}, 't1') assert.deepEqual(byTag(d, ordersTag('t1')).data.orders, []) assert.deepEqual(byTag(d, tasksTag('t1')).data.tasks, []) assert.deepEqual(byTag(d, itemsTag('t1')).data.items, []) assert.equal(byTag(d, itemsTag('t1')).data.total, 0) }) /* ── 사람 ──────────────────────────────────────────────────────────────── * * 인원은 설비와 별개 엔티티다(고장·설비효율이 아니라 등급·교대로 산다). 같은 통과 규칙을 쓰되 * 자기 태그로 나가고, 집약 태그도 따로 둔다 — 인원을 한눈에 보는 소비처가 있기 때문이다. */ test('사람이 개별 태그로 나간다 — 등급·교대가 그대로', () => { const deltas = buildEntityDeltas({ persons: [{ id: 'w1', personnelClass: 'welder-op', status: 'busy', taskId: 't1', offShift: undefined }] }, 't1') const one = deltas.find(d => d.tag === 'w1')! assert.equal(one.data.entity, 'person') assert.equal(one.data.personnelClass, 'welder-op') assert.equal(one.data.status, 'busy') assert.equal(one.data.taskId, 't1') }) test('사람 집약 태그가 항상 나간다 — 선언하지 않은 트윈은 빈 배열(없음과 모름을 구별)', () => { const withNone = buildEntityDeltas({}, 't1') const agg = withNone.find(d => d.tag === personsTag('t1'))! assert.deepEqual(agg.data.persons, []) const withTwo = buildEntityDeltas({ persons: [{ id: 'w1' }, { id: 'w2' }] }, 't1') assert.equal(withTwo.find(d => d.tag === personsTag('t1'))!.data.persons.length, 2) }) /* * ── 에너지 (EMS 6단계) ────────────────────────────────────────────────────── * * 계측은 `state.energy` 에 산다 — 자원 축이 아니다. 이 매핑이 없으면 보드에 놓은 계량기가 값을 * 받지 못하고 **영원히 빈 모습**이 된다(컴포넌트는 있는데 데이터가 없는, 눈에 안 보이는 결함). */ test('계량 지점의 마지막 관측이 그 설비 태그로 나간다', () => { const deltas = buildEntityDeltas( { locations: [], equipment: [], energy: { points: [ { id: 'meter-incoming', kW: 1200.5, kWh: 98_000, powerFactor: 0.95, atMs: Date.parse('2026-08-14T09:01:00.000Z'), samplesInWindow: 3 }, { id: 'meter-quiet', samplesInWindow: 0 } ], closed: [], closedTotal: 0 } }, 'ems-1' ) const m = deltas.find(d => d.tag === 'meter-incoming')! assert.equal(m.data.entity, 'meter') assert.equal(m.data.kW, 1200.5) assert.equal(m.data.measuredAt, '2026-08-14T09:01:00.000Z', '시각을 함께 낸다 — 화면이 계측 끊김을 스스로 안다') /* 값이 없는 지점: 키를 넣지 않는다(0 으로 만들면 「전기를 안 썼다」가 된다). */ const q = deltas.find(d => d.tag === 'meter-quiet')! assert.equal('kW' in q.data, false) assert.equal(q.data.samplesInWindow, 0, '받은 표본 수는 사실이다') }) test('수요 구간·피크·계약은 집약 태그로 나간다 — 개체의 사실이 아니다', () => { const deltas = buildEntityDeltas( { locations: [], equipment: [], energy: { points: [], contractKW: 4000, open: { startMs: 1, endMs: 2, samples: 1, maxKW: 4200 }, peakSince: { kW: 4200, windowStartMs: 1 }, closed: Array.from({ length: 20 }, (_, i) => ({ startMs: i, endMs: i + 1, samples: 1, maxKW: 100 + i })), closedTotal: 137 } }, 'ems-1' ) const agg = deltas.find(d => d.tag === energyTag('ems-1'))! assert.ok(agg, '집약 태그가 있어야 한다') assert.equal(agg.data.contractKW, 4000) assert.equal(agg.data.peakSince.kW, 4200) /* 하루치를 매 브로드캐스팅에 실으면 채널이 그것으로 찬다 — 최근 것만 내고 전체 수를 함께 낸다. */ assert.equal(agg.data.recent.length, 8) assert.equal(agg.data.closedTotal, 137, '자른 사실을 알리지 않고 두지 않는다') }) test('에너지가 없는 트윈은 에너지 태그를 내지 않는다 — 없는 것을 만들지 않는다', () => { const deltas = buildEntityDeltas({ locations: [], equipment: [] }, 'wms-1') assert.equal(deltas.some(d => d.tag === energyTag('wms-1')), false) assert.equal(deltas.some(d => d.data?.entity === 'meter'), false) }) test('자리에도 에너지가 실린다 — 능력이 「잰다」고 말하는데 값이 없던 자리', () => { const deltas = buildEntityDeltas( { locations: [ { id: 'incoming-1', type: 'incoming' }, { id: 'feeder-a', type: 'feeder' }, { id: 'feeder-b', type: 'feeder' } ], equipment: [ { id: 'm-main', kind: 'meter', homeLocation: 'incoming-1' }, { id: 'm-a1', kind: 'meter', homeLocation: 'feeder-a' }, { id: 'm-a2', kind: 'meter', homeLocation: 'feeder-a' } ], energy: { points: [ { id: 'm-main', kW: 3800, kWh: 1000, atMs: Date.parse('2026-08-14T09:00:00.000Z'), samplesInWindow: 3 }, { id: 'm-a1', kW: 1000, atMs: Date.parse('2026-08-14T09:01:00.000Z'), samplesInWindow: 2 }, { id: 'm-a2', kW: 600, atMs: Date.parse('2026-08-14T09:02:00.000Z'), samplesInWindow: 1 } ], closed: [], closedTotal: 0 } }, 'ems-1' ) const incoming = deltas.find(d => d.tag === 'incoming-1')!.data assert.equal(incoming.entity, 'location', '자리 payload 를 덮지 않고 합친다') assert.equal(incoming.kW, 3800) assert.deepEqual(incoming.meters, ['m-main'], '어느 계량기에서 온 수인지 되짚을 수 있어야 한다') /* 자리에 계량기가 둘이면 그 자리의 수는 둘의 합이고, 나이는 **가장 최근**이다. */ const a = deltas.find(d => d.tag === 'feeder-a')!.data assert.equal(a.kW, 1600) assert.equal(a.samplesInWindow, 3) assert.equal(a.measuredAt, '2026-08-14T09:02:00.000Z') assert.deepEqual(a.meters, ['m-a1', 'm-a2']) /* 계량기가 없는 자리는 예전 그대로 — 에너지 키를 만들지 않는다(0 은 「안 썼다」가 된다). */ const b = deltas.find(d => d.tag === 'feeder-b')!.data assert.equal('kW' in b, false) assert.equal('meters' in b, false) }) test('자리를 모르는 계량기는 자리에 실리지 않는다 — 설비 태그로만 나간다', () => { const deltas = buildEntityDeltas( { locations: [{ id: 'incoming-1', type: 'incoming' }], equipment: [], energy: { points: [{ id: 'm-orphan', kW: 500, samplesInWindow: 1 }], closed: [], closedTotal: 0 } }, 'ems-1' ) assert.equal('kW' in deltas.find(d => d.tag === 'incoming-1')!.data, false) assert.equal(deltas.find(d => d.tag === 'm-orphan')!.data.kW, 500, '설비 태그로는 나간다') }) /* * ── 능력 넷의 값이 컴포넌트까지 간다 (2026-08-15) ─────────────────────────── * 보드에 놓인 컴포넌트는 `data(tag)` 로만 값을 받는다. 커널이 값을 들고 있어도 이 페이로드에 실리지 * 않으면 화면은 영원히 「—」다 — 실제로 그 부류를 두 번 겪었다(스냅샷 투영이 필드를 빠뜨렸고, * 값의 시각을 버렸다). * * 그래서 **설비 태그의 payload** 를 직접 못 박는다: 값과 그 값의 나이가 함께 실려야 한다. */ test('발전·저장·감축·개폐 값과 그 나이가 설비 태그로 나간다', () => { const deltas = buildEntityDeltas( { locations: [{ id: 'incoming-1', type: 'incoming' }], equipment: [ { id: 'pv-1', kind: 'pv-array', homeLocation: 'incoming-1', status: 'idle', generatedKW: 210, exportKW: 40, measuredAt: '2026-08-15T09:00:00.000Z' }, { id: 'ess-1', kind: 'battery', homeLocation: 'incoming-1', status: 'idle', soc: 62, chargeKW: 0, dischargeKW: 85, measuredAt: '2026-08-15T09:00:00.000Z' }, { id: 'load-1', kind: 'curtailable-load', homeLocation: 'incoming-1', status: 'busy', curtailable: true, minKW: 30, measuredAt: '2026-08-15T09:00:00.000Z' }, { id: 'cb-1', kind: 'breaker', homeLocation: 'incoming-1', status: 'idle', position: 'closed', measuredAt: '2026-08-15T09:00:00.000Z' } ] }, 'ems-1' ) const by = Object.fromEntries(deltas.map(d => [d.tag, d.data as any])) assert.equal(by['pv-1'].generatedKW, 210) assert.equal(by['pv-1'].exportKW, 40) assert.equal(by['ess-1'].soc, 62) assert.equal(by['ess-1'].dischargeKW, 85) assert.equal(by['load-1'].curtailable, true) assert.equal(by['load-1'].minKW, 30) assert.equal(by['cb-1'].position, 'closed') /* 나이가 없으면 화면이 멈춘 값을 지금 값처럼 그린다 — 네 컴포넌트가 모두 이 키를 읽는다. */ for (const id of ['pv-1', 'ess-1', 'load-1', 'cb-1']) { assert.equal(by[id].measuredAt, '2026-08-15T09:00:00.000Z', `${id}: 값의 나이가 함께 가야 한다`) } }) test('값이 없는 설비에는 그 키를 만들지 않는다 — 0 으로 그리면 「안 쓴다」가 된다', () => { const deltas = buildEntityDeltas( { locations: [{ id: 'incoming-1', type: 'incoming' }], equipment: [{ id: 'pv-2', kind: 'pv-array', homeLocation: 'incoming-1', status: 'idle' }] }, 'ems-1' ) const pv = deltas.find(d => d.tag === 'pv-2')!.data as any assert.equal('generatedKW' in pv, false) assert.equal('measuredAt' in pv, false) })