@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.
- 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 +376 -0
- package/dist-server/engine/ingest-health.js +482 -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 +386 -16
- package/dist-server/engine/twin-engine.js +1217 -172
- 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 +210 -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 +78 -0
- package/dist-server/service/reference/reference-assessment.js +156 -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 +134 -11
- package/dist-server/service/reference/reference-live.js.map +1 -1
- package/dist-server/service/reference/reference-master.d.ts +59 -3
- 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 +26 -2
- 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 +46 -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 +108 -14
- 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 +782 -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 +1334 -168
- 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 +220 -5
- package/server/service/reference/reference-assessment.ts +250 -0
- package/server/service/reference/reference-live.ts +143 -11
- package/server/service/reference/reference-master.ts +76 -6
- 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 +29 -2
- 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 +29 -8
- package/server/service/twin-model/iec61850-coverage.ts +2 -8
- package/server/service/twin-model/isa95-coverage.ts +107 -14
- 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/checkpoint-refuses-empty.test.ts +103 -0
- package/test/command-routing.test.ts +1 -1
- package/test/cursor-stall-not-read-failure.test.ts +126 -0
- 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/mirror-resumes-from-checkpoint.test.ts +192 -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 +104 -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,82 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 루프 지체 계기 — **원인을 지어내지 않기 위한 값**이 정확한지 본다 (2026-08-21).
|
|
3
|
+
*
|
|
4
|
+
* 지키는 것:
|
|
5
|
+
* ① 늦게 깨어난 만큼만 지체다 — 제때 깨어난 표본은 0 이고, 0 을 정체로 세지 않는다.
|
|
6
|
+
* ② 창이 닫히기 전에는 `recent` 가 **null** 이다(채우는 중인 창을 「지금」으로 읽으면 과소 집계다).
|
|
7
|
+
* ③ 가장 길게 막힌 **시각**을 든다 — 언제였는지가 원인을 가리는 근거다.
|
|
8
|
+
* ④ 문턱 이하의 흔들림은 정체로 세지 않는다.
|
|
9
|
+
*/
|
|
10
|
+
import { test } from 'node:test'
|
|
11
|
+
import assert from 'node:assert/strict'
|
|
12
|
+
|
|
13
|
+
import { LoopLagSampler } from '../server/engine/loop-lag.ts'
|
|
14
|
+
|
|
15
|
+
/** 시계를 손으로 밀어 넣는다 — 실제 타이머로 재면 기계 상태가 결과를 정한다. */
|
|
16
|
+
function sampler(sampleMs = 500, stallMs = 200, windowMs = 10_000) {
|
|
17
|
+
let now = 1_000_000
|
|
18
|
+
const s = new LoopLagSampler(sampleMs, stallMs, windowMs, () => now)
|
|
19
|
+
return { s, advance: (ms: number) => (now += ms), at: () => now }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test('제때 깨어난 표본은 지체가 0 이고 정체로 세지 않는다', () => {
|
|
23
|
+
const { s, advance, at } = sampler()
|
|
24
|
+
for (let i = 0; i < 4; i++) {
|
|
25
|
+
advance(500)
|
|
26
|
+
assert.equal(s.sample(at()), 0)
|
|
27
|
+
}
|
|
28
|
+
assert.equal(s.view().total.stalls, 0)
|
|
29
|
+
assert.equal(s.view().total.maxMs, 0)
|
|
30
|
+
assert.equal(s.view().total.samples, 4)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('늦게 깨어난 만큼이 지체다 — 그 초과분만', () => {
|
|
34
|
+
const { s, advance, at } = sampler()
|
|
35
|
+
advance(500)
|
|
36
|
+
s.sample(at())
|
|
37
|
+
advance(4_600) // 예정보다 4,100ms 늦게
|
|
38
|
+
assert.equal(s.sample(at()), 4_100)
|
|
39
|
+
assert.equal(s.view().total.maxMs, 4_100)
|
|
40
|
+
assert.equal(s.view().total.stalls, 1)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('문턱 이하의 흔들림은 정체가 아니다 — 값은 남기고 세지는 않는다', () => {
|
|
44
|
+
const { s, advance, at } = sampler(500, 200)
|
|
45
|
+
advance(650) // 150ms 지체 — 문턱 200 미만
|
|
46
|
+
s.sample(at())
|
|
47
|
+
const v = s.view()
|
|
48
|
+
assert.equal(v.total.maxMs, 150)
|
|
49
|
+
assert.equal(v.total.stalls, 0, '문턱 이하를 세면 정상 상태가 정체로 보인다')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('창이 닫히기 전에는 recent 가 null 이다 — 채우는 중인 창을 「지금」으로 읽지 않는다', () => {
|
|
53
|
+
const { s, advance, at } = sampler(500, 200, 2_000)
|
|
54
|
+
advance(500)
|
|
55
|
+
s.sample(at())
|
|
56
|
+
assert.equal(s.view().recent, null)
|
|
57
|
+
|
|
58
|
+
/* 창 길이를 넘기면 그 창이 닫혀 `recent` 가 된다. */
|
|
59
|
+
advance(2_000)
|
|
60
|
+
s.sample(at())
|
|
61
|
+
const v = s.view()
|
|
62
|
+
assert.ok(v.recent, '창 길이를 넘겼으면 닫힌 창이 있어야 한다')
|
|
63
|
+
assert.equal(v.recent!.stalls, 1, '1,500ms 지체는 정체 한 번이다')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('가장 길게 막힌 시각을 든다 — 언제였는지가 원인을 가린다', () => {
|
|
67
|
+
const { s, advance, at } = sampler()
|
|
68
|
+
advance(500)
|
|
69
|
+
s.sample(at())
|
|
70
|
+
advance(3_000)
|
|
71
|
+
const worst = at()
|
|
72
|
+
s.sample(worst)
|
|
73
|
+
advance(500)
|
|
74
|
+
s.sample(at())
|
|
75
|
+
assert.equal(s.view().worstAtMs, worst)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('한 번도 재지 않았으면 정체 시각은 null 이다 — 0 으로 적지 않는다', () => {
|
|
79
|
+
const { s } = sampler()
|
|
80
|
+
assert.equal(s.view().worstAtMs, null)
|
|
81
|
+
assert.equal(s.view().recent, null)
|
|
82
|
+
})
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ★ **재기동이 저널을 처음부터 다시 접지 않는다** — 미러가 저장된 재개점에서 이어 선다.
|
|
3
|
+
*
|
|
4
|
+
* ── 무엇이 오래 잘못돼 있었나 (2026-08-24 실측) ─────────────────────────────
|
|
5
|
+
* 재개점을 **읽는** 쪽은 다 있었다: `recover` 는 `fold` 가 있으면 꼬리만 접고, 없으면 저널을 0부터
|
|
6
|
+
* 접는다. 그런데 **쓰는 쪽이 없었다** — `persistSnapshot` 은 `{revision, state, structureRev}` 만
|
|
7
|
+
* 적었다. 실측으로 저장된 스냅샷 34건 전부 `fold` 가 비어 있었고, 저널은 2,960만 줄이었다.
|
|
8
|
+
*
|
|
9
|
+
* 그래서 두 가지가 함께 일어났다.
|
|
10
|
+
*
|
|
11
|
+
* ① 켤 때마다 처음부터 접었다 — 규모 기준(엔티티 10만·품목 100만)에서 느린 것이 아니라 못 하는 것이다
|
|
12
|
+
* ② 미러는 되찾은 상태를 **버렸다**(`startLive`). 「다음 계측이 정정한다」는 전제였는데, 원본이 커서
|
|
13
|
+
* 증분으로 말하는 현장에서는 그 정정이 오지 않는다 — 미러가 영구히 빈 채로 남고 화면은 조용했다
|
|
14
|
+
*
|
|
15
|
+
* ── 왜 커널 시험만으로 충분하지 않나 ────────────────────────────────────────
|
|
16
|
+
* 커널은 「재개점 + 꼬리 == 0부터 접기」를 증명한다(`observed-checkpoint.test.ts`). 그것이 참이어도
|
|
17
|
+
* 호스트가 재개점을 **적지 않거나 되세우지 않으면** 아무 일도 일어나지 않는다 — 이 저장소가 실제로
|
|
18
|
+
* 넉 달 동안 그 상태였다. 그래서 여기서 DB 를 물고 실제로 걷는다.
|
|
19
|
+
*
|
|
20
|
+
* ── 결정적인 단정 ───────────────────────────────────────────────────────────
|
|
21
|
+
* 재기동 전에 **저널을 지운다.** 그래도 상태가 돌아오면 그것은 저널을 접어 온 것이 **아니다** —
|
|
22
|
+
* 저장된 재개점에서 온 것이다. 그리고 그 상황은 가정이 아니라 설계된 거동이다: 보존 정책이 체크포인트
|
|
23
|
+
* 리비전 이하의 행을 지운다(「그 앞은 스냅샷이 대신한다」).
|
|
24
|
+
*/
|
|
25
|
+
import { test, before, after } from 'node:test'
|
|
26
|
+
import assert from 'node:assert/strict'
|
|
27
|
+
import { createRequire } from 'node:module'
|
|
28
|
+
import type { DataSource } from 'typeorm'
|
|
29
|
+
|
|
30
|
+
const req = createRequire(import.meta.url)
|
|
31
|
+
const { NamingStrategy, addDataSource, entities: shellEntities, Domain } = req('@things-factory/shell')
|
|
32
|
+
const { entities: authEntities } = req('@things-factory/auth-base')
|
|
33
|
+
const { entities: twinEntities } = req('../dist-server/service/index.js')
|
|
34
|
+
/* 체크포인트는 `cache-service` 표에 앉는다 — 그 엔티티를 빼면 저장이 조용히 실패한다(스냅샷도 씨앗도). */
|
|
35
|
+
const { entities: cacheEntities } = req('@things-factory/cache-service/dist-server/service/index.js')
|
|
36
|
+
const { TwinEngine } = req('../dist-server/engine/index.js')
|
|
37
|
+
const { TwinEvent } = req('../dist-server/service/twin-event/twin-event.js')
|
|
38
|
+
const { DataSource: TypeOrmDataSource } = req('typeorm')
|
|
39
|
+
|
|
40
|
+
let ds: DataSource
|
|
41
|
+
let domainId: string
|
|
42
|
+
|
|
43
|
+
const INSTANCE = 'resume-probe'
|
|
44
|
+
|
|
45
|
+
/** 미러 하나 — 원본이 사실을 말해 주는 트윈이므로 시나리오도 레시피도 필요 없다. */
|
|
46
|
+
const master = (): any => ({
|
|
47
|
+
source: INSTANCE,
|
|
48
|
+
system: 'wms',
|
|
49
|
+
siteName: '재개점 창고',
|
|
50
|
+
origin: { kind: 'reference' }, // 현실 출처 선언 — 이것이 미러로 뜨는 근거다
|
|
51
|
+
space: { width: 40, depth: 20, unit: 'm' },
|
|
52
|
+
locations: [
|
|
53
|
+
{ id: 'dock', type: 'dock', role: 'location', capacity: 100 },
|
|
54
|
+
{ id: 'bin-1', type: 'storage', role: 'location', capacity: 100 }
|
|
55
|
+
],
|
|
56
|
+
equipment: [{ id: 'fk-1', kind: 'forklift', homeLocationId: 'dock' }]
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
/** 원본이 말한 사실 — 오더와 작업. 미러는 이것을 접어 상태를 만든다. */
|
|
60
|
+
function facts(n: number): any[] {
|
|
61
|
+
const out: any[] = []
|
|
62
|
+
for (let i = 1; i <= n; i++) {
|
|
63
|
+
out.push({
|
|
64
|
+
eventId: `o-${i}`,
|
|
65
|
+
eventType: 'order.status',
|
|
66
|
+
eventTime: '2026-08-24T01:00:00.000Z',
|
|
67
|
+
tenantId: domainId,
|
|
68
|
+
data: { orderId: `WO-${i}`, kind: 'workorder', status: 'in-progress', requested: 10, fulfilled: 3 }
|
|
69
|
+
})
|
|
70
|
+
out.push({
|
|
71
|
+
eventId: `t-${i}`,
|
|
72
|
+
eventType: 'task.status',
|
|
73
|
+
eventTime: '2026-08-24T01:00:01.000Z',
|
|
74
|
+
tenantId: domainId,
|
|
75
|
+
data: {
|
|
76
|
+
taskId: `TK-${i}`,
|
|
77
|
+
kind: 'putaway',
|
|
78
|
+
status: 'in-progress',
|
|
79
|
+
orderId: `WO-${i}`,
|
|
80
|
+
fromNode: 'dock',
|
|
81
|
+
toNode: 'bin-1',
|
|
82
|
+
intent: 'transport',
|
|
83
|
+
durationMs: 60000,
|
|
84
|
+
remainingMs: 30000
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
return out
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const observed = (s: any): number => (s?.items?.length ?? 0) + (s?.orders?.length ?? 0) + (s?.tasks?.length ?? 0)
|
|
92
|
+
|
|
93
|
+
before(async () => {
|
|
94
|
+
ds = new TypeOrmDataSource({
|
|
95
|
+
type: 'sqlite',
|
|
96
|
+
database: ':memory:',
|
|
97
|
+
synchronize: true,
|
|
98
|
+
dropSchema: true,
|
|
99
|
+
namingStrategy: new NamingStrategy(),
|
|
100
|
+
entities: [...shellEntities, ...authEntities, ...twinEntities, ...cacheEntities],
|
|
101
|
+
logging: false
|
|
102
|
+
})
|
|
103
|
+
await ds.initialize()
|
|
104
|
+
addDataSource('default', ds)
|
|
105
|
+
const d = await ds.getRepository(Domain).save({ name: 'resume-test', subdomain: 'resume-test' } as any)
|
|
106
|
+
domainId = d.id
|
|
107
|
+
await TwinEngine.ingestMaster(domainId, master(), 'reset')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
after(async () => {
|
|
111
|
+
await TwinEngine.stop(domainId, INSTANCE).catch(() => {})
|
|
112
|
+
await ds?.destroy()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('체크포인트가 **이어 접을 씨앗을 함께 적는다** — 이 줄이 없어서 넉 달 동안 처음부터 접었다', async () => {
|
|
116
|
+
const rt = await TwinEngine.bootDeclared(domainId, INSTANCE, 'resync')
|
|
117
|
+
assert.equal(rt.mode, 'live', '미러로 떠야 이 검사가 뜻이 있다')
|
|
118
|
+
|
|
119
|
+
const applied = TwinEngine.ingestLive(domainId, INSTANCE, facts(5))
|
|
120
|
+
assert.equal(applied, 10, '원본이 말한 사실이 커널에 들어갔다')
|
|
121
|
+
const before = TwinEngine.snapshot(domainId, INSTANCE)
|
|
122
|
+
assert.ok(observed(before) > 0, `상태에 사실이 있어야 이 검사가 뜻이 있다 (실제 ${observed(before)})`)
|
|
123
|
+
|
|
124
|
+
await TwinEngine.persistSnapshot(domainId, INSTANCE)
|
|
125
|
+
const stored = await TwinEngine.loadSnapshot(domainId, INSTANCE)
|
|
126
|
+
assert.ok(stored, '스냅샷이 저장됐다')
|
|
127
|
+
assert.ok(stored.fold?.reducer, '★ 재개점이 함께 적혀야 한다 — 없으면 다음 기동이 저널을 0부터 접는다')
|
|
128
|
+
assert.ok(stored.fold?.oee, '가동 누적기도 함께 — 상태만 되돌리면 가동률이 부팅 이후로만 잡힌다')
|
|
129
|
+
/* 씨앗이 실제로 사실을 들었는지 — 빈 껍데기를 적어 두고 초록이 되지 않게. */
|
|
130
|
+
assert.ok((stored.fold.reducer as any).orders?.length, '씨앗에 오더가 담겨 있다')
|
|
131
|
+
assert.ok((stored.fold.reducer as any).tasks?.length, '씨앗에 작업이 담겨 있다')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('★ 저널을 지워도 미러가 상태를 들고 다시 선다 — 그러므로 저널을 접은 것이 아니다', async () => {
|
|
135
|
+
/*
|
|
136
|
+
* 이 단정이 이 축의 전부다. 저널이 없는데 상태가 돌아오면 그 상태는 **저장된 재개점**에서 온 것이다.
|
|
137
|
+
* 그리고 이 상황은 가정이 아니다 — 보존 정책이 체크포인트 리비전 이하의 행을 실제로 지운다.
|
|
138
|
+
*/
|
|
139
|
+
const before = observed(TwinEngine.snapshot(domainId, INSTANCE))
|
|
140
|
+
assert.ok(before > 0, '멈추기 전에 상태가 있어야 이 검사가 뜻이 있다')
|
|
141
|
+
|
|
142
|
+
await TwinEngine.stop(domainId, INSTANCE)
|
|
143
|
+
/* 이 인스턴스의 저널만 — 정확한 소유 표식으로 지운다. */
|
|
144
|
+
await ds.getRepository(TwinEvent).delete({ domain: { id: domainId } as any, instanceId: INSTANCE } as any)
|
|
145
|
+
assert.equal(
|
|
146
|
+
await ds.getRepository(TwinEvent).countBy({ domain: { id: domainId } as any, instanceId: INSTANCE } as any),
|
|
147
|
+
0,
|
|
148
|
+
'저널이 비었다 — 이제 접을 것이 없다'
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
await TwinEngine.bootDeclared(domainId, INSTANCE, 'resync')
|
|
152
|
+
const after = TwinEngine.snapshot(domainId, INSTANCE)
|
|
153
|
+
assert.equal(observed(after), before, '★ 사실이 그대로 돌아왔다 — 저널 없이')
|
|
154
|
+
assert.equal(after.orders.length, 5, '오더 다섯이 그대로다')
|
|
155
|
+
assert.equal(after.tasks.length, 5, '작업 다섯이 그대로다')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('되세운 뒤 들어온 사실이 앞의 것을 덮지 않는다 — 더해진다', async () => {
|
|
159
|
+
/*
|
|
160
|
+
* 미러는 첫 봉투가 올 때 리듀서를 만든다. 되돌리기가 그보다 먼저 서지 않으면 첫 봉투가 **빈 리듀서**를
|
|
161
|
+
* 만들고 되세운 것을 덮는다 — 오류 없이 상태가 사라진다.
|
|
162
|
+
*/
|
|
163
|
+
const before = observed(TwinEngine.snapshot(domainId, INSTANCE))
|
|
164
|
+
TwinEngine.ingestLive(domainId, INSTANCE, [
|
|
165
|
+
{
|
|
166
|
+
eventId: 'o-new',
|
|
167
|
+
eventType: 'order.status',
|
|
168
|
+
eventTime: '2026-08-24T02:00:00.000Z',
|
|
169
|
+
tenantId: domainId,
|
|
170
|
+
data: { orderId: 'WO-NEW', kind: 'workorder', status: 'in-progress', requested: 1, fulfilled: 0 }
|
|
171
|
+
} as any
|
|
172
|
+
])
|
|
173
|
+
const after = TwinEngine.snapshot(domainId, INSTANCE)
|
|
174
|
+
assert.equal(observed(after), before + 1, '앞의 것이 남고 새 것이 더해진다')
|
|
175
|
+
assert.ok(after.orders.some((o: any) => o.id === 'WO-1'), '되세운 오더가 남아 있다')
|
|
176
|
+
assert.ok(after.orders.some((o: any) => o.id === 'WO-NEW'), '새 오더도 있다')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('씨앗이 없으면 지어내지 않는다 — 전과 같이 빈 채로 시작한다', async () => {
|
|
180
|
+
/*
|
|
181
|
+
* 처음 뜨는 트윈에는 씨앗이 없다. 여기서 무엇이든 심으면 없던 재고가 생긴다 — 그것은 이 작업이
|
|
182
|
+
* 고치려던 것보다 나쁘다.
|
|
183
|
+
*/
|
|
184
|
+
const FRESH = 'resume-probe-fresh'
|
|
185
|
+
await TwinEngine.ingestMaster(domainId, { ...master(), source: FRESH }, 'reset')
|
|
186
|
+
try {
|
|
187
|
+
await TwinEngine.bootDeclared(domainId, FRESH, 'resync')
|
|
188
|
+
assert.equal(observed(TwinEngine.snapshot(domainId, FRESH)), 0, '아무것도 듣지 못했으므로 비어 있다')
|
|
189
|
+
} finally {
|
|
190
|
+
await TwinEngine.stop(domainId, FRESH).catch(() => {})
|
|
191
|
+
}
|
|
192
|
+
})
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ★ 「원본이 말한 적 없는 것」을 세는 규율.
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 이것이 필요한가 (2026-08-20 실측) ────────────────────────────────────
|
|
5
|
+
* 동기화 건강 기능을 만들 때 제가 전제를 이렇게 적었다: *「커넥터의 매핑이 한 글자 틀리면 트윈은
|
|
6
|
+
* 빈 공장이 된다」*. 사용자가 그 전제를 검증하게 해서 재 봤더니, 유입 문은 참조 오류를 **거부하지
|
|
7
|
+
* 않았다**:
|
|
8
|
+
*
|
|
9
|
+
* 모르는 로케이션 「오타-99」 → 통과
|
|
10
|
+
* 모르는 bizStep 「PUTAWAY_X17」 → 통과
|
|
11
|
+
* 모르는 계량기 「MMXU-오타-99」 → 통과
|
|
12
|
+
*
|
|
13
|
+
* 즉 그 기능이 잡겠다고 내세운 대표 사례가 `mapping-blocked` 로는 **뜰 수 없었다.** 거부되는 것은
|
|
14
|
+
* 형태 오류뿐이다(시각 형식·숫자 아닌 값·epc 없음).
|
|
15
|
+
*
|
|
16
|
+
* 거부하지 않는 것이 옳다 — 원천이 모델보다 먼저 새 로케이션을 말하는 것은 정상이고, 거부하면 그 선행
|
|
17
|
+
* 계측을 잃는다. 커널은 흡수하고 **표시한다**(`origin: 'observed'`). 빠져 있던 것은 그 표시를
|
|
18
|
+
* 「맞춰지고 있나」를 보는 사람에게 잇는 일이었다.
|
|
19
|
+
*
|
|
20
|
+
* ── 지키는 것 ───────────────────────────────────────────────────────────────
|
|
21
|
+
* ① 마스터가 말한 것과 관측으로 알게 된 것을 섞지 않는다
|
|
22
|
+
* ② 트윈이 안 돌면 `null` — 「없다」와 「세어 보지 못했다」는 다르다
|
|
23
|
+
* ③ 조용히 자르지 않는다 — 상한에서 밀려난 수를 함께 낸다
|
|
24
|
+
* ④ 덮지 못하는 축을 밝힌다 — 계량기는 커널이 표시를 달지 않아 이 화면이 못 찾아 준다
|
|
25
|
+
*/
|
|
26
|
+
import { test } from 'node:test'
|
|
27
|
+
import assert from 'node:assert/strict'
|
|
28
|
+
|
|
29
|
+
import { GAP_ID_LIMIT, modelGapOf } from '../server/engine/model-gap.ts'
|
|
30
|
+
|
|
31
|
+
const loc = (id: string, origin: string) => ({ id, origin })
|
|
32
|
+
|
|
33
|
+
test('마스터가 말한 로케이션은 갭이 아니다 — 선언된 것을 문제로 세지 않는다', () => {
|
|
34
|
+
const g = modelGapOf({
|
|
35
|
+
locations: [loc('dock-recv', 'master'), loc('bin-1', 'master')],
|
|
36
|
+
equipment: [loc('fk-1', 'master')]
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
assert.equal(g!.total, 0)
|
|
40
|
+
assert.deepEqual(g!.locations.ids, [])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('관측으로만 알게 된 것을 축마다 센다 — 원천이 우리가 모르는 id 를 보내고 있다', () => {
|
|
44
|
+
const g = modelGapOf({
|
|
45
|
+
locations: [loc('dock-recv', 'master'), loc('오타-99', 'observed'), loc('A-77', 'observed')],
|
|
46
|
+
equipment: [loc('fk-1', 'master'), loc('없는-지게차', 'observed')],
|
|
47
|
+
persons: [loc('unknown-op', 'observed')]
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
assert.equal(g!.locations.count, 2)
|
|
51
|
+
assert.equal(g!.locations.unknown, 0, '표시가 다 있으면 모름은 0')
|
|
52
|
+
assert.deepEqual(g!.locations.ids, ['오타-99', 'A-77'], 'id 를 보여 준다 — 오타는 눈으로 찾는다')
|
|
53
|
+
assert.equal(g!.equipment.count, 1)
|
|
54
|
+
assert.equal(g!.persons.count, 1)
|
|
55
|
+
assert.equal(g!.total, 4, '세 축의 합')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('트윈이 안 돌면 「없음」이다 — 0 이 아니다', () => {
|
|
59
|
+
/* 비교할 상대가 없다. 0 으로 답하면 「원천이 우리가 아는 것만 보낸다」는 없는 사실이 생긴다. */
|
|
60
|
+
assert.equal(modelGapOf(null), null)
|
|
61
|
+
assert.equal(modelGapOf(undefined), null)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('돌지만 관측 갭이 없으면 0 이다 — 이건 「없음」과 다르다', () => {
|
|
65
|
+
const g = modelGapOf({ locations: [] })
|
|
66
|
+
|
|
67
|
+
assert.notEqual(g, null, '세어 봤다')
|
|
68
|
+
assert.equal(g!.total, 0, '세어 보니 없었다')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('id 를 조용히 자르지 않는다 — 밀려난 수를 함께 낸다', () => {
|
|
72
|
+
const many = Array.from({ length: GAP_ID_LIMIT + 7 }, (_, i) => loc(`ghost-${i}`, 'observed'))
|
|
73
|
+
const g = modelGapOf({ locations: many })
|
|
74
|
+
|
|
75
|
+
assert.equal(g!.locations.count, GAP_ID_LIMIT + 7, '전부 센다')
|
|
76
|
+
assert.equal(g!.locations.ids.length, GAP_ID_LIMIT, '보이는 것은 상한까지')
|
|
77
|
+
assert.equal(g!.locations.more, 7, '밀려난 수를 말한다')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('커널이 담을 줄 모른 사건을 함께 낸다 — 참조 문제와 다른 사실이다', () => {
|
|
81
|
+
const at = Date.parse('2026-08-20T01:02:03.000Z')
|
|
82
|
+
const g = modelGapOf({
|
|
83
|
+
unhandled: [
|
|
84
|
+
{ eventType: 'epcis.TransformationEvent', count: 12, lastAtMs: at },
|
|
85
|
+
{ eventType: 'aggregation-mixed-classes', count: 3 }
|
|
86
|
+
]
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
assert.equal(g!.unhandled.length, 2)
|
|
90
|
+
assert.equal(g!.unhandled[0].lastAt, '2026-08-20T01:02:03.000Z', '언제까지 났는지 — 멈춘 종류는 고쳐진 것이다')
|
|
91
|
+
assert.equal(g!.unhandled[1].lastAt, null, '시각이 없으면 지어내지 않는다')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('모르는 설비의 에너지 보고는 「없음」과 0 을 가른다 — 경로가 `energy` 안이다', () => {
|
|
95
|
+
/*
|
|
96
|
+
* 이 값을 **루트에서 읽고 있었다**(2026-08-20에 잡음). 커널은 `EnergyState` 안에 담으므로 루트에서
|
|
97
|
+
* 읽으면 영원히 `null` 이고, 오류 없이 「에너지 트윈이 아니다」로 읽힌다. 나만 읽는 필드를 잘못된
|
|
98
|
+
* 경로에서 읽으면 이렇게 조용히 사라진다.
|
|
99
|
+
*
|
|
100
|
+
* 그리고 커널은 0 이면 그 칸을 만들지 않는다. 그래서 `energy` 자체가 있는지로 「세었고 0」과
|
|
101
|
+
* 「에너지 트윈이 아님」을 가른다.
|
|
102
|
+
*/
|
|
103
|
+
assert.equal(modelGapOf({})!.unknownEquipmentReports, null, '에너지 트윈이 아니면 이 수가 없다')
|
|
104
|
+
assert.equal(modelGapOf({ energy: { points: [] } })!.unknownEquipmentReports, 0, '에너지 트윈이면 세었고 0 이다')
|
|
105
|
+
assert.equal(modelGapOf({ energy: { unknownEquipment: 41 } })!.unknownEquipmentReports, 41)
|
|
106
|
+
assert.equal(
|
|
107
|
+
modelGapOf({ unknownEquipment: 41 } as any)!.unknownEquipmentReports,
|
|
108
|
+
null,
|
|
109
|
+
'루트에 있는 값을 집지 않는다 — 그 경로는 커널의 것이 아니다'
|
|
110
|
+
)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('계량기도 덮는다 — 이 축이 가장 무겁다', () => {
|
|
114
|
+
/*
|
|
115
|
+
* 모르는 `meterId` 는 조용히 새 계량기가 되고 그 kW 가 그 현장의 수요·피크·요금 판정에 더해진다.
|
|
116
|
+
* 로케이션 오타는 스키매틱이 비는 것으로 드러나지만, 이건 **그럴듯한 숫자**로 나온다.
|
|
117
|
+
* 커널 0.7.39 가 `MeterPointState.origin` 을 내면서 덮을 수 있게 됐다.
|
|
118
|
+
*/
|
|
119
|
+
const g = modelGapOf({
|
|
120
|
+
energy: { points: [loc('main-incoming', 'master'), loc('MMXU-오타-99', 'observed')] }
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
assert.equal(g!.meters.count, 1)
|
|
124
|
+
assert.deepEqual(g!.meters.ids, ['MMXU-오타-99'])
|
|
125
|
+
assert.equal(g!.total, 1, '계량기도 합에 든다')
|
|
126
|
+
assert.deepEqual(g!.notCovered, [], '덮지 못하는 축이 없다')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('표시가 없는 항목은 「선언됨」이 아니라 「모름」으로 센다', () => {
|
|
130
|
+
/*
|
|
131
|
+
* 계량기의 `origin` 은 늦게 생긴 칸이라, 옛 스냅샷에서 이어받은 지점에는 없다(`undefined`).
|
|
132
|
+
* 마스터로 세면 오타를 놓치고, 관측으로 세면 정상을 문제로 만든다. 둘 다 틀리다.
|
|
133
|
+
*/
|
|
134
|
+
const g = modelGapOf({
|
|
135
|
+
energy: { points: [{ id: 'carried-over' }, loc('main', 'master'), loc('ghost', 'observed')] }
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
assert.equal(g!.meters.unknown, 1, '표시 없는 것은 모름')
|
|
139
|
+
assert.equal(g!.meters.count, 1, '관측 갭에 섞이지 않는다')
|
|
140
|
+
assert.ok(!g!.meters.ids.includes('carried-over'), '모르는 것을 오타로 지목하지 않는다')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('`origin` 은 「지금 모델에 있는가」의 답이다 — 채택되면 갭에서 빠진다', () => {
|
|
144
|
+
/*
|
|
145
|
+
* 커널이 표본마다 다시 맞춘다. 한 번 찍고 두면 나중에 선언된 계량기가 영원히 `observed` 로 남는다.
|
|
146
|
+
* 그래서 채택 뒤 스냅샷에서는 같은 id 가 `master` 로 오고, 이 도출은 그것을 그대로 따른다.
|
|
147
|
+
*/
|
|
148
|
+
const before = modelGapOf({ energy: { points: [loc('new-meter', 'observed')] } })
|
|
149
|
+
const after = modelGapOf({ energy: { points: [loc('new-meter', 'master')] } })
|
|
150
|
+
|
|
151
|
+
assert.equal(before!.meters.count, 1)
|
|
152
|
+
assert.equal(after!.meters.count, 0, '모델에 들어오면 갭이 아니다')
|
|
153
|
+
})
|
|
@@ -18,6 +18,10 @@ const TICK = 1000
|
|
|
18
18
|
function runSimCollect() {
|
|
19
19
|
const m = {
|
|
20
20
|
source: 'oee', system: 'wms' as const, siteName: 'O',
|
|
21
|
+
/* 이 픽스처가 식별자를 만드는 이름공간 — **픽스처마다 다르게** 둔다(같은 접두 아래에서 서로 다른
|
|
22
|
+
시험의 번호가 부딪치면 표준의 유일성 요구를 깬다, CBV 2.0 §8.2.4). 커널은 선언 없이 팔레트·문서를
|
|
23
|
+
만들지 않는다(0.7.45 에서 날조 프리픽스를 지웠다). */
|
|
24
|
+
identity: { namespaces: ['https://twin.test/oee-accumulator'] },
|
|
21
25
|
space: { width: 90, depth: 40, unit: 'm' },
|
|
22
26
|
locations: [
|
|
23
27
|
{ id: 'a', type: 'warehouse', role: 'area' as const },
|
|
@@ -37,7 +37,7 @@ test('운영 사실이 자기 봉투로 나온다 — EPCIS 로 감싸지지 않
|
|
|
37
37
|
test('네 어휘가 한 배치에서 갈린다 — 실 피드는 섞여 온다', () => {
|
|
38
38
|
const { accepted, rejected } = ingestCanonicalRecords(
|
|
39
39
|
[
|
|
40
|
-
{ epc: 'urn:epc:id:sgtin:
|
|
40
|
+
{ epc: 'urn:epc:id:sgtin:9521321.012345.1', action: 'ADD', bizStep: 'urn:epcglobal:cbv:bizstep:receiving', readPoint: 'dock-1' },
|
|
41
41
|
{ meterId: 'm-1', kW: 120 },
|
|
42
42
|
{ equipmentId: 'pv-1', generatedKW: 40 },
|
|
43
43
|
{ moverId: 'fk-1', kind: 'forklift', status: 'busy' }, // vocabulary-guard: allow 저널 와이어 필드
|
|
@@ -80,8 +80,8 @@ test('피드 결선이 거부를 조용히 버리지 않는다 — 이유를 사
|
|
|
80
80
|
test('담김 레코드가 자기 봉투로 나온다 — 개체 관측과 한 배치에 섞여 와도 갈린다', () => {
|
|
81
81
|
const { accepted, rejected } = ingestCanonicalRecords(
|
|
82
82
|
[
|
|
83
|
-
{ epc: 'urn:epc:id:sgtin:
|
|
84
|
-
{ parentID: 'urn:epc:id:sscc:0614141.0000000001', childEPCs: ['urn:epc:id:sgtin:
|
|
83
|
+
{ epc: 'urn:epc:id:sgtin:9521321.012345.1', action: 'ADD', bizStep: 'urn:epcglobal:cbv:bizstep:receiving', readPoint: 'dock-1' },
|
|
84
|
+
{ parentID: 'urn:epc:id:sscc:0614141.0000000001', childEPCs: ['urn:epc:id:sgtin:9521321.012345.1'], action: 'ADD', bizStep: 'urn:epcglobal:cbv:bizstep:packing', readPoint: 'packing' }
|
|
85
85
|
] as any,
|
|
86
86
|
'domain-1',
|
|
87
87
|
AT
|
|
@@ -93,7 +93,7 @@ test('담김 레코드가 자기 봉투로 나온다 — 개체 관측과 한
|
|
|
93
93
|
|
|
94
94
|
test('자리를 구조째 실으면 거부한다 — 예전에는 "[object Object]" 로 굳어 저널에 쌓였다', () => {
|
|
95
95
|
const { accepted, rejected } = ingestCanonicalRecords(
|
|
96
|
-
[{ epc: 'urn:epc:id:sgtin:
|
|
96
|
+
[{ epc: 'urn:epc:id:sgtin:9521321.012345.1', action: 'ADD', bizStep: 'urn:epcglobal:cbv:bizstep:receiving', readPoint: { id: 'dock-1' } }] as any,
|
|
97
97
|
'domain-1',
|
|
98
98
|
AT
|
|
99
99
|
)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ★ 「원본에 닿지 못한다」와 「원본이 조용하다」는 **다른 사실**이다 — 상태가 그것을 가려야 한다.
|
|
3
|
+
*
|
|
4
|
+
* ── 무엇이 조용했나 (2026-08-23 실측) ───────────────────────────────────────
|
|
5
|
+
* 실 원본이 끊겼다(`jejoup.hatiolab.com:4000` 접속 시간 초과, HTTP 000 / 8초). 그런데 트윈의
|
|
6
|
+
* **조회 가능한 상태 어디에도 그 사실이 없었다.**
|
|
7
|
+
*
|
|
8
|
+
* recordIngest `onRecords` 가 불릴 때만 도달한다 — 읽기 실패는 그 문에 오지 않는다
|
|
9
|
+
* TwinReference.lastError 마스터 동기·접속 시험에만 적힌다 — 라이브 피드는 적지 않았다
|
|
10
|
+
* 계약 `openLiveFeed` 에 「읽지 못했다」를 말할 통로가 없었다
|
|
11
|
+
*
|
|
12
|
+
* 어댑터는 **로그로는 말하고 있었다**(재시도 경고). 그러나 로그는 사람이 볼 때만 값이 있다 — 화면이
|
|
13
|
+
* 말하려면 상태에 있어야 한다. 실증 데모 중에 원본이 끊기면 사용자가 원인을 찾을 수 없다.
|
|
14
|
+
*
|
|
15
|
+
* ── 이 시험이 지키는 것 ─────────────────────────────────────────────────────
|
|
16
|
+
* ① 실패가 상태에 남는다 ② 「몇 번」과 「언제부터」를 함께 든다(한 번의 흔들림과 20분째 단절은 다르다)
|
|
17
|
+
* ③ **빈 읽기는 성공이다** — 조용한 원본을 끊긴 원본으로 만들지 않는다
|
|
18
|
+
* ④ 조회 뷰로 나간다 — 상태에만 있고 안 나가면 화면이 못 본다
|
|
19
|
+
*/
|
|
20
|
+
import { test } from 'node:test'
|
|
21
|
+
import assert from 'node:assert/strict'
|
|
22
|
+
import { readFileSync } from 'node:fs'
|
|
23
|
+
import { join } from 'node:path'
|
|
24
|
+
import {
|
|
25
|
+
clearReadFailure,
|
|
26
|
+
ingestHealth,
|
|
27
|
+
newIngestLedger,
|
|
28
|
+
recordIngest,
|
|
29
|
+
recordReadFailure
|
|
30
|
+
} from '../server/engine/ingest-health.ts'
|
|
31
|
+
|
|
32
|
+
const T0 = Date.parse('2026-08-23T06:00:00.000Z')
|
|
33
|
+
|
|
34
|
+
test('실패가 상태에 남는다 — 사유와 흐름까지', () => {
|
|
35
|
+
const l = newIngestLedger()
|
|
36
|
+
assert.equal(l.readFailure, undefined, '처음에는 없다(없음이 「닿는다」는 뜻이다)')
|
|
37
|
+
recordReadFailure(l, 'connect ETIMEDOUT', T0, 'lot')
|
|
38
|
+
assert.equal(l.readFailure?.consecutive, 1)
|
|
39
|
+
assert.equal(l.readFailure?.reason, 'connect ETIMEDOUT')
|
|
40
|
+
assert.equal(l.readFailure?.stream, 'lot', '어느 표가 막혔나가 원인 찾기의 절반이다')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('한 번의 흔들림과 20분째 단절을 가른다 — 시작 시각을 지킨다', () => {
|
|
44
|
+
/*
|
|
45
|
+
* 매번 새로 적으면 화면이 늘 「방금 실패했다」만 보게 되고, 계속된 단절을 대수롭지 않게 말한다.
|
|
46
|
+
*/
|
|
47
|
+
const l = newIngestLedger()
|
|
48
|
+
recordReadFailure(l, 'ETIMEDOUT', T0)
|
|
49
|
+
recordReadFailure(l, 'ETIMEDOUT', T0 + 600_000)
|
|
50
|
+
recordReadFailure(l, 'ETIMEDOUT', T0 + 1_200_000)
|
|
51
|
+
assert.equal(l.readFailure?.consecutive, 3)
|
|
52
|
+
assert.equal(l.readFailure?.sinceMs, T0, '처음 실패한 시각을 지킨다')
|
|
53
|
+
assert.equal(l.readFailure?.lastAtMs, T0 + 1_200_000, '마지막 실패는 갱신된다')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('빈 읽기는 성공이다 — 조용한 원본을 끊긴 원본으로 만들지 않는다', () => {
|
|
57
|
+
/*
|
|
58
|
+
* ★ 이 단정이 고치려던 것의 **반대 방향으로 틀리는 것**을 막는다. 원본이 「새 것이 없다」고 답한 것은
|
|
59
|
+
* 닿았다는 뜻이다.
|
|
60
|
+
*/
|
|
61
|
+
const l = newIngestLedger()
|
|
62
|
+
recordReadFailure(l, 'ETIMEDOUT', T0)
|
|
63
|
+
clearReadFailure(l)
|
|
64
|
+
assert.equal(l.readFailure, undefined, '읽었으면 단절 기록이 사라진다')
|
|
65
|
+
/* 그리고 그 뒤 다시 실패하면 **새 단절**이다 — 옛 시작 시각을 물려받지 않는다. */
|
|
66
|
+
recordReadFailure(l, 'ETIMEDOUT', T0 + 3_600_000)
|
|
67
|
+
assert.equal(l.readFailure?.consecutive, 1, '연속이 끊겼으므로 1 부터')
|
|
68
|
+
assert.equal(l.readFailure?.sinceMs, T0 + 3_600_000, '새 단절의 시작이다')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('조회 뷰로 나간다 — 상태에만 있으면 화면이 못 본다', () => {
|
|
72
|
+
const l = newIngestLedger()
|
|
73
|
+
recordReadFailure(l, 'connect ETIMEDOUT', T0, 'inventory')
|
|
74
|
+
const v = ingestHealth(l, 'attached', undefined, T0 + 60_000)
|
|
75
|
+
assert.ok(v.readFailure, '뷰에 실려야 한다')
|
|
76
|
+
assert.equal(v.readFailure?.consecutive, 1)
|
|
77
|
+
assert.equal(v.readFailure?.since, new Date(T0).toISOString())
|
|
78
|
+
assert.equal(v.readFailure?.reason, 'connect ETIMEDOUT')
|
|
79
|
+
assert.equal(v.readFailure?.stream, 'inventory')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('닿고 있으면 그 필드를 만들지 않는다 — 빈 값이 「닿는다」로 읽히지 않게', () => {
|
|
83
|
+
const l = newIngestLedger()
|
|
84
|
+
recordIngest(l, 3, [], T0)
|
|
85
|
+
const v = ingestHealth(l, 'attached', undefined, T0 + 1000)
|
|
86
|
+
assert.equal(v.readFailure, undefined)
|
|
87
|
+
/* 가드가 스스로를 증명한다 — 이 장부가 실제로 무엇을 받았다. */
|
|
88
|
+
assert.equal(v.total.offered, 3, '빈 비교로 통과하지 않는다')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('「유입 없음」과 「닿지 못함」은 다른 판정이다', () => {
|
|
92
|
+
/*
|
|
93
|
+
* 유입 없음은 원본이 조용한 것일 수 있고, 닿지 못함은 우리가 묻지도 못한 것이다. 두 상태가 같은
|
|
94
|
+
* 값으로 뭉개지면 화면이 두 문장을 다르게 말할 수 없다.
|
|
95
|
+
*/
|
|
96
|
+
const quiet = newIngestLedger()
|
|
97
|
+
const dark = newIngestLedger()
|
|
98
|
+
recordReadFailure(dark, 'ETIMEDOUT', T0)
|
|
99
|
+
|
|
100
|
+
const vq = ingestHealth(quiet, 'attached', undefined, T0)
|
|
101
|
+
const vd = ingestHealth(dark, 'attached', undefined, T0)
|
|
102
|
+
assert.equal(vq.lastAt, null, '조용한 쪽도 받은 것이 없다')
|
|
103
|
+
assert.equal(vd.lastAt, null, '끊긴 쪽도 받은 것이 없다 — 그 값만으로는 갈리지 않는다')
|
|
104
|
+
assert.notEqual(Boolean(vq.readFailure), Boolean(vd.readFailure), '이 축이 그 둘을 가른다')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
/*
|
|
108
|
+
* ── 읽기 실패가 **피드를 끄면 안 된다** (2026-08-23, 되돌린 자리) ────────────
|
|
109
|
+
*
|
|
110
|
+
* 처음 구현에서 읽기 실패가 레퍼런스 행의 `status` 를 `'error'` 로 적었다. 그것이 **피드를 영구히
|
|
111
|
+
* 껐다**: 부팅 재부착은 `status: 'connected'` 인 레퍼런스만 되살리므로, 원본이 **잠깐** 끊긴 것이
|
|
112
|
+
* 다음 재기동에서 그 트윈을 아예 굶겼다. 실측으로 그렇게 났다 — 그 판의 로그에 `[twin-live]` 줄이
|
|
113
|
+
* 한 줄도 없었다.
|
|
114
|
+
*
|
|
115
|
+
* 두 축을 한 칸에 섞은 것이 원인이다.
|
|
116
|
+
*
|
|
117
|
+
* status **연결 설정이 성립하나** — 접속 시험·마스터 동기가 정한다. 재부착의 **자격**이다
|
|
118
|
+
* 읽기 실패 **지금 닿나** — 오갈 수 있는 사실이고 유입 장부가 그 축을 갖는다
|
|
119
|
+
*
|
|
120
|
+
* 「사실을 말하게」 하려던 것이 「사실을 못 듣게」 만들었다. 그 방향의 실패는 조용하다 — 화면은
|
|
121
|
+
* 「원본에 닿지 못한다」를 정직하게 말하면서, 정작 다시 닿을 기회를 잃는다.
|
|
122
|
+
*/
|
|
123
|
+
test('읽기 실패가 레퍼런스의 status 를 건드리지 않는다 — 그것이 재부착의 자격이다', () => {
|
|
124
|
+
const src = readFileSync(join(import.meta.dirname, '..', 'server', 'service', 'reference', 'reference-live.ts'), 'utf8')
|
|
125
|
+
const code = src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/.*$/gm, '$1')
|
|
126
|
+
|
|
127
|
+
const at = code.indexOf('onReadFailure:')
|
|
128
|
+
assert.ok(at > 0, 'onReadFailure 배선이 있어야 한다')
|
|
129
|
+
const body = code.slice(at, at + 600)
|
|
130
|
+
|
|
131
|
+
assert.doesNotMatch(body, /status:/, '읽기 실패는 status 를 적지 않는다 — 적으면 다음 부팅이 그 피드를 되살리지 않는다')
|
|
132
|
+
/* 대신 이유는 남긴다 — 사람이 목록에서 볼 수 있게. */
|
|
133
|
+
assert.match(body, /lastError/, '이유는 레퍼런스 행에 남긴다')
|
|
134
|
+
/* 그리고 「지금 닿나」는 장부가 답한다. */
|
|
135
|
+
assert.match(body, /recordIngestReadFailure/, '유입 장부가 그 축을 든다')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('재부착의 자격은 connected 뿐이다 — 그래서 그 값을 함부로 바꾸면 안 된다', () => {
|
|
139
|
+
/*
|
|
140
|
+
* 이 단정이 위 가드의 **이유**를 못박는다. 재부착 조건이 넓어지면 위 규율의 근거가 약해지므로,
|
|
141
|
+
* 둘이 함께 움직여야 한다.
|
|
142
|
+
*/
|
|
143
|
+
const src = readFileSync(join(import.meta.dirname, '..', 'server', 'service', 'reference', 'reference-live.ts'), 'utf8')
|
|
144
|
+
assert.match(src, /find\(\{ where: \{ status: 'connected' \}/, '부팅 재부착은 connected 만 되살린다')
|
|
145
|
+
})
|