@things-factory/headless-twin 10.0.6 → 10.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/README.md +37 -24
  2. package/dist-server/engine/entity-delta.d.ts +13 -1
  3. package/dist-server/engine/entity-delta.js +130 -16
  4. package/dist-server/engine/entity-delta.js.map +1 -1
  5. package/dist-server/engine/index.d.ts +4 -0
  6. package/dist-server/engine/index.js +4 -0
  7. package/dist-server/engine/index.js.map +1 -1
  8. package/dist-server/engine/kpi-fold.d.ts +20 -3
  9. package/dist-server/engine/kpi-fold.js +21 -4
  10. package/dist-server/engine/kpi-fold.js.map +1 -1
  11. package/dist-server/engine/kpi-query.d.ts +9 -0
  12. package/dist-server/engine/kpi-query.js +118 -19
  13. package/dist-server/engine/kpi-query.js.map +1 -1
  14. package/dist-server/engine/kpi-target.d.ts +141 -0
  15. package/dist-server/engine/kpi-target.js +137 -0
  16. package/dist-server/engine/kpi-target.js.map +1 -0
  17. package/dist-server/engine/live-attentions.d.ts +4 -2
  18. package/dist-server/engine/live-attentions.js +5 -1
  19. package/dist-server/engine/live-attentions.js.map +1 -1
  20. package/dist-server/engine/measured-estimator.d.ts +50 -0
  21. package/dist-server/engine/measured-estimator.js +78 -0
  22. package/dist-server/engine/measured-estimator.js.map +1 -0
  23. package/dist-server/engine/model-basis.d.ts +23 -0
  24. package/dist-server/engine/model-basis.js +100 -0
  25. package/dist-server/engine/model-basis.js.map +1 -0
  26. package/dist-server/engine/oee-accumulator.d.ts +2 -2
  27. package/dist-server/engine/oee-accumulator.js +4 -4
  28. package/dist-server/engine/oee-accumulator.js.map +1 -1
  29. package/dist-server/engine/spec-coverage.d.ts +49 -0
  30. package/dist-server/engine/spec-coverage.js +60 -0
  31. package/dist-server/engine/spec-coverage.js.map +1 -0
  32. package/dist-server/engine/structure-diff.d.ts +25 -0
  33. package/dist-server/engine/structure-diff.js +63 -0
  34. package/dist-server/engine/structure-diff.js.map +1 -0
  35. package/dist-server/engine/travel-estimator.d.ts +57 -0
  36. package/dist-server/engine/travel-estimator.js +120 -0
  37. package/dist-server/engine/travel-estimator.js.map +1 -0
  38. package/dist-server/engine/twin-engine.d.ts +153 -17
  39. package/dist-server/engine/twin-engine.js +473 -81
  40. package/dist-server/engine/twin-engine.js.map +1 -1
  41. package/dist-server/engine/warm-start.d.ts +5 -5
  42. package/dist-server/engine/warm-start.js +4 -4
  43. package/dist-server/engine/warm-start.js.map +1 -1
  44. package/dist-server/service/index.d.ts +4 -2
  45. package/dist-server/service/index.js +21 -14
  46. package/dist-server/service/index.js.map +1 -1
  47. package/dist-server/service/reference/reference-live.js +2 -1
  48. package/dist-server/service/reference/reference-live.js.map +1 -1
  49. package/dist-server/service/reference/reference-master.d.ts +307 -1
  50. package/dist-server/service/reference/reference-master.js +96 -8
  51. package/dist-server/service/reference/reference-master.js.map +1 -1
  52. package/dist-server/service/reference/reference-resolver.js +3 -3
  53. package/dist-server/service/reference/reference-resolver.js.map +1 -1
  54. package/dist-server/service/reference/template-registry.d.ts +1 -1
  55. package/dist-server/service/reference/template-registry.js.map +1 -1
  56. package/dist-server/service/twin-attention/twin-attention-query.js +1 -1
  57. package/dist-server/service/twin-attention/twin-attention-query.js.map +1 -1
  58. package/dist-server/service/twin-control/twin-control-mutation.js +1 -1
  59. package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
  60. package/dist-server/service/twin-event/twin-event-keys.d.ts +1 -1
  61. package/dist-server/service/twin-event/twin-event-keys.js +3 -3
  62. package/dist-server/service/twin-event/twin-event-keys.js.map +1 -1
  63. package/dist-server/service/twin-event/twin-event.d.ts +11 -0
  64. package/dist-server/service/twin-event/twin-event.js +6 -1
  65. package/dist-server/service/twin-event/twin-event.js.map +1 -1
  66. package/dist-server/service/twin-forecast/twin-forecast-query.js +50 -7
  67. package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
  68. package/dist-server/service/twin-instance/twin-instance.js +1 -1
  69. package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
  70. package/dist-server/service/twin-journal/twin-journal-query.d.ts +9 -0
  71. package/dist-server/service/twin-journal/twin-journal-query.js +54 -3
  72. package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
  73. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.d.ts +1 -1
  74. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +4 -3
  75. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
  76. package/dist-server/service/twin-space/twin-space-area.js +1 -1
  77. package/dist-server/service/twin-space/twin-space-area.js.map +1 -1
  78. package/dist-server/service/twin-space/twin-space-resolver.js +3 -3
  79. package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
  80. package/dist-server/service/twin-space/twin-space.d.ts +11 -0
  81. package/dist-server/service/twin-space/twin-space.js +5 -0
  82. package/dist-server/service/twin-space/twin-space.js.map +1 -1
  83. package/dist-server/service/twin-structure/index.d.ts +2 -0
  84. package/dist-server/service/twin-structure/index.js +6 -0
  85. package/dist-server/service/twin-structure/index.js.map +1 -0
  86. package/dist-server/service/twin-structure/twin-structure.d.ts +28 -0
  87. package/dist-server/service/twin-structure/twin-structure.js +97 -0
  88. package/dist-server/service/twin-structure/twin-structure.js.map +1 -0
  89. package/dist-server/service/twin-target/index.d.ts +4 -0
  90. package/dist-server/service/twin-target/index.js +8 -0
  91. package/dist-server/service/twin-target/index.js.map +1 -0
  92. package/dist-server/service/twin-target/twin-target-resolver.d.ts +7 -0
  93. package/dist-server/service/twin-target/twin-target-resolver.js +143 -0
  94. package/dist-server/service/twin-target/twin-target-resolver.js.map +1 -0
  95. package/dist-server/service/twin-target/twin-target.d.ts +25 -0
  96. package/dist-server/service/twin-target/twin-target.js +112 -0
  97. package/dist-server/service/twin-target/twin-target.js.map +1 -0
  98. package/dist-server/tsconfig.tsbuildinfo +1 -1
  99. package/package.json +6 -6
  100. package/server/engine/entity-delta.ts +135 -15
  101. package/server/engine/index.ts +4 -0
  102. package/server/engine/kpi-fold.ts +40 -8
  103. package/server/engine/kpi-query.ts +129 -19
  104. package/server/engine/kpi-target.ts +226 -0
  105. package/server/engine/live-attentions.ts +12 -2
  106. package/server/engine/measured-estimator.ts +91 -0
  107. package/server/engine/model-basis.ts +94 -0
  108. package/server/engine/oee-accumulator.ts +5 -5
  109. package/server/engine/spec-coverage.ts +85 -0
  110. package/server/engine/structure-diff.ts +88 -0
  111. package/server/engine/travel-estimator.ts +133 -0
  112. package/server/engine/twin-engine.ts +498 -84
  113. package/server/engine/warm-start.ts +8 -8
  114. package/server/service/index.ts +7 -0
  115. package/server/service/reference/reference-live.ts +2 -1
  116. package/server/service/reference/reference-master.ts +383 -10
  117. package/server/service/reference/reference-resolver.ts +3 -3
  118. package/server/service/reference/template-registry.ts +1 -1
  119. package/server/service/twin-attention/twin-attention-query.ts +1 -1
  120. package/server/service/twin-control/twin-control-mutation.ts +1 -1
  121. package/server/service/twin-event/twin-event-keys.ts +2 -2
  122. package/server/service/twin-event/twin-event.ts +15 -1
  123. package/server/service/twin-forecast/twin-forecast-query.ts +56 -8
  124. package/server/service/twin-instance/twin-instance.ts +1 -1
  125. package/server/service/twin-journal/twin-journal-query.ts +52 -4
  126. package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +4 -3
  127. package/server/service/twin-space/twin-space-area.ts +1 -1
  128. package/server/service/twin-space/twin-space-resolver.ts +3 -3
  129. package/server/service/twin-space/twin-space.ts +14 -0
  130. package/server/service/twin-structure/index.ts +3 -0
  131. package/server/service/twin-structure/twin-structure.ts +93 -0
  132. package/server/service/twin-target/index.ts +5 -0
  133. package/server/service/twin-target/twin-target-resolver.ts +124 -0
  134. package/server/service/twin-target/twin-target.ts +97 -0
  135. package/test/capability-mapping.test.ts +5 -5
  136. package/test/duration-estimators.test.ts +144 -0
  137. package/test/entity-delta.test.ts +150 -19
  138. package/test/ingest-bench.test.ts +9 -9
  139. package/test/kpi-fold.test.ts +232 -3
  140. package/test/live-mirror-parity.test.ts +53 -19
  141. package/test/master-to-twin.test.ts +87 -13
  142. package/test/model-basis.test.ts +86 -0
  143. package/test/oee-accumulator.test.ts +9 -9
  144. package/test/scale-twin-bench.test.ts +22 -22
  145. package/test/spec-coverage.test.ts +113 -0
  146. package/test/streamline-e2e.test.ts +10 -10
  147. package/test/structure-revision-db.test.ts +310 -0
  148. package/test/twin-event-keys.test.ts +2 -2
  149. package/test/vocabulary-guard.test.ts +43 -0
  150. package/test/warm-start.test.ts +9 -9
@@ -63,14 +63,14 @@ test('능력 소싱 구조: 4 자원/위치 능력은 카탈로그 타입에서,
63
63
 
64
64
  test('★ 스트림라인 브릿지: 능력의 상태필드가 라이브 payload 에 실림 (커널→보드 도달)', () => {
65
65
  // storable 노드 → occupancy/capacity (+ operable status)
66
- const nodeD: any = buildEntityDeltas({ nodes: [{ id: 'n', type: 'storage', occupancy: 2, capacity: 9, status: 'idle' }] })[0].data
66
+ const nodeD: any = buildEntityDeltas({ locations: [{ id: 'n', type: 'storage', occupancy: 2, capacity: 9, status: 'idle' }] })[0].data
67
67
  for (const f of stateFieldsOf(['storable'])) assert.ok(f in nodeD, `storable payload.${f}`)
68
68
  assert.ok('status' in nodeD, 'operable(노드 status) payload')
69
69
 
70
70
  // mobile+operable 무버 → location/motion/status
71
- const moverD: any = buildEntityDeltas({ movers: [{ id: 'm', kind: 'forklift', location: 'n', status: 'busy', motion: { toNode: 'n2', progress: 0.3 } }] }).find(x => x.tag === 'm')!.data
72
- for (const f of stateFieldsOf(['mobile'])) assert.ok(f in moverD, `mobile payload.${f}`)
73
- for (const f of stateFieldsOf(['operable'])) assert.ok(f in moverD, `operable payload.${f}`)
71
+ const equipmentD: any = buildEntityDeltas({ equipment: [{ id: 'm', kind: 'forklift', location: 'n', status: 'busy', motion: { toNode: 'n2', progress: 0.3 } }] }).find(x => x.tag === 'm')!.data
72
+ for (const f of stateFieldsOf(['mobile'])) assert.ok(f in equipmentD, `mobile payload.${f}`)
73
+ for (const f of stateFieldsOf(['operable'])) assert.ok(f in equipmentD, `operable payload.${f}`)
74
74
 
75
75
  // trackable 오더 → progress/held (lifecycle 은 payload 에서 'status' 로 표면화 — 이름-매핑 시임)
76
76
  const orderD: any = buildEntityDeltas({ orders: [{ id: 'o', status: 'picking', progress: 0.5, held: false }] }).find(x => x.tag === 'o')!.data
@@ -81,7 +81,7 @@ test('★ 스트림라인 브릿지: 능력의 상태필드가 라이브 payload
81
81
  test('시임 문서화 · processable output(양품/불량)은 payload oee.good/scrap 으로 표면화', () => {
82
82
  // 계약 stateField = 'output' 이나, 라이브 payload 는 이를 oee(정수%+good/scrap)로 집약해 표면화.
83
83
  const d: any = buildEntityDeltas({
84
- movers: [{ id: 'c', kind: 'cutter', status: 'busy', oee: { overall: 0.8, availability: 0.9, performance: 0.9, quality: 0.98, goodCount: 10, scrapCount: 1 } }]
84
+ equipment: [{ id: 'c', kind: 'cutter', status: 'busy', oee: { overall: 0.8, availability: 0.9, performance: 0.9, quality: 0.98, goodCount: 10, scrapCount: 1 } }]
85
85
  }).find(x => x.tag === 'c')!.data
86
86
  assert.ok(d.oee, 'processable → payload.oee 존재')
87
87
  assert.equal(d.oee.good, 10, 'output.good = oee.good')
@@ -0,0 +1,144 @@
1
+ /*
2
+ * 소요시간 추정기 — **상수를 데이터로 바꾸는 두 층(실측 · 거리×속도).**
3
+ *
4
+ * 커널 `durationOf` 의 우선순위는 추정기 > 명세 > 상수다. 그 추정기 자리에 호스트가 넣는 두 가지를
5
+ * 여기서 고정한다. 핵심 규율은 하나다 — **모르면 답하지 않는다**(undefined → 커널이 명세·상수로).
6
+ */
7
+ import { test } from 'node:test'
8
+ import assert from 'node:assert/strict'
9
+
10
+ import { EQUIPMENT_PROPERTY, buildTravelEstimator, chainEstimators } from '../server/engine/travel-estimator.ts'
11
+ import { DEFAULT_MIN_SAMPLES, buildMeasuredEstimator } from '../server/engine/measured-estimator.ts'
12
+
13
+ const layout = { dock: { x: 0, y: 0 }, rack: { x: 30, y: 40 } } // 거리 50m
14
+ const equipment = (uom?: string, value = '2') => [
15
+ { id: 'fk-1', kind: 'forklift', properties: [{ id: EQUIPMENT_PROPERTY.speed, value, ...(uom ? { uom } : {}) }] }
16
+ ]
17
+
18
+ /* ── 거리 × 속도 ────────────────────────────────────────────────────────── */
19
+
20
+ test('거리와 속도가 있으면 이동시간을 계산한다 — 상수 30초가 아니라 50m/2(m/s)=25초', () => {
21
+ const { estimator, reasons } = buildTravelEstimator({ layout, equipment: equipment('MTS'), unit: 'm' })
22
+ assert.deepEqual(reasons, [])
23
+ assert.equal(estimator!.estimate({ kind: 'putaway', fromNode: 'dock', toNode: 'rack', resourceKind: 'forklift' }), 25_000)
24
+ })
25
+
26
+ test('단위 코드를 표준대로 환산한다 — KMH 는 m/s 가 아니다', () => {
27
+ const { estimator } = buildTravelEstimator({ layout, equipment: equipment('KMH', '7.2'), unit: 'm' })
28
+ // 7.2 km/h = 2 m/s → 50m = 25초
29
+ assert.equal(estimator!.estimate({ kind: 'putaway', fromNode: 'dock', toNode: 'rack', resourceKind: 'forklift' }), 25_000)
30
+ })
31
+
32
+ test('단위 없는 속도는 m/s 라고 가정하지 않는다 — 3 이 초속 3m 인지 시속 3km 인지 뜻이 다르다', () => {
33
+ const { estimator, reasons } = buildTravelEstimator({ layout, equipment: equipment(undefined), unit: 'm' })
34
+ assert.equal(estimator, undefined)
35
+ assert.ok(reasons.some(r => r.includes('unit of measure')))
36
+ })
37
+
38
+ test('모르는 단위 코드는 받지 않는다', () => {
39
+ const { estimator, reasons } = buildTravelEstimator({ layout, equipment: equipment('WAT'), unit: 'm' })
40
+ assert.equal(estimator, undefined)
41
+ assert.ok(reasons.some(r => r.includes('unknown speed unit')))
42
+ })
43
+
44
+ test('좌표가 없으면 만들지 않고 이유를 남긴다', () => {
45
+ const { estimator, reasons } = buildTravelEstimator({ layout: {}, equipment: equipment('MTS'), unit: 'm' })
46
+ assert.equal(estimator, undefined)
47
+ assert.ok(reasons.some(r => r.includes('no location coordinates')))
48
+ })
49
+
50
+ test('속도를 아무도 선언하지 않으면 거리만으로 시간을 만들지 않는다', () => {
51
+ const { estimator, reasons } = buildTravelEstimator({ layout, equipment: [{ id: 'fk-1', kind: 'forklift' }], unit: 'm' })
52
+ assert.equal(estimator, undefined)
53
+ assert.ok(reasons.some(r => r.includes('speed')))
54
+ })
55
+
56
+ test('좌표 단위가 미터가 아니면 환산을 추측하지 않는다', () => {
57
+ const { estimator, reasons } = buildTravelEstimator({ layout, equipment: equipment('MTS'), unit: 'ft' })
58
+ assert.equal(estimator, undefined)
59
+ assert.ok(reasons.some(r => r.includes('not metres')))
60
+ })
61
+
62
+ test('같은 자리의 작업(처리·체류)은 다루지 않는다 — 거리 0 을 0 초로 만들면 공정 시간이 사라진다', () => {
63
+ const { estimator } = buildTravelEstimator({ layout, equipment: equipment('MTS'), unit: 'm' })
64
+ assert.equal(estimator!.estimate({ kind: 'cut', fromNode: 'rack', toNode: 'rack', resourceKind: 'forklift' }), undefined)
65
+ })
66
+
67
+ test('좌표 모르는 노드·속도 모르는 자원종류는 넘긴다(부분 적용)', () => {
68
+ const { estimator } = buildTravelEstimator({ layout, equipment: equipment('MTS'), unit: 'm' })
69
+ assert.equal(estimator!.estimate({ kind: 'putaway', fromNode: 'dock', toNode: 'rack-99', resourceKind: 'forklift' }), undefined)
70
+ assert.equal(estimator!.estimate({ kind: 'spot', fromNode: 'dock', toNode: 'rack', resourceKind: 'hostler' }), undefined)
71
+ assert.equal(estimator!.estimate({ kind: 'spot', fromNode: 'dock', toNode: 'rack' }), undefined, '자원 종류 미지')
72
+ })
73
+
74
+ /* ── 실측(이력) ─────────────────────────────────────────────────────────── */
75
+
76
+ test('작업 종류별 작업시간 p50 을 소요로 배운다 — 리드타임(대기 포함)이 아니라 작업시간', () => {
77
+ const r = buildMeasuredEstimator([
78
+ { key: 'putaway', workTime: { count: 40, p50Ms: 47_000 } },
79
+ { key: 'pick', workTime: { count: 12, p50Ms: 22_500 } }
80
+ ])
81
+ assert.deepEqual(r.learned, { putaway: 47_000, pick: 22_500 })
82
+ assert.deepEqual(r.estimator!.estimate({ kind: 'putaway', fromNode: 'a', toNode: 'b' }), { meanMs: 47_000 })
83
+ assert.equal(r.estimator!.estimate({ kind: 'unseen', fromNode: 'a', toNode: 'b' }), undefined, '못 배운 종류는 넘긴다')
84
+ })
85
+
86
+ /* ── 관측된 퍼짐 ────────────────────────────────────────────────────────── */
87
+
88
+ test('두 꼬리가 있으면 퍼짐까지 배운다 — 평균만 배우면 줄이 생기지 않는다', () => {
89
+ const r = buildMeasuredEstimator([{ key: 'putaway', workTime: { count: 40, p10Ms: 30_000, p50Ms: 47_000, p90Ms: 80_000 } }])
90
+ assert.deepEqual(r.spreads, { putaway: { minMs: 30_000, maxMs: 80_000 } })
91
+ assert.deepEqual(r.estimator!.estimate({ kind: 'putaway', fromNode: 'a', toNode: 'b' }), {
92
+ meanMs: 47_000,
93
+ spread: { distribution: 'triangular', minMs: 30_000, maxMs: 80_000, modeMs: 47_000 }
94
+ })
95
+ })
96
+
97
+ test('한쪽 꼬리만 있으면 퍼짐을 만들지 않는다 — 아래쪽을 위쪽에서 베끼는 것은 발명이다', () => {
98
+ const r = buildMeasuredEstimator([{ key: 'pick', workTime: { count: 40, p50Ms: 20_000, p90Ms: 40_000 } }])
99
+ assert.deepEqual(r.spreads, {})
100
+ assert.deepEqual(r.estimator!.estimate({ kind: 'pick', fromNode: 'a', toNode: 'b' }), { meanMs: 20_000 })
101
+ })
102
+
103
+ test('구간이 성립하지 않으면 퍼짐 없이 평균만 — 없는 흔들림을 만들지 않는다', () => {
104
+ const r = buildMeasuredEstimator([{ key: 'pick', workTime: { count: 40, p10Ms: 50_000, p50Ms: 20_000, p90Ms: 20_000 } }])
105
+ assert.deepEqual(r.spreads, {}, 'p10 >= p90 이면 구간이 아니다')
106
+ })
107
+
108
+ test('표본이 적으면 배우지 않고, 무엇을 버렸는지 밝힌다 — 두 건의 p50 은 현장 값이 아니다', () => {
109
+ const r = buildMeasuredEstimator([{ key: 'pick', workTime: { count: 2, p50Ms: 9_000 } }])
110
+ assert.equal(r.estimator, undefined)
111
+ assert.deepEqual(r.skipped, [{ kind: 'pick', count: 2 }])
112
+ assert.equal(r.minSamples, DEFAULT_MIN_SAMPLES)
113
+ })
114
+
115
+ test("'unknown' 축은 배우지 않는다 — 종류 모르는 기록을 특정 종류의 근거로 쓰지 않는다", () => {
116
+ const r = buildMeasuredEstimator([{ key: 'unknown', workTime: { count: 99, p50Ms: 30_000 } }])
117
+ assert.deepEqual(r.learned, {})
118
+ assert.equal(r.estimator, undefined)
119
+ })
120
+
121
+ test('p50 이 0 이면 쓰지 않는다 — 0 초 작업은 측정 실패다', () => {
122
+ const r = buildMeasuredEstimator([{ key: 'pick', workTime: { count: 50, p50Ms: 0 } }])
123
+ assert.deepEqual(r.learned, {})
124
+ })
125
+
126
+ test('그룹이 없으면 빈 결과 — 던지지 않는다', () => {
127
+ assert.equal(buildMeasuredEstimator(undefined).estimator, undefined)
128
+ assert.equal(buildMeasuredEstimator([]).estimator, undefined)
129
+ })
130
+
131
+ /* ── 사슬 — 실측이 계산을 이긴다 ────────────────────────────────────────── */
132
+
133
+ test('실측이 먼저, 없으면 거리 계산 — 강한 근거가 앞이다', () => {
134
+ const measured = buildMeasuredEstimator([{ key: 'pick', workTime: { count: 30, p50Ms: 11_000 } }])
135
+ const travel = buildTravelEstimator({ layout, equipment: equipment('MTS'), unit: 'm' })
136
+ const chained = chainEstimators([measured.estimator, travel.estimator])!
137
+ assert.deepEqual(chained.estimate({ kind: 'pick', fromNode: 'dock', toNode: 'rack', resourceKind: 'forklift' }), { meanMs: 11_000 }, '실측 우선')
138
+ assert.equal(chained.estimate({ kind: 'putaway', fromNode: 'dock', toNode: 'rack', resourceKind: 'forklift' }), 25_000, '실측 없으면 거리')
139
+ assert.equal(chained.estimate({ kind: 'putaway', fromNode: 'dock', toNode: 'dock', resourceKind: 'forklift' }), undefined, '둘 다 모르면 넘긴다')
140
+ })
141
+
142
+ test('빈 사슬은 undefined — 주입하지 않는다(상수로 굴러가는 것이 정직하다)', () => {
143
+ assert.equal(chainEstimators([undefined, undefined]), undefined)
144
+ })
@@ -1,51 +1,182 @@
1
1
  /*
2
2
  * 호스트 방출 매핑(buildEntityDeltas) 단위 검증 — 씬 컴포넌트가 소비하는 data(tag) payload 계약.
3
- * held/oee(정수 퍼센트)/오더 집약 등 최근 추가분이 정확히 실리는지(회귀 방지).
3
+ *
4
+ * 지키는 것(2026-08-01 재편):
5
+ * ① **레퍼런스가 아는 것은 전부 나간다** — 필드를 손으로 골라 담지 않는다. 화이트리스트였을 때
6
+ * 노드 parentId·작업 전량·물품 전량이 조용히 누락됐다. 계약에 속성이 생기면 자동으로 도달해야 한다.
7
+ * ② **표식은 도메인 낱말을 훔치지 않는다** — 엔티티 종류 표식은 `entity`, `kind` 는 도메인 것
8
+ * (무버 cutter · 작업 putaway · 오더 workorder).
9
+ * ③ **조용히 자르지 않는다** — 물품 집약이 상한에 걸리면 total·truncated 로 밝힌다.
4
10
  */
5
11
  import { test } from 'node:test'
6
12
  import assert from 'node:assert/strict'
7
13
 
8
- import { buildEntityDeltas, ORDERS_TAG } from '../server/engine/entity-delta.ts'
14
+ import { buildEntityDeltas, ITEMS_AGGREGATE_LIMIT, ITEMS_TAG, ORDERS_TAG, PERSONS_TAG, TASKS_TAG } from '../server/engine/entity-delta.ts'
9
15
 
10
16
  const byTag = (arr: any[], tag: string) => arr.find(x => x.tag === tag)
11
17
 
12
- test('node delta: occupancy/capacity/status/type', () => {
13
- const d = buildEntityDeltas({ nodes: [{ id: 'bin-1', occupancy: 3, capacity: 10, status: 'idle', type: 'storage' }] })
14
- assert.deepEqual(byTag(d, 'bin-1').data, { kind: 'node', occupancy: 3, capacity: 10, status: 'idle', type: 'storage' })
18
+ test('노드: 계약의 속성이 전부 나간다 — parentId(소속 구역)를 포함해', () => {
19
+ const d = buildEntityDeltas({
20
+ locations: [{ id: 'bin-1', occupancy: 3, capacity: 10, status: 'idle', type: 'storage', parentId: 'zone-a' }]
21
+ })
22
+ assert.deepEqual(byTag(d, 'bin-1').data, {
23
+ entity: 'location',
24
+ occupancy: 3,
25
+ capacity: 10,
26
+ status: 'idle',
27
+ type: 'storage',
28
+ parentId: 'zone-a'
29
+ })
15
30
  })
16
31
 
17
- test('mover delta: held(계획정지) + status + OEE 정수 퍼센트 정형화', () => {
32
+ test('표식은 entity, kind 도메인 무버 종류가 표식에 덮이지 않는다', () => {
18
33
  const d = buildEntityDeltas({
19
- movers: [{ id: 'cutter-1', kind: 'cutter', location: 'cut', status: 'down', held: true, oee: { overall: 0.8234, availability: 0.9, performance: 0.95, quality: 0.96, goodCount: 12, scrapCount: 3, idleMs: 99999 } }]
34
+ equipment: [{ id: 'cutter-1', kind: 'cutter', location: 'cut', status: 'down', held: true }]
20
35
  })
21
36
  const m = byTag(d, 'cutter-1').data
22
- assert.equal(m.kind, 'mover')
37
+ assert.equal(m.entity, 'equipment', '표식')
38
+ assert.equal(m.kind, 'cutter', '도메인 속성 — 예전엔 표식에 덮여 사라졌다')
23
39
  assert.equal(m.status, 'down')
24
40
  assert.equal(m.held, true)
25
- // 정수 퍼센트 + 카운트만(원시 누적 ms 는 제외 — churn 억제)
26
- assert.deepEqual(m.oee, { overall: 82, availability: 90, performance: 95, quality: 96, good: 12, scrap: 3 })
27
41
  })
28
42
 
29
- test('mover delta: oee 미측정이면 undefined', () => {
30
- const d = buildEntityDeltas({ movers: [{ id: 'fk-1', kind: 'forklift', status: 'idle' }] })
43
+ test('무버: OEE 정수 퍼센트로 정형화하고 원시 누적은 빼낸다(churn 억제)', () => {
44
+ const d = buildEntityDeltas({
45
+ equipment: [
46
+ {
47
+ id: 'cutter-1',
48
+ kind: 'cutter',
49
+ status: 'down',
50
+ oee: { overall: 0.8234, availability: 0.9, performance: 0.95, quality: 0.96, goodCount: 12, scrapCount: 3, idleMs: 99999 }
51
+ }
52
+ ]
53
+ })
54
+ assert.deepEqual(byTag(d, 'cutter-1').data.oee, {
55
+ overall: 82,
56
+ availability: 90,
57
+ performance: 95,
58
+ quality: 96,
59
+ good: 12,
60
+ scrap: 3
61
+ })
62
+ })
63
+
64
+ test('무버: oee 미측정이면 실리지 않는다', () => {
65
+ const d = buildEntityDeltas({ equipment: [{ id: 'fk-1', kind: 'forklift', status: 'idle' }] })
31
66
  assert.equal(byTag(d, 'fk-1').data.oee, undefined)
32
67
  })
33
68
 
34
- test('order delta + __orders__ 집약(전체 오더)', () => {
69
+ test('작업: 개별 + 집약으로 나간다 — 예전엔 아예 방출되지 않았다', () => {
70
+ const d = buildEntityDeltas({
71
+ tasks: [
72
+ { id: 't1', kind: 'putaway', status: 'in-progress', fromNode: 'dock', toNode: 'rack-1', resourceRef: 'fk-1', orderId: 'o1', progress: 0.4 },
73
+ { id: 't2', kind: 'pick', status: 'created' }
74
+ ]
75
+ })
76
+ assert.deepEqual(byTag(d, 't1').data, {
77
+ entity: 'task',
78
+ kind: 'putaway',
79
+ status: 'in-progress',
80
+ fromNode: 'dock',
81
+ toNode: 'rack-1',
82
+ resourceRef: 'fk-1',
83
+ orderId: 'o1',
84
+ progress: 0.4
85
+ })
86
+ const agg = byTag(d, TASKS_TAG).data
87
+ assert.equal(agg.entity, 'tasks')
88
+ assert.equal(agg.tasks.length, 2)
89
+ assert.equal(agg.tasks[0].id, 't1', '집약에는 식별자가 있다')
90
+ assert.equal(agg.tasks[0].resourceRef, 'fk-1', '집약도 속성을 다 담는다')
91
+ })
92
+
93
+ test('물품: 개별(태그=epc) + 집약으로 나간다 — 수량·소속 팔레트·만료까지', () => {
94
+ const d = buildEntityDeltas({
95
+ items: [{ epc: 'urn:epc:id:sgtin:1.1.1', gtin: 'g1', location: 'rack-1', disposition: 'sellable', parent: 'sscc-1', qty: 40, expiry: 1234 }]
96
+ })
97
+ assert.deepEqual(byTag(d, 'urn:epc:id:sgtin:1.1.1').data, {
98
+ entity: 'item',
99
+ gtin: 'g1',
100
+ location: 'rack-1',
101
+ disposition: 'sellable',
102
+ parent: 'sscc-1',
103
+ qty: 40,
104
+ expiry: 1234
105
+ })
106
+ const agg = byTag(d, ITEMS_TAG).data
107
+ assert.equal(agg.entity, 'items')
108
+ assert.equal(agg.items[0].epc, 'urn:epc:id:sgtin:1.1.1')
109
+ assert.equal(agg.items[0].qty, 40)
110
+ assert.equal(agg.total, 1)
111
+ assert.equal(agg.truncated, undefined, '상한 안이면 truncated 없음')
112
+ })
113
+
114
+ test('물품 집약: 상한을 넘으면 잘린 사실을 밝힌다 — 조용한 절단 금지', () => {
115
+ const items = Array.from({ length: ITEMS_AGGREGATE_LIMIT + 7 }, (_, i) => ({ epc: `e${i}`, location: 'rack-1' }))
116
+ const d = buildEntityDeltas({ items })
117
+ const agg = byTag(d, ITEMS_TAG).data
118
+ assert.equal(agg.items.length, ITEMS_AGGREGATE_LIMIT)
119
+ assert.equal(agg.total, ITEMS_AGGREGATE_LIMIT + 7)
120
+ assert.equal(agg.truncated, 7)
121
+ /* 개별 물품은 상한과 무관하게 전부 태그별로 나간다(시그니처 dedup 이 변한 것만 재방송한다). */
122
+ assert.equal(d.filter(x => x.data?.entity === 'item').length, ITEMS_AGGREGATE_LIMIT + 7)
123
+ })
124
+
125
+ test('오더: 개별 + 집약, 도메인 kind 유지', () => {
35
126
  const d = buildEntityDeltas({
36
127
  orders: [
37
128
  { id: 'o1', kind: 'workorder', status: 'picking', progress: 0.5, held: false },
38
- { id: 'o2', kind: 'workorder', status: 'created', progress: 0, held: true }
129
+ { id: 'o2', kind: 'outbound', status: 'created', progress: 0, held: true }
39
130
  ]
40
131
  })
41
- assert.ok(byTag(d, 'o1'), '개별 오더도 방출')
132
+ assert.equal(byTag(d, 'o1').data.kind, 'workorder')
42
133
  const agg = byTag(d, ORDERS_TAG).data
43
- assert.equal(agg.kind, 'orders')
134
+ assert.equal(agg.entity, 'orders')
44
135
  assert.equal(agg.orders.length, 2)
45
136
  assert.equal(agg.orders[1].held, true, '집약에 held 반영')
46
137
  })
47
138
 
48
- test(' 상태 집약 태그는 항상 방출(빈 배열)', () => {
49
- const agg = byTag(buildEntityDeltas({}), ORDERS_TAG).data
50
- assert.deepEqual(agg.orders, [])
139
+ test('계약에 속성이 생기면 손대지 않아도 도달한다 — 화이트리스트 재발 방지', () => {
140
+ /* 테스트가 실패하면 누군가 필드를 손으로 골라 담기 시작한 것이다. */
141
+ const d = buildEntityDeltas({ locations: [{ id: 'n1', occupancy: 0, temperatureC: -18, hazmatClass: '3' }] })
142
+ const n = byTag(d, 'n1').data
143
+ assert.equal(n.temperatureC, -18, '알지 못하는 새 속성도 통과해야 한다')
144
+ assert.equal(n.hazmatClass, '3')
145
+ })
146
+
147
+ test('undefined 값은 싣지 않는다 — 키 유무가 시그니처를 흔들지 않게', () => {
148
+ const d = buildEntityDeltas({ equipment: [{ id: 'fk-1', kind: 'forklift', location: undefined, status: 'idle' }] })
149
+ assert.equal('location' in byTag(d, 'fk-1').data, false)
150
+ })
151
+
152
+ test('빈 상태 — 집약 태그 셋은 항상 방출(빈 배열)', () => {
153
+ const d = buildEntityDeltas({})
154
+ assert.deepEqual(byTag(d, ORDERS_TAG).data.orders, [])
155
+ assert.deepEqual(byTag(d, TASKS_TAG).data.tasks, [])
156
+ assert.deepEqual(byTag(d, ITEMS_TAG).data.items, [])
157
+ assert.equal(byTag(d, ITEMS_TAG).data.total, 0)
158
+ })
159
+
160
+ /* ── 사람 ────────────────────────────────────────────────────────────────
161
+ *
162
+ * 인원은 설비와 별개 엔티티다(고장·설비효율이 아니라 등급·교대로 산다). 같은 통과 규칙을 쓰되
163
+ * 자기 태그로 나가고, 집약 태그도 따로 둔다 — 인원을 한눈에 보는 소비처가 있기 때문이다.
164
+ */
165
+ test('사람이 개별 태그로 나간다 — 등급·교대가 그대로', () => {
166
+ const deltas = buildEntityDeltas({
167
+ persons: [{ id: 'w1', personnelClass: 'welder-op', status: 'busy', taskId: 't1', offShift: undefined }]
168
+ })
169
+ const one = deltas.find(d => d.tag === 'w1')!
170
+ assert.equal(one.data.entity, 'person')
171
+ assert.equal(one.data.personnelClass, 'welder-op')
172
+ assert.equal(one.data.status, 'busy')
173
+ assert.equal(one.data.taskId, 't1')
174
+ })
175
+
176
+ test('사람 집약 태그가 항상 나간다 — 선언하지 않은 트윈은 빈 배열(없음과 모름을 구별)', () => {
177
+ const withNone = buildEntityDeltas({})
178
+ const agg = withNone.find(d => d.tag === PERSONS_TAG)!
179
+ assert.deepEqual(agg.data.persons, [])
180
+ const withTwo = buildEntityDeltas({ persons: [{ id: 'w1' }, { id: 'w2' }] })
181
+ assert.equal(withTwo.find(d => d.tag === PERSONS_TAG)!.data.persons.length, 2)
51
182
  })
@@ -12,11 +12,11 @@ import { buildEntityDeltas } from '../server/engine/entity-delta.ts'
12
12
 
13
13
  const T0 = '2026-07-19T00:00:00.000Z'
14
14
  const board = (nNodes: number) => ({
15
- nodes: Array.from({ length: nNodes }, (_, i) => ({ id: `bin-${i}`, type: 'storage', capacity: 100000 })),
16
- movers: []
15
+ locations: Array.from({ length: nNodes }, (_, i) => ({ id: `bin-${i}`, type: 'storage', capacity: 100000 })),
16
+ equipment: []
17
17
  })
18
- function envelope(epc: string, node: string): any {
19
- const ev = objectEvent({ eventTime: T0, action: 'OBSERVE' as any, bizStep: BIZSTEP.storing, disposition: DISP.sellable, epcList: [epc], readPoint: node })
18
+ function envelope(epc: string, location: string): any {
19
+ const ev = objectEvent({ eventTime: T0, action: 'OBSERVE' as any, bizStep: BIZSTEP.storing, disposition: DISP.sellable, epcList: [epc], readPoint: location })
20
20
  return { eventId: 'x', eventType: `epcis.${ev.type}`, eventTime: T0, tenantId: 't', data: ev }
21
21
  }
22
22
 
@@ -52,18 +52,18 @@ test('bench: buildEntityDeltas 비용 vs 엔티티 수 (전 엔티티 매번 방
52
52
  const t0 = performance.now()
53
53
  for (let k = 0; k < iters; k++) buildEntityDeltas(snap)
54
54
  const per = (performance.now() - t0) / iters
55
- console.log(` [buildEntityDeltas] @ ${N.toLocaleString()} nodes: ${per.toFixed(3)}ms/call`)
55
+ console.log(` [buildEntityDeltas] @ ${N.toLocaleString()} locations: ${per.toFixed(3)}ms/call`)
56
56
  }
57
57
  })
58
58
 
59
59
  test('bench: 방송 1회 총비용(snapshot + buildEntityDeltas) vs 상태 규모', () => {
60
- for (const [nodes, items] of [[200, 5000], [1000, 50000], [2000, 100000]]) {
61
- const proj = new StateProjector(board(nodes))
62
- for (let i = 0; i < items; i++) proj.apply(envelope(`urn:epc:id:sscc:0614141.${i}`, `bin-${i % nodes}`))
60
+ for (const [locations, items] of [[200, 5000], [1000, 50000], [2000, 100000]]) {
61
+ const proj = new StateProjector(board(locations))
62
+ for (let i = 0; i < items; i++) proj.apply(envelope(`urn:epc:id:sscc:0614141.${i}`, `bin-${i % locations}`))
63
63
  const iters = 100
64
64
  const t0 = performance.now()
65
65
  for (let k = 0; k < iters; k++) buildEntityDeltas(proj.snapshot())
66
66
  const per = (performance.now() - t0) / iters
67
- console.log(` [방송1회] ${nodes.toLocaleString()} nodes / ${items.toLocaleString()} items: ${per.toFixed(3)}ms → 최대 ${Math.round(1000 / per).toLocaleString()} 방송/s`)
67
+ console.log(` [방송1회] ${locations.toLocaleString()} locations / ${items.toLocaleString()} items: ${per.toFixed(3)}ms → 최대 ${Math.round(1000 / per).toLocaleString()} 방송/s`)
68
68
  }
69
69
  })