/* * ★ 「원본이 말한 적 없는 것」을 세는 규율. * * ── 왜 이것이 필요한가 (2026-08-20 실측) ──────────────────────────────────── * 동기화 건강 기능을 만들 때 제가 전제를 이렇게 적었다: *「커넥터의 매핑이 한 글자 틀리면 트윈은 * 빈 공장이 된다」*. 사용자가 그 전제를 검증하게 해서 재 봤더니, 유입 문은 참조 오류를 **거부하지 * 않았다**: * * 모르는 로케이션 「오타-99」 → 통과 * 모르는 bizStep 「PUTAWAY_X17」 → 통과 * 모르는 계량기 「MMXU-오타-99」 → 통과 * * 즉 그 기능이 잡겠다고 내세운 대표 사례가 `mapping-blocked` 로는 **뜰 수 없었다.** 거부되는 것은 * 형태 오류뿐이다(시각 형식·숫자 아닌 값·epc 없음). * * 거부하지 않는 것이 옳다 — 원천이 모델보다 먼저 새 로케이션을 말하는 것은 정상이고, 거부하면 그 선행 * 계측을 잃는다. 커널은 흡수하고 **표시한다**(`origin: 'observed'`). 빠져 있던 것은 그 표시를 * 「맞춰지고 있나」를 보는 사람에게 잇는 일이었다. * * ── 지키는 것 ─────────────────────────────────────────────────────────────── * ① 마스터가 말한 것과 관측으로 알게 된 것을 섞지 않는다 * ② 트윈이 안 돌면 `null` — 「없다」와 「세어 보지 못했다」는 다르다 * ③ 알리지 않고 자르지 않는다 — 상한에서 밀려난 수를 함께 낸다 * ④ 덮지 못하는 축을 밝힌다 — 계량기는 커널이 표시를 달지 않아 이 화면이 못 찾아 준다 */ import { test } from 'node:test' import assert from 'node:assert/strict' import { GAP_ID_LIMIT, modelGapOf } from '../server/engine/model-gap.ts' const loc = (id: string, origin: string) => ({ id, origin }) test('마스터가 말한 로케이션은 갭이 아니다 — 선언된 것을 문제로 세지 않는다', () => { const g = modelGapOf({ locations: [loc('dock-recv', 'master'), loc('bin-1', 'master')], equipment: [loc('fk-1', 'master')] }) assert.equal(g!.total, 0) assert.deepEqual(g!.locations.ids, []) }) test('관측으로만 알게 된 것을 축마다 센다 — 원천이 우리가 모르는 id 를 보내고 있다', () => { const g = modelGapOf({ locations: [loc('dock-recv', 'master'), loc('오타-99', 'observed'), loc('A-77', 'observed')], equipment: [loc('fk-1', 'master'), loc('없는-지게차', 'observed')], persons: [loc('unknown-op', 'observed')] }) assert.equal(g!.locations.count, 2) assert.equal(g!.locations.unknown, 0, '표시가 다 있으면 모름은 0') assert.deepEqual(g!.locations.ids, ['오타-99', 'A-77'], 'id 를 보여 준다 — 오타는 눈으로 찾는다') assert.equal(g!.equipment.count, 1) assert.equal(g!.persons.count, 1) assert.equal(g!.total, 4, '세 축의 합') }) test('트윈이 안 돌면 「없음」이다 — 0 이 아니다', () => { /* 비교할 상대가 없다. 0 으로 답하면 「원천이 우리가 아는 것만 보낸다」는 없는 사실이 생긴다. */ assert.equal(modelGapOf(null), null) assert.equal(modelGapOf(undefined), null) }) test('돌지만 관측 갭이 없으면 0 이다 — 이건 「없음」과 다르다', () => { const g = modelGapOf({ locations: [] }) assert.notEqual(g, null, '세어 봤다') assert.equal(g!.total, 0, '세어 보니 없었다') }) test('id 를 알리지 않고 자르지 않는다 — 밀려난 수를 함께 낸다', () => { const many = Array.from({ length: GAP_ID_LIMIT + 7 }, (_, i) => loc(`ghost-${i}`, 'observed')) const g = modelGapOf({ locations: many }) assert.equal(g!.locations.count, GAP_ID_LIMIT + 7, '전부 센다') assert.equal(g!.locations.ids.length, GAP_ID_LIMIT, '보이는 것은 상한까지') assert.equal(g!.locations.more, 7, '밀려난 수를 말한다') }) test('커널이 담을 줄 모른 사건을 함께 낸다 — 참조 문제와 다른 사실이다', () => { const at = Date.parse('2026-08-20T01:02:03.000Z') const g = modelGapOf({ unhandled: [ { eventType: 'epcis.TransformationEvent', count: 12, lastAtMs: at }, { eventType: 'aggregation-mixed-classes', count: 3 } ] }) assert.equal(g!.unhandled.length, 2) assert.equal(g!.unhandled[0].lastAt, '2026-08-20T01:02:03.000Z', '언제까지 났는지 — 멈춘 종류는 고쳐진 것이다') assert.equal(g!.unhandled[1].lastAt, null, '시각이 없으면 지어내지 않는다') }) test('모르는 설비의 에너지 보고는 「없음」과 0 을 가른다 — 경로가 `energy` 안이다', () => { /* * 이 값을 **루트에서 읽고 있었다**(2026-08-20에 잡음). 커널은 `EnergyState` 안에 담으므로 루트에서 * 읽으면 영원히 `null` 이고, 오류 없이 「에너지 트윈이 아니다」로 읽힌다. 나만 읽는 필드를 잘못된 * 경로에서 읽으면 이렇게 오류 없이 사라진다. * * 그리고 커널은 0 이면 그 칸을 만들지 않는다. 그래서 `energy` 자체가 있는지로 「세었고 0」과 * 「에너지 트윈이 아님」을 가른다. */ assert.equal(modelGapOf({})!.unknownEquipmentReports, null, '에너지 트윈이 아니면 이 수가 없다') assert.equal(modelGapOf({ energy: { points: [] } })!.unknownEquipmentReports, 0, '에너지 트윈이면 세었고 0 이다') assert.equal(modelGapOf({ energy: { unknownEquipment: 41 } })!.unknownEquipmentReports, 41) assert.equal( modelGapOf({ unknownEquipment: 41 } as any)!.unknownEquipmentReports, null, '루트에 있는 값을 집지 않는다 — 그 경로는 커널의 것이 아니다' ) }) test('계량기도 덮는다 — 이 축이 가장 무겁다', () => { /* * 모르는 `meterId` 는 알리지 않고 새 계량기가 되고 그 kW 가 그 현장의 수요·피크·요금 판정에 더해진다. * 로케이션 오타는 스키매틱이 비는 것으로 드러나지만, 이건 **그럴듯한 숫자**로 나온다. * 커널 0.7.39 가 `MeterPointState.origin` 을 내면서 덮을 수 있게 됐다. */ const g = modelGapOf({ energy: { points: [loc('main-incoming', 'master'), loc('MMXU-오타-99', 'observed')] } }) assert.equal(g!.meters.count, 1) assert.deepEqual(g!.meters.ids, ['MMXU-오타-99']) assert.equal(g!.total, 1, '계량기도 합에 든다') assert.deepEqual(g!.notCovered, [], '덮지 못하는 축이 없다') }) test('표시가 없는 항목은 「선언됨」이 아니라 「모름」으로 센다', () => { /* * 계량기의 `origin` 은 늦게 생긴 칸이라, 옛 스냅샷에서 이어받은 지점에는 없다(`undefined`). * 마스터로 세면 오타를 놓치고, 관측으로 세면 정상을 문제로 만든다. 둘 다 틀리다. */ const g = modelGapOf({ energy: { points: [{ id: 'carried-over' }, loc('main', 'master'), loc('ghost', 'observed')] } }) assert.equal(g!.meters.unknown, 1, '표시 없는 것은 모름') assert.equal(g!.meters.count, 1, '관측 갭에 섞이지 않는다') assert.ok(!g!.meters.ids.includes('carried-over'), '모르는 것을 오타로 지목하지 않는다') }) test('`origin` 은 「지금 모델에 있는가」의 답이다 — 채택되면 갭에서 빠진다', () => { /* * 커널이 표본마다 다시 맞춘다. 한 번 찍고 두면 나중에 선언된 계량기가 영원히 `observed` 로 남는다. * 그래서 채택 뒤 스냅샷에서는 같은 id 가 `master` 로 오고, 이 도출은 그것을 그대로 따른다. */ const before = modelGapOf({ energy: { points: [loc('new-meter', 'observed')] } }) const after = modelGapOf({ energy: { points: [loc('new-meter', 'master')] } }) assert.equal(before!.meters.count, 1) assert.equal(after!.meters.count, 0, '모델에 들어오면 갭이 아니다') })