/* * ★ 웜스타트 판정 검증 — 재기동하는 커널에 직전 관측 상태를 심을지. * * 이 판정은 **양쪽으로 오류 없이 틀린 값을 낸다**: 심어야 할 때 안 심으면 있는 재고가 0 으로 보이고 * (2026-07-31 hatiolab-wms — 저널에 입고 540·출고 94 인데 재고 화면이 비어 있었다), * 벤치에 심으면 용량 측정이 오염된다. 둘 다 화면에는 아무 이상이 없어 보인다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { planLiveContinuity, planWarmStart, unwrapState } from '../server/engine/warm-start.ts' const STATE = { locations: [{ id: 'dock-recv' }, { id: 'rack-1' }], items: [{ epc: 'a' }, { epc: 'b' }, { epc: 'c' }], equipment: [{ id: 'fork-1' }], orders: [{ id: 'SO-1', kind: 'outbound', status: 'created', progress: 0.5 }], tasks: [{ id: 't1' }] } test('상태가 있으면 심는다 — 재고·노드·무버', () => { const plan = planWarmStart(STATE, undefined, true) assert.equal(plan.action, 'hydrate') if (plan.action !== 'hydrate') return assert.equal(plan.itemCount, 3) assert.equal(plan.equipmentCount, 1) assert.deepEqual(plan.seed.locations, STATE.locations) }) test('원값 없는 오더는 심지 않는다 — 남은 수량을 알 수 없어 지어내야 하기 때문', () => { /* STATE 의 오더는 progress 만 들고 있다 — 커널 판정(requested !== undefined)과 같은 기준으로 뺀다. */ const plan = planWarmStart(STATE, undefined, true) assert.equal(plan.action, 'hydrate') if (plan.action !== 'hydrate') return assert.equal(plan.seed.orders, undefined) assert.equal(plan.orderCount, 0) assert.equal(plan.ordersWithoutDemand, 1) }) test('뺀 오더의 수를 센다 — "주문이 없다" 와 "주문을 못 심었다" 가 같아지면 안 된다', () => { /* * 이 숫자가 로그의 근거다. 세지 않고 알리지 않고 빼면, 재기동 후 빈 주문 목록을 보고 * 사용자는 "원래 없었다" 로 읽는다. 실제로 그 화면이 오래 남아 있었다. */ const mixed = { ...STATE, orders: [ { id: 'SO-1', kind: 'outbound', status: 'created', progress: 0.5 }, { id: 'SO-2', kind: 'outbound', status: 'created', requested: 100, fulfilled: 40 }, { id: 'SO-3', kind: 'outbound', status: 'created', requested: 30 } ] } const plan = planWarmStart(mixed, undefined, true) assert.equal(plan.action, 'hydrate') if (plan.action !== 'hydrate') return assert.equal(plan.orderCount, 2) assert.equal(plan.ordersWithoutDemand, 1) assert.deepEqual((plan.seed.orders as any[]).map(o => o.id), ['SO-2', 'SO-3']) }) test('진행 중 주문을 심는다 — 재기동하면 주문이 사라졌던 결함의 회귀 방지', () => { /* * 예전에는 씨앗이 자리·물품·설비 셋만 넘겨, 재고는 맞는데 주문 목록이 비었다. 원값이 있는 * 오더는 반드시 씨앗에 실려야 한다 — 이 단언이 그 결함으로 되돌아가는 것을 막는다. */ const plan = planWarmStart( { ...STATE, orders: [{ id: 'SO-9', kind: 'outbound', status: 'created', requested: 50, fulfilled: 10, lines: [{ gtin: 'g1', requested: 50 }] }] }, undefined, true ) assert.equal(plan.action, 'hydrate') if (plan.action !== 'hydrate') return assert.equal(plan.orderCount, 1) assert.equal(plan.ordersWithoutDemand, 0) assert.equal((plan.seed.orders as any[])[0].requested, 50) assert.equal((plan.seed.orders as any[])[0].fulfilled, 10) }) test('작업·사람·자산도 함께 심는다 — 커널이 심을 수 있는 축을 호스트가 버리지 않는다', () => { const plan = planWarmStart( { ...STATE, persons: [{ id: 'p1' }, { id: 'p2' }], assets: [{ id: 'pallet-1' }] }, undefined, true ) assert.equal(plan.action, 'hydrate') if (plan.action !== 'hydrate') return assert.equal(plan.taskCount, 1) assert.equal(plan.personCount, 2) assert.equal(plan.assetCount, 1) assert.deepEqual(plan.seed.tasks, STATE.tasks) assert.equal((plan.seed.persons as any[]).length, 2) }) test('없는 축은 씨앗에 키를 만들지 않는다 — 빈 배열로 커널 판정을 흐리지 않는다', () => { const plan = planWarmStart({ locations: [{ id: 'n1' }], items: [], equipment: [] }, undefined, true) assert.equal(plan.action, 'hydrate') if (plan.action !== 'hydrate') return assert.deepEqual(Object.keys(plan.seed).sort(), ['equipment', 'items', 'locations']) }) test('오더만 있어도 심는다 — 구조가 모델에서 오는 경우가 있다', () => { const plan = planWarmStart({ orders: [{ id: 'SO-1', requested: 10 }] } as any, undefined, true) assert.equal(plan.action, 'hydrate') if (plan.action !== 'hydrate') return assert.equal(plan.orderCount, 1) }) test('벤치 트윈에는 심지 않는다 — 새 시작에서 용량을 재는 것이 목적이다', () => { const plan = planWarmStart(STATE, 'bench', true) assert.deepEqual(plan, { action: 'skip', reason: 'bench' }) }) test('벤치 판정은 상태 유무보다 먼저다 — 상태가 없어도 이유는 bench 여야 한다', () => { /* 이유가 뒤바뀌면 로그가 "심을 게 없었다" 로 남아, 벤치를 의도적으로 비웠다는 사실이 사라진다. */ assert.deepEqual(planWarmStart(null, 'bench', true), { action: 'skip', reason: 'bench' }) }) test('심을 상태가 없으면 알리지 않고 넘어간다 — 처음 만든 트윈의 정상 경로다', () => { assert.deepEqual(planWarmStart(null, undefined, true), { action: 'skip', reason: 'no-state' }) assert.deepEqual(planWarmStart(undefined, undefined, true), { action: 'skip', reason: 'no-state' }) assert.deepEqual(planWarmStart({ locations: [], items: [], equipment: [] }, undefined, true), { action: 'skip', reason: 'no-state' }) }) test('노드만 있어도 심는다 — 구조 배치가 복원되면 화면이 살아난다', () => { const plan = planWarmStart({ locations: [{ id: 'n1' }], items: [], equipment: [] }, undefined, true) assert.equal(plan.action, 'hydrate') if (plan.action !== 'hydrate') return assert.equal(plan.itemCount, 0) }) test('커널이 주입을 지원하지 않으면 알린다 — 알리지 않고 빈 채로 뜨면 안 된다', () => { assert.deepEqual(planWarmStart(STATE, undefined, false), { action: 'skip', reason: 'unsupported' }) }) test('심을 게 없으면 미지원이어도 경고할 일이 아니다', () => { /* 미지원 경고가 매 기동마다 뜨면 진짜 경고가 묻힌다. */ assert.deepEqual(planWarmStart(null, undefined, false), { action: 'skip', reason: 'no-state' }) }) test('빠진 배열은 빈 배열로 다뤄지되 없는 것을 지어내지 않는다', () => { const plan = planWarmStart({ items: [{ epc: 'a' }] } as any, undefined, true) assert.equal(plan.action, 'hydrate') if (plan.action !== 'hydrate') return assert.deepEqual(plan.seed.locations, []) assert.deepEqual(plan.seed.equipment, []) assert.equal(plan.itemCount, 1) }) test('확인해 둔 주목 신호를 이어받는다 — 재기동하면 다시 빨개지던 것', () => { /* * 확인 처리는 상태에서 파생되지 않는 유일한 축이다. 씨앗에서 빠지면 재기동 후 확인해 둔 신호가 * 전부 다시 활성으로 뜬다 — 오류는 나지 않고, 사용자는 "또 터졌다" 로 읽는다. */ const plan = planWarmStart({ ...STATE, acked: ['bottleneck:bin-1', 'breakdown:fk-1'] } as any, undefined, true) assert.equal(plan.action, 'hydrate') if (plan.action !== 'hydrate') return assert.deepEqual(plan.seed.acked, ['bottleneck:bin-1', 'breakdown:fk-1']) assert.equal(plan.ackedCount, 2) }) test('확인 처리만 있어도 복원한다 — 신호를 확인해 둔 사실 자체가 이어받을 가치가 있다', () => { const plan = planWarmStart({ acked: ['bottleneck:bin-1'] } as any, undefined, true) assert.equal(plan.action, 'hydrate') if (plan.action !== 'hydrate') return assert.equal(plan.ackedCount, 1) }) test('확인 처리가 문자열 목록이 아니면 없는 것으로 본다 — 지어내지 않는다', () => { const plan = planWarmStart({ ...STATE, acked: 'nope' } as any, undefined, true) assert.equal(plan.action, 'hydrate') if (plan.action !== 'hydrate') return assert.equal(plan.ackedCount, 0) assert.equal(plan.seed.acked, undefined) }) /* * ── 겹포장 회귀 방지 (2026-08-05) ────────────────────────────────────────── * 체크포인트가 `{revision, state:{revision, state:{실제}}}` 로 저장돼, 꺼낸 값에 축이 하나도 없었다. * 판정은 "심을 것이 없다"(no-state)로 답하고 **알리지 않고** 넘어갔다 — 저널에 수천 건이 있고 캐시도 * 최신인데 트윈이 빈 채로 떴다. 오류도 로그도 없어서 이 경로가 처음부터 죽어 있었다. */ test('봉투로 감싸여 와도 벗겨서 심는다 — 체크포인트가 겹포장돼 복원이 죽었던 자리', () => { const enveloped = { revision: 2684, state: { revision: 2684, state: STATE } } const plan = planWarmStart(enveloped as any, undefined, true) assert.equal(plan.action, 'hydrate', '봉투를 받으면 심을 것이 없다고 답하면 안 된다') if (plan.action !== 'hydrate') return assert.equal(plan.itemCount, 3) assert.deepEqual(plan.seed.locations, STATE.locations) }) test('한 겹 봉투도 벗긴다 — 저장 방식이 바뀌는 동안 두 모양이 함께 남는다', () => { const plan = planWarmStart({ revision: 1, state: STATE } as any, undefined, true) assert.equal(plan.action, 'hydrate') if (plan.action !== 'hydrate') return assert.equal(plan.itemCount, 3) }) test('벗길 것이 없으면 그대로 쓴다 — 상태를 봉투로 착각하지 않는다', () => { const plan = planWarmStart(STATE, undefined, true) assert.equal(plan.action, 'hydrate') if (plan.action !== 'hydrate') return assert.equal(plan.itemCount, 3) }) test('빈 봉투는 여전히 no-state — 벗겼는데도 아무 축이 없으면 심을 것이 없다', () => { assert.deepEqual(planWarmStart({ revision: 5, state: { revision: 5 } } as any, undefined, true), { action: 'skip', reason: 'no-state' }) }) test('unwrapState 는 상태를 먼저 알아본다 — state 라는 이름의 축이 있어도 벗기지 않는다', () => { const st = { items: [{ epc: 'a' }], state: 'whatever' } assert.equal(unwrapState(st as any), st, '축이 있으면 그것이 상태다') }) /* * ── 「언제부터인가」도 씨앗에 실린다 (2026-08-18) ───────────────────────────── * 확인과 같은 성질이다 — 상태에서 파생되지 않으므로 넘기지 않으면 사라진다. 사라지면 세 시간째 * 지속된 조건이 재기동 뒤 「0초째」가 되고, 화면은 사람을 잘못된 순서로 움직인다. */ test('주목 신호 시작 시각이 씨앗에 실린다 — 재기동 뒤 지속 시간이 거짓이 되지 않게', () => { const since = [{ id: 'bottleneck:bin-1', since: '2026-08-18T01:00:00.000Z' }] const plan = planWarmStart({ ...STATE, attentionSince: since } as any, undefined, true) assert.equal(plan.action, 'hydrate') if (plan.action !== 'hydrate') return assert.deepEqual(plan.seed.attentionSince, since) assert.equal(plan.attentionSinceCount, 1) }) test('시작 시각만 있어도 복원한다 — 그 사실 자체가 이어받을 가치가 있다', () => { const plan = planWarmStart({ attentionSince: [{ id: 'a', since: '2026-08-18T01:00:00.000Z' }] } as any, undefined, true) assert.equal(plan.action, 'hydrate') if (plan.action !== 'hydrate') return assert.equal(plan.attentionSinceCount, 1) }) test('모양이 아닌 시작 시각은 없는 것으로 본다 — 지어낸 시각을 심지 않는다', () => { const plan = planWarmStart({ ...STATE, attentionSince: ['bottleneck:bin-1', { id: 'ok', since: 5 }, { since: '2026-08-18T01:00:00.000Z' }] } as any, undefined, true) assert.equal(plan.action, 'hydrate') if (plan.action !== 'hydrate') return assert.equal(plan.attentionSinceCount, 0) assert.equal(plan.seed.attentionSince, undefined) }) /* * ── 미러 재기동의 연속성 (2026-08-18) ──────────────────────────────────────── * 미러는 오랫동안 아무것도 이어받지 않았다 — 진실이 원천에 있으니 옳았지만, 원천이 **애초에 다시 말해 * 주지 않는 축**까지 함께 버렸다. 그래서 재기동하면 오류 없이 값이 작아졌다. */ test('미러는 되풀어 주지 않는 축만 이어받는다 — 관측 축은 원천에서 온다', () => { const plan = planLiveContinuity({ ...STATE, acked: ['bottleneck:bin-1'], attentionSince: [{ id: 'bottleneck:bin-1', since: '2026-08-18T01:00:00.000Z' }], energy: { points: [{ id: 'm-1', kWhAtWindowStart: 1000, samplesInWindow: 3 }], closed: [], closedTotal: 0 } }) assert.equal(plan.ackedCount, 1) assert.equal(plan.attentionSinceCount, 1) assert.equal(plan.energy, true) /* 관측 축은 **일부러** 넣지 않는다 — 심으면 부팅 전에 떠난 물건이 되살아난다. */ assert.equal((plan.seed as any).locations, undefined) assert.equal((plan.seed as any).items, undefined) assert.equal((plan.seed as any).equipment, undefined) assert.equal((plan.seed as any).orders, undefined) }) test('에너지가 없는 트윈은 에너지를 이어받지 않는다 — 없는 것을 있다고 하지 않는다', () => { const plan = planLiveContinuity({ ...STATE, acked: ['x'] }) assert.equal(plan.energy, false) assert.equal((plan.seed as any).energy, undefined) }) test('이어받을 것이 없으면 빈 씨앗이다 — 빈 주입으로 로그만 남기지 않는다', () => { const plan = planLiveContinuity(STATE) assert.deepEqual(plan.seed, {}) assert.equal(plan.ackedCount + plan.attentionSinceCount, 0) assert.equal(plan.energy, false) }) test('겹포장된 체크포인트도 벗겨 읽는다 — 한 번은 반드시 만난다', () => { const plan = planLiveContinuity({ revision: 42, state: { acked: ['a'], energy: { points: [] } } }) assert.equal(plan.ackedCount, 1) assert.equal(plan.energy, true) }) /* 배선이 사라지면 미러는 알리지 않고 예전으로 돌아간다 — 소스 모양으로 못 박는다(엔진은 DB 를 물고 있다). */ test('미러 기동이 연속성 씨앗을 심는다 — 배선이 사라지지 않게', () => { const src = readFileSync(fileURLToPath(new URL('../server/engine/twin-engine.ts', import.meta.url)), 'utf-8') const at = src.indexOf('static startLive(') assert.ok(at > 0) const body = src.slice(at, src.indexOf('\n static ', at + 10)) assert.match(body, /seedLiveContinuity\(domainId, id, kernel\)/, '미러가 연속성을 이어받아야 한다') assert.ok( body.indexOf('seedLiveContinuity') < body.indexOf('delete this.recovered[key]'), '캐시를 지우기 전에 심는다 — 순서가 뒤집히면 늘 빈손이다' ) })