@things-factory/headless-twin 10.0.12 → 10.0.14

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 (315) hide show
  1. package/dist-server/engine/canonical-ingest.d.ts +57 -2
  2. package/dist-server/engine/canonical-ingest.js +43 -2
  3. package/dist-server/engine/canonical-ingest.js.map +1 -1
  4. package/dist-server/engine/command-routing.js +1 -1
  5. package/dist-server/engine/command-routing.js.map +1 -1
  6. package/dist-server/engine/index.d.ts +3 -0
  7. package/dist-server/engine/index.js +4 -0
  8. package/dist-server/engine/index.js.map +1 -1
  9. package/dist-server/engine/ingest-health.d.ts +376 -0
  10. package/dist-server/engine/ingest-health.js +482 -0
  11. package/dist-server/engine/ingest-health.js.map +1 -0
  12. package/dist-server/engine/integration-coverage.d.ts +76 -0
  13. package/dist-server/engine/integration-coverage.js +73 -0
  14. package/dist-server/engine/integration-coverage.js.map +1 -0
  15. package/dist-server/engine/integration-probes.d.ts +65 -0
  16. package/dist-server/engine/integration-probes.js +100 -0
  17. package/dist-server/engine/integration-probes.js.map +1 -0
  18. package/dist-server/engine/integration-runner.d.ts +45 -0
  19. package/dist-server/engine/integration-runner.js +59 -0
  20. package/dist-server/engine/integration-runner.js.map +1 -0
  21. package/dist-server/engine/integration-target-profile.d.ts +57 -0
  22. package/dist-server/engine/integration-target-profile.js +79 -0
  23. package/dist-server/engine/integration-target-profile.js.map +1 -0
  24. package/dist-server/engine/kpi-fold.d.ts +27 -0
  25. package/dist-server/engine/kpi-fold.js +47 -4
  26. package/dist-server/engine/kpi-fold.js.map +1 -1
  27. package/dist-server/engine/kpi-query.js +128 -14
  28. package/dist-server/engine/kpi-query.js.map +1 -1
  29. package/dist-server/engine/live-feed-registry.js +2 -16
  30. package/dist-server/engine/live-feed-registry.js.map +1 -1
  31. package/dist-server/engine/load-meter.d.ts +1 -1
  32. package/dist-server/engine/load-meter.js +12 -3
  33. package/dist-server/engine/load-meter.js.map +1 -1
  34. package/dist-server/engine/log.d.ts +18 -0
  35. package/dist-server/engine/log.js +80 -0
  36. package/dist-server/engine/log.js.map +1 -0
  37. package/dist-server/engine/loop-lag.d.ts +54 -0
  38. package/dist-server/engine/loop-lag.js +87 -0
  39. package/dist-server/engine/loop-lag.js.map +1 -0
  40. package/dist-server/engine/model-gap.d.ts +108 -0
  41. package/dist-server/engine/model-gap.js +95 -0
  42. package/dist-server/engine/model-gap.js.map +1 -0
  43. package/dist-server/engine/restart-policy.d.ts +3 -3
  44. package/dist-server/engine/restart-policy.js +4 -4
  45. package/dist-server/engine/restart-policy.js.map +1 -1
  46. package/dist-server/engine/runtime-key.js +1 -1
  47. package/dist-server/engine/runtime-key.js.map +1 -1
  48. package/dist-server/engine/stage-path.d.ts +83 -0
  49. package/dist-server/engine/stage-path.js +118 -0
  50. package/dist-server/engine/stage-path.js.map +1 -0
  51. package/dist-server/engine/twin-engine.d.ts +386 -16
  52. package/dist-server/engine/twin-engine.js +1217 -172
  53. package/dist-server/engine/twin-engine.js.map +1 -1
  54. package/dist-server/index.js +34 -2
  55. package/dist-server/index.js.map +1 -1
  56. package/dist-server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.d.ts +5 -0
  57. package/dist-server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.js +58 -0
  58. package/dist-server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.js.map +1 -0
  59. package/dist-server/migrations/1786200000000-CarryLiveFeedCursor.d.ts +5 -0
  60. package/dist-server/migrations/1786200000000-CarryLiveFeedCursor.js +55 -0
  61. package/dist-server/migrations/1786200000000-CarryLiveFeedCursor.js.map +1 -0
  62. package/dist-server/migrations/index.js +7 -1
  63. package/dist-server/migrations/index.js.map +1 -1
  64. package/dist-server/service/index.d.ts +5 -2
  65. package/dist-server/service/index.js +18 -7
  66. package/dist-server/service/index.js.map +1 -1
  67. package/dist-server/service/reference/discovery-result.d.ts +1 -1
  68. package/dist-server/service/reference/discovery-result.js +1 -1
  69. package/dist-server/service/reference/discovery-result.js.map +1 -1
  70. package/dist-server/service/reference/index.d.ts +4 -1
  71. package/dist-server/service/reference/index.js +6 -1
  72. package/dist-server/service/reference/index.js.map +1 -1
  73. package/dist-server/service/reference/reference-adapter.d.ts +210 -3
  74. package/dist-server/service/reference/reference-adapter.js +28 -4
  75. package/dist-server/service/reference/reference-adapter.js.map +1 -1
  76. package/dist-server/service/reference/reference-assessment.d.ts +78 -0
  77. package/dist-server/service/reference/reference-assessment.js +156 -0
  78. package/dist-server/service/reference/reference-assessment.js.map +1 -0
  79. package/dist-server/service/reference/reference-live.d.ts +12 -1
  80. package/dist-server/service/reference/reference-live.js +134 -11
  81. package/dist-server/service/reference/reference-live.js.map +1 -1
  82. package/dist-server/service/reference/reference-master.d.ts +59 -3
  83. package/dist-server/service/reference/reference-master.js +34 -4
  84. package/dist-server/service/reference/reference-master.js.map +1 -1
  85. package/dist-server/service/reference/reference-probe.d.ts +92 -0
  86. package/dist-server/service/reference/reference-probe.js +186 -0
  87. package/dist-server/service/reference/reference-probe.js.map +1 -0
  88. package/dist-server/service/reference/reference-progress-subscription.d.ts +17 -0
  89. package/dist-server/service/reference/reference-progress-subscription.js +94 -0
  90. package/dist-server/service/reference/reference-progress-subscription.js.map +1 -0
  91. package/dist-server/service/reference/reference-progress.d.ts +38 -0
  92. package/dist-server/service/reference/reference-progress.js +71 -0
  93. package/dist-server/service/reference/reference-progress.js.map +1 -0
  94. package/dist-server/service/reference/reference-resolver.js +210 -43
  95. package/dist-server/service/reference/reference-resolver.js.map +1 -1
  96. package/dist-server/service/reference/twin-reference.d.ts +32 -0
  97. package/dist-server/service/reference/twin-reference.js +10 -0
  98. package/dist-server/service/reference/twin-reference.js.map +1 -1
  99. package/dist-server/service/twin-control/twin-control-mutation.js +4 -3
  100. package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
  101. package/dist-server/service/twin-event/journal-count.d.ts +21 -0
  102. package/dist-server/service/twin-event/journal-count.js +26 -0
  103. package/dist-server/service/twin-event/journal-count.js.map +1 -0
  104. package/dist-server/service/twin-event/twin-event-keys.d.ts +9 -0
  105. package/dist-server/service/twin-event/twin-event-keys.js +15 -18
  106. package/dist-server/service/twin-event/twin-event-keys.js.map +1 -1
  107. package/dist-server/service/twin-event/twin-event-type.d.ts +3 -1
  108. package/dist-server/service/twin-event/twin-event-type.js +18 -1
  109. package/dist-server/service/twin-event/twin-event-type.js.map +1 -1
  110. package/dist-server/service/twin-event/twin-event.d.ts +1 -0
  111. package/dist-server/service/twin-event/twin-event.js +9 -1
  112. package/dist-server/service/twin-event/twin-event.js.map +1 -1
  113. package/dist-server/service/twin-forecast/twin-forecast-query.js +26 -2
  114. package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
  115. package/dist-server/service/twin-ingest-window/index.d.ts +5 -0
  116. package/dist-server/service/twin-ingest-window/index.js +10 -0
  117. package/dist-server/service/twin-ingest-window/index.js.map +1 -0
  118. package/dist-server/service/twin-ingest-window/twin-ingest-window-query.d.ts +12 -0
  119. package/dist-server/service/twin-ingest-window/twin-ingest-window-query.js +123 -0
  120. package/dist-server/service/twin-ingest-window/twin-ingest-window-query.js.map +1 -0
  121. package/dist-server/service/twin-ingest-window/twin-ingest-window-writer.d.ts +1 -0
  122. package/dist-server/service/twin-ingest-window/twin-ingest-window-writer.js +51 -0
  123. package/dist-server/service/twin-ingest-window/twin-ingest-window-writer.js.map +1 -0
  124. package/dist-server/service/twin-ingest-window/twin-ingest-window.d.ts +16 -0
  125. package/dist-server/service/twin-ingest-window/twin-ingest-window.js +108 -0
  126. package/dist-server/service/twin-ingest-window/twin-ingest-window.js.map +1 -0
  127. package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
  128. package/dist-server/service/twin-journal/twin-journal-query.js +58 -5
  129. package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
  130. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.d.ts +14 -0
  131. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +72 -2
  132. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
  133. package/dist-server/service/twin-metrics/twin-metrics-query.d.ts +1 -0
  134. package/dist-server/service/twin-metrics/twin-metrics-query.js +78 -3
  135. package/dist-server/service/twin-metrics/twin-metrics-query.js.map +1 -1
  136. package/dist-server/service/twin-model/axis-journal-evidence.d.ts +2 -0
  137. package/dist-server/service/twin-model/axis-journal-evidence.js +89 -0
  138. package/dist-server/service/twin-model/axis-journal-evidence.js.map +1 -0
  139. package/dist-server/service/twin-model/epcis-coverage.d.ts +1 -1
  140. package/dist-server/service/twin-model/epcis-coverage.js +46 -7
  141. package/dist-server/service/twin-model/epcis-coverage.js.map +1 -1
  142. package/dist-server/service/twin-model/iec61850-coverage.d.ts +1 -1
  143. package/dist-server/service/twin-model/iec61850-coverage.js +18 -7
  144. package/dist-server/service/twin-model/iec61850-coverage.js.map +1 -1
  145. package/dist-server/service/twin-model/isa95-coverage.d.ts +14 -0
  146. package/dist-server/service/twin-model/isa95-coverage.js +108 -14
  147. package/dist-server/service/twin-model/isa95-coverage.js.map +1 -1
  148. package/dist-server/service/twin-model/item-ref.d.ts +24 -0
  149. package/dist-server/service/twin-model/item-ref.js +88 -0
  150. package/dist-server/service/twin-model/item-ref.js.map +1 -0
  151. package/dist-server/service/twin-model/name-index.d.ts +36 -0
  152. package/dist-server/service/twin-model/name-index.js +116 -0
  153. package/dist-server/service/twin-model/name-index.js.map +1 -0
  154. package/dist-server/service/twin-model/project-structure.js +1 -1
  155. package/dist-server/service/twin-model/project-structure.js.map +1 -1
  156. package/dist-server/service/twin-model/status-tally.d.ts +9 -0
  157. package/dist-server/service/twin-model/status-tally.js +37 -0
  158. package/dist-server/service/twin-model/status-tally.js.map +1 -0
  159. package/dist-server/service/twin-model/twin-lineage-query.js +40 -12
  160. package/dist-server/service/twin-model/twin-lineage-query.js.map +1 -1
  161. package/dist-server/service/twin-model/twin-model-item-query.js +38 -39
  162. package/dist-server/service/twin-model/twin-model-item-query.js.map +1 -1
  163. package/dist-server/service/twin-model/twin-model-query.js +156 -4
  164. package/dist-server/service/twin-model/twin-model-query.js.map +1 -1
  165. package/dist-server/service/twin-model/twin-model-tree-query.js +7 -0
  166. package/dist-server/service/twin-model/twin-model-tree-query.js.map +1 -1
  167. package/dist-server/service/twin-readiness/index.d.ts +2 -0
  168. package/dist-server/service/twin-readiness/index.js +6 -0
  169. package/dist-server/service/twin-readiness/index.js.map +1 -0
  170. package/dist-server/service/twin-readiness/twin-readiness-query.d.ts +3 -0
  171. package/dist-server/service/twin-readiness/twin-readiness-query.js +103 -0
  172. package/dist-server/service/twin-readiness/twin-readiness-query.js.map +1 -0
  173. package/dist-server/service/twin-space/twin-space-resolver.d.ts +2 -2
  174. package/dist-server/service/twin-space/twin-space-resolver.js +5 -4
  175. package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
  176. package/dist-shared/entity-delta.d.ts +23 -3
  177. package/dist-shared/entity-delta.js +12 -8
  178. package/dist-shared/entity-delta.js.map +1 -1
  179. package/dist-shared/kpi-broadcast.js +1 -1
  180. package/dist-shared/kpi-broadcast.js.map +1 -1
  181. package/dist-shared/touched-items.d.ts +7 -0
  182. package/dist-shared/touched-items.js +80 -0
  183. package/dist-shared/touched-items.js.map +1 -0
  184. package/package.json +7 -7
  185. package/server/engine/canonical-ingest.ts +92 -7
  186. package/server/engine/command-routing.ts +1 -1
  187. package/server/engine/index.ts +4 -0
  188. package/server/engine/ingest-health.ts +782 -0
  189. package/server/engine/integration-coverage.ts +147 -0
  190. package/server/engine/integration-probes.ts +144 -0
  191. package/server/engine/integration-runner.ts +95 -0
  192. package/server/engine/integration-target-profile.ts +103 -0
  193. package/server/engine/kpi-fold.ts +72 -4
  194. package/server/engine/kpi-query.ts +129 -15
  195. package/server/engine/live-feed-registry.ts +2 -1
  196. package/server/engine/load-meter.ts +12 -3
  197. package/server/engine/log.ts +72 -0
  198. package/server/engine/loop-lag.ts +120 -0
  199. package/server/engine/model-gap.ts +168 -0
  200. package/server/engine/restart-policy.ts +4 -4
  201. package/server/engine/runtime-key.ts +1 -1
  202. package/server/engine/stage-path.ts +172 -0
  203. package/server/engine/twin-engine.ts +1334 -168
  204. package/server/index.ts +35 -2
  205. package/server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.ts +54 -0
  206. package/server/migrations/1786200000000-CarryLiveFeedCursor.ts +53 -0
  207. package/server/migrations/index.ts +7 -1
  208. package/server/service/index.ts +11 -0
  209. package/server/service/reference/discovery-result.ts +1 -1
  210. package/server/service/reference/index.ts +6 -1
  211. package/server/service/reference/reference-adapter.ts +220 -5
  212. package/server/service/reference/reference-assessment.ts +250 -0
  213. package/server/service/reference/reference-live.ts +143 -11
  214. package/server/service/reference/reference-master.ts +76 -6
  215. package/server/service/reference/reference-probe.ts +264 -0
  216. package/server/service/reference/reference-progress-subscription.ts +73 -0
  217. package/server/service/reference/reference-progress.ts +95 -0
  218. package/server/service/reference/reference-resolver.ts +200 -12
  219. package/server/service/reference/twin-reference.ts +39 -1
  220. package/server/service/twin-control/twin-control-mutation.ts +4 -3
  221. package/server/service/twin-event/journal-count.ts +53 -0
  222. package/server/service/twin-event/twin-event-keys.ts +16 -1
  223. package/server/service/twin-event/twin-event-type.ts +26 -2
  224. package/server/service/twin-event/twin-event.ts +7 -0
  225. package/server/service/twin-forecast/twin-forecast-query.ts +29 -2
  226. package/server/service/twin-ingest-window/index.ts +7 -0
  227. package/server/service/twin-ingest-window/twin-ingest-window-query.ts +125 -0
  228. package/server/service/twin-ingest-window/twin-ingest-window-writer.ts +57 -0
  229. package/server/service/twin-ingest-window/twin-ingest-window.ts +113 -0
  230. package/server/service/twin-instance/twin-instance.ts +1 -1
  231. package/server/service/twin-journal/twin-journal-query.ts +59 -5
  232. package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +77 -3
  233. package/server/service/twin-metrics/twin-metrics-query.ts +81 -3
  234. package/server/service/twin-model/axis-journal-evidence.ts +57 -0
  235. package/server/service/twin-model/epcis-coverage.ts +29 -8
  236. package/server/service/twin-model/iec61850-coverage.ts +2 -8
  237. package/server/service/twin-model/isa95-coverage.ts +107 -14
  238. package/server/service/twin-model/item-ref.ts +80 -0
  239. package/server/service/twin-model/name-index.ts +94 -0
  240. package/server/service/twin-model/project-structure.ts +1 -1
  241. package/server/service/twin-model/status-tally.ts +37 -0
  242. package/server/service/twin-model/twin-lineage-query.ts +38 -9
  243. package/server/service/twin-model/twin-model-item-query.ts +26 -27
  244. package/server/service/twin-model/twin-model-query.ts +155 -4
  245. package/server/service/twin-model/twin-model-tree-query.ts +7 -0
  246. package/server/service/twin-readiness/index.ts +3 -0
  247. package/server/service/twin-readiness/twin-readiness-query.ts +96 -0
  248. package/server/service/twin-space/twin-space-resolver.ts +5 -4
  249. package/shared/entity-delta.ts +31 -7
  250. package/shared/kpi-broadcast.ts +1 -1
  251. package/shared/touched-items.ts +73 -0
  252. package/test/axis-journal-evidence.test.ts +71 -0
  253. package/test/axis-read.test.ts +101 -1
  254. package/test/boot-resume.test.ts +92 -26
  255. package/test/broadcast-cost-baseline.test.ts +200 -0
  256. package/test/broadcast-period.test.ts +58 -0
  257. package/test/canonical-ingest-vocabularies.test.ts +36 -1
  258. package/test/canonical-quantity-door.test.ts +61 -0
  259. package/test/checkpoint-refuses-empty.test.ts +103 -0
  260. package/test/command-routing.test.ts +1 -1
  261. package/test/cursor-stall-not-read-failure.test.ts +126 -0
  262. package/test/declared-location-types.test.ts +89 -0
  263. package/test/discovery-result.test.ts +1 -1
  264. package/test/duration-estimators.test.ts +1 -1
  265. package/test/entity-delta.test.ts +2 -2
  266. package/test/ingest-bench.test.ts +3 -3
  267. package/test/ingest-health-engine.test.ts +248 -0
  268. package/test/ingest-health-wiring.test.ts +119 -0
  269. package/test/ingest-health.test.ts +306 -0
  270. package/test/ingest-history.test.ts +247 -0
  271. package/test/integration-probes.test.ts +103 -0
  272. package/test/integration-runner.test.ts +95 -0
  273. package/test/item-ref.test.ts +78 -0
  274. package/test/journal-read-discipline.test.ts +177 -0
  275. package/test/journal-retention.test.ts +133 -0
  276. package/test/journal-sort-axis.test.ts +142 -0
  277. package/test/journal-write-door.test.ts +110 -0
  278. package/test/journal-write-trend.test.ts +115 -0
  279. package/test/kernel-kind-guard.test.ts +4 -4
  280. package/test/kpi-fold.test.ts +90 -3
  281. package/test/kpi-query-bench.test.ts +2 -2
  282. package/test/lineage-survives-restart.test.ts +17 -1
  283. package/test/live-cursor-wiring.test.ts +87 -0
  284. package/test/live-feed-registry.test.ts +9 -1
  285. package/test/live-kernel-facts.test.ts +7 -7
  286. package/test/live-mirror-parity.test.ts +6 -0
  287. package/test/load-meter.test.ts +29 -15
  288. package/test/local-declarations.test.ts +1 -1
  289. package/test/log-stamp.test.ts +59 -0
  290. package/test/loop-lag.test.ts +82 -0
  291. package/test/mirror-resumes-from-checkpoint.test.ts +192 -0
  292. package/test/model-gap.test.ts +153 -0
  293. package/test/oee-accumulator.test.ts +4 -0
  294. package/test/operational-vocabulary.test.ts +4 -4
  295. package/test/read-failure-visible.test.ts +145 -0
  296. package/test/reference-grounding.test.ts +70 -0
  297. package/test/resolve-ts-siblings.mjs +52 -0
  298. package/test/restart-policy.test.ts +7 -7
  299. package/test/revision-axis.test.ts +104 -0
  300. package/test/runtime-key.test.ts +2 -2
  301. package/test/scale-twin-bench.test.ts +2 -2
  302. package/test/spec-coverage.test.ts +1 -1
  303. package/test/stage-path.test.ts +95 -0
  304. package/test/standard-coverage.test.ts +15 -3
  305. package/test/status-tally.test.ts +55 -0
  306. package/test/structure-revision-db.test.ts +4 -0
  307. package/test/tenant-registry-db.test.ts +1 -1
  308. package/test/time-range.test.ts +152 -0
  309. package/test/touched-items.test.ts +61 -0
  310. package/test/twin-event-keys.test.ts +10 -2
  311. package/test/twin-model-item-db.test.ts +2 -0
  312. package/test/warm-start-seam.test.ts +4 -0
  313. package/test/yield-loop.test.ts +57 -2
  314. package/tsconfig.shared.tsbuildinfo +1 -1
  315. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,247 @@
1
+ /*
2
+ * ★ 인제스트 이력 — **재기동 뒤에도 추이가 이어진다.**
3
+ *
4
+ * ── 무엇이 미완이었나 (2026-08-20) ──────────────────────────────────────────
5
+ * 닫힌 창을 표로 남기기 시작했는데 **읽는 코드가 없었다.** 21행이 쌓여 있고 아무도 보지 않았다 —
6
+ * 이 프로젝트가 결함으로 보는 상태다(「필드만 만들고 아무도 읽지 않는다」). 표를 만든 이유가
7
+ * 「메모리 장부는 재기동에 사라지고 6시간까지인데 추이는 그보다 오래 봐야 한다」였으니 목적이
8
+ * 절반만 서 있었다.
9
+ *
10
+ * ── 지키는 것 ───────────────────────────────────────────────────────────────
11
+ * ① 표와 메모리를 이어 붙이되 **겹친 창을 두 번 내지 않는다** — 같은 구간이 두 번 그려지면 추이가
12
+ * 거짓이 된다. 겹치면 메모리 쪽을 쓴다(같은 사실의 원본이다).
13
+ * ② 어디서부터 메모리인지 밝힌다(`liveFrom`) — 화면이 근거를 말할 수 있게. 재기동 직후에는 `null`
14
+ * 이고, 그때 보이는 것은 전부 표에서 온 것이다.
15
+ * ③ **조용히 자르지 않는다** — 상한에서 밀려난 창 수와, 물은 기간이 잘렸다는 사실을 함께 낸다.
16
+ * ④ 남의 도메인·남의 트윈의 창을 집지 않는다.
17
+ * ⑤ 제시가 0 인 창의 통과율은 `null` 이다 — 빈 창은 완벽한 창이 아니다.
18
+ */
19
+ import { test, before, after } from 'node:test'
20
+ import assert from 'node:assert/strict'
21
+ import { createRequire } from 'node:module'
22
+ import type { DataSource } from 'typeorm'
23
+
24
+ const req = createRequire(import.meta.url)
25
+ const { NamingStrategy, addDataSource, entities: shellEntities, Domain } = req('@things-factory/shell')
26
+ const { entities: authEntities } = req('@things-factory/auth-base')
27
+ const { entities: twinEntities } = req('../dist-server/service/index.js')
28
+ const { TwinEngine } = req('../dist-server/engine/index.js')
29
+ const { INGEST_WINDOW_MS } = req('../dist-server/engine/ingest-health.js')
30
+ const { TwinIngestWindow } = req('../dist-server/service/twin-ingest-window/twin-ingest-window.js')
31
+ const { TwinIngestWindowQuery, HISTORY_WINDOW_LIMIT, HISTORY_HOURS_MAX } = req(
32
+ '../dist-server/service/twin-ingest-window/twin-ingest-window-query.js'
33
+ )
34
+ const { TwinInstance } = req('../dist-server/service/twin-instance/twin-instance.js')
35
+ const { DataSource: TypeOrmDataSource } = req('typeorm')
36
+
37
+ let ds: DataSource
38
+ let domainId: string
39
+ let otherDomainId: string
40
+
41
+ const TWIN = 'hist-twin'
42
+ const HOUR = 3_600_000
43
+
44
+ const resolver = new TwinIngestWindowQuery()
45
+ const ask = (instanceId: string, hours?: number, domain = domainId) =>
46
+ resolver.twinIngestHistory(instanceId, hours, { state: { domain: { id: domain } } } as any)
47
+
48
+ /** 표에 닫힌 창 하나 — 시각은 「몇 시간 전」으로 준다. */
49
+ async function storedWindow(instanceId: string, hoursAgo: number, offered: number, rejected = 0, domain = domainId) {
50
+ const closed = new Date(Date.now() - hoursAgo * HOUR)
51
+ const opened = new Date(closed.getTime() - 600_000)
52
+ const repo = ds.getRepository(TwinIngestWindow)
53
+ await repo.save(
54
+ repo.create({
55
+ domain: { id: domain } as any,
56
+ instanceId,
57
+ openedAt: opened,
58
+ closedAt: closed,
59
+ offered,
60
+ rejected,
61
+ undelivered: 0,
62
+ journaledRows: 0,
63
+ journalFlushes: 0,
64
+ ...(offered > 0 ? { acceptedRatio: (offered - rejected) / offered } : {})
65
+ } as any)
66
+ )
67
+ return opened.toISOString()
68
+ }
69
+
70
+ async function instance(instanceId: string, domain = domainId) {
71
+ const repo = ds.getRepository(TwinInstance)
72
+ await repo.save(
73
+ repo.create({
74
+ domain: { id: domain } as any,
75
+ instanceId,
76
+ spaceId: 'hist-space',
77
+ kind: 'wms',
78
+ status: 'stopped',
79
+ restartPolicy: 'reset',
80
+ purpose: 'operational'
81
+ } as any)
82
+ )
83
+ }
84
+
85
+ before(async () => {
86
+ ds = new TypeOrmDataSource({
87
+ type: 'sqlite',
88
+ database: ':memory:',
89
+ synchronize: true,
90
+ dropSchema: true,
91
+ namingStrategy: new NamingStrategy(),
92
+ entities: [...shellEntities, ...authEntities, ...twinEntities],
93
+ logging: false
94
+ })
95
+ await ds.initialize()
96
+ addDataSource('default', ds)
97
+
98
+ domainId = (await ds.getRepository(Domain).save({ name: 'hist', subdomain: 'hist' } as any)).id
99
+ otherDomainId = (await ds.getRepository(Domain).save({ name: 'hist-other', subdomain: 'hist-other' } as any)).id
100
+ await instance(TWIN)
101
+ })
102
+
103
+ after(async () => {
104
+ await ds?.destroy()
105
+ })
106
+
107
+ test('표에 남은 창을 읽어 온다 — 재기동으로 메모리가 비어도 추이가 있다', async () => {
108
+ await storedWindow(TWIN, 5, 100, 4)
109
+ await storedWindow(TWIN, 3, 200, 0)
110
+
111
+ const r = await ask(TWIN)
112
+ assert.equal(r.windows.length, 2)
113
+ assert.equal(r.storedCount, 2)
114
+ assert.equal(r.liveCount, 0, '메모리에는 닫힌 창이 없다(이 트윈은 이 프로세스에서 받은 적이 없다)')
115
+ assert.equal(r.liveFrom, null, '그래서 「여기서부터 메모리」가 없다 — 0 이나 지금으로 채우지 않는다')
116
+ })
117
+
118
+ test('오래된 것이 앞, 최신이 뒤 — 추이는 시간순이어야 그림이 된다', async () => {
119
+ const r = await ask(TWIN)
120
+ const times = r.windows.map((w: any) => Date.parse(w.from))
121
+ assert.deepEqual(times, [...times].sort((a, b) => a - b), '오름차순')
122
+ })
123
+
124
+ test('통과는 트윈에 닿은 것만 — 표의 통과율을 그대로 쓴다', async () => {
125
+ const r = await ask(TWIN)
126
+ const w = r.windows.find((x: any) => x.offered === 100)
127
+ assert.equal(w.rejected, 4)
128
+ assert.equal(w.accepted, 96)
129
+ assert.equal(w.acceptedRatio, 0.96)
130
+ })
131
+
132
+ test('제시가 0 인 창의 통과율은 「없음」이다 — 빈 창은 완벽한 창이 아니다', async () => {
133
+ await storedWindow(TWIN, 2, 0, 0)
134
+
135
+ const r = await ask(TWIN)
136
+ const empty = r.windows.find((x: any) => x.offered === 0)
137
+ assert.equal(empty.acceptedRatio, null)
138
+ })
139
+
140
+ test('물은 기간 밖의 창은 오지 않는다', async () => {
141
+ await storedWindow(TWIN, 40, 999, 0) // 40시간 전
142
+
143
+ const r = await ask(TWIN, 24)
144
+ assert.ok(
145
+ !r.windows.some((w: any) => w.offered === 999),
146
+ '24시간을 물었으면 40시간 전 창은 안 온다'
147
+ )
148
+
149
+ const wide = await ask(TWIN, 48)
150
+ assert.ok(
151
+ wide.windows.some((w: any) => w.offered === 999),
152
+ '48시간을 물으면 온다'
153
+ )
154
+ })
155
+
156
+ test('기간 상한을 넘겨 물으면 자르고, 자른 사실을 낸다', async () => {
157
+ const r = await ask(TWIN, HISTORY_HOURS_MAX + 100)
158
+
159
+ assert.equal(r.hours, HISTORY_HOURS_MAX, '상한까지만 본다')
160
+ assert.equal(r.hoursClamped, HISTORY_HOURS_MAX + 100, '얼마를 물었는지 밝힌다 — 조용히 자르지 않는다')
161
+ })
162
+
163
+ test('상한 안에서는 잘렸다고 말하지 않는다 — 없는 문제를 만들지 않는다', async () => {
164
+ const r = await ask(TWIN, 24)
165
+ assert.equal(r.hoursClamped, undefined)
166
+ assert.equal(r.storedDropped, 0)
167
+ })
168
+
169
+ test('창 수 상한에서 밀려난 수를 센다', async () => {
170
+ const many = 'hist-many'
171
+ await instance(many)
172
+ /* 상한 + 5개를 심는다. 10분 간격이므로 상한을 넘기려면 그만큼의 창이 필요하다. */
173
+ for (let i = 0; i < HISTORY_WINDOW_LIMIT + 5; i++) await storedWindow(many, 1 + i * 0.01, 10, 0)
174
+
175
+ const r = await ask(many, HISTORY_HOURS_MAX)
176
+ assert.equal(r.windows.length, HISTORY_WINDOW_LIMIT, '상한까지만 낸다')
177
+ assert.equal(r.storedDropped, 5, '밀려난 수를 말한다')
178
+ })
179
+
180
+ test('남의 도메인 창을 집지 않는다', async () => {
181
+ await instance('hist-shared', otherDomainId)
182
+ await storedWindow('hist-shared', 1, 777, 0, otherDomainId)
183
+
184
+ /* 같은 instanceId 를 내 도메인에서 물어도 남의 창이 오면 안 된다. */
185
+ await instance('hist-shared')
186
+ const mine = await ask('hist-shared')
187
+ assert.ok(
188
+ !mine.windows.some((w: any) => w.offered === 777),
189
+ '남의 도메인 창이 새지 않는다'
190
+ )
191
+ })
192
+
193
+ test('내 도메인에 없는 트윈은 「없음」이다 — 남의 것이라 말하지 않는다', async () => {
194
+ assert.equal(await ask('does-not-exist'), null)
195
+ })
196
+
197
+ test('겹친 창을 두 번 내지 않는다 — 같은 구간이 두 번 그려지면 추이가 거짓이 된다', async () => {
198
+ /*
199
+ * 프로세스가 살아 있는 동안 닫힌 창은 **표와 메모리에 둘 다** 있다(영속 훅이 창이 닫힐 때 쓴다).
200
+ * 정리하지 않으면 그 구간이 추이에 두 번 들어가 유입이 두 배로 보인다.
201
+ */
202
+ const twin = 'hist-overlap'
203
+ await instance(twin)
204
+
205
+ /* 메모리에 닫힌 창 하나를 만든다 — 창은 다음 유입이 있을 때 닫힌다. */
206
+ const t0 = Date.now() - Math.floor(INGEST_WINDOW_MS * 1.4)
207
+ TwinEngine.recordIngestResult(domainId, twin, 50, [], t0)
208
+ TwinEngine.recordIngestResult(domainId, twin, 1, [], t0 + INGEST_WINDOW_MS + 1)
209
+
210
+ const live = TwinEngine.ingestHealthOf(domainId, twin).trend
211
+ assert.equal(live.length, 1, '메모리에 닫힌 창 하나')
212
+
213
+ /* 같은 창을 표에도 심는다(영속 훅이 했을 일). 값은 일부러 다르게 두어 어느 쪽이 이겼는지 본다. */
214
+ const repo = ds.getRepository(TwinIngestWindow)
215
+ await repo.save(
216
+ repo.create({
217
+ domain: { id: domainId } as any,
218
+ instanceId: twin,
219
+ openedAt: new Date(Date.parse(live[0].from)),
220
+ closedAt: new Date(Date.parse(live[0].to)),
221
+ offered: 999,
222
+ rejected: 0,
223
+ undelivered: 0,
224
+ journaledRows: 0,
225
+ journalFlushes: 0,
226
+ acceptedRatio: 1
227
+ } as any)
228
+ )
229
+
230
+ const r = await ask(twin)
231
+ const sameStart = r.windows.filter((w: any) => Date.parse(w.from) === Date.parse(live[0].from))
232
+ assert.equal(sameStart.length, 1, '한 번만 낸다')
233
+ assert.equal(sameStart[0].source, 'live', '겹치면 메모리 쪽 — 같은 사실의 원본이다')
234
+ assert.equal(sameStart[0].offered, 50, '표의 999 가 아니라 메모리의 50')
235
+ assert.equal(r.liveFrom, live[0].from, '어디서부터 메모리인지 밝힌다')
236
+ })
237
+
238
+ test('겹치지 않는 표의 창은 메모리 앞에 이어 붙는다', async () => {
239
+ const twin = 'hist-overlap'
240
+ /* 메모리 창보다 오래된 것 — 이건 표에서만 온다. */
241
+ await storedWindow(twin, 6, 30, 1)
242
+
243
+ const r = await ask(twin)
244
+ assert.equal(r.windows.length, 2, '표 하나 + 메모리 하나')
245
+ assert.equal(r.windows[0].source, 'stored', '오래된 것이 앞')
246
+ assert.equal(r.windows[1].source, 'live')
247
+ })
@@ -0,0 +1,103 @@
1
+ /*
2
+ * ★ 연결 적합성 하네스 — **절반만 돌린 스위트가 초록을 주장하지 못하게.**
3
+ *
4
+ * 지키는 것:
5
+ * ① 돌리지 않은 프로브는 **「모른다」**로 접힌다 → 판정이 그 단계를 달성으로 만들지 않는다.
6
+ * ② **실패한 프로브의 수를 0 으로 읽지 않는다.** 「0건이었다」와 「세지 못했다」는 다른 사실이다.
7
+ * ③ **쓰기 프로브는 동의 없이 계획에 들어오지 않는다** — 남의 운영 시스템에 명령을 넣는 일이다.
8
+ * ④ **게이팅 실패 뒤는 돌리지 않는다** — 빨간 칸을 늘리는 것은 원인을 알려 주지 않는다.
9
+ */
10
+ import { test } from 'node:test'
11
+ import assert from 'node:assert/strict'
12
+
13
+ import { PROBE_SEQUENCE, foldProbeResults, plannedProbes, type ProbeResult } from '../server/engine/integration-probes.ts'
14
+ import { assessStagePath, forbiddenClaims } from '../server/engine/stage-path.ts'
15
+
16
+ const pass = (id: any, count?: number, coverage?: { declared: number; total: number }): ProbeResult =>
17
+ ({ id, ran: true, passed: true, ...(count !== undefined ? { count } : {}), ...(coverage ? { coverage } : {}) })
18
+
19
+ /** 읽기 프로브 전부 통과 — 반출까지 관측된 상태. */
20
+ const readOnlyAllPass = (): ProbeResult[] => [
21
+ pass('connect'), pass('discover', 1), pass('master', 12), pass('master-warnings', 3), pass('items', 340),
22
+ pass('live-ingest', 1200), pass('live-removal', 17),
23
+ pass('declarations', undefined, { declared: 6, total: 8 }), pass('forecast'),
24
+ pass('actuate-declared')
25
+ ]
26
+
27
+ test('시퀀스는 읽기부터 시작하고 쓰기 프로브는 하나뿐이다', () => {
28
+ assert.equal(PROBE_SEQUENCE[0].id, 'connect')
29
+ assert.deepEqual(PROBE_SEQUENCE.filter(p => p.writes).map(p => p.id), ['actuate-effect'])
30
+ })
31
+
32
+ test('아무것도 돌리지 않았으면 첫 칸부터 「모른다」다 — 실패로 접지 않는다', () => {
33
+ const p = assessStagePath(foldProbeResults([]))
34
+ assert.equal(p.reached, null)
35
+ assert.deepEqual(p.next?.stage, 'structure')
36
+ assert.ok(p.stages[0].blockedBy.includes('no-locations'))
37
+ assert.ok(p.stages[0].unmeasured.includes('master-warnings'), '세지 않은 것은 모르는 것이다')
38
+ })
39
+
40
+ test('읽기만 전부 통과하면 예측까지 도달하고 폐루프는 「검증되지 않음」으로 남는다', () => {
41
+ const p = assessStagePath(foldProbeResults(readOnlyAllPass()))
42
+ assert.equal(p.reached, 'forecast')
43
+ assert.deepEqual(p.stages[3].unmeasured, ['correlated-effect'])
44
+ assert.deepEqual(p.stages[3].blockedBy, [], '능력은 선언∩구현이므로 막힌 것이 아니라 안 돌린 것이다')
45
+ assert.deepEqual(forbiddenClaims(p), ['claim.automatic-control'])
46
+ })
47
+
48
+ test('쓰기 프로브를 돌려 효과가 관측되면 폐루프가 닫힌다', () => {
49
+ const p = assessStagePath(foldProbeResults([...readOnlyAllPass(), pass('actuate-effect', 2)]))
50
+ assert.equal(p.reached, 'closed-loop')
51
+ assert.deepEqual(forbiddenClaims(p), [])
52
+ })
53
+
54
+ test('측정하지 못한 프로브는 수를 들지 않는다 — 0 을 지어내지 않는다', () => {
55
+ /*
56
+ * 규칙은 「실패면 모른다」가 아니다 — **센 값은 판정과 무관한 사실**이다(0건도 사실이다).
57
+ * 측정 자체를 못 한 프로브(타임아웃 등)는 `count` 를 **비운다**. 0 을 채우면 「세어 보니 없었다」로
58
+ * 읽히고, 그건 스냅샷 피드를 delta 로 오인하게 만드는 정반대의 결론을 낳는다.
59
+ */
60
+ const rs: ProbeResult[] = [...readOnlyAllPass().filter(r => r.id !== 'live-removal'),
61
+ { id: 'live-removal', ran: true, passed: false, detail: 'timeout' }]
62
+ const e = foldProbeResults(rs)
63
+ assert.equal(e.removalsObserved, null, '측정하지 못했으면 모른다')
64
+ assert.deepEqual(assessStagePath(e).stages[1].unmeasured, ['removals'])
65
+ })
66
+
67
+ test('세어서 0 을 얻은 것은 사실이다 — 모른다로 접지 않는다', () => {
68
+ const rs: ProbeResult[] = [...readOnlyAllPass().filter(r => r.id !== 'live-removal'),
69
+ { id: 'live-removal', ran: true, passed: false, count: 0 }]
70
+ const e = foldProbeResults(rs)
71
+ assert.equal(e.removalsObserved, 0)
72
+ assert.deepEqual(assessStagePath(e).stages[1].blockedBy, ['no-removal-observed'])
73
+ })
74
+
75
+ test('반출을 세었는데 0 이면 라이브가 막힌다 — 스냅샷 되풀어 읽기', () => {
76
+ const rs: ProbeResult[] = [...readOnlyAllPass().filter(r => r.id !== 'live-removal'), pass('live-removal', 0)]
77
+ const p = assessStagePath(foldProbeResults(rs))
78
+ assert.deepEqual(p.stages[1].blockedBy, ['no-removal-observed'])
79
+ assert.equal(p.reached, 'structure')
80
+ assert.ok(forbiddenClaims(p).includes('claim.realtime'))
81
+ })
82
+
83
+ test('쓰기 프로브는 동의 없이 계획에 들어오지 않는다', () => {
84
+ assert.ok(!plannedProbes([]).includes('actuate-effect'))
85
+ assert.ok(plannedProbes([], { allowWrites: true }).includes('actuate-effect'))
86
+ })
87
+
88
+ test('게이팅 프로브가 실패하면 그 뒤는 계획하지 않는다', () => {
89
+ const planned = plannedProbes([{ id: 'connect', ran: true, passed: false, detail: '401' }])
90
+ assert.deepEqual(planned, [], '붙지 못했으면 뒤를 두드릴 이유가 없다')
91
+ })
92
+
93
+ test('통과한 프로브는 다시 계획하지 않고, 남은 것만 계획한다', () => {
94
+ const planned = plannedProbes([pass('connect'), pass('discover', 1)])
95
+ assert.equal(planned[0], 'master')
96
+ assert.ok(!planned.includes('connect'))
97
+ })
98
+
99
+ test('게이팅이 아닌 프로브의 실패는 뒤를 멈추지 않는다', () => {
100
+ const planned = plannedProbes([pass('connect'), pass('discover', 1), pass('master', 12),
101
+ { id: 'master-warnings', ran: true, passed: false, detail: 'not-counted' }])
102
+ assert.ok(planned.includes('items'), '못 옮긴 값을 세지 못한 것이 구조 확인을 멈추지는 않는다')
103
+ })
@@ -0,0 +1,95 @@
1
+ /*
2
+ * ★ 실행기 — **회색 칸이 빨간 칸으로 위장되지 않게.**
3
+ *
4
+ * ① 시뮬 트윈에 라이브를 요구하지 않는다(해당 없음). 그 빨강은 결함이 아니다.
5
+ * ② 템플릿으로 세운 트윈에는 옮겨 온 마스터가 없다 — 「못 옮긴 값」을 물을 대상이 아니다.
6
+ * ③ 반출 0 의 처방이 둘로 갈린다: 스냅샷이면 방식을 바꿔야 하고, 아니면 더 기다릴 수 있다.
7
+ * ④ 액추에이션은 계약에 없다 — 측정을 만들지 않고 미실행으로 남긴다.
8
+ */
9
+ import { test } from 'node:test'
10
+ import assert from 'node:assert/strict'
11
+
12
+ import { assessSuite, type SuiteFacts } from '../server/engine/integration-runner.ts'
13
+
14
+ const live = (): SuiteFacts => ({
15
+ subject: 'live',
16
+ fromReference: true,
17
+ adapterCapabilities: ['live'],
18
+ connectOk: true,
19
+ siteCount: 3,
20
+ locationCount: 12,
21
+ itemCount: 340,
22
+ lastWarningCount: 2,
23
+ journalTotal: 1200,
24
+ removalsJournaled: 17,
25
+ specCoverage: { declared: 6, total: 8 },
26
+ forecastRan: true,
27
+ feed: 'delta-poll'
28
+ })
29
+
30
+ test('라이브 전부 갖추면 예측까지 도달하고, 폐루프는 능력이 없어 막힌다', () => {
31
+ const a = assessSuite(live())
32
+ assert.equal(a.path.reached, 'forecast')
33
+ assert.deepEqual(a.path.stages[3].blockedBy, ['no-actuate-capability'])
34
+ assert.deepEqual(a.forbiddenClaims, ['claim.automatic-control'])
35
+ assert.equal(a.profile.multiSite, true)
36
+ })
37
+
38
+ test('시뮬 트윈은 라이브가 해당 없음이고, 그 사유를 말한다', () => {
39
+ const a = assessSuite({ ...live(), subject: 'sim', journalTotal: null, removalsJournaled: null })
40
+ assert.deepEqual(a.notApplicable.map(n => n.id), ['live-ingest', 'live-removal'])
41
+ assert.deepEqual(a.path.stages[1].blockedBy, ['subject-is-simulation'])
42
+ assert.deepEqual(a.path.stages[1].unmeasured, [], '해당 없음을 미측정으로 접지 않는다')
43
+ assert.equal(a.path.reached, 'structure')
44
+ assert.ok(!a.planned.includes('live-removal'), '해당 없는 프로브는 계획하지 않는다')
45
+ })
46
+
47
+ test('템플릿으로 세운 트윈은 못 옮긴 값을 물을 대상이 아니다', () => {
48
+ const a = assessSuite({ ...live(), fromReference: false, lastWarningCount: null })
49
+ assert.deepEqual(a.notApplicable.map(n => n.id), ['master-warnings'])
50
+ assert.equal(a.path.stages[0].achieved, true, '해당 없음이 구조 달성을 막지 않는다')
51
+ })
52
+
53
+ test('반출 0 — 스냅샷이면 방식을 바꿔야 한다고 말한다', () => {
54
+ const a = assessSuite({ ...live(), removalsJournaled: 0, feed: 'snapshot-poll' })
55
+ assert.deepEqual(a.path.stages[1].blockedBy, ['snapshot-only-feed'])
56
+ assert.ok(a.forbiddenClaims.includes('claim.realtime'))
57
+ })
58
+
59
+ test('반출 0 — 스냅샷이 아니면 아직 일어나지 않은 것일 수 있다', () => {
60
+ const a = assessSuite({ ...live(), removalsJournaled: 0, feed: 'delta-poll' })
61
+ assert.deepEqual(a.path.stages[1].blockedBy, ['no-removal-observed'])
62
+ })
63
+
64
+ test('승격 전 저널은 반출을 셀 수 없다 — 「없다」가 아니라 「모른다」', () => {
65
+ const a = assessSuite({ ...live(), removalsJournaled: null })
66
+ assert.deepEqual(a.path.stages[1].unmeasured, ['removals'])
67
+ assert.deepEqual(a.path.stages[1].blockedBy, [])
68
+ })
69
+
70
+ test('액추에이션 효과는 측정을 만들지 않고 미실행으로 남긴다', () => {
71
+ const a = assessSuite({ ...live(), adapterCapabilities: ['live', 'actuate'] })
72
+ const eff = a.results.find(r => r.id === 'actuate-effect')!
73
+ assert.equal(eff.ran, false)
74
+ assert.equal(eff.detail, 'actuation-not-wired')
75
+ assert.deepEqual(a.path.stages[3].unmeasured, ['correlated-effect'])
76
+ })
77
+
78
+ test('쓰기 프로브는 동의 없이 계획에 들어오지 않는다', () => {
79
+ const able = { ...live(), adapterCapabilities: ['live', 'actuate'] }
80
+ assert.ok(!assessSuite(able).planned.includes('actuate-effect'))
81
+ assert.ok(assessSuite(able, { allowWrites: true }).planned.includes('actuate-effect'))
82
+ })
83
+
84
+ test('액추에이션 능력이 없으면 쓰기 동의가 있어도 계획하지 않는다 — 보낼 문이 없다', () => {
85
+ assert.ok(!assessSuite(live(), { allowWrites: true }).planned.includes('actuate-effect'))
86
+ })
87
+
88
+ test('접속을 안 돌렸으면 구조부터 미측정이 아니라 미실행으로 남고, 계획의 첫 칸이 된다', () => {
89
+ const a = assessSuite({ ...live(), connectOk: null })
90
+ assert.equal(a.planned[0], 'connect')
91
+ })
92
+
93
+ test('못 옮긴 값이 0 이어도 세었으므로 통과다', () => {
94
+ assert.equal(assessSuite({ ...live(), lastWarningCount: 0 }).path.stages[0].achieved, true)
95
+ })
@@ -0,0 +1,78 @@
1
+ /*
2
+ * 개체 → 품목 참조 — **정체성 모양이 하나가 아니고, 구분자는 우리가 정하지 않는다** (2026-08-23).
3
+ *
4
+ * ── 무엇이 조용히 비어 있었나 ───────────────────────────────────────────────
5
+ * 화면이 「이 개체는 선언된 자재가 아닙니다」라고 말했다. 그런데 그 품목은 원본의 선언에 이름까지 있었고
6
+ * (「냉동그린빈스」), 대응 표도 멀쩡했다. 원인은 참조를 꺼내는 규칙이 GS1 SGTIN 하나만 알았던 것이다.
7
+ *
8
+ * ── 그리고 고치는 방향을 한 번 더 고쳤다 ────────────────────────────────────
9
+ * 처음에는 「`@` 뒤는 개체다」를 규칙으로 넣었다. 그것은 **한 원본(chef)의 관행**이고, 커널 계약이 그
10
+ * 구분자를 정한 적이 없다 — 사용자가 「이 화면이 특정 레거시를 기준으로 해서는 안 된다」고 짚었다.
11
+ * 그래서 자르는 판단을 걷고, **그 트윈의 선언과 비교해** 답한다.
12
+ *
13
+ * 이 시험이 지키는 것:
14
+ * ① SGTIN 은 표준이 정한 자리에서 품목 참조를 꺼낸다.
15
+ * ② 그 밖의 모양은 **손대지 않는다**(구분자를 모른다).
16
+ * ③ 선언과 비교할 때는 두 방향을 본다 — 관측이 선언을 확장한 경우, 화면이 짧게 줄인 경우.
17
+ * ④ 후보가 둘 이상이면 **답하지 않는다**(모르는 것이 틀린 답보다 낫다).
18
+ */
19
+ import { test } from 'node:test'
20
+ import assert from 'node:assert/strict'
21
+
22
+ import { itemRefOf, resolveByDeclaration } from '../server/service/twin-model/item-ref.ts'
23
+
24
+ test('GS1 SGTIN 은 품목 참조 숫자다 — 표준이 자리를 정해 두었다', () => {
25
+ assert.equal(itemRefOf('urn:epc:id:sgtin:0614141.077101.42'), '077101')
26
+ assert.equal(itemRefOf('urn:epc:idpat:sgtin:0614141.078101.*'), '078101')
27
+ })
28
+
29
+ test('그 밖의 정체성은 손대지 않는다 — 어느 부호가 개체인지 우리가 정하지 않는다', () => {
30
+ assert.equal(itemRefOf('https://x.net/class/product-M1@M2R0001'), 'https://x.net/class/product-M1@M2R0001')
31
+ assert.equal(itemRefOf('https://x.net/class/product-M1'), 'https://x.net/class/product-M1')
32
+ assert.equal(itemRefOf(' https://x.net/class/p-1 '), 'https://x.net/class/p-1', '앞뒤 공백만 다듬는다')
33
+ })
34
+
35
+ test('읽을 수 없는 값은 null 이다', () => {
36
+ assert.equal(itemRefOf(''), null)
37
+ assert.equal(itemRefOf(' '), null)
38
+ assert.equal(itemRefOf(null), null)
39
+ assert.equal(itemRefOf(undefined), null)
40
+ assert.equal(itemRefOf(42), null)
41
+ })
42
+
43
+ const DECLARED = new Map<string, string>([
44
+ ['https://x.net/class/product-M0000438', 'M0000438'],
45
+ ['https://x.net/class/product-B0000033', 'B0000033']
46
+ ])
47
+
48
+ test('그대로 같으면 그것이다', () => {
49
+ assert.equal(resolveByDeclaration('https://x.net/class/product-M0000438', DECLARED), 'M0000438')
50
+ })
51
+
52
+ test('관측이 선언을 확장한 경우 — 개체는 자기 클래스로 시작한다(부호를 모른다)', () => {
53
+ for (const sep of ['@', '#', '/', ':', '']) {
54
+ assert.equal(
55
+ resolveByDeclaration(`https://x.net/class/product-M0000438${sep}M2R0001`, DECLARED),
56
+ 'M0000438',
57
+ `구분자 「${sep}」 에서도 같은 답이어야 한다`
58
+ )
59
+ }
60
+ })
61
+
62
+ test('화면이 짧게 줄인 경우 — 선언이 그것으로 끝난다', () => {
63
+ assert.equal(resolveByDeclaration('net/class/product-B0000033', DECLARED), 'B0000033')
64
+ assert.equal(resolveByDeclaration('product-M0000438', DECLARED), 'M0000438')
65
+ })
66
+
67
+ test('후보가 둘 이상이면 답하지 않는다 — 남의 레시피와 비교하지 않게', () => {
68
+ const ambiguous = new Map<string, string>([
69
+ ['https://a.net/class/product-1', 'A1'],
70
+ ['https://b.net/class/product-1', 'B1']
71
+ ])
72
+ assert.equal(resolveByDeclaration('class/product-1', ambiguous), undefined)
73
+ })
74
+
75
+ test('선언에 없으면 없는 것이다 — 비슷한 것을 이어 주지 않는다', () => {
76
+ assert.equal(resolveByDeclaration('https://x.net/class/product-ZZZ', DECLARED), undefined)
77
+ assert.equal(resolveByDeclaration(null, DECLARED), undefined)
78
+ })