@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,248 @@
1
+ /*
2
+ * ★ 동기화 판정이 **실제 트윈 위에서** 옳게 나온다 — 엔진 이음새.
3
+ *
4
+ * 순수 모듈 시험(`ingest-health.test.ts`)은 「이런 장부가 주어지면 이런 판정」을 지킨다. 그런데 화면이
5
+ * 실제로 받는 판정은 그 앞에 한 층이 더 있다: **피드 사실을 어디서 얻는가**(`liveFeedStateOf` — 이 트윈이
6
+ * 미러인가 시뮬인가, 지금 도는가) 그리고 **장부를 어느 트윈의 것으로 찾는가**(도메인+인스턴스 키).
7
+ *
8
+ * 그 층이 틀리면 순수 시험은 전부 초록인 채로 화면만 거짓말한다. 실제로 두 가지가 조용히 틀릴 수 있다:
9
+ * ① 시뮬 트윈이 「연결 안 됨」으로 보인다 — 거짓 경보이고, 거짓 경보는 참 경보를 죽인다
10
+ * ② 남의 도메인 장부를 집는다 — 키를 도메인 없이 만들면 그렇게 된다
11
+ *
12
+ * 그래서 실 DataSource 로 트윈을 세우고, 미러 하나·시뮬 하나로 판정 여섯을 이 자리에서 확인한다.
13
+ * 목 HTTP 게이트웨이는 쓰지 않는다(그것은 데모 환경변수에 딸린 것이고, 여기서 볼 것은 장부와 판정이다).
14
+ */
15
+ import { test, before, after } from 'node:test'
16
+ import assert from 'node:assert/strict'
17
+ import { createRequire } from 'node:module'
18
+ import type { DataSource } from 'typeorm'
19
+
20
+ const req = createRequire(import.meta.url)
21
+ const { NamingStrategy, addDataSource, entities: shellEntities, Domain } = req('@things-factory/shell')
22
+ const { entities: authEntities } = req('@things-factory/auth-base')
23
+ const { entities: twinEntities } = req('../dist-server/service/index.js')
24
+ const { TwinEngine } = req('../dist-server/engine/index.js')
25
+ const { INGEST_WINDOW_MS, STALE_AFTER_MS } = req('../dist-server/engine/ingest-health.js')
26
+ const { registerLiveFeedProbe } = req('../dist-server/engine/live-feed-registry.js')
27
+ const { DataSource: TypeOrmDataSource } = req('typeorm')
28
+
29
+ /*
30
+ * 피드 사실을 이 시험이 쥔다.
31
+ *
32
+ * 실제 커넥터 피드는 원천에 붙어야 서고, 붙지 못하면 비동기로 떨어진다 — 그 타이밍에 판정이 흔들리면
33
+ * 이 시험은 **판정을 재는 게 아니라 커넥터를 재는 것**이 된다(실제로 그렇게 깨졌다: 세 번째 검사까지는
34
+ * 붙어 있다가 네 번째에서 떨어져 「연결 안 됨」이 나왔다). 그래서 `registerLiveFeedProbe` — 계층이 자기
35
+ * 장부를 등록하도록 만들어 둔 그 확장점 — 으로 시험의 장부를 하나 더 얹는다. 커넥터 거동은 커넥터
36
+ * 시험이 지킨다.
37
+ */
38
+ let feedUp = new Set<string>()
39
+
40
+ let ds: DataSource
41
+ let domainId: string
42
+ let otherDomainId: string
43
+
44
+ /*
45
+ * 시나리오마다 트윈을 따로 둔다.
46
+ *
47
+ * 장부는 트윈마다 하나이고 **창은 이어진다.** 트윈 하나로 여러 시나리오를 돌리면 앞 검사가 남긴 창에
48
+ * 뒤 검사의 건수가 섞여 「최근 창」이 엉뚱한 것을 가리킨다(실제로 그렇게 깨졌다: 200건을 기대한 자리에
49
+ * 50건이 나왔다). 트윈을 나누면 각 검사가 자기 창만 본다.
50
+ */
51
+ const MIRROR = 'sync-mirror' /* 붙었는데 아직 안 받음 → 창이 닫히면 동기화 중 */
52
+ const BLOCKED = 'sync-blocked' /* 거부가 문턱을 넘음 */
53
+ const STALE = 'sync-stale' /* 오래 끊김 */
54
+ const SIM = 'sync-sim'
55
+ const MIRRORS = [MIRROR, BLOCKED, STALE]
56
+
57
+ /**
58
+ * 이 검사가 쓰는 창의 시작점.
59
+ *
60
+ * 창 하나를 닫고도 **읽는 시점이 다음 창 안에** 있어야 한다. `1.4창` 전에서 시작해 `창+1` 에서 닫으면
61
+ * 읽을 때까지 `0.4창`밖에 지나지 않아 두 번째 창이 닫히지 않는다 — 정확히 `2창` 전으로 잡았을 때
62
+ * 밀리초 몇 개 때문에 한 창이 더 닫혀 깨졌다.
63
+ */
64
+ const windowStart = () => Date.now() - Math.floor(INGEST_WINDOW_MS * 1.4)
65
+
66
+ const master = (source: string): any => ({
67
+ source,
68
+ system: 'wms',
69
+ siteName: '동기화 검사 창고',
70
+ space: { width: 40, depth: 20, unit: 'm' },
71
+ locations: [
72
+ { id: 'dock-recv', type: 'dock', role: 'location', name: '입고 도크', capacity: 100 },
73
+ { id: 'bin-1', type: 'storage', role: 'location', name: '빈1', capacity: 20 }
74
+ ],
75
+ equipment: [{ id: 'fk-1', kind: 'forklift', homeLocationId: 'dock-recv' }]
76
+ })
77
+
78
+ /** 거부 하나 — 사유는 커널이 주는 모양을 그대로 흉내낸다(레코드 원문 포함). */
79
+ const reject = (why: string) => ({ errors: [why], record: { epc: 'urn:epc:id:sgtin:0614141.107346.2018', bizStep: '?' } })
80
+
81
+ before(async () => {
82
+ ds = new TypeOrmDataSource({
83
+ type: 'sqlite',
84
+ database: ':memory:',
85
+ synchronize: true,
86
+ dropSchema: true,
87
+ namingStrategy: new NamingStrategy(),
88
+ entities: [...shellEntities, ...authEntities, ...twinEntities],
89
+ logging: false
90
+ })
91
+ await ds.initialize()
92
+ addDataSource('default', ds)
93
+ registerLiveFeedProbe(() => [...feedUp])
94
+
95
+ const d = await ds.getRepository(Domain).save({ name: 'sync-test', subdomain: 'sync-test' } as any)
96
+ domainId = d.id
97
+ const o = await ds.getRepository(Domain).save({ name: 'sync-other', subdomain: 'sync-other' } as any)
98
+ otherDomainId = o.id
99
+
100
+ for (const id of [...MIRRORS, SIM]) await TwinEngine.ingestMaster(domainId, master(id), 'reset')
101
+ })
102
+
103
+ after(async () => {
104
+ for (const id of [...MIRRORS, SIM]) await TwinEngine.stop(domainId, id).catch(() => {})
105
+ await ds?.destroy()
106
+ })
107
+
108
+ /** 미러로 세우고 피드를 붙인 상태로 만든다 — 각 검사가 자기 트윈을 자기 손으로 세운다. */
109
+ async function bootMirror(instanceId: string): Promise<void> {
110
+ const rt = await TwinEngine.bootDeclared(domainId, instanceId, 'resync')
111
+ assert.equal(rt.mode, 'live', `${instanceId} 는 미러로 떠야 이 검사가 뜻이 있다`)
112
+ feedUp.add(instanceId)
113
+ }
114
+
115
+ test('시뮬 트윈은 「해당 없음」이다 — 물을 대상이 아닌 것에 경고색을 주지 않는다', async () => {
116
+ const rt = await TwinEngine.bootDeclared(domainId, SIM, 'reset')
117
+ assert.equal(rt.mode, 'sim', '시뮬로 떠야 이 검사가 뜻이 있다')
118
+
119
+ assert.equal(TwinEngine.ingestHealthOf(domainId, SIM).verdict, 'not-applicable')
120
+ })
121
+
122
+ test('미러가 붙었지만 아직 아무것도 안 받았으면 「모름」이다 — 정상도 비정상도 아니다', async () => {
123
+ await bootMirror(MIRROR)
124
+
125
+ const v = TwinEngine.ingestHealthOf(domainId, MIRROR)
126
+ assert.equal(v.verdict, 'unknown')
127
+ assert.equal(v.lastAt, null, '받은 적이 없다 — 0 이 아니다')
128
+ assert.equal(v.total.acceptedRatio, null, '비율이 없다 — 1 이 아니다')
129
+ })
130
+
131
+ test('창이 닫히면 「동기화 중」이 되고, 통과와 거부를 함께 낸다', () => {
132
+ const t0 = windowStart()
133
+
134
+ TwinEngine.recordIngestResult(domainId, MIRROR, 200, [reject('unknown bizStep "?"')], t0)
135
+ /* 창을 닫는다 — 창은 **다음 유입이 있을 때** 닫힌다. */
136
+ TwinEngine.recordIngestResult(domainId, MIRROR, 50, [], t0 + INGEST_WINDOW_MS + 1)
137
+
138
+ const v = TwinEngine.ingestHealthOf(domainId, MIRROR)
139
+ assert.equal(v.verdict, 'syncing')
140
+ assert.equal(v.recent.offered, 200)
141
+ assert.equal(v.recent.rejected, 1)
142
+ assert.equal(v.recent.accepted, 199)
143
+ assert.equal(v.recent.acceptedRatio, 0.995)
144
+ assert.equal(v.total.offered, 250, '누적은 두 창을 합한다')
145
+ })
146
+
147
+ test('거부가 문턱을 넘으면 「매핑이 막고 있음」이고, 사유와 레코드 원문이 함께 나온다', async () => {
148
+ await bootMirror(BLOCKED)
149
+ const t0 = windowStart()
150
+
151
+ TwinEngine.recordIngestResult(
152
+ domainId,
153
+ BLOCKED,
154
+ 100,
155
+ Array.from({ length: 40 }, () => reject('unknown location "A-99"')),
156
+ t0
157
+ )
158
+ TwinEngine.recordIngestResult(domainId, BLOCKED, 1, [], t0 + INGEST_WINDOW_MS + 1)
159
+
160
+ const v = TwinEngine.ingestHealthOf(domainId, BLOCKED)
161
+ assert.equal(v.verdict, 'mapping-blocked')
162
+ assert.equal(v.recent.acceptedRatio, 0.6, '100건 중 40건이 거부됐다')
163
+
164
+ const reason = v.reasons.find((r: any) => r.reason === 'unknown location "A-99"')
165
+ assert.ok(reason, '사유를 묶어서 낸다')
166
+ assert.equal(reason.count, 40)
167
+
168
+ /* 표본은 커널이 낸 사유를 그대로 들고 있다(`errors`) — 우리가 다시 쓰지 않는다. */
169
+ const sample = v.samples.find((s: any) => s.errors.includes('unknown location "A-99"'))
170
+ assert.ok(sample, '사유만으로는 원천이 무엇을 보냈는지 알 수 없다 — 원문이 있어야 고친다')
171
+ assert.match(sample.record, /urn:epc:id:sgtin/, '레코드 원문이 그대로 들어 있다')
172
+ })
173
+
174
+ test('오래 끊긴 미러는 「유입 없음」이다 — 건강했던 옛 창으로 초록을 내지 않는다', async () => {
175
+ /*
176
+ * 이것이 이 기능이 있는 이유다. 창은 다음 유입이 있을 때 닫히므로, 유입이 멈추면 「최근 창」은 멈추기
177
+ * 직전의 건강했던 창으로 남는다. 그 창만 보면 어제 죽은 피드가 오늘도 「동기화 중」이다.
178
+ */
179
+ await bootMirror(STALE)
180
+ const long = Date.now() - STALE_AFTER_MS * 5
181
+
182
+ TwinEngine.recordIngestResult(domainId, STALE, 1000, [], long)
183
+ TwinEngine.recordIngestResult(domainId, STALE, 1000, [], long + INGEST_WINDOW_MS + 1) // 건강한 창을 닫는다
184
+
185
+ const v = TwinEngine.ingestHealthOf(domainId, STALE)
186
+ assert.equal(v.verdict, 'no-traffic', '마지막 유입이 한 창보다 오래 전이다')
187
+ assert.equal(v.recent.acceptedRatio, 1, '(옛 창 자체는 건강했다 — 그래서 창만 보면 속는다)')
188
+ })
189
+
190
+ test('피드가 떨어진 미러는 「연결 안 됨」이다 — 「유입 없음」과 처방이 다르다', () => {
191
+ /* 「유입 없음」은 원천·망을 보라는 말이고, 「연결 안 됨」은 결선하라는 말이다. 섞으면 엉뚱한 곳을 본다. */
192
+ feedUp.delete(MIRROR)
193
+
194
+ assert.equal(TwinEngine.ingestHealthOf(domainId, MIRROR).verdict, 'not-connected')
195
+
196
+ feedUp.add(MIRROR)
197
+ })
198
+
199
+ test('멈춘 미러는 「해당 없음」이다 — 고치려고 멈춰 둔 트윈에 「연결 안 됨」이라 하지 않는다', async () => {
200
+ await TwinEngine.stop(domainId, MIRROR)
201
+
202
+ const v = TwinEngine.ingestHealthOf(domainId, MIRROR)
203
+ assert.equal(v.verdict, 'not-applicable')
204
+ assert.ok(v.total.offered > 0, '멈춰도 장부는 남는다 — 마지막에 무엇이 거부됐는지 볼 수 있어야 한다')
205
+ })
206
+
207
+ test('멈춘 트윈에 계속 들어오면 「버려지는 중」이라 말한다 — 실제로 겪은 거짓 안심', async () => {
208
+ /*
209
+ * 겪은 모습: 트윈이 멈췄는데 피드는 살아 레코드를 나르고, 커널은 조용히 버렸다. 화면은 「해당 없음 ·
210
+ * 150 통과 · 100%」였다. 여기서는 그 상황을 그대로 만들어 판정과 수치를 확인한다.
211
+ */
212
+ /* 사람이 트윈을 멈춘다 — 그런데 피드는 남는다. 그 조합이 사실을 잃게 만든다. */
213
+ await TwinEngine.stop(domainId, STALE)
214
+ assert.equal(TwinEngine.instances[`${domainId}:${STALE}`], undefined, '멈췄다')
215
+
216
+ /* 커널이 받지 않는다 — `ingestLive` 가 0 을 돌려준다. */
217
+ const applied = TwinEngine.ingestLive(domainId, STALE, [{ any: 'envelope' } as any])
218
+ assert.equal(applied, 0, '멈춘 트윈은 봉투를 받지 않는다')
219
+
220
+ TwinEngine.recordIngestResult(domainId, STALE, 10, [], Date.now(), 10)
221
+
222
+ const v = TwinEngine.ingestHealthOf(domainId, STALE)
223
+ assert.equal(v.verdict, 'twin-not-running', '「해당 없음」으로 덮지 않는다')
224
+ assert.equal(v.total.undelivered >= 10, true, '버려진 수를 센다')
225
+ })
226
+
227
+ test('장부는 도메인으로 갈린다 — 같은 instanceId 라도 남의 것을 집지 않는다', () => {
228
+ const v = TwinEngine.ingestHealthOf(otherDomainId, MIRROR)
229
+
230
+ assert.equal(v.total.offered, 0, '다른 도메인에는 이 트윈의 장부가 없다')
231
+ assert.equal(v.total.acceptedRatio, null)
232
+ assert.equal(v.samples.length, 0, '남의 레코드 원문이 새지 않는다')
233
+ })
234
+
235
+ test('목록 요약은 숫자만 낸다 — 원문을 스무 벌 실어 조회를 무겁게 하지 않는다', () => {
236
+ const brief = TwinEngine.ingestHealthBrief(domainId, [MIRROR, SIM])
237
+
238
+ assert.equal(brief.length, 2)
239
+ const m = brief.find((b: any) => b.instanceId === MIRROR)
240
+ assert.ok(m, '멈춘 트윈도 목록에 남는다')
241
+ assert.equal((m as any).samples, undefined, '표본은 목록에 없다')
242
+ assert.equal((m as any).reasons, undefined, '사유도 목록에 없다')
243
+ assert.ok(Array.isArray((m as any).trend), '스파크라인용 추이는 있다')
244
+
245
+ const s = brief.find((b: any) => b.instanceId === SIM)
246
+ assert.equal((s as any).verdict, 'not-applicable')
247
+ assert.equal((s as any).acceptedRatio, null, '시뮬은 비율이 없다 — 0 이 아니다')
248
+ })
@@ -0,0 +1,119 @@
1
+ /*
2
+ * 유입 경로가 **모두 같은 장부**에 적는다 — 소스 규율 검사.
3
+ *
4
+ * ── 왜 기계가 지켜야 하나 ───────────────────────────────────────────────────
5
+ * 피드를 여는 경로가 둘이다(커넥터 `openLiveFeed` · 시나리오 `twin-ingest` 태스크). 한쪽만 장부에
6
+ * 적으면 그 경로로 먹이는 트윈이 **「한 번도 받지 않은 것」**으로 보인다. 그 판정은 `unknown` 이고,
7
+ * `unknown` 은 화면에서 「정상」과 구별되지 않는다 — 즉 **조용히 틀린 답**이 된다.
8
+ *
9
+ * 세 번째 경로가 생길 때 사람이 이 사실을 기억할 것이라고 믿지 않는다. 인제스트를 부르는 자리를
10
+ * 소스에서 찾아 장부 기록이 함께 있는지 본다.
11
+ */
12
+ import { test } from 'node:test'
13
+ import assert from 'node:assert/strict'
14
+ import { readFileSync, readdirSync, statSync } from 'node:fs'
15
+ import { join } from 'node:path'
16
+
17
+ /** 훑는 곳 — 이 저장소에서 인제스트를 부를 수 있는 층 둘. */
18
+ const ROOTS = [
19
+ new URL('../server', import.meta.url).pathname,
20
+ new URL('../../operato-twin/server', import.meta.url).pathname
21
+ ]
22
+
23
+ /** 인제스트를 여는 함수 이름. 이 이름이 나오는 자리가 곧 유입 경로다. */
24
+ const INGEST_CALL = 'ingestCanonicalRecords('
25
+
26
+ /** 장부에 적는 창구. */
27
+ const LEDGER_CALL = 'recordIngestResult('
28
+
29
+ /**
30
+ * 예외 — 인제스트를 **부르지 않고 다시 내보내기만** 하는 자리.
31
+ *
32
+ * 늘리려면 이유를 함께 적는다. 「부르는데 안 적는다」는 예외가 될 수 없다 — 그게 이 검사가 막는 것이다.
33
+ */
34
+ const REEXPORT_ONLY = ['board/tasks/twin-ingest-core.ts', 'engine/index.ts', 'engine/canonical-ingest.ts']
35
+
36
+ function sources(dir: string): string[] {
37
+ let entries: string[]
38
+ try {
39
+ entries = readdirSync(dir)
40
+ } catch {
41
+ return []
42
+ }
43
+ return entries.flatMap(f => {
44
+ const p = join(dir, f)
45
+ return statSync(p).isDirectory() ? sources(p) : p.endsWith('.ts') && !p.endsWith('.d.ts') ? [p] : []
46
+ })
47
+ }
48
+
49
+ function ingestCallSites(): { file: string; src: string }[] {
50
+ const out: { file: string; src: string }[] = []
51
+ for (const root of ROOTS) {
52
+ for (const f of sources(root)) {
53
+ const src = readFileSync(f, 'utf-8')
54
+ if (!src.includes(INGEST_CALL)) continue
55
+ const rel = f.slice(root.length + 1)
56
+ if (REEXPORT_ONLY.some(x => rel.endsWith(x))) continue
57
+ out.push({ file: rel, src })
58
+ }
59
+ }
60
+ return out
61
+ }
62
+
63
+ test('인제스트를 부르는 곳은 모두 장부에 적는다 — 한 경로만 빠지면 그 트윈은 「모름」으로 보인다', () => {
64
+ const missing = ingestCallSites()
65
+ .filter(s => !s.src.includes(LEDGER_CALL))
66
+ .map(s => s.file)
67
+
68
+ assert.deepEqual(
69
+ missing,
70
+ [],
71
+ `장부에 적지 않는 유입 경로 ${missing.length}곳:\n ${missing.join('\n ')}\n\n` +
72
+ `그 경로로 먹이는 트윈은 twinIngestHealth 에서 「한 번도 받지 않음」(unknown)으로 보이고, ` +
73
+ `화면에서 정상과 구별되지 않는다. TwinEngine.recordIngestResult(도메인, 인스턴스, 제시수, 거부) 를 부른다.`
74
+ )
75
+ })
76
+
77
+ test('검사가 실제로 경로를 보고 있다 — 대상이 사라지면 초록이 거짓이 된다', () => {
78
+ const sites = ingestCallSites().map(s => s.file)
79
+
80
+ assert.ok(
81
+ sites.length >= 2,
82
+ `유입 경로를 ${sites.length}곳 찾았다 — 둘(커넥터·시나리오)보다 적으면 호출 형태가 바뀐 것이다. ` +
83
+ `이 검사의 패턴을 함께 옮긴다. 찾은 것: ${sites.join(', ') || '없음'}`
84
+ )
85
+ })
86
+
87
+ test('유입 경로는 커널이 실제로 받은 수를 견준다 — 버려진 것을 통과로 세지 않게', () => {
88
+ /*
89
+ * 겪은 일: 트윈이 멈춘 뒤에도 피드는 레코드를 나르고 커널은 조용히 버렸는데, 장부는 그것을 「통과」로
90
+ * 셌다. 화면은 멈춘 트윈 옆에 「150 통과 · 100%」라고 적었다 — 사실이 사라지는 동안 안심시킨 것이다.
91
+ *
92
+ * 고친 방법은 `ingestLive` 가 **넣은 수를 돌려주는** 것이다. 새 경로가 그 반환을 안 쓰고 0 을 넘기면
93
+ * 같은 침묵이 돌아온다. 그래서 경로마다 미전달을 세는지 소스에서 본다.
94
+ */
95
+ const missing = ingestCallSites()
96
+ .filter(s => !s.src.includes('undelivered'))
97
+ .map(s => s.file)
98
+
99
+ assert.deepEqual(
100
+ missing,
101
+ [],
102
+ `커널이 받은 수를 견주지 않는 유입 경로 ${missing.length}곳:\n ${missing.join('\n ')}\n\n` +
103
+ `TwinEngine.ingestLive(...) 의 반환(넣은 수)을 받아 accepted.length 와의 차이를 ` +
104
+ `recordIngestResult(..., nowMs, undelivered) 로 넘긴다. 그러지 않으면 트윈이 멈춘 사이 ` +
105
+ `버려진 레코드가 「통과」로 집계되어 화면이 100% 라고 말한다.`
106
+ )
107
+ })
108
+
109
+ test('제시 수를 거부 수로 대신하지 않는다 — 통과율의 분모가 뒤바뀌지 않게', () => {
110
+ /*
111
+ * 실수하기 쉬운 자리다: `recordIngestResult(..., rejected.length, rejected)` 처럼 적으면 분모가
112
+ * 거부 수가 되어 **통과율이 언제나 0** 이 된다. 오류 없이 「전부 거부」로 읽힌다.
113
+ */
114
+ const bad = ingestCallSites()
115
+ .filter(s => /recordIngestResult\([^)]*rejected\.length\s*,/.test(s.src))
116
+ .map(s => s.file)
117
+
118
+ assert.deepEqual(bad, [], `제시 수 자리에 거부 수를 넘긴 곳:\n ${bad.join('\n ')}`)
119
+ })
@@ -0,0 +1,306 @@
1
+ /*
2
+ * 동기화 건강 — **「이 트윈이 현장과 맞춰지고 있나」에 정직하게 답한다.**
3
+ *
4
+ * 지키는 것: ① 판정 일곱이 서로 섞이지 않고, **끊긴 피드가 옛 창으로 초록을 내지 않으며, 버려지고 있는
5
+ * 것을 「해당 없음」으로 덮지 않는다** ② 닫힌 창만 「최근」이다 ③ 0 과 「없음」이 다르다 ④ 상한에서
6
+ * 밀려난 것을 함께 낸다 ⑤ 사유 종류가 무한히 자라지 않는다 ⑥ 같은 사유를 두 번 직렬화하지 않는다
7
+ * ⑦ 영속 훅이 장부의 진행을 막지 않는다.
8
+ */
9
+ import { test } from 'node:test'
10
+ import assert from 'node:assert/strict'
11
+
12
+ import {
13
+ HEALTHY_ACCEPTED_RATIO,
14
+ INGEST_WINDOW_MS,
15
+ OTHER_REASON,
16
+ REASON_LIMIT,
17
+ STALE_AFTER_MS,
18
+ TREND_LIMIT,
19
+ ingestHealth,
20
+ newIngestLedger,
21
+ recordIngest,
22
+ rollIngestWindow,
23
+ syncVerdictOf
24
+ } from '../server/engine/ingest-health.ts'
25
+
26
+ /* ── ① 판정 ─────────────────────────────────────────────────────────────── */
27
+
28
+ test('시뮬 트윈에는 「동기화 안 됨」이라 말하지 않는다 — 거짓 경보는 참 경보를 죽인다', () => {
29
+ assert.equal(syncVerdictOf({ feedState: 'not-applicable', recent: null }), 'not-applicable')
30
+ assert.equal(syncVerdictOf({ feedState: 'not-applicable', recent: { offered: 0, rejected: 0 } }), 'not-applicable')
31
+ })
32
+
33
+ test('피드가 없는 것과 붙었는데 안 오는 것을 가른다', () => {
34
+ assert.equal(syncVerdictOf({ feedState: 'detached', recent: null }), 'not-connected')
35
+ assert.equal(syncVerdictOf({ feedState: 'attached', recent: { offered: 0, rejected: 0 } }), 'no-traffic')
36
+ })
37
+
38
+ test('재지 않은 것을 정상·비정상으로 채우지 않는다', () => {
39
+ assert.equal(syncVerdictOf({ feedState: 'attached', recent: null }), 'unknown')
40
+ })
41
+
42
+ test('오래 끊긴 피드는 건강했던 옛 창으로 초록을 내지 않는다 — 이 기능이 있는 이유', () => {
43
+ /*
44
+ * 창은 다음 유입이 있을 때 닫힌다. 유입이 멈추면 「최근 창」은 멈추기 직전의 건강했던 창으로 남는다.
45
+ * 그 창만 보면 어제 죽은 피드가 오늘도 「동기화 중」이다.
46
+ */
47
+ const healthy = { offered: 1000, rejected: 0 }
48
+
49
+ assert.equal(
50
+ syncVerdictOf({ feedState: 'attached', recent: healthy, lastAtMs: 0, nowMs: STALE_AFTER_MS + 1 }),
51
+ 'no-traffic'
52
+ )
53
+ assert.equal(
54
+ syncVerdictOf({ feedState: 'attached', recent: healthy, lastAtMs: 0, nowMs: STALE_AFTER_MS }),
55
+ 'syncing',
56
+ '경계까지는 아직 조용하다고 하지 않는다'
57
+ )
58
+ })
59
+
60
+ test('침묵이 거부보다 앞선다 — 끊긴 피드에 「매핑이 막고 있음」이라 말하지 않는다', () => {
61
+ /* 매핑을 고치라는 말은 유입이 있을 때만 맞는 처방이다. 오지 않는 것은 매핑 문제가 아니다. */
62
+ assert.equal(
63
+ syncVerdictOf({ feedState: 'attached', recent: { offered: 100, rejected: 90 }, lastAtMs: 0, nowMs: STALE_AFTER_MS + 1 }),
64
+ 'no-traffic'
65
+ )
66
+ })
67
+
68
+ test('붙었지만 한 번도 받지 않은 것을 「유입 없음」이라 단정하지 않는다', () => {
69
+ /* `-1` 은 「없음」이다. 0(1970년)으로 읽으면 방금 띄운 트윈이 곧바로 「끊겼다」가 된다. */
70
+ assert.equal(
71
+ syncVerdictOf({ feedState: 'attached', recent: null, lastAtMs: -1, nowMs: STALE_AFTER_MS * 100 }),
72
+ 'unknown'
73
+ )
74
+ })
75
+
76
+ test('시뮬 트윈은 오래 조용해도 「유입 없음」이 아니다', () => {
77
+ assert.equal(
78
+ syncVerdictOf({ feedState: 'not-applicable', recent: null, lastAtMs: -1, nowMs: STALE_AFTER_MS * 100 }),
79
+ 'not-applicable'
80
+ )
81
+ })
82
+
83
+ test('들어오는데 트윈이 안 돌면 「버려지는 중」이다 — 「해당 없음」으로 안심시키지 않는다', () => {
84
+ /*
85
+ * 실제로 겪었다: 트윈이 멈춘 뒤에도 피드는 남아 레코드를 나르고, 커널은 그것을 조용히 버렸다.
86
+ * 그런데 화면은 「해당 없음 · 150 통과 · 100%」였다 — 사실이 사라지는 동안 화면이 안심시킨 것이다.
87
+ *
88
+ * 멈춘 트윈의 피드 사실은 `not-applicable` 이므로, 미전달을 **그보다 먼저** 봐야 이 상태가 드러난다.
89
+ */
90
+ assert.equal(
91
+ syncVerdictOf({ feedState: 'not-applicable', recent: null, lastUndeliveredAtMs: 0, nowMs: 1000 }),
92
+ 'twin-not-running'
93
+ )
94
+ assert.equal(
95
+ syncVerdictOf({ feedState: 'attached', recent: { offered: 100, rejected: 0 }, lastUndeliveredAtMs: 0, nowMs: 1000 }),
96
+ 'twin-not-running',
97
+ '통과율이 좋아 보여도 버려지고 있으면 그것을 말한다'
98
+ )
99
+ })
100
+
101
+ test('버려진 지 오래됐으면 더는 「버려지는 중」이라 하지 않는다 — 지난 일을 현재로 말하지 않는다', () => {
102
+ assert.equal(
103
+ syncVerdictOf({ feedState: 'not-applicable', recent: null, lastUndeliveredAtMs: 0, nowMs: STALE_AFTER_MS + 1 }),
104
+ 'not-applicable'
105
+ )
106
+ })
107
+
108
+ test('버려진 적이 없으면 미전달 판정이 서지 않는다 — `-1` 을 시각 0 으로 읽지 않는다', () => {
109
+ assert.equal(
110
+ syncVerdictOf({ feedState: 'not-applicable', recent: null, lastUndeliveredAtMs: -1, nowMs: 1000 }),
111
+ 'not-applicable'
112
+ )
113
+ })
114
+
115
+ test('버려진 것을 「통과」로 세지 않는다 — 통과율이 그만큼 내려간다', () => {
116
+ const l = newIngestLedger()
117
+ /* 100건 제시 · 매핑 거부 0 · 그런데 40건이 트윈에 닿지 못했다 */
118
+ recordIngest(l, 100, [], 0, undefined, 40)
119
+ recordIngest(l, 1, [], INGEST_WINDOW_MS + 1) // 창을 닫는다
120
+
121
+ const v = ingestHealth(l, 'attached', undefined, INGEST_WINDOW_MS + 1)
122
+ assert.equal(v.recent!.offered, 100)
123
+ assert.equal(v.recent!.rejected, 0, '매핑은 아무것도 거부하지 않았다')
124
+ assert.equal(v.recent!.undelivered, 40)
125
+ assert.equal(v.recent!.accepted, 60, '트윈에 닿은 것만 통과다')
126
+ assert.equal(v.recent!.acceptedRatio, 0.6, '100% 가 아니다')
127
+ assert.equal(v.total.undelivered, 40)
128
+ })
129
+
130
+ test('거부가 문턱을 넘으면 매핑을 지목한다 — 문턱 경계를 정확히 지킨다', () => {
131
+ /* 문턱은 「이상이면 정상」이다. 통과율이 정확히 문턱이면 정상으로 본다. */
132
+ assert.equal(syncVerdictOf({ feedState: 'attached', recent: { offered: 100, rejected: 5 } }), 'syncing')
133
+ assert.equal(syncVerdictOf({ feedState: 'attached', recent: { offered: 100, rejected: 6 } }), 'mapping-blocked')
134
+ assert.equal(HEALTHY_ACCEPTED_RATIO, 0.95)
135
+ })
136
+
137
+ /* ── ② 창 ───────────────────────────────────────────────────────────────── */
138
+
139
+ test('채우는 중인 창을 「최근」이라 부르지 않는다', () => {
140
+ const l = newIngestLedger()
141
+ recordIngest(l, 100, [], 0)
142
+ recordIngest(l, 100, [], 1000)
143
+
144
+ /* 읽는 시각을 넘긴다 — 넘기지 않으면 벽시계와 견주어 「오래 조용함」으로 읽힌다(1970년 장부이므로). */
145
+ const v = ingestHealth(l, 'attached', undefined, 1000)
146
+ assert.equal(v.recent, null, '창이 아직 닫히지 않았다')
147
+ assert.equal(v.verdict, 'unknown', '그래서 판정도 모름이다')
148
+ assert.equal(v.total.offered, 200, '누적은 이미 센다')
149
+ })
150
+
151
+ test('창이 닫히면 최근이 되고, 통과와 거부를 함께 낸다', () => {
152
+ const l = newIngestLedger()
153
+ recordIngest(l, 200, [{ errors: ['epc is required'] }, { errors: ['epc is required'] }], 0)
154
+ recordIngest(l, 5, [], INGEST_WINDOW_MS + 1) // 창을 닫는다
155
+
156
+ const v = ingestHealth(l, 'attached', undefined, INGEST_WINDOW_MS + 1)
157
+ assert.equal(v.recent!.offered, 200)
158
+ assert.equal(v.recent!.rejected, 2)
159
+ assert.equal(v.recent!.accepted, 198)
160
+ assert.equal(v.recent!.acceptedRatio, 0.99)
161
+ assert.equal(v.verdict, 'syncing')
162
+ })
163
+
164
+ test('추이 상한을 넘으면 오래된 창이 밀려나고, 밀려난 수를 낸다', () => {
165
+ const l = newIngestLedger()
166
+ for (let i = 0; i <= TREND_LIMIT + 3; i++) recordIngest(l, 1, [], i * (INGEST_WINDOW_MS + 1))
167
+
168
+ const v = ingestHealth(l, 'attached')
169
+ assert.equal(v.trend.length, TREND_LIMIT)
170
+ assert.ok(v.trendDropped > 0, `밀려난 창을 조용히 버리지 않는다 — 실제 ${v.trendDropped}`)
171
+ })
172
+
173
+ /* ── ③ 0 과 없음 ────────────────────────────────────────────────────────── */
174
+
175
+ test('한 번도 받지 않은 트윈은 수치를 0 으로 채우지 않는다', () => {
176
+ const v = ingestHealth(newIngestLedger(), 'attached')
177
+
178
+ assert.equal(v.lastAt, null, '받은 적이 없다')
179
+ assert.equal(v.total.acceptedRatio, null, '비율이 없다 — 1 이 아니다')
180
+ assert.equal(v.recent, null)
181
+ assert.equal(v.verdict, 'unknown')
182
+ })
183
+
184
+ test('장부가 없어도 피드 사실만으로 답할 수 있는 판정은 낸다', () => {
185
+ const v = ingestHealth(undefined, 'not-applicable')
186
+
187
+ assert.equal(v.verdict, 'not-applicable')
188
+ assert.equal(v.total.acceptedRatio, null, '없는 값을 지어내지 않는다')
189
+ })
190
+
191
+ test('제시는 있고 거부가 0 이면 비율은 1 이다 — 이건 「없음」이 아니다', () => {
192
+ const l = newIngestLedger()
193
+ recordIngest(l, 50, [], 0)
194
+
195
+ assert.equal(ingestHealth(l, 'attached').total.acceptedRatio, 1)
196
+ })
197
+
198
+ test('1,000건 중 1건 거부가 「전부 통과」로 읽히지 않는다', () => {
199
+ const l = newIngestLedger()
200
+ recordIngest(l, 1000, [{ errors: ['bad'] }], 0)
201
+
202
+ assert.equal(ingestHealth(l, 'attached').total.acceptedRatio, 0.999)
203
+ })
204
+
205
+ /* ── ④⑤⑥ 상한과 비용 ───────────────────────────────────────────────────── */
206
+
207
+ test('사유 종류가 무한히 자라지 않는다 — 문구에 변수가 섞여도', () => {
208
+ const l = newIngestLedger()
209
+ for (let i = 0; i < REASON_LIMIT * 5; i++) recordIngest(l, 1, [{ errors: [`unknown bizStep "S${i}"`] }], 1)
210
+
211
+ const v = ingestHealth(l, 'attached')
212
+ assert.ok(v.reasons.length <= REASON_LIMIT + 1, `사유 칸이 ${v.reasons.length}개 — 상한 + 기타 한 칸`)
213
+ assert.equal(v.reasonsCapped, true, '접혔다는 사실을 말한다')
214
+ assert.ok(
215
+ v.reasons.some(r => r.reason === OTHER_REASON),
216
+ '접힌 것들이 갈 칸이 있다'
217
+ )
218
+ assert.equal(v.total.rejected, REASON_LIMIT * 5, '건수는 하나도 잃지 않는다')
219
+ })
220
+
221
+ test('같은 사유는 한 번만 표본을 뜬다 — 나머지는 직렬화하지 않는다', () => {
222
+ const l = newIngestLedger()
223
+ let serialized = 0
224
+ const record = () => {
225
+ serialized++
226
+ return { epc: 'x' }
227
+ }
228
+ for (let i = 0; i < 500; i++) recordIngest(l, 1, [{ errors: ['epc is required'], record: record() }], 1)
229
+
230
+ const v = ingestHealth(l, 'attached')
231
+ assert.equal(v.samples.length, 1, '사유 하나 → 표본 하나')
232
+ assert.equal(v.sampleDropped, 499, '나머지는 세어서 밝힌다')
233
+ assert.equal(v.total.rejected, 500, '건수는 그대로')
234
+ assert.equal(serialized, 500, '(레코드를 만든 횟수 — 장부가 직렬화한 횟수는 아래에서 본다)')
235
+ })
236
+
237
+ test('사유가 여럿이면 사유마다 표본이 하나씩 생긴다', () => {
238
+ const l = newIngestLedger()
239
+ recordIngest(l, 3, [{ errors: ['a'] }, { errors: ['b'] }, { errors: ['a'] }], 1)
240
+
241
+ const v = ingestHealth(l, 'attached')
242
+ assert.equal(v.samples.length, 2, 'a·b 하나씩')
243
+ assert.equal(v.sampleDropped, 1, '두 번째 a 는 안 뜬다')
244
+ })
245
+
246
+ test('긴 레코드는 자르고, 잘랐다고 표시한다', () => {
247
+ const l = newIngestLedger()
248
+ recordIngest(l, 1, [{ errors: ['big'], record: { blob: 'x'.repeat(5000) } }], 1)
249
+
250
+ const s = ingestHealth(l, 'attached').samples[0]
251
+ assert.equal(s.recordTruncated, true)
252
+ assert.ok(s.record.length <= 2000)
253
+ })
254
+
255
+ test('직렬화할 수 없는 레코드가 장부를 멈추지 않는다', () => {
256
+ const l = newIngestLedger()
257
+ const cyclic: any = {}
258
+ cyclic.self = cyclic
259
+ recordIngest(l, 1, [{ errors: ['cyclic'], record: cyclic }], 1)
260
+
261
+ assert.equal(ingestHealth(l, 'attached').total.rejected, 1, '건수는 센다')
262
+ })
263
+
264
+ /* ── ⑦ 영속 훅 ──────────────────────────────────────────────────────────── */
265
+
266
+ test('창이 닫힐 때만 영속 훅이 불린다 — 건마다가 아니다', () => {
267
+ const l = newIngestLedger()
268
+ const closed: { offered: number; rejected: number }[] = []
269
+
270
+ for (let i = 0; i < 50; i++) recordIngest(l, 10, [], i * 1000, w => closed.push(w))
271
+ assert.equal(closed.length, 0, '10분이 안 지났으면 한 번도 안 부른다')
272
+
273
+ recordIngest(l, 10, [], INGEST_WINDOW_MS + 1, w => closed.push(w))
274
+ assert.equal(closed.length, 1, '창이 닫힐 때 한 번')
275
+ assert.equal(closed[0].offered, 500, '닫힌 창의 값을 그대로 넘긴다')
276
+ })
277
+
278
+ test('영속이 실패해도 장부는 계속 굴러간다', () => {
279
+ const l = newIngestLedger()
280
+ recordIngest(l, 10, [], 0)
281
+
282
+ assert.doesNotThrow(() =>
283
+ recordIngest(l, 10, [], INGEST_WINDOW_MS + 1, () => {
284
+ throw new Error('DB down')
285
+ })
286
+ )
287
+ assert.equal(l.trend.length, 1, '창은 닫혔다')
288
+ assert.equal(ingestHealth(l, 'attached').total.offered, 20, '집계도 이어진다')
289
+ })
290
+
291
+ /* ── 입력 방어 ──────────────────────────────────────────────────────────── */
292
+
293
+ test('망가진 제시 수는 기록하지 않는다 — 통과율이 거짓이 되지 않게', () => {
294
+ const l = newIngestLedger()
295
+ recordIngest(l, -5, [], 1)
296
+ recordIngest(l, NaN, [], 1)
297
+
298
+ assert.equal(ingestHealth(l, 'attached').lastAt, null, '아무것도 안 쌓였다')
299
+ })
300
+
301
+ test('사유가 비어 있으면 지어내지 않고 unknown 으로 둔다', () => {
302
+ const l = newIngestLedger()
303
+ recordIngest(l, 1, [{ errors: [] }], 1)
304
+
305
+ assert.equal(ingestHealth(l, 'attached').reasons[0].reason, 'unknown')
306
+ })