/* * **승인을 지나지 않은 조치는 현장에 나가지 못한다** — 배선. * * ── 왜 배선을 따로 보나 (2026-08-31) ───────────────────────────────────── * 승인 판정 자체는 계약이 지킨다(`ops-contract/test/actuation-gate.test.ts`). 여기서 보는 것은 * **그 판정이 실제 경로에 서 있는가**다 — 어댑터가 불렸는가, 상태가 적혔는가, 다시 세운 뒤에도 승인이 * 남아 있는가. * * 이 저장소에서 가장 자주 나는 결함이 「자리는 있고 길이 없다」이고, 게이트가 그 부류로 나면 결과가 * 현장의 작업지시다. 그래서 마지막 시험이 **저장본만 남기고 다시 세운다.** */ import { test } from 'node:test' import assert from 'node:assert/strict' import type { TwinCommand } from '@operato/ops-contract' import { approve, dispatch, reject, type CommandAck, type CommandStore, type StoredCommand } from '../server/service/actuation/command-dispatcher.js' const DOMAIN = 'd-1' const AT = '2026-08-31T02:00:00.000Z' const APPROVAL = { by: 'line-lead', at: AT } const irreversible = (over: Partial = {}): TwinCommand => ({ id: 'cmd-1', instanceId: 'twin-a', type: 'order.hold', origin: 'ai', proposedAt: AT, state: 'proposed', ...over }) /** 저장본 하나 — 프로세스가 아니라 여기 산다. 그래서 다시 세워도 남는다. */ function makeStore(initial: StoredCommand): CommandStore & { rows: Map } { const rows = new Map([[initial.id, initial]]) return { rows, load: async (domainId, id) => (domainId === DOMAIN ? (rows.get(id) ?? null) : null), /* 저장소를 흉내 내는 것이라 복사해 넣는다 — 같은 객체면 안 적혀도 통과한다. */ save: async (domainId, command) => { assert.equal(domainId, DOMAIN) rows.set(command.id, JSON.parse(JSON.stringify(command))) }, create: async (domainId, command) => { assert.equal(domainId, DOMAIN) const copy = JSON.parse(JSON.stringify(command)) rows.set(command.id, copy) return copy } } } const ok = (ref = 'JOB-1') => async (): Promise => ({ ok: true, ref }) /* ── 승인 없이는 나가지 못한다 ────────────────────────────────────────────── */ test('★ 승인하지 않은 조치는 어댑터에 닿지 않는다', async () => { const store = makeStore(irreversible()) let called = false await assert.rejects( dispatch(store, DOMAIN, 'cmd-1', async () => { called = true return { ok: true } }), /승인되지 않은/ ) assert.equal(called, false, '어댑터가 불렸다 — 게이트가 서지 않았다') assert.equal(store.rows.get('cmd-1')!.state, 'proposed', '상태가 움직였다') }) test('★ 상태만 approved 로 적고 승인 기록이 없으면 막는다 — 저장본을 손으로 고쳐도 열리지 않는다', async () => { const store = makeStore(irreversible({ state: 'approved' })) let called = false await assert.rejects( dispatch(store, DOMAIN, 'cmd-1', async () => { called = true return { ok: true } }), /승인 기록이 없는/ ) assert.equal(called, false) }) test('★ 승인하면 나간다 — 어댑터가 돌려준 것이 남는다', async () => { const store = makeStore(irreversible()) await approve(store, DOMAIN, 'cmd-1', APPROVAL) const settled = await dispatch(store, DOMAIN, 'cmd-1', ok('JOB-77')) assert.equal(settled.state, 'acked') assert.equal(settled.dispatchRef, 'JOB-77', '되돌릴 때 무엇을 되돌릴지 말할 수 없게 된다') assert.equal(store.rows.get('cmd-1')!.approval?.by, 'line-lead') }) test('★ 넘기기 전에 「넘기는 중」을 먼저 적는다 — 그 사이에 죽으면 현장이 두 번 움직인다', async () => { const store = makeStore(irreversible()) await approve(store, DOMAIN, 'cmd-1', APPROVAL) let seenWhileDispatching: string | undefined await dispatch(store, DOMAIN, 'cmd-1', async () => { seenWhileDispatching = store.rows.get('cmd-1')!.state return { ok: true, ref: 'x' } }) assert.equal(seenWhileDispatching, 'dispatched', '어댑터를 부르는 동안 저장본이 아직 approved 였다') }) /* ── 되돌릴 수 있는 것의 다른 문 ─────────────────────────────────────────── */ test('★ 되돌릴 수 있는 조치는 사람 없이 지난다 — 시뮬과 what-if 가 이 길이다', async () => { const store = makeStore(irreversible({ reversible: true })) const settled = await dispatch(store, DOMAIN, 'cmd-1', ok()) assert.equal(settled.state, 'acked') }) test('★ 되돌릴 수 있다고 적지 않은 것은 그 문으로 못 간다 — 모름을 통과로 읽지 않는다', async () => { const store = makeStore(irreversible({ reversible: false })) await assert.rejects(dispatch(store, DOMAIN, 'cmd-1', ok())) }) /* ── 실패와 되풀이 ────────────────────────────────────────────────────────── */ test('★ 어댑터가 던지면 실패로 남고 이유가 적힌다', async () => { const store = makeStore(irreversible()) await approve(store, DOMAIN, 'cmd-1', APPROVAL) const settled = await dispatch(store, DOMAIN, 'cmd-1', async () => { throw new Error('MES 가 응답하지 않는다') }) assert.equal(settled.state, 'failed') assert.match(String(settled.error), /응답하지 않는다/) }) test('어댑터가 이유 없이 실패해도 빈 칸으로 두지 않는다', async () => { const store = makeStore(irreversible()) await approve(store, DOMAIN, 'cmd-1', APPROVAL) const settled = await dispatch(store, DOMAIN, 'cmd-1', async () => ({ ok: false })) assert.ok(String(settled.error).length > 0) }) test('★ 실패한 것은 다시 넘길 수 있다 — 승인을 다시 받지 않는다', async () => { const store = makeStore(irreversible()) await approve(store, DOMAIN, 'cmd-1', APPROVAL) await dispatch(store, DOMAIN, 'cmd-1', async () => ({ ok: false, error: '잠시 끊겼다' })) const retried = await dispatch(store, DOMAIN, 'cmd-1', ok('JOB-9')) assert.equal(retried.state, 'acked') assert.equal(retried.dispatchRef, 'JOB-9') }) test('★ 이미 답을 받은 것은 다시 나가지 않는다 — 두 번 나가면 현장이 두 번 움직인다', async () => { const store = makeStore(irreversible()) await approve(store, DOMAIN, 'cmd-1', APPROVAL) await dispatch(store, DOMAIN, 'cmd-1', ok()) await assert.rejects(dispatch(store, DOMAIN, 'cmd-1', ok()), /넘길 수 없다/) }) /* ── 거절 ─────────────────────────────────────────────────────────────────── */ test('★ 거절하면 끝이다 — 되살아나지 않는다', async () => { const store = makeStore(irreversible()) await reject(store, DOMAIN, 'cmd-1', { by: 'line-lead', at: AT, note: '지금 그 라인은 정지 중' }) assert.equal(store.rows.get('cmd-1')!.state, 'rejected') await assert.rejects(approve(store, DOMAIN, 'cmd-1', APPROVAL)) await assert.rejects(dispatch(store, DOMAIN, 'cmd-1', ok())) }) test('승인을 기다리는 중에도 거절할 수 있다', async () => { const store = makeStore(irreversible()) await approve(store, DOMAIN, 'cmd-1', APPROVAL) await reject(store, DOMAIN, 'cmd-1', { by: 'plant-manager', at: AT }) assert.equal(store.rows.get('cmd-1')!.state, 'rejected') }) /* ── 재기동 ───────────────────────────────────────────────────────────────── */ test('★ 승인이 재기동을 넘어 산다 — 사람이 승인한 것이 사라지면 안 된다', async () => { const first = makeStore(irreversible()) await approve(first, DOMAIN, 'cmd-1', APPROVAL) const saved: TwinCommand = JSON.parse(JSON.stringify(first.rows.get('cmd-1'))) /* 프로세스가 새로 선 것으로 본다 — 앞의 저장소 객체는 버리고 저장본만 물려준다. */ const second = makeStore(saved) const settled = await dispatch(second, DOMAIN, 'cmd-1', ok('JOB-3')) assert.equal(settled.state, 'acked', '이어받지 못하면 승인이 사라져 던진다') assert.equal(settled.approval?.by, 'line-lead') }) test('★ 재기동이 게이트를 열지 않는다 — 되세운 것도 같은 문을 지난다', async () => { /* JSON 에는 승인 표식이 없다. 되세울 때 표식을 그냥 붙이면 재기동이 곧 우회로가 된다. */ const saved: TwinCommand = JSON.parse(JSON.stringify(irreversible())) const store = makeStore(saved) await assert.rejects(dispatch(store, DOMAIN, 'cmd-1', ok()), /승인되지 않은/) }) test('모르는 커맨드는 조용히 넘어가지 않는다', async () => { const store = makeStore(irreversible()) await assert.rejects(dispatch(store, DOMAIN, 'nope', ok()), /모르는 커맨드/) await assert.rejects(approve(store, DOMAIN, 'nope', APPROVAL), /모르는 커맨드/) })