/* * 유입 배선 감시 — **커널이 문을 열었는데 그 문으로 가는 길이 없는 것**을 잡는다. * * ── 왜 있나 (2026-08-30) ──────────────────────────────────────────────────── * 2026-08-29 하루에 이 부류가 다섯 번 났다. 커널에 새 어휘의 문을 만들고, 정규 유입의 갈림에 * 그 길을 내지 않은 것이다. 그때마다 레코드는 **거부되지도 않고 아무 문에도 들어가지 못한 채** * 사라졌다 — 커넥터는 보냈다고 믿고, 화면은 값이 없다고 말한다. 사람이 하나씩 찾아냈다. * * 다음 날 같은 부류의 **두 번째 얼굴**이 드러났다. EPCIS 갈래는 「나머지 전부」라, 새 어휘를 그 * 목록에서 빼 주지 않으면 레코드가 제 문으로 가면서 **EPCIS 문으로도 들어가 거부된다.** 받아들여 * 지기도 하고 거부되기도 하는 상태가 되어, 유입 장부의 거부 수가 늘고 사람이 그것을 진짜 결함으로 * 읽는다. * * ── 무엇을 지키나 ────────────────────────────────────────────────────────── * ① 커널의 유입 함수마다 부르는 곳이 있다 * ② 어휘마다 **정확히 한 문**으로 간다 — 어느 문에도 못 가거나 두 문에 걸치지 않는다 * * ── 목록을 손으로 적지 않는다 ────────────────────────────────────────────── * 커널이 내보내는 이름에서 읽는다. 커널이 문을 늘리면 이 시험이 먼저 빨갛게 난다. 손 목록을 두면 * 늘어난 것을 모른다 — 이 저장소가 이미 그 실패를 겪었다(`vocabulary-labels` 의 주석). */ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' /* 유입 문과 판별 함수는 계약에 있다 — 커널은 엔진만 내보낸다(2026-08-30 층 분리). */ import * as kernel from '@operato/ops-contract' import { ingestCanonicalRecords } from '../server/engine/canonical-ingest.ts' const here = dirname(fileURLToPath(import.meta.url)) const routerSource = readFileSync(join(here, '..', 'server', 'engine', 'canonical-ingest.ts'), 'utf-8') /** * 정규 유입이 부르지 **않아도 되는** 것 — 각각 이유가 있다. * * 이 목록에 올릴 때는 이유를 함께 적는다. 이유 없이 올리면 이 시험은 아무것도 지키지 않는다. */ const NOT_ROUTED: Record = { /* 물류 어휘의 진입점 자체다 — 갈림이 이것을 부른다(이름이 `ingest` 하나뿐이라 규칙에 안 걸린다). */ ingest: 'EPCIS 어휘의 진입점. 갈림이 직접 부른다', /* 마스터 데이터는 사건이 아니라 어휘 갱신이라 다른 경로로 간다. */ ingestMasterData: '사건이 아니라 어휘 갱신 — 참조 계층이 부른다' } test('★ 커널의 유입 함수마다 부르는 곳이 있다', () => { const ingests = Object.keys(kernel).filter(n => /^ingest/.test(n)) assert.ok(ingests.length >= 8, `유입 함수를 못 읽었다(${ingests.length}개) — 커널을 다시 읽어야 한다`) const unrouted = ingests.filter(n => !NOT_ROUTED[n] && !routerSource.includes(n)) assert.deepEqual( unrouted, [], `커널이 문을 열었는데 정규 유입에 길이 없다:\n${unrouted.join('\n')}\n` + '그 문으로 보내는 레코드는 거부되지도 않고 사라진다.\n' + '갈림에 길을 내거나, 부르지 않아도 되는 이유를 NOT_ROUTED 에 적어라.' ) }) test('부르지 않아도 되는 목록에 이유가 적혀 있다', () => { for (const [name, why] of Object.entries(NOT_ROUTED)) { assert.ok(why.trim().length > 5, `${name} 에 이유가 없다`) } }) /** * 어휘마다 대표 레코드 하나 — **그 문으로만 가야 한다.** * * 값은 실제로 오는 모양에서 가져왔다(2026-08-29 실측). 지어낸 모양으로 검사하면 실제 레코드가 * 다른 문으로 가는 것을 놓친다. */ const NOW = '2026-08-30T00:00:00.000Z' const SAMPLES: { vocabulary: string; eventType: string; record: Record }[] = [ { vocabulary: '물품 관측(EPCIS)', eventType: 'epcis.ObjectEvent', record: { epc: 'urn:epc:id:sgtin:0614141.107346.2017', action: 'OBSERVE', bizStep: 'urn:epcglobal:cbv:bizstep:storing', readPoint: 'rack-1' } }, { vocabulary: '계량 표본', eventType: 'energy.measured', record: { meterId: 'incoming', kW: 100, at: NOW } }, { vocabulary: '설비 상태', eventType: 'energy.equipment', record: { equipmentId: 'inv-1', generatedKW: 24.6, dcVoltage: 469.13, at: NOW } }, { vocabulary: '발전 적산', eventType: 'energy.generated', record: { equipmentId: 'inv-1', kWh: 174, at: NOW } }, { vocabulary: '마감된 사용 구간', eventType: 'energy.usage.period', record: { meterId: 'incoming', from: '2026-08-29T15:00:00.000Z', to: '2026-08-29T16:00:00.000Z', kWh: 474.48, maxKW: 478.08, unitPrice: 87.3, currency: 'KRW', basis: 'provider' } }, { vocabulary: '요금 기준', eventType: 'energy.tariff.basis', record: { /* 기준 선언은 **끝나지 않은 달**이 정상이다 — 사건 시각이 기간의 시작이라 미래에 적히지 않는다. */ from: '2026-07-31T15:00:00.000Z', to: '2026-08-31T15:00:00.000Z', billingDemandKW: 1650, demandChargePerKW: 8320, currency: 'KRW' } }, { vocabulary: '청구서', eventType: 'energy.bill', record: { /* * 청구서는 **마감된 달**이다. 처음 이 표본은 끝나지 않은 8월을 담고 있었는데, 그것은 실제로 * 들어와 있던 잘못된 데이터를 그대로 베낀 것이었다(2026-08-30). 그 데이터 때문에 한 현장의 * 성과 구간이 미래로 밀려 화면이 비어 있었다. 표본이 결함을 베끼면 시험이 결함을 지킨다. */ from: '2026-06-30T15:00:00.000Z', to: '2026-07-31T15:00:00.000Z', demandCharge: 13_728_000, currency: 'KRW', billingDemandKW: 1650 } }, { vocabulary: '발전 단가', eventType: 'energy.generation.price', record: { from: '2026-08-29T15:00:00.000Z', to: '2026-08-30T15:00:00.000Z', unitPrice: 148.61, currency: 'KRW' } }, { /* * 라이브에서는 커널이 스스로 마감해 내지만, **지난 기록을 채울 때**는 밖에서 들어온다. * 구간이 있으므로 적산의 문(`energy.generated`)이 아니라 이쪽으로 가야 한다 — 겹치면 지난 * 기록이 「지금 적산」으로도 읽혀 트윈의 시계가 과거로 끌린다. */ vocabulary: '마감된 발전 기간', eventType: 'energy.generation.period', record: { equipmentId: '001', from: '2026-08-28T15:00:00.000Z', to: '2026-08-29T15:00:00.000Z', kWh: 394.3, accumulation: 'daily' } } ] test('★ 어휘마다 정확히 한 문으로 간다 — 사라지지도, 두 문에 걸치지도 않는다', () => { const wrong: string[] = [] for (const s of SAMPLES) { const r = ingestCanonicalRecords([s.record] as any, 't', NOW) const accepted = (r.accepted as any[]).map(e => e.eventType) if (r.rejected.length) { wrong.push(`${s.vocabulary}: 거부됐다 — ${JSON.stringify(r.rejected[0]?.errors)}`) continue } if (accepted.length !== 1) { wrong.push(`${s.vocabulary}: 봉투가 ${accepted.length}개다(${accepted.join(', ')}) — 한 문으로만 가야 한다`) continue } if (accepted[0] !== s.eventType) wrong.push(`${s.vocabulary}: ${s.eventType} 이어야 하는데 ${accepted[0]} 로 갔다`) } assert.deepEqual(wrong, [], `\n${wrong.join('\n')}`) }) test('★ 한 배치에 모든 어휘가 섞여 와도 각자의 문으로 간다', () => { const r = ingestCanonicalRecords(SAMPLES.map(s => s.record) as any, 't', NOW) assert.deepEqual(r.rejected, [], `거부가 없어야 한다: ${JSON.stringify(r.rejected)}`) assert.deepEqual( (r.accepted as any[]).map(e => e.eventType).sort(), SAMPLES.map(s => s.eventType).sort() ) }) test('커널이 어휘를 늘리면 이 시험이 먼저 안다', () => { /* * 판별 함수의 수와 위 표본의 수가 어긋나면, 커널에 어휘가 늘었는데 여기 표본이 없다는 뜻이다. * (EPCIS 안의 갈림 둘(담김·변환)은 한 어휘로 센다 — 같은 문으로 간다.) */ const gates = Object.keys(kernel).filter(n => /^is.*Record$/.test(n)) const counted = gates.filter(n => !['isAggregationRecord', 'isTransformationRecord', 'isMasterDataRecord'].includes(n)) assert.equal( counted.length, SAMPLES.length, `커널의 어휘는 ${counted.length}개인데 표본은 ${SAMPLES.length}개다:\n${counted.join(' ')}\n` + '늘어난 어휘의 표본을 SAMPLES 에 더하라 — 그래야 그 어휘도 한 문으로 가는지 검사된다.' ) })