/* * 자극은 **원본이 선언한다** — 그리고 재기동에도 살아 있다. * * ── 무엇이 났나 (2026-08-19) ──────────────────────────────────────────────── * 데모의 시나리오는 시드 코드가 **메모리에만** 실었다(`runtime.scenario.load`). 그래서 트윈을 재기동하면 * 자극이 사라지고 구조만 서 있는 트윈이 남았다 — 작업 0·오더 0. 「살아 있는 데모」가 첫 재기동까지만 * 살았고, 오늘 서버를 여러 번 띄우는 동안 데모 트윈들이 실제로 그 상태였다. * * ── 집을 트윈이 아니라 원본으로 (ADR-0029 · plans/simulator-as-source.md §4) ─ * 무엇이 들어오고 무슨 주문이 나는지는 **시뮬레이터(원본)가 정하는 사실**이고 트윈은 반영한다. 트윈에 * 축을 만들면 ADR-0029 가 나중에 걷어내야 하고, 원본(`TwinReference.connectionConfig`)에 두면 그 계획의 * 첫 조각(`openLiveFeed`)이 같은 선언을 그대로 읽는다. * * 이 시험이 지키는 것: * ① 선언이 없으면 알리지 않고 지난다(정적 구조만 반영하는 트윈이 정상이다). * ② **미러에는 싣지 않는다** — 외부 실물이 진실인 트윈에 도착을 지어 주면 그것은 현실이 아니다. * ③ 검사를 통과하지 못한 선언은 **태우지 않고** 이유를 남긴다(잘못된 선언 하나가 서버를 내린 이력이 있다). * ④ 검사기가 없으면 싣지 않는다 — 검사할 수 없는 선언을 태우는 것이 가장 비싼 선택이다. * ⑤ 선언을 얹을 때 **옆 설정을 지우지 않는다**(접속 정보가 같은 자리에 산다). */ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { planStimulus, withStimulus } from '../server/engine/declared-stimulus.ts' const scenario = { seed: 1, generators: [{ kind: 'inbound-arrival', rate: { distribution: 'constant', meanPerHour: 60 }, content: {} }] } const ok = () => ({ ok: true as const }) const bad = () => ({ ok: false as const, errorCode: 'scenario-sku-mix-required' }) const sim = { hasScenarioEngine: true, mode: 'sim' as const } test('선언이 없으면 알리지 않고 지난다 — 구조만 반영하는 트윈이 정상이다', () => { assert.deepEqual(planStimulus(undefined, sim, ok), { action: 'skip', reason: 'none' }) assert.deepEqual(planStimulus({ endpoint: 'x' }, sim, ok), { action: 'skip', reason: 'none' }) /* 시나리오 자리에 객체가 아닌 것이 오면 선언이 아니다(문자열을 시나리오로 읽지 않는다). */ assert.deepEqual(planStimulus({ scenario: 'daily' }, sim, ok), { action: 'skip', reason: 'none' }) }) test('관측으로 도는 트윈에는 싣지 않는다 — 현실을 지어내지 않는다', () => { const plan = planStimulus({ scenario }, { hasScenarioEngine: true, mode: 'live' }, ok) assert.deepEqual(plan, { action: 'skip', reason: 'observed' }) }) test('시나리오 엔진이 없으면 싣지 않는다 — 알리지 않고 아무 일도 없게 두지 않는다', () => { const plan = planStimulus({ scenario }, { hasScenarioEngine: false, mode: 'sim' }, ok) assert.deepEqual(plan, { action: 'skip', reason: 'no-engine' }) }) test('검사를 통과하지 못한 선언은 태우지 않는다 — 사유를 든다', () => { const plan = planStimulus({ scenario }, sim, bad) assert.deepEqual(plan, { action: 'skip', reason: 'invalid', detail: 'scenario-sku-mix-required' }) }) test('검사기가 없으면 싣지 않는다 — 검사할 수 없는 선언이 가장 위험하다', () => { const plan = planStimulus({ scenario }, sim, undefined) assert.equal(plan.action, 'skip') assert.equal((plan as any).reason, 'invalid') }) test('통과한 선언은 그대로 싣는다 — 값을 손대지 않는다', () => { const plan = planStimulus({ scenario }, sim, ok) assert.deepEqual(plan, { action: 'load', scenario }) }) test('선언을 얹으면서 접속 정보를 지우지 않는다', () => { const merged = withStimulus({ endpoint: 'https://sim', token: 't' }, scenario) assert.deepEqual(merged, { endpoint: 'https://sim', token: 't', scenario }) /* 설정이 없던 원본에도 얹힌다(빈 객체에서 시작). */ assert.deepEqual(withStimulus(undefined, scenario), { scenario }) }) /* * ── 배선 (소스로 지킨다) ──────────────────────────────────────────────────── * 판정은 순수 함수가 하지만, 그것을 **기동 때 부르지 않으면** 아무 일도 일어나지 않는다. 그리고 못 실은 * 이유는 말해야 한다 — 「선언했는데 안 실렸다」를 말하지 않으면 사용자는 트윈이 고장 난 줄로 읽는다. */ test('두 기동 경로에서 부른다 — 시뮬은 싣고, 미러는 그 사실을 말한다', () => { const src = readFileSync(fileURLToPath(new URL('../server/engine/twin-engine.ts', import.meta.url)), 'utf-8') const calls = src.match(/this\.installStimulus\(domainId, id, inst\)/g) ?? [] assert.equal(calls.length, 2, `기동 경로 둘에서 불러야 한다(발견 ${calls.length})`) assert.match(src, /planStimulus\(config, \{ hasScenarioEngine/, '판정은 순수 함수에 맡긴다') assert.match(src, /validateScenario/, '커널 검사기를 그대로 쓴다(규칙을 두 벌로 만들지 않는다)') assert.match(src, /a stimulus is declared on its source but was not loaded/, '못 실은 이유를 말한다') /* 선언을 적는 문도 있어야 한다 — 읽기만 있으면 아무도 선언할 수 없다. */ assert.match(src, /static async declareStimulus/) assert.match(src, /no source reference — a stimulus has nowhere to be declared/, '원본이 없으면 지어 만들지 않는다') })