@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,56 @@
1
+ /*
2
+ * 같은 사실을 두 번 받았을 때 한 번으로 세는 근거 — **정체**.
3
+ *
4
+ * 정체를 못 만들면 그 사실은 dedupe 밖이고, 다시 받을 때마다 저널에 새로 앉는다. backfill 이 그것을
5
+ * 드러냈다(2026-09-06).
6
+ */
7
+ import { test } from 'node:test'
8
+ import assert from 'node:assert/strict'
9
+
10
+ import { factIdentity, takeUnidentified } from '../server/engine/ingest-dedupe.js'
11
+
12
+ /* ── 주체가 subjectId 인 사실 ───────────────────────────────────────────────── */
13
+
14
+ test('★ 처분과 시험은 subjectId 가 주체다 — 그 이름이 없으면 정체가 안 만들어진다', () => {
15
+ /*
16
+ * [측정함 2026-09-06] `fillTwinScope` 를 돌릴 때마다 처분 4건 · 시험 3건이 다시 앉았다. 나머지
17
+ * 34건은 걸러지는데 이 둘만 계속 늘었다.
18
+ *
19
+ * 원인은 `discriminatorsOf` 에 `subjectId` 가 없어서 `parts` 가 비고, `factIdentity` 가
20
+ * `undefined` 를 낸 것이다. 정체가 없으면 dedupe 밖이다.
21
+ */
22
+ const disposition = {
23
+ eventType: 'nonconformance.disposition',
24
+ eventTime: '2026-09-01T02:50:51.832Z',
25
+ data: { subjectId: 'LOT-A', decision: 'scrap', quantity: 50 }
26
+ }
27
+ const id = factIdentity(disposition)
28
+ assert.ok(id, '정체를 못 만들면 다시 받을 때마다 새로 앉는다')
29
+ assert.ok(id!.includes('subject=LOT-A'), `주체가 정체에 안 들어갔다: ${id}`)
30
+ })
31
+
32
+ test('★ 다른 로트의 같은 처분은 다른 사실이다', () => {
33
+ const at = '2026-09-01T02:50:51.832Z'
34
+ const a = factIdentity({ eventType: 'nonconformance.disposition', eventTime: at, data: { subjectId: 'LOT-A', decision: 'scrap' } })
35
+ const b = factIdentity({ eventType: 'nonconformance.disposition', eventTime: at, data: { subjectId: 'LOT-F', decision: 'scrap' } })
36
+ assert.notEqual(a, b, '두 로트의 처분이 한 사실로 뭉쳤다')
37
+ })
38
+
39
+ test('★ 같은 사실을 다시 받으면 같은 정체다 — backfill 이 저널을 안 부풀린다', () => {
40
+ const rec = { eventType: 'nonconformance.disposition', eventTime: '2026-09-01T02:50:51.832Z', data: { subjectId: 'LOT-A', decision: 'scrap', quantity: 50 } }
41
+ assert.equal(factIdentity(rec), factIdentity({ ...rec, data: { ...rec.data } }))
42
+ })
43
+
44
+ test('★ 시험 결과는 명세도 가른다 — 같은 대상에 시험이 여럿이다', () => {
45
+ const at = '2026-09-01T00:00:00.000Z'
46
+ const a = factIdentity({ eventType: 'test.result', eventTime: at, data: { subjectId: 'EQ-1', specId: 'CP-PBA-INCOMING' } })
47
+ const b = factIdentity({ eventType: 'test.result', eventTime: at, data: { subjectId: 'EQ-1', specId: 'CP-PBA-TEST' } })
48
+ assert.notEqual(a, b, '같은 대상의 두 시험이 한 사실로 뭉쳤다')
49
+ })
50
+
51
+ test('★ 정체를 못 만든 것을 센다 — 조용히 dedupe 밖으로 나가지 않는다', () => {
52
+ takeUnidentified()
53
+ assert.equal(factIdentity({ eventType: 'made.up.kind', eventTime: '2026-09-01T00:00:00.000Z', data: {} }), undefined)
54
+ assert.deepEqual(takeUnidentified(), { 'made.up.kind': 1 })
55
+ assert.deepEqual(takeUnidentified(), {}, '읽고 비운다')
56
+ })
@@ -0,0 +1,111 @@
1
+ /*
2
+ * ★ **지금은 조용한 것이 정상이다** — 밤마다 나던 거짓 빨강.
3
+ *
4
+ * ── 무엇이 틀렸나 (2026-09-04) ──────────────────────────────────────────────
5
+ * 유입 건강이 **한 자로 전부를 쟀다.** 10분 안에 아무것도 오지 않으면 「유입 없음」이다. 그런데
6
+ * 태양광은 밤에 발전하지 않고, 업무시간만 도는 원천은 저녁에 아무것도 내지 않는다. 정상인데
7
+ * **매일 밤 절반이 빨갛게 났다.**
8
+ *
9
+ * 거짓 빨강이 쌓이면 진짜 빨강도 같이 안 읽힌다. 오늘 UX 레인이 「늘 참인 경고는 벽지가 된다」고
10
+ * 한 것과 같은 부류다 — 사흘이면 그 자리에 무엇이 적혀도 안 읽는다.
11
+ *
12
+ * 그리고 15분마다 긷는 원천은 긷는 사이마다 빨갛다. 이 파일이 고치는 코드가 그 한계를 **스스로
13
+ * 주석에 적어 두고 있었다**: 「주기를 알게 되면 그 값으로 견주는 것이 맞다」.
14
+ *
15
+ * ── 초록도 빨강도 아닌 셋째 값 ──────────────────────────────────────────────
16
+ * 조용한 시간을 초록으로 칠하면 **밤에 정말 끊긴 원천**을 아침까지 아무도 모른다. 그래서 답은
17
+ * 「지금은 안 오는 것이 맞다」이고, 그것은 다른 값이어야 한다.
18
+ */
19
+ import { test } from 'node:test'
20
+ import assert from 'node:assert/strict'
21
+
22
+ import { syncVerdictOf, STALE_AFTER_MS } from '../server/engine/ingest-health.js'
23
+
24
+ const NOW = 1_800_000_000_000
25
+ const long = NOW - STALE_AFTER_MS - 60_000 /* 한 창을 넘긴 침묵 */
26
+
27
+ const at = (over: Record<string, unknown> = {}) =>
28
+ syncVerdictOf({
29
+ feedState: 'attached',
30
+ recent: { offered: 100, rejected: 0 },
31
+ lastAtMs: NOW - 1_000,
32
+ nowMs: NOW,
33
+ lastUndeliveredAtMs: -1,
34
+ offeredTotal: 100,
35
+ ...over
36
+ } as any)
37
+
38
+ /* ── 조용한 시간 ──────────────────────────────────────────────────────────── */
39
+
40
+ test('★ 조용한 시간의 긴 침묵은 「유입 없음」이 아니다', () => {
41
+ assert.equal(at({ lastAtMs: long, quietNow: true }), 'expected-quiet')
42
+ })
43
+
44
+ test('★ 같은 침묵이 활동 시간이면 「유입 없음」이다', () => {
45
+ assert.equal(at({ lastAtMs: long, quietNow: false }), 'no-traffic')
46
+ })
47
+
48
+ test('★ 달력을 선언하지 않았으면 지금까지처럼 답한다 — 모르는 것을 「조용할 때」로 읽지 않는다', () => {
49
+ /*
50
+ * `quietNow` 를 주지 않은 원천이 전부 「지금은 조용한 시간」이 되면, 정말 끊긴 원천이 통째로
51
+ * 조용해진다. 없으면 옛 거동 그대로다.
52
+ */
53
+ assert.equal(at({ lastAtMs: long }), 'no-traffic')
54
+ })
55
+
56
+ test('★ 조용한 시간이어도 사실이 사라지고 있으면 그것이 먼저다', () => {
57
+ /*
58
+ * 버려지는 중은 **몇 시든 문제**다. 밤이라고 덮으면 밤새 새는 것을 아침에 안다.
59
+ */
60
+ const v = at({ lastAtMs: long, quietNow: true, lastUndeliveredAtMs: NOW - 1_000 })
61
+ assert.equal(v, 'twin-not-running')
62
+ })
63
+
64
+ test('★ 조용한 시간이어도 결선이 없으면 그것이 먼저다', () => {
65
+ assert.equal(at({ feedState: 'detached', lastAtMs: long, quietNow: true }), 'not-connected')
66
+ })
67
+
68
+ test('★ 한 번도 받지 않았는데 지금이 조용한 시간이면 「모른다」가 아니다', () => {
69
+ /*
70
+ * 밤에 처음 붙인 태양광 연결이 「기다린다」로 보이면, 사람이 아침까지 무엇을 기다리는지 모른다.
71
+ */
72
+ assert.equal(at({ lastAtMs: -1, recent: null, offeredTotal: 0, quietNow: true }), 'expected-quiet')
73
+ assert.equal(at({ lastAtMs: -1, recent: null, offeredTotal: 0 }), 'unknown')
74
+ })
75
+
76
+ test('★ 창이 비었어도 조용한 시간이면 정상이다', () => {
77
+ assert.equal(at({ recent: { offered: 0, rejected: 0 }, quietNow: true }), 'expected-quiet')
78
+ assert.equal(at({ recent: { offered: 0, rejected: 0 } }), 'no-traffic')
79
+ })
80
+
81
+ test('조용한 시간에도 받고 있으면 받는 중이다 — 침묵이 아니면 이 축이 개입하지 않는다', () => {
82
+ assert.equal(at({ quietNow: true }), 'syncing')
83
+ })
84
+
85
+ /* ── 원천이 선언한 주기 ──────────────────────────────────────────────────── */
86
+
87
+ test('★ 15분마다 긷는 원천은 그 주기 안에서 정상이다', () => {
88
+ /*
89
+ * 창 하나(10분)로 재면 긷는 사이마다 빨갛다. 원천이 주기를 선언하면 그것으로 견준다.
90
+ */
91
+ const twelveMin = NOW - 12 * 60_000
92
+
93
+ assert.equal(at({ lastAtMs: twelveMin }), 'no-traffic', '선언이 없으면 옛 거동')
94
+ assert.equal(at({ lastAtMs: twelveMin, expectedSilenceMs: 20 * 60_000 }), 'syncing')
95
+ })
96
+
97
+ test('★ 선언한 주기를 넘기면 그때는 「유입 없음」이다 — 선언이 면죄부가 아니다', () => {
98
+ const thirtyMin = NOW - 30 * 60_000
99
+ assert.equal(at({ lastAtMs: thirtyMin, expectedSilenceMs: 20 * 60_000 }), 'no-traffic')
100
+ })
101
+
102
+ test('0이나 음수 주기는 선언이 아니다 — 창 하나로 돌아간다', () => {
103
+ for (const bad of [0, -1, NaN]) {
104
+ assert.equal(at({ lastAtMs: long, expectedSilenceMs: bad }), 'no-traffic', `${bad} 를 주기로 받았다`)
105
+ }
106
+ })
107
+
108
+ test('★ 주기와 달력은 다른 축이다 — 주기를 넘겼는데 조용한 시간이면 정상이다', () => {
109
+ const thirtyMin = NOW - 30 * 60_000
110
+ assert.equal(at({ lastAtMs: thirtyMin, expectedSilenceMs: 20 * 60_000, quietNow: true }), 'expected-quiet')
111
+ })
@@ -130,3 +130,82 @@ test('트윈마다 따로 센다 — 한 트윈의 되풀이가 다른 트윈을
130
130
  const { duplicates } = b.filter([energy('inv-1', 1)])
131
131
  assert.equal(duplicates, 0)
132
132
  })
133
+
134
+ /* ════════════════════════════════════════════════════════════════════════════
135
+ * ★ **구별할 값이 봉투 안에 있으면 다 본다** — 골라 쓰고 나머지를 버리던 자리.
136
+ *
137
+ * ── 실측 (2026-09-05, 인티그레이션·plant 레인) ─────────────────────────────
138
+ * plant 이 실적 둘을 보냈는데 한 건이 저널에 안 남았다.
139
+ *
140
+ * seq 36 quality.output moverId EQ-CT-MWELD-01 · taskId PR-CT-WIRE-01 · goodCount 12
141
+ * seq 41 quality.output moverId EQ-CT-MWELD-01 · taskId PR-CT-WIRE-02 · goodCount 9
142
+ *
143
+ * 시각이 같았고(원본이 끝시각을 복사) 대상을 **먼저 걸리는 하나**로 골라서 `moverId` 가 이겼다.
144
+ * 거부가 아니라 **되풀이로 세어졌다** — 유입 장부에 `offered 5 · rejected 0 · journaled 3`.
145
+ *
146
+ * 원인이 시험 데이터였다고 해서 거동이 옳아지지 않는다. 두 봉투는 `taskId` 도 수량도 달랐고,
147
+ * **구별할 값이 이미 봉투 안에 있었는데 판정이 그것을 안 봤다.**
148
+ *
149
+ * ── 이 파일이 스스로 적어 둔 값 ───────────────────────────────────────────
150
+ * 「모르는 것을 같다고 판단하면 서로 다른 사실이 하나로 합쳐진다.
151
+ * 알리지 않고 잃는 것보다 두 번 세는 것이 낫다.」
152
+ *
153
+ * 지금 거동은 **알리지 않고 잃는 쪽**이었다.
154
+ * ════════════════════════════════════════════════════════════════════════════ */
155
+
156
+ const AT_36 = '2026-09-05T06:50:00.000Z'
157
+ const quality = (taskId: string, goodCount: number) => ({
158
+ eventType: 'quality.output',
159
+ data: { moverId: 'EQ-CT-MWELD-01', taskId, goodCount, at: AT_36 }
160
+ })
161
+
162
+ test('★ 한 설비가 두 작업을 같은 시각에 보고해도 두 사실이다 — 실제로 한 건을 잃던 그 줄', () => {
163
+ const a = factIdentity(quality('PR-CT-WIRE-01', 12))
164
+ const b = factIdentity(quality('PR-CT-WIRE-02', 9))
165
+
166
+ assert.ok(a && b, '정체를 못 만들면 거르지 않는다 — 이 검사가 뜻이 없다')
167
+ assert.notEqual(a, b, '작업이 다른 두 실적이 한 사실이 됐다')
168
+ })
169
+
170
+ test('★ 같은 봉투를 다시 보내면 여전히 같은 사실이다 — 넓혀도 되풀이는 잡힌다', () => {
171
+ /*
172
+ * 넓히는 것이 「되풀이를 놓친다」로 이어지지 않는다는 것이 이 변경의 근거다. 같은 봉투는 이
173
+ * 값들이 전부 같으므로 정체도 같다.
174
+ */
175
+ assert.equal(factIdentity(quality('PR-CT-WIRE-01', 12)), factIdentity(quality('PR-CT-WIRE-01', 12)))
176
+ })
177
+
178
+ test('★ 한 개체의 서로 다른 관측을 가른다 — EPCIS 는 그것이 정상이다', () => {
179
+ /*
180
+ * 저널 246만 건에 옛 규칙을 다시 걸어 여섯 묶음이 나왔고 전부 이 모양이었다. 한 물건이 한
181
+ * 순간에 입고이면서 개시일 수 있다 — 표준에서 이상한 일이 아닌데 규칙이 그 셋을 안 봤다.
182
+ */
183
+ const epc = 'urn:epc:id:sgtin:9521321.011111.1'
184
+ const receiving = {
185
+ eventType: 'epcis.ObjectEvent',
186
+ data: { epcList: [epc], bizStep: 'receiving', action: 'ADD', disposition: 'sellable_accessible', eventTime: AT_36 }
187
+ }
188
+ const commissioning = {
189
+ eventType: 'epcis.ObjectEvent',
190
+ data: { epcList: [epc], bizStep: 'commissioning', action: 'OBSERVE', disposition: 'reserved', eventTime: AT_36 }
191
+ }
192
+
193
+ assert.notEqual(factIdentity(receiving), factIdentity(commissioning), '입고와 개시가 한 사실이 됐다')
194
+ })
195
+
196
+ test('★ 참조가 달라도 갈린다 — 주체만 보고 나머지를 버리지 않는다', () => {
197
+ /*
198
+ * 「주체를 먼저 본다」는 판단 자체는 옳았다. 틀린 것은 **고르고 나머지를 버린 것**이다.
199
+ */
200
+ const one = { eventType: 'task.status', data: { taskId: 'T-1', orderId: 'order-1', at: AT_36 } }
201
+ const two = { eventType: 'task.status', data: { taskId: 'T-1', orderId: 'order-2', at: AT_36 } }
202
+
203
+ assert.notEqual(factIdentity(one), factIdentity(two))
204
+ })
205
+
206
+ test('★ 이 검사가 실제로 무언가를 잰다 — 견준 것이 0이면 빨강', () => {
207
+ /* 가르는 값이 하나도 없으면 정체를 안 만든다(거르지 않고 통과) — 그 경계가 살아 있는지. */
208
+ assert.equal(factIdentity({ eventType: 'x', data: { at: AT_36 } }), undefined, '대상 없는 봉투에 정체를 만들었다')
209
+ assert.equal(factIdentity({ eventType: 'x', data: { taskId: 'T-1' } }), undefined, '시각 없는 봉투에 정체를 만들었다')
210
+ assert.ok(factIdentity(quality('T-1', 1))!.includes('task=T-1'), '작업이 정체에 안 들어갔다')
211
+ })
@@ -0,0 +1,97 @@
1
+ /*
2
+ * ★ **셈이 닫혀야 한다** — 두 갈래로는 큰 수가 「사라졌다」로 떠 있게 된다.
3
+ *
4
+ * ── 실측 (2026-09-05, 인티그레이션·plant 레인) ─────────────────────────────
5
+ * plant 아웃박스와 저널을 손으로 맞춰 보니 정확히 닫혔다.
6
+ *
7
+ * 나간 것 157 = 앉은 것 46 + 거절 2 + 어휘 1 + 되풀이 108
8
+ *
9
+ * 되풀이 108은 하네스가 같은 실적을 같은 시각으로 다시 보낸 것이고 **합친 것이 옳은 거동**이다.
10
+ * 어휘 1은 저널에 안 가는 것이 옳다(시각축이 없다).
11
+ *
12
+ * ── 왜 두 갈래로는 안 되나 ────────────────────────────────────────────────
13
+ * 두 갈래 157 − 46 − 2 = 109 「설명 못 함」
14
+ * 네 갈래 157 − 46 − 2 − 108 − 1 = 0
15
+ *
16
+ * **109 를 「사라졌다」로 내면 그날부터 아무도 그 수를 안 본다.** 늘 큰 수가 떠 있으면 진짜로 하나
17
+ * 사라진 날을 못 본다 — 오늘 이 저장소에서 「늘 참인 경고는 벽지가 된다」로 여러 번 나온 것이다.
18
+ *
19
+ * ── 그리고 되풀이 108은 잰 값이 아니었다 ──────────────────────────────────
20
+ * 뺄셈으로만 구할 수 있었다. 어휘 항이 없어서다. 그 뺄셈을 코드가 하려면 넷이 다 있어야 한다.
21
+ */
22
+ import { test } from 'node:test'
23
+ import assert from 'node:assert/strict'
24
+
25
+ import {
26
+ newIngestLedger,
27
+ recordIngest,
28
+ recordDuplicates,
29
+ recordVocabulary,
30
+ recordJournalWrite
31
+ } from '../server/engine/ingest-health.js'
32
+
33
+ const T0 = 1_757_000_000_000
34
+
35
+ test('★ 넷을 다 세면 셈이 0으로 닫힌다 — 실측한 그 수로', () => {
36
+ const ledger = newIngestLedger()
37
+
38
+ /* 나간 것 157 · 거절 2 */
39
+ recordIngest(ledger, 157, [{ errors: ['x'] }, { errors: ['y'] }], T0)
40
+ /* 저널에 앉은 것 46 */
41
+ recordJournalWrite(ledger, 46, T0)
42
+ /* 합친 것 108 */
43
+ recordDuplicates(ledger, 108, T0)
44
+ /* 저널에 안 가는 어휘 1 */
45
+ recordVocabulary(ledger, 1)
46
+
47
+ const w = ledger.window
48
+ const unexplained = w.offered - w.rows - w.rejected - ledger.duplicates - w.vocabulary
49
+
50
+ assert.equal(unexplained, 0, `설명 못 한 것이 ${unexplained} 남았다 — 셈이 안 닫힌다`)
51
+ })
52
+
53
+ test('★ 어휘 항이 없으면 그 수가 「사라졌다」로 남는다 — 이 항의 존재 이유', () => {
54
+ const ledger = newIngestLedger()
55
+ recordIngest(ledger, 20, undefined, T0)
56
+ recordJournalWrite(ledger, 17, T0)
57
+ recordVocabulary(ledger, 1)
58
+
59
+ const w = ledger.window
60
+ /* 어휘를 안 빼면 셋이 빈 것으로 보인다 — 실제로 사라진 것은 둘이다. */
61
+ assert.equal(w.offered - w.rows, 3, '실측 상황을 못 만들고 있다')
62
+ assert.equal(w.offered - w.rows - w.vocabulary, 2, '어휘를 빼도 수가 안 맞는다')
63
+ })
64
+
65
+ test('★ 어휘는 저널 수에 안 들어간다 — 두 번 세면 셈이 음수가 된다', () => {
66
+ const ledger = newIngestLedger()
67
+ recordIngest(ledger, 1, undefined, T0)
68
+ recordVocabulary(ledger, 1)
69
+
70
+ assert.equal(ledger.window.rows, 0, '어휘가 저널 수에 섞였다')
71
+ assert.equal(ledger.window.vocabulary, 1)
72
+ })
73
+
74
+ test('★ 창이 갈리면 각 창이 자기 어휘를 든다 — 누적으로 비교하면 안 된다', () => {
75
+ /*
76
+ * 인티그레이션 레인이 짚은 것: `mes-line-a` 가 커서 48 · 저널 11 인데 결함이 아니다(그 사이
77
+ * 저널을 비웠다). **창 단위로** 맞춰야 한다.
78
+ */
79
+ const ledger = newIngestLedger()
80
+ recordIngest(ledger, 5, undefined, T0)
81
+ recordVocabulary(ledger, 2)
82
+ assert.equal(ledger.window.vocabulary, 2)
83
+
84
+ /* 창을 넘긴다 — 새 창은 0에서 시작한다. */
85
+ recordIngest(ledger, 5, undefined, T0 + 700_000)
86
+ assert.equal(ledger.window.vocabulary, 0, '새 창이 앞 창의 어휘를 물고 왔다')
87
+ assert.equal(ledger.vocabulary, 2, '누적은 남아야 한다')
88
+ })
89
+
90
+ test('★ 이 검사가 실제로 무언가를 잰다 — 견준 것이 0이면 빨강', () => {
91
+ const ledger = newIngestLedger()
92
+ assert.equal(ledger.window.vocabulary, 0, '새 창에 그 항이 없다')
93
+ recordVocabulary(ledger, 0)
94
+ assert.equal(ledger.vocabulary, 0, '0을 세었다 — 모르면 안 적는다')
95
+ recordVocabulary(ledger, -3)
96
+ assert.equal(ledger.vocabulary, 0, '음수를 세었다')
97
+ })
@@ -0,0 +1,100 @@
1
+ /*
2
+ * 트윈의 고정 유입 규칙이 **표준 헤더 필드를 세 갈래 모두에서** 통과시키나.
3
+ *
4
+ * ── 왜 필요한가 (2026-09-07) ────────────────────────────────────────────────
5
+ * 같은 결함이 이 저장소에서 네 번 났고, 네 번 다 **층이 달랐다.**
6
+ *
7
+ * ```
8
+ * 1 EpcisHeaderOptions 에 칸이 없다 objectEvent 만 자기 인자로 갖고 있었다
9
+ * 2 매핑 스펙에 칸이 없다 커넥터가 선언할 자리가 없었다
10
+ * 3 이 파일의 규칙 셋에 칸이 없다 선언해도 여기서 사라졌다
11
+ * 4 받는 쪽 — 이건 처음부터 서 있었다
12
+ * ```
13
+ *
14
+ * 세 층이 각각 새는 자리였고, **아래 두 층을 고친 뒤에도 위층에서 계속 사라졌다.** 계약을 열고
15
+ * 「됐다」고 한 것이 아니라 인티그레이션 레인이 이 규칙 그대로 `mapRecord` 를 돌려 봐서 찾았다.
16
+ *
17
+ * 그래서 이 시험은 **계약이 아니라 이 파일의 규칙**을 돌린다. 계약 쪽 시험이 초록이어도 여기서
18
+ * 사라질 수 있다는 것이 오늘 실제로 일어난 일이다.
19
+ */
20
+ import test from 'node:test'
21
+ import assert from 'node:assert/strict'
22
+
23
+ import { mapRecordChecked } from '@operato/ops-contract'
24
+
25
+ import { canonicalRulesForTest } from '../server/engine/canonical-ingest.js'
26
+
27
+ const AT = '2026-09-07T02:00:00.000Z'
28
+
29
+ /** 세 갈래가 각자 성립하는 최소 레코드 — 헤더 값은 같은 것을 싣는다. */
30
+ const HEADER = {
31
+ bizStep: 'urn:epcglobal:cbv:bizstep:storing',
32
+ disposition: 'urn:epcglobal:cbv:disp:in_progress',
33
+ readPoint: 'urn:epc:id:sgln:0614141.00001.0',
34
+ bizLocation: 'urn:epc:id:sgln:0614141.00002.0',
35
+ bizTransactionList: [{ type: 'urn:epcglobal:cbv:btt:po', bizTransaction: 'urn:epcglobal:cbv:bt:0614141:order-33' }],
36
+ errorDeclaration: { declarationTime: AT, reason: 'urn:epcglobal:cbv:er:incorrect_data' }
37
+ }
38
+
39
+ const RECORDS: { sourceType: string; record: Record<string, unknown> }[] = [
40
+ { sourceType: 'twin', record: { ...HEADER, action: 'OBSERVE', epc: 'urn:epc:id:sgtin:0614141.107346.2018' } },
41
+ {
42
+ sourceType: 'twin-transformation',
43
+ record: {
44
+ ...HEADER,
45
+ inputEPCList: ['urn:epc:id:sgtin:0614141.107346.1000'],
46
+ outputEPCList: ['urn:epc:id:sgtin:0614141.107346.2000'],
47
+ transformationID: 'urn:uuid:11111111-2222-3333-4444-555555555555'
48
+ }
49
+ },
50
+ {
51
+ sourceType: 'twin-aggregation',
52
+ record: { ...HEADER, action: 'ADD', parentID: 'urn:epc:id:sscc:0614141.1234567890', childEPCs: ['urn:epc:id:sgtin:0614141.107346.2018'] }
53
+ }
54
+ ]
55
+
56
+ function mapWith(sourceType: string, record: Record<string, unknown>): any {
57
+ const rule = canonicalRulesForTest().find(r => r.sourceType === sourceType)
58
+
59
+ assert.ok(rule, `${sourceType} 규칙이 없다 — 갈래가 사라졌다`)
60
+
61
+ return mapRecordChecked(record as any, rule!.mapping as any, AT)
62
+ }
63
+
64
+ test('규칙이 셋이다 — 갈래가 줄면 그 사실이 조용히 사라진다', () => {
65
+ assert.deepEqual(
66
+ canonicalRulesForTest().map(r => r.sourceType).sort(),
67
+ ['twin', 'twin-aggregation', 'twin-transformation']
68
+ )
69
+ })
70
+
71
+ for (const { sourceType, record } of RECORDS) {
72
+ test(`${sourceType} 이 오더 참조를 커널까지 넘긴다 — 이 파일에서 사라지던 자리다`, () => {
73
+ const { event, errors } = mapWith(sourceType, record)
74
+
75
+ assert.deepEqual(errors, [], `${sourceType}: 성립하는 레코드인데 오류가 났다`)
76
+ assert.deepEqual(
77
+ event.bizTransactionList,
78
+ HEADER.bizTransactionList,
79
+ `${sourceType}: 오더 참조가 사라졌다 — 커넥터가 실어도 저널의 biz_transaction 이 빈다`
80
+ )
81
+ })
82
+
83
+ test(`${sourceType} 이 나머지 헤더 필드도 함께 넘긴다 — 하나만 보면 다음 것이 또 샌다`, () => {
84
+ const { event } = mapWith(sourceType, record)
85
+
86
+ assert.equal(event.bizStep, HEADER.bizStep)
87
+ assert.equal(event.disposition, HEADER.disposition)
88
+ assert.equal(event.readPoint?.id, HEADER.readPoint)
89
+ assert.equal(event.bizLocation?.id, HEADER.bizLocation)
90
+ assert.equal(event.errorDeclaration?.declarationTime, AT)
91
+ })
92
+ }
93
+
94
+ test('오더 참조를 안 실으면 그 칸을 만들지 않는다 — 빈 배열은 「참조가 없다」와 다르다', () => {
95
+ const { bizTransactionList: _drop, ...bare } = RECORDS[0].record
96
+ const { event, errors } = mapWith('twin', bare)
97
+
98
+ assert.deepEqual(errors, [])
99
+ assert.equal('bizTransactionList' in event, false)
100
+ })
@@ -0,0 +1,72 @@
1
+ /*
2
+ * ★ **보는 사람이 없어도 창이 닫힌다** — 아무도 안 보는 트윈일수록 기록이 없었다.
3
+ *
4
+ * ── 실측 (2026-09-05) ──────────────────────────────────────────────────────
5
+ * mes-miratek 사실 46건 · 62분에 흩어짐 창 1개
6
+ * mes-line-a 사실 11건 창 48개
7
+ *
8
+ * **사실 수와 창 수가 거꾸로다.** 창은 「다음 사건이 왔을 때」와 「누가 물어볼 때」만 굴렀고,
9
+ * `ingestHealthOf()` 가 굴리는 쪽이 실제로 창을 만들고 있었다 — 점검표·화면이 계속 묻는 트윈만
10
+ * 기록이 쌓였다.
11
+ *
12
+ * 그래서 **아무도 안 보는 트윈일수록 기록이 없었다.** 사실이 조용히 사라지는 것을 잡으려고 만든
13
+ * 장부가 정확히 그 반대로 돌았다. 실제로 137봉투가 장부 없이 지나갔다(인티그레이션 레인 실측).
14
+ *
15
+ * ── 왜 주기인가 ────────────────────────────────────────────────────────────
16
+ * 창이 닫히는 이유는 **시간이 지난 것**이다. 사건도 질의도 그것을 알려 주는 신호일 뿐이고 둘 다
17
+ * 안 올 수 있다. 시간은 늘 지나므로 주기가 그 사실에 맞는 신호다.
18
+ */
19
+ import { test } from 'node:test'
20
+ import assert from 'node:assert/strict'
21
+
22
+ import { INGEST_WINDOW_MS, newIngestLedger, recordJournalWrite, rollIngestWindow } from '../server/engine/ingest-health.js'
23
+
24
+ const T0 = 1_757_000_000_000
25
+
26
+ test('★ 사건도 질의도 없이 시간만 지나면 창이 닫힌다', () => {
27
+ const ledger = newIngestLedger()
28
+ const closed: any[] = []
29
+
30
+ /* 사실 하나가 들어와 창이 열린다. */
31
+ recordJournalWrite(ledger, 1, T0, w => closed.push(w))
32
+ assert.equal(closed.length, 0, '열리자마자 닫히면 안 된다')
33
+
34
+ /* 그 뒤로 아무 일도 없다 — 주기만 온다. */
35
+ rollIngestWindow(ledger, T0 + INGEST_WINDOW_MS + 1, INGEST_WINDOW_MS, w => closed.push(w))
36
+
37
+ assert.equal(closed.length, 1, '시간이 지났는데 창이 안 닫혔다 — 기록이 안 남는다')
38
+ assert.equal(closed[0].rows, 1, '닫힌 창이 그 사실을 안 들었다')
39
+ })
40
+
41
+ test('★ 창 길이 전에는 안 닫는다 — 주기가 창을 잘게 쪼개면 안 된다', () => {
42
+ const ledger = newIngestLedger()
43
+ const closed: any[] = []
44
+ recordJournalWrite(ledger, 1, T0)
45
+
46
+ /* 주기(30초)가 창 길이(10분)보다 짧다 — 여러 번 와도 안 닫혀야 한다. */
47
+ for (let i = 1; i <= 10; i++) rollIngestWindow(ledger, T0 + i * 30_000, INGEST_WINDOW_MS, w => closed.push(w))
48
+
49
+ assert.equal(closed.length, 0, `주기가 창을 ${closed.length}개로 쪼갰다`)
50
+ })
51
+
52
+ test('★ 조용한 구간도 창으로 남는다 — expected-quiet 판정이 그 기록 위에 선다', () => {
53
+ /*
54
+ * 사실이 하나도 안 온 구간이 표에 남아야 「조용한 것이 정상인가」를 판정할 수 있다. 그 구간이
55
+ * 아예 기록되지 않으면 조용함과 기록 없음이 구별되지 않는다.
56
+ */
57
+ const ledger = newIngestLedger()
58
+ const closed: any[] = []
59
+ recordJournalWrite(ledger, 1, T0, w => closed.push(w))
60
+
61
+ /* 두 창을 통째로 건너뛴다 — 그 사이 아무 사실도 없었다. */
62
+ rollIngestWindow(ledger, T0 + INGEST_WINDOW_MS * 2 + 1, INGEST_WINDOW_MS, w => closed.push(w))
63
+
64
+ assert.ok(closed.length >= 1, '건너뛴 구간이 하나도 안 남았다')
65
+ })
66
+
67
+ test('★ 이 검사가 실제로 무언가를 잰다 — 견준 것이 0이면 빨강', () => {
68
+ assert.ok(INGEST_WINDOW_MS > 0, `창 길이가 ${INGEST_WINDOW_MS} 다`)
69
+ const ledger = newIngestLedger()
70
+ recordJournalWrite(ledger, 3, T0)
71
+ assert.equal(ledger.window.rows, 3, '장부가 사실을 안 세고 있다 — 이 검사가 아무것도 안 잰다')
72
+ })