/* * 공정 목록 — **무엇을 돌리고, 그 시간이 어디서 오나.** * * ── 왜 이 목록이 필요한가 (2026-08-18) ───────────────────────────────────── * 「측정이 없을 때의 표준 시간」을 현장이 선언하려면 먼저 무엇에 선언할지 보여야 한다. 그런데 실측으로 * 운영 중인 트윈 35개 중 **창고·야드는 공정 명세 행이 0개**였다 — 모델만 보면 「공정이 없다」로 읽힌다. * 실제로는 `putaway`·`spot` 을 돌리고 있고, 그 사실은 **커널만 안다**(`specCoverage`). * * 그래서 이 모듈이 지키는 것: * ① 목록을 지어내지 않는다 — 원천 행·커널 보고·우리 선언, 세 곳에서만 모은다. * ② 돌지 않은 트윈에서 「공정이 없다」고 말하지 않는다 — `basis` 가 비는 것으로 모름을 드러낸다. * ③ 선언해 둔 공정이 목록에서 사라지지 않는다(그러면 철회할 자리가 없어진다). */ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { operationBasis, knownOperationKinds } from '../server/engine/operation-basis.ts' /** 커널 흉내 — 자기보고만 낸다(이 모듈이 커널에서 읽는 것은 그것뿐이다). */ const kernelReporting = (ops: { kind: string; duration: 'measured' | 'declared' | 'default' }[]) => ({ specCoverage: () => ({ operations: ops.map(o => ({ ...o, parameters: [] })), measuredDurations: ops.filter(o => o.duration === 'measured').length, declaredDurations: ops.filter(o => o.duration === 'declared').length, defaultDurations: ops.filter(o => o.duration === 'default').length }) }) test('행이 없는 트윈에서도 돌린 공정이 나온다 — 창고·야드가 바로 그 경우다', () => { const rows = operationBasis({ locations: [], equipment: [] }, kernelReporting([{ kind: 'putaway', duration: 'default' }])) assert.deepEqual(rows, [{ kind: 'putaway', basis: 'default' }]) }) test('원천 명세 행은 이름과 시간을 함께 든다 — 우리 선언이 그것을 이긴다', () => { const model = { operations: [{ key: 'weld', label: 'Welding', intent: 'process', duration: 'PT90S' }], localDurations: { weld: 'PT60S' } } const rows = operationBasis(model, kernelReporting([{ kind: 'weld', duration: 'declared' }])) assert.deepEqual(rows, [{ kind: 'weld', label: 'Welding', referenceDuration: 'PT90S', basis: 'declared', localDuration: 'PT60S' }]) }) test('정지 트윈은 basis 가 빈다 — 「공정이 없다」가 아니라 「모른다」다', () => { const rows = operationBasis({ operations: [{ key: 'pick', label: 'Pick' }] }, undefined) assert.deepEqual(rows, [{ kind: 'pick', label: 'Pick' }]) assert.equal(rows[0].basis, undefined) }) test('선언해 둔 공정은 커널이 아직 돌리지 않아도 목록에 남는다 — 철회할 자리가 사라지지 않게', () => { const rows = operationBasis({ localDurations: { spot: 'PT300S' } }, kernelReporting([])) assert.deepEqual(rows, [{ kind: 'spot', localDuration: 'PT300S' }]) }) test('아무 근거도 없으면 목록을 지어내지 않는다 — 빈 배열이다', () => { assert.deepEqual(operationBasis({}, undefined), []) }) test('확인 목록은 하나도 없을 때 undefined 다 — 빈 배열은 「아무것도 안 된다」로 읽힌다', () => { assert.equal(knownOperationKinds({}, undefined), undefined) assert.deepEqual(knownOperationKinds({}, kernelReporting([{ kind: 'putaway', duration: 'default' }])), ['putaway']) }) test('커널이 보고하지 못해도 터지지 않는다 — 옛 커널에서도 목록은 나온다', () => { const rows = operationBasis({ operations: [{ key: 'pick', label: 'Pick' }] }, { tick: () => {} }) assert.deepEqual(rows, [{ kind: 'pick', label: 'Pick' }]) }) /* * ── 재기동 직후의 빈칸 (2026-08-18 실측) ──────────────────────────────────── * 커널의 자기보고는 **작업이 새로 생길 때** 채워진다. 그래서 이력이 풍부한 창고 트윈(pick·putaway 를 * 이미 배운 트윈)도 재기동 직후에는 목록이 비었다 — 값을 채울 곳을 보여야 하는 화면이 그때 아무 말도 * 못 한다. 이력에서 배운 종류를 넷째 근거로 넣어 그 빈칸을 메운다. */ test('이력에서 배운 종류가 목록에 나온다 — 재기동 직후에도 빈칸이 아니다', () => { const rows = operationBasis({}, kernelReporting([]), ['pick', 'putaway']) assert.deepEqual(rows, [{ kind: 'pick', basis: 'measured' }, { kind: 'putaway', basis: 'measured' }]) }) test('커널이 실제로 돌린 사실이 이력 목록을 덮지 않는다 — 더 강한 사실이 이긴다', () => { const rows = operationBasis({}, kernelReporting([{ kind: 'putaway', duration: 'default' }]), ['putaway']) assert.deepEqual(rows, [{ kind: 'putaway', basis: 'default' }], '지금 상수로 돌고 있으면 그렇게 말한다') }) test('이력 목록은 선언 대조에도 쓰인다 — 오타를 그때부터 잡는다', () => { assert.deepEqual(knownOperationKinds({}, undefined, ['pick']), ['pick']) }) /* * ── 도는 시뮬레이션은 「저장했다」가 아니다 (2026-08-18 실측) ──────────────── * 시뮬 기동 경로가 `mode` 를 비워 두어(「미지정=sim」 규약에 기대) `modeOf()` 가 도는 트윈에 undefined 를 * 답했다. 그래서 선언·철회의 답이 「저장했다」로 나갔다 — 실제로는 도는 커널이 옛 수를 쓰고 재기동 때 * 받는데, 사용자는 지금 화면의 수가 새 값으로 계산된 줄로 읽는다. 사실을 적는 쪽으로 고쳤다. */ test('시뮬 기동이 모드를 적는다 — 「돌고 있나」를 묻는 쪽이 답을 받아야 한다', () => { const src = readFileSync(fileURLToPath(new URL('../server/engine/twin-engine.ts', import.meta.url)), 'utf-8') assert.match(src, /mode: 'sim',\s*\n\s*runtime/, '시뮬 런타임도 모드를 든다') /* 답이 갈리는 자리 — 도는 트윈이면 「재기동 때 받는다」로 말해야 한다. */ const mut = readFileSync(fileURLToPath(new URL('../server/service/twin-model/twin-model-mutation.ts', import.meta.url)), 'utf-8') assert.match(mut, /running \? 'on-restart' : 'stored'/) }) /* * ── 공정 모수도 목록에 실린다 (2026-08-19) ────────────────────────────────── * 수율·셋업은 시간과 다른 표에 담긴다(`localParams`). 목록이 그것을 함께 들지 않으면 선언해 둔 값이 * 화면에서 사라지고, 그러면 철회할 자리도 없어진다. 원천이 말한 모수도 함께 들어 견줄 수 있게 한다. */ test('선언한 모수와 원천 모수를 함께 든다 — 무엇을 고쳤나가 보인다', () => { const model = { operations: [{ key: 'weld', label: 'Welding', intent: 'process', parameters: [{ id: 'yield', value: '0.7' }] }], localParams: { weld: { yield: '0.92', setupDuration: 'PT30S' } } } const rows = operationBasis(model, kernelReporting([{ kind: 'weld', duration: 'declared' }])) assert.deepEqual(rows, [ { kind: 'weld', label: 'Welding', referenceParams: { yield: '0.7' }, basis: 'declared', localParams: { yield: '0.92', setupDuration: 'PT30S' } } ]) }) test('모수만 선언한 공정도 목록에 남는다 — 커널이 아직 돌리지 않았어도', () => { const rows = operationBasis({ localParams: { inspect: { yield: '0.99' } } }, undefined) assert.deepEqual(rows, [{ kind: 'inspect', localParams: { yield: '0.99' } }]) })