/* * 속성 효과 등록부 — **지어낸 효과를 등록하지 않고, 있는 효과를 숨기지 않는다.** * * ── 왜 이 시험이 있나 (2026-08-18) ────────────────────────────────────────── * 화면은 속성마다 「무엇을 바꾸나」를 말한다. 등록되지 않은 속성에는 「표시만 됩니다」라고 한다 — 그것이 * 정직한 기본값이다. 그런데 에너지 값들(계약전력·정격·요금)은 **오래전부터 읽히고 있었는데** 등록부에 * 없었다: 사용자는 계약전력을 넣고도 「이 값은 아무것도 바꾸지 않는다」는 문장을 읽었다. 들어온 것이 없는 거짓말이다. * * 반대 방향의 위험도 있다: 효과를 등록해 두고 **읽는 코드가 없으면** 화면이 「쓰입니다」라고 말하는데 * 아무 일도 일어나지 않는다. 그래서 등록부의 각 줄이 가리키는 자리를 이 시험이 확인한다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { createRequire } from 'node:module' import { PROPERTY_EFFECTS, propertyEffectsOf, declarationTargetsOf } from '../server/engine/property-effects.ts' const req = createRequire(import.meta.url) /* * 커널과 계약은 각각 **한 벌로 묶인 산출물**을 내보낸다(`dist-cjs/index.cjs`) — 서브패스는 exports 로 * 막혀 있다. 그래서 모듈마다 읽지 않고 묶음을 읽는다. * * **둘 다 읽는다** — 어휘와 판별은 계약에, 그것을 쓰는 엔진은 커널에 있다(2026-08-30 층 분리). * 한쪽만 읽으면 「읽는 코드가 없다」는 거짓 판정이 난다. */ const kernelBundle = readFileSync(req.resolve('@operato/twin-kernel'), 'utf-8') + '\n' + readFileSync(req.resolve('@operato/ops-contract'), 'utf-8') const hostSrc = (file: string): string => readFileSync(fileURLToPath(new URL(`../server/${file}`, import.meta.url)), 'utf-8') test('등록부의 모든 줄이 자기 자리를 가리킨다 — 빈 항목이 없다', () => { for (const e of PROPERTY_EFFECTS) { assert.ok(e.id, '속성 id 가 있어야 한다') assert.ok(e.axes.length, `${e.id}: 어느 축의 속성인지 말해야 한다`) assert.match(e.effect, /^twin\.propeffect\./, `${e.id}: 효과 문장은 i18n 키다(서버는 사람 말을 만들지 않는다)`) assert.ok(e.readBy && e.readBy.length > 10, `${e.id}: 실제로 읽는 자리를 적어야 한다`) } }) test('등록한 속성 id 를 읽는 코드가 실제로 있다 — 「쓰입니다」가 거짓이 되지 않게', () => { /* 읽는 쪽 소스 — 커널(에너지·환산·주행)과 호스트(요금·주행 추정). */ const corpus = [kernelBundle, hostSrc('engine/kpi-query.ts'), hostSrc('engine/travel-estimator.ts')].join('\n') const missing: string[] = [] for (const e of PROPERTY_EFFECTS) { /* id 그대로 나오거나(문자열 리터럴), 커널 어휘 상수의 마지막 마디로 나온다(EMS_PROPERTY.contractKW). */ const tail = e.id.includes('.') ? e.id.split('.').pop()! : e.id if (!corpus.includes(`'${e.id}'`) && !corpus.includes(`"${e.id}"`) && !new RegExp(`\\b${tail}\\b`).test(corpus)) missing.push(e.id) } assert.deepEqual(missing, [], `읽는 코드를 찾지 못한 등록 항목:\n${missing.join('\n')}`) }) test('에너지 값들이 등록돼 있다 — 예전에는 「표시만 됩니다」라고 말했다', () => { const ids = PROPERTY_EFFECTS.map(e => e.id) for (const id of ['contract.kW', 'power.ratedKW', 'storage.capacityKWh', 'tariff.demandChargePerKW', 'tariff.currency']) assert.ok(ids.includes(id), `${id} 가 등록부에 없다`) }) test('축으로 갈라 낸다 — 그 축의 항목에만 효과를 말한다', () => { const locations = propertyEffectsOf('locations') const equipment = propertyEffectsOf('equipment') assert.ok(locations['contract.kW'], '계약전력은 자리의 속성이다') assert.equal(locations['power.ratedKW'], undefined, '설비 속성을 자리에 말하지 않는다') assert.ok(equipment['speed'], '속도는 설비의 속성이다') assert.equal(equipment['tariff.currency'], undefined, '요금은 자리(수전)의 속성이다') }) test('요금은 커널이 아니라 호스트가 읽는다 — 그 사실을 readBy 가 말한다', () => { const tariff = PROPERTY_EFFECTS.filter(e => e.id.startsWith('tariff.')) assert.equal(tariff.length, 3) for (const e of tariff) assert.match(e.readBy, /kpi-query/, '금액은 저널을 계산하는 쪽이 만든다') }) /* * ── 어느 대상에 얹히나 (2026-08-18) ──────────────────────────────────────── * 지게차에 계약전력을 선언할 수 있었다 — 문은 받고 화면은 「넣었습니다」라고 말하는데, 읽는 코드는 * 자리(수전)만 본다. 그 제한은 이미 등록부의 축이 알고 있으므로, 검사에 그 지식을 쓴다(두 곳에 적지 * 않는다). 등록부에 없는 속성은 **제한을 모르는 것**이므로 받아 준다 — 속성은 열려 있고 효과만 닫혀 있다. */ test('축이 곧 얹을 수 있는 대상이다 — 목록을 따로 적지 않는다', () => { assert.deepEqual(declarationTargetsOf('contract.kW'), ['location']) assert.deepEqual(declarationTargetsOf('speed'), ['equipment']) assert.deepEqual(declarationTargetsOf('operation.duration'), ['operation']) /* 선언 대상이 아닌 축(자재 정의)은 제한을 말하지 않는다 — 이 문의 대상이 아니다. */ assert.equal(declarationTargetsOf('perBaseUnit'), undefined) assert.equal(declarationTargetsOf('made.up.property'), undefined, '모르는 속성은 제한도 모른다') }) test('공정 시간이 등록돼 있고, 그 값을 실어 보내는 자리를 가리킨다', () => { const e = PROPERTY_EFFECTS.find(x => x.id === 'operation.duration')! assert.ok(e, '공정 시간이 등록부에 있어야 한다') assert.deepEqual(e.axes, ['operations']) assert.match(e.readBy, /declareDurations/, '커널의 어느 창구로 가는지 말한다') /* 실어 보내는 코드가 실제로 있다 — 등록만 해 두고 아무도 나르지 않으면 화면이 거짓을 말한다. */ const layer = hostSrc('engine/local-declarations.ts') assert.match(layer, /OPERATION_PROPERTY\.duration/, '선언 겹이 그 속성을 안다') assert.match(layer, /isoDurationOf/, '커널이 읽는 표기로 바꾼다') assert.match(hostSrc('engine/twin-engine.ts'), /declareDurations\(declared\)/, '기동·구조전환 때 커널에 싣는다') }) /* * 모수도 같은 배선을 지난다 (2026-08-19). * * 이 시험이 소스를 보는 이유: 모수를 읽는 순간은 **작업이 완료될 때**라서 실 앱에서 관측하려면 라인이 * 한 바퀴 돌아야 한다(복원된 25분 작업이 쌓인 트윈에서는 그 관측이 오래 걸린다 — 실제로 그랬다). * 값이 커널까지 가는 **경로**는 여기서 지키고, 커널이 그 값을 쓰는지는 커널 시험이 값으로 지킨다. */ test('수율·셋업이 커널 창구까지 배선돼 있다 — 등록만 하고 나르지 않으면 화면이 거짓을 말한다', () => { const layer = hostSrc('engine/local-declarations.ts') assert.match(layer, /OPERATION_PROPERTY\.yield/, '선언 겹이 수율을 안다') assert.match(layer, /OPERATION_PROPERTY\.setupDuration/, '선언 겹이 셋업을 안다') /* 퍼센트 → 비율 환산은 **한 곳**이어야 한다(두 곳이면 100 이 두 번 걸린다). */ const divisions = layer.match(/pct \/ 100/g) ?? [] assert.equal(divisions.length, 1, `퍼센트 환산은 한 자리에서만 (발견 ${divisions.length})`) assert.match(hostSrc('engine/twin-engine.ts'), /declareParameters\(params\)/, '기동·구조전환 때 커널에 싣는다') /* 커널이 그 창구를 갖지 않은 버전이면 **말한다** — 알리지 않고 상수로 돌지 않게. */ assert.match(hostSrc('engine/twin-engine.ts'), /declareParameters missing/, '버전 어긋남을 경고로 드러낸다') }) test('공정 모수가 등록부에 있고 읽는 자리를 가리킨다', () => { for (const id of ['operation.yield', 'operation.setupDuration']) { const e = PROPERTY_EFFECTS.find(x => x.id === id)! assert.ok(e, `${id} 가 등록부에 있어야 한다`) assert.deepEqual(e.axes, ['operations']) assert.match(e.readBy, /declareParameters/, '어느 창구로 가는지 말한다') } })