/* * ★ **조치가 어떻게 됐나** — 승인한 사람에게 돌려줄 사실, 그리고 그 안의 판정 하나. * * ── 이 판정이 왜 커널에 있나 (2026-09-04) ────────────────────────────────── * 「저쪽에서 사람이 할 일이 남았나」의 답이 한 필드에 없다. `dispatchNote` 만 보면 틀린다 — * 그것은 **넘길 때 저쪽이 한 말**이고, 사람이 나중에 지시서를 골라도 그대로 남는다. 저쪽에 다시 * 물을 일이 없어 갱신되지 않는다(인티그레이션 레인이 MES 코드를 읽어 찾았다). * * 그래서 답은 「말이 있고 효과가 아직 없을 때」다. 그 겹침을 화면마다 하게 두면 목록·상세·알림이 * 각자 겹치고 한 곳이 빠진 날 그 화면만 다른 말을 한다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { actuationOutcomeOf } from '../server/service/actuation/actuation-outcome.js' import type { StoredCommand } from '../server/service/actuation/command-dispatcher.js' const at = (over: Partial = {}): StoredCommand => ({ id: 'cmd-1', instanceId: 'mes-line-a', kind: 'production.release', proposedBy: 'rule', origin: 'rule', state: 'acked', ...over }) as StoredCommand /* ── 저쪽에 할 일이 남았나 ───────────────────────────────────────────────── */ test('★ 말이 있고 효과가 아직 없으면 저쪽에 할 일이 남았다', () => { const out = actuationOutcomeOf(at({ dispatchNote: '지시서를 골라야 합니다' }), false) assert.equal(out.pendingAtSource, true) assert.equal(out.note, '지시서를 골라야 합니다') }) test('★ 효과가 오면 할 일이 끝났다 — 말은 그대로 남아 있어도', () => { /* * 이것이 이 판정의 존재 이유다. `dispatchNote` 는 갱신되지 않으므로 그것만 보면 사람이 지시서를 * 고른 뒤에도 「골라야 합니다」가 계속 보인다. 이미 처리된 일을 계속 시키는 화면이 된다. */ const out = actuationOutcomeOf( at({ dispatchNote: '지시서를 골라야 합니다', effect: { status: 'released', at: '2026-09-04T01:00:00.000Z', seen: 1 } }), false ) assert.equal(out.pendingAtSource, false, '효과가 왔는데 아직 할 일이 남았다고 답했다') assert.equal(out.note, '지시서를 골라야 합니다', '기록은 지우지 않는다 — 왜 늦었는지가 사라진다') assert.equal(out.effectStatus, 'released') }) test('★ 다음에 할 일이 갈라져 오면 그대로 낸다 — 화면이 문장을 자르지 않게', () => { const out = actuationOutcomeOf( at({ dispatchNote: '접수됐지만 지시서가 붙지 않았습니다', dispatchNoteNext: 'MES 에서 지시서를 골라야 현장에 나갑니다' }), false ) assert.equal(out.note, '접수됐지만 지시서가 붙지 않았습니다') assert.equal(out.noteNext, 'MES 에서 지시서를 골라야 현장에 나갑니다') assert.equal(out.pendingAtSource, true) }) test('★ 어댑터가 아직 안 갈랐으면 비운다 — 없는 줄을 만들지 않는다', () => { /* * 그때 `note` 는 두 문장이 붙은 한 덩이이고, 화면은 통째로 그려야 한다. 커널이 그것을 잘라 * 두 칸에 넣으면 **파싱 규칙이 커널에 생기고** 번역되면 깨진다. */ const out = actuationOutcomeOf(at({ dispatchNote: '원인과 다음 할 일이 붙은 한 문장입니다' }), false) assert.equal(out.note, '원인과 다음 할 일이 붙은 한 문장입니다') assert.equal('noteNext' in out, false) }) test('★ 말이 없으면 할 일이 없다', () => { assert.equal(actuationOutcomeOf(at(), false).pendingAtSource, false) }) test('★ 아직 넘겨 보지 않았으면 판정하지 않는다 — 「없다」와 「모른다」는 다르다', () => { /* * `approved` 는 승인만 된 상태다. 저쪽에 물어본 적이 없으니 할 일이 남았는지 알 방법이 없다. * `false` 로 접으면 화면이 「할 일 없음」으로 그리고, 그것은 넘긴 뒤와 구별되지 않는다. */ for (const state of ['proposed', 'approved', 'rejected']) { const out = actuationOutcomeOf(at({ state, dispatchNote: '지시서를 골라야 합니다' }), true) assert.equal(out.pendingAtSource, undefined, `${state} 인데 판정했다`) } }) test('넘긴 상태 셋에서는 판정한다', () => { for (const state of ['dispatching', 'acked', 'failed']) { const out = actuationOutcomeOf(at({ state }), false) assert.equal(out.pendingAtSource, false, `${state} 인데 판정을 비웠다`) } }) /* ── 저쪽 낱말을 해석하지 않는다 ────────────────────────────────────────── */ test('★ 효과 상태를 그대로 낸다 — 어느 값이 「현장에 나갔다」인지는 도메인이 안다', () => { for (const status of ['released', 'closed', 'WAITING', '작업중']) { const out = actuationOutcomeOf(at({ effect: { status, at: '2026-09-04T01:00:00.000Z', seen: 2 } }), false) assert.equal(out.effectStatus, status) assert.equal(out.pendingAtSource, false, `${status} 를 「효과 없음」으로 읽었다`) } }) /* ── 되돌릴 수 있나 ─────────────────────────────────────────────────────── */ test('★ 되돌릴 수 있으면 그렇게 낸다 — 화면은 참일 때만 한 줄 낸다', () => { assert.equal(actuationOutcomeOf(at({ reversible: true }), false).reversible, true) }) test('★ 모르면 비운다 — 「되돌릴 수 없습니다」를 커널이 단언하지 않는다', () => { /* * 늘 참인 경고는 사흘이면 안 읽힌다(UX 레인 판단). 그러니 흔한 쪽을 단언하지 않고, 드문 쪽만 * 값으로 낸다. 커널이 `false` 를 채워 보내면 화면이 그것을 그릴 유혹이 생긴다. */ assert.equal(actuationOutcomeOf(at(), false).reversible, undefined) }) test('돌이킬 수 없다고 선언된 것은 그대로 낸다', () => { assert.equal(actuationOutcomeOf(at({ reversible: false }), false).reversible, false) }) /* ── 나머지 사실 ────────────────────────────────────────────────────────── */ test('★ 나간 것은 저쪽 이름을 든다 — 없으면 무엇을 되돌릴지 지목할 수 없다', () => { const out = actuationOutcomeOf(at({ dispatchRef: 'WO-20260903-001' }), false) assert.equal(out.dispatchRef, 'WO-20260903-001') }) test('★ 못 나간 것은 사유를 든다', () => { const out = actuationOutcomeOf(at({ state: 'failed', error: 'MES 가 받지 않았습니다 — unknown material ITEM-1' }), true) assert.equal(out.state, 'failed') assert.match(out.error!, /unknown material/) assert.equal(out.dispatchable, true, '다시 넘길 수 있는 상태다') }) test('★ 종착이면 다시 넘기지 않는다', () => { assert.equal(actuationOutcomeOf(at({ state: 'acked' }), false).dispatchable, false) }) test('없는 값으로 키를 만들지 않는다 — 화면이 빈 줄을 그리지 않게', () => { const out = actuationOutcomeOf(at(), false) for (const k of ['dispatchRef', 'error', 'note', 'effectStatus', 'effectAt', 'reversible']) { assert.equal(k in out, false, `${k} 가 빈 값으로 들어 있다`) } assert.equal(out.id, 'cmd-1') assert.equal(out.state, 'acked') })