/* * 전력 계통도 — **선언된 것만 그린다.** * * 이 시험이 지키는 것: 계층·소속이 그림에 그대로 남고, 모르는 것(자리 없는 설비·계량 없는 구간·결선 * 미상 부하)이 **각자 자기 칸으로** 나온다. 그림에서 사라지면 사용자는 자기 현장이 다 그려졌다고 믿는다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { buildEnergyTopology } from '../server/engine/energy-topology.ts' const plant = () => ({ locations: [ { id: 'a-plant', type: 'plant', name: '공장' }, // 공간 부모 — 계통 축이 아니다 { id: 'incoming-1', type: 'incoming', name: '수전', parentId: 'a-plant' }, { id: 'feeder-press', type: 'feeder', name: 'Press line', parentId: 'incoming-1' }, { id: 'feeder-weld', type: 'feeder', name: 'Weld line', parentId: 'incoming-1' }, { id: 'zone-1', type: 'submeter-zone', name: '도장 구역', parentId: 'feeder-weld' } ], equipment: [ { id: 'meter-incoming', kind: 'meter', homeLocation: 'incoming-1' }, { id: 'meter-press', kind: 'meter', homeLocation: 'feeder-press' }, { id: 'breaker-1', kind: 'breaker', homeLocation: 'feeder-press' }, { id: 'ess-1', kind: 'battery', homeLocation: 'incoming-1' }, { id: 'press-1', kind: 'curtailable-load', homeLocation: 'feeder-press' }, { id: 'load-hvac', kind: 'curtailable-load', homeLocation: 'incoming-1' }, // 결선 미상 { id: 'ghost', kind: 'curtailable-load', homeLocation: 'nowhere' } ] }) test('계통 계층만 잇는다 — 공간 부모(공장)는 이 축이 아니다', () => { const t = buildEnergyTopology(plant()) assert.equal(t.roots.length, 1) assert.equal(t.roots[0].id, 'incoming-1', '수전이 뿌리다(공장 아래에 있어도)') assert.deepEqual(t.roots[0].children.map(c => c.id), ['feeder-press', 'feeder-weld']) assert.deepEqual(t.roots[0].children[1].children.map(c => c.id), ['zone-1'], '구역은 그 분기 아래') }) test('계량기는 「이 구간을 잰다」로, 계통 설비는 따로, 부하는 부하로 — 섞지 않는다', () => { const t = buildEnergyTopology(plant()) const press = t.roots[0].children[0] assert.deepEqual(press.meters, ['meter-press']) assert.deepEqual(press.gear.map(g => g.kind), ['breaker']) assert.deepEqual(press.loads.map(l => l.id), ['press-1']) assert.deepEqual(t.roots[0].gear.map(g => g.kind), ['battery'], '축전지는 부하가 아니다') }) test('자리를 모르는 설비를 그림에서 지우지 않는다', () => { const t = buildEnergyTopology(plant()) assert.deepEqual(t.unplaced.map(u => u.id), ['ghost']) }) test('계량 없는 구간과 결선 미상 부하를 센다 — 「어디에 더 달아야 하나」의 답', () => { const t = buildEnergyTopology(plant()) assert.deepEqual(t.unmetered.sort(), ['feeder-weld', 'zone-1']) assert.equal(t.loadsOnIncoming, 1, '수전에 직결된 부하 — 어느 분기가 주는지 모르는 것들') }) test('폴드가 낸 몫을 노드에 얹는다 — 값을 다시 계산하지 않는다', () => { const t = buildEnergyTopology(plant(), { byFeeder: [ { key: 'feeder-press', kWh: 124, basis: 'measured' }, { key: 'feeder-weld', kWh: 103, basis: 'apportioned' } ] }) const [press, weld] = t.roots[0].children assert.equal(press.kWh, 124) assert.equal(press.basis, 'measured') assert.equal(weld.basis, 'apportioned') assert.equal(t.roots[0].kWh, undefined, '뿌리의 몫은 분기 표에 없다 — 지어내지 않는다') }) test('에너지 자리가 없으면 그림을 만들지 않는다 — 빈 그림은 고장으로 읽힌다', () => { const t = buildEnergyTopology({ locations: [{ id: 'dock-1', type: 'dock' }], equipment: [] }) assert.equal(t.roots.length, 0) }) /* * ── 발전·저장의 **지금 값**을 기호 옆에 (2026-08-18) ───────────────────────── * 원천이 태양광 발전량과 축전지 충전율·방전을 보내 주는데도 그림에는 기호만 있었다 — 화면이 아는 사실을 * 그림이 감추는 것이다. 다만 지금 값은 구간 값과 성격이 다르므로 시각을 함께 나른다(계측이 끊기면 * 멈춘 값을 지금 값으로 그리게 된다). */ const withSources = () => ({ locations: [{ id: 'incoming-1', type: 'incoming', name: '수전' }], equipment: [ { id: 'meter-incoming', kind: 'meter', homeLocation: 'incoming-1' }, { id: 'pv-roof', kind: 'pv-array', homeLocation: 'incoming-1' }, { id: 'ess-1', kind: 'battery', homeLocation: 'incoming-1' } ] }) test('종류가 아는 필드만 옮긴다 — 태양광에 충전율을 붙이지 않는다', () => { const t = buildEnergyTopology(withSources(), undefined, [ { id: 'pv-roof', kind: 'pv-array', generatedKW: 16, exportKW: 0, soc: 99, measuredAt: '2026-08-18T09:00:00.000Z' }, { id: 'ess-1', kind: 'battery', soc: 48, dischargeKW: 200, chargeKW: 0, generatedKW: 7, measuredAt: '2026-08-18T09:00:00.000Z' } ]) const gear = new Map(t.roots[0].gear.map(g => [g.id, g])) assert.deepEqual(gear.get('pv-roof')?.live, { generatedKW: 16, exportKW: 0, measuredAt: '2026-08-18T09:00:00.000Z' }) assert.deepEqual(gear.get('ess-1')?.live, { soc: 48, dischargeKW: 200, chargeKW: 0, measuredAt: '2026-08-18T09:00:00.000Z' }) }) test('상태를 주지 않으면 값을 만들지 않는다 — 구조만 그린다', () => { const t = buildEnergyTopology(withSources()) for (const g of t.roots[0].gear) assert.equal(g.live, undefined) }) test('보내지 않은 필드는 없다 — 0 으로 채우지 않는다', () => { const t = buildEnergyTopology(withSources(), undefined, [{ id: 'ess-1', kind: 'battery', soc: 30 }]) const ess = t.roots[0].gear.find(g => g.id === 'ess-1') assert.deepEqual(ess?.live, { soc: 30 }) assert.equal(ess?.live?.dischargeKW, undefined, '방전을 보내지 않았으면 모르는 것이다') })