/* * 축의 값을 어디서 읽나 — **선언이 말한 자리에서** (§10 6.5단계) * * ── 왜 있나 ──────────────────────────────────────────────────────────────── * 개념 지도는 상태 축을 `state[축이름]` 으로 읽었다. 에너지의 수요 구간은 `state.energy.closed` 에 * 살아서 그 규칙으로는 **언제나 `undefined`** 다 — 카드가 「관측 없음」으로 굳는다. 축은 선언돼 있고 * 상태에도 값이 있는데 이름이 안 맞을 뿐이라, 화면도 서버도 그 원인을 말해 주지 않는다. * * 이 시험이 지키는 것은 **모름과 없음의 구별**이다: 경로 중간이 비면 `undefined`(모른다)이고, 배열이 * 비면 빈 배열(없다)이다. 그 둘을 섞으면 「아직 안 온 것」과 「하나도 없는 것」이 같아진다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { atPath, axisValueOf } from '../shared/axis-read.ts' test('경로가 없는 축은 이름으로 읽는다 — 지금까지의 거동', () => { const state = { orders: [{ id: 'o1' }], tasks: [] } assert.deepEqual(axisValueOf(state, { axis: 'orders' }), [{ id: 'o1' }]) assert.deepEqual(axisValueOf(state, { axis: 'tasks' }), [], '빈 배열은 「없다」다 — 모른다가 아니다') }) test('경로가 있으면 그 경로로 읽는다 — 중첩된 축이 오류 없이 사라지지 않게', () => { const state = { energy: { closed: [{ startMs: 1 }, { startMs: 2 }] } } const value = axisValueOf(state, { axis: 'demandWindows', path: 'energy.closed' }) assert.equal(Array.isArray(value), true) assert.equal(value.length, 2) /* 축 이름으로 읽으면 없다 — 그것이 이 함수가 생긴 이유다. */ assert.equal((state as any).demandWindows, undefined) }) test('경로 중간이 비면 모른다 — 빈 배열로 메우지 않는다', () => { assert.equal(axisValueOf({}, { axis: 'demandWindows', path: 'energy.closed' }), undefined) assert.equal(axisValueOf({ energy: {} }, { axis: 'demandWindows', path: 'energy.closed' }), undefined) /* 에너지 트윈이 아직 표본을 못 받았을 때가 이 경우다 — 「구간 0」이 아니라 「모름」이다. */ }) test('상태가 없으면 모른다 — 커널이 멈춰 있으면 0 이 아니다', () => { assert.equal(axisValueOf(null, { axis: 'orders' }), undefined) assert.equal(axisValueOf(undefined, { axis: 'demandWindows', path: 'energy.closed' }), undefined) }) test('경로 따라가기는 중간 부재에서 멈춘다 — 오류를 내지 않는다', () => { assert.equal(atPath({ a: { b: 1 } }, 'a.b'), 1) assert.equal(atPath({ a: null }, 'a.b'), undefined) assert.equal(atPath(undefined, 'a.b'), undefined) }) test('커널 선언과 상태의 모양이 맞는다 — 선언한 경로에 정말 값이 있다', async () => { /* * 선언(`path`)과 실제 상태의 모양이 어긋나면 이 배선은 알리지 않고 빈 카드를 만든다. 그래서 **커널이 낸 * 상태로** 확인한다 — 선언을 읽는 쪽과 값을 만드는 쪽이 같은 모양을 쓰는지 보는 것이 요점이다. */ const { EmsKernel, DEMAND_WINDOW_MS } = await import('@operato/twin-kernel') const { axisInfo, ENERGY_EVENT } = await import('@operato/ops-contract') const info = axisInfo('demandWindows')! assert.ok(info, '커널이 그 축을 선언해야 한다') assert.equal(info.path, 'energy.closed') const k = new EmsKernel('t') k.loadTwinModel({ locations: [{ id: 'i', type: 'incoming', capacity: 0 }], equipment: [] }) const at = Date.parse('2026-08-14T05:00:00.000Z') const sample = (whenMs: number, kW: number) => ({ eventId: `e${whenMs}`, eventType: ENERGY_EVENT.measured, eventTime: new Date(whenMs).toISOString(), tenantId: 't', data: { meterId: 'm', kW, at: new Date(whenMs).toISOString() } }) /* * 표본이 오기 전에는 **빈 목록**이다 — 그리고 그것이 맞다. * * 처음에는 `undefined`(모름)를 기대했다가 이 시험에 걸렸다. 판단해 보면 빈 목록이 옳다: 커널은 * 관측 중이고, 수요 구간은 15분이 지나야 닫힌다. 「아직 하나도 마감되지 않았다」는 **참인 사실**이고 * 개념 지도가 그것을 `none-yet` 으로 그린다. * * 「모른다」는 다른 경우다 — 커널이 멈춰 있어 상태 자체가 없을 때(위 시험). 그 둘을 섞지 않는 것이 * 이 배선의 요점이다. */ assert.deepEqual(axisValueOf(k.getSnapshot(), info), [], '관측 중인데 아직 마감이 없으면 빈 목록이다') k.apply(sample(at + 60_000, 100) as any) k.apply(sample(at + DEMAND_WINDOW_MS + 60_000, 120) as any) // 앞 구간 마감 const windows = axisValueOf(k.getSnapshot(), info) assert.equal(Array.isArray(windows), true, '선언한 경로에 목록이 있어야 한다') assert.equal(windows.length, 1, '마감된 구간 하나') }) /* * ── 규칙의 집이 하나인지 **기계가 지킨다** (2026-08-21) ────────────────────── * * 위 단위 시험은 처음부터 초록이었다. 함수는 옳았고, **호출부 하나가 그 함수를 지나지 않았다** — * `twin-model-item-query.ts` 가 `(snap as any)[axis]` 로 축 이름을 직접 읽었다. 그래서 개념 지도의 * 카드는 수요 구간을 **42** 로 세는데 그 개념을 열면 **0건**이 나왔다. 사용자에게는 「트윈이 비어 * 있다」로 읽힌다. * * 그 함정은 형제 파일 주석에 이미 적혀 있었다(「축 이름으로만 읽으면 중첩된 축이 언제나 관측 없음이 * 된다 — 에너지의 수요 구간이 그렇다」). **적어 두는 것으로는 막히지 않았다.** 그래서 센다. */ import { readFileSync, readdirSync } from 'node:fs' import { createRequire } from 'node:module' import { join } from 'node:path' import { fileURLToPath } from 'node:url' const SVC = fileURLToPath(new URL('../server/service/twin-model', import.meta.url)) /** * 상태에서 축을 **이름으로** 읽는 모양 — `snap[axis]` · `(state as any)[axis]` 류. * * 앞에 `\b` 를 둔다: 처음에는 없어서 `const` 안의 `st` 를 잡아 `ROW_BACKED[axis]`(조회표이지 상태 * 읽기가 아니다)를 위반으로 냈다. **거짓 빨강을 내는 가드는 없는 것보다 나쁘다** — 이름이 근거다. */ const BY_NAME = /\(?\s*\b(?:snap|state)\b[^)\n]*\)?\s*\[\s*axis\s*\]/g test('축을 이름으로 읽는 자리가 없다 — 선언된 경로를 지나야 중첩된 축이 보인다', () => { const offenders: string[] = [] for (const f of readdirSync(SVC).filter(n => n.endsWith('.ts'))) { const src = readFileSync(join(SVC, f), 'utf8') for (const line of src.split('\n')) { /* 주석은 규칙이 아니라 설명이다 — 함정을 적어 둔 줄이 스스로 위반으로 잡히면 신호가 묻힌다. */ const code = line.replace(/\/\*.*?\*\//g, '').replace(/\/\/.*$/, '') if (code.trimStart().startsWith('*')) continue if (BY_NAME.test(code)) offenders.push(`${f}: ${line.trim()}`) BY_NAME.lastIndex = 0 } } assert.deepEqual( offenders, [], `축을 이름으로 읽는 자리가 있다 — \`axisValueOf(state, 축선언)\` 을 쓰십시오.\n${offenders.join('\n')}` ) }) test('수요 구간은 실제로 중첩된 축이다 — 이 가드가 지키는 대상이 있다', () => { /* 가드가 지킬 것이 없으면 그 가드는 초록으로 거짓을 말한다. 대상이 실재하는지 함께 본다. */ const req = createRequire(import.meta.url) const { TWIN_AXES } = req('@operato/ops-contract') const nested = (TWIN_AXES as any[]).filter(a => a.path) assert.ok(nested.length > 0, '경로를 가진 축이 하나도 없으면 이 가드는 의미가 없다') assert.ok( nested.some(a => a.axis === 'demandWindows'), `수요 구간이 경로 축이어야 한다 — 실제 경로 축: ${nested.map(a => a.axis).join(', ')}` ) }) /* * ── 무엇이 항목을 가리키는지도 **선언이 말한다** (2026-08-21) ───────────────── * * 개념 지도의 수요 구간을 열면 여섯 구간이 모두 「식별자 없음 — 참조할 수 없는 항목입니다」로 보였다. * 값(구간 시작·종료·최대수요)은 실재하는데 **가리킬 수 없다고 말한 것**이다. * * 원인은 축이 사는 자리를 짐작했던 것과 **같은 부류**다: 소비처가 `id`·`key`·`gtin` 을 순서대로 * 짐작했고, 수요 구간에는 그 셋이 없다. 그것은 결함이 아니다 — 구간의 정체성은 **언제 시작했나**이고 * 커널도 그것으로 키를 만든다(`contract-projected-over:`). */ test('id 가 없는 상태 축은 무엇이 항목을 가리키는지 선언한다', () => { const req2 = createRequire(import.meta.url) const { TWIN_AXES } = req2('@operato/ops-contract') const dw = (TWIN_AXES as any[]).find(a => a.axis === 'demandWindows') assert.ok(dw, '수요 구간 축이 있어야 한다') assert.equal(dw.idField, 'startMs', '구간의 정체성은 시작 시각이다') }) test('항목 질의가 짐작하지 않는다 — 선언된 자리를 읽는다', () => { /* * 기계가 지킨다. 주석에 적어 두는 것으로는 막히지 않는 것을 오늘 이미 겪었다(축이 사는 자리를 * 짐작한 자리 셋이 형제 파일 주석의 경고에도 남아 있었다). */ /* * ── **파일 이름에 묶지 않는다** (2026-08-22) ──────────────────────────────── * 예전에는 `twin-model-item-query.ts` 하나만 읽었다. 다른 레인이 `idOf` 를 `name-index.ts` 로 * 옮기자(기능은 배열 차례까지 온전했다) 이 가드가 **거짓 빨강**을 냈다. 가드가 지켜야 하는 것은 * 「그 파일에 그 낱말이 있나」가 아니라 「**이 서비스 어딘가에서 선언된 식별 자리를 읽나**」다. * * 그래서 서비스 폴더를 훑는다 — 옮겨도 따라간다. 옮길 자유를 막는 가드는 사람을 가드와 싸우게 만든다. */ const sources = readdirSync(SVC, { recursive: true } as any) .filter((f: any) => String(f).endsWith('.ts')) .map((f: any) => readFileSync(join(SVC, String(f)), 'utf8')) const all = sources.join('\n') assert.match(all, /idField/, '선언된 식별 자리를 읽어야 한다(이 서비스 어딘가에서)') /* `x?.id === itemId` 로 찾던 자리가 남아 있으면 그 축의 항목은 열 수 없다. */ assert.doesNotMatch(all, /find\(\(x: any\) => x\?\.id === itemId\)/, 'id 로만 찾는 자리가 남지 않았다') })