@things-factory/headless-twin 10.0.18 → 10.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-server/engine/canonical-ingest.d.ts +10 -1
- package/dist-server/engine/canonical-ingest.js +110 -33
- package/dist-server/engine/canonical-ingest.js.map +1 -1
- package/dist-server/engine/event-time.d.ts +25 -0
- package/dist-server/engine/event-time.js +98 -0
- package/dist-server/engine/event-time.js.map +1 -0
- package/dist-server/engine/index.d.ts +2 -0
- package/dist-server/engine/index.js +23 -0
- package/dist-server/engine/index.js.map +1 -1
- package/dist-server/engine/ingest-dedupe.d.ts +18 -6
- package/dist-server/engine/ingest-dedupe.js +201 -15
- package/dist-server/engine/ingest-dedupe.js.map +1 -1
- package/dist-server/engine/ingest-health.d.ts +60 -1
- package/dist-server/engine/ingest-health.js +43 -8
- package/dist-server/engine/ingest-health.js.map +1 -1
- package/dist-server/engine/kpi-fold.d.ts +19 -0
- package/dist-server/engine/kpi-fold.js +4 -1
- package/dist-server/engine/kpi-fold.js.map +1 -1
- package/dist-server/engine/kpi-query.js +23 -3
- package/dist-server/engine/kpi-query.js.map +1 -1
- package/dist-server/engine/oee-accumulator.d.ts +35 -2
- package/dist-server/engine/oee-accumulator.js +56 -4
- package/dist-server/engine/oee-accumulator.js.map +1 -1
- package/dist-server/engine/twin-engine.d.ts +72 -3
- package/dist-server/engine/twin-engine.js +118 -5
- package/dist-server/engine/twin-engine.js.map +1 -1
- package/dist-server/index.js +21 -0
- package/dist-server/index.js.map +1 -1
- package/dist-server/migrations/1786600000000-RenameOperatoMesAdapterType.d.ts +5 -0
- package/dist-server/migrations/1786600000000-RenameOperatoMesAdapterType.js +66 -0
- package/dist-server/migrations/1786600000000-RenameOperatoMesAdapterType.js.map +1 -0
- package/dist-server/migrations/1786700000000-AddDispatchRetryColumns.d.ts +5 -0
- package/dist-server/migrations/1786700000000-AddDispatchRetryColumns.js +79 -0
- package/dist-server/migrations/1786700000000-AddDispatchRetryColumns.js.map +1 -0
- package/dist-server/migrations/index.js +5 -1
- package/dist-server/migrations/index.js.map +1 -1
- package/dist-server/routes.js +19 -16
- package/dist-server/routes.js.map +1 -1
- package/dist-server/service/actuation/actuation-advisor.d.ts +60 -0
- package/dist-server/service/actuation/actuation-advisor.js +53 -0
- package/dist-server/service/actuation/actuation-advisor.js.map +1 -0
- package/dist-server/service/actuation/actuation-outcome.d.ts +52 -0
- package/dist-server/service/actuation/actuation-outcome.js +34 -0
- package/dist-server/service/actuation/actuation-outcome.js.map +1 -0
- package/dist-server/service/actuation/actuation-rule-resolver.d.ts +19 -0
- package/dist-server/service/actuation/actuation-rule-resolver.js +161 -0
- package/dist-server/service/actuation/actuation-rule-resolver.js.map +1 -0
- package/dist-server/service/actuation/actuation-rule.d.ts +55 -0
- package/dist-server/service/actuation/actuation-rule.js +130 -0
- package/dist-server/service/actuation/actuation-rule.js.map +1 -0
- package/dist-server/service/actuation/approval-gateway.d.ts +20 -0
- package/dist-server/service/actuation/approval-gateway.js +14 -0
- package/dist-server/service/actuation/approval-gateway.js.map +1 -0
- package/dist-server/service/actuation/catch-up-effects.d.ts +16 -0
- package/dist-server/service/actuation/catch-up-effects.js +120 -0
- package/dist-server/service/actuation/catch-up-effects.js.map +1 -0
- package/dist-server/service/actuation/command-dispatcher.d.ts +230 -0
- package/dist-server/service/actuation/command-dispatcher.js +269 -0
- package/dist-server/service/actuation/command-dispatcher.js.map +1 -0
- package/dist-server/service/actuation/command-store.d.ts +11 -0
- package/dist-server/service/actuation/command-store.js +219 -0
- package/dist-server/service/actuation/command-store.js.map +1 -0
- package/dist-server/service/actuation/dispatch-after-approval.d.ts +11 -0
- package/dist-server/service/actuation/dispatch-after-approval.js +73 -0
- package/dist-server/service/actuation/dispatch-after-approval.js.map +1 -0
- package/dist-server/service/actuation/effect-match.d.ts +53 -0
- package/dist-server/service/actuation/effect-match.js +65 -0
- package/dist-server/service/actuation/effect-match.js.map +1 -0
- package/dist-server/service/actuation/index.d.ts +25 -0
- package/dist-server/service/actuation/index.js +30 -0
- package/dist-server/service/actuation/index.js.map +1 -0
- package/dist-server/service/actuation/retry-plan.d.ts +63 -0
- package/dist-server/service/actuation/retry-plan.js +99 -0
- package/dist-server/service/actuation/retry-plan.js.map +1 -0
- package/dist-server/service/actuation/rule-evaluate.d.ts +127 -0
- package/dist-server/service/actuation/rule-evaluate.js +205 -0
- package/dist-server/service/actuation/rule-evaluate.js.map +1 -0
- package/dist-server/service/actuation/rule-loop.d.ts +5 -0
- package/dist-server/service/actuation/rule-loop.js +156 -0
- package/dist-server/service/actuation/rule-loop.js.map +1 -0
- package/dist-server/service/actuation/rule-runner.d.ts +52 -0
- package/dist-server/service/actuation/rule-runner.js +135 -0
- package/dist-server/service/actuation/rule-runner.js.map +1 -0
- package/dist-server/service/actuation/rule-wiring.d.ts +24 -0
- package/dist-server/service/actuation/rule-wiring.js +80 -0
- package/dist-server/service/actuation/rule-wiring.js.map +1 -0
- package/dist-server/service/actuation/settle-effects.d.ts +21 -0
- package/dist-server/service/actuation/settle-effects.js +101 -0
- package/dist-server/service/actuation/settle-effects.js.map +1 -0
- package/dist-server/service/actuation/sweep-dispatch.d.ts +37 -0
- package/dist-server/service/actuation/sweep-dispatch.js +125 -0
- package/dist-server/service/actuation/sweep-dispatch.js.map +1 -0
- package/dist-server/service/actuation/twin-command-resolver.d.ts +120 -0
- package/dist-server/service/actuation/twin-command-resolver.js +406 -0
- package/dist-server/service/actuation/twin-command-resolver.js.map +1 -0
- package/dist-server/service/actuation/twin-command.d.ts +111 -0
- package/dist-server/service/actuation/twin-command.js +170 -0
- package/dist-server/service/actuation/twin-command.js.map +1 -0
- package/dist-server/service/actuation/twin-now.d.ts +41 -0
- package/dist-server/service/actuation/twin-now.js +65 -0
- package/dist-server/service/actuation/twin-now.js.map +1 -0
- package/dist-server/service/index.d.ts +5 -2
- package/dist-server/service/index.js +28 -17
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/reference/actuation-routing.d.ts +60 -0
- package/dist-server/service/reference/actuation-routing.js +126 -0
- package/dist-server/service/reference/actuation-routing.js.map +1 -0
- package/dist-server/service/reference/actuation-target.d.ts +65 -0
- package/dist-server/service/reference/actuation-target.js +94 -0
- package/dist-server/service/reference/actuation-target.js.map +1 -0
- package/dist-server/service/reference/connection-portability-resolver.d.ts +29 -0
- package/dist-server/service/reference/connection-portability-resolver.js +181 -0
- package/dist-server/service/reference/connection-portability-resolver.js.map +1 -0
- package/dist-server/service/reference/connection-portability.d.ts +138 -0
- package/dist-server/service/reference/connection-portability.js +230 -0
- package/dist-server/service/reference/connection-portability.js.map +1 -0
- package/dist-server/service/reference/fill-cadence.d.ts +17 -0
- package/dist-server/service/reference/fill-cadence.js +31 -0
- package/dist-server/service/reference/fill-cadence.js.map +1 -0
- package/dist-server/service/reference/fill-dedupe.d.ts +16 -0
- package/dist-server/service/reference/fill-dedupe.js +63 -0
- package/dist-server/service/reference/fill-dedupe.js.map +1 -0
- package/dist-server/service/reference/fill-loop.d.ts +35 -0
- package/dist-server/service/reference/fill-loop.js +187 -0
- package/dist-server/service/reference/fill-loop.js.map +1 -0
- package/dist-server/service/reference/fill-span.d.ts +10 -0
- package/dist-server/service/reference/fill-span.js +37 -0
- package/dist-server/service/reference/fill-span.js.map +1 -0
- package/dist-server/service/reference/hook-contract.d.ts +149 -22
- package/dist-server/service/reference/hook-contract.js +236 -23
- package/dist-server/service/reference/hook-contract.js.map +1 -1
- package/dist-server/service/reference/hook-rejected.d.ts +25 -0
- package/dist-server/service/reference/hook-rejected.js +48 -0
- package/dist-server/service/reference/hook-rejected.js.map +1 -0
- package/dist-server/service/reference/hook-store.d.ts +3 -0
- package/dist-server/service/reference/hook-store.js +28 -0
- package/dist-server/service/reference/hook-store.js.map +1 -0
- package/dist-server/service/reference/index.d.ts +4 -1
- package/dist-server/service/reference/index.js +5 -1
- package/dist-server/service/reference/index.js.map +1 -1
- package/dist-server/service/reference/live-cadence.d.ts +68 -0
- package/dist-server/service/reference/live-cadence.js +44 -0
- package/dist-server/service/reference/live-cadence.js.map +1 -0
- package/dist-server/service/reference/live-feed-lease.d.ts +15 -0
- package/dist-server/service/reference/live-feed-lease.js +56 -0
- package/dist-server/service/reference/live-feed-lease.js.map +1 -0
- package/dist-server/service/reference/live-ingest.d.ts +21 -0
- package/dist-server/service/reference/live-ingest.js +66 -0
- package/dist-server/service/reference/live-ingest.js.map +1 -0
- package/dist-server/service/reference/reference-adapter.d.ts +355 -2
- package/dist-server/service/reference/reference-adapter.js +19 -1
- package/dist-server/service/reference/reference-adapter.js.map +1 -1
- package/dist-server/service/reference/reference-fill-run.d.ts +50 -0
- package/dist-server/service/reference/reference-fill-run.js +142 -0
- package/dist-server/service/reference/reference-fill-run.js.map +1 -0
- package/dist-server/service/reference/reference-fill.d.ts +90 -0
- package/dist-server/service/reference/reference-fill.js +141 -0
- package/dist-server/service/reference/reference-fill.js.map +1 -0
- package/dist-server/service/reference/reference-hook.d.ts +64 -6
- package/dist-server/service/reference/reference-hook.js +265 -27
- package/dist-server/service/reference/reference-hook.js.map +1 -1
- package/dist-server/service/reference/reference-link-groups.d.ts +10 -0
- package/dist-server/service/reference/reference-link-groups.js +15 -0
- package/dist-server/service/reference/reference-link-groups.js.map +1 -0
- package/dist-server/service/reference/reference-links.d.ts +14 -0
- package/dist-server/service/reference/reference-links.js +73 -0
- package/dist-server/service/reference/reference-links.js.map +1 -0
- package/dist-server/service/reference/reference-live.d.ts +1 -0
- package/dist-server/service/reference/reference-live.js +32 -6
- package/dist-server/service/reference/reference-live.js.map +1 -1
- package/dist-server/service/reference/reference-master.d.ts +56 -2
- package/dist-server/service/reference/reference-master.js +33 -3
- package/dist-server/service/reference/reference-master.js.map +1 -1
- package/dist-server/service/reference/reference-resolver.d.ts +27 -0
- package/dist-server/service/reference/reference-resolver.js +79 -1
- package/dist-server/service/reference/reference-resolver.js.map +1 -1
- package/dist-server/service/twin-event/twin-event-keys.d.ts +31 -3
- package/dist-server/service/twin-event/twin-event-keys.js +36 -4
- package/dist-server/service/twin-event/twin-event-keys.js.map +1 -1
- package/dist-server/service/twin-event/twin-event.d.ts +1 -0
- package/dist-server/service/twin-event/twin-event.js +11 -2
- package/dist-server/service/twin-event/twin-event.js.map +1 -1
- package/dist-server/service/twin-model/choose-recipe.d.ts +15 -0
- package/dist-server/service/twin-model/choose-recipe.js +33 -0
- package/dist-server/service/twin-model/choose-recipe.js.map +1 -0
- package/dist-server/service/twin-model/external-resolver.d.ts +76 -0
- package/dist-server/service/twin-model/external-resolver.js +109 -0
- package/dist-server/service/twin-model/external-resolver.js.map +1 -0
- package/dist-server/service/twin-model/project-structure.js +16 -3
- package/dist-server/service/twin-model/project-structure.js.map +1 -1
- package/dist-server/service/twin-model/twin-equipment.d.ts +17 -0
- package/dist-server/service/twin-model/twin-equipment.js +10 -0
- package/dist-server/service/twin-model/twin-equipment.js.map +1 -1
- package/dist-server/service/twin-model/twin-lineage-query.js +75 -1
- package/dist-server/service/twin-model/twin-lineage-query.js.map +1 -1
- package/dist-server/service/twin-model/twin-model-item-query.js +94 -5
- package/dist-server/service/twin-model/twin-model-item-query.js.map +1 -1
- package/dist-server/service/twin-model/twin-model-tree-query.js.map +1 -1
- package/dist-server/service/twin-space/twin-space.d.ts +29 -0
- package/dist-server/service/twin-space/twin-space.js +13 -0
- package/dist-server/service/twin-space/twin-space.js.map +1 -1
- package/dist-server/service/twin-subject/event-subjects.d.ts +3 -1
- package/dist-server/service/twin-subject/event-subjects.js +8 -1
- package/dist-server/service/twin-subject/event-subjects.js.map +1 -1
- package/dist-shared/entity-delta.js +1 -1
- package/dist-shared/entity-delta.js.map +1 -1
- package/package.json +7 -7
- package/server/engine/canonical-ingest.ts +119 -33
- package/server/engine/event-time.ts +96 -0
- package/server/engine/index.ts +20 -0
- package/server/engine/ingest-dedupe.ts +203 -27
- package/server/engine/ingest-health.ts +91 -6
- package/server/engine/kpi-fold.ts +24 -1
- package/server/engine/kpi-query.ts +23 -4
- package/server/engine/oee-accumulator.ts +80 -8
- package/server/engine/twin-engine.ts +119 -5
- package/server/index.ts +23 -0
- package/server/migrations/1786600000000-RenameOperatoMesAdapterType.ts +66 -0
- package/server/migrations/1786700000000-AddDispatchRetryColumns.ts +82 -0
- package/server/migrations/index.ts +5 -1
- package/server/routes.ts +19 -20
- package/server/service/actuation/actuation-advisor.ts +124 -0
- package/server/service/actuation/actuation-outcome.ts +97 -0
- package/server/service/actuation/actuation-rule-resolver.ts +147 -0
- package/server/service/actuation/actuation-rule.ts +139 -0
- package/server/service/actuation/approval-gateway.ts +44 -0
- package/server/service/actuation/catch-up-effects.ts +152 -0
- package/server/service/actuation/command-dispatcher.ts +511 -0
- package/server/service/actuation/command-store.ts +236 -0
- package/server/service/actuation/dispatch-after-approval.ts +71 -0
- package/server/service/actuation/effect-match.ts +124 -0
- package/server/service/actuation/index.ts +27 -0
- package/server/service/actuation/retry-plan.ts +145 -0
- package/server/service/actuation/rule-evaluate.ts +352 -0
- package/server/service/actuation/rule-loop.ts +160 -0
- package/server/service/actuation/rule-runner.ts +216 -0
- package/server/service/actuation/rule-wiring.ts +84 -0
- package/server/service/actuation/settle-effects.ts +115 -0
- package/server/service/actuation/sweep-dispatch.ts +179 -0
- package/server/service/actuation/twin-command-resolver.ts +387 -0
- package/server/service/actuation/twin-command.ts +232 -0
- package/server/service/actuation/twin-now.ts +97 -0
- package/server/service/index.ts +11 -0
- package/server/service/reference/actuation-routing.ts +165 -0
- package/server/service/reference/actuation-target.ts +163 -0
- package/server/service/reference/connection-portability-resolver.ts +171 -0
- package/server/service/reference/connection-portability.ts +285 -0
- package/server/service/reference/fill-cadence.ts +29 -0
- package/server/service/reference/fill-dedupe.ts +66 -0
- package/server/service/reference/fill-loop.ts +153 -0
- package/server/service/reference/fill-span.ts +31 -0
- package/server/service/reference/hook-contract.ts +280 -22
- package/server/service/reference/hook-rejected.ts +53 -0
- package/server/service/reference/hook-store.ts +28 -0
- package/server/service/reference/index.ts +5 -1
- package/server/service/reference/live-cadence.ts +129 -0
- package/server/service/reference/live-feed-lease.ts +56 -0
- package/server/service/reference/live-ingest.ts +94 -0
- package/server/service/reference/reference-adapter.ts +392 -5
- package/server/service/reference/reference-fill-run.ts +165 -0
- package/server/service/reference/reference-fill.ts +223 -0
- package/server/service/reference/reference-hook.ts +355 -32
- package/server/service/reference/reference-link-groups.ts +21 -0
- package/server/service/reference/reference-links.ts +77 -0
- package/server/service/reference/reference-live.ts +30 -5
- package/server/service/reference/reference-master.ts +90 -5
- package/server/service/reference/reference-resolver.ts +77 -2
- package/server/service/twin-event/twin-event-keys.ts +37 -4
- package/server/service/twin-event/twin-event.ts +18 -1
- package/server/service/twin-model/choose-recipe.ts +31 -0
- package/server/service/twin-model/external-resolver.ts +161 -0
- package/server/service/twin-model/project-structure.ts +16 -3
- package/server/service/twin-model/twin-equipment.ts +22 -0
- package/server/service/twin-model/twin-lineage-query.ts +82 -1
- package/server/service/twin-model/twin-model-item-query.ts +95 -4
- package/server/service/twin-model/twin-model-tree-query.ts +1 -1
- package/server/service/twin-space/twin-space.ts +39 -0
- package/server/service/twin-subject/event-subjects.ts +8 -1
- package/shared/entity-delta.ts +1 -1
- package/test/ack-shape-alignment.test.ts +97 -0
- package/test/actuation-approval-door.test.ts +421 -0
- package/test/actuation-dispatch.test.ts +209 -0
- package/test/actuation-effect.test.ts +162 -0
- package/test/actuation-outcome.test.ts +152 -0
- package/test/actuation-rule-runner.test.ts +281 -0
- package/test/actuation-rule.test.ts +266 -0
- package/test/actuation-seam.test.ts +249 -0
- package/test/actuation-settle.test.ts +164 -0
- package/test/actuation-target.test.ts +145 -0
- package/test/canonical-ingest-vocabularies.test.ts +49 -2
- package/test/capability-mapping.test.ts +1 -1
- package/test/choose-recipe.test.ts +117 -0
- package/test/command-store-writes-columns.test.ts +126 -0
- package/test/connection-portability-doors.test.ts +87 -0
- package/test/connector-capability-declaration.test.ts +54 -4
- package/test/declaration-reaches-model.test.ts +43 -0
- package/test/dedupe-transformation.test.ts +112 -0
- package/test/entity-delta.test.ts +1 -1
- package/test/event-subjects.test.ts +2 -1
- package/test/external-resolver.test.ts +171 -0
- package/test/fill-dedupe.test.ts +59 -0
- package/test/fill-loop.test.ts +35 -0
- package/test/first-envelope.test.ts +153 -0
- package/test/fold-shift-dst.test.ts +110 -0
- package/test/home-in-area.test.ts +90 -0
- package/test/hook-rejected-shape.test.ts +86 -0
- package/test/hook-response-sum.test.ts +193 -0
- package/test/hook-sequence.test.ts +333 -0
- package/test/ingest-dedupe.test.ts +56 -0
- package/test/ingest-expected-quiet.test.ts +111 -0
- package/test/ingest-idempotent.test.ts +79 -0
- package/test/ingest-reconcile-four-ways.test.ts +97 -0
- package/test/ingest-rules-header.test.ts +100 -0
- package/test/ingest-window-rolls-unobserved.test.ts +72 -0
- package/test/intake-mapping-parity.test.ts +175 -0
- package/test/live-cadence.test.ts +170 -0
- package/test/live-feed-lease.test.ts +63 -0
- package/test/live-mirror-parity.test.ts +1 -1
- package/test/oee-accumulator.test.ts +3 -3
- package/test/oee-site-calendar.test.ts +146 -0
- package/test/order-and-transaction-split.test.ts +107 -0
- package/test/reference-fill.test.ts +235 -0
- package/test/reference-hook.test.ts +114 -23
- package/test/reference-links.test.ts +56 -0
- package/test/retry-plan.test.ts +139 -0
- package/test/sweep-dispatch.test.ts +191 -0
- package/test/twin-event-keys.test.ts +20 -6
- package/test/twin-model-item-db.test.ts +6 -6
- package/test/twin-model-tree-db.test.ts +6 -6
- package/tsconfig.shared.tsbuildinfo +1 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* **승인의 문은 한 벌이다** — 결재선에 올라간 조치는 여기서 결정되지 않는다.
|
|
3
|
+
*
|
|
4
|
+
* ── 무엇이 있었나 (2026-08-31) ───────────────────────────────────────────
|
|
5
|
+
* 게이트를 세우고 승인 화면을 계획하다가 물었다 — 「무엇을 승인하는 화면인가」. 커맨드 행을 **적는
|
|
6
|
+
* 코드가 하나도 없었다.** 대기 줄은 구조적으로 비어 있었다. 자리는 있고 길이 없는 그 부류다.
|
|
7
|
+
*
|
|
8
|
+
* 그것을 채우면서 문이 둘이 되었다: 이 패키지의 직접 결정과, 업무 목록(worklist)의 결재선. 문이 둘이면
|
|
9
|
+
* 결재선에는 대기로 남아 있는데 조치는 이미 나간 상태가 만들어지고, 사고 뒤에 **어느 기록이 진짜인지**
|
|
10
|
+
* 말할 수 없다.
|
|
11
|
+
*
|
|
12
|
+
* 그래서 규칙 하나: **결재선 자리가 적힌 커맨드는 결재선에서만 결정한다.** 이 시험이 그 규칙을 지킨다.
|
|
13
|
+
*/
|
|
14
|
+
import { test } from 'node:test'
|
|
15
|
+
import assert from 'node:assert/strict'
|
|
16
|
+
|
|
17
|
+
import { CMD, MES_COMMANDS, type CommandTypeSpec } from '@operato/ops-contract'
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
approve,
|
|
21
|
+
dispatch,
|
|
22
|
+
linkApproval,
|
|
23
|
+
propose,
|
|
24
|
+
reject,
|
|
25
|
+
settleByApproval,
|
|
26
|
+
type CommandStore,
|
|
27
|
+
type StoredCommand
|
|
28
|
+
} from '../server/service/actuation/command-dispatcher.js'
|
|
29
|
+
|
|
30
|
+
const DOMAIN = 'd-1'
|
|
31
|
+
const AT = '2026-08-31T02:00:00.000Z'
|
|
32
|
+
const REF = 'activity-instance-1'
|
|
33
|
+
|
|
34
|
+
/* MES 프로필의 선언을 그대로 쓴다 — 시험용 종류를 지어내면 선언과 어긋난 것을 시험이 통과시킨다. */
|
|
35
|
+
const SPEC: readonly CommandTypeSpec[] = MES_COMMANDS
|
|
36
|
+
const ORDER = MES_COMMANDS[0]!
|
|
37
|
+
const PAYLOAD = { materialDefinitionId: 'urn:epc:idpat:sgtin:9521234.00001.*', quantity: 12, uom: 'EA' }
|
|
38
|
+
|
|
39
|
+
const PROPOSER = 'line-lead@plant.example'
|
|
40
|
+
const APPROVER = 'plant-manager@plant.example'
|
|
41
|
+
|
|
42
|
+
const seed = (over: Partial<Parameters<typeof propose>[3]> = {}) => ({
|
|
43
|
+
id: 'cmd-1',
|
|
44
|
+
instanceId: 'twin-a',
|
|
45
|
+
type: ORDER.type,
|
|
46
|
+
origin: 'operator' as const,
|
|
47
|
+
proposedBy: PROPOSER,
|
|
48
|
+
proposedAt: AT,
|
|
49
|
+
payload: PAYLOAD,
|
|
50
|
+
...over
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
/** 저장본 하나 — 프로세스가 아니라 여기 산다. 그래서 다시 세워도 남는다. */
|
|
54
|
+
function makeStore(initial?: StoredCommand): CommandStore & { rows: Map<string, StoredCommand> } {
|
|
55
|
+
const rows = new Map<string, StoredCommand>(initial ? [[initial.id, initial]] : [])
|
|
56
|
+
const put = (command: StoredCommand) => {
|
|
57
|
+
const copy: StoredCommand = JSON.parse(JSON.stringify(command))
|
|
58
|
+
rows.set(copy.id, copy)
|
|
59
|
+
return copy
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
rows,
|
|
63
|
+
load: async (domainId, id) => (domainId === DOMAIN ? (rows.get(id) ?? null) : null),
|
|
64
|
+
save: async (domainId, command) => {
|
|
65
|
+
assert.equal(domainId, DOMAIN)
|
|
66
|
+
put(command)
|
|
67
|
+
},
|
|
68
|
+
create: async (domainId, command) => {
|
|
69
|
+
assert.equal(domainId, DOMAIN)
|
|
70
|
+
if (rows.has(command.id)) throw new Error(`이미 있는 커맨드다 (${command.id})`)
|
|
71
|
+
return put(command)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const ok = async (): Promise<{ ok: boolean; ref?: string }> => ({ ok: true, ref: 'JOB-1' })
|
|
77
|
+
|
|
78
|
+
/* ── 낸다 ─────────────────────────────────────────────────────────────────── */
|
|
79
|
+
|
|
80
|
+
test('★ 낸 조치가 대기 줄에 남는다 — 이 길이 없어 대기 줄이 비어 있었다', async () => {
|
|
81
|
+
const store = makeStore()
|
|
82
|
+
const command = await propose(store, DOMAIN, SPEC, seed())
|
|
83
|
+
|
|
84
|
+
assert.equal(command.state, 'proposed')
|
|
85
|
+
assert.equal(store.rows.size, 1, '행이 적히지 않았다')
|
|
86
|
+
assert.equal(store.rows.get('cmd-1')!.type, ORDER.type)
|
|
87
|
+
assert.equal(store.rows.get('cmd-1')!.payload.quantity, 12, '무엇을 지시하는지가 남지 않으면 승인할 근거가 없다')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('★ 선언되지 않은 종류는 낼 수 없다 — 되돌릴 수 있는지 모르는 채로 게이트를 지나게 하지 않는다', async () => {
|
|
91
|
+
const store = makeStore()
|
|
92
|
+
await assert.rejects(propose(store, DOMAIN, SPEC, seed({ type: 'line.shutdown' })), /선언되지 않은 조치 종류/)
|
|
93
|
+
assert.equal(store.rows.size, 0, '거절하면서 행을 적었다')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('★ 요구하는 칸이 없으면 낼 수 없다 — 빈 지시가 대기 줄에 쌓이지 않게', async () => {
|
|
97
|
+
const store = makeStore()
|
|
98
|
+
await assert.rejects(propose(store, DOMAIN, SPEC, seed({ payload: { quantity: 12 } })), /요구하는 칸이 없다/)
|
|
99
|
+
assert.equal(store.rows.size, 0)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('선언이 「되돌릴 수 없다」이면 그대로 적힌다 — 낸 쪽이 정하지 않는다', async () => {
|
|
103
|
+
const store = makeStore()
|
|
104
|
+
const command = await propose(store, DOMAIN, SPEC, seed())
|
|
105
|
+
assert.equal(command.reversible, ORDER.reversible)
|
|
106
|
+
assert.equal(command.reversible, false, 'MES 생산 지시는 되돌릴 수 없다 — 취소해도 만든 것은 남는다')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
/* ── 문은 한 벌이다 ───────────────────────────────────────────────────────── */
|
|
110
|
+
|
|
111
|
+
test('★ 결재선에 올라간 조치는 여기서 승인되지 않는다', async () => {
|
|
112
|
+
const store = makeStore()
|
|
113
|
+
await propose(store, DOMAIN, SPEC, seed())
|
|
114
|
+
await linkApproval(store, DOMAIN, 'cmd-1', REF)
|
|
115
|
+
|
|
116
|
+
await assert.rejects(approve(store, DOMAIN, 'cmd-1', { by: APPROVER, at: AT }), /결재선에 올라가 있다/)
|
|
117
|
+
await assert.rejects(reject(store, DOMAIN, 'cmd-1', { by: APPROVER, at: AT }), /결재선에 올라가 있다/)
|
|
118
|
+
assert.equal(store.rows.get('cmd-1')!.state, 'proposed', '막았다면서 상태가 움직였다')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('★ 결재선을 쓰지 않는 배포에서는 직접 결정이 유일한 문이다', async () => {
|
|
122
|
+
const store = makeStore()
|
|
123
|
+
await propose(store, DOMAIN, SPEC, seed())
|
|
124
|
+
|
|
125
|
+
const approved = await approve(store, DOMAIN, 'cmd-1', { by: APPROVER, at: AT })
|
|
126
|
+
assert.equal(approved.state, 'approved')
|
|
127
|
+
assert.equal((await dispatch(store, DOMAIN, 'cmd-1', ok)).state, 'acked')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('★ 올라가지 않은 커맨드에 결재선 판정을 옮기지 않는다 — 어디서 온 판정인지 알 수 없다', async () => {
|
|
131
|
+
const store = makeStore()
|
|
132
|
+
await propose(store, DOMAIN, SPEC, seed())
|
|
133
|
+
|
|
134
|
+
await assert.rejects(
|
|
135
|
+
settleByApproval(store, DOMAIN, 'cmd-1', { approvalRef: REF, approved: true, by: APPROVER, at: AT }),
|
|
136
|
+
/결재선에 올라가지 않은/
|
|
137
|
+
)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('★ 다른 결재 건의 판정은 받지 않는다 — 옆 건의 승인으로 게이트가 열리지 않게', async () => {
|
|
141
|
+
const store = makeStore()
|
|
142
|
+
await propose(store, DOMAIN, SPEC, seed())
|
|
143
|
+
await linkApproval(store, DOMAIN, 'cmd-1', REF)
|
|
144
|
+
|
|
145
|
+
await assert.rejects(
|
|
146
|
+
settleByApproval(store, DOMAIN, 'cmd-1', { approvalRef: 'activity-instance-9', approved: true, by: APPROVER, at: AT }),
|
|
147
|
+
/다른 결재 건의 판정/
|
|
148
|
+
)
|
|
149
|
+
assert.equal(store.rows.get('cmd-1')!.state, 'proposed')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
/* ── 결재선의 판정이 옮겨진다 ────────────────────────────────────────────── */
|
|
153
|
+
|
|
154
|
+
test('★ 결재선이 승인하면 나갈 수 있다 — 누가 승인했는지가 커맨드에도 남는다', async () => {
|
|
155
|
+
const store = makeStore()
|
|
156
|
+
await propose(store, DOMAIN, SPEC, seed())
|
|
157
|
+
await linkApproval(store, DOMAIN, 'cmd-1', REF)
|
|
158
|
+
|
|
159
|
+
const approved = await settleByApproval(store, DOMAIN, 'cmd-1', {
|
|
160
|
+
approvalRef: REF,
|
|
161
|
+
approved: true,
|
|
162
|
+
by: APPROVER,
|
|
163
|
+
at: AT,
|
|
164
|
+
note: '야간 물량 반영'
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
assert.equal(approved.state, 'approved')
|
|
168
|
+
/* 커맨드 목록이 결재선을 조회하지 않고도 「누가 언제」를 보여야 한다. */
|
|
169
|
+
assert.equal(store.rows.get('cmd-1')!.approval?.by, APPROVER)
|
|
170
|
+
assert.equal(store.rows.get('cmd-1')!.approval?.note, '야간 물량 반영')
|
|
171
|
+
assert.equal((await dispatch(store, DOMAIN, 'cmd-1', ok)).state, 'acked')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test('★ 결재선이 거절하면 커맨드는 거기서 닫힌다 — 뒤늦은 승인이 게이트를 열지 못한다', async () => {
|
|
175
|
+
/*
|
|
176
|
+
* 업무 목록에서 「거절」은 고쳐 다시 올리라는 뜻이지만, 조치에는 고칠 것이 없다 — 지시 내용은 제안될
|
|
177
|
+
* 때 정해진다. 그래서 커맨드 쪽은 끝으로 다룬다. 그 뒤에 결재선에서 승인이 와도 계약이 막는다.
|
|
178
|
+
*/
|
|
179
|
+
const store = makeStore()
|
|
180
|
+
await propose(store, DOMAIN, SPEC, seed())
|
|
181
|
+
await linkApproval(store, DOMAIN, 'cmd-1', REF)
|
|
182
|
+
|
|
183
|
+
const rejected = await settleByApproval(store, DOMAIN, 'cmd-1', {
|
|
184
|
+
approvalRef: REF,
|
|
185
|
+
approved: false,
|
|
186
|
+
by: APPROVER,
|
|
187
|
+
at: AT,
|
|
188
|
+
note: '그 라인은 정지 중이다'
|
|
189
|
+
})
|
|
190
|
+
assert.equal(rejected.state, 'rejected')
|
|
191
|
+
|
|
192
|
+
await assert.rejects(
|
|
193
|
+
settleByApproval(store, DOMAIN, 'cmd-1', { approvalRef: REF, approved: true, by: 'someone-else@plant.example', at: AT }),
|
|
194
|
+
/승인할 수 없다|승인되지 않은|rejected/
|
|
195
|
+
)
|
|
196
|
+
await assert.rejects(dispatch(store, DOMAIN, 'cmd-1', ok))
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
/* ── 자극과 조치는 다른 어휘다 ───────────────────────────────────────────── */
|
|
200
|
+
|
|
201
|
+
test('★ 커널 자극을 조치로 낼 수 없다 — 모델 안의 자극이 현장 지시가 되지 않게', async () => {
|
|
202
|
+
/*
|
|
203
|
+
* 계약에 어휘가 두 벌이다.
|
|
204
|
+
*
|
|
205
|
+
* CMD 커널 안에서 도는 자극. `resource.repair` = 모델에서 수리된 것으로 둔다
|
|
206
|
+
* MES_COMMANDS 현장에 나가는 지시. `production.order` = 실제로 만든다
|
|
207
|
+
*
|
|
208
|
+
* 주의 신호가 권하는 것(`Attention.suggestedAction.command`)은 **앞엣것**이다. 주의를 읽어 조치를
|
|
209
|
+
* 내는 코드를 쓰다 보면 그 값을 그대로 넘기게 된다 — 실제로 그러려다 멈췄다.
|
|
210
|
+
*/
|
|
211
|
+
const store = makeStore()
|
|
212
|
+
await assert.rejects(
|
|
213
|
+
propose(store, DOMAIN, SPEC, seed({ type: CMD.resourceRepair })),
|
|
214
|
+
/커널 안에서 도는 자극이다/
|
|
215
|
+
)
|
|
216
|
+
assert.equal(store.rows.size, 0)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
test('★ 두 어휘가 겹치지 않는다 — 겹치면 어느 쪽으로 읽어야 할지 알 수 없다', () => {
|
|
220
|
+
/*
|
|
221
|
+
* 이 시험이 지키는 것은 앞의 거절이 **뜻을 갖는다**는 사실이다. 같은 문자열이 양쪽에 있으면 거절도
|
|
222
|
+
* 통과도 옳지 않게 되고, 그때 위 시험은 초록인데 규칙은 사라진다.
|
|
223
|
+
*/
|
|
224
|
+
const stimuli = new Set(Object.values(CMD) as string[])
|
|
225
|
+
const overlap = MES_COMMANDS.map(s => s.type).filter(t => stimuli.has(t))
|
|
226
|
+
|
|
227
|
+
assert.deepEqual(
|
|
228
|
+
overlap,
|
|
229
|
+
[],
|
|
230
|
+
`자극과 조치에 같은 이름이 있다 (${overlap.join(', ')}) — 한 이름이 모델 상태를 바꾸기도 하고 현장에 나가기도 한다`
|
|
231
|
+
)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
/* ── 낸 사람은 자기 것을 승인하지 못한다 ─────────────────────────────────── */
|
|
235
|
+
|
|
236
|
+
test('★ 낸 사람이 자기 조치를 승인하지 못한다 — 게이트가 시늉이 되는 자리', async () => {
|
|
237
|
+
/*
|
|
238
|
+
* 이 규칙이 왜 필요한지는 돌려 보고 알았다. 업무 목록은 결재선이 비어 있으면 스레드를 곧바로
|
|
239
|
+
* 종료하고(§`submit.ts`), 그러면 콜백이 「승인됐다」로 받는다. 승인자를 지정하지 않은 도메인에서는
|
|
240
|
+
* 낸 사람이 그 자리에서 자기 조치를 승인한 것이 된다 — 오류 한 줄 없이.
|
|
241
|
+
*/
|
|
242
|
+
const store = makeStore()
|
|
243
|
+
await propose(store, DOMAIN, SPEC, seed())
|
|
244
|
+
|
|
245
|
+
await assert.rejects(approve(store, DOMAIN, 'cmd-1', { by: PROPOSER, at: AT }), /낸 사람이 자기 조치를 승인할 수 없다/)
|
|
246
|
+
assert.equal(store.rows.get('cmd-1')!.state, 'proposed')
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
test('★ 결재선을 지나온 판정에도 같은 규칙이 걸린다 — Myself 를 승인자로 넣으면 같은 사람이다', async () => {
|
|
250
|
+
const store = makeStore()
|
|
251
|
+
await propose(store, DOMAIN, SPEC, seed())
|
|
252
|
+
await linkApproval(store, DOMAIN, 'cmd-1', REF)
|
|
253
|
+
|
|
254
|
+
await assert.rejects(
|
|
255
|
+
settleByApproval(store, DOMAIN, 'cmd-1', { approvalRef: REF, approved: true, by: PROPOSER, at: AT }),
|
|
256
|
+
/낸 사람이 자기 조치를 승인할 수 없다/
|
|
257
|
+
)
|
|
258
|
+
assert.equal(store.rows.get('cmd-1')!.state, 'proposed')
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
test('대소문자가 달라도 같은 사람이다 — 로그인 이름의 표기로 규칙을 비껴가지 못한다', async () => {
|
|
262
|
+
const store = makeStore()
|
|
263
|
+
await propose(store, DOMAIN, SPEC, seed())
|
|
264
|
+
await assert.rejects(approve(store, DOMAIN, 'cmd-1', { by: PROPOSER.toUpperCase(), at: AT }), /낸 사람이/)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
test('★ 낸 사람은 자기 것을 물릴 수 있다 — 거절에는 걸지 않는다', async () => {
|
|
268
|
+
const store = makeStore()
|
|
269
|
+
await propose(store, DOMAIN, SPEC, seed())
|
|
270
|
+
const rejected = await reject(store, DOMAIN, 'cmd-1', { by: PROPOSER, at: AT, note: '잘못 냈다' })
|
|
271
|
+
assert.equal(rejected.state, 'rejected')
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
test('★ 사람이 내는데 낸 사람이 없으면 받지 않는다 — 그 한 건만 규칙을 비껴간다', async () => {
|
|
275
|
+
const store = makeStore()
|
|
276
|
+
await assert.rejects(propose(store, DOMAIN, SPEC, seed({ proposedBy: undefined })), /낸 사람이 있어야 한다/)
|
|
277
|
+
assert.equal(store.rows.size, 0)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
test('규칙·AI 가 낸 것은 낸 사람이 없다 — 사람이 아니다', async () => {
|
|
281
|
+
const store = makeStore()
|
|
282
|
+
const command = await propose(store, DOMAIN, SPEC, seed({ origin: 'rule', proposedBy: undefined }))
|
|
283
|
+
assert.equal(command.proposedBy, undefined)
|
|
284
|
+
/* 사람이 없으니 누구든 승인할 수 있다 — 그것이 이 경로의 뜻이다. */
|
|
285
|
+
assert.equal((await approve(store, DOMAIN, 'cmd-1', { by: APPROVER, at: AT })).state, 'approved')
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
test('★ 낸 사람이 재기동을 넘어 산다 — 사라지면 자기 승인이 열린다', async () => {
|
|
289
|
+
const first = makeStore()
|
|
290
|
+
await propose(first, DOMAIN, SPEC, seed())
|
|
291
|
+
const saved: StoredCommand = JSON.parse(JSON.stringify(first.rows.get('cmd-1')))
|
|
292
|
+
|
|
293
|
+
const second = makeStore(saved)
|
|
294
|
+
assert.equal(second.rows.get('cmd-1')!.proposedBy, PROPOSER, '재기동에 낸 사람이 사라졌다')
|
|
295
|
+
await assert.rejects(approve(second, DOMAIN, 'cmd-1', { by: PROPOSER, at: AT }), /낸 사람이/)
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
test('★ 넘긴 뒤에도 낸 사람이 남는다 — 사고 뒤에 누가 냈는지 물을 자리다', async () => {
|
|
299
|
+
const store = makeStore()
|
|
300
|
+
await propose(store, DOMAIN, SPEC, seed())
|
|
301
|
+
await approve(store, DOMAIN, 'cmd-1', { by: APPROVER, at: AT })
|
|
302
|
+
const settled = await dispatch(store, DOMAIN, 'cmd-1', ok)
|
|
303
|
+
|
|
304
|
+
assert.equal(settled.proposedBy, PROPOSER)
|
|
305
|
+
assert.equal(store.rows.get('cmd-1')!.proposedBy, PROPOSER, '넘기는 사이에 낸 사람이 지워졌다')
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
/* ── 재기동 ───────────────────────────────────────────────────────────────── */
|
|
309
|
+
|
|
310
|
+
test('★ 결재선 자리가 재기동을 넘어 산다 — 다시 세운 뒤 직접 결정이 열리면 우회로가 된다', async () => {
|
|
311
|
+
const first = makeStore()
|
|
312
|
+
await propose(first, DOMAIN, SPEC, seed())
|
|
313
|
+
await linkApproval(first, DOMAIN, 'cmd-1', REF)
|
|
314
|
+
|
|
315
|
+
/* 프로세스가 새로 선 것으로 본다 — 앞의 저장소 객체는 버리고 저장본만 물려준다. */
|
|
316
|
+
const saved: StoredCommand = JSON.parse(JSON.stringify(first.rows.get('cmd-1')))
|
|
317
|
+
const second = makeStore(saved)
|
|
318
|
+
|
|
319
|
+
assert.equal(second.rows.get('cmd-1')!.approvalRef, REF, '재기동에 결재선 자리가 사라졌다')
|
|
320
|
+
await assert.rejects(approve(second, DOMAIN, 'cmd-1', { by: APPROVER, at: AT }), /결재선에 올라가 있다/)
|
|
321
|
+
|
|
322
|
+
const approved = await settleByApproval(second, DOMAIN, 'cmd-1', {
|
|
323
|
+
approvalRef: REF,
|
|
324
|
+
approved: true,
|
|
325
|
+
by: APPROVER,
|
|
326
|
+
at: AT
|
|
327
|
+
})
|
|
328
|
+
assert.equal(approved.state, 'approved')
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
test('★ 넘긴 뒤에도 결재선 자리가 남는다 — 사고 뒤에 어느 결재로 나갔는지 물을 자리다', async () => {
|
|
332
|
+
const store = makeStore()
|
|
333
|
+
await propose(store, DOMAIN, SPEC, seed())
|
|
334
|
+
await linkApproval(store, DOMAIN, 'cmd-1', REF)
|
|
335
|
+
await settleByApproval(store, DOMAIN, 'cmd-1', { approvalRef: REF, approved: true, by: APPROVER, at: AT })
|
|
336
|
+
|
|
337
|
+
const settled = await dispatch(store, DOMAIN, 'cmd-1', ok)
|
|
338
|
+
assert.equal(settled.state, 'acked')
|
|
339
|
+
assert.equal(settled.approvalRef, REF)
|
|
340
|
+
assert.equal(store.rows.get('cmd-1')!.approvalRef, REF, '넘기는 사이에 결재선 자리가 지워졌다')
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
/* ── 받아들였으나 남은 것이 있다 ────────────────────────────────────────────── */
|
|
344
|
+
|
|
345
|
+
test('★ 저쪽이 덧붙인 말은 실패가 아니다 — 두 답이 구별되어야 한다', async () => {
|
|
346
|
+
/*
|
|
347
|
+
* 실측(2026-09-02): MES 에 조치 둘을 넘겼더니 트윈 쪽 두 행이 완전히 같았다. 하나는 지시서가
|
|
348
|
+
* 붙었고 하나는 안 붙었는데 `state`·`dispatch_ref`·`error` 가 셋 다 같았다. 화면은 둘 다
|
|
349
|
+
* 「나갔다」로 읽는다.
|
|
350
|
+
*/
|
|
351
|
+
const store = makeStore()
|
|
352
|
+
await propose(store, DOMAIN, SPEC, seed())
|
|
353
|
+
await approve(store, DOMAIN, 'cmd-1', { by: APPROVER, at: AT })
|
|
354
|
+
|
|
355
|
+
const out = await dispatch(store, DOMAIN, 'cmd-1', async () => ({
|
|
356
|
+
ok: true,
|
|
357
|
+
ref: 'WO-005',
|
|
358
|
+
note: '지시서가 붙지 않았습니다 — 사람이 MES 에서 골라야 합니다'
|
|
359
|
+
}))
|
|
360
|
+
|
|
361
|
+
assert.equal(out.state, 'acked', '덧붙인 말이 있는 것을 실패로 앉혔다')
|
|
362
|
+
assert.equal(out.dispatchRef, 'WO-005')
|
|
363
|
+
assert.match(out.dispatchNote!, /지시서가 붙지 않았습니다/)
|
|
364
|
+
assert.equal(out.error, undefined, '덧붙인 말을 오류 칸에 적었다')
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
test('덧붙인 말이 없으면 그 칸은 비어 있다 — 빈 문장을 만들지 않는다', async () => {
|
|
368
|
+
const store = makeStore()
|
|
369
|
+
await propose(store, DOMAIN, SPEC, seed())
|
|
370
|
+
await approve(store, DOMAIN, 'cmd-1', { by: APPROVER, at: AT })
|
|
371
|
+
|
|
372
|
+
const out = await dispatch(store, DOMAIN, 'cmd-1', async () => ({ ok: true, ref: 'WO-004' }))
|
|
373
|
+
|
|
374
|
+
assert.equal(out.state, 'acked')
|
|
375
|
+
assert.equal(out.dispatchNote, undefined)
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
/* ── 다시 넘길 때 지난 시도의 자국을 지운다 ────────────────────────────────── */
|
|
379
|
+
|
|
380
|
+
test('★ 다시 넘겨 성공하면 지난 실패의 오류가 남지 않는다 — 한 행이 두 가지를 말하면 안 된다', async () => {
|
|
381
|
+
/*
|
|
382
|
+
* 실측(2026-09-03): `failed → dispatch` 로 다시 넘겨 성공했는데 지난 실패의 `error` 가 그대로
|
|
383
|
+
* 남아, 한 행이 「받았다(acked)」와 「저쪽이 안 받는다」를 동시에 말했다. 화면이 그 행을 그리면
|
|
384
|
+
* 어느 쪽을 믿어야 하는지 알 수 없다.
|
|
385
|
+
*/
|
|
386
|
+
const store = makeStore()
|
|
387
|
+
await propose(store, DOMAIN, SPEC, seed())
|
|
388
|
+
await approve(store, DOMAIN, 'cmd-1', { by: APPROVER, at: AT })
|
|
389
|
+
|
|
390
|
+
const failed = await dispatch(store, DOMAIN, 'cmd-1', async () => ({ ok: false, error: '저쪽에 비밀값이 없습니다' }))
|
|
391
|
+
assert.equal(failed.state, 'failed')
|
|
392
|
+
assert.match(failed.error!, /비밀값/)
|
|
393
|
+
|
|
394
|
+
const retried = await dispatch(store, DOMAIN, 'cmd-1', async () => ({ ok: true, ref: 'WO-001' }))
|
|
395
|
+
assert.equal(retried.state, 'acked')
|
|
396
|
+
assert.equal(retried.dispatchRef, 'WO-001')
|
|
397
|
+
assert.equal(retried.error, undefined, '지난 실패의 오류가 성공한 행에 남았다')
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
test('★ 다시 넘겨 덧붙일 말이 없으면 옛 말이 남지 않는다 — 오류와 같은 부류다', async () => {
|
|
401
|
+
const store = makeStore()
|
|
402
|
+
await propose(store, DOMAIN, SPEC, seed())
|
|
403
|
+
await approve(store, DOMAIN, 'cmd-1', { by: APPROVER, at: AT })
|
|
404
|
+
|
|
405
|
+
/* 한 번 실패하면서 덧붙인 말이 남을 수 있는 자리를 만든다. */
|
|
406
|
+
await dispatch(store, DOMAIN, 'cmd-1', async () => ({ ok: true, ref: 'WO-1', note: '지시서를 골라야 합니다' }))
|
|
407
|
+
|
|
408
|
+
/* `acked` 는 종착이라 다시 넘길 수 없다 — 그 사실을 함께 못 박는다. */
|
|
409
|
+
await assert.rejects(() => dispatch(store, DOMAIN, 'cmd-1', async () => ({ ok: true, ref: 'WO-2' })), /넘길 수 없다/)
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
test('★ 다시 넘기다 또 실패하면 이번 사유만 남는다', async () => {
|
|
413
|
+
const store = makeStore()
|
|
414
|
+
await propose(store, DOMAIN, SPEC, seed())
|
|
415
|
+
await approve(store, DOMAIN, 'cmd-1', { by: APPROVER, at: AT })
|
|
416
|
+
|
|
417
|
+
await dispatch(store, DOMAIN, 'cmd-1', async () => ({ ok: false, error: '첫 번째 사유' }))
|
|
418
|
+
const again = await dispatch(store, DOMAIN, 'cmd-1', async () => ({ ok: false, error: '두 번째 사유' }))
|
|
419
|
+
|
|
420
|
+
assert.equal(again.error, '두 번째 사유', '두 사유가 겹쳐 남았다')
|
|
421
|
+
})
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* **승인을 지나지 않은 조치는 현장에 나가지 못한다** — 배선.
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 배선을 따로 보나 (2026-08-31) ─────────────────────────────────────
|
|
5
|
+
* 승인 판정 자체는 계약이 지킨다(`ops-contract/test/actuation-gate.test.ts`). 여기서 보는 것은
|
|
6
|
+
* **그 판정이 실제 경로에 서 있는가**다 — 어댑터가 불렸는가, 상태가 적혔는가, 다시 세운 뒤에도 승인이
|
|
7
|
+
* 남아 있는가.
|
|
8
|
+
*
|
|
9
|
+
* 이 저장소에서 가장 자주 나는 결함이 「자리는 있고 길이 없다」이고, 게이트가 그 부류로 나면 결과가
|
|
10
|
+
* 현장의 작업지시다. 그래서 마지막 시험이 **저장본만 남기고 다시 세운다.**
|
|
11
|
+
*/
|
|
12
|
+
import { test } from 'node:test'
|
|
13
|
+
import assert from 'node:assert/strict'
|
|
14
|
+
|
|
15
|
+
import type { TwinCommand } from '@operato/ops-contract'
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
approve,
|
|
19
|
+
dispatch,
|
|
20
|
+
reject,
|
|
21
|
+
type CommandAck,
|
|
22
|
+
type CommandStore,
|
|
23
|
+
type StoredCommand
|
|
24
|
+
} from '../server/service/actuation/command-dispatcher.js'
|
|
25
|
+
|
|
26
|
+
const DOMAIN = 'd-1'
|
|
27
|
+
const AT = '2026-08-31T02:00:00.000Z'
|
|
28
|
+
const APPROVAL = { by: 'line-lead', at: AT }
|
|
29
|
+
|
|
30
|
+
const irreversible = (over: Partial<TwinCommand> = {}): TwinCommand => ({
|
|
31
|
+
id: 'cmd-1',
|
|
32
|
+
instanceId: 'twin-a',
|
|
33
|
+
type: 'order.hold',
|
|
34
|
+
origin: 'ai',
|
|
35
|
+
proposedAt: AT,
|
|
36
|
+
state: 'proposed',
|
|
37
|
+
...over
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
/** 저장본 하나 — 프로세스가 아니라 여기 산다. 그래서 다시 세워도 남는다. */
|
|
41
|
+
function makeStore(initial: StoredCommand): CommandStore & { rows: Map<string, StoredCommand> } {
|
|
42
|
+
const rows = new Map<string, StoredCommand>([[initial.id, initial]])
|
|
43
|
+
return {
|
|
44
|
+
rows,
|
|
45
|
+
load: async (domainId, id) => (domainId === DOMAIN ? (rows.get(id) ?? null) : null),
|
|
46
|
+
/* 저장소를 흉내 내는 것이라 복사해 넣는다 — 같은 객체면 안 적혀도 통과한다. */
|
|
47
|
+
save: async (domainId, command) => {
|
|
48
|
+
assert.equal(domainId, DOMAIN)
|
|
49
|
+
rows.set(command.id, JSON.parse(JSON.stringify(command)))
|
|
50
|
+
},
|
|
51
|
+
create: async (domainId, command) => {
|
|
52
|
+
assert.equal(domainId, DOMAIN)
|
|
53
|
+
const copy = JSON.parse(JSON.stringify(command))
|
|
54
|
+
rows.set(command.id, copy)
|
|
55
|
+
return copy
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const ok = (ref = 'JOB-1') => async (): Promise<CommandAck> => ({ ok: true, ref })
|
|
61
|
+
|
|
62
|
+
/* ── 승인 없이는 나가지 못한다 ────────────────────────────────────────────── */
|
|
63
|
+
|
|
64
|
+
test('★ 승인하지 않은 조치는 어댑터에 닿지 않는다', async () => {
|
|
65
|
+
const store = makeStore(irreversible())
|
|
66
|
+
let called = false
|
|
67
|
+
|
|
68
|
+
await assert.rejects(
|
|
69
|
+
dispatch(store, DOMAIN, 'cmd-1', async () => {
|
|
70
|
+
called = true
|
|
71
|
+
return { ok: true }
|
|
72
|
+
}),
|
|
73
|
+
/승인되지 않은/
|
|
74
|
+
)
|
|
75
|
+
assert.equal(called, false, '어댑터가 불렸다 — 게이트가 서지 않았다')
|
|
76
|
+
assert.equal(store.rows.get('cmd-1')!.state, 'proposed', '상태가 움직였다')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('★ 상태만 approved 로 적고 승인 기록이 없으면 막는다 — 저장본을 손으로 고쳐도 열리지 않는다', async () => {
|
|
80
|
+
const store = makeStore(irreversible({ state: 'approved' }))
|
|
81
|
+
let called = false
|
|
82
|
+
await assert.rejects(
|
|
83
|
+
dispatch(store, DOMAIN, 'cmd-1', async () => {
|
|
84
|
+
called = true
|
|
85
|
+
return { ok: true }
|
|
86
|
+
}),
|
|
87
|
+
/승인 기록이 없는/
|
|
88
|
+
)
|
|
89
|
+
assert.equal(called, false)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('★ 승인하면 나간다 — 어댑터가 돌려준 것이 남는다', async () => {
|
|
93
|
+
const store = makeStore(irreversible())
|
|
94
|
+
await approve(store, DOMAIN, 'cmd-1', APPROVAL)
|
|
95
|
+
const settled = await dispatch(store, DOMAIN, 'cmd-1', ok('JOB-77'))
|
|
96
|
+
|
|
97
|
+
assert.equal(settled.state, 'acked')
|
|
98
|
+
assert.equal(settled.dispatchRef, 'JOB-77', '되돌릴 때 무엇을 되돌릴지 말할 수 없게 된다')
|
|
99
|
+
assert.equal(store.rows.get('cmd-1')!.approval?.by, 'line-lead')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('★ 넘기기 전에 「넘기는 중」을 먼저 적는다 — 그 사이에 죽으면 현장이 두 번 움직인다', async () => {
|
|
103
|
+
const store = makeStore(irreversible())
|
|
104
|
+
await approve(store, DOMAIN, 'cmd-1', APPROVAL)
|
|
105
|
+
|
|
106
|
+
let seenWhileDispatching: string | undefined
|
|
107
|
+
await dispatch(store, DOMAIN, 'cmd-1', async () => {
|
|
108
|
+
seenWhileDispatching = store.rows.get('cmd-1')!.state
|
|
109
|
+
return { ok: true, ref: 'x' }
|
|
110
|
+
})
|
|
111
|
+
assert.equal(seenWhileDispatching, 'dispatched', '어댑터를 부르는 동안 저장본이 아직 approved 였다')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
/* ── 되돌릴 수 있는 것의 다른 문 ─────────────────────────────────────────── */
|
|
115
|
+
|
|
116
|
+
test('★ 되돌릴 수 있는 조치는 사람 없이 지난다 — 시뮬과 what-if 가 이 길이다', async () => {
|
|
117
|
+
const store = makeStore(irreversible({ reversible: true }))
|
|
118
|
+
const settled = await dispatch(store, DOMAIN, 'cmd-1', ok())
|
|
119
|
+
assert.equal(settled.state, 'acked')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('★ 되돌릴 수 있다고 적지 않은 것은 그 문으로 못 간다 — 모름을 통과로 읽지 않는다', async () => {
|
|
123
|
+
const store = makeStore(irreversible({ reversible: false }))
|
|
124
|
+
await assert.rejects(dispatch(store, DOMAIN, 'cmd-1', ok()))
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
/* ── 실패와 되풀이 ────────────────────────────────────────────────────────── */
|
|
128
|
+
|
|
129
|
+
test('★ 어댑터가 던지면 실패로 남고 이유가 적힌다', async () => {
|
|
130
|
+
const store = makeStore(irreversible())
|
|
131
|
+
await approve(store, DOMAIN, 'cmd-1', APPROVAL)
|
|
132
|
+
const settled = await dispatch(store, DOMAIN, 'cmd-1', async () => {
|
|
133
|
+
throw new Error('MES 가 응답하지 않는다')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
assert.equal(settled.state, 'failed')
|
|
137
|
+
assert.match(String(settled.error), /응답하지 않는다/)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('어댑터가 이유 없이 실패해도 빈 칸으로 두지 않는다', async () => {
|
|
141
|
+
const store = makeStore(irreversible())
|
|
142
|
+
await approve(store, DOMAIN, 'cmd-1', APPROVAL)
|
|
143
|
+
const settled = await dispatch(store, DOMAIN, 'cmd-1', async () => ({ ok: false }))
|
|
144
|
+
assert.ok(String(settled.error).length > 0)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('★ 실패한 것은 다시 넘길 수 있다 — 승인을 다시 받지 않는다', async () => {
|
|
148
|
+
const store = makeStore(irreversible())
|
|
149
|
+
await approve(store, DOMAIN, 'cmd-1', APPROVAL)
|
|
150
|
+
await dispatch(store, DOMAIN, 'cmd-1', async () => ({ ok: false, error: '잠시 끊겼다' }))
|
|
151
|
+
|
|
152
|
+
const retried = await dispatch(store, DOMAIN, 'cmd-1', ok('JOB-9'))
|
|
153
|
+
assert.equal(retried.state, 'acked')
|
|
154
|
+
assert.equal(retried.dispatchRef, 'JOB-9')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('★ 이미 답을 받은 것은 다시 나가지 않는다 — 두 번 나가면 현장이 두 번 움직인다', async () => {
|
|
158
|
+
const store = makeStore(irreversible())
|
|
159
|
+
await approve(store, DOMAIN, 'cmd-1', APPROVAL)
|
|
160
|
+
await dispatch(store, DOMAIN, 'cmd-1', ok())
|
|
161
|
+
|
|
162
|
+
await assert.rejects(dispatch(store, DOMAIN, 'cmd-1', ok()), /넘길 수 없다/)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
/* ── 거절 ─────────────────────────────────────────────────────────────────── */
|
|
166
|
+
|
|
167
|
+
test('★ 거절하면 끝이다 — 되살아나지 않는다', async () => {
|
|
168
|
+
const store = makeStore(irreversible())
|
|
169
|
+
await reject(store, DOMAIN, 'cmd-1', { by: 'line-lead', at: AT, note: '지금 그 라인은 정지 중' })
|
|
170
|
+
|
|
171
|
+
assert.equal(store.rows.get('cmd-1')!.state, 'rejected')
|
|
172
|
+
await assert.rejects(approve(store, DOMAIN, 'cmd-1', APPROVAL))
|
|
173
|
+
await assert.rejects(dispatch(store, DOMAIN, 'cmd-1', ok()))
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('승인을 기다리는 중에도 거절할 수 있다', async () => {
|
|
177
|
+
const store = makeStore(irreversible())
|
|
178
|
+
await approve(store, DOMAIN, 'cmd-1', APPROVAL)
|
|
179
|
+
await reject(store, DOMAIN, 'cmd-1', { by: 'plant-manager', at: AT })
|
|
180
|
+
assert.equal(store.rows.get('cmd-1')!.state, 'rejected')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
/* ── 재기동 ───────────────────────────────────────────────────────────────── */
|
|
184
|
+
|
|
185
|
+
test('★ 승인이 재기동을 넘어 산다 — 사람이 승인한 것이 사라지면 안 된다', async () => {
|
|
186
|
+
const first = makeStore(irreversible())
|
|
187
|
+
await approve(first, DOMAIN, 'cmd-1', APPROVAL)
|
|
188
|
+
const saved: TwinCommand = JSON.parse(JSON.stringify(first.rows.get('cmd-1')))
|
|
189
|
+
|
|
190
|
+
/* 프로세스가 새로 선 것으로 본다 — 앞의 저장소 객체는 버리고 저장본만 물려준다. */
|
|
191
|
+
const second = makeStore(saved)
|
|
192
|
+
const settled = await dispatch(second, DOMAIN, 'cmd-1', ok('JOB-3'))
|
|
193
|
+
|
|
194
|
+
assert.equal(settled.state, 'acked', '이어받지 못하면 승인이 사라져 던진다')
|
|
195
|
+
assert.equal(settled.approval?.by, 'line-lead')
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
test('★ 재기동이 게이트를 열지 않는다 — 되세운 것도 같은 문을 지난다', async () => {
|
|
199
|
+
/* JSON 에는 승인 표식이 없다. 되세울 때 표식을 그냥 붙이면 재기동이 곧 우회로가 된다. */
|
|
200
|
+
const saved: TwinCommand = JSON.parse(JSON.stringify(irreversible()))
|
|
201
|
+
const store = makeStore(saved)
|
|
202
|
+
await assert.rejects(dispatch(store, DOMAIN, 'cmd-1', ok()), /승인되지 않은/)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
test('모르는 커맨드는 조용히 넘어가지 않는다', async () => {
|
|
206
|
+
const store = makeStore(irreversible())
|
|
207
|
+
await assert.rejects(dispatch(store, DOMAIN, 'nope', ok()), /모르는 커맨드/)
|
|
208
|
+
await assert.rejects(approve(store, DOMAIN, 'nope', APPROVAL), /모르는 커맨드/)
|
|
209
|
+
})
|