/* * **규칙 주기가 저장소에 닿는다** — 그 배선. * * 판정 자체는 `actuation-rule.test.ts` 가 지킨다. 여기서 보는 것은 **그 판정이 실제 경로에 서 있는가**다 * — 커맨드가 적혔는가, 근거가 남았는가, 다시 세운 뒤에도 중복이 안 나는가, 한 트윈이 실패해도 나머지가 * 도는가. * * 이 저장소에서 가장 자주 나는 결함이 「자리는 있고 길이 없다」이고, 규칙이 그 부류로 나면 두 결과 중 * 하나다 — 아무 지시도 안 나가거나, 같은 지시가 대기 줄에 쌓인다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { MES_COMMANDS } from '@operato/ops-contract' import type { CommandStore, StoredCommand } from '../server/service/actuation/command-dispatcher.js' import type { ActuationRuleSpec, AttentionLike } from '../server/service/actuation/rule-evaluate.js' import { runRulesOnce, type RuleRunnerDeps } from '../server/service/actuation/rule-runner.js' const NOW = Date.parse('2026-09-01T03:00:00.000Z') const HOUR = 3_600_000 const TWIN = { domainId: 'd-1', instanceId: 'twin-a' } const ORDER = MES_COMMANDS[0]! const attention = (over: Partial = {}): AttentionLike => ({ id: 'bottleneck:paint-1', kind: 'bottleneck', severity: 'high', state: 'active', anchor: { locationId: 'paint-1' }, timeStamp: new Date(NOW - 3 * HOUR).toISOString(), params: { ratioPct: 118 }, ...over }) const rule = (over: Partial = {}): ActuationRuleSpec => ({ id: 'rule-1', instanceId: TWIN.instanceId, attentionKind: 'bottleneck', minHeldMs: 2 * HOUR, commandType: ORDER.type, payloadTemplate: { materialDefinitionId: 'urn:epc:idpat:sgtin:9521234.00001.*', quantity: 20, uom: 'EA' }, ...over }) /** 저장본 하나 — 프로세스가 아니라 여기 산다. 그래서 다시 세워도 남는다. */ function makeStore(seed: StoredCommand[] = []) { const rows = new Map(seed.map(c => [c.id, c])) const store: CommandStore & { rows: Map } = { rows, load: async (_d, id) => rows.get(id) ?? null, save: async (_d, c) => void rows.set(c.id, JSON.parse(JSON.stringify(c))), create: async (_d, c) => { const copy: StoredCommand = JSON.parse(JSON.stringify(c)) rows.set(copy.id, copy) return copy }, openCauses: async (_d, instanceId) => new Set( [...rows.values()] .filter(c => c.instanceId === instanceId && c.state === 'proposed' && c.causeRef) .map(c => c.causeRef!) ) } return store } function deps(over: Partial = {}): RuleRunnerDeps { return { ownedTwins: () => [TWIN], rulesOf: async () => [rule()], attentionsOf: () => [attention()], modeOf: () => 'live' as const, store: makeStore(), specs: MES_COMMANDS, nowMs: () => NOW, ...over } } /* ── 실제로 적힌다 ────────────────────────────────────────────────────────── */ test('★ 규칙이 낸 조치가 저장소에 적힌다 — 판정만 있고 적히지 않으면 아무 일도 없다', async () => { const store = makeStore() const out = await runRulesOnce(deps({ store })) assert.equal(out.length, 1) assert.equal(out[0]!.proposed, 1) assert.equal(store.rows.size, 1) const row = [...store.rows.values()][0]! assert.equal(row.state, 'proposed') assert.equal(row.type, ORDER.type) assert.equal(row.payload.quantity, 20) }) test('★ 근거가 커맨드에 남는다 — 이것이 없으면 다음 주기가 같은 것을 또 올린다', async () => { const store = makeStore() await runRulesOnce(deps({ store })) const row = [...store.rows.values()][0]! assert.equal(row.causeRef, 'bottleneck:paint-1') assert.equal(row.ruleRef, 'rule-1') }) test('★ 규칙이 낸 것에는 낸 사람이 없다 — 사람이 아니므로 아무 승인자든 두 번째 사람이 된다', async () => { const store = makeStore() await runRulesOnce(deps({ store })) const row = [...store.rows.values()][0]! assert.equal(row.origin, 'rule') assert.equal(row.proposedBy, undefined) }) test('★ 규칙은 결재선에 올리지 않는다 — 배경 루프에는 「나」가 없어 Myself·MySupervisor 가 뜻을 잃는다', async () => { const store = makeStore() await runRulesOnce(deps({ store })) const row = [...store.rows.values()][0]! assert.equal(row.approvalRef, undefined, '올렸다면 결재선이 시스템 사용자 기준으로 풀린다') assert.equal(row.state, 'proposed', '올리는 것은 사람이 한다 — 그때까지 게이트는 서 있다') }) /* ── 시늉된 조건에서 현장 지시를 내지 않는다 ─────────────────────────────── */ test('★ 시뮬 트윈에는 조치를 내지 않는다 — 지어낸 조건이 현장 지시가 되지 않게', async () => { /* * 시뮬 트윈의 병목은 커널이 스스로 물품을 옮겨 만든 것이다. 현장에서 관측된 것이 아니다. * 오늘 규칙을 확인한 트윈이 전부 `sim` 이었고 시늉된 병목에서 생산 지시가 올라왔다 — 배선은 * 옳게 돌았지만 근거가 가정이었다. */ const store = makeStore() const out = await runRulesOnce(deps({ store, modeOf: () => 'sim' })) assert.equal(out[0]!.proposed, 0) assert.equal(store.rows.size, 0) assert.match(out[0]!.skips[0]!.reason, /시뮬레이션 모드.*현장 지시를 생성하지 않습니다/) }) test('★ 모드를 모르면 내지 않는다 — 모름을 「관측이다」로 읽지 않는다', async () => { const store = makeStore() const out = await runRulesOnce(deps({ store, modeOf: () => undefined })) assert.equal(out[0]!.proposed, 0) assert.match(out[0]!.skips[0]!.reason, /실행 모드를 확인할 수 없/) }) /* ── 두 번 올리지 않는다 ─────────────────────────────────────────────────── */ test('★ 두 번 돌아도 하나만 적힌다', async () => { const store = makeStore() const d = deps({ store }) await runRulesOnce(d) const second = await runRulesOnce(d) assert.equal(second[0]!.proposed, 0) assert.equal(store.rows.size, 1, '같은 조건에 지시가 둘 적혔다') }) test('★ 재기동해도 두 번 올리지 않는다 — 근거를 저장소에서 읽기 때문이다', async () => { const first = makeStore() await runRulesOnce(deps({ store: first })) /* 프로세스가 새로 선 것으로 본다 — 저장본만 물려주고 판정 상태는 버린다. */ const saved = [...first.rows.values()].map(c => JSON.parse(JSON.stringify(c)) as StoredCommand) const second = makeStore(saved) const out = await runRulesOnce(deps({ store: second })) assert.equal(out[0]!.proposed, 0) assert.equal(second.rows.size, 1) }) test('★ 결정이 끝난 것은 다시 막지 않는다 — 어제 거절한 조건이 오늘 또 성립하면 새 판단거리다', async () => { const store = makeStore() await runRulesOnce(deps({ store })) /* 사람이 거절했다. */ const row = [...store.rows.values()][0]! await store.save(TWIN.domainId, { ...row, state: 'rejected' }) const out = await runRulesOnce(deps({ store })) assert.equal(out[0]!.proposed, 1, '거절된 것이 영구히 그 조건을 막고 있다') assert.equal(store.rows.size, 2) }) /* ── 저장소가 근거를 말하지 않으면 판정하지 않는다 ──────────────────────── */ test('★ 근거를 읽을 길이 없으면 아무것도 올리지 않고 말한다 — 조용히 중복을 내지 않는다', async () => { const store = makeStore() delete (store as any).openCauses const out = await runRulesOnce(deps({ store })) assert.equal(out[0]!.proposed, 0) assert.equal(store.rows.size, 0) assert.match(out[0]!.skips[0]!.reason, /openCauses/) }) /* ── 상태를 못 읽는 트윈 ─────────────────────────────────────────────────── */ test('★ 스냅샷이 없는 트윈은 「주의 0건」이 아니다 — 멈춘 트윈을 조건 없음으로 읽지 않는다', async () => { const out = await runRulesOnce(deps({ attentionsOf: () => undefined })) assert.equal(out[0]!.proposed, 0) assert.match(out[0]!.skips[0]!.reason, /실행이 중지되었거나 아직 시작되지 않았을 수 있습니다/) }) test('주의가 정말 0건이면 이유를 만들지 않는다 — 정상이다', async () => { const out = await runRulesOnce(deps({ attentionsOf: () => [] })) assert.equal(out[0]!.proposed, 0) assert.deepEqual(out[0]!.skips, []) }) test('규칙이 없으면 상태를 읽지도 않는다 — 아무것도 안 할 일에 값을 만들지 않는다', async () => { let read = 0 const out = await runRulesOnce( deps({ rulesOf: async () => [], attentionsOf: () => { read++ return [] } }) ) assert.equal(read, 0) assert.deepEqual(out[0]!.skips, []) }) /* ── 한 트윈이 실패해도 나머지는 돈다 ───────────────────────────────────── */ test('★ 한 트윈의 규칙이 터져도 다른 트윈은 돈다 — 고치는 동안 전체가 멈추지 않게', async () => { const other = { domainId: 'd-1', instanceId: 'twin-b' } const store = makeStore() const out = await runRulesOnce( deps({ store, ownedTwins: () => [TWIN, other], rulesOf: async twin => { if (twin.instanceId === TWIN.instanceId) throw new Error('규칙을 읽지 못했다') return [rule({ instanceId: other.instanceId })] } }) ) assert.equal(out.length, 2) assert.match(String(out[0]!.failed), /규칙을 읽지 못했다/) assert.equal(out[1]!.proposed, 1, '앞 트윈이 터져서 뒤 트윈이 안 돌았다') }) test('★ 한 지시가 거절돼도 나머지는 낸다 — 그리고 거절을 삼키지 않는다', async () => { const store = makeStore() const out = await runRulesOnce( deps({ store, /* 하나는 선언되지 않은 종류를 가리키고, 하나는 제대로 된 것. */ rulesOf: async () => [rule({ id: 'bad', commandType: 'line.shutdown' }), rule({ id: 'good' })], attentionsOf: () => [attention()] }) ) assert.equal(out[0]!.proposed, 1) assert.equal(out[0]!.skips.length, 1) assert.match(out[0]!.skips[0]!.reason, /선언되지 않은 조치 종류/) }) /* ── 돈 결과를 남긴다 ───────────────────────────────────────────────────── */ test('★ 돈 결과를 밖으로 넘긴다 — 조용히 안 도는 규칙과 조건이 없어서 안 내는 규칙을 구별하려면 필요하다', async () => { const notes: any[] = [] await runRulesOnce(deps({ noteRun: async (twin, report) => void notes.push({ twin, report }) })) assert.equal(notes.length, 1) assert.equal(notes[0].report.proposed, 1) }) test('기록을 남기지 못해도 판정은 끝난 것이다 — 기록 실패로 조치를 되돌리지 않는다', async () => { const store = makeStore() const out = await runRulesOnce( deps({ store, noteRun: async () => { throw new Error('기록 실패') } }) ) assert.equal(out[0]!.proposed, 1) assert.equal(store.rows.size, 1) })