/* * **폐루프의 배선** — 관측이 들어올 때 나간 지시와 실제로 이어지는가. * * 판정은 `actuation-effect.test.ts` 가 지킨다. 여기서 보는 것은 **그 판정이 유입 경로에 서 있는가**다 * — 봉투에서 오더 보고를 골라내는가, 저장소에 적히는가, 실패해도 관측 유입이 멈추지 않는가. * * 마지막 것이 특히 중요하다. 효과를 적다 실패해서 유입이 멈추면 **고치는 동안 트윈이 눈을 감는다.** * 효과는 다음 보고에 다시 이을 수 있고 관측은 다시 오지 않는다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { OP_EVENT } from '@operato/ops-contract' import type { CommandStore, StoredCommand } from '../server/service/actuation/command-dispatcher.js' import { orderObservationsIn, settleEffects } from '../server/service/actuation/settle-effects.js' const DOMAIN = 'd-1' const AT = '2026-09-02T09:00:00.000Z' /** * 실측한 봉투 모양 그대로 — 훅과 라이브가 같은 것을 낸다. * * `data` 를 따로 뺀다. 처음에 `...over` 를 뒤에 두었더니 병합한 `data` 를 통째로 덮어써서 `orderId` * 가 사라졌고, **시험이 이을 것이 없는 봉투로 판정을 부르고 있었다.** */ const orderEnv = ({ data, ...over }: Record = {}) => ({ eventId: `e-${Math.random()}`, eventType: OP_EVENT.order, eventTime: AT, tenantId: DOMAIN, ...over, data: { orderId: 'WO-20260902-005', kind: 'workorder', status: 'released', ...(data ?? {}) } }) const otherEnv = () => ({ eventId: 'x', eventType: OP_EVENT.equipment, eventTime: AT, tenantId: DOMAIN, data: {} }) function makeStore(seed: Partial[] = []) { const rows = seed.map(c => ({ id: 'cmd-1', instanceId: 'twin-a', type: 'production.order', state: 'acked', ...c }) as any) const written: any[] = [] const store: CommandStore & { written: any[]; queries: string[][] } = { written, queries: [], load: async () => null, save: async () => {}, create: async (_d, c) => c, byDispatchRef: async (_d, refs) => { store.queries.push([...refs]) return new Map(rows.filter(r => r.dispatchRef && refs.includes(r.dispatchRef)).map(r => [r.dispatchRef, r])) }, recordEffects: async (_d, updates) => void written.push(...updates) } return store } /* ── 봉투에서 골라낸다 ──────────────────────────────────────────────────────── */ test('★ 오더 보고만 골라낸다 — 다른 어휘를 오더로 읽지 않는다', () => { const out = orderObservationsIn([orderEnv(), otherEnv(), orderEnv({ data: { orderId: 'WO-2' } })]) assert.equal(out.length, 2) assert.deepEqual(out.map(o => o.orderId).sort(), ['WO-2', 'WO-20260902-005']) }) test('★ 시각은 봉투의 것을 쓴다 — 우리가 처리한 시각이 아니다', () => { const out = orderObservationsIn([orderEnv({ eventTime: '2026-08-31T01:02:03.000Z' })]) assert.equal(out[0]!.at, '2026-08-31T01:02:03.000Z') }) test('식별자 없는 보고는 골라내지 않는다 — 이을 수 없는 것을 판정에 넘기지 않는다', () => { assert.deepEqual(orderObservationsIn([orderEnv({ data: { orderId: '' } })]), []) }) test('봉투가 없어도 터지지 않는다 — 유입 경로에서 부른다', () => { assert.deepEqual(orderObservationsIn(undefined as any), []) assert.deepEqual(orderObservationsIn([null, undefined] as any), []) }) /* ── 실제로 적힌다 ─────────────────────────────────────────────────────────── */ test('★ 우리가 낸 지시의 보고가 저장소에 적힌다 — 이것이 폐루프가 닫히는 순간이다', async () => { const store = makeStore([{ id: 'cmd-1', dispatchRef: 'WO-20260902-005' }]) const out = await settleEffects({ domainId: DOMAIN, envelopes: [orderEnv()], store }) assert.equal(out.recorded, 1) assert.deepEqual(store.written, [{ commandId: 'cmd-1', status: 'released', at: AT, seen: 1 }]) }) test('★ 오더 보고가 없는 묶음은 저장소를 건드리지 않는다 — 유입은 초당 수천 건이 지난다', async () => { const store = makeStore([{ dispatchRef: 'WO-20260902-005' }]) const out = await settleEffects({ domainId: DOMAIN, envelopes: [otherEnv(), otherEnv()], store }) assert.deepEqual(store.queries, [], '오더가 없는데 저장소에 물었다') assert.equal(out.recorded, 0) }) test('★ 찾아볼 식별자만 묶어서 한 번 묻는다', async () => { const store = makeStore([{ dispatchRef: 'WO-1' }]) await settleEffects({ domainId: DOMAIN, envelopes: [orderEnv({ data: { orderId: 'WO-1' } }), orderEnv({ data: { orderId: 'WO-1' } }), orderEnv({ data: { orderId: 'WO-2' } })], store }) assert.equal(store.queries.length, 1, '보고마다 질의를 붙였다') assert.deepEqual(store.queries[0]!.sort(), ['WO-1', 'WO-2']) }) test('우리가 내지 않은 오더만 온 묶음은 아무것도 적지 않는다', async () => { const store = makeStore([{ dispatchRef: 'WO-우리것' }]) const out = await settleEffects({ domainId: DOMAIN, envelopes: [orderEnv({ data: { orderId: 'WO-남의것' } })], store }) assert.deepEqual(store.written, []) assert.equal(out.unmatched, 1) }) test('★ 이미 적힌 효과에서 이어 센다 — 저장소가 든 값을 판정에 넘긴다', async () => { const store = makeStore([ { id: 'cmd-1', dispatchRef: 'WO-20260902-005', effect: { status: 'released', at: AT, seen: 2 } } ]) await settleEffects({ domainId: DOMAIN, envelopes: [orderEnv({ data: { status: 'in-progress' } })], store }) assert.equal(store.written[0]!.seen, 3) }) /* ── 관측 유입을 멈추지 않는다 ─────────────────────────────────────────────── */ test('★ 저장소가 터져도 던지지 않는다 — 고치는 동안 트윈이 눈을 감으면 안 된다', async () => { const store = makeStore([{ dispatchRef: 'WO-20260902-005' }]) store.byDispatchRef = async () => { throw new Error('연결이 끊겼다') } const out = await settleEffects({ domainId: DOMAIN, envelopes: [orderEnv()], store }) assert.deepEqual(out, { recorded: 0, unmatched: 0 }) }) test('★ 적다가 터져도 던지지 않는다', async () => { const store = makeStore([{ dispatchRef: 'WO-20260902-005' }]) store.recordEffects = async () => { throw new Error('쓰다 실패했다') } const out = await settleEffects({ domainId: DOMAIN, envelopes: [orderEnv()], store }) assert.deepEqual(out, { recorded: 0, unmatched: 0 }) }) test('★ 저장소에 이을 자리가 없으면 조용히 지나지 않는다 — 영원히 모르는 상태가 된다', async () => { const said: string[] = [] const warn = console.warn console.warn = (...a: unknown[]) => void said.push(a.join(' ')) try { const store = makeStore([{ dispatchRef: 'WO-20260902-005' }]) delete (store as any).byDispatchRef const out = await settleEffects({ domainId: DOMAIN, envelopes: [orderEnv()], store }) assert.equal(out.recorded, 0) assert.ok(said.join('\n').includes('byDispatchRef'), '무엇이 없어서인지 말하지 않았다') } finally { console.warn = warn } })