/* * 마감된 구간 사실의 **범위 배선** — 유입을 부르는 곳은 모두 어느 트윈인지 말해야 한다. * * ── 왜 이 시험이 있나 (2026-08-30) ──────────────────────────────────────── * 커널은 `new Kernel(domainId, …)` 으로 서므로 자기가 어느 트윈인지 모른다. 설비 번호·계량 지점 번호는 * 트윈 안에서만 통하는 이름표라, 범위를 넘기지 않으면 **서로 다른 설비의 같은 날이 한 사실**이 된다. * 실측으로 그 일이 났다 — 두 발전소의 `002` 가 겹쳐, 저널에 6줄이 있는데 서로 다른 이름은 5개였다. * 여러 현장을 함께 계산하는 성과 화면은 이름으로 중복을 걷어내므로 한쪽 발전량이 사라진다. * * 배선은 조용히 빠진다. 인자를 안 넘겨도 컴파일되고, 시험도 초록이고, 실 서버에서 값이 하나 사라질 * 뿐이다. 그래서 **부르는 자리를 세어서** 지킨다 — `ingest-wiring-guard` 와 같은 부류다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync, readdirSync, statSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' import { resolveSubject, ingestEnergyGenerationPeriodRecords } from '@operato/ops-contract' const here = dirname(fileURLToPath(import.meta.url)) const serverDir = join(here, '..', 'server') function allTs(dir: string, out: string[] = []): string[] { for (const name of readdirSync(dir)) { const full = join(dir, name) if (statSync(full).isDirectory()) allTs(full, out) else if (name.endsWith('.ts')) out.push(full) } return out } /** * 주석을 걷어낸다 — **설명에 적힌 이름을 호출로 세지 않는다.** * * 처음 이 시험을 만들었을 때 `reference-live.ts` 의 흐름 설명 한 줄(`ingestCanonicalRecords(정규→봉투)`)을 * 배선 빠진 호출로 잡았다. 주석을 고치게 만드는 시험은 아무것도 지키지 않는다. */ function withoutComments(src: string): string { return src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1') } test('★ 유입을 부르는 곳은 모두 어느 트윈인지 말한다 — 안 말하면 두 현장의 같은 번호가 겹친다', () => { const bad: string[] = [] for (const file of allTs(serverDir)) { if (file.endsWith('canonical-ingest.ts')) continue // 정의하는 자리 const src = withoutComments(readFileSync(file, 'utf-8')) let i = src.indexOf('ingestCanonicalRecords(') while (i >= 0) { /* 호출 하나를 괄호 균형으로 잘라 낸다 — 줄바꿈이 있어도 본다. */ let depth = 0 let j = i + 'ingestCanonicalRecords'.length for (; j < src.length; j++) { if (src[j] === '(') depth++ else if (src[j] === ')') { depth-- if (depth === 0) break } } const call = src.slice(i, j + 1) if (!call.includes('factScope')) { bad.push(`${file.slice(serverDir.length + 1)}: ${call.replace(/\s+/g, ' ').slice(0, 90)}`) } i = src.indexOf('ingestCanonicalRecords(', j) } } assert.deepEqual( bad, [], `범위를 넘기지 않는 유입 호출:\n${bad.join('\n')}\n` + 'TwinEngine.factScope(domainId, instanceId) 를 넷째 인자로 넘겨라 — ' + '넘기지 않으면 다른 현장의 같은 설비 번호가 한 사실로 겹치고, 성과 화면에서 한쪽이 사라진다.' ) }) test('★ 범위를 넘기면 두 현장의 같은 번호가 다른 사실이다', () => { const rec = { equipmentId: '002', from: '2026-08-28T15:00:00.000Z', to: '2026-08-29T15:00:00.000Z', kWh: 400.6 } const a = ingestEnergyGenerationPeriodRecords(rec, { tenantId: 'd', scopeId: 'twin-a' }).accepted[0] const b = ingestEnergyGenerationPeriodRecords(rec, { tenantId: 'd', scopeId: 'twin-b' }).accepted[0] assert.notEqual(a.eventId, b.eventId) }) test('선언된 정체성이 있으면 범위와 무관하게 한 사실이다 — 그릇이 정체성을 정하지 않는다', () => { const rec = { equipmentId: '002', from: '2026-08-28T15:00:00.000Z', to: '2026-08-29T15:00:00.000Z', kWh: 400.6 } const identityOf = () => 'https://example.com/obj/inv-002' const a = ingestEnergyGenerationPeriodRecords(rec, { tenantId: 'd', scopeId: 'twin-a', identityOf }).accepted[0] const b = ingestEnergyGenerationPeriodRecords(rec, { tenantId: 'd', scopeId: 'twin-b', identityOf }).accepted[0] assert.equal(a.eventId, b.eventId) assert.equal((a.data as any).subjectBasis, 'declared') }) test('커널이 범위를 답할 자리가 있다 — 호스트가 그것을 넘긴다', () => { const r = resolveSubject('equipment', '002', { tenantId: 'd', scopeId: 'ppms-호현' }) assert.equal(r.basis, 'twin-local') assert.equal(r.subject, 'ppms-호현/002') })