/* * **관측에서 조치를 낸다** — 그 판정. * * ── 무엇이 없었나 (2026-09-01) ─────────────────────────────────────────── * 승인 게이트를 세우고 나니 낼 수 있는 것이 **사람뿐**이었다. 계약은 낸 쪽을 네 가지로 두는데 * 사람 말고는 길이 없었다. * * ── 이 판정에서 무너지는 것 ────────────────────────────────────────────── * 행복한 길은 짧다. 실제로 무너지는 것은 이것들이고, 넷 다 오류가 나지 않는다. * * 같은 조건이 매 주기 다시 올라온다 대기 줄에 같은 지시가 수십 개 * 방금 생긴 조건에 바로 올린다 깜빡인 것에 현장 지시가 나간다 * 한 번에 수백 개 사람이 읽지 않고, 읽지 않으면 게이트는 시늉이다 * 값이 없는데 지시를 만든다 무엇을 지시하는지 모르는 것이 승인 대기에 선다 */ import { test } from 'node:test' import assert from 'node:assert/strict' import { CMD, MES_COMMANDS, type CommandTypeSpec } from '@operato/ops-contract' import { DEFAULT_COOLDOWN_MS, DEFAULT_MAX_PER_RUN, evaluateRules, type ActuationRuleSpec, type AttentionLike } from '../server/service/actuation/rule-evaluate.js' const SPECS: readonly CommandTypeSpec[] = MES_COMMANDS const ORDER = MES_COMMANDS[0]! const NOW = Date.parse('2026-09-01T03:00:00.000Z') const HOUR = 3_600_000 /** 세 시간째 이어진 병목 하나. */ const bottleneck = (over: Partial = {}): AttentionLike => ({ id: 'bottleneck:paint-1', kind: 'bottleneck', severity: 'high', state: 'active', anchor: { locationId: 'paint-1' }, timeStamp: new Date(NOW - 3 * HOUR).toISOString(), params: { ratioPct: 118 }, ...over }) /** 병목이 오래가면 생산 지시를 낸다 — 품목·단위는 규칙이 정하고 수량도 규칙이 정한다. */ const rule = (over: Partial = {}): ActuationRuleSpec => ({ id: 'rule-1', instanceId: 'twin-a', attentionKind: 'bottleneck', minSeverity: 'high', minHeldMs: 2 * HOUR, commandType: ORDER.type, payloadTemplate: { materialDefinitionId: 'urn:epc:idpat:sgtin:9521234.00001.*', quantity: 20, uom: 'EA', note: '{locationId} 가 {ratioPct}% 로 {attentionKind} 다' }, ...over }) const run = (args: Partial[0]> = {}) => evaluateRules({ rules: [rule()], attentions: [bottleneck()], specs: SPECS, openCauses: new Set(), nowMs: NOW, ...args }) /* ── 낸다 ─────────────────────────────────────────────────────────────────── */ test('★ 오래 이어진 조건이 조치로 올라간다 — 이 길이 없어 낼 수 있는 것이 사람뿐이었다', () => { const out = run() assert.equal(out.proposals.length, 1) assert.equal(out.proposals[0]!.commandType, ORDER.type) assert.equal(out.proposals[0]!.attentionId, 'bottleneck:paint-1') assert.deepEqual(out.skips, []) }) test('★ 주의의 값이 지시에 들어간다 — 승인하는 사람이 왜 올라왔는지 읽을 수 있게', () => { const payload = run().proposals[0]!.payload assert.equal(payload.note, 'paint-1 가 118% 로 bottleneck 다') assert.equal(payload.quantity, 20) }) test('★ 값 하나를 그대로 나르는 자리는 수로 남는다 — 수량이 문자열이면 그 뒤 계산이 무너진다', () => { const out = run({ rules: [rule({ payloadTemplate: { ...rule().payloadTemplate, quantity: '{ratioPct}' } })] }) assert.strictEqual(out.proposals[0]!.payload.quantity, 118, '문자열 "118" 이 되었다') }) /* ── 같은 조건을 두 번 올리지 않는다 ─────────────────────────────────────── */ test('★ 이미 올라가 있는 근거에는 다시 올리지 않는다 — 주기마다 같은 지시가 쌓이지 않게', () => { const out = run({ openCauses: new Set(['bottleneck:paint-1']) }) assert.equal(out.proposals.length, 0) assert.deepEqual(out.skips, [], '중복은 이유를 말할 것이 아니다 — 정상이다') }) test('★ 한 주의에 규칙이 둘 붙어도 조치는 하나다 — 같은 조건에 두 지시가 나가면 현장이 두 번 움직인다', () => { const out = run({ rules: [rule({ id: 'rule-1' }), rule({ id: 'rule-2' })] }) assert.equal(out.proposals.length, 1) }) test('★ 근거가 저장소에서 온다 — 프로세스 기억에 두면 재기동이 곧 중복이다', () => { /* * 판정이 순수 함수라 「이미 올라간 것」을 인자로 받는다. 재기동한 프로세스도 저장소에서 같은 집합을 * 읽어 넘기면 같은 답이 나온다. */ const openCauses = new Set(['bottleneck:paint-1']) assert.equal(run({ openCauses }).proposals.length, 0) assert.equal(run({ openCauses: new Set() }).proposals.length, 1, '집합이 비면 다시 올라간다 — 그것이 저장소의 몫이다') }) /* ── 깜빡이는 것에 지시를 내지 않는다 ───────────────────────────────────── */ test('★ 아직 짧게 이어진 조건은 올리지 않는다', () => { const out = run({ attentions: [bottleneck({ timeStamp: new Date(NOW - 10_000).toISOString() })] }) assert.equal(out.proposals.length, 0) }) test('★ 언제부터인지 모르는 주의는 올리지 않고 말한다 — 모름을 「오래됐다」로 읽지 않는다', () => { const out = run({ attentions: [bottleneck({ timeStamp: undefined })] }) assert.equal(out.proposals.length, 0) assert.equal(out.skips.length, 1) assert.match(out.skips[0]!.reason, /언제부터인지 말하지 않는다/) }) test('모양이 아닌 시각도 모름으로 본다 — 지어낸 기간으로 지시를 내지 않는다', () => { const out = run({ attentions: [bottleneck({ timeStamp: '어제' })] }) assert.equal(out.proposals.length, 0) assert.match(out.skips[0]!.reason, /언제부터인지 말하지 않는다/) }) test('기간을 요구하지 않는 규칙은 처음 본 주기에 올린다', () => { const out = run({ rules: [rule({ minHeldMs: undefined })], attentions: [bottleneck({ timeStamp: new Date(NOW - 1000).toISOString() })] }) assert.equal(out.proposals.length, 1) }) /* ── 낸 지 얼마 안 된 근거에는 다시 내지 않는다 ─────────────────────────── */ test('★ 승인해도 곧바로 다시 내지 않는다 — 걸어 보고 실제로 만든 상태다', () => { /* * 결정을 기다리는 것만 보고 중복을 막았더니, **승인하는 순간** 그 커맨드가 대기에서 빠지고 조건은 * 그대로 서 있어 다음 바퀴가 같은 것을 또 냈다. 사람이 승인할수록 대기 줄이 다시 차는 모양이었다. */ const justProposed = new Map([['bottleneck:paint-1', NOW - 60_000]]) const out = run({ openCauses: new Set(), lastProposedAt: justProposed }) assert.equal(out.proposals.length, 0) }) test('★ 기다리는 시간이 지나면 다시 낸다 — 조건이 여전하면 다시 판단거리다', () => { const longAgo = new Map([['bottleneck:paint-1', NOW - 2 * HOUR]]) const out = run({ openCauses: new Set(), lastProposedAt: longAgo }) assert.equal(out.proposals.length, 1) }) test('★ 선언하지 않아도 기다린다 — 그 결함을 기본값으로 두지 않는다', () => { const recent = new Map([['bottleneck:paint-1', NOW - (DEFAULT_COOLDOWN_MS - 1000)]]) const out = run({ rules: [rule({ cooldownMs: undefined })], openCauses: new Set(), lastProposedAt: recent }) assert.equal(out.proposals.length, 0) }) test('0 으로 두면 기다리지 않는다 — 그 뜻을 그대로 존중한다', () => { const justProposed = new Map([['bottleneck:paint-1', NOW - 1000]]) const out = run({ rules: [rule({ cooldownMs: 0 })], openCauses: new Set(), lastProposedAt: justProposed }) assert.equal(out.proposals.length, 1) }) test('낸 적 없는 근거는 기다리지 않는다', () => { const other = new Map([['bottleneck:other', NOW - 1000]]) assert.equal(run({ lastProposedAt: other }).proposals.length, 1) }) /* ── 무엇을 걸러내나 ─────────────────────────────────────────────────────── */ test('★ 확인한 주의에는 올리지 않는다 — 사람이 이미 본 것이다', () => { assert.equal(run({ attentions: [bottleneck({ state: 'acknowledged' })] }).proposals.length, 0) assert.equal(run({ attentions: [bottleneck({ state: 'cleared' })] }).proposals.length, 0) }) test('심각도가 문턱 아래면 올리지 않는다', () => { assert.equal(run({ attentions: [bottleneck({ severity: 'medium' })] }).proposals.length, 0) assert.equal(run({ attentions: [bottleneck({ severity: 'critical' })] }).proposals.length, 1) }) test('★ 모르는 심각도는 통과시키지 않는다 — 모름을 「충분하다」로 읽지 않는다', () => { assert.equal(run({ attentions: [bottleneck({ severity: 'ㅁ' })] }).proposals.length, 0) }) test('꺼 둔 규칙은 판정하지 않는다 — 지우지 않고 끌 수 있어야 한다', () => { assert.equal(run({ rules: [rule({ enabled: false })] }).proposals.length, 0) }) test('다른 종류의 주의는 이 규칙이 보지 않는다', () => { assert.equal(run({ attentions: [bottleneck({ kind: 'scrap-high' })] }).proposals.length, 0) }) /* ── 지어낸 값으로 지시를 만들지 않는다 ─────────────────────────────────── */ test('★ 바꿔 넣을 값이 주의에 없으면 올리지 않고 말한다', () => { const out = run({ rules: [rule({ payloadTemplate: { ...rule().payloadTemplate, quantity: '{shortfall}' } })] }) assert.equal(out.proposals.length, 0) assert.equal(out.skips.length, 1) assert.match(out.skips[0]!.reason, /넣을 값이 주의에 없다/) assert.match(out.skips[0]!.reason, /shortfall/) }) test('★ 요구하는 칸이 비면 올리지 않고 말한다 — 무엇을 지시하는지 모르면 승인할 근거가 없다', () => { const out = run({ rules: [rule({ payloadTemplate: { note: '병목' } })] }) assert.equal(out.proposals.length, 0) assert.match(out.skips[0]!.reason, /요구하는 칸이 비었다/) for (const key of ORDER.required) assert.match(out.skips[0]!.reason, new RegExp(key)) }) test('★ 선언되지 않은 조치 종류를 가리키는 규칙은 말한다 — 조용히 아무 일도 안 하지 않는다', () => { const out = run({ rules: [rule({ commandType: 'line.shutdown' })] }) assert.equal(out.proposals.length, 0) assert.equal(out.skips.length, 1) assert.match(out.skips[0]!.reason, /선언되지 않은 조치 종류/) }) test('★ 커널 자극을 가리키는 규칙도 같은 자리에서 걸린다 — 모델 자극이 현장 지시가 되지 않게', () => { /* * `Attention.suggestedAction.command` 가 나르는 것이 이 어휘다. 주의를 읽어 규칙을 쓰다 보면 그 값이 * 손에 잡힌다 — `resource.repair` 는 「모델에서 수리된 것으로 두라」이고 「사람을 보내라」가 아니다. */ const out = run({ rules: [rule({ commandType: CMD.resourceRepair })] }) assert.equal(out.proposals.length, 0) assert.match(out.skips[0]!.reason, /선언되지 않은 조치 종류/) }) /* ── 한 번에 쏟지 않는다 ─────────────────────────────────────────────────── */ test('★ 상한에 닿으면 두고 오고, 두고 온 수를 말한다 — 조용히 자르면 다 올린 줄로 읽는다', () => { const many = Array.from({ length: 30 }, (_, i) => bottleneck({ id: `bottleneck:line-${i}`, anchor: { locationId: `line-${i}` } }) ) const out = run({ attentions: many, maxPerRun: 5 }) assert.equal(out.proposals.length, 5) assert.equal(out.withheld, 25) }) test('상한은 기본값이 있다 — 사람이 읽을 수 있는 수', () => { const many = Array.from({ length: DEFAULT_MAX_PER_RUN + 3 }, (_, i) => bottleneck({ id: `bottleneck:line-${i}`, anchor: { locationId: `line-${i}` } }) ) const out = run({ attentions: many }) assert.equal(out.proposals.length, DEFAULT_MAX_PER_RUN) assert.equal(out.withheld, 3) }) test('두고 온 것이 없으면 그 자리를 만들지 않는다 — 0 을 「잘렸다」로 읽히게 하지 않는다', () => { assert.equal(run().withheld, undefined) })