@things-factory/headless-twin 10.0.12 → 10.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-server/engine/canonical-ingest.d.ts +57 -2
- package/dist-server/engine/canonical-ingest.js +43 -2
- package/dist-server/engine/canonical-ingest.js.map +1 -1
- package/dist-server/engine/command-routing.js +1 -1
- package/dist-server/engine/command-routing.js.map +1 -1
- package/dist-server/engine/index.d.ts +3 -0
- package/dist-server/engine/index.js +4 -0
- package/dist-server/engine/index.js.map +1 -1
- package/dist-server/engine/ingest-health.d.ts +335 -0
- package/dist-server/engine/ingest-health.js +434 -0
- package/dist-server/engine/ingest-health.js.map +1 -0
- package/dist-server/engine/integration-coverage.d.ts +76 -0
- package/dist-server/engine/integration-coverage.js +73 -0
- package/dist-server/engine/integration-coverage.js.map +1 -0
- package/dist-server/engine/integration-probes.d.ts +65 -0
- package/dist-server/engine/integration-probes.js +100 -0
- package/dist-server/engine/integration-probes.js.map +1 -0
- package/dist-server/engine/integration-runner.d.ts +45 -0
- package/dist-server/engine/integration-runner.js +59 -0
- package/dist-server/engine/integration-runner.js.map +1 -0
- package/dist-server/engine/integration-target-profile.d.ts +57 -0
- package/dist-server/engine/integration-target-profile.js +79 -0
- package/dist-server/engine/integration-target-profile.js.map +1 -0
- package/dist-server/engine/kpi-fold.d.ts +27 -0
- package/dist-server/engine/kpi-fold.js +47 -4
- package/dist-server/engine/kpi-fold.js.map +1 -1
- package/dist-server/engine/kpi-query.js +128 -14
- package/dist-server/engine/kpi-query.js.map +1 -1
- package/dist-server/engine/live-feed-registry.js +2 -16
- package/dist-server/engine/live-feed-registry.js.map +1 -1
- package/dist-server/engine/load-meter.d.ts +1 -1
- package/dist-server/engine/load-meter.js +12 -3
- package/dist-server/engine/load-meter.js.map +1 -1
- package/dist-server/engine/log.d.ts +18 -0
- package/dist-server/engine/log.js +80 -0
- package/dist-server/engine/log.js.map +1 -0
- package/dist-server/engine/loop-lag.d.ts +54 -0
- package/dist-server/engine/loop-lag.js +87 -0
- package/dist-server/engine/loop-lag.js.map +1 -0
- package/dist-server/engine/model-gap.d.ts +108 -0
- package/dist-server/engine/model-gap.js +95 -0
- package/dist-server/engine/model-gap.js.map +1 -0
- package/dist-server/engine/restart-policy.d.ts +3 -3
- package/dist-server/engine/restart-policy.js +4 -4
- package/dist-server/engine/restart-policy.js.map +1 -1
- package/dist-server/engine/runtime-key.js +1 -1
- package/dist-server/engine/runtime-key.js.map +1 -1
- package/dist-server/engine/stage-path.d.ts +83 -0
- package/dist-server/engine/stage-path.js +118 -0
- package/dist-server/engine/stage-path.js.map +1 -0
- package/dist-server/engine/twin-engine.d.ts +352 -16
- package/dist-server/engine/twin-engine.js +1037 -166
- package/dist-server/engine/twin-engine.js.map +1 -1
- package/dist-server/index.js +34 -2
- package/dist-server/index.js.map +1 -1
- package/dist-server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.d.ts +5 -0
- package/dist-server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.js +58 -0
- package/dist-server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.js.map +1 -0
- package/dist-server/migrations/1786200000000-CarryLiveFeedCursor.d.ts +5 -0
- package/dist-server/migrations/1786200000000-CarryLiveFeedCursor.js +55 -0
- package/dist-server/migrations/1786200000000-CarryLiveFeedCursor.js.map +1 -0
- package/dist-server/migrations/index.js +7 -1
- package/dist-server/migrations/index.js.map +1 -1
- package/dist-server/service/index.d.ts +5 -2
- package/dist-server/service/index.js +18 -7
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/reference/discovery-result.d.ts +1 -1
- package/dist-server/service/reference/discovery-result.js +1 -1
- package/dist-server/service/reference/discovery-result.js.map +1 -1
- package/dist-server/service/reference/index.d.ts +4 -1
- package/dist-server/service/reference/index.js +6 -1
- package/dist-server/service/reference/index.js.map +1 -1
- package/dist-server/service/reference/reference-adapter.d.ts +184 -3
- package/dist-server/service/reference/reference-adapter.js +28 -4
- package/dist-server/service/reference/reference-adapter.js.map +1 -1
- package/dist-server/service/reference/reference-assessment.d.ts +68 -0
- package/dist-server/service/reference/reference-assessment.js +136 -0
- package/dist-server/service/reference/reference-assessment.js.map +1 -0
- package/dist-server/service/reference/reference-live.d.ts +12 -1
- package/dist-server/service/reference/reference-live.js +116 -11
- package/dist-server/service/reference/reference-live.js.map +1 -1
- package/dist-server/service/reference/reference-master.d.ts +47 -1
- package/dist-server/service/reference/reference-master.js +34 -4
- package/dist-server/service/reference/reference-master.js.map +1 -1
- package/dist-server/service/reference/reference-probe.d.ts +92 -0
- package/dist-server/service/reference/reference-probe.js +186 -0
- package/dist-server/service/reference/reference-probe.js.map +1 -0
- package/dist-server/service/reference/reference-progress-subscription.d.ts +17 -0
- package/dist-server/service/reference/reference-progress-subscription.js +94 -0
- package/dist-server/service/reference/reference-progress-subscription.js.map +1 -0
- package/dist-server/service/reference/reference-progress.d.ts +38 -0
- package/dist-server/service/reference/reference-progress.js +71 -0
- package/dist-server/service/reference/reference-progress.js.map +1 -0
- package/dist-server/service/reference/reference-resolver.js +210 -43
- package/dist-server/service/reference/reference-resolver.js.map +1 -1
- package/dist-server/service/reference/twin-reference.d.ts +32 -0
- package/dist-server/service/reference/twin-reference.js +10 -0
- package/dist-server/service/reference/twin-reference.js.map +1 -1
- package/dist-server/service/twin-control/twin-control-mutation.js +4 -3
- package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
- package/dist-server/service/twin-event/journal-count.d.ts +21 -0
- package/dist-server/service/twin-event/journal-count.js +26 -0
- package/dist-server/service/twin-event/journal-count.js.map +1 -0
- package/dist-server/service/twin-event/twin-event-keys.d.ts +9 -0
- package/dist-server/service/twin-event/twin-event-keys.js +15 -18
- package/dist-server/service/twin-event/twin-event-keys.js.map +1 -1
- package/dist-server/service/twin-event/twin-event-type.d.ts +3 -1
- package/dist-server/service/twin-event/twin-event-type.js +18 -1
- package/dist-server/service/twin-event/twin-event-type.js.map +1 -1
- package/dist-server/service/twin-event/twin-event.d.ts +1 -0
- package/dist-server/service/twin-event/twin-event.js +9 -1
- package/dist-server/service/twin-event/twin-event.js.map +1 -1
- package/dist-server/service/twin-forecast/twin-forecast-query.js +2 -1
- package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
- package/dist-server/service/twin-ingest-window/index.d.ts +5 -0
- package/dist-server/service/twin-ingest-window/index.js +10 -0
- package/dist-server/service/twin-ingest-window/index.js.map +1 -0
- package/dist-server/service/twin-ingest-window/twin-ingest-window-query.d.ts +12 -0
- package/dist-server/service/twin-ingest-window/twin-ingest-window-query.js +123 -0
- package/dist-server/service/twin-ingest-window/twin-ingest-window-query.js.map +1 -0
- package/dist-server/service/twin-ingest-window/twin-ingest-window-writer.d.ts +1 -0
- package/dist-server/service/twin-ingest-window/twin-ingest-window-writer.js +51 -0
- package/dist-server/service/twin-ingest-window/twin-ingest-window-writer.js.map +1 -0
- package/dist-server/service/twin-ingest-window/twin-ingest-window.d.ts +16 -0
- package/dist-server/service/twin-ingest-window/twin-ingest-window.js +108 -0
- package/dist-server/service/twin-ingest-window/twin-ingest-window.js.map +1 -0
- package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
- package/dist-server/service/twin-journal/twin-journal-query.js +58 -5
- package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.d.ts +14 -0
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +72 -2
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
- package/dist-server/service/twin-metrics/twin-metrics-query.d.ts +1 -0
- package/dist-server/service/twin-metrics/twin-metrics-query.js +78 -3
- package/dist-server/service/twin-metrics/twin-metrics-query.js.map +1 -1
- package/dist-server/service/twin-model/axis-journal-evidence.d.ts +2 -0
- package/dist-server/service/twin-model/axis-journal-evidence.js +89 -0
- package/dist-server/service/twin-model/axis-journal-evidence.js.map +1 -0
- package/dist-server/service/twin-model/epcis-coverage.d.ts +1 -1
- package/dist-server/service/twin-model/epcis-coverage.js +19 -7
- package/dist-server/service/twin-model/epcis-coverage.js.map +1 -1
- package/dist-server/service/twin-model/iec61850-coverage.d.ts +1 -1
- package/dist-server/service/twin-model/iec61850-coverage.js +18 -7
- package/dist-server/service/twin-model/iec61850-coverage.js.map +1 -1
- package/dist-server/service/twin-model/isa95-coverage.d.ts +14 -0
- package/dist-server/service/twin-model/isa95-coverage.js +23 -9
- package/dist-server/service/twin-model/isa95-coverage.js.map +1 -1
- package/dist-server/service/twin-model/item-ref.d.ts +24 -0
- package/dist-server/service/twin-model/item-ref.js +88 -0
- package/dist-server/service/twin-model/item-ref.js.map +1 -0
- package/dist-server/service/twin-model/name-index.d.ts +36 -0
- package/dist-server/service/twin-model/name-index.js +116 -0
- package/dist-server/service/twin-model/name-index.js.map +1 -0
- package/dist-server/service/twin-model/project-structure.js +1 -1
- package/dist-server/service/twin-model/project-structure.js.map +1 -1
- package/dist-server/service/twin-model/status-tally.d.ts +9 -0
- package/dist-server/service/twin-model/status-tally.js +37 -0
- package/dist-server/service/twin-model/status-tally.js.map +1 -0
- package/dist-server/service/twin-model/twin-lineage-query.js +40 -12
- package/dist-server/service/twin-model/twin-lineage-query.js.map +1 -1
- package/dist-server/service/twin-model/twin-model-item-query.js +38 -39
- package/dist-server/service/twin-model/twin-model-item-query.js.map +1 -1
- package/dist-server/service/twin-model/twin-model-query.js +156 -4
- package/dist-server/service/twin-model/twin-model-query.js.map +1 -1
- package/dist-server/service/twin-model/twin-model-tree-query.js +7 -0
- package/dist-server/service/twin-model/twin-model-tree-query.js.map +1 -1
- package/dist-server/service/twin-readiness/index.d.ts +2 -0
- package/dist-server/service/twin-readiness/index.js +6 -0
- package/dist-server/service/twin-readiness/index.js.map +1 -0
- package/dist-server/service/twin-readiness/twin-readiness-query.d.ts +3 -0
- package/dist-server/service/twin-readiness/twin-readiness-query.js +103 -0
- package/dist-server/service/twin-readiness/twin-readiness-query.js.map +1 -0
- package/dist-server/service/twin-space/twin-space-resolver.d.ts +2 -2
- package/dist-server/service/twin-space/twin-space-resolver.js +5 -4
- package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
- package/dist-shared/entity-delta.d.ts +23 -3
- package/dist-shared/entity-delta.js +12 -8
- package/dist-shared/entity-delta.js.map +1 -1
- package/dist-shared/kpi-broadcast.js +1 -1
- package/dist-shared/kpi-broadcast.js.map +1 -1
- package/dist-shared/touched-items.d.ts +7 -0
- package/dist-shared/touched-items.js +80 -0
- package/dist-shared/touched-items.js.map +1 -0
- package/package.json +7 -7
- package/server/engine/canonical-ingest.ts +92 -7
- package/server/engine/command-routing.ts +1 -1
- package/server/engine/index.ts +4 -0
- package/server/engine/ingest-health.ts +704 -0
- package/server/engine/integration-coverage.ts +147 -0
- package/server/engine/integration-probes.ts +144 -0
- package/server/engine/integration-runner.ts +95 -0
- package/server/engine/integration-target-profile.ts +103 -0
- package/server/engine/kpi-fold.ts +72 -4
- package/server/engine/kpi-query.ts +129 -15
- package/server/engine/live-feed-registry.ts +2 -1
- package/server/engine/load-meter.ts +12 -3
- package/server/engine/log.ts +72 -0
- package/server/engine/loop-lag.ts +120 -0
- package/server/engine/model-gap.ts +168 -0
- package/server/engine/restart-policy.ts +4 -4
- package/server/engine/runtime-key.ts +1 -1
- package/server/engine/stage-path.ts +172 -0
- package/server/engine/twin-engine.ts +1135 -162
- package/server/index.ts +35 -2
- package/server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.ts +54 -0
- package/server/migrations/1786200000000-CarryLiveFeedCursor.ts +53 -0
- package/server/migrations/index.ts +7 -1
- package/server/service/index.ts +11 -0
- package/server/service/reference/discovery-result.ts +1 -1
- package/server/service/reference/index.ts +6 -1
- package/server/service/reference/reference-adapter.ts +197 -5
- package/server/service/reference/reference-assessment.ts +215 -0
- package/server/service/reference/reference-live.ts +123 -11
- package/server/service/reference/reference-master.ts +64 -4
- package/server/service/reference/reference-probe.ts +264 -0
- package/server/service/reference/reference-progress-subscription.ts +73 -0
- package/server/service/reference/reference-progress.ts +95 -0
- package/server/service/reference/reference-resolver.ts +200 -12
- package/server/service/reference/twin-reference.ts +39 -1
- package/server/service/twin-control/twin-control-mutation.ts +4 -3
- package/server/service/twin-event/journal-count.ts +53 -0
- package/server/service/twin-event/twin-event-keys.ts +16 -1
- package/server/service/twin-event/twin-event-type.ts +26 -2
- package/server/service/twin-event/twin-event.ts +7 -0
- package/server/service/twin-forecast/twin-forecast-query.ts +2 -1
- package/server/service/twin-ingest-window/index.ts +7 -0
- package/server/service/twin-ingest-window/twin-ingest-window-query.ts +125 -0
- package/server/service/twin-ingest-window/twin-ingest-window-writer.ts +57 -0
- package/server/service/twin-ingest-window/twin-ingest-window.ts +113 -0
- package/server/service/twin-instance/twin-instance.ts +1 -1
- package/server/service/twin-journal/twin-journal-query.ts +59 -5
- package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +77 -3
- package/server/service/twin-metrics/twin-metrics-query.ts +81 -3
- package/server/service/twin-model/axis-journal-evidence.ts +57 -0
- package/server/service/twin-model/epcis-coverage.ts +2 -8
- package/server/service/twin-model/iec61850-coverage.ts +2 -8
- package/server/service/twin-model/isa95-coverage.ts +22 -9
- package/server/service/twin-model/item-ref.ts +80 -0
- package/server/service/twin-model/name-index.ts +94 -0
- package/server/service/twin-model/project-structure.ts +1 -1
- package/server/service/twin-model/status-tally.ts +37 -0
- package/server/service/twin-model/twin-lineage-query.ts +38 -9
- package/server/service/twin-model/twin-model-item-query.ts +26 -27
- package/server/service/twin-model/twin-model-query.ts +155 -4
- package/server/service/twin-model/twin-model-tree-query.ts +7 -0
- package/server/service/twin-readiness/index.ts +3 -0
- package/server/service/twin-readiness/twin-readiness-query.ts +96 -0
- package/server/service/twin-space/twin-space-resolver.ts +5 -4
- package/shared/entity-delta.ts +31 -7
- package/shared/kpi-broadcast.ts +1 -1
- package/shared/touched-items.ts +73 -0
- package/test/axis-journal-evidence.test.ts +71 -0
- package/test/axis-read.test.ts +101 -1
- package/test/boot-resume.test.ts +92 -26
- package/test/broadcast-cost-baseline.test.ts +200 -0
- package/test/broadcast-period.test.ts +58 -0
- package/test/canonical-ingest-vocabularies.test.ts +36 -1
- package/test/canonical-quantity-door.test.ts +61 -0
- package/test/command-routing.test.ts +1 -1
- package/test/declared-location-types.test.ts +89 -0
- package/test/discovery-result.test.ts +1 -1
- package/test/duration-estimators.test.ts +1 -1
- package/test/entity-delta.test.ts +2 -2
- package/test/ingest-bench.test.ts +3 -3
- package/test/ingest-health-engine.test.ts +248 -0
- package/test/ingest-health-wiring.test.ts +119 -0
- package/test/ingest-health.test.ts +306 -0
- package/test/ingest-history.test.ts +247 -0
- package/test/integration-probes.test.ts +103 -0
- package/test/integration-runner.test.ts +95 -0
- package/test/item-ref.test.ts +78 -0
- package/test/journal-read-discipline.test.ts +177 -0
- package/test/journal-retention.test.ts +133 -0
- package/test/journal-sort-axis.test.ts +142 -0
- package/test/journal-write-door.test.ts +110 -0
- package/test/journal-write-trend.test.ts +115 -0
- package/test/kernel-kind-guard.test.ts +4 -4
- package/test/kpi-fold.test.ts +90 -3
- package/test/kpi-query-bench.test.ts +2 -2
- package/test/lineage-survives-restart.test.ts +17 -1
- package/test/live-cursor-wiring.test.ts +87 -0
- package/test/live-feed-registry.test.ts +9 -1
- package/test/live-kernel-facts.test.ts +7 -7
- package/test/live-mirror-parity.test.ts +6 -0
- package/test/load-meter.test.ts +29 -15
- package/test/local-declarations.test.ts +1 -1
- package/test/log-stamp.test.ts +59 -0
- package/test/loop-lag.test.ts +82 -0
- package/test/model-gap.test.ts +153 -0
- package/test/oee-accumulator.test.ts +4 -0
- package/test/operational-vocabulary.test.ts +4 -4
- package/test/read-failure-visible.test.ts +145 -0
- package/test/reference-grounding.test.ts +70 -0
- package/test/resolve-ts-siblings.mjs +52 -0
- package/test/restart-policy.test.ts +7 -7
- package/test/revision-axis.test.ts +93 -0
- package/test/runtime-key.test.ts +2 -2
- package/test/scale-twin-bench.test.ts +2 -2
- package/test/spec-coverage.test.ts +1 -1
- package/test/stage-path.test.ts +95 -0
- package/test/standard-coverage.test.ts +15 -3
- package/test/status-tally.test.ts +55 -0
- package/test/structure-revision-db.test.ts +4 -0
- package/test/tenant-registry-db.test.ts +1 -1
- package/test/time-range.test.ts +152 -0
- package/test/touched-items.test.ts +61 -0
- package/test/twin-event-keys.test.ts +10 -2
- package/test/twin-model-item-db.test.ts +2 -0
- package/test/warm-start-seam.test.ts +4 -0
- package/test/yield-loop.test.ts +57 -2
- package/tsconfig.shared.tsbuildinfo +1 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ★ 비직렬 수량이 들어오는 문 — **조용히 사라지지 않는다.**
|
|
3
|
+
*
|
|
4
|
+
* `CanonicalRecord` 에 `quantityList` 자리를 냈다(담김의 `childQuantityList` 가 하루 먼저 같은 이유로
|
|
5
|
+
* 받은 자리다). 원본이 「이 자리에 이 품목이 얼마 남았다」를 말할 길이 없어서, 그것이 주된 경로인
|
|
6
|
+
* 시스템(식품 제조 MES)은 라이브를 낼 수 없었다.
|
|
7
|
+
*
|
|
8
|
+
* ── 배포 뒤 (2026-08-21) ────────────────────────────────────────────────────
|
|
9
|
+
* 커널 0.7.40 에 `ObjectEventMapping.quantityList` 가 들어와 **수량만 오는 레코드가 통과한다.**
|
|
10
|
+
* 그 전까지는 `epcList` 와 `quantityList` 가 둘 다 비어 검증이 거부했고, 이 시험은 그 거부가 **조용하지
|
|
11
|
+
* 않다**(이유와 함께 계수된다)는 것을 지켰다.
|
|
12
|
+
*
|
|
13
|
+
* 이제 통과 쪽으로 고정한다 — 통과와 거부를 둘 다 받아들이면 다시 막혔을 때 초록으로 남는다.
|
|
14
|
+
*/
|
|
15
|
+
import { test } from 'node:test'
|
|
16
|
+
import assert from 'node:assert/strict'
|
|
17
|
+
|
|
18
|
+
import { ingestCanonicalRecords } from '../server/engine/canonical-ingest.ts'
|
|
19
|
+
|
|
20
|
+
const T0 = '2026-08-20T09:00:00.000Z'
|
|
21
|
+
const CLASS = 'https://chef.example.com/class/product-7f3a'
|
|
22
|
+
|
|
23
|
+
const qtyRecord = {
|
|
24
|
+
action: 'OBSERVE',
|
|
25
|
+
bizStep: 'urn:epcglobal:cbv:bizstep:storing',
|
|
26
|
+
disposition: 'urn:epcglobal:cbv:disp:sellable_accessible',
|
|
27
|
+
quantityList: [{ epcClass: CLASS, quantity: 3.5, uom: 'KGM' }],
|
|
28
|
+
readPoint: 'chef-wh:w1',
|
|
29
|
+
bizLocation: 'chef-wh:w1'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
test('수량만 오는 레코드가 통과하고 수량이 봉투까지 실린다', () => {
|
|
33
|
+
const { accepted, rejected } = ingestCanonicalRecords(qtyRecord as any, 'plantA', T0)
|
|
34
|
+
assert.deepEqual(rejected, [], `거부됐다 — 커널에 quantityList 자리가 있는지 확인: ${JSON.stringify(rejected)}`)
|
|
35
|
+
assert.equal(accepted.length, 1)
|
|
36
|
+
/* 자리만 통과하고 값이 빠지면 더 나쁘다 — 실린 줄 알고 잃는다. */
|
|
37
|
+
const ev: any = (accepted[0] as any).data ?? (accepted[0] as any).event ?? accepted[0]
|
|
38
|
+
assert.deepEqual(ev.quantityList, [{ epcClass: CLASS, quantity: 3.5, uom: 'KGM' }])
|
|
39
|
+
assert.deepEqual(ev.epcList, [], '개체가 없으므로 빈 배열이다 — 지어내지 않는다')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('개체 관측은 그대로 통과한다 — 문을 내면서 기존 길을 막지 않는다', () => {
|
|
43
|
+
const { accepted, rejected } = ingestCanonicalRecords(
|
|
44
|
+
{ epc: 'urn:epc:id:sgtin:9521321.012345.1', action: 'OBSERVE', bizStep: 'urn:epcglobal:cbv:bizstep:storing', readPoint: 'chef-wh:w1' } as any,
|
|
45
|
+
'plantA',
|
|
46
|
+
T0
|
|
47
|
+
)
|
|
48
|
+
assert.equal(rejected.length, 0, `개체 관측이 거부되면 안 된다: ${JSON.stringify(rejected)}`)
|
|
49
|
+
assert.equal(accepted.length, 1)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('개체도 수량도 없으면 거부된다 — 무엇을 관측했는지 말하지 않았다', () => {
|
|
53
|
+
const { accepted, rejected } = ingestCanonicalRecords(
|
|
54
|
+
{ action: 'OBSERVE', bizStep: 'urn:epcglobal:cbv:bizstep:storing', readPoint: 'chef-wh:w1' } as any,
|
|
55
|
+
'plantA',
|
|
56
|
+
T0
|
|
57
|
+
)
|
|
58
|
+
assert.equal(accepted.length, 0)
|
|
59
|
+
assert.equal(rejected.length, 1)
|
|
60
|
+
assert.ok(rejected[0].errors?.length, '이유 없이 버리지 않는다')
|
|
61
|
+
})
|
|
@@ -43,7 +43,7 @@ test('없는 인스턴스와 다른 도메인은 같은 사유로 거절한다
|
|
|
43
43
|
assert.deepEqual(routeCommand(sim, false, 'attention.ack'), { target: 'reject', errorCode: 'no-instance' })
|
|
44
44
|
})
|
|
45
45
|
|
|
46
|
-
test('런타임이 없는 시뮬 —
|
|
46
|
+
test('런타임이 없는 시뮬 — 오류를 내지 않고 이유를 알린다', () => {
|
|
47
47
|
assert.deepEqual(routeCommand({ hasRuntime: false, hasKernelDispatch: true }, true, 'order.hold'), { target: 'reject', errorCode: 'no-runtime' })
|
|
48
48
|
})
|
|
49
49
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ★ 자리 타입을 **누구에게 묻는가** — 원본이 선언했으면 그 선언이 정본이다.
|
|
3
|
+
*
|
|
4
|
+
* ── 무엇이 틀렸나 (2026-08-20) ──────────────────────────────────────────────
|
|
5
|
+
* 호스트는 「이 자리 타입을 아는가」를 **내장 카탈로그**에게만 물었다(`DOMAIN_CATALOG[system]`).
|
|
6
|
+
* 그런데 원본은 자기 `DomainDefinition.locationTypes` 를 함께 보낼 수 있고, 그러면 **그 선언이
|
|
7
|
+
* 정본**이다 — 카탈로그는 우리가 아는 도메인들의 기본 어휘일 뿐이다.
|
|
8
|
+
*
|
|
9
|
+
* 상대가 틀렸으니 결과가 이랬다: 자기 어휘를 선언한 원본(예: F&B 라인 — 배합·가열·세척·금속검출·
|
|
10
|
+
* 소분·포장)은 **정상적인 자리마다 경고를 하나씩** 받는다. 경고가 소음이 되면 사람은 그것을 읽지 않게
|
|
11
|
+
* 되고, 그때 **진짜 경고도 함께 죽는다.**
|
|
12
|
+
*
|
|
13
|
+
* ── 지키는 것 ───────────────────────────────────────────────────────────────
|
|
14
|
+
* ① 선언이 있으면 선언에 대조한다 ② 선언이 없으면 카탈로그로 떨어진다 ③ 선언을 **못 읽은 것**과
|
|
15
|
+
* 「아무것도 선언하지 않은 것」을 가른다(못 읽었으면 카탈로그로 떨어진다 — 빈 목록으로 읽으면 모든
|
|
16
|
+
* 자리가 경고를 받는다) ④ 아무 데도 없는 타입은 여전히 경고한다.
|
|
17
|
+
*/
|
|
18
|
+
import { test } from 'node:test'
|
|
19
|
+
import assert from 'node:assert/strict'
|
|
20
|
+
import { createRequire } from 'node:module'
|
|
21
|
+
|
|
22
|
+
const req = createRequire(import.meta.url)
|
|
23
|
+
const { masterToTwin, declaredLocationTypes } = req('../dist-server/service/reference/reference-master.js')
|
|
24
|
+
|
|
25
|
+
/** F&B 라인 — 내장 MES 카탈로그(트레일러 제조)에는 없는 어휘. */
|
|
26
|
+
const FNB = ['mixing', 'cooking', 'washing', 'metal-detect', 'portioning', 'packing']
|
|
27
|
+
|
|
28
|
+
const CATALOG = { mes: { locationTypes: ['raw-store', 'cut-station', 'fg-store'] } }
|
|
29
|
+
|
|
30
|
+
const master = (types: string[], productionSpec?: unknown) => ({
|
|
31
|
+
source: 'chef-1',
|
|
32
|
+
system: 'mes',
|
|
33
|
+
siteName: '식품 공장',
|
|
34
|
+
/* `masterToTwin` 이 공간에서 spaceId 를 꺼낸다 — 최소 모양을 준다. */
|
|
35
|
+
space: { spaceId: 'chef-site', width: 60, depth: 30, unit: 'm' },
|
|
36
|
+
locations: types.map((t, i) => ({ id: `loc-${i}`, type: t, role: 'location' as const })),
|
|
37
|
+
equipment: [],
|
|
38
|
+
...(productionSpec ? { productionSpec } : {})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const specWith = (keys: string[]) => ({ definition: { locationTypes: keys.map(k => ({ key: k, label: k })) } })
|
|
42
|
+
|
|
43
|
+
const unknownWarnings = (r: any) => (r.warnings ?? []).filter((w: any) => w.code === 'unknown-location-type')
|
|
44
|
+
|
|
45
|
+
test('원본이 선언했으면 그 선언에 대조한다 — 정상적인 자리에 경고를 주지 않는다', () => {
|
|
46
|
+
const r = masterToTwin(master(FNB, specWith(FNB)), CATALOG)
|
|
47
|
+
|
|
48
|
+
assert.deepEqual(unknownWarnings(r), [], 'F&B 여섯 자리 모두 선언돼 있다')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('선언이 없으면 카탈로그로 떨어진다 — 예전 거동 그대로', () => {
|
|
52
|
+
const r = masterToTwin(master(['raw-store', 'mixing']), CATALOG)
|
|
53
|
+
const w = unknownWarnings(r)
|
|
54
|
+
|
|
55
|
+
assert.equal(w.length, 1, '카탈로그에 없는 하나만')
|
|
56
|
+
assert.equal(w[0].params.type, 'mixing')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('선언에도 카탈로그에도 없으면 경고한다 — 진짜 경고는 죽이지 않는다', () => {
|
|
60
|
+
const r = masterToTwin(master([...FNB, 'ghost-station'], specWith(FNB)), CATALOG)
|
|
61
|
+
const w = unknownWarnings(r)
|
|
62
|
+
|
|
63
|
+
assert.equal(w.length, 1)
|
|
64
|
+
assert.equal(w[0].params.type, 'ghost-station')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('선언을 못 읽으면 카탈로그로 떨어진다 — 빈 목록으로 읽지 않는다', () => {
|
|
68
|
+
/*
|
|
69
|
+
* 「없는 것」과 「못 읽은 것」은 다르다. 못 읽은 것을 빈 목록으로 읽으면 **모든 자리가 경고**를 받고,
|
|
70
|
+
* 그것이 바로 이 시험이 막으려는 소음이다.
|
|
71
|
+
*/
|
|
72
|
+
for (const broken of [{}, { definition: {} }, { definition: { locationTypes: [] } }, { definition: { locationTypes: 'nope' } }]) {
|
|
73
|
+
assert.equal(declaredLocationTypes(broken), undefined, `못 읽은 모양: ${JSON.stringify(broken)}`)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const r = masterToTwin(master(['raw-store', 'mixing'], { definition: { locationTypes: [] } }), CATALOG)
|
|
77
|
+
assert.equal(unknownWarnings(r).length, 1, '카탈로그 기준으로 하나만 경고한다')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('선언 목록의 모양을 너그럽게 읽는다 — 문자열 목록도 받는다', () => {
|
|
81
|
+
assert.deepEqual(declaredLocationTypes({ definition: { locationTypes: ['mixing', 'packing'] } }), ['mixing', 'packing'])
|
|
82
|
+
assert.deepEqual(declaredLocationTypes({ definition: { locationTypes: [{ key: 'mixing' }, { nope: 1 }] } }), ['mixing'])
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('카탈로그도 선언도 없으면 아무 경고도 하지 않는다 — 모르는 것을 문제로 만들지 않는다', () => {
|
|
86
|
+
const r = masterToTwin(master(FNB), undefined)
|
|
87
|
+
|
|
88
|
+
assert.deepEqual(unknownWarnings(r), [], '견줄 상대가 없으면 판단하지 않는다')
|
|
89
|
+
})
|
|
@@ -65,7 +65,7 @@ test('타임아웃은 초 단위로 알린다 — 얼마를 기다렸는지 알
|
|
|
65
65
|
assert.match(e.message, /15s/)
|
|
66
66
|
})
|
|
67
67
|
|
|
68
|
-
test('어댑터가
|
|
68
|
+
test('어댑터가 낸 오류에서 사유를 뽑는다 — 형태를 가정하지 않는다', () => {
|
|
69
69
|
assert.equal(detailOf(new Error('ECONNREFUSED')), 'ECONNREFUSED')
|
|
70
70
|
assert.equal(detailOf('404 Not Found'), '404 Not Found')
|
|
71
71
|
/* 무엇이 왔는지 모르면 그렇게 말한다 — 빈 문장으로 "이유 없음" 처럼 보이게 하지 않는다. */
|
|
@@ -123,7 +123,7 @@ test('p50 이 0 이면 쓰지 않는다 — 0 초 작업은 측정 실패다', (
|
|
|
123
123
|
assert.deepEqual(r.learned, {})
|
|
124
124
|
})
|
|
125
125
|
|
|
126
|
-
test('그룹이 없으면 빈 결과 —
|
|
126
|
+
test('그룹이 없으면 빈 결과 — 오류를 내지 않는다', () => {
|
|
127
127
|
assert.equal(buildMeasuredEstimator(undefined).estimator, undefined)
|
|
128
128
|
assert.equal(buildMeasuredEstimator([]).estimator, undefined)
|
|
129
129
|
})
|
|
@@ -118,7 +118,7 @@ test('물품 집약: 상한을 넘으면 잘린 사실을 밝힌다 — 조용
|
|
|
118
118
|
assert.equal(agg.items.length, ITEMS_AGGREGATE_LIMIT)
|
|
119
119
|
assert.equal(agg.total, ITEMS_AGGREGATE_LIMIT + 7)
|
|
120
120
|
assert.equal(agg.truncated, 7)
|
|
121
|
-
/* 개별 물품은 상한과 무관하게 전부 태그별로 나간다(시그니처 dedup 이 변한 것만
|
|
121
|
+
/* 개별 물품은 상한과 무관하게 전부 태그별로 나간다(시그니처 dedup 이 변한 것만 재브로드캐스팅한다). */
|
|
122
122
|
assert.equal(d.filter(x => x.data?.entity === 'item').length, ITEMS_AGGREGATE_LIMIT + 7)
|
|
123
123
|
})
|
|
124
124
|
|
|
@@ -234,7 +234,7 @@ test('수요 구간·피크·계약은 집약 태그로 나간다 — 개체의
|
|
|
234
234
|
assert.ok(agg, '집약 태그가 있어야 한다')
|
|
235
235
|
assert.equal(agg.data.contractKW, 4000)
|
|
236
236
|
assert.equal(agg.data.peakSince.kW, 4200)
|
|
237
|
-
/* 하루치를 매
|
|
237
|
+
/* 하루치를 매 브로드캐스팅에 실으면 채널이 그것으로 찬다 — 최근 것만 내고 전체 수를 함께 낸다. */
|
|
238
238
|
assert.equal(agg.data.recent.length, 8)
|
|
239
239
|
assert.equal(agg.data.closedTotal, 137, '자른 사실을 조용히 두지 않는다')
|
|
240
240
|
})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* 커널 인제스트 인터페이스 부하 실측(가정 아닌 측정) — Face1/커널 동기 인터페이스의 처리량 특성.
|
|
3
|
-
* apply(이벤트 흡수) · snapshot(전체 투영) · buildEntityDeltas(
|
|
3
|
+
* apply(이벤트 흡수) · snapshot(전체 투영) · buildEntityDeltas(브로드캐스팅 payload, 't1') 의 비용을 규모별로 잰다.
|
|
4
4
|
* 결론은 design/integration/ingest-scale.md §커널 인터페이스에 스펙으로 반영.
|
|
5
5
|
* (순수 커널+호스트 매핑, shell 무관 → node:test.)
|
|
6
6
|
*/
|
|
@@ -56,7 +56,7 @@ test('bench: buildEntityDeltas 비용 vs 엔티티 수 (전 엔티티 매번 방
|
|
|
56
56
|
}
|
|
57
57
|
})
|
|
58
58
|
|
|
59
|
-
test('bench:
|
|
59
|
+
test('bench: 브로드캐스팅 1회 총비용(snapshot + buildEntityDeltas) vs 상태 규모', () => {
|
|
60
60
|
for (const [locations, items] of [[200, 5000], [1000, 50000], [2000, 100000]]) {
|
|
61
61
|
const proj = new StateProjector(model(locations))
|
|
62
62
|
for (let i = 0; i < items; i++) proj.apply(envelope(`urn:epc:id:sscc:0614141.${i}`, `bin-${i % locations}`))
|
|
@@ -64,6 +64,6 @@ test('bench: 방송 1회 총비용(snapshot + buildEntityDeltas) vs 상태 규
|
|
|
64
64
|
const t0 = performance.now()
|
|
65
65
|
for (let k = 0; k < iters; k++) buildEntityDeltas(proj.snapshot(), 't1')
|
|
66
66
|
const per = (performance.now() - t0) / iters
|
|
67
|
-
console.log(` [
|
|
67
|
+
console.log(` [브로드캐스팅1회] ${locations.toLocaleString()} locations / ${items.toLocaleString()} items: ${per.toFixed(3)}ms → 최대 ${Math.round(1000 / per).toLocaleString()} 브로드캐스팅/s`)
|
|
68
68
|
}
|
|
69
69
|
})
|
|
@@ -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
|
+
})
|