/* * **승인의 문은 한 벌이다** — 결재선에 올라간 조치는 여기서 결정되지 않는다. * * ── 무엇이 있었나 (2026-08-31) ─────────────────────────────────────────── * 게이트를 세우고 승인 화면을 계획하다가 물었다 — 「무엇을 승인하는 화면인가」. 커맨드 행을 **적는 * 코드가 하나도 없었다.** 대기 줄은 구조적으로 비어 있었다. 자리는 있고 길이 없는 그 부류다. * * 그것을 채우면서 문이 둘이 되었다: 이 패키지의 직접 결정과, 업무 목록(worklist)의 결재선. 문이 둘이면 * 결재선에는 대기로 남아 있는데 조치는 이미 나간 상태가 만들어지고, 사고 뒤에 **어느 기록이 진짜인지** * 말할 수 없다. * * 그래서 규칙 하나: **결재선 자리가 적힌 커맨드는 결재선에서만 결정한다.** 이 시험이 그 규칙을 지킨다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { CMD, MES_COMMANDS, type CommandTypeSpec } from '@operato/ops-contract' import { approve, dispatch, linkApproval, propose, reject, settleByApproval, type CommandStore, type StoredCommand } from '../server/service/actuation/command-dispatcher.js' const DOMAIN = 'd-1' const AT = '2026-08-31T02:00:00.000Z' const REF = 'activity-instance-1' /* MES 프로필의 선언을 그대로 쓴다 — 시험용 종류를 지어내면 선언과 어긋난 것을 시험이 통과시킨다. */ const SPEC: readonly CommandTypeSpec[] = MES_COMMANDS const ORDER = MES_COMMANDS[0]! const PAYLOAD = { materialDefinitionId: 'urn:epc:idpat:sgtin:9521234.00001.*', quantity: 12, uom: 'EA' } const PROPOSER = 'line-lead@plant.example' const APPROVER = 'plant-manager@plant.example' const seed = (over: Partial[3]> = {}) => ({ id: 'cmd-1', instanceId: 'twin-a', type: ORDER.type, origin: 'operator' as const, proposedBy: PROPOSER, proposedAt: AT, payload: PAYLOAD, ...over }) /** 저장본 하나 — 프로세스가 아니라 여기 산다. 그래서 다시 세워도 남는다. */ function makeStore(initial?: StoredCommand): CommandStore & { rows: Map } { const rows = new Map(initial ? [[initial.id, initial]] : []) const put = (command: StoredCommand) => { const copy: StoredCommand = JSON.parse(JSON.stringify(command)) rows.set(copy.id, copy) return copy } return { rows, load: async (domainId, id) => (domainId === DOMAIN ? (rows.get(id) ?? null) : null), save: async (domainId, command) => { assert.equal(domainId, DOMAIN) put(command) }, create: async (domainId, command) => { assert.equal(domainId, DOMAIN) if (rows.has(command.id)) throw new Error(`이미 있는 커맨드다 (${command.id})`) return put(command) } } } const ok = async (): Promise<{ ok: boolean; ref?: string }> => ({ ok: true, ref: 'JOB-1' }) /* ── 낸다 ─────────────────────────────────────────────────────────────────── */ test('★ 낸 조치가 대기 줄에 남는다 — 이 길이 없어 대기 줄이 비어 있었다', async () => { const store = makeStore() const command = await propose(store, DOMAIN, SPEC, seed()) assert.equal(command.state, 'proposed') assert.equal(store.rows.size, 1, '행이 적히지 않았다') assert.equal(store.rows.get('cmd-1')!.type, ORDER.type) assert.equal(store.rows.get('cmd-1')!.payload.quantity, 12, '무엇을 지시하는지가 남지 않으면 승인할 근거가 없다') }) test('★ 선언되지 않은 종류는 낼 수 없다 — 되돌릴 수 있는지 모르는 채로 게이트를 지나게 하지 않는다', async () => { const store = makeStore() await assert.rejects(propose(store, DOMAIN, SPEC, seed({ type: 'line.shutdown' })), /선언되지 않은 조치 종류/) assert.equal(store.rows.size, 0, '거절하면서 행을 적었다') }) test('★ 요구하는 칸이 없으면 낼 수 없다 — 빈 지시가 대기 줄에 쌓이지 않게', async () => { const store = makeStore() await assert.rejects(propose(store, DOMAIN, SPEC, seed({ payload: { quantity: 12 } })), /요구하는 칸이 없다/) assert.equal(store.rows.size, 0) }) test('선언이 「되돌릴 수 없다」이면 그대로 적힌다 — 낸 쪽이 정하지 않는다', async () => { const store = makeStore() const command = await propose(store, DOMAIN, SPEC, seed()) assert.equal(command.reversible, ORDER.reversible) assert.equal(command.reversible, false, 'MES 생산 지시는 되돌릴 수 없다 — 취소해도 만든 것은 남는다') }) /* ── 문은 한 벌이다 ───────────────────────────────────────────────────────── */ test('★ 결재선에 올라간 조치는 여기서 승인되지 않는다', async () => { const store = makeStore() await propose(store, DOMAIN, SPEC, seed()) await linkApproval(store, DOMAIN, 'cmd-1', REF) await assert.rejects(approve(store, DOMAIN, 'cmd-1', { by: APPROVER, at: AT }), /결재선에 올라가 있다/) await assert.rejects(reject(store, DOMAIN, 'cmd-1', { by: APPROVER, at: AT }), /결재선에 올라가 있다/) assert.equal(store.rows.get('cmd-1')!.state, 'proposed', '막았다면서 상태가 움직였다') }) test('★ 결재선을 쓰지 않는 배포에서는 직접 결정이 유일한 문이다', async () => { const store = makeStore() await propose(store, DOMAIN, SPEC, seed()) const approved = await approve(store, DOMAIN, 'cmd-1', { by: APPROVER, at: AT }) assert.equal(approved.state, 'approved') assert.equal((await dispatch(store, DOMAIN, 'cmd-1', ok)).state, 'acked') }) test('★ 올라가지 않은 커맨드에 결재선 판정을 옮기지 않는다 — 어디서 온 판정인지 알 수 없다', async () => { const store = makeStore() await propose(store, DOMAIN, SPEC, seed()) await assert.rejects( settleByApproval(store, DOMAIN, 'cmd-1', { approvalRef: REF, approved: true, by: APPROVER, at: AT }), /결재선에 올라가지 않은/ ) }) test('★ 다른 결재 건의 판정은 받지 않는다 — 옆 건의 승인으로 게이트가 열리지 않게', async () => { const store = makeStore() await propose(store, DOMAIN, SPEC, seed()) await linkApproval(store, DOMAIN, 'cmd-1', REF) await assert.rejects( settleByApproval(store, DOMAIN, 'cmd-1', { approvalRef: 'activity-instance-9', approved: true, by: APPROVER, at: AT }), /다른 결재 건의 판정/ ) assert.equal(store.rows.get('cmd-1')!.state, 'proposed') }) /* ── 결재선의 판정이 옮겨진다 ────────────────────────────────────────────── */ test('★ 결재선이 승인하면 나갈 수 있다 — 누가 승인했는지가 커맨드에도 남는다', async () => { const store = makeStore() await propose(store, DOMAIN, SPEC, seed()) await linkApproval(store, DOMAIN, 'cmd-1', REF) const approved = await settleByApproval(store, DOMAIN, 'cmd-1', { approvalRef: REF, approved: true, by: APPROVER, at: AT, note: '야간 물량 반영' }) assert.equal(approved.state, 'approved') /* 커맨드 목록이 결재선을 조회하지 않고도 「누가 언제」를 보여야 한다. */ assert.equal(store.rows.get('cmd-1')!.approval?.by, APPROVER) assert.equal(store.rows.get('cmd-1')!.approval?.note, '야간 물량 반영') assert.equal((await dispatch(store, DOMAIN, 'cmd-1', ok)).state, 'acked') }) test('★ 결재선이 거절하면 커맨드는 거기서 닫힌다 — 뒤늦은 승인이 게이트를 열지 못한다', async () => { /* * 업무 목록에서 「거절」은 고쳐 다시 올리라는 뜻이지만, 조치에는 고칠 것이 없다 — 지시 내용은 제안될 * 때 정해진다. 그래서 커맨드 쪽은 끝으로 다룬다. 그 뒤에 결재선에서 승인이 와도 계약이 막는다. */ const store = makeStore() await propose(store, DOMAIN, SPEC, seed()) await linkApproval(store, DOMAIN, 'cmd-1', REF) const rejected = await settleByApproval(store, DOMAIN, 'cmd-1', { approvalRef: REF, approved: false, by: APPROVER, at: AT, note: '그 라인은 정지 중이다' }) assert.equal(rejected.state, 'rejected') await assert.rejects( settleByApproval(store, DOMAIN, 'cmd-1', { approvalRef: REF, approved: true, by: 'someone-else@plant.example', at: AT }), /승인할 수 없다|승인되지 않은|rejected/ ) await assert.rejects(dispatch(store, DOMAIN, 'cmd-1', ok)) }) /* ── 자극과 조치는 다른 어휘다 ───────────────────────────────────────────── */ test('★ 커널 자극을 조치로 낼 수 없다 — 모델 안의 자극이 현장 지시가 되지 않게', async () => { /* * 계약에 어휘가 두 벌이다. * * CMD 커널 안에서 도는 자극. `resource.repair` = 모델에서 수리된 것으로 둔다 * MES_COMMANDS 현장에 나가는 지시. `production.order` = 실제로 만든다 * * 주의 신호가 권하는 것(`Attention.suggestedAction.command`)은 **앞엣것**이다. 주의를 읽어 조치를 * 내는 코드를 쓰다 보면 그 값을 그대로 넘기게 된다 — 실제로 그러려다 멈췄다. */ const store = makeStore() await assert.rejects( propose(store, DOMAIN, SPEC, seed({ type: CMD.resourceRepair })), /커널 안에서 도는 자극이다/ ) assert.equal(store.rows.size, 0) }) test('★ 두 어휘가 겹치지 않는다 — 겹치면 어느 쪽으로 읽어야 할지 알 수 없다', () => { /* * 이 시험이 지키는 것은 앞의 거절이 **뜻을 갖는다**는 사실이다. 같은 문자열이 양쪽에 있으면 거절도 * 통과도 옳지 않게 되고, 그때 위 시험은 초록인데 규칙은 사라진다. */ const stimuli = new Set(Object.values(CMD) as string[]) const overlap = MES_COMMANDS.map(s => s.type).filter(t => stimuli.has(t)) assert.deepEqual( overlap, [], `자극과 조치에 같은 이름이 있다 (${overlap.join(', ')}) — 한 이름이 모델 상태를 바꾸기도 하고 현장에 나가기도 한다` ) }) /* ── 낸 사람도 자기 것을 승인한다 ───────────────────────────────────────── */ /* * 낸 사람은 자기 조치를 승인하지 못한다는 규칙은 2026-10-23 사용자가 거두었다: 「twin 액추에이션 * 커맨드의 자기 승인 금지도 걷어내라」(워크리스트의 상신자 규칙 ADR-0052 ② 와 함께). 결재선이 비어 * 아무도 승인하지 않은 채 끝나는 길은 올리는 자리(operato-twin `approvalLineGap`)가 막는다. */ test('★ 낸 사람이 자기 조치를 직접 승인한다', async () => { const store = makeStore() await propose(store, DOMAIN, SPEC, seed()) assert.equal((await approve(store, DOMAIN, 'cmd-1', { by: PROPOSER, at: AT })).state, 'approved') }) test('★ 결재선을 지나온 판정도 낸 사람의 승인을 받는다', async () => { const store = makeStore() await propose(store, DOMAIN, SPEC, seed()) await linkApproval(store, DOMAIN, 'cmd-1', REF) await settleByApproval(store, DOMAIN, 'cmd-1', { approvalRef: REF, approved: true, by: PROPOSER, at: AT }) assert.equal(store.rows.get('cmd-1')!.state, 'approved') }) test('★ 낸 사람은 자기 것을 물릴 수 있다 — 거절에는 걸지 않는다', async () => { const store = makeStore() await propose(store, DOMAIN, SPEC, seed()) const rejected = await reject(store, DOMAIN, 'cmd-1', { by: PROPOSER, at: AT, note: '잘못 냈다' }) assert.equal(rejected.state, 'rejected') }) test('★ 사람이 내는데 낸 사람이 없으면 받지 않는다 — 누가 냈는지 남아야 한다', async () => { const store = makeStore() await assert.rejects(propose(store, DOMAIN, SPEC, seed({ proposedBy: undefined })), /낸 사람이 있어야 한다/) assert.equal(store.rows.size, 0) }) test('규칙·AI 가 낸 것은 낸 사람이 없다 — 사람이 아니다', async () => { const store = makeStore() const command = await propose(store, DOMAIN, SPEC, seed({ origin: 'rule', proposedBy: undefined })) assert.equal(command.proposedBy, undefined) /* 사람이 없으니 누구든 승인할 수 있다 — 그것이 이 경로의 뜻이다. */ assert.equal((await approve(store, DOMAIN, 'cmd-1', { by: APPROVER, at: AT })).state, 'approved') }) test('★ 낸 사람이 재기동을 넘어 산다 — 사고 뒤에 누가 냈는지 물을 자리다', async () => { const first = makeStore() await propose(first, DOMAIN, SPEC, seed()) const saved: StoredCommand = JSON.parse(JSON.stringify(first.rows.get('cmd-1'))) const second = makeStore(saved) assert.equal(second.rows.get('cmd-1')!.proposedBy, PROPOSER, '재기동에 낸 사람이 사라졌다') }) test('★ 넘긴 뒤에도 낸 사람이 남는다 — 사고 뒤에 누가 냈는지 물을 자리다', async () => { const store = makeStore() await propose(store, DOMAIN, SPEC, seed()) await approve(store, DOMAIN, 'cmd-1', { by: APPROVER, at: AT }) const settled = await dispatch(store, DOMAIN, 'cmd-1', ok) assert.equal(settled.proposedBy, PROPOSER) assert.equal(store.rows.get('cmd-1')!.proposedBy, PROPOSER, '넘기는 사이에 낸 사람이 지워졌다') }) /* ── 재기동 ───────────────────────────────────────────────────────────────── */ test('★ 결재선 자리가 재기동을 넘어 산다 — 다시 세운 뒤 직접 결정이 열리면 우회로가 된다', async () => { const first = makeStore() await propose(first, DOMAIN, SPEC, seed()) await linkApproval(first, DOMAIN, 'cmd-1', REF) /* 프로세스가 새로 선 것으로 본다 — 앞의 저장소 객체는 버리고 저장본만 물려준다. */ const saved: StoredCommand = JSON.parse(JSON.stringify(first.rows.get('cmd-1'))) const second = makeStore(saved) assert.equal(second.rows.get('cmd-1')!.approvalRef, REF, '재기동에 결재선 자리가 사라졌다') await assert.rejects(approve(second, DOMAIN, 'cmd-1', { by: APPROVER, at: AT }), /결재선에 올라가 있다/) const approved = await settleByApproval(second, DOMAIN, 'cmd-1', { approvalRef: REF, approved: true, by: APPROVER, at: AT }) assert.equal(approved.state, 'approved') }) test('★ 넘긴 뒤에도 결재선 자리가 남는다 — 사고 뒤에 어느 결재로 나갔는지 물을 자리다', async () => { const store = makeStore() await propose(store, DOMAIN, SPEC, seed()) await linkApproval(store, DOMAIN, 'cmd-1', REF) await settleByApproval(store, DOMAIN, 'cmd-1', { approvalRef: REF, approved: true, by: APPROVER, at: AT }) const settled = await dispatch(store, DOMAIN, 'cmd-1', ok) assert.equal(settled.state, 'acked') assert.equal(settled.approvalRef, REF) assert.equal(store.rows.get('cmd-1')!.approvalRef, REF, '넘기는 사이에 결재선 자리가 지워졌다') }) /* ── 받아들였으나 남은 것이 있다 ────────────────────────────────────────────── */ test('★ 저쪽이 덧붙인 말은 실패가 아니다 — 두 답이 구별되어야 한다', async () => { /* * 실측(2026-09-02): MES 에 조치 둘을 넘겼더니 트윈 쪽 두 행이 완전히 같았다. 하나는 지시서가 * 붙었고 하나는 안 붙었는데 `state`·`dispatch_ref`·`error` 가 셋 다 같았다. 화면은 둘 다 * 「나갔다」로 읽는다. */ const store = makeStore() await propose(store, DOMAIN, SPEC, seed()) await approve(store, DOMAIN, 'cmd-1', { by: APPROVER, at: AT }) const out = await dispatch(store, DOMAIN, 'cmd-1', async () => ({ ok: true, ref: 'WO-005', note: '지시서가 붙지 않았습니다 — 사람이 MES 에서 골라야 합니다' })) assert.equal(out.state, 'acked', '덧붙인 말이 있는 것을 실패로 앉혔다') assert.equal(out.dispatchRef, 'WO-005') assert.match(out.dispatchNote!, /지시서가 붙지 않았습니다/) assert.equal(out.error, undefined, '덧붙인 말을 오류 칸에 적었다') }) test('덧붙인 말이 없으면 그 칸은 비어 있다 — 빈 문장을 만들지 않는다', async () => { const store = makeStore() await propose(store, DOMAIN, SPEC, seed()) await approve(store, DOMAIN, 'cmd-1', { by: APPROVER, at: AT }) const out = await dispatch(store, DOMAIN, 'cmd-1', async () => ({ ok: true, ref: 'WO-004' })) assert.equal(out.state, 'acked') assert.equal(out.dispatchNote, undefined) }) /* ── 다시 넘길 때 지난 시도의 자국을 지운다 ────────────────────────────────── */ test('★ 다시 넘겨 성공하면 지난 실패의 오류가 남지 않는다 — 한 행이 두 가지를 말하면 안 된다', async () => { /* * 실측(2026-09-03): `failed → dispatch` 로 다시 넘겨 성공했는데 지난 실패의 `error` 가 그대로 * 남아, 한 행이 「받았다(acked)」와 「저쪽이 안 받는다」를 동시에 말했다. 화면이 그 행을 그리면 * 어느 쪽을 믿어야 하는지 알 수 없다. */ const store = makeStore() await propose(store, DOMAIN, SPEC, seed()) await approve(store, DOMAIN, 'cmd-1', { by: APPROVER, at: AT }) const failed = await dispatch(store, DOMAIN, 'cmd-1', async () => ({ ok: false, error: '저쪽에 비밀값이 없습니다' })) assert.equal(failed.state, 'failed') assert.match(failed.error!, /비밀값/) const retried = await dispatch(store, DOMAIN, 'cmd-1', async () => ({ ok: true, ref: 'WO-001' })) assert.equal(retried.state, 'acked') assert.equal(retried.dispatchRef, 'WO-001') assert.equal(retried.error, undefined, '지난 실패의 오류가 성공한 행에 남았다') }) test('★ 다시 넘겨 덧붙일 말이 없으면 옛 말이 남지 않는다 — 오류와 같은 부류다', async () => { const store = makeStore() await propose(store, DOMAIN, SPEC, seed()) await approve(store, DOMAIN, 'cmd-1', { by: APPROVER, at: AT }) /* 한 번 실패하면서 덧붙인 말이 남을 수 있는 자리를 만든다. */ await dispatch(store, DOMAIN, 'cmd-1', async () => ({ ok: true, ref: 'WO-1', note: '지시서를 골라야 합니다' })) /* `acked` 는 종착이라 다시 넘길 수 없다 — 그 사실을 함께 못 박는다. */ await assert.rejects(() => dispatch(store, DOMAIN, 'cmd-1', async () => ({ ok: true, ref: 'WO-2' })), /넘길 수 없다/) }) test('★ 다시 넘기다 또 실패하면 이번 사유만 남는다', async () => { const store = makeStore() await propose(store, DOMAIN, SPEC, seed()) await approve(store, DOMAIN, 'cmd-1', { by: APPROVER, at: AT }) await dispatch(store, DOMAIN, 'cmd-1', async () => ({ ok: false, error: '첫 번째 사유' })) const again = await dispatch(store, DOMAIN, 'cmd-1', async () => ({ ok: false, error: '두 번째 사유' })) assert.equal(again.error, '두 번째 사유', '두 사유가 겹쳐 남았다') })