/* * **호스트가 끝난 오더를 접고, 방송이 「최근 T 완료」를 싣는다** — ADR-0092 3 단계. * * 커널은 스스로 접지 않는다. 호스트가 체크포인트 직전에 부르고, `__orders__` 방송이 창 안 수(계약의 `finishedFlowOrdersWithin`)와 * 접힌 수를 싣는다. 판정이 요구한 시험 둘: 같은 상태로 두 번 만든 방송의 칩 값이 같다 · KPI 의 같은 구간 수와 같다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { createRequire } from 'node:module' import { buildEntityDeltas, ordersTag } from '../shared/entity-delta.ts' import { foldForCheckpoint } from '../server/engine/checkpoint-fold.ts' import { planLiveContinuity } from '../server/engine/warm-start.ts' import { foldKpi } from '../server/engine/kpi-fold.ts' const req = createRequire(import.meta.url) const { WmsKernel } = req('@operato/twin-kernel') const { foldedOrderCount } = req('@operato/ops-contract') const H = 60 * 60 * 1000 const GTIN = 'urn:epc:idpat:sgtin:0614141.012345.*' const BOARD = { identity: { namespaces: ['https://twin.test/order-fold-host'] }, locations: [ { id: 'dock-recv', type: 'dock', capacity: 2000 }, { id: 'bin-1', type: 'storage', capacity: 20000 }, { id: 'staging', type: 'staging', capacity: 2000 }, { id: 'dock-ship', type: 'dock-ship', capacity: 2000 } ], equipment: Array.from({ length: 12 }, (_, i) => ({ id: `fk-${i}`, kind: 'forklift', homeLocation: 'dock-recv' })) } /** 바쁜 창고(도착 240 · 출고 200/h)를 돌리며 한 시간마다 접는다 — 체크포인트처럼. N 이 먼저 걸리는 규모다. */ function busy(hours: number) { const k: any = new WmsKernel('host-fold') const events: any[] = [] k.onEvent((e: any) => events.push(e)) k.loadTwinModel(BOARD) k.scenario.load({ seed: 9, generators: [ { kind: 'inbound-arrival', rate: { distribution: 'poisson', meanPerHour: 240 }, content: { skuMix: [{ gtin: GTIN, weight: 1 }], qtyPerLine: { min: 1, max: 1 } } }, { kind: 'outbound-order', rate: { distribution: 'poisson', meanPerHour: 200 }, content: { skuMix: [{ gtin: GTIN, weight: 1 }], qtyPerLine: { min: 1, max: 1 } } } ] }) k.scenario.start() for (let h = 0; h < hours; h++) { for (let i = 0; i < H / 5000; i++) k.tick(5000) foldForCheckpoint({ mode: 'sim', kernel: k }) } return { k, events } } const ordersAggregate = (state: any) => buildEntityDeltas(state, 'host-fold').find(d => d.tag === ordersTag('host-fold'))!.data as any test('★ 체크포인트 직전의 접기는 그 구동의 커널을 부른다 — 미러는 projector, 시뮬은 kernel', () => { const calls: string[] = [] const fake = (name: string) => ({ foldFinishedOrders: () => (calls.push(name), 3) }) assert.equal(foldForCheckpoint({ mode: 'live', projector: fake('projector'), kernel: fake('kernel') }), 3) assert.equal(foldForCheckpoint({ mode: 'sim', projector: fake('projector'), kernel: fake('kernel') }), 3) assert.deepEqual(calls, ['projector', 'kernel']) assert.equal(foldForCheckpoint({ mode: 'sim', kernel: {} }), 0, '접는 문이 없는 옛 커널은 건드리지 않는다') assert.equal(foldForCheckpoint(undefined), 0) }) test('persistSnapshot 은 상태를 뜨기 전에 접는다 — 접힌 쪽이 그 체크포인트와 같은 때에 적힌다', () => { const src = readFileSync(fileURLToPath(new URL('../server/engine/twin-engine.ts', import.meta.url)), 'utf-8') const body = src.slice(src.indexOf('static async persistSnapshot(')) const fold = body.indexOf('foldForCheckpoint(inst)') const snap = body.indexOf('unwrapState(this.snapshot(domainId, instanceId))') assert.ok(fold > 0 && snap > 0 && fold < snap, '접기가 상태를 뜨는 것보다 먼저여야 한다') }) test('★ 방송은 창 안 수를 싣고, 같은 상태로 두 번 만들어도 칩 값이 같다', () => { const { k } = busy(26) const state = k.getSnapshot() const a = ordersAggregate(state).finished const b = ordersAggregate(state).finished assert.ok(a, '창 안 수가 실린다') assert.deepEqual(a, b) assert.equal(a.windowMs, 24 * H) assert.ok(ordersAggregate(state).folded > 0, '접힌 수도 실린다') const hotDone = state.orders.filter((o: any) => o.terminalAtMs !== undefined).length assert.ok(hotDone <= 1000 && a.count > 1000, `N 에서 멈추지 않는다 — 뜨거운 종결 ${hotDone}, 창 안 ${a.count}`) }) test('★ 창 안 수는 KPI 의 같은 구간 종결 수와 같다 — 같은 판정, 같은 원천', () => { const { k, events } = busy(26) const state = k.getSnapshot() const finished = ordersAggregate(state).finished const kpi = foldKpi( events.map(e => ({ eventType: e.eventType, eventTime: e.eventTime, payload: e })), { fromMs: finished.fromMs, toMs: finished.toMs } ) assert.equal(finished.count, kpi.throughput.orders) const sum = Object.values(finished.byStatus as Record).reduce((s, n) => s + n, 0) assert.equal(sum, finished.count, '상태별 수의 합은 그 수다') }) test('지금을 모르는 상태에는 창 안 수를 싣지 않는다 — 0 으로 메우지 않는다', () => { const agg = ordersAggregate({ orders: [], nowTime: undefined }) assert.equal(agg.finished, undefined) }) test('이어가기 씨앗이 접힌 오더를 나른다 — 원천을 다시 읽는 미러가 세는 수와 경계를 잃지 않게', () => { const { k } = busy(26) const plan = planLiveContinuity({ revision: 1, state: k.getSnapshot() }) assert.equal(plan.foldedOrders, true) assert.equal(foldedOrderCount((plan.seed as any).foldedOrders), foldedOrderCount(k.getSnapshot().foldedOrders)) })