@things-factory/headless-twin 10.0.19 → 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.
Files changed (327) hide show
  1. package/dist-server/engine/canonical-ingest.d.ts +10 -1
  2. package/dist-server/engine/canonical-ingest.js +110 -33
  3. package/dist-server/engine/canonical-ingest.js.map +1 -1
  4. package/dist-server/engine/event-time.d.ts +25 -0
  5. package/dist-server/engine/event-time.js +98 -0
  6. package/dist-server/engine/event-time.js.map +1 -0
  7. package/dist-server/engine/index.d.ts +2 -0
  8. package/dist-server/engine/index.js +23 -0
  9. package/dist-server/engine/index.js.map +1 -1
  10. package/dist-server/engine/ingest-dedupe.d.ts +18 -6
  11. package/dist-server/engine/ingest-dedupe.js +201 -15
  12. package/dist-server/engine/ingest-dedupe.js.map +1 -1
  13. package/dist-server/engine/ingest-health.d.ts +60 -1
  14. package/dist-server/engine/ingest-health.js +43 -8
  15. package/dist-server/engine/ingest-health.js.map +1 -1
  16. package/dist-server/engine/kpi-fold.d.ts +19 -0
  17. package/dist-server/engine/kpi-fold.js +4 -1
  18. package/dist-server/engine/kpi-fold.js.map +1 -1
  19. package/dist-server/engine/kpi-query.js +23 -3
  20. package/dist-server/engine/kpi-query.js.map +1 -1
  21. package/dist-server/engine/oee-accumulator.d.ts +35 -2
  22. package/dist-server/engine/oee-accumulator.js +56 -4
  23. package/dist-server/engine/oee-accumulator.js.map +1 -1
  24. package/dist-server/engine/twin-engine.d.ts +72 -3
  25. package/dist-server/engine/twin-engine.js +118 -5
  26. package/dist-server/engine/twin-engine.js.map +1 -1
  27. package/dist-server/index.js +21 -0
  28. package/dist-server/index.js.map +1 -1
  29. package/dist-server/migrations/1786600000000-RenameOperatoMesAdapterType.d.ts +5 -0
  30. package/dist-server/migrations/1786600000000-RenameOperatoMesAdapterType.js +66 -0
  31. package/dist-server/migrations/1786600000000-RenameOperatoMesAdapterType.js.map +1 -0
  32. package/dist-server/migrations/1786700000000-AddDispatchRetryColumns.d.ts +5 -0
  33. package/dist-server/migrations/1786700000000-AddDispatchRetryColumns.js +79 -0
  34. package/dist-server/migrations/1786700000000-AddDispatchRetryColumns.js.map +1 -0
  35. package/dist-server/migrations/index.js +5 -1
  36. package/dist-server/migrations/index.js.map +1 -1
  37. package/dist-server/routes.js +12 -20
  38. package/dist-server/routes.js.map +1 -1
  39. package/dist-server/service/actuation/actuation-advisor.d.ts +60 -0
  40. package/dist-server/service/actuation/actuation-advisor.js +53 -0
  41. package/dist-server/service/actuation/actuation-advisor.js.map +1 -0
  42. package/dist-server/service/actuation/actuation-outcome.d.ts +52 -0
  43. package/dist-server/service/actuation/actuation-outcome.js +34 -0
  44. package/dist-server/service/actuation/actuation-outcome.js.map +1 -0
  45. package/dist-server/service/actuation/actuation-rule-resolver.d.ts +19 -0
  46. package/dist-server/service/actuation/actuation-rule-resolver.js +161 -0
  47. package/dist-server/service/actuation/actuation-rule-resolver.js.map +1 -0
  48. package/dist-server/service/actuation/actuation-rule.d.ts +55 -0
  49. package/dist-server/service/actuation/actuation-rule.js +130 -0
  50. package/dist-server/service/actuation/actuation-rule.js.map +1 -0
  51. package/dist-server/service/actuation/approval-gateway.d.ts +20 -0
  52. package/dist-server/service/actuation/approval-gateway.js +14 -0
  53. package/dist-server/service/actuation/approval-gateway.js.map +1 -0
  54. package/dist-server/service/actuation/catch-up-effects.d.ts +16 -0
  55. package/dist-server/service/actuation/catch-up-effects.js +120 -0
  56. package/dist-server/service/actuation/catch-up-effects.js.map +1 -0
  57. package/dist-server/service/actuation/command-dispatcher.d.ts +206 -8
  58. package/dist-server/service/actuation/command-dispatcher.js +191 -14
  59. package/dist-server/service/actuation/command-dispatcher.js.map +1 -1
  60. package/dist-server/service/actuation/command-store.d.ts +10 -2
  61. package/dist-server/service/actuation/command-store.js +168 -5
  62. package/dist-server/service/actuation/command-store.js.map +1 -1
  63. package/dist-server/service/actuation/dispatch-after-approval.d.ts +11 -0
  64. package/dist-server/service/actuation/dispatch-after-approval.js +73 -0
  65. package/dist-server/service/actuation/dispatch-after-approval.js.map +1 -0
  66. package/dist-server/service/actuation/effect-match.d.ts +53 -0
  67. package/dist-server/service/actuation/effect-match.js +65 -0
  68. package/dist-server/service/actuation/effect-match.js.map +1 -0
  69. package/dist-server/service/actuation/index.d.ts +21 -2
  70. package/dist-server/service/actuation/index.js +21 -2
  71. package/dist-server/service/actuation/index.js.map +1 -1
  72. package/dist-server/service/actuation/retry-plan.d.ts +63 -0
  73. package/dist-server/service/actuation/retry-plan.js +99 -0
  74. package/dist-server/service/actuation/retry-plan.js.map +1 -0
  75. package/dist-server/service/actuation/rule-evaluate.d.ts +127 -0
  76. package/dist-server/service/actuation/rule-evaluate.js +205 -0
  77. package/dist-server/service/actuation/rule-evaluate.js.map +1 -0
  78. package/dist-server/service/actuation/rule-loop.d.ts +5 -0
  79. package/dist-server/service/actuation/rule-loop.js +156 -0
  80. package/dist-server/service/actuation/rule-loop.js.map +1 -0
  81. package/dist-server/service/actuation/rule-runner.d.ts +52 -0
  82. package/dist-server/service/actuation/rule-runner.js +135 -0
  83. package/dist-server/service/actuation/rule-runner.js.map +1 -0
  84. package/dist-server/service/actuation/rule-wiring.d.ts +24 -0
  85. package/dist-server/service/actuation/rule-wiring.js +80 -0
  86. package/dist-server/service/actuation/rule-wiring.js.map +1 -0
  87. package/dist-server/service/actuation/settle-effects.d.ts +21 -0
  88. package/dist-server/service/actuation/settle-effects.js +101 -0
  89. package/dist-server/service/actuation/settle-effects.js.map +1 -0
  90. package/dist-server/service/actuation/sweep-dispatch.d.ts +37 -0
  91. package/dist-server/service/actuation/sweep-dispatch.js +125 -0
  92. package/dist-server/service/actuation/sweep-dispatch.js.map +1 -0
  93. package/dist-server/service/actuation/twin-command-resolver.d.ts +120 -0
  94. package/dist-server/service/actuation/twin-command-resolver.js +406 -0
  95. package/dist-server/service/actuation/twin-command-resolver.js.map +1 -0
  96. package/dist-server/service/actuation/twin-command.d.ts +81 -0
  97. package/dist-server/service/actuation/twin-command.js +60 -0
  98. package/dist-server/service/actuation/twin-command.js.map +1 -1
  99. package/dist-server/service/actuation/twin-now.d.ts +41 -0
  100. package/dist-server/service/actuation/twin-now.js +65 -0
  101. package/dist-server/service/actuation/twin-now.js.map +1 -0
  102. package/dist-server/service/index.d.ts +5 -2
  103. package/dist-server/service/index.js +8 -0
  104. package/dist-server/service/index.js.map +1 -1
  105. package/dist-server/service/reference/actuation-routing.d.ts +60 -0
  106. package/dist-server/service/reference/actuation-routing.js +126 -0
  107. package/dist-server/service/reference/actuation-routing.js.map +1 -0
  108. package/dist-server/service/reference/actuation-target.d.ts +65 -0
  109. package/dist-server/service/reference/actuation-target.js +94 -0
  110. package/dist-server/service/reference/actuation-target.js.map +1 -0
  111. package/dist-server/service/reference/connection-portability-resolver.d.ts +29 -0
  112. package/dist-server/service/reference/connection-portability-resolver.js +181 -0
  113. package/dist-server/service/reference/connection-portability-resolver.js.map +1 -0
  114. package/dist-server/service/reference/connection-portability.d.ts +138 -0
  115. package/dist-server/service/reference/connection-portability.js +230 -0
  116. package/dist-server/service/reference/connection-portability.js.map +1 -0
  117. package/dist-server/service/reference/fill-cadence.d.ts +17 -0
  118. package/dist-server/service/reference/fill-cadence.js +31 -0
  119. package/dist-server/service/reference/fill-cadence.js.map +1 -0
  120. package/dist-server/service/reference/fill-dedupe.d.ts +16 -0
  121. package/dist-server/service/reference/fill-dedupe.js +63 -0
  122. package/dist-server/service/reference/fill-dedupe.js.map +1 -0
  123. package/dist-server/service/reference/fill-loop.d.ts +35 -0
  124. package/dist-server/service/reference/fill-loop.js +187 -0
  125. package/dist-server/service/reference/fill-loop.js.map +1 -0
  126. package/dist-server/service/reference/fill-span.d.ts +10 -0
  127. package/dist-server/service/reference/fill-span.js +37 -0
  128. package/dist-server/service/reference/fill-span.js.map +1 -0
  129. package/dist-server/service/reference/hook-contract.d.ts +88 -4
  130. package/dist-server/service/reference/hook-contract.js +105 -4
  131. package/dist-server/service/reference/hook-contract.js.map +1 -1
  132. package/dist-server/service/reference/hook-rejected.d.ts +25 -0
  133. package/dist-server/service/reference/hook-rejected.js +48 -0
  134. package/dist-server/service/reference/hook-rejected.js.map +1 -0
  135. package/dist-server/service/reference/index.d.ts +4 -1
  136. package/dist-server/service/reference/index.js +5 -1
  137. package/dist-server/service/reference/index.js.map +1 -1
  138. package/dist-server/service/reference/live-cadence.d.ts +68 -0
  139. package/dist-server/service/reference/live-cadence.js +44 -0
  140. package/dist-server/service/reference/live-cadence.js.map +1 -0
  141. package/dist-server/service/reference/live-feed-lease.d.ts +15 -0
  142. package/dist-server/service/reference/live-feed-lease.js +56 -0
  143. package/dist-server/service/reference/live-feed-lease.js.map +1 -0
  144. package/dist-server/service/reference/live-ingest.d.ts +21 -0
  145. package/dist-server/service/reference/live-ingest.js +66 -0
  146. package/dist-server/service/reference/live-ingest.js.map +1 -0
  147. package/dist-server/service/reference/reference-adapter.d.ts +291 -2
  148. package/dist-server/service/reference/reference-adapter.js +19 -1
  149. package/dist-server/service/reference/reference-adapter.js.map +1 -1
  150. package/dist-server/service/reference/reference-fill-run.d.ts +50 -0
  151. package/dist-server/service/reference/reference-fill-run.js +142 -0
  152. package/dist-server/service/reference/reference-fill-run.js.map +1 -0
  153. package/dist-server/service/reference/reference-fill.d.ts +90 -0
  154. package/dist-server/service/reference/reference-fill.js +141 -0
  155. package/dist-server/service/reference/reference-fill.js.map +1 -0
  156. package/dist-server/service/reference/reference-hook.d.ts +22 -0
  157. package/dist-server/service/reference/reference-hook.js +135 -55
  158. package/dist-server/service/reference/reference-hook.js.map +1 -1
  159. package/dist-server/service/reference/reference-link-groups.d.ts +10 -0
  160. package/dist-server/service/reference/reference-link-groups.js +15 -0
  161. package/dist-server/service/reference/reference-link-groups.js.map +1 -0
  162. package/dist-server/service/reference/reference-links.d.ts +14 -0
  163. package/dist-server/service/reference/reference-links.js +73 -0
  164. package/dist-server/service/reference/reference-links.js.map +1 -0
  165. package/dist-server/service/reference/reference-live.d.ts +1 -0
  166. package/dist-server/service/reference/reference-live.js +32 -6
  167. package/dist-server/service/reference/reference-live.js.map +1 -1
  168. package/dist-server/service/reference/reference-master.d.ts +56 -2
  169. package/dist-server/service/reference/reference-master.js +33 -3
  170. package/dist-server/service/reference/reference-master.js.map +1 -1
  171. package/dist-server/service/reference/reference-resolver.d.ts +27 -0
  172. package/dist-server/service/reference/reference-resolver.js +79 -1
  173. package/dist-server/service/reference/reference-resolver.js.map +1 -1
  174. package/dist-server/service/twin-event/twin-event-keys.d.ts +31 -3
  175. package/dist-server/service/twin-event/twin-event-keys.js +36 -4
  176. package/dist-server/service/twin-event/twin-event-keys.js.map +1 -1
  177. package/dist-server/service/twin-event/twin-event.d.ts +1 -0
  178. package/dist-server/service/twin-event/twin-event.js +11 -2
  179. package/dist-server/service/twin-event/twin-event.js.map +1 -1
  180. package/dist-server/service/twin-model/choose-recipe.d.ts +15 -0
  181. package/dist-server/service/twin-model/choose-recipe.js +33 -0
  182. package/dist-server/service/twin-model/choose-recipe.js.map +1 -0
  183. package/dist-server/service/twin-model/external-resolver.d.ts +76 -0
  184. package/dist-server/service/twin-model/external-resolver.js +109 -0
  185. package/dist-server/service/twin-model/external-resolver.js.map +1 -0
  186. package/dist-server/service/twin-model/project-structure.js +16 -3
  187. package/dist-server/service/twin-model/project-structure.js.map +1 -1
  188. package/dist-server/service/twin-model/twin-equipment.d.ts +17 -0
  189. package/dist-server/service/twin-model/twin-equipment.js +10 -0
  190. package/dist-server/service/twin-model/twin-equipment.js.map +1 -1
  191. package/dist-server/service/twin-model/twin-lineage-query.js +75 -1
  192. package/dist-server/service/twin-model/twin-lineage-query.js.map +1 -1
  193. package/dist-server/service/twin-model/twin-model-item-query.js +94 -5
  194. package/dist-server/service/twin-model/twin-model-item-query.js.map +1 -1
  195. package/dist-server/service/twin-model/twin-model-tree-query.js.map +1 -1
  196. package/dist-server/service/twin-space/twin-space.d.ts +29 -0
  197. package/dist-server/service/twin-space/twin-space.js +13 -0
  198. package/dist-server/service/twin-space/twin-space.js.map +1 -1
  199. package/dist-server/service/twin-subject/event-subjects.d.ts +3 -1
  200. package/dist-server/service/twin-subject/event-subjects.js +8 -1
  201. package/dist-server/service/twin-subject/event-subjects.js.map +1 -1
  202. package/dist-shared/entity-delta.js +1 -1
  203. package/dist-shared/entity-delta.js.map +1 -1
  204. package/package.json +7 -7
  205. package/server/engine/canonical-ingest.ts +119 -33
  206. package/server/engine/event-time.ts +96 -0
  207. package/server/engine/index.ts +20 -0
  208. package/server/engine/ingest-dedupe.ts +203 -27
  209. package/server/engine/ingest-health.ts +91 -6
  210. package/server/engine/kpi-fold.ts +24 -1
  211. package/server/engine/kpi-query.ts +23 -4
  212. package/server/engine/oee-accumulator.ts +80 -8
  213. package/server/engine/twin-engine.ts +119 -5
  214. package/server/index.ts +23 -0
  215. package/server/migrations/1786600000000-RenameOperatoMesAdapterType.ts +66 -0
  216. package/server/migrations/1786700000000-AddDispatchRetryColumns.ts +82 -0
  217. package/server/migrations/index.ts +5 -1
  218. package/server/routes.ts +12 -24
  219. package/server/service/actuation/actuation-advisor.ts +124 -0
  220. package/server/service/actuation/actuation-outcome.ts +97 -0
  221. package/server/service/actuation/actuation-rule-resolver.ts +147 -0
  222. package/server/service/actuation/actuation-rule.ts +139 -0
  223. package/server/service/actuation/approval-gateway.ts +44 -0
  224. package/server/service/actuation/catch-up-effects.ts +152 -0
  225. package/server/service/actuation/command-dispatcher.ts +401 -20
  226. package/server/service/actuation/command-store.ts +182 -8
  227. package/server/service/actuation/dispatch-after-approval.ts +71 -0
  228. package/server/service/actuation/effect-match.ts +124 -0
  229. package/server/service/actuation/index.ts +21 -2
  230. package/server/service/actuation/retry-plan.ts +145 -0
  231. package/server/service/actuation/rule-evaluate.ts +352 -0
  232. package/server/service/actuation/rule-loop.ts +160 -0
  233. package/server/service/actuation/rule-runner.ts +216 -0
  234. package/server/service/actuation/rule-wiring.ts +84 -0
  235. package/server/service/actuation/settle-effects.ts +115 -0
  236. package/server/service/actuation/sweep-dispatch.ts +179 -0
  237. package/server/service/actuation/twin-command-resolver.ts +387 -0
  238. package/server/service/actuation/twin-command.ts +128 -1
  239. package/server/service/actuation/twin-now.ts +97 -0
  240. package/server/service/index.ts +9 -1
  241. package/server/service/reference/actuation-routing.ts +165 -0
  242. package/server/service/reference/actuation-target.ts +163 -0
  243. package/server/service/reference/connection-portability-resolver.ts +171 -0
  244. package/server/service/reference/connection-portability.ts +285 -0
  245. package/server/service/reference/fill-cadence.ts +29 -0
  246. package/server/service/reference/fill-dedupe.ts +66 -0
  247. package/server/service/reference/fill-loop.ts +153 -0
  248. package/server/service/reference/fill-span.ts +31 -0
  249. package/server/service/reference/hook-contract.ts +185 -5
  250. package/server/service/reference/hook-rejected.ts +53 -0
  251. package/server/service/reference/index.ts +5 -1
  252. package/server/service/reference/live-cadence.ts +129 -0
  253. package/server/service/reference/live-feed-lease.ts +56 -0
  254. package/server/service/reference/live-ingest.ts +94 -0
  255. package/server/service/reference/reference-adapter.ts +327 -5
  256. package/server/service/reference/reference-fill-run.ts +165 -0
  257. package/server/service/reference/reference-fill.ts +223 -0
  258. package/server/service/reference/reference-hook.ts +162 -56
  259. package/server/service/reference/reference-link-groups.ts +21 -0
  260. package/server/service/reference/reference-links.ts +77 -0
  261. package/server/service/reference/reference-live.ts +30 -5
  262. package/server/service/reference/reference-master.ts +90 -5
  263. package/server/service/reference/reference-resolver.ts +77 -2
  264. package/server/service/twin-event/twin-event-keys.ts +37 -4
  265. package/server/service/twin-event/twin-event.ts +18 -1
  266. package/server/service/twin-model/choose-recipe.ts +31 -0
  267. package/server/service/twin-model/external-resolver.ts +161 -0
  268. package/server/service/twin-model/project-structure.ts +16 -3
  269. package/server/service/twin-model/twin-equipment.ts +22 -0
  270. package/server/service/twin-model/twin-lineage-query.ts +82 -1
  271. package/server/service/twin-model/twin-model-item-query.ts +95 -4
  272. package/server/service/twin-model/twin-model-tree-query.ts +1 -1
  273. package/server/service/twin-space/twin-space.ts +39 -0
  274. package/server/service/twin-subject/event-subjects.ts +8 -1
  275. package/shared/entity-delta.ts +1 -1
  276. package/test/ack-shape-alignment.test.ts +97 -0
  277. package/test/actuation-approval-door.test.ts +421 -0
  278. package/test/actuation-dispatch.test.ts +16 -3
  279. package/test/actuation-effect.test.ts +162 -0
  280. package/test/actuation-outcome.test.ts +152 -0
  281. package/test/actuation-rule-runner.test.ts +281 -0
  282. package/test/actuation-rule.test.ts +266 -0
  283. package/test/actuation-seam.test.ts +249 -0
  284. package/test/actuation-settle.test.ts +164 -0
  285. package/test/actuation-target.test.ts +145 -0
  286. package/test/canonical-ingest-vocabularies.test.ts +49 -2
  287. package/test/capability-mapping.test.ts +1 -1
  288. package/test/choose-recipe.test.ts +117 -0
  289. package/test/command-store-writes-columns.test.ts +126 -0
  290. package/test/connection-portability-doors.test.ts +87 -0
  291. package/test/connector-capability-declaration.test.ts +54 -4
  292. package/test/declaration-reaches-model.test.ts +43 -0
  293. package/test/dedupe-transformation.test.ts +112 -0
  294. package/test/entity-delta.test.ts +1 -1
  295. package/test/event-subjects.test.ts +2 -1
  296. package/test/external-resolver.test.ts +171 -0
  297. package/test/fill-dedupe.test.ts +59 -0
  298. package/test/fill-loop.test.ts +35 -0
  299. package/test/first-envelope.test.ts +153 -0
  300. package/test/fold-shift-dst.test.ts +110 -0
  301. package/test/home-in-area.test.ts +90 -0
  302. package/test/hook-rejected-shape.test.ts +86 -0
  303. package/test/hook-response-sum.test.ts +193 -0
  304. package/test/hook-sequence.test.ts +1 -1
  305. package/test/ingest-dedupe.test.ts +56 -0
  306. package/test/ingest-expected-quiet.test.ts +111 -0
  307. package/test/ingest-idempotent.test.ts +79 -0
  308. package/test/ingest-reconcile-four-ways.test.ts +97 -0
  309. package/test/ingest-rules-header.test.ts +100 -0
  310. package/test/ingest-window-rolls-unobserved.test.ts +72 -0
  311. package/test/intake-mapping-parity.test.ts +175 -0
  312. package/test/live-cadence.test.ts +170 -0
  313. package/test/live-feed-lease.test.ts +63 -0
  314. package/test/live-mirror-parity.test.ts +1 -1
  315. package/test/oee-accumulator.test.ts +3 -3
  316. package/test/oee-site-calendar.test.ts +146 -0
  317. package/test/order-and-transaction-split.test.ts +107 -0
  318. package/test/reference-fill.test.ts +235 -0
  319. package/test/reference-hook.test.ts +1 -1
  320. package/test/reference-links.test.ts +56 -0
  321. package/test/retry-plan.test.ts +139 -0
  322. package/test/sweep-dispatch.test.ts +191 -0
  323. package/test/twin-event-keys.test.ts +20 -6
  324. package/test/twin-model-item-db.test.ts +6 -6
  325. package/test/twin-model-tree-db.test.ts +6 -6
  326. package/tsconfig.shared.tsbuildinfo +1 -1
  327. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,164 @@
1
+ /*
2
+ * **폐루프의 배선** — 관측이 들어올 때 나간 지시와 실제로 이어지는가.
3
+ *
4
+ * 판정은 `actuation-effect.test.ts` 가 지킨다. 여기서 보는 것은 **그 판정이 유입 경로에 서 있는가**다
5
+ * — 봉투에서 오더 보고를 골라내는가, 저장소에 적히는가, 실패해도 관측 유입이 멈추지 않는가.
6
+ *
7
+ * 마지막 것이 특히 중요하다. 효과를 적다 실패해서 유입이 멈추면 **고치는 동안 트윈이 눈을 감는다.**
8
+ * 효과는 다음 보고에 다시 이을 수 있고 관측은 다시 오지 않는다.
9
+ */
10
+ import { test } from 'node:test'
11
+ import assert from 'node:assert/strict'
12
+
13
+ import { OP_EVENT } from '@operato/ops-contract'
14
+
15
+ import type { CommandStore, StoredCommand } from '../server/service/actuation/command-dispatcher.js'
16
+ import { orderObservationsIn, settleEffects } from '../server/service/actuation/settle-effects.js'
17
+
18
+ const DOMAIN = 'd-1'
19
+ const AT = '2026-09-02T09:00:00.000Z'
20
+
21
+ /**
22
+ * 실측한 봉투 모양 그대로 — 훅과 라이브가 같은 것을 낸다.
23
+ *
24
+ * `data` 를 따로 뺀다. 처음에 `...over` 를 뒤에 두었더니 병합한 `data` 를 통째로 덮어써서 `orderId`
25
+ * 가 사라졌고, **시험이 이을 것이 없는 봉투로 판정을 부르고 있었다.**
26
+ */
27
+ const orderEnv = ({ data, ...over }: Record<string, any> = {}) => ({
28
+ eventId: `e-${Math.random()}`,
29
+ eventType: OP_EVENT.order,
30
+ eventTime: AT,
31
+ tenantId: DOMAIN,
32
+ ...over,
33
+ data: { orderId: 'WO-20260902-005', kind: 'workorder', status: 'released', ...(data ?? {}) }
34
+ })
35
+
36
+ const otherEnv = () => ({ eventId: 'x', eventType: OP_EVENT.equipment, eventTime: AT, tenantId: DOMAIN, data: {} })
37
+
38
+ function makeStore(seed: Partial<StoredCommand>[] = []) {
39
+ const rows = seed.map(c => ({ id: 'cmd-1', instanceId: 'twin-a', type: 'production.order', state: 'acked', ...c }) as any)
40
+ const written: any[] = []
41
+ const store: CommandStore & { written: any[]; queries: string[][] } = {
42
+ written,
43
+ queries: [],
44
+ load: async () => null,
45
+ save: async () => {},
46
+ create: async (_d, c) => c,
47
+ byDispatchRef: async (_d, refs) => {
48
+ store.queries.push([...refs])
49
+ return new Map(rows.filter(r => r.dispatchRef && refs.includes(r.dispatchRef)).map(r => [r.dispatchRef, r]))
50
+ },
51
+ recordEffects: async (_d, updates) => void written.push(...updates)
52
+ }
53
+ return store
54
+ }
55
+
56
+ /* ── 봉투에서 골라낸다 ──────────────────────────────────────────────────────── */
57
+
58
+ test('★ 오더 보고만 골라낸다 — 다른 어휘를 오더로 읽지 않는다', () => {
59
+ const out = orderObservationsIn([orderEnv(), otherEnv(), orderEnv({ data: { orderId: 'WO-2' } })])
60
+
61
+ assert.equal(out.length, 2)
62
+ assert.deepEqual(out.map(o => o.orderId).sort(), ['WO-2', 'WO-20260902-005'])
63
+ })
64
+
65
+ test('★ 시각은 봉투의 것을 쓴다 — 우리가 처리한 시각이 아니다', () => {
66
+ const out = orderObservationsIn([orderEnv({ eventTime: '2026-08-31T01:02:03.000Z' })])
67
+
68
+ assert.equal(out[0]!.at, '2026-08-31T01:02:03.000Z')
69
+ })
70
+
71
+ test('식별자 없는 보고는 골라내지 않는다 — 이을 수 없는 것을 판정에 넘기지 않는다', () => {
72
+ assert.deepEqual(orderObservationsIn([orderEnv({ data: { orderId: '' } })]), [])
73
+ })
74
+
75
+ test('봉투가 없어도 터지지 않는다 — 유입 경로에서 부른다', () => {
76
+ assert.deepEqual(orderObservationsIn(undefined as any), [])
77
+ assert.deepEqual(orderObservationsIn([null, undefined] as any), [])
78
+ })
79
+
80
+ /* ── 실제로 적힌다 ─────────────────────────────────────────────────────────── */
81
+
82
+ test('★ 우리가 낸 지시의 보고가 저장소에 적힌다 — 이것이 폐루프가 닫히는 순간이다', async () => {
83
+ const store = makeStore([{ id: 'cmd-1', dispatchRef: 'WO-20260902-005' }])
84
+ const out = await settleEffects({ domainId: DOMAIN, envelopes: [orderEnv()], store })
85
+
86
+ assert.equal(out.recorded, 1)
87
+ assert.deepEqual(store.written, [{ commandId: 'cmd-1', status: 'released', at: AT, seen: 1 }])
88
+ })
89
+
90
+ test('★ 오더 보고가 없는 묶음은 저장소를 건드리지 않는다 — 유입은 초당 수천 건이 지난다', async () => {
91
+ const store = makeStore([{ dispatchRef: 'WO-20260902-005' }])
92
+ const out = await settleEffects({ domainId: DOMAIN, envelopes: [otherEnv(), otherEnv()], store })
93
+
94
+ assert.deepEqual(store.queries, [], '오더가 없는데 저장소에 물었다')
95
+ assert.equal(out.recorded, 0)
96
+ })
97
+
98
+ test('★ 찾아볼 식별자만 묶어서 한 번 묻는다', async () => {
99
+ const store = makeStore([{ dispatchRef: 'WO-1' }])
100
+ await settleEffects({
101
+ domainId: DOMAIN,
102
+ envelopes: [orderEnv({ data: { orderId: 'WO-1' } }), orderEnv({ data: { orderId: 'WO-1' } }), orderEnv({ data: { orderId: 'WO-2' } })],
103
+ store
104
+ })
105
+
106
+ assert.equal(store.queries.length, 1, '보고마다 질의를 붙였다')
107
+ assert.deepEqual(store.queries[0]!.sort(), ['WO-1', 'WO-2'])
108
+ })
109
+
110
+ test('우리가 내지 않은 오더만 온 묶음은 아무것도 적지 않는다', async () => {
111
+ const store = makeStore([{ dispatchRef: 'WO-우리것' }])
112
+ const out = await settleEffects({ domainId: DOMAIN, envelopes: [orderEnv({ data: { orderId: 'WO-남의것' } })], store })
113
+
114
+ assert.deepEqual(store.written, [])
115
+ assert.equal(out.unmatched, 1)
116
+ })
117
+
118
+ test('★ 이미 적힌 효과에서 이어 센다 — 저장소가 든 값을 판정에 넘긴다', async () => {
119
+ const store = makeStore([
120
+ { id: 'cmd-1', dispatchRef: 'WO-20260902-005', effect: { status: 'released', at: AT, seen: 2 } }
121
+ ])
122
+ await settleEffects({ domainId: DOMAIN, envelopes: [orderEnv({ data: { status: 'in-progress' } })], store })
123
+
124
+ assert.equal(store.written[0]!.seen, 3)
125
+ })
126
+
127
+ /* ── 관측 유입을 멈추지 않는다 ─────────────────────────────────────────────── */
128
+
129
+ test('★ 저장소가 터져도 던지지 않는다 — 고치는 동안 트윈이 눈을 감으면 안 된다', async () => {
130
+ const store = makeStore([{ dispatchRef: 'WO-20260902-005' }])
131
+ store.byDispatchRef = async () => {
132
+ throw new Error('연결이 끊겼다')
133
+ }
134
+
135
+ const out = await settleEffects({ domainId: DOMAIN, envelopes: [orderEnv()], store })
136
+ assert.deepEqual(out, { recorded: 0, unmatched: 0 })
137
+ })
138
+
139
+ test('★ 적다가 터져도 던지지 않는다', async () => {
140
+ const store = makeStore([{ dispatchRef: 'WO-20260902-005' }])
141
+ store.recordEffects = async () => {
142
+ throw new Error('쓰다 실패했다')
143
+ }
144
+
145
+ const out = await settleEffects({ domainId: DOMAIN, envelopes: [orderEnv()], store })
146
+ assert.deepEqual(out, { recorded: 0, unmatched: 0 })
147
+ })
148
+
149
+ test('★ 저장소에 이을 자리가 없으면 조용히 지나지 않는다 — 영원히 모르는 상태가 된다', async () => {
150
+ const said: string[] = []
151
+ const warn = console.warn
152
+ console.warn = (...a: unknown[]) => void said.push(a.join(' '))
153
+
154
+ try {
155
+ const store = makeStore([{ dispatchRef: 'WO-20260902-005' }])
156
+ delete (store as any).byDispatchRef
157
+
158
+ const out = await settleEffects({ domainId: DOMAIN, envelopes: [orderEnv()], store })
159
+ assert.equal(out.recorded, 0)
160
+ assert.ok(said.join('\n').includes('byDispatchRef'), '무엇이 없어서인지 말하지 않았다')
161
+ } finally {
162
+ console.warn = warn
163
+ }
164
+ })
@@ -0,0 +1,145 @@
1
+ /*
2
+ * **승인된 조치를 어디로 보낼 수 있나** — 그 판정.
3
+ *
4
+ * 이 판정이 없어서 실제로 끊겨 있었다. 넘기는 리졸버가 어댑터를 찾지도 않고 「구현이 없습니다」를
5
+ * 답했고, MES 커넥터는 그때 이미 `actuate` 를 다 만들어 두었다. 답이 그럴듯해서 설계 문서에도 다른
6
+ * 레인에도 그대로 옮겼다.
7
+ *
8
+ * 그래서 여기서 지키는 것은 둘이다 — **보낼 수 있으면 보내는가**, 그리고 **못 보낼 때 무엇이 없어서
9
+ * 인지 말하는가.** 뒤엣것이 없으면 사람은 커넥터를 다시 만들려 든다.
10
+ */
11
+ import { test } from 'node:test'
12
+ import assert from 'node:assert/strict'
13
+
14
+ import { actuationTarget, isActuationAllowed, type ActuationTargetInput } from '../server/service/reference/actuation-target.js'
15
+
16
+ const input = (over: Partial<ActuationTargetInput> = {}): ActuationTargetInput => ({
17
+ instanceId: 'twin-a',
18
+ twinExists: true,
19
+ siteId: 'plant-a',
20
+ ref: { source: 'twin-a', adapterType: 'operato-plant' },
21
+ adapterRegistered: true,
22
+ capabilities: ['live', 'actuate'],
23
+ declaresActuate: true,
24
+ hasActuateImpl: true,
25
+ ...over
26
+ })
27
+
28
+ /** 거절 코드를 꺼낸다 — 통과했으면 시험을 세운다(캐스팅으로 덮지 않는다). */
29
+ function refusalOf(t: ReturnType<typeof actuationTarget>): string {
30
+ assert.equal(isActuationAllowed(t), false, '거절해야 하는데 통과했다')
31
+ return (t as any).code
32
+ }
33
+
34
+ /* ── 보낼 수 있으면 어디로까지 답한다 ───────────────────────────────────────── */
35
+
36
+ test('★ 선언과 구현이 다 있으면 보낼 수 있다 — 어디로까지 답한다', () => {
37
+ const t = actuationTarget(input())
38
+
39
+ assert.equal(isActuationAllowed(t), true)
40
+ assert.deepEqual(t, { ok: true, siteId: 'plant-a', adapterType: 'operato-plant', source: 'twin-a' })
41
+ })
42
+
43
+ test('사이트 이름의 앞뒤 공백은 다듬는다 — 주소에 그대로 들어가는 값이다', () => {
44
+ const t = actuationTarget(input({ siteId: ' plant-a ' }))
45
+ assert.equal((t as any).siteId, 'plant-a')
46
+ })
47
+
48
+ /* ── 구현만 있고 선언이 없는 경우를 따로 말한다 ────────────────────────────── */
49
+
50
+ test('★ 구현은 있는데 선언이 없으면 그렇게 말한다 — 커넥터를 다시 만들게 하지 않는다', () => {
51
+ /*
52
+ * 실제로 있던 상태다. `operato-plant` 가 `actuate` 를 만들어 두고 `capabilities: ['live','control']` 만
53
+ * 선언했다. 「이 원본은 조치를 받지 않습니다」로 뭉개면 고칠 한 줄을 못 찾는다.
54
+ */
55
+ const t = actuationTarget(input({ capabilities: ['live', 'control'], declaresActuate: false, hasActuateImpl: true }))
56
+
57
+ assert.equal(refusalOf(t), 'not-declared')
58
+ assert.match((t as any).detail, /capabilities/, '무엇을 고쳐야 하는지 말하지 않는다')
59
+ })
60
+
61
+ test('★ 구현도 선언도 없으면 다른 코드로 말한다 — 고칠 것이 다르다', () => {
62
+ const t = actuationTarget(input({ capabilities: ['live'], declaresActuate: false, hasActuateImpl: false }))
63
+
64
+ assert.equal(refusalOf(t), 'not-supported')
65
+ assert.match((t as any).detail, /관측만/)
66
+ })
67
+
68
+ test('★ 선언은 있는데 구현이 없으면 「관측만 냅니다」가 아니다 — 그 사람은 조치를 하겠다고 적었다', () => {
69
+ /*
70
+ * 만들다 만 커넥터다. 등록 검사는 이쪽을 **말하지 않는다** — 구현했는데 선언 안 한 반대 방향만 본다.
71
+ * 그러니 이 자리가 말하지 않으면 아무도 말하지 않고, 사람은 자기가 적어 둔 선언을 믿고 조치가 되는
72
+ * 줄로 읽는다.
73
+ */
74
+ const t = actuationTarget(input({ capabilities: ['live'], declaresActuate: true, hasActuateImpl: false }))
75
+
76
+ assert.equal(refusalOf(t), 'not-implemented')
77
+ assert.doesNotMatch((t as any).detail, /관측만/, '선언한 사람에게 「관측만 냅니다」라고 답했다')
78
+ assert.match((t as any).detail, /actuate/, '무엇을 만들어야 하는지 말하지 않는다')
79
+ })
80
+
81
+ test('낼 수 있나는 능력 판정만 본다 — 선언·구현 두 값은 이유를 말하기 위한 것이다', () => {
82
+ /*
83
+ * `capabilitiesOf` 가 선언과 구현을 함께 보고 답한 값이 판정의 근거다. 여기서 두 값으로 다시
84
+ * 판단하면 규칙이 두 벌이 되고, 한쪽만 고쳐지는 날이 온다.
85
+ */
86
+ const t = actuationTarget(input({ capabilities: ['actuate'], declaresActuate: false, hasActuateImpl: false }))
87
+ assert.equal(isActuationAllowed(t), true, '능력 판정의 결과를 신뢰하지 않고 여기서 다시 판단했다')
88
+ })
89
+
90
+ /* ── 모르면 보내지 않는다 ───────────────────────────────────────────────────── */
91
+
92
+ test('★ 어느 사이트에서 왔는지 모르면 보내지 않는다 — 첫 번째 사이트로 보내지 않는다', () => {
93
+ /*
94
+ * 원본 하나가 여러 사이트를 낸다. 모르는 채로 아무 데나 보내면 **잘못된 공장이 실제로 만든다.**
95
+ * 구동(control)은 사이트를 몰라도 넘기지만 이쪽은 다르다.
96
+ */
97
+ assert.equal(refusalOf(actuationTarget(input({ siteId: undefined }))), 'no-site')
98
+ assert.equal(refusalOf(actuationTarget(input({ siteId: ' ' }))), 'no-site', '공백을 사이트 이름으로 읽었다')
99
+ })
100
+
101
+ test('★ 원본이 없는 트윈에는 보내지 않는다 — 시뮬레이션으로만 세운 트윈이다', () => {
102
+ assert.equal(refusalOf(actuationTarget(input({ ref: undefined }))), 'no-source')
103
+ })
104
+
105
+ test('★ 거절 문장이 어느 트윈인지 말한다 — 「모르는 트윈입니다」만으로는 찾을 수 없다', () => {
106
+ for (const over of [{ twinExists: false, ref: undefined }, { ref: undefined }, { siteId: undefined }]) {
107
+ const t = actuationTarget(input(over)) as any
108
+ assert.match(t.detail, /twin-a/, `${t.code} 가 어느 트윈인지 말하지 않는다`)
109
+ }
110
+ })
111
+
112
+ test('모르는 트윈은 원본을 묻기 전에 거절한다', () => {
113
+ assert.equal(refusalOf(actuationTarget(input({ twinExists: false, ref: undefined }))), 'unknown-twin')
114
+ })
115
+
116
+ test('등록되지 않은 커넥터는 능력을 묻기 전에 거절한다 — 물을 데가 없다', () => {
117
+ const t = actuationTarget(input({ adapterRegistered: false, capabilities: [], hasActuateImpl: false }))
118
+
119
+ assert.equal(refusalOf(t), 'no-adapter')
120
+ assert.match((t as any).detail, /operato-plant/, '어느 커넥터인지 말하지 않으면 찾을 수 없다')
121
+ })
122
+
123
+ /* ── 거절은 전부 코드를 갖는다 ──────────────────────────────────────────────── */
124
+
125
+ test('★ 모든 거절에 코드와 문장이 함께 있다 — 화면이 다음 할 일을 가를 수 있게', () => {
126
+ const cases: Partial<ActuationTargetInput>[] = [
127
+ { twinExists: false },
128
+ { ref: undefined },
129
+ { adapterRegistered: false, capabilities: [], declaresActuate: false, hasActuateImpl: false },
130
+ { capabilities: ['live', 'control'], declaresActuate: false, hasActuateImpl: true },
131
+ { capabilities: ['live'], declaresActuate: true, hasActuateImpl: false },
132
+ { capabilities: ['live'], declaresActuate: false, hasActuateImpl: false },
133
+ { siteId: undefined }
134
+ ]
135
+
136
+ const codes = cases.map(over => {
137
+ const t = actuationTarget(input(over)) as any
138
+ assert.equal(t.ok, false)
139
+ assert.ok(t.code, '거절에 코드가 없다')
140
+ assert.ok(t.detail?.length > 10, '거절에 사람이 읽을 문장이 없다')
141
+ return t.code
142
+ })
143
+
144
+ assert.equal(new Set(codes).size, codes.length, '다른 원인이 같은 코드로 답했다 — 화면이 구별할 수 없다')
145
+ })
@@ -63,8 +63,13 @@ test('거부분이 사라지지 않는다 — 커넥터 매핑이 틀린 것을
63
63
  })
64
64
 
65
65
  test('빈 입력에서 모든 갈래가 들어온 것이 없다 — 없는 것을 만들지 않는다', () => {
66
- /* 어휘가 다섯이다 — 사건 넷과 사건이 아닌 것 하나(마스터데이터). 어느 갈래도 값을 지어내지 않는다. */
67
- const none = { accepted: [], rejected: [], masterData: [] }
66
+ /*
67
+ * 어휘가 다섯이다 — 사건 넷과 사건이 아닌 것 하나(마스터데이터). 어느 갈래도 값을 지어내지 않는다.
68
+ *
69
+ * `undated` 는 `masterData` 와 같은 규칙으로 언제나 있다(빈 것이면 빈 객체). 없을 때 칸을 빼면
70
+ * 읽는 쪽이 「이 판이 그것을 안 센다」와 「셌는데 0이다」를 구별하지 못한다.
71
+ */
72
+ const none = { accepted: [], rejected: [], masterData: [], undated: {} }
68
73
  assert.deepEqual(ingestCanonicalRecords([], 't', NOW), none)
69
74
  assert.deepEqual(ingestCanonicalRecords(null, 't', NOW), none)
70
75
  })
@@ -402,3 +407,45 @@ test('★ 단가·사용 구간·요금 기준·청구서가 한 배치에 섞
402
407
  const types = (r.accepted as any[]).map(e => e.eventType).sort()
403
408
  assert.deepEqual(types, ['energy.bill', 'energy.generation.price', 'energy.tariff.basis', 'energy.usage.period'])
404
409
  })
410
+
411
+ /* ── fact type 마다 시각 칸 이름이 다르다 ──────────────────────────────────── */
412
+
413
+ test('★ nonconformance 는 decidedAt 을 자기 시각으로 쓴다 — 세 번째로 밟은 자리다', () => {
414
+ /*
415
+ * [측정함 2026-09-06] backfill 뒤 mes-line-a 의 nonconformance 4건이 event_time == created_at
416
+ * 이었다. payload 의 `data.decidedAt` 은 2026-09-01 인데 저장된 시각은 2026-09-05 였다 — 5일 차이.
417
+ *
418
+ * push 경로에서는 안 보인다. 사실이 나자마자 보내니 몇 초 차이다. backfill 이 그 간격을 벌려서
419
+ * 드러냈다.
420
+ */
421
+ const got = ingestCanonicalRecords(
422
+ [{ subjectId: 'LOT-A', decision: 'scrap', decidedAt: '2026-09-01T02:50:51.832Z' }] as any,
423
+ 't',
424
+ NOW
425
+ )
426
+ assert.equal(got.accepted[0]?.eventTime, '2026-09-01T02:50:51.832Z', 'ingest 시각으로 떨어졌다')
427
+ assert.deepEqual(got.undated, {}, '시각을 읽었으면 세지 않는다')
428
+ })
429
+
430
+ test('★ 시각을 못 읽으면 그 사실을 센다 — 조용히 지금 시각으로 떨어지지 않는다', () => {
431
+ /*
432
+ * 오류가 아니다. 절대값 snapshot 은 polling 순간이 맞는 시각이라 여기 세어지는 것이 정상이다.
433
+ * 다만 그 수가 어디에도 안 남으면 `event_time == created_at` 이 유일한 단서이고, 그것은 우연히
434
+ * 같은 것과 구별되지 않는다.
435
+ */
436
+ const got = ingestCanonicalRecords(
437
+ [{ subjectId: 'LOT-B', decision: 'scrap' }] as any,
438
+ 't',
439
+ NOW
440
+ )
441
+ assert.deepEqual(got.undated, { disposition: 1 })
442
+ })
443
+
444
+ test('공통 이름이 있으면 그것이 이긴다 — 봉투 층의 판단이 가장 바깥이다', () => {
445
+ const got = ingestCanonicalRecords(
446
+ [{ subjectId: 'L', decision: 'scrap', at: '2026-09-03T00:00:00.000Z', decidedAt: '2026-09-01T00:00:00.000Z' }] as any,
447
+ 't',
448
+ NOW
449
+ )
450
+ assert.equal(got.accepted[0]?.eventTime, '2026-09-03T00:00:00.000Z')
451
+ })
@@ -81,7 +81,7 @@ test('★ 스트림라인 브릿지: 능력의 상태필드가 라이브 payload
81
81
  test('시임 문서화 · processable output(양품/불량)은 payload oee.good/scrap 으로 표면화', () => {
82
82
  // 계약 stateField = 'output' 이나, 라이브 payload 는 이를 oee(정수%+good/scrap)로 집약해 표면화.
83
83
  const d: any = buildEntityDeltas({
84
- equipment: [{ id: 'c', kind: 'cutter', status: 'busy', oee: { overall: 0.8, availability: 0.9, performance: 0.9, quality: 0.98, goodCount: 10, scrapCount: 1 } }]
84
+ equipment: [{ id: 'c', kind: 'cutter', status: 'busy', oee: { overall: 0.8, availability: 0.9, performance: 0.9, quality: 0.98, goodCount: 10, nonconformingCount: 1 } }]
85
85
  }, 't1').find(x => x.tag === 'c')!.data
86
86
  assert.ok(d.oee, 'processable → payload.oee 존재')
87
87
  assert.equal(d.oee.good, 10, 'output.good = oee.good')
@@ -0,0 +1,117 @@
1
+ /*
2
+ * ★ **어느 레시피로 만들었나 — 짐작하지 않는다.**
3
+ *
4
+ * ── 무엇이 있었나 (2026-09-05) ─────────────────────────────────────────────
5
+ * 계보 진단이 자재로 되짚어 `recipes.find(...)` 로 **먼저 걸리는 하나**를 골랐다. 배열 순서가 답을
6
+ * 정한다는 뜻이다. 그리고 한 자재에 레시피가 둘인 경우가 이미 있다 — plant 마스터 실측:
7
+ *
8
+ * BOLT-M8 → RCP-BOLT-STD 유효시작 2026-09-01 09:25:29.447
9
+ * RCP-BOLT-FAST 유효시작 2026-09-01 09:25:29.510
10
+ *
11
+ * 빠른 방식으로 만든 볼트인데 표준 방식과 견주고 「설계와 다릅니다」가 나온다. 사람은 라인에
12
+ * 문제가 있다고 믿고 확인하러 간다. **틀린 답이 확신에 찬 얼굴로 나오는 것**이 그 상태였다.
13
+ *
14
+ * ── 답은 적혀 있었다 ───────────────────────────────────────────────────────
15
+ * 만들 때 정해지는 사실이고 커널이 오더에 싣는다(`FlowOrder.recipeKey`). 변환 사건은 그 오더를
16
+ * 거래 식별자로 가리키고, 계보 함수는 그 값을 이미 꺼내 놓고도 쓰지 않았다.
17
+ *
18
+ * 세 자리가 다 있었고 **마지막 연결만 없었다.**
19
+ *
20
+ * ── 왜 순수 함수인가 ───────────────────────────────────────────────────────
21
+ * 규칙만 따로 재려면 DB 도 트윈도 필요 없어야 한다. 여기서 값으로 못 박아 두면 다음에 「하나쯤
22
+ * 골라 주는 게 친절하지 않나」가 올라올 때 그 줄이 빨개진다.
23
+ */
24
+ import { test } from 'node:test'
25
+ import assert from 'node:assert/strict'
26
+
27
+ import { chooseRecipe } from '../server/service/twin-model/choose-recipe.js'
28
+
29
+ const STD = { key: 'RCP-BOLT-STD', outputs: [{ material: 'BOLT-M8' }] }
30
+ const FAST = { key: 'RCP-BOLT-FAST', outputs: [{ material: 'BOLT-M8' }] }
31
+ const OTHER = { key: 'RCP-CT-BLANK', outputs: [{ material: 'CT-BLANK' }] }
32
+
33
+ const ALL = [STD, FAST, OTHER]
34
+
35
+ /* ── 오더가 말하면 그것을 쓴다 ───────────────────────────────────────────── */
36
+
37
+ test('★ 오더가 말한 레시피를 쓴다 — 판이 둘이어도 고민하지 않는다', () => {
38
+ const r = chooseRecipe(ALL, [STD, FAST], 'RCP-BOLT-FAST')
39
+
40
+ assert.equal(r.recipe, FAST, '오더가 말했는데 다른 것을 골랐다')
41
+ assert.equal(r.basis, 'order')
42
+ })
43
+
44
+ test('★ 배열 순서가 답을 바꾸지 않는다 — 옛 결함의 핵심이 이것이었다', () => {
45
+ const forward = chooseRecipe([STD, FAST], [STD, FAST], 'RCP-BOLT-FAST')
46
+ const reversed = chooseRecipe([FAST, STD], [FAST, STD], 'RCP-BOLT-FAST')
47
+
48
+ assert.equal(forward.recipe, reversed.recipe, '순서를 뒤집으니 답이 달라졌다')
49
+ assert.equal(forward.recipe?.key, 'RCP-BOLT-FAST')
50
+ })
51
+
52
+ /* ── 오더가 말 안 할 때 ─────────────────────────────────────────────────── */
53
+
54
+ test('★ 후보가 하나면 쓴다 — 고를 것이 없으면 짐작이 아니다', () => {
55
+ const r = chooseRecipe(ALL, [OTHER], undefined)
56
+
57
+ assert.equal(r.recipe, OTHER)
58
+ assert.equal(r.basis, 'only-candidate')
59
+ })
60
+
61
+ test('★ 후보가 둘 이상이면 답하지 않는다 — 여기가 옛 결함의 자리다', () => {
62
+ /*
63
+ * 옛 코드: `recipes.find(...)` — 이 자리에서 STD 를 돌려줬다. 그 답이 틀릴 확률이 절반이고,
64
+ * 틀렸다는 사실은 아무 데도 안 남았다.
65
+ */
66
+ const r = chooseRecipe(ALL, [STD, FAST], undefined)
67
+
68
+ assert.equal(r.recipe, undefined, '어느 것으로 만들었는지 모르는 채로 하나를 골랐다')
69
+ assert.equal(r.basis, 'ambiguous')
70
+ })
71
+
72
+ /* ── 어긋남과 모름을 가른다 ─────────────────────────────────────────────── */
73
+
74
+ test('★ 오더가 말한 레시피가 선언에 없으면 「모른다」가 아니라 「어긋났다」', () => {
75
+ /*
76
+ * 사람이 할 일이 다르다. 어긋남은 선언이나 원본을 고칠 일이고, 모호함은 그 개체의 기록이 없는
77
+ * 일이다. 한 낱말로 합치면 화면이 둘 중 어느 쪽인지 말할 수 없다.
78
+ */
79
+ const r = chooseRecipe(ALL, [STD, FAST], 'RCP-사라진것')
80
+
81
+ assert.equal(r.recipe, undefined)
82
+ assert.equal(r.basis, 'said-but-undeclared')
83
+ })
84
+
85
+ test('★ 후보가 아예 없으면 none — 「이 자재를 내는 레시피가 없다」', () => {
86
+ const r = chooseRecipe(ALL, [], undefined)
87
+
88
+ assert.equal(r.recipe, undefined)
89
+ assert.equal(r.basis, 'none')
90
+ })
91
+
92
+ /* ── 무너지지 않는다 ────────────────────────────────────────────────────── */
93
+
94
+ test('빈 목록·빈 값에도 터지지 않는다', () => {
95
+ assert.equal(chooseRecipe([], [], undefined).basis, 'none')
96
+ assert.equal(chooseRecipe([], [], 'X').basis, 'said-but-undeclared')
97
+ assert.equal(chooseRecipe(ALL, [STD], '').basis, 'only-candidate', '빈 문자열은 「말했다」가 아니다')
98
+ })
99
+
100
+ /* ── 검사가 실제로 무언가를 재나 ─────────────────────────────────────────── */
101
+
102
+ test('★ 이 검사가 실제로 무언가를 잰다 — 견준 것이 0이면 빨강', () => {
103
+ /* 네 판정이 다 나오는지. 하나라도 안 나오면 위 검사들이 같은 가지만 밟고 있는 것이다. */
104
+ const bases = new Set([
105
+ chooseRecipe(ALL, [STD, FAST], 'RCP-BOLT-FAST').basis,
106
+ chooseRecipe(ALL, [OTHER], undefined).basis,
107
+ chooseRecipe(ALL, [STD, FAST], undefined).basis,
108
+ chooseRecipe(ALL, [STD], 'RCP-없음').basis,
109
+ chooseRecipe(ALL, [], undefined).basis
110
+ ])
111
+
112
+ assert.deepEqual(
113
+ [...bases].sort(),
114
+ ['ambiguous', 'none', 'only-candidate', 'order', 'said-but-undeclared'],
115
+ `판정이 다 나오지 않는다: ${[...bases].join(' · ')}`
116
+ )
117
+ })
@@ -0,0 +1,126 @@
1
+ /*
2
+ * ★ **칸이 있으면 쓰는 길도 있어야 한다** — `save` 가 손으로 든 목록이라 새 칸을 빠뜨린다.
3
+ *
4
+ * ── 무엇이 있었나 (2026-09-04) ──────────────────────────────────────────────
5
+ * 일꾼에 네 칸을 더했다 — `dispatch_note_next` · `retry` · `attempts` · `next_attempt_at`.
6
+ * 엔티티에 선언하고 마이그레이션까지 걸었는데 **`save` 가 그 칸을 안 썼다.** 그 함수는 쓸 칸을
7
+ * 손으로 들고 있고, 새 칸이 그 목록에 없었다.
8
+ *
9
+ * 그래서 일꾼이 물러섬을 계산해 넘겼는데 표에는 아무것도 안 남았다. 재기동하면 물러서던 것이
10
+ * 전부 즉시 재시도가 되고, 목록의 「몇 번 해 봤나」가 영원히 비어 있다. **오류는 나지 않는다.**
11
+ *
12
+ * 30분을 뒤진 끝에 찾았고, 이 저장소에서 하루 종일 「자리는 있고 길이 없다」로 부른 것과 같다 —
13
+ * 이번에는 **칸과 쓰는 길** 사이였다.
14
+ *
15
+ * ── 무엇을 검사하나 ────────────────────────────────────────────────────────
16
+ * 엔티티가 칸을 가진 필드는 `save` 가 쓰거나, 안 쓰는 이유가 아래 표에 있어야 한다. 목록을
17
+ * 소스에서 읽는다 — 손으로 적으면 새 칸이 조용히 빠지고 그것이 바로 이 검사가 막으려는 실패다.
18
+ */
19
+ import { test } from 'node:test'
20
+ import assert from 'node:assert/strict'
21
+ import { readFileSync } from 'node:fs'
22
+
23
+ const bare = (p: string): string =>
24
+ readFileSync(new URL(p, import.meta.url), 'utf8')
25
+ .replace(/\/\*[\s\S]*?\*\//g, '')
26
+ .replace(/\/\/[^\n]*/g, '')
27
+
28
+ const ENTITY = bare('../server/service/actuation/twin-command.ts')
29
+ const STORE = bare('../server/service/actuation/command-store.ts')
30
+
31
+ /**
32
+ * `save` 가 일부러 안 쓰는 칸 — 이유와 함께.
33
+ *
34
+ * 비어 있는 것이 정상이 아니다: 이 표는 「고치는 경로가 다른 칸」을 든다. 채울 때 그 이유가
35
+ * 「다른 함수가 쓴다」인지 「읽기 전용」인지 적어야 한다.
36
+ */
37
+ const NOT_BY_SAVE: { column: string; why: string }[] = [
38
+ { column: 'domain', why: '열쇠 — 테넌트로 좁히는 조건이다' },
39
+ { column: 'instanceId', why: '낼 때 정해지고 바뀌지 않는다(create)' },
40
+ { column: 'type', why: '낼 때 정해지고 바뀌지 않는다(create)' },
41
+ { column: 'payload', why: '낼 때 정해지고 바뀌지 않는다 — 지시 내용은 제안될 때 굳는다' },
42
+ { column: 'origin', why: '낼 때 정해지고 바뀌지 않는다(create)' },
43
+ { column: 'proposedBy', why: '낼 때 정해지고 바뀌지 않는다(create)' },
44
+ { column: 'reversible', why: '명세에서 온다 — 낼 때 굳는다(create)' },
45
+ { column: 'causeRef', why: '낼 때 정해진다 — 규칙이 낸 근거(create)' },
46
+ { column: 'ruleRef', why: '낼 때 정해진다(create)' },
47
+ { column: 'effectStatus', why: '유입 경로가 `recordEffects` 로만 적는다 — 관측이 조치의 수명에 손대지 않게' },
48
+ { column: 'effectAt', why: '같음 — `recordEffects`' },
49
+ { column: 'effectSeen', why: '같음 — `recordEffects`' },
50
+ { column: 'createdAt', why: 'TypeORM 이 넣는다' },
51
+ { column: 'updatedAt', why: 'TypeORM 이 넣는다' }
52
+ ]
53
+
54
+ /** 엔티티가 컬럼을 선언한 속성 이름들 — 데코레이터 다음 줄의 필드를 읽는다. */
55
+ function entityColumns(): string[] {
56
+ const out: string[] = []
57
+ const lines = ENTITY.split('\n')
58
+ for (let i = 0; i < lines.length; i++) {
59
+ if (!/@(Column|CreateDateColumn|UpdateDateColumn|ManyToOne)\(/.test(lines[i])) continue
60
+ /* 데코레이터가 여럿 붙는다(@Column + @Field) — 다음 필드 줄까지 내려간다. */
61
+ for (let j = i + 1; j < Math.min(i + 6, lines.length); j++) {
62
+ const m = lines[j].match(/^\s{2}([a-zA-Z][\w]*)\??:/)
63
+ if (m) {
64
+ out.push(m[1])
65
+ break
66
+ }
67
+ }
68
+ }
69
+ return [...new Set(out)]
70
+ }
71
+
72
+ /** `save` 가 실제로 쓰는 속성 — 그 함수 본문에서만 읽는다. */
73
+ function savedColumns(): string[] {
74
+ const start = STORE.indexOf('save: async')
75
+ assert.ok(start > 0, '`save` 를 못 찾았다 — 이 검사가 따라가야 한다')
76
+ const body = STORE.slice(start, STORE.indexOf('create: async', start))
77
+ return [...new Set([...body.matchAll(/^\s{10}([a-zA-Z][\w]*):/gm)].map(m => m[1]))]
78
+ }
79
+
80
+ test('★ 엔티티가 든 칸은 save 가 쓰거나 안 쓰는 이유가 적혀 있다', () => {
81
+ const excused = new Set(NOT_BY_SAVE.map(e => e.column))
82
+ const written = new Set(savedColumns())
83
+
84
+ const orphans = entityColumns().filter(c => !written.has(c) && !excused.has(c))
85
+
86
+ assert.deepEqual(
87
+ orphans,
88
+ [],
89
+ '엔티티에 칸이 있는데 `save` 가 안 쓴다. 값이 계산되고 표에는 안 남는다 — 오류는 나지 않는다:\n ' +
90
+ orphans.join('\n ') +
91
+ '\n쓰게 하거나, 다른 경로로 쓴다면 NOT_BY_SAVE 에 이유를 적는다.'
92
+ )
93
+ })
94
+
95
+ test('★ 이 검사가 실제로 무언가를 잰다 — 견준 것이 0이면 빨강', () => {
96
+ /*
97
+ * 정규식이 엔티티 모양을 못 따라가면 목록이 비고, 그러면 위 검사가 **초록인데 아무것도 안 재는
98
+ * 상태**가 된다. 오늘 이 저장소의 다른 검사가 그렇게 났다.
99
+ */
100
+ const columns = entityColumns()
101
+ const written = savedColumns()
102
+
103
+ assert.ok(columns.length > 15, `엔티티 칸을 ${columns.length}개만 읽었다 — 정규식이 모양을 놓쳤다`)
104
+ assert.ok(written.length > 5, `save 가 쓰는 칸을 ${written.length}개만 읽었다`)
105
+
106
+ /* 예외 표가 낡지 않았나 — 지워진 칸이 남아 있으면 검사를 넓혀 주는 척만 한다. */
107
+ const known = new Set(columns)
108
+ const stale = NOT_BY_SAVE.map(e => e.column).filter(c => !known.has(c))
109
+ assert.deepEqual(stale, [], `예외 표에 있는데 엔티티에 없다:\n ${stale.join('\n ')}`)
110
+
111
+ /* 이유 없는 예외는 예외가 아니다. */
112
+ const mute = NOT_BY_SAVE.filter(e => !e.why?.trim()).map(e => e.column)
113
+ assert.deepEqual(mute, [], `이유 없이 예외로 올렸다:\n ${mute.join('\n ')}`)
114
+ })
115
+
116
+ test('★ 일꾼이 쓰는 네 칸이 실제로 save 를 지난다', () => {
117
+ /*
118
+ * 위 검사는 목록 대조라 예외 표에 올리면 통과한다. 이 넷은 **물러섬이 표에 남는 근거**여서
119
+ * 예외가 될 수 없다 — 이름으로 못 박는다.
120
+ */
121
+ const written = new Set(savedColumns())
122
+
123
+ for (const c of ['dispatchNoteNext', 'retry', 'attempts', 'nextAttemptAt']) {
124
+ assert.ok(written.has(c), `save 가 ${c} 를 안 쓴다 — 물러섬이 재기동에 사라진다`)
125
+ }
126
+ })