/* * **승인된 조치를 어디로 보낼 수 있나** — 그 판정. * * 이 판정이 없어서 실제로 끊겨 있었다. 넘기는 리졸버가 어댑터를 찾지도 않고 「구현이 없습니다」를 * 답했고, MES 커넥터는 그때 이미 `actuate` 를 다 만들어 두었다. 답이 그럴듯해서 설계 문서에도 다른 * 레인에도 그대로 옮겼다. * * 그래서 여기서 지키는 것은 둘이다 — **보낼 수 있으면 보내는가**, 그리고 **못 보낼 때 무엇이 없어서 * 인지 말하는가.** 뒤엣것이 없으면 사람은 커넥터를 다시 만들려 든다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { actuationTarget, isActuationAllowed, type ActuationTargetInput } from '../server/service/reference/actuation-target.js' const input = (over: Partial = {}): ActuationTargetInput => ({ instanceId: 'twin-a', twinExists: true, siteId: 'plant-a', ref: { source: 'twin-a', adapterType: 'operato-plant' }, adapterRegistered: true, capabilities: ['live', 'actuate'], declaresActuate: true, hasActuateImpl: true, ...over }) /** 거절 코드를 꺼낸다 — 통과했으면 시험을 세운다(캐스팅으로 덮지 않는다). */ function refusalOf(t: ReturnType): string { assert.equal(isActuationAllowed(t), false, '거절해야 하는데 통과했다') return (t as any).code } /* ── 보낼 수 있으면 어디로까지 답한다 ───────────────────────────────────────── */ test('★ 선언과 구현이 다 있으면 보낼 수 있다 — 어디로까지 답한다', () => { const t = actuationTarget(input()) assert.equal(isActuationAllowed(t), true) assert.deepEqual(t, { ok: true, siteId: 'plant-a', adapterType: 'operato-plant', source: 'twin-a' }) }) test('사이트 이름의 앞뒤 공백은 다듬는다 — 주소에 그대로 들어가는 값이다', () => { const t = actuationTarget(input({ siteId: ' plant-a ' })) assert.equal((t as any).siteId, 'plant-a') }) /* ── 구현만 있고 선언이 없는 경우를 따로 말한다 ────────────────────────────── */ test('★ 구현은 있는데 선언이 없으면 그렇게 말한다 — 커넥터를 다시 만들게 하지 않는다', () => { /* * 실제로 있던 상태다. `operato-plant` 가 `actuate` 를 만들어 두고 `capabilities: ['live','control']` 만 * 선언했다. 「이 원본은 조치를 받지 않습니다」로 뭉개면 고칠 한 줄을 못 찾는다. */ const t = actuationTarget(input({ capabilities: ['live', 'control'], declaresActuate: false, hasActuateImpl: true })) assert.equal(refusalOf(t), 'not-declared') assert.match((t as any).detail, /capabilities/, '무엇을 고쳐야 하는지 말하지 않는다') }) test('★ 구현도 선언도 없으면 다른 코드로 말한다 — 고칠 것이 다르다', () => { const t = actuationTarget(input({ capabilities: ['live'], declaresActuate: false, hasActuateImpl: false })) assert.equal(refusalOf(t), 'not-supported') assert.match((t as any).detail, /관측만/) }) test('★ 선언은 있는데 구현이 없으면 「관측만 냅니다」가 아니다 — 그 사람은 조치를 하겠다고 적었다', () => { /* * 만들다 만 커넥터다. 등록 검사는 이쪽을 **말하지 않는다** — 구현했는데 선언 안 한 반대 방향만 본다. * 그러니 이 자리가 말하지 않으면 아무도 말하지 않고, 사람은 자기가 적어 둔 선언을 믿고 조치가 되는 * 줄로 읽는다. */ const t = actuationTarget(input({ capabilities: ['live'], declaresActuate: true, hasActuateImpl: false })) assert.equal(refusalOf(t), 'not-implemented') assert.doesNotMatch((t as any).detail, /관측만/, '선언한 사람에게 「관측만 냅니다」라고 답했다') assert.match((t as any).detail, /actuate/, '무엇을 만들어야 하는지 말하지 않는다') }) test('낼 수 있나는 능력 판정만 본다 — 선언·구현 두 값은 이유를 말하기 위한 것이다', () => { /* * `capabilitiesOf` 가 선언과 구현을 함께 보고 답한 값이 판정의 근거다. 여기서 두 값으로 다시 * 판단하면 규칙이 두 벌이 되고, 한쪽만 고쳐지는 날이 온다. */ const t = actuationTarget(input({ capabilities: ['actuate'], declaresActuate: false, hasActuateImpl: false })) assert.equal(isActuationAllowed(t), true, '능력 판정의 결과를 신뢰하지 않고 여기서 다시 판단했다') }) /* ── 모르면 보내지 않는다 ───────────────────────────────────────────────────── */ test('★ 어느 사이트에서 왔는지 모르면 보내지 않는다 — 첫 번째 사이트로 보내지 않는다', () => { /* * 원본 하나가 여러 사이트를 낸다. 모르는 채로 아무 데나 보내면 **잘못된 공장이 실제로 만든다.** * 구동(control)은 사이트를 몰라도 넘기지만 이쪽은 다르다. */ assert.equal(refusalOf(actuationTarget(input({ siteId: undefined }))), 'no-site') assert.equal(refusalOf(actuationTarget(input({ siteId: ' ' }))), 'no-site', '공백을 사이트 이름으로 읽었다') }) test('★ 원본이 없는 트윈에는 보내지 않는다 — 시뮬레이션으로만 세운 트윈이다', () => { assert.equal(refusalOf(actuationTarget(input({ ref: undefined }))), 'no-source') }) test('★ 거절 문장이 어느 트윈인지 말한다 — 「모르는 트윈입니다」만으로는 찾을 수 없다', () => { for (const over of [{ twinExists: false, ref: undefined }, { ref: undefined }, { siteId: undefined }]) { const t = actuationTarget(input(over)) as any assert.match(t.detail, /twin-a/, `${t.code} 가 어느 트윈인지 말하지 않는다`) } }) test('모르는 트윈은 원본을 묻기 전에 거절한다', () => { assert.equal(refusalOf(actuationTarget(input({ twinExists: false, ref: undefined }))), 'unknown-twin') }) test('등록되지 않은 커넥터는 능력을 묻기 전에 거절한다 — 물을 데가 없다', () => { const t = actuationTarget(input({ adapterRegistered: false, capabilities: [], hasActuateImpl: false })) assert.equal(refusalOf(t), 'no-adapter') assert.match((t as any).detail, /operato-plant/, '어느 커넥터인지 말하지 않으면 찾을 수 없다') }) /* ── 거절은 전부 코드를 갖는다 ──────────────────────────────────────────────── */ test('★ 모든 거절에 코드와 문장이 함께 있다 — 화면이 다음 할 일을 가를 수 있게', () => { const cases: Partial[] = [ { twinExists: false }, { ref: undefined }, { adapterRegistered: false, capabilities: [], declaresActuate: false, hasActuateImpl: false }, { capabilities: ['live', 'control'], declaresActuate: false, hasActuateImpl: true }, { capabilities: ['live'], declaresActuate: true, hasActuateImpl: false }, { capabilities: ['live'], declaresActuate: false, hasActuateImpl: false }, { siteId: undefined } ] const codes = cases.map(over => { const t = actuationTarget(input(over)) as any assert.equal(t.ok, false) assert.ok(t.code, '거절에 코드가 없다') assert.ok(t.detail?.length > 10, '거절에 사람이 읽을 문장이 없다') return t.code }) assert.equal(new Set(codes).size, codes.length, '다른 원인이 같은 코드로 답했다 — 화면이 구별할 수 없다') })