@things-factory/headless-twin 10.0.17 → 10.0.18

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 (122) 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 +1 -1
  22. package/dist-server/routes.js.map +1 -1
  23. package/dist-server/service/index.d.ts +1 -1
  24. package/dist-server/service/index.js +3 -0
  25. package/dist-server/service/index.js.map +1 -1
  26. package/dist-server/service/reference/reference-live.js +1 -1
  27. package/dist-server/service/reference/reference-live.js.map +1 -1
  28. package/dist-server/service/reference/reference-master.d.ts +11 -2
  29. package/dist-server/service/reference/reference-master.js +1 -1
  30. package/dist-server/service/reference/reference-master.js.map +1 -1
  31. package/dist-server/service/reference/reference-resolver.js +2 -2
  32. package/dist-server/service/reference/reference-resolver.js.map +1 -1
  33. package/dist-server/service/reference/template-registry.d.ts +1 -1
  34. package/dist-server/service/reference/template-registry.js.map +1 -1
  35. package/dist-server/service/twin-backfill/backfill-period-facts.d.ts +24 -0
  36. package/dist-server/service/twin-backfill/backfill-period-facts.js +96 -0
  37. package/dist-server/service/twin-backfill/backfill-period-facts.js.map +1 -0
  38. package/dist-server/service/twin-backfill/backfill-shape.d.ts +34 -0
  39. package/dist-server/service/twin-backfill/backfill-shape.js +75 -0
  40. package/dist-server/service/twin-backfill/backfill-shape.js.map +1 -0
  41. package/dist-server/service/twin-backfill/index.d.ts +2 -0
  42. package/dist-server/service/twin-backfill/index.js +6 -0
  43. package/dist-server/service/twin-backfill/index.js.map +1 -0
  44. package/dist-server/service/twin-backfill/twin-backfill-resolver.d.ts +10 -0
  45. package/dist-server/service/twin-backfill/twin-backfill-resolver.js +44 -0
  46. package/dist-server/service/twin-backfill/twin-backfill-resolver.js.map +1 -0
  47. package/dist-server/service/twin-control/twin-control-mutation.js +2 -2
  48. package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
  49. package/dist-server/service/twin-lifecycle/domain-catalog.js +2 -1
  50. package/dist-server/service/twin-lifecycle/domain-catalog.js.map +1 -1
  51. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
  52. package/dist-server/service/twin-model/name-index.js +1 -1
  53. package/dist-server/service/twin-model/name-index.js.map +1 -1
  54. package/dist-server/service/twin-model/project-structure.js +4 -3
  55. package/dist-server/service/twin-model/project-structure.js.map +1 -1
  56. package/dist-server/service/twin-model/twin-equipment.d.ts +1 -1
  57. package/dist-server/service/twin-model/twin-equipment.js +2 -2
  58. package/dist-server/service/twin-model/twin-equipment.js.map +1 -1
  59. package/dist-server/service/twin-model/twin-model-item-query.js +1 -1
  60. package/dist-server/service/twin-model/twin-model-item-query.js.map +1 -1
  61. package/dist-server/service/twin-model/twin-model-query.js +4 -4
  62. package/dist-server/service/twin-model/twin-model-query.js.map +1 -1
  63. package/dist-server/service/twin-model/twin-model-tree-query.js +1 -1
  64. package/dist-server/service/twin-model/twin-model-tree-query.js.map +1 -1
  65. package/dist-server/service/twin-space/twin-space-resolver.js +1 -1
  66. package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
  67. package/dist-shared/entity-delta.js +1 -1
  68. package/dist-shared/entity-delta.js.map +1 -1
  69. package/package.json +7 -6
  70. package/server/engine/canonical-ingest.ts +72 -84
  71. package/server/engine/energy-topology.ts +1 -1
  72. package/server/engine/kpi-fold.ts +219 -36
  73. package/server/engine/kpi-query.ts +97 -4
  74. package/server/engine/local-declarations.ts +2 -1
  75. package/server/engine/model-vocabulary.ts +1 -1
  76. package/server/engine/oee-accumulator.ts +4 -2
  77. package/server/engine/twin-engine.ts +163 -5
  78. package/server/routes.ts +6 -1
  79. package/server/service/index.ts +3 -0
  80. package/server/service/reference/reference-live.ts +8 -2
  81. package/server/service/reference/reference-master.ts +12 -3
  82. package/server/service/reference/reference-resolver.ts +1 -1
  83. package/server/service/reference/template-registry.ts +1 -1
  84. package/server/service/twin-backfill/backfill-period-facts.ts +168 -0
  85. package/server/service/twin-backfill/backfill-shape.ts +86 -0
  86. package/server/service/twin-backfill/index.ts +3 -0
  87. package/server/service/twin-backfill/twin-backfill-resolver.ts +35 -0
  88. package/server/service/twin-control/twin-control-mutation.ts +1 -1
  89. package/server/service/twin-lifecycle/domain-catalog.ts +2 -1
  90. package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +1 -1
  91. package/server/service/twin-model/name-index.ts +1 -1
  92. package/server/service/twin-model/project-structure.ts +3 -2
  93. package/server/service/twin-model/twin-equipment.ts +14 -2
  94. package/server/service/twin-model/twin-model-item-query.ts +1 -1
  95. package/server/service/twin-model/twin-model-query.ts +2 -2
  96. package/server/service/twin-model/twin-model-tree-query.ts +1 -1
  97. package/server/service/twin-space/twin-space-resolver.ts +1 -1
  98. package/shared/entity-delta.ts +1 -1
  99. package/test/axis-read.test.ts +4 -3
  100. package/test/backfill-shape.test.ts +89 -0
  101. package/test/broadcast-cost-baseline.test.ts +2 -1
  102. package/test/canonical-ingest-vocabularies.test.ts +82 -3
  103. package/test/capability-mapping.test.ts +1 -1
  104. package/test/contract-layer-guard.test.ts +48 -0
  105. package/test/declaration-reaches-model.test.ts +2 -0
  106. package/test/equipment-identity-reaches.test.ts +57 -0
  107. package/test/fact-scope-wiring.test.ts +96 -0
  108. package/test/generated-not-counted.test.ts +99 -0
  109. package/test/ingest-bench.test.ts +2 -1
  110. package/test/ingest-wiring-guard.test.ts +197 -0
  111. package/test/kpi-fold.test.ts +81 -0
  112. package/test/live-mirror-parity.test.ts +6 -3
  113. package/test/local-declarations.test.ts +3 -2
  114. package/test/master-to-twin.test.ts +2 -2
  115. package/test/oee-accumulator.test.ts +18 -3
  116. package/test/projection-reaches-screen.test.ts +5 -0
  117. package/test/projection-reads-declared.test.ts +109 -0
  118. package/test/property-effects.test.ts +7 -3
  119. package/test/scale-twin-bench.test.ts +2 -1
  120. package/test/vocabulary-guard.test.ts +1 -1
  121. package/tsconfig.shared.tsbuildinfo +1 -1
  122. 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
+ })
@@ -7,7 +7,8 @@
7
7
  import { test } from 'node:test'
8
8
  import assert from 'node:assert/strict'
9
9
 
10
- import { StateProjector, objectEvent, BIZSTEP, DISP } from '@operato/twin-kernel'
10
+ import { StateProjector } from '@operato/twin-kernel'
11
+ import { objectEvent, BIZSTEP, DISP } from '@operato/ops-contract'
11
12
  import { buildEntityDeltas } from '../shared/entity-delta.ts'
12
13
 
13
14
  const T0 = '2026-07-19T00:00:00.000Z'
@@ -0,0 +1,197 @@
1
+ /*
2
+ * 유입 배선 감시 — **커널이 문을 열었는데 그 문으로 가는 길이 없는 것**을 잡는다.
3
+ *
4
+ * ── 왜 있나 (2026-08-30) ────────────────────────────────────────────────────
5
+ * 2026-08-29 하루에 이 부류가 다섯 번 났다. 커널에 새 어휘의 문을 만들고, 정규 유입의 갈림에
6
+ * 그 길을 내지 않은 것이다. 그때마다 레코드는 **거부되지도 않고 아무 문에도 들어가지 못한 채**
7
+ * 사라졌다 — 커넥터는 보냈다고 믿고, 화면은 값이 없다고 말한다. 사람이 하나씩 찾아냈다.
8
+ *
9
+ * 다음 날 같은 부류의 **두 번째 얼굴**이 드러났다. EPCIS 갈래는 「나머지 전부」라, 새 어휘를 그
10
+ * 목록에서 빼 주지 않으면 레코드가 제 문으로 가면서 **EPCIS 문으로도 들어가 거부된다.** 받아들여
11
+ * 지기도 하고 거부되기도 하는 상태가 되어, 유입 장부의 거부 수가 늘고 사람이 그것을 진짜 결함으로
12
+ * 읽는다.
13
+ *
14
+ * ── 무엇을 지키나 ──────────────────────────────────────────────────────────
15
+ * ① 커널의 유입 함수마다 부르는 곳이 있다
16
+ * ② 어휘마다 **정확히 한 문**으로 간다 — 어느 문에도 못 가거나 두 문에 걸치지 않는다
17
+ *
18
+ * ── 목록을 손으로 적지 않는다 ──────────────────────────────────────────────
19
+ * 커널이 내보내는 이름에서 읽는다. 커널이 문을 늘리면 이 시험이 먼저 빨갛게 난다. 손 목록을 두면
20
+ * 늘어난 것을 모른다 — 이 저장소가 이미 그 실패를 겪었다(`vocabulary-labels` 의 주석).
21
+ */
22
+ import { test } from 'node:test'
23
+ import assert from 'node:assert/strict'
24
+ import { readFileSync } from 'node:fs'
25
+ import { fileURLToPath } from 'node:url'
26
+ import { dirname, join } from 'node:path'
27
+
28
+ /* 유입 문과 판별 함수는 계약에 있다 — 커널은 엔진만 내보낸다(2026-08-30 층 분리). */
29
+ import * as kernel from '@operato/ops-contract'
30
+ import { ingestCanonicalRecords } from '../server/engine/canonical-ingest.ts'
31
+
32
+ const here = dirname(fileURLToPath(import.meta.url))
33
+ const routerSource = readFileSync(join(here, '..', 'server', 'engine', 'canonical-ingest.ts'), 'utf-8')
34
+
35
+ /**
36
+ * 정규 유입이 부르지 **않아도 되는** 것 — 각각 이유가 있다.
37
+ *
38
+ * 이 목록에 올릴 때는 이유를 함께 적는다. 이유 없이 올리면 이 시험은 아무것도 지키지 않는다.
39
+ */
40
+ const NOT_ROUTED: Record<string, string> = {
41
+ /* 물류 어휘의 진입점 자체다 — 갈림이 이것을 부른다(이름이 `ingest` 하나뿐이라 규칙에 안 걸린다). */
42
+ ingest: 'EPCIS 어휘의 진입점. 갈림이 직접 부른다',
43
+ /* 마스터 데이터는 사건이 아니라 어휘 갱신이라 다른 경로로 간다. */
44
+ ingestMasterData: '사건이 아니라 어휘 갱신 — 참조 계층이 부른다'
45
+ }
46
+
47
+ test('★ 커널의 유입 함수마다 부르는 곳이 있다', () => {
48
+ const ingests = Object.keys(kernel).filter(n => /^ingest/.test(n))
49
+ assert.ok(ingests.length >= 8, `유입 함수를 못 읽었다(${ingests.length}개) — 커널을 다시 읽어야 한다`)
50
+
51
+ const unrouted = ingests.filter(n => !NOT_ROUTED[n] && !routerSource.includes(n))
52
+ assert.deepEqual(
53
+ unrouted,
54
+ [],
55
+ `커널이 문을 열었는데 정규 유입에 길이 없다:\n${unrouted.join('\n')}\n` +
56
+ '그 문으로 보내는 레코드는 거부되지도 않고 사라진다.\n' +
57
+ '갈림에 길을 내거나, 부르지 않아도 되는 이유를 NOT_ROUTED 에 적어라.'
58
+ )
59
+ })
60
+
61
+ test('부르지 않아도 되는 목록에 이유가 적혀 있다', () => {
62
+ for (const [name, why] of Object.entries(NOT_ROUTED)) {
63
+ assert.ok(why.trim().length > 5, `${name} 에 이유가 없다`)
64
+ }
65
+ })
66
+
67
+ /**
68
+ * 어휘마다 대표 레코드 하나 — **그 문으로만 가야 한다.**
69
+ *
70
+ * 값은 실제로 오는 모양에서 가져왔다(2026-08-29 실측). 지어낸 모양으로 검사하면 실제 레코드가
71
+ * 다른 문으로 가는 것을 놓친다.
72
+ */
73
+ const NOW = '2026-08-30T00:00:00.000Z'
74
+ const SAMPLES: { vocabulary: string; eventType: string; record: Record<string, unknown> }[] = [
75
+ {
76
+ vocabulary: '물품 관측(EPCIS)',
77
+ eventType: 'epcis.ObjectEvent',
78
+ record: {
79
+ epc: 'urn:epc:id:sgtin:0614141.107346.2017',
80
+ action: 'OBSERVE',
81
+ bizStep: 'urn:epcglobal:cbv:bizstep:storing',
82
+ readPoint: 'rack-1'
83
+ }
84
+ },
85
+ { vocabulary: '계량 표본', eventType: 'energy.measured', record: { meterId: 'incoming', kW: 100, at: NOW } },
86
+ {
87
+ vocabulary: '설비 상태',
88
+ eventType: 'energy.equipment',
89
+ record: { equipmentId: 'inv-1', generatedKW: 24.6, dcVoltage: 469.13, at: NOW }
90
+ },
91
+ { vocabulary: '발전 적산', eventType: 'energy.generated', record: { equipmentId: 'inv-1', kWh: 174, at: NOW } },
92
+ {
93
+ vocabulary: '마감된 사용 구간',
94
+ eventType: 'energy.usage.period',
95
+ record: {
96
+ meterId: 'incoming',
97
+ from: '2026-08-29T15:00:00.000Z',
98
+ to: '2026-08-29T16:00:00.000Z',
99
+ kWh: 474.48,
100
+ maxKW: 478.08,
101
+ unitPrice: 87.3,
102
+ currency: 'KRW',
103
+ basis: 'provider'
104
+ }
105
+ },
106
+ {
107
+ vocabulary: '요금 기준',
108
+ eventType: 'energy.tariff.basis',
109
+ record: {
110
+ /* 기준 선언은 **끝나지 않은 달**이 정상이다 — 사건 시각이 기간의 시작이라 미래에 적히지 않는다. */
111
+ from: '2026-07-31T15:00:00.000Z',
112
+ to: '2026-08-31T15:00:00.000Z',
113
+ billingDemandKW: 1650,
114
+ demandChargePerKW: 8320,
115
+ currency: 'KRW'
116
+ }
117
+ },
118
+ {
119
+ vocabulary: '청구서',
120
+ eventType: 'energy.bill',
121
+ record: {
122
+ /*
123
+ * 청구서는 **마감된 달**이다. 처음 이 표본은 끝나지 않은 8월을 담고 있었는데, 그것은 실제로
124
+ * 들어와 있던 잘못된 데이터를 그대로 베낀 것이었다(2026-08-30). 그 데이터 때문에 한 현장의
125
+ * 성과 구간이 미래로 밀려 화면이 비어 있었다. 표본이 결함을 베끼면 시험이 결함을 지킨다.
126
+ */
127
+ from: '2026-06-30T15:00:00.000Z',
128
+ to: '2026-07-31T15:00:00.000Z',
129
+ demandCharge: 13_728_000,
130
+ currency: 'KRW',
131
+ billingDemandKW: 1650
132
+ }
133
+ },
134
+ {
135
+ vocabulary: '발전 단가',
136
+ eventType: 'energy.generation.price',
137
+ record: { from: '2026-08-29T15:00:00.000Z', to: '2026-08-30T15:00:00.000Z', unitPrice: 148.61, currency: 'KRW' }
138
+ },
139
+ {
140
+ /*
141
+ * 라이브에서는 커널이 스스로 마감해 내지만, **지난 기록을 채울 때**는 밖에서 들어온다.
142
+ * 구간이 있으므로 적산의 문(`energy.generated`)이 아니라 이쪽으로 가야 한다 — 겹치면 지난
143
+ * 기록이 「지금 적산」으로도 읽혀 트윈의 시계가 과거로 끌린다.
144
+ */
145
+ vocabulary: '마감된 발전 기간',
146
+ eventType: 'energy.generation.period',
147
+ record: {
148
+ equipmentId: '001',
149
+ from: '2026-08-28T15:00:00.000Z',
150
+ to: '2026-08-29T15:00:00.000Z',
151
+ kWh: 394.3,
152
+ accumulation: 'daily'
153
+ }
154
+ }
155
+ ]
156
+
157
+ test('★ 어휘마다 정확히 한 문으로 간다 — 사라지지도, 두 문에 걸치지도 않는다', () => {
158
+ const wrong: string[] = []
159
+ for (const s of SAMPLES) {
160
+ const r = ingestCanonicalRecords([s.record] as any, 't', NOW)
161
+ const accepted = (r.accepted as any[]).map(e => e.eventType)
162
+ if (r.rejected.length) {
163
+ wrong.push(`${s.vocabulary}: 거부됐다 — ${JSON.stringify(r.rejected[0]?.errors)}`)
164
+ continue
165
+ }
166
+ if (accepted.length !== 1) {
167
+ wrong.push(`${s.vocabulary}: 봉투가 ${accepted.length}개다(${accepted.join(', ')}) — 한 문으로만 가야 한다`)
168
+ continue
169
+ }
170
+ if (accepted[0] !== s.eventType) wrong.push(`${s.vocabulary}: ${s.eventType} 이어야 하는데 ${accepted[0]} 로 갔다`)
171
+ }
172
+ assert.deepEqual(wrong, [], `\n${wrong.join('\n')}`)
173
+ })
174
+
175
+ test('★ 한 배치에 모든 어휘가 섞여 와도 각자의 문으로 간다', () => {
176
+ const r = ingestCanonicalRecords(SAMPLES.map(s => s.record) as any, 't', NOW)
177
+ assert.deepEqual(r.rejected, [], `거부가 없어야 한다: ${JSON.stringify(r.rejected)}`)
178
+ assert.deepEqual(
179
+ (r.accepted as any[]).map(e => e.eventType).sort(),
180
+ SAMPLES.map(s => s.eventType).sort()
181
+ )
182
+ })
183
+
184
+ test('커널이 어휘를 늘리면 이 시험이 먼저 안다', () => {
185
+ /*
186
+ * 판별 함수의 수와 위 표본의 수가 어긋나면, 커널에 어휘가 늘었는데 여기 표본이 없다는 뜻이다.
187
+ * (EPCIS 안의 갈림 둘(담김·변환)은 한 어휘로 센다 — 같은 문으로 간다.)
188
+ */
189
+ const gates = Object.keys(kernel).filter(n => /^is.*Record$/.test(n))
190
+ const counted = gates.filter(n => !['isAggregationRecord', 'isTransformationRecord', 'isMasterDataRecord'].includes(n))
191
+ assert.equal(
192
+ counted.length,
193
+ SAMPLES.length,
194
+ `커널의 어휘는 ${counted.length}개인데 표본은 ${SAMPLES.length}개다:\n${counted.join(' ')}\n` +
195
+ '늘어난 어휘의 표본을 SAMPLES 에 더하라 — 그래야 그 어휘도 한 문으로 가는지 검사된다.'
196
+ )
197
+ })