/* * ★ 웜스타트 이음매 검증 — 판정이 만든 씨앗이 **실제 커널에 심기는지**. * * ── 왜 따로 있나 ──────────────────────────────────────────────────────────── * `warm-start.test.ts` 는 순수 판정만 본다("무엇을 씨앗에 담기로 했나"). 그것만으로는 부족했다: * 재기동 후 주문이 사라진 결함은 판정과 커널 **사이**에 있었기 때문이다. 판정은 오더를 버렸고, * 커널은 받을 준비가 되어 있었다. 양쪽 테스트가 각각 초록이어도 이음매는 끊겨 있었다. * * 특히 이 테스트가 겨누는 것은 **`snap.orders` 경로**다. 커널은 오더를 두 가지로 받는다 — * 두 번째 인자(`OrderStatusDelta[]`)와 스냅샷 안(`snap.orders`). 커널 테스트는 두 번째 인자를 * 쓰고, 호스트 웜스타트는 스냅샷 안을 쓴다. 즉 **호스트가 의존하는 쪽이 검증되지 않은 상태**였다. * * ── 무엇을 단언하나 ──────────────────────────────────────────────────────── * 스냅샷의 오더가 남은 데맨드(requested − fulfilled)로 커널에 서고, 이행이 끝난 오더는 서지 않고, * 원값이 없는 오더는 알리지 않고 서지 않는다는 것. 숫자를 지어내지 않는다는 규칙이 이음매 끝까지 지켜지는가. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { WmsKernel } from '@operato/twin-kernel' import { planWarmStart } from '../server/engine/warm-start.ts' import { withLiveAttentions } from '../server/engine/live-attentions.ts' const GTIN = 'urn:epc:idpat:sgtin:0614141.012345.*' /* * 보드 — WMS 출고 흐름(피킹 → 스테이징 → 출하)이 실제로 굴러갈 최소 구성. * `staging` 을 빼고 짜 봤더니 오더가 서기만 하고 진행이 0 이었다. 이음매가 아니라 픽스처 문제였다 — * 이 테스트가 흐름 요건까지 시험하려는 것이 아니므로 커널 테스트와 같은 네 자리를 쓴다. */ const model = () => ({ /* 이 픽스처가 식별자를 만드는 이름공간 — **픽스처마다 다르게** 둔다(같은 접두 아래에서 서로 다른 시험의 번호가 부딪치면 표준의 유일성 요구를 깬다, CBV 2.0 §8.2.4). 커널은 선언 없이 팔레트·문서를 만들지 않는다(0.7.45 에서 날조 프리픽스를 지웠다). */ identity: { namespaces: ['https://twin.test/warm-start-seam'] }, locations: [ { id: 'dock-recv', type: 'dock', capacity: 100 }, { id: 'bin-1', type: 'storage', capacity: 200 }, { id: 'staging', type: 'staging', capacity: 100 }, { id: 'dock-ship', type: 'dock-ship', capacity: 100 } ], equipment: [ { id: 'fk-1', kind: 'forklift', homeLocation: 'dock-recv' }, { id: 'fk-2', kind: 'forklift', homeLocation: 'dock-recv' } ] }) /** 관측 스냅샷 흉내가 아니라, 스냅샷이 실제로 들고 오는 모양(일곱 축 중 필요한 것). */ const observed = (orders: unknown[]) => ({ locations: model().locations.map(n => ({ ...n, occupancy: 0, status: 'idle' })), items: Array.from({ length: 20 }, (_, i) => ({ epc: `urn:epc:id:sgtin:0614141.012345.${i}`, location: 'bin-1', gtin: GTIN, qty: 1 })), equipment: model().equipment.map(m => ({ ...m, location: 'dock-recv', status: 'idle' })), tasks: [], orders }) /** 판정 → 주입. 호스트 `warmStart` 가 하는 일과 같은 순서로 잇는다. */ function seedKernel(state: ReturnType) { const plan = planWarmStart(state, undefined, true) assert.equal(plan.action, 'hydrate', '심기로 판정해야 한다') if (plan.action !== 'hydrate') throw new Error('unreachable') const k = new WmsKernel('t') k.loadTwinModel(model() as any) ;(k as any).hydrateObserved(plan.seed) return { k, plan } } test('스냅샷 안의 오더가 커널에 선다 — 남은 데맨드로', () => { const { k, plan } = seedKernel( observed([{ id: 'SO-1', kind: 'outbound', status: 'in-progress', requested: 20, fulfilled: 5, lines: [{ gtin: GTIN, requested: 20, fulfilled: 5 }] }]) ) assert.equal(plan.orderCount, 1) const o = (k as any).orders.get('SO-1') assert.ok(o, '재기동 후 진행 중 주문이 커널에 있어야 한다 — 사라지던 결함의 회귀 방지') assert.equal(o.requested, 15, '남은 데맨드 15(20−5)로 서야 한다 — 원값이 아니라 남은 양이다') }) test('재고가 있으면 복원된 오더가 실제로 이행된다 — 서 있기만 하면 의미가 없다', () => { const { k } = seedKernel( observed([{ id: 'SO-1', kind: 'outbound', status: 'in-progress', requested: 8, fulfilled: 0, lines: [{ gtin: GTIN, requested: 8, fulfilled: 0 }] }]) ) const fc = (k as any).fork() for (let t = 0; t < 1200; t++) fc.tick(1000) const o = fc.getSnapshot().orders.find((x: any) => x.id === 'SO-1') assert.ok(o, '예측에도 오더가 있어야 한다') assert.ok(o.progress > 0, `복원된 오더가 진행돼야 한다(progress=${o.progress})`) }) test('이미 이행된 오더는 서지 않는다 — 다 끝난 일을 다시 계획하지 않는다', () => { const { k, plan } = seedKernel( observed([{ id: 'SO-DONE', kind: 'outbound', status: 'completed', requested: 10, fulfilled: 10, lines: [{ gtin: GTIN, requested: 10, fulfilled: 10 }] }]) ) assert.equal(plan.orderCount, 1, '판정은 원값이 있으므로 넘긴다') assert.equal((k as any).orders.get('SO-DONE'), undefined, '커널이 남은 양 0 으로 보고 세우지 않는다') }) test('원값이 없는 오더는 커널에 서지 않고, 몇 건인지 남는다', () => { const state = observed([{ id: 'SO-P', kind: 'outbound', status: 'in-progress', progress: 0.5 }]) const plan = planWarmStart(state, undefined, true) assert.equal(plan.action, 'hydrate') if (plan.action !== 'hydrate') return assert.equal(plan.orderCount, 0) assert.equal(plan.ordersWithoutDemand, 1, '뺀 사실이 숫자로 남아야 로그가 말할 수 있다') const k = new WmsKernel('t') k.loadTwinModel(model() as any) ;(k as any).hydrateObserved(plan.seed) assert.equal((k as any).orders.size, 0, 'progress 만으로는 남은 양을 알 수 없으므로 지어내지 않는다') }) test('재고도 함께 살아난다 — 오더를 넣다가 기존 복원을 깨지 않았는지', () => { const { k } = seedKernel(observed([{ id: 'SO-1', requested: 5, fulfilled: 0, kind: 'outbound', status: 'in-progress' }])) const snap = (k as any).getSnapshot() assert.equal(snap.items.length, 20, '재고 20건이 그대로 복원돼야 한다') assert.equal(snap.equipment.length, 2) }) test('라이브 상태의 확인 처리가 신호에 반영된다 — 라이브에서는 전혀 반영되지 않던 것', () => { /* * `withLiveAttentions` 가 확인 목록을 넘기지 않아, 라이브(미러)에서는 사람이 확인해도 신호가 * 계속 활성으로 나갔다. 저널에서 되살린 확인 처리가 여기까지 닿는지 본다. */ const state = { locations: [{ id: 'bin-tight', type: 'storage', capacity: 1, occupancy: 1 }], equipment: [], orders: [], tasks: [], acked: [] as string[] } const before = withLiveAttentions(state).attentions ?? [] const signal = before[0] assert.ok(signal, '픽스처 전제 — 병목 신호가 서야 한다') /* 확인 전에는 `state` 를 채우지 않는다(확인된 것만 표시한다) — 없음과 'acknowledged' 를 가른다. */ assert.notEqual(signal.state, 'acknowledged', '확인하지 않았는데 확인된 것으로 나오면 안 된다') const after = withLiveAttentions({ ...state, acked: [signal.id] }).attentions ?? [] assert.equal(after.find((a: any) => a.id === signal.id)?.state, 'acknowledged', '확인 처리가 라이브 신호에 반영돼야 한다') }) /* * ★ Folded orders survive a restart (ADR-0092). Their counts are the only record of the finished orders * taken out of the hot list, and the seed used to leave them out — a restart after a fold lost them all. */ test('the folded side comes back with the twin — nothing re-sends those counts', () => { const foldedOrders = { counts: { outbound: { shipped: 40, cancelled: 2 }, inbound: { completed: 55 } }, boundaryMs: 1_700_000_000_000 } const { k, plan } = seedKernel({ ...observed([]), foldedOrders } as any) assert.equal(plan.foldedOrderCount, 97) assert.deepEqual((k as any).getSnapshot().foldedOrders?.counts, foldedOrders.counts) }) test('a value without counts is not a folded side, and a twin that folded nothing carries none', () => { const junk = planWarmStart({ ...observed([]), foldedOrders: { boundaryMs: 1 } } as any, undefined, true) assert.equal(junk.action === 'hydrate' && 'foldedOrders' in junk.seed, false) const none = planWarmStart(observed([]), undefined, true) assert.equal(none.action === 'hydrate' && none.foldedOrderCount, 0) })