@things-factory/headless-twin 10.0.17 → 10.0.19

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 (156) hide show
  1. package/dist-server/engine/canonical-ingest.d.ts +12 -85
  2. package/dist-server/engine/canonical-ingest.js +92 -26
  3. package/dist-server/engine/canonical-ingest.js.map +1 -1
  4. package/dist-server/engine/energy-topology.js +3 -3
  5. package/dist-server/engine/energy-topology.js.map +1 -1
  6. package/dist-server/engine/kpi-fold.d.ts +91 -3
  7. package/dist-server/engine/kpi-fold.js +149 -25
  8. package/dist-server/engine/kpi-fold.js.map +1 -1
  9. package/dist-server/engine/kpi-query.js +99 -11
  10. package/dist-server/engine/kpi-query.js.map +1 -1
  11. package/dist-server/engine/local-declarations.js +6 -5
  12. package/dist-server/engine/local-declarations.js.map +1 -1
  13. package/dist-server/engine/model-vocabulary.js +2 -2
  14. package/dist-server/engine/model-vocabulary.js.map +1 -1
  15. package/dist-server/engine/oee-accumulator.d.ts +2 -1
  16. package/dist-server/engine/oee-accumulator.js +3 -2
  17. package/dist-server/engine/oee-accumulator.js.map +1 -1
  18. package/dist-server/engine/twin-engine.d.ts +72 -2
  19. package/dist-server/engine/twin-engine.js +150 -4
  20. package/dist-server/engine/twin-engine.js.map +1 -1
  21. package/dist-server/routes.js +13 -2
  22. package/dist-server/routes.js.map +1 -1
  23. package/dist-server/service/actuation/command-dispatcher.d.ts +32 -0
  24. package/dist-server/service/actuation/command-dispatcher.js +92 -0
  25. package/dist-server/service/actuation/command-dispatcher.js.map +1 -0
  26. package/dist-server/service/actuation/command-store.d.ts +3 -0
  27. package/dist-server/service/actuation/command-store.js +56 -0
  28. package/dist-server/service/actuation/command-store.js.map +1 -0
  29. package/dist-server/service/actuation/index.d.ts +6 -0
  30. package/dist-server/service/actuation/index.js +11 -0
  31. package/dist-server/service/actuation/index.js.map +1 -0
  32. package/dist-server/service/actuation/twin-command.d.ts +30 -0
  33. package/dist-server/service/actuation/twin-command.js +110 -0
  34. package/dist-server/service/actuation/twin-command.js.map +1 -0
  35. package/dist-server/service/index.d.ts +2 -2
  36. package/dist-server/service/index.js +22 -16
  37. package/dist-server/service/index.js.map +1 -1
  38. package/dist-server/service/reference/hook-contract.d.ts +64 -21
  39. package/dist-server/service/reference/hook-contract.js +134 -22
  40. package/dist-server/service/reference/hook-contract.js.map +1 -1
  41. package/dist-server/service/reference/hook-store.d.ts +3 -0
  42. package/dist-server/service/reference/hook-store.js +28 -0
  43. package/dist-server/service/reference/hook-store.js.map +1 -0
  44. package/dist-server/service/reference/reference-adapter.d.ts +65 -1
  45. package/dist-server/service/reference/reference-adapter.js.map +1 -1
  46. package/dist-server/service/reference/reference-hook.d.ts +42 -6
  47. package/dist-server/service/reference/reference-hook.js +185 -27
  48. package/dist-server/service/reference/reference-hook.js.map +1 -1
  49. package/dist-server/service/reference/reference-live.js +1 -1
  50. package/dist-server/service/reference/reference-live.js.map +1 -1
  51. package/dist-server/service/reference/reference-master.d.ts +11 -2
  52. package/dist-server/service/reference/reference-master.js +1 -1
  53. package/dist-server/service/reference/reference-master.js.map +1 -1
  54. package/dist-server/service/reference/reference-resolver.js +2 -2
  55. package/dist-server/service/reference/reference-resolver.js.map +1 -1
  56. package/dist-server/service/reference/template-registry.d.ts +1 -1
  57. package/dist-server/service/reference/template-registry.js.map +1 -1
  58. package/dist-server/service/twin-backfill/backfill-period-facts.d.ts +24 -0
  59. package/dist-server/service/twin-backfill/backfill-period-facts.js +96 -0
  60. package/dist-server/service/twin-backfill/backfill-period-facts.js.map +1 -0
  61. package/dist-server/service/twin-backfill/backfill-shape.d.ts +34 -0
  62. package/dist-server/service/twin-backfill/backfill-shape.js +75 -0
  63. package/dist-server/service/twin-backfill/backfill-shape.js.map +1 -0
  64. package/dist-server/service/twin-backfill/index.d.ts +2 -0
  65. package/dist-server/service/twin-backfill/index.js +6 -0
  66. package/dist-server/service/twin-backfill/index.js.map +1 -0
  67. package/dist-server/service/twin-backfill/twin-backfill-resolver.d.ts +10 -0
  68. package/dist-server/service/twin-backfill/twin-backfill-resolver.js +44 -0
  69. package/dist-server/service/twin-backfill/twin-backfill-resolver.js.map +1 -0
  70. package/dist-server/service/twin-control/twin-control-mutation.js +2 -2
  71. package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
  72. package/dist-server/service/twin-lifecycle/domain-catalog.js +2 -1
  73. package/dist-server/service/twin-lifecycle/domain-catalog.js.map +1 -1
  74. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
  75. package/dist-server/service/twin-model/name-index.js +1 -1
  76. package/dist-server/service/twin-model/name-index.js.map +1 -1
  77. package/dist-server/service/twin-model/project-structure.js +4 -3
  78. package/dist-server/service/twin-model/project-structure.js.map +1 -1
  79. package/dist-server/service/twin-model/twin-equipment.d.ts +1 -1
  80. package/dist-server/service/twin-model/twin-equipment.js +2 -2
  81. package/dist-server/service/twin-model/twin-equipment.js.map +1 -1
  82. package/dist-server/service/twin-model/twin-model-item-query.js +1 -1
  83. package/dist-server/service/twin-model/twin-model-item-query.js.map +1 -1
  84. package/dist-server/service/twin-model/twin-model-query.js +4 -4
  85. package/dist-server/service/twin-model/twin-model-query.js.map +1 -1
  86. package/dist-server/service/twin-model/twin-model-tree-query.js +1 -1
  87. package/dist-server/service/twin-model/twin-model-tree-query.js.map +1 -1
  88. package/dist-server/service/twin-space/twin-space-resolver.js +1 -1
  89. package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
  90. package/dist-shared/entity-delta.js +1 -1
  91. package/dist-shared/entity-delta.js.map +1 -1
  92. package/package.json +7 -6
  93. package/server/engine/canonical-ingest.ts +72 -84
  94. package/server/engine/energy-topology.ts +1 -1
  95. package/server/engine/kpi-fold.ts +219 -36
  96. package/server/engine/kpi-query.ts +97 -4
  97. package/server/engine/local-declarations.ts +2 -1
  98. package/server/engine/model-vocabulary.ts +1 -1
  99. package/server/engine/oee-accumulator.ts +4 -2
  100. package/server/engine/twin-engine.ts +163 -5
  101. package/server/routes.ts +18 -2
  102. package/server/service/actuation/command-dispatcher.ts +130 -0
  103. package/server/service/actuation/command-store.ts +62 -0
  104. package/server/service/actuation/index.ts +8 -0
  105. package/server/service/actuation/twin-command.ts +105 -0
  106. package/server/service/index.ts +6 -0
  107. package/server/service/reference/hook-contract.ts +99 -21
  108. package/server/service/reference/hook-store.ts +28 -0
  109. package/server/service/reference/reference-adapter.ts +67 -2
  110. package/server/service/reference/reference-hook.ts +249 -32
  111. package/server/service/reference/reference-live.ts +8 -2
  112. package/server/service/reference/reference-master.ts +12 -3
  113. package/server/service/reference/reference-resolver.ts +1 -1
  114. package/server/service/reference/template-registry.ts +1 -1
  115. package/server/service/twin-backfill/backfill-period-facts.ts +168 -0
  116. package/server/service/twin-backfill/backfill-shape.ts +86 -0
  117. package/server/service/twin-backfill/index.ts +3 -0
  118. package/server/service/twin-backfill/twin-backfill-resolver.ts +35 -0
  119. package/server/service/twin-control/twin-control-mutation.ts +1 -1
  120. package/server/service/twin-lifecycle/domain-catalog.ts +2 -1
  121. package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +1 -1
  122. package/server/service/twin-model/name-index.ts +1 -1
  123. package/server/service/twin-model/project-structure.ts +3 -2
  124. package/server/service/twin-model/twin-equipment.ts +14 -2
  125. package/server/service/twin-model/twin-model-item-query.ts +1 -1
  126. package/server/service/twin-model/twin-model-query.ts +2 -2
  127. package/server/service/twin-model/twin-model-tree-query.ts +1 -1
  128. package/server/service/twin-space/twin-space-resolver.ts +1 -1
  129. package/shared/entity-delta.ts +1 -1
  130. package/test/actuation-dispatch.test.ts +196 -0
  131. package/test/axis-read.test.ts +4 -3
  132. package/test/backfill-shape.test.ts +89 -0
  133. package/test/broadcast-cost-baseline.test.ts +2 -1
  134. package/test/canonical-ingest-vocabularies.test.ts +82 -3
  135. package/test/capability-mapping.test.ts +1 -1
  136. package/test/contract-layer-guard.test.ts +48 -0
  137. package/test/declaration-reaches-model.test.ts +2 -0
  138. package/test/equipment-identity-reaches.test.ts +57 -0
  139. package/test/fact-scope-wiring.test.ts +96 -0
  140. package/test/generated-not-counted.test.ts +99 -0
  141. package/test/hook-sequence.test.ts +333 -0
  142. package/test/ingest-bench.test.ts +2 -1
  143. package/test/ingest-wiring-guard.test.ts +197 -0
  144. package/test/kpi-fold.test.ts +81 -0
  145. package/test/live-mirror-parity.test.ts +6 -3
  146. package/test/local-declarations.test.ts +3 -2
  147. package/test/master-to-twin.test.ts +2 -2
  148. package/test/oee-accumulator.test.ts +18 -3
  149. package/test/projection-reaches-screen.test.ts +5 -0
  150. package/test/projection-reads-declared.test.ts +109 -0
  151. package/test/property-effects.test.ts +7 -3
  152. package/test/reference-hook.test.ts +114 -23
  153. package/test/scale-twin-bench.test.ts +2 -1
  154. package/test/vocabulary-guard.test.ts +1 -1
  155. package/tsconfig.shared.tsbuildinfo +1 -1
  156. package/tsconfig.tsbuildinfo +1 -1
@@ -15,7 +15,12 @@ import assert from 'node:assert/strict'
15
15
 
16
16
  import { ingestCanonicalRecords } from '../server/engine/canonical-ingest.ts'
17
17
 
18
- const NOW = '2026-08-14T09:00:00.000Z'
18
+ /*
19
+ * 이 파일의 **시계**. 아래 픽스처들이 8월 말을 쓰는데 시계가 8월 14일이었다 — 자기 시계보다 뒤의
20
+ * 사실을 표본으로 두고 있었다. 커널이 「아직 오지 않은 시각의 사실」을 거부하게 되면서 드러났다
21
+ * (2026-08-30). 시계를 픽스처 뒤로 옮긴다.
22
+ */
23
+ const NOW = '2026-09-01T00:00:00.000Z'
19
24
 
20
25
  const epcisRecord = {
21
26
  epc: 'urn:epc:id:sgtin:0614141.107346.2017',
@@ -282,8 +287,8 @@ const usageRecord = {
282
287
  basis: 'provider'
283
288
  }
284
289
  const billRecord = {
285
- from: '2026-07-31T15:00:00.000Z',
286
- to: '2026-08-31T15:00:00.000Z',
290
+ from: '2026-06-30T15:00:00.000Z',
291
+ to: '2026-07-31T15:00:00.000Z',
287
292
  demandCharge: 13728000,
288
293
  currency: 'KRW',
289
294
  billingDemandKW: 1650
@@ -323,3 +328,77 @@ test('한 배치에 넷이 섞여도 각자의 문으로 간다', () => {
323
328
  assert.equal(accepted.length, 4, '넷 다 받아들인다')
324
329
  assert.equal(new Set(accepted.map(e => e.eventType)).size, 4, '넷이 서로 다른 사건이다')
325
330
  })
331
+
332
+ /*
333
+ * ── 여덟째 어휘 — 요금 기준 (2026-08-29, 커널 0.7.76) ─────────────────────────
334
+ *
335
+ * 문이 열렸는데 유입 갈림이 또 몰랐다. 오늘 세 번째다.
336
+ */
337
+ const tariffBasisRecord = {
338
+ from: '2026-07-31T15:00:00.000Z',
339
+ to: '2026-08-31T15:00:00.000Z',
340
+ billingDemandKW: 1650,
341
+ demandChargePerKW: 8320,
342
+ currency: 'KRW'
343
+ }
344
+
345
+ test('★ 요금 기준이 자기 문으로 들어온다', () => {
346
+ const { accepted, rejected } = ingestCanonicalRecords([tariffBasisRecord] as any, 't', NOW)
347
+ assert.deepEqual(rejected, [], JSON.stringify(rejected))
348
+ assert.equal(accepted.length, 1)
349
+ assert.equal(accepted[0].eventType, 'energy.tariff.basis')
350
+ })
351
+
352
+ test('요금 기준의 사건 시각은 기간의 시작이다 — 진행 중인 주기도 미래에 적히지 않는다', () => {
353
+ const { accepted } = ingestCanonicalRecords([tariffBasisRecord] as any, 't', NOW)
354
+ assert.equal(accepted[0].eventTime, '2026-07-31T15:00:00.000Z', '청구서와 달리 to 가 아니다')
355
+ })
356
+
357
+ test('요금 기준과 청구서를 섞어도 각자의 문으로 간다', () => {
358
+ const { accepted, rejected } = ingestCanonicalRecords([tariffBasisRecord, billRecord] as any, 't', NOW)
359
+ assert.deepEqual(rejected, [], JSON.stringify(rejected))
360
+ assert.deepEqual(accepted.map(e => e.eventType).sort(), ['energy.bill', 'energy.tariff.basis'])
361
+ })
362
+
363
+ test('설비의 전기 계측값은 설비 상태 문으로 간다 — 발전 출력이 없어도 받는다', () => {
364
+ const { accepted, rejected } = ingestCanonicalRecords(
365
+ [{ equipmentId: '002', dcVoltage: 469.13, dcCurrent: 68.7, acVoltage: [377.8, 379.9, 377.7], acCurrent: [37, 37.2, 37] }] as any,
366
+ 't',
367
+ NOW
368
+ )
369
+ assert.deepEqual(rejected, [], JSON.stringify(rejected))
370
+ assert.equal(accepted[0].eventType, 'energy.equipment')
371
+ })
372
+
373
+ /* ── 발전 단가가 지나갈 길 (2026-08-30) ────────────────────────────────────
374
+ *
375
+ * ★ 오늘 다섯 번째다. 커널에 문을 열고 이 갈림에 길을 내지 않으면, 레코드가 아무 문에도
376
+ * 들어가지 못하고 조용히 사라진다 — 거부도 되지 않으므로 아무도 모른다.
377
+ */
378
+ test('발전 단가가 그 문으로 간다', () => {
379
+ const r = ingestCanonicalRecords(
380
+ [{ from: '2026-08-29T15:00:00.000Z', to: '2026-08-30T15:00:00.000Z', unitPrice: 148.61, currency: 'KRW' }] as any,
381
+ 't',
382
+ NOW
383
+ )
384
+ assert.equal(r.rejected.length, 0, `거부되면 안 된다: ${JSON.stringify(r.rejected)}`)
385
+ assert.equal(r.accepted.length, 1)
386
+ assert.equal((r.accepted[0] as any).eventType, 'energy.generation.price')
387
+ assert.equal((r.accepted[0] as any).eventTime, '2026-08-29T15:00:00.000Z', '사건 시각은 기간의 시작이다')
388
+ })
389
+
390
+ test('★ 단가·사용 구간·요금 기준·청구서가 한 배치에 섞여 와도 각자의 문으로 간다', () => {
391
+ const r = ingestCanonicalRecords(
392
+ [
393
+ { from: '2026-08-29T15:00:00.000Z', to: '2026-08-30T15:00:00.000Z', unitPrice: 148.61, currency: 'KRW' },
394
+ { meterId: 'incoming', from: '2026-08-29T15:00:00.000Z', to: '2026-08-29T16:00:00.000Z', kWh: 474.48 },
395
+ { from: '2026-07-31T15:00:00.000Z', to: '2026-08-31T15:00:00.000Z', billingDemandKW: 1650, demandChargePerKW: 8320, currency: 'KRW' },
396
+ { from: '2026-06-30T15:00:00.000Z', to: '2026-07-31T15:00:00.000Z', demandCharge: 13_728_000, currency: 'KRW' }
397
+ ] as any,
398
+ 't',
399
+ NOW
400
+ )
401
+ assert.equal(r.rejected.length, 0, JSON.stringify(r.rejected))
402
+ const types = (r.accepted as any[]).map(e => e.eventType).sort()
403
+ assert.deepEqual(types, ['energy.bill', 'energy.generation.price', 'energy.tariff.basis', 'energy.usage.period'])
404
+ })
@@ -8,7 +8,7 @@
8
8
  import { test } from 'node:test'
9
9
  import assert from 'node:assert/strict'
10
10
 
11
- import { DOMAIN_CATALOG, CAPABILITIES, CAPABILITY_KEYS, capabilitiesForType, stateFieldsOf } from '@operato/twin-kernel'
11
+ import { DOMAIN_CATALOG, CAPABILITIES, CAPABILITY_KEYS, capabilitiesForType, stateFieldsOf } from '@operato/ops-contract'
12
12
  import { buildEntityDeltas } from '../shared/entity-delta.ts'
13
13
 
14
14
  const SYSTEMS = ['wms', 'yms', 'mes'] as const
@@ -0,0 +1,48 @@
1
+ /*
2
+ * **층이 이름만 남지 않게 한다** — 계약은 계약 패키지에서 온다.
3
+ *
4
+ * ── 왜 있나 (2026-08-30) ──────────────────────────────────────────────────
5
+ * 어휘와 판별 함수를 `@operato/ops-contract` 로 갈라냈다. 사실을 만드는 쪽(MES · WMS · 커넥터)이
6
+ * 엔진을 의존하지 않게 하기 위해서다.
7
+ *
8
+ * ── 처음에는 커널이 계약을 다시 내보냈다 ──────────────────────────────────
9
+ * 이유는 「이 저장소를 한 줄도 안 고쳐도 되게」였다. 그것은 **관리 편의**이지 구조의 근거가 아니다.
10
+ * 그렇게 두면 같은 이름을 두 경로로 가져올 수 있고, 새 코드가 편한 쪽을 쓰면 계약이 커널 뒤에 숨는다.
11
+ *
12
+ * 그때는 이 파일에 **세는 시험**이 있었다 — 커널 경로로 가져오는 자리가 늘지 않는지 보는 것이다.
13
+ * 세는 시험은 층이 깨졌다는 것을 인정하고 빚을 재는 도구다. 재export 를 걷으면서 그 시험도 없앴다.
14
+ *
15
+ * 지금 남은 것은 **사실을 확인하는 것**뿐이다. 커널이 계약을 내보내지 않으므로 잘못된 경로는 아예
16
+ * 컴파일되지 않는다.
17
+ */
18
+ import { test } from 'node:test'
19
+ import assert from 'node:assert/strict'
20
+
21
+ import * as contract from '@operato/ops-contract'
22
+ import * as kernel from '@operato/twin-kernel'
23
+
24
+ test('★ 계약 패키지가 어휘를 내보낸다', () => {
25
+ const names = Object.keys(contract)
26
+ assert.ok(names.length > 100, `계약이 내보내는 이름이 ${names.length}개뿐이다 — 설치가 잘못됐다`)
27
+ for (const must of ['isEnergyRecord', 'ENERGY_EVENT', 'effectivityAt', 'ingestMasterData', 'procedureViolations']) {
28
+ assert.ok(names.includes(must), `${must} 가 계약에 없다`)
29
+ }
30
+ })
31
+
32
+ test('★ 커널은 계약을 다시 내보내지 않는다 — 내보내면 어휘가 엔진 뒤에 숨는다', () => {
33
+ const contractNames = new Set(Object.keys(contract))
34
+ const leaked = Object.keys(kernel).filter(n => contractNames.has(n))
35
+ assert.deepEqual(
36
+ leaked,
37
+ [],
38
+ `커널이 계약 이름을 다시 내보낸다:\n${leaked.slice(0, 20).join(', ')}\n` +
39
+ '두 경로로 가져올 수 있게 되면 새 코드가 편한 쪽을 쓰고, 사실을 만들기만 하는 쪽이 엔진을 의존하게 된다.'
40
+ )
41
+ })
42
+
43
+ test('커널은 엔진을 내보낸다 — 걷어내다가 필요한 것까지 걷지 않았는지 본다', () => {
44
+ const names = Object.keys(kernel)
45
+ for (const must of ['EmsKernel', 'MesKernel', 'WmsKernel', 'YmsKernel', 'StateProjector']) {
46
+ assert.ok(names.includes(must), `${must} 가 커널에 없다`)
47
+ }
48
+ })
@@ -86,6 +86,8 @@ const VALUE: Record<string, unknown> = {
86
86
  level: 'WorkCenter',
87
87
  footprint: { x: 1, y: 2, w: 3, h: 4 },
88
88
  gs1Id: 'urn:epc:id:sgln:0614141.00001.0',
89
+ /* 설비의 정체성 — **GS1 키가 아닌 것이 정상이다**(실측: 인버터 하드웨어 식별자). */
90
+ identity: 'ES100000007d8d061d-1',
89
91
  homeLocationId: 'cut-station',
90
92
  mtbfMs: 600000,
91
93
  mttrMs: 60000,
@@ -0,0 +1,57 @@
1
+ /*
2
+ * **선언한 설비 정체성이 투영까지 닿는가** — 선언에서 화면까지 걸어 본다.
3
+ *
4
+ * ── 무엇이 있었나 (2026-08-30) ────────────────────────────────────────────
5
+ * 투영이 `m.gs1Id` 를 읽고 있었는데 **선언에 그런 필드가 없었다.** 그래서 그 칸은 일곱 대 모두 영원히
6
+ * 비어 있었다. 오류는 나지 않았다 — `?? null` 이 늘 `null` 을 냈을 뿐이다.
7
+ *
8
+ * 값은 한 칸 옆에 있었다. 커넥터가 인버터의 하드웨어 식별자를 속성으로 이미 보내고 있었고
9
+ * (`properties.deviceId = ES100000007d8d061d-1`), 정체성 칸도 있었는데 **그 사이에 길이 없었다.**
10
+ *
11
+ * 이 저장소에서 가장 자주 나는 결함 부류다. 그래서 필드를 여는 것으로 끝내지 않고 **선언 → 투영**을
12
+ * 실제로 걸어 본다. 필드만 있고 길이 없으면 여기서 빨갛게 난다.
13
+ */
14
+ import { test } from 'node:test'
15
+ import assert from 'node:assert/strict'
16
+ import { readFileSync } from 'node:fs'
17
+ import { fileURLToPath } from 'node:url'
18
+ import { dirname, join } from 'node:path'
19
+
20
+ import type { TwinModelDef } from '@operato/ops-contract'
21
+
22
+ const here = dirname(fileURLToPath(import.meta.url))
23
+ const src = (p: string) => readFileSync(join(here, '..', 'server', p), 'utf-8')
24
+
25
+ test('★ 선언에 정체성 자리가 있다', () => {
26
+ /* 타입이 받아들이면 선언에 자리가 있는 것이다 — 없으면 이 파일이 컴파일되지 않는다. */
27
+ const model: TwinModelDef = {
28
+ locations: [{ id: 'incoming', type: 'incoming' }],
29
+ equipment: [{ id: '001', kind: 'pv-array', homeLocation: 'incoming', identity: 'ES100000007d8d061d-1' }]
30
+ } as any
31
+ assert.equal((model.equipment?.[0] as any).identity, 'ES100000007d8d061d-1')
32
+ })
33
+
34
+ test('★ 투영이 그 자리를 읽는다 — 선언에 없는 이름을 읽으면 칸이 영원히 빈다', () => {
35
+ const projection = src('service/twin-model/project-structure.ts')
36
+ assert.match(
37
+ projection,
38
+ /identity:\s*\(m as any\)\.identity/,
39
+ '설비 투영이 선언의 identity 를 읽어야 한다. 다른 이름을 읽으면 그 칸은 늘 null 이다.'
40
+ )
41
+ assert.doesNotMatch(
42
+ projection,
43
+ /gs1Id:\s*m\.gs1Id/,
44
+ '설비 선언에 gs1Id 는 없다 — 읽으면 늘 null 이 된다(2026-08-30 실측: 일곱 대 모두 비어 있었다)'
45
+ )
46
+ })
47
+
48
+ test('★ 저장 자리의 이름이 선언과 같다 — 두 벌이면 한쪽만 고쳐진다', () => {
49
+ const entity = src('service/twin-model/twin-equipment.ts')
50
+ assert.match(entity, /identity\?: string/, '설비 행에 identity 칸이 있어야 한다')
51
+ assert.doesNotMatch(entity, /gs1Id\?: string/, '설비에서는 gs1Id 를 쓰지 않는다 — 정체성이 GS1 키가 아닌 것이 정상이다')
52
+ })
53
+
54
+ test('자리(location)의 gs1Id 는 그대로 둔다 — 그쪽은 실제로 SGLN 이 온다', () => {
55
+ const entity = src('service/twin-model/twin-location.ts')
56
+ assert.match(entity, /gs1Id\?: string/, '자리는 GS1 키를 받는다 — 설비와 같은 이유로 지우면 안 된다')
57
+ })
@@ -0,0 +1,96 @@
1
+ /*
2
+ * 마감된 구간 사실의 **범위 배선** — 유입을 부르는 곳은 모두 어느 트윈인지 말해야 한다.
3
+ *
4
+ * ── 왜 이 시험이 있나 (2026-08-30) ────────────────────────────────────────
5
+ * 커널은 `new Kernel(domainId, …)` 으로 서므로 자기가 어느 트윈인지 모른다. 설비 번호·계량 지점 번호는
6
+ * 트윈 안에서만 통하는 이름표라, 범위를 넘기지 않으면 **서로 다른 설비의 같은 날이 한 사실**이 된다.
7
+ * 실측으로 그 일이 났다 — 두 발전소의 `002` 가 겹쳐, 저널에 6줄이 있는데 서로 다른 이름은 5개였다.
8
+ * 여러 현장을 함께 계산하는 성과 화면은 이름으로 중복을 걷어내므로 한쪽 발전량이 사라진다.
9
+ *
10
+ * 배선은 조용히 빠진다. 인자를 안 넘겨도 컴파일되고, 시험도 초록이고, 실 서버에서 값이 하나 사라질
11
+ * 뿐이다. 그래서 **부르는 자리를 세어서** 지킨다 — `ingest-wiring-guard` 와 같은 부류다.
12
+ */
13
+ import { test } from 'node:test'
14
+ import assert from 'node:assert/strict'
15
+ import { readFileSync, readdirSync, statSync } from 'node:fs'
16
+ import { fileURLToPath } from 'node:url'
17
+ import { dirname, join } from 'node:path'
18
+
19
+ import { resolveSubject, ingestEnergyGenerationPeriodRecords } from '@operato/ops-contract'
20
+
21
+ const here = dirname(fileURLToPath(import.meta.url))
22
+ const serverDir = join(here, '..', 'server')
23
+
24
+ function allTs(dir: string, out: string[] = []): string[] {
25
+ for (const name of readdirSync(dir)) {
26
+ const full = join(dir, name)
27
+ if (statSync(full).isDirectory()) allTs(full, out)
28
+ else if (name.endsWith('.ts')) out.push(full)
29
+ }
30
+ return out
31
+ }
32
+
33
+ /**
34
+ * 주석을 걷어낸다 — **설명에 적힌 이름을 호출로 세지 않는다.**
35
+ *
36
+ * 처음 이 시험을 만들었을 때 `reference-live.ts` 의 흐름 설명 한 줄(`ingestCanonicalRecords(정규→봉투)`)을
37
+ * 배선 빠진 호출로 잡았다. 주석을 고치게 만드는 시험은 아무것도 지키지 않는다.
38
+ */
39
+ function withoutComments(src: string): string {
40
+ return src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1')
41
+ }
42
+
43
+ test('★ 유입을 부르는 곳은 모두 어느 트윈인지 말한다 — 안 말하면 두 현장의 같은 번호가 겹친다', () => {
44
+ const bad: string[] = []
45
+ for (const file of allTs(serverDir)) {
46
+ if (file.endsWith('canonical-ingest.ts')) continue // 정의하는 자리
47
+ const src = withoutComments(readFileSync(file, 'utf-8'))
48
+ let i = src.indexOf('ingestCanonicalRecords(')
49
+ while (i >= 0) {
50
+ /* 호출 하나를 괄호 균형으로 잘라 낸다 — 줄바꿈이 있어도 본다. */
51
+ let depth = 0
52
+ let j = i + 'ingestCanonicalRecords'.length
53
+ for (; j < src.length; j++) {
54
+ if (src[j] === '(') depth++
55
+ else if (src[j] === ')') {
56
+ depth--
57
+ if (depth === 0) break
58
+ }
59
+ }
60
+ const call = src.slice(i, j + 1)
61
+ if (!call.includes('factScope')) {
62
+ bad.push(`${file.slice(serverDir.length + 1)}: ${call.replace(/\s+/g, ' ').slice(0, 90)}`)
63
+ }
64
+ i = src.indexOf('ingestCanonicalRecords(', j)
65
+ }
66
+ }
67
+ assert.deepEqual(
68
+ bad,
69
+ [],
70
+ `범위를 넘기지 않는 유입 호출:\n${bad.join('\n')}\n` +
71
+ 'TwinEngine.factScope(domainId, instanceId) 를 넷째 인자로 넘겨라 — ' +
72
+ '넘기지 않으면 다른 현장의 같은 설비 번호가 한 사실로 겹치고, 성과 화면에서 한쪽이 사라진다.'
73
+ )
74
+ })
75
+
76
+ test('★ 범위를 넘기면 두 현장의 같은 번호가 다른 사실이다', () => {
77
+ const rec = { equipmentId: '002', from: '2026-08-28T15:00:00.000Z', to: '2026-08-29T15:00:00.000Z', kWh: 400.6 }
78
+ const a = ingestEnergyGenerationPeriodRecords(rec, { tenantId: 'd', scopeId: 'twin-a' }).accepted[0]
79
+ const b = ingestEnergyGenerationPeriodRecords(rec, { tenantId: 'd', scopeId: 'twin-b' }).accepted[0]
80
+ assert.notEqual(a.eventId, b.eventId)
81
+ })
82
+
83
+ test('선언된 정체성이 있으면 범위와 무관하게 한 사실이다 — 그릇이 정체성을 정하지 않는다', () => {
84
+ const rec = { equipmentId: '002', from: '2026-08-28T15:00:00.000Z', to: '2026-08-29T15:00:00.000Z', kWh: 400.6 }
85
+ const identityOf = () => 'https://example.com/obj/inv-002'
86
+ const a = ingestEnergyGenerationPeriodRecords(rec, { tenantId: 'd', scopeId: 'twin-a', identityOf }).accepted[0]
87
+ const b = ingestEnergyGenerationPeriodRecords(rec, { tenantId: 'd', scopeId: 'twin-b', identityOf }).accepted[0]
88
+ assert.equal(a.eventId, b.eventId)
89
+ assert.equal((a.data as any).subjectBasis, 'declared')
90
+ })
91
+
92
+ test('커널이 범위를 답할 자리가 있다 — 호스트가 그것을 넘긴다', () => {
93
+ const r = resolveSubject('equipment', '002', { tenantId: 'd', scopeId: 'ppms-호현' })
94
+ assert.equal(r.basis, 'twin-local')
95
+ assert.equal(r.subject, 'ppms-호현/002')
96
+ })
@@ -0,0 +1,99 @@
1
+ /*
2
+ * **창에 다 들어오지 않은 발전 기간을 조용히 버리지 않는다.**
3
+ *
4
+ * ── 무엇이 일어났나 (2026-08-30) ──────────────────────────────────────────
5
+ * 어제 발전량이 저널에 멀쩡히 있는데 화면이 비어 있었다. 발전 기간은 하루 단위이고(`08-28 15:00 →
6
+ * 08-29 15:00`), 성과 구간은 「지금부터 24시간 전」이라 한낮에서 시작한다(`08-29 02:53 → 08-30 02:53`).
7
+ * 하루 기간이 그 구간에 **온전히 들어오지 않는다.**
8
+ *
9
+ * 더하지 않는 것 자체는 맞다 — 걸친 기간을 더하면 다른 날의 값이 이 창의 값인 척한다. 틀린 것은
10
+ * **아무 말도 하지 않은 것**이다. 사용자는 빈 화면을 「발전량이 없다」로 읽는다. 그 둘은 완전히
11
+ * 다른 상황이다.
12
+ */
13
+ import { test } from 'node:test'
14
+ import assert from 'node:assert/strict'
15
+
16
+ import { foldKpi } from '../server/engine/kpi-fold.ts'
17
+
18
+ const T = (iso: string) => Date.parse(iso)
19
+
20
+ /** 실측값 — 오창의 8월 29일 하루. */
21
+ const DAY_START = '2026-08-28T15:00:00.000Z'
22
+ const DAY_END = '2026-08-29T15:00:00.000Z'
23
+
24
+ const period = (equipmentId: string, kWh: number) => ({
25
+ eventType: 'energy.generation.period',
26
+ eventTime: DAY_END,
27
+ payload: {
28
+ eventId: `id|${equipmentId}`,
29
+ data: {
30
+ equipmentId,
31
+ kWh,
32
+ periodStart: DAY_START,
33
+ periodEnd: DAY_END,
34
+ observedFrom: DAY_START,
35
+ observedTo: DAY_END,
36
+ accumulation: 'daily',
37
+ boundary: 'offset'
38
+ }
39
+ }
40
+ })
41
+
42
+ const EVENTS = [period('002', 127.1), period('003', 104.1), period('004', 312.4), period('005', 102.3)] as any
43
+
44
+ test('★ 창이 기간을 온전히 담으면 더한다', () => {
45
+ const r = foldKpi(EVENTS, { fromMs: T(DAY_START), toMs: T(DAY_END) })
46
+ const g = r.energy?.generated
47
+ assert.ok(g, '발전량이 있어야 한다')
48
+ assert.equal(g.counted, 4)
49
+ assert.equal(Math.round(g.kWh * 10) / 10, 645.9)
50
+ assert.equal(g.notCounted, undefined, '버린 것이 없으면 말하지 않는다')
51
+ })
52
+
53
+ test('★ 창이 기간을 반만 담으면 더하지 않되 **버렸다고 말한다**', () => {
54
+ /* 실제로 났던 구간 — 하루 기간의 한가운데에서 시작한다. */
55
+ const r = foldKpi(EVENTS, { fromMs: T('2026-08-29T02:53:00.000Z'), toMs: T('2026-08-30T02:53:00.000Z') })
56
+ const g = r.energy?.generated
57
+ assert.ok(g, '값이 없다고 아무것도 내지 않으면 화면은 「발전량 없음」으로 읽는다')
58
+ assert.equal(g.kWh, 0, '걸친 기간은 더하지 않는다 — 더하면 다른 날 값이 섞인다')
59
+ assert.equal(g.counted, 0)
60
+ assert.ok(g.notCounted, '버린 것을 말해야 한다')
61
+ assert.equal(g.notCounted.periods, 4)
62
+ assert.equal(Math.round(g.notCounted.kWh * 10) / 10, 645.9, '얼마를 못 세었는지 말한다')
63
+ assert.equal(g.notCounted.earliestStart, DAY_START)
64
+ assert.equal(g.notCounted.latestEnd, DAY_END)
65
+ })
66
+
67
+ test('창이 기간과 아예 겹치지 않으면 말할 것이 없다 — 없는 것을 만들지 않는다', () => {
68
+ const r = foldKpi(EVENTS, { fromMs: T('2026-07-01T00:00:00.000Z'), toMs: T('2026-07-02T00:00:00.000Z') })
69
+ assert.equal(r.energy?.generated, undefined)
70
+ })
71
+
72
+ test('온전히 든 것과 걸친 것이 섞이면 더한 것과 못 센 것을 함께 낸다', () => {
73
+ const earlier = {
74
+ ...period('002', 100),
75
+ eventTime: '2026-08-28T15:00:00.000Z',
76
+ payload: {
77
+ eventId: 'id|earlier',
78
+ data: {
79
+ equipmentId: '002',
80
+ kWh: 100,
81
+ periodStart: '2026-08-27T15:00:00.000Z',
82
+ periodEnd: '2026-08-28T15:00:00.000Z',
83
+ observedFrom: '2026-08-27T15:00:00.000Z',
84
+ observedTo: '2026-08-28T15:00:00.000Z',
85
+ accumulation: 'daily',
86
+ boundary: 'offset'
87
+ }
88
+ }
89
+ }
90
+ const r = foldKpi([earlier, ...EVENTS] as any, {
91
+ fromMs: T('2026-08-27T15:00:00.000Z'),
92
+ toMs: T('2026-08-29T02:00:00.000Z')
93
+ })
94
+ const g = r.energy?.generated
95
+ assert.ok(g)
96
+ assert.equal(g.counted, 1, '27→28 하루만 온전히 들었다')
97
+ assert.equal(g.kWh, 100)
98
+ assert.equal(g.notCounted?.periods, 4, '28→29 넷은 창의 끝을 넘는다')
99
+ })