@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.
- package/dist-server/engine/canonical-ingest.d.ts +12 -85
- package/dist-server/engine/canonical-ingest.js +92 -26
- package/dist-server/engine/canonical-ingest.js.map +1 -1
- package/dist-server/engine/energy-topology.js +3 -3
- package/dist-server/engine/energy-topology.js.map +1 -1
- package/dist-server/engine/kpi-fold.d.ts +91 -3
- package/dist-server/engine/kpi-fold.js +149 -25
- package/dist-server/engine/kpi-fold.js.map +1 -1
- package/dist-server/engine/kpi-query.js +99 -11
- package/dist-server/engine/kpi-query.js.map +1 -1
- package/dist-server/engine/local-declarations.js +6 -5
- package/dist-server/engine/local-declarations.js.map +1 -1
- package/dist-server/engine/model-vocabulary.js +2 -2
- package/dist-server/engine/model-vocabulary.js.map +1 -1
- package/dist-server/engine/oee-accumulator.d.ts +2 -1
- package/dist-server/engine/oee-accumulator.js +3 -2
- package/dist-server/engine/oee-accumulator.js.map +1 -1
- package/dist-server/engine/twin-engine.d.ts +72 -2
- package/dist-server/engine/twin-engine.js +150 -4
- package/dist-server/engine/twin-engine.js.map +1 -1
- package/dist-server/routes.js +13 -2
- package/dist-server/routes.js.map +1 -1
- package/dist-server/service/actuation/command-dispatcher.d.ts +32 -0
- package/dist-server/service/actuation/command-dispatcher.js +92 -0
- package/dist-server/service/actuation/command-dispatcher.js.map +1 -0
- package/dist-server/service/actuation/command-store.d.ts +3 -0
- package/dist-server/service/actuation/command-store.js +56 -0
- package/dist-server/service/actuation/command-store.js.map +1 -0
- package/dist-server/service/actuation/index.d.ts +6 -0
- package/dist-server/service/actuation/index.js +11 -0
- package/dist-server/service/actuation/index.js.map +1 -0
- package/dist-server/service/actuation/twin-command.d.ts +30 -0
- package/dist-server/service/actuation/twin-command.js +110 -0
- package/dist-server/service/actuation/twin-command.js.map +1 -0
- package/dist-server/service/index.d.ts +2 -2
- package/dist-server/service/index.js +22 -16
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/reference/hook-contract.d.ts +64 -21
- package/dist-server/service/reference/hook-contract.js +134 -22
- package/dist-server/service/reference/hook-contract.js.map +1 -1
- package/dist-server/service/reference/hook-store.d.ts +3 -0
- package/dist-server/service/reference/hook-store.js +28 -0
- package/dist-server/service/reference/hook-store.js.map +1 -0
- package/dist-server/service/reference/reference-adapter.d.ts +65 -1
- package/dist-server/service/reference/reference-adapter.js.map +1 -1
- package/dist-server/service/reference/reference-hook.d.ts +42 -6
- package/dist-server/service/reference/reference-hook.js +185 -27
- package/dist-server/service/reference/reference-hook.js.map +1 -1
- package/dist-server/service/reference/reference-live.js +1 -1
- package/dist-server/service/reference/reference-live.js.map +1 -1
- package/dist-server/service/reference/reference-master.d.ts +11 -2
- package/dist-server/service/reference/reference-master.js +1 -1
- package/dist-server/service/reference/reference-master.js.map +1 -1
- package/dist-server/service/reference/reference-resolver.js +2 -2
- package/dist-server/service/reference/reference-resolver.js.map +1 -1
- package/dist-server/service/reference/template-registry.d.ts +1 -1
- package/dist-server/service/reference/template-registry.js.map +1 -1
- package/dist-server/service/twin-backfill/backfill-period-facts.d.ts +24 -0
- package/dist-server/service/twin-backfill/backfill-period-facts.js +96 -0
- package/dist-server/service/twin-backfill/backfill-period-facts.js.map +1 -0
- package/dist-server/service/twin-backfill/backfill-shape.d.ts +34 -0
- package/dist-server/service/twin-backfill/backfill-shape.js +75 -0
- package/dist-server/service/twin-backfill/backfill-shape.js.map +1 -0
- package/dist-server/service/twin-backfill/index.d.ts +2 -0
- package/dist-server/service/twin-backfill/index.js +6 -0
- package/dist-server/service/twin-backfill/index.js.map +1 -0
- package/dist-server/service/twin-backfill/twin-backfill-resolver.d.ts +10 -0
- package/dist-server/service/twin-backfill/twin-backfill-resolver.js +44 -0
- package/dist-server/service/twin-backfill/twin-backfill-resolver.js.map +1 -0
- package/dist-server/service/twin-control/twin-control-mutation.js +2 -2
- package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
- package/dist-server/service/twin-lifecycle/domain-catalog.js +2 -1
- package/dist-server/service/twin-lifecycle/domain-catalog.js.map +1 -1
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
- package/dist-server/service/twin-model/name-index.js +1 -1
- package/dist-server/service/twin-model/name-index.js.map +1 -1
- package/dist-server/service/twin-model/project-structure.js +4 -3
- package/dist-server/service/twin-model/project-structure.js.map +1 -1
- package/dist-server/service/twin-model/twin-equipment.d.ts +1 -1
- package/dist-server/service/twin-model/twin-equipment.js +2 -2
- package/dist-server/service/twin-model/twin-equipment.js.map +1 -1
- package/dist-server/service/twin-model/twin-model-item-query.js +1 -1
- package/dist-server/service/twin-model/twin-model-item-query.js.map +1 -1
- package/dist-server/service/twin-model/twin-model-query.js +4 -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 +1 -1
- package/dist-server/service/twin-model/twin-model-tree-query.js.map +1 -1
- package/dist-server/service/twin-space/twin-space-resolver.js +1 -1
- package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
- package/dist-shared/entity-delta.js +1 -1
- package/dist-shared/entity-delta.js.map +1 -1
- package/package.json +7 -6
- package/server/engine/canonical-ingest.ts +72 -84
- package/server/engine/energy-topology.ts +1 -1
- package/server/engine/kpi-fold.ts +219 -36
- package/server/engine/kpi-query.ts +97 -4
- package/server/engine/local-declarations.ts +2 -1
- package/server/engine/model-vocabulary.ts +1 -1
- package/server/engine/oee-accumulator.ts +4 -2
- package/server/engine/twin-engine.ts +163 -5
- package/server/routes.ts +18 -2
- package/server/service/actuation/command-dispatcher.ts +130 -0
- package/server/service/actuation/command-store.ts +62 -0
- package/server/service/actuation/index.ts +8 -0
- package/server/service/actuation/twin-command.ts +105 -0
- package/server/service/index.ts +6 -0
- package/server/service/reference/hook-contract.ts +99 -21
- package/server/service/reference/hook-store.ts +28 -0
- package/server/service/reference/reference-adapter.ts +67 -2
- package/server/service/reference/reference-hook.ts +249 -32
- package/server/service/reference/reference-live.ts +8 -2
- package/server/service/reference/reference-master.ts +12 -3
- package/server/service/reference/reference-resolver.ts +1 -1
- package/server/service/reference/template-registry.ts +1 -1
- package/server/service/twin-backfill/backfill-period-facts.ts +168 -0
- package/server/service/twin-backfill/backfill-shape.ts +86 -0
- package/server/service/twin-backfill/index.ts +3 -0
- package/server/service/twin-backfill/twin-backfill-resolver.ts +35 -0
- package/server/service/twin-control/twin-control-mutation.ts +1 -1
- package/server/service/twin-lifecycle/domain-catalog.ts +2 -1
- package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +1 -1
- package/server/service/twin-model/name-index.ts +1 -1
- package/server/service/twin-model/project-structure.ts +3 -2
- package/server/service/twin-model/twin-equipment.ts +14 -2
- package/server/service/twin-model/twin-model-item-query.ts +1 -1
- package/server/service/twin-model/twin-model-query.ts +2 -2
- package/server/service/twin-model/twin-model-tree-query.ts +1 -1
- package/server/service/twin-space/twin-space-resolver.ts +1 -1
- package/shared/entity-delta.ts +1 -1
- package/test/actuation-dispatch.test.ts +196 -0
- package/test/axis-read.test.ts +4 -3
- package/test/backfill-shape.test.ts +89 -0
- package/test/broadcast-cost-baseline.test.ts +2 -1
- package/test/canonical-ingest-vocabularies.test.ts +82 -3
- package/test/capability-mapping.test.ts +1 -1
- package/test/contract-layer-guard.test.ts +48 -0
- package/test/declaration-reaches-model.test.ts +2 -0
- package/test/equipment-identity-reaches.test.ts +57 -0
- package/test/fact-scope-wiring.test.ts +96 -0
- package/test/generated-not-counted.test.ts +99 -0
- package/test/hook-sequence.test.ts +333 -0
- package/test/ingest-bench.test.ts +2 -1
- package/test/ingest-wiring-guard.test.ts +197 -0
- package/test/kpi-fold.test.ts +81 -0
- package/test/live-mirror-parity.test.ts +6 -3
- package/test/local-declarations.test.ts +3 -2
- package/test/master-to-twin.test.ts +2 -2
- package/test/oee-accumulator.test.ts +18 -3
- package/test/projection-reaches-screen.test.ts +5 -0
- package/test/projection-reads-declared.test.ts +109 -0
- package/test/property-effects.test.ts +7 -3
- package/test/reference-hook.test.ts +114 -23
- package/test/scale-twin-bench.test.ts +2 -1
- package/test/vocabulary-guard.test.ts +1 -1
- package/tsconfig.shared.tsbuildinfo +1 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* **밀어 받는 길의 번호** — 빠진 것을 알아채고, 재기동을 넘어 이어받는다.
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 이 시험이 있나 (2026-08-31) ────────────────────────────────────────
|
|
5
|
+
* 훅이 놓치는 것은 예외가 아니라 정상이다. 우리가 내려가 있을 때, 그쪽이 못 보냈을 때, 네트워크가
|
|
6
|
+
* 끊겼을 때. 그 대비가 드문 폴링 하나뿐이었다 — 폴링은 「놓친 것이 있나」를 값으로 알아내는 것이고,
|
|
7
|
+
* 번호는 빠진 것을 곧바로 안다.
|
|
8
|
+
*
|
|
9
|
+
* ── 왜 순수 함수 시험만으로 부족한가 ─────────────────────────────────────
|
|
10
|
+
* 번호 판정 자체는 계약 쪽에서 확인한다(`ops-contract/test/webhook-contract.test.ts`). 여기서 보는
|
|
11
|
+
* 것은 **배선**이다 — 판정이 응답이 되는가, 커서에 적히는가, 다시 세운 뒤에 읽히는가.
|
|
12
|
+
*
|
|
13
|
+
* 이 저장소에서 가장 자주 나는 결함이 「자리는 있고 길이 없다」이고, 커서가 정확히 그 모양이었다.
|
|
14
|
+
* `TwinReference.liveCursor` 는 재기동을 넘어 사는 자리인데 **훅 경로가 그것을 읽지도 쓰지도 않았다.**
|
|
15
|
+
* 쓰는 곳은 폴링 하나뿐이었다.
|
|
16
|
+
*
|
|
17
|
+
* 그래서 마지막 시험이 저장본을 그대로 두고 **핸들러를 새로 부른다.** 한 프로세스 안에서만 확인하면
|
|
18
|
+
* 「적히나」는 보이지만 「다시 세우면 읽히나」는 보이지 않는다.
|
|
19
|
+
*/
|
|
20
|
+
import { test } from 'node:test'
|
|
21
|
+
import assert from 'node:assert/strict'
|
|
22
|
+
|
|
23
|
+
import { WEBHOOK_STATUS, webhookSenderAction } from '@operato/ops-contract'
|
|
24
|
+
|
|
25
|
+
import { handleHook, type HookStore } from '../server/service/reference/reference-hook.js'
|
|
26
|
+
import { registerAdapterType, getAdapter } from '../server/service/reference/reference-adapter.js'
|
|
27
|
+
|
|
28
|
+
const DOMAIN = 'd-1'
|
|
29
|
+
const SOURCE = 'operato-mes'
|
|
30
|
+
const TWIN = 'twin-a'
|
|
31
|
+
const SECRET = 'shared-secret'
|
|
32
|
+
const SCOPE = 'EVENTS'
|
|
33
|
+
/** 커서 열쇠에는 트윈이 함께 들어간다 — 연결 하나가 트윈 여럿을 만든다. */
|
|
34
|
+
const KEY = `${TWIN}::${SCOPE}`
|
|
35
|
+
|
|
36
|
+
/** 보낸 봉투를 그대로 레코드로 내는 커넥터 — 옮길 것이 없으므로 번호만 꺼낸다. */
|
|
37
|
+
const ADAPTER_TYPE = 'test-numbered-push'
|
|
38
|
+
if (!getAdapter(ADAPTER_TYPE)) {
|
|
39
|
+
registerAdapterType({
|
|
40
|
+
type: ADAPTER_TYPE,
|
|
41
|
+
label: 'numbered push (test)',
|
|
42
|
+
handleInbound: (_cfg: any, _site: any, body: any) => ({
|
|
43
|
+
items: body?.items ?? [{ record: { moverId: 'eq-1', kind: 'press', status: 'idle' }, seq: body?.seq }],
|
|
44
|
+
scope: body?.scope
|
|
45
|
+
})
|
|
46
|
+
} as any)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 저장본 하나 — 프로세스가 아니라 여기에 산다. 그래서 핸들러를 새로 불러도 남는다. */
|
|
50
|
+
function makeStore(initial?: unknown): HookStore & { row: any } {
|
|
51
|
+
const row = {
|
|
52
|
+
id: 'ref-1',
|
|
53
|
+
source: SOURCE,
|
|
54
|
+
adapterType: ADAPTER_TYPE,
|
|
55
|
+
connectionConfig: { hookSecret: SECRET },
|
|
56
|
+
scopeSpec: { produced: [{ siteId: 'site-a', instanceId: TWIN }] },
|
|
57
|
+
liveCursor: initial
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
row,
|
|
61
|
+
load: async (domainId: string, source: string) => (domainId === DOMAIN && source === SOURCE ? row : null),
|
|
62
|
+
saveCursor: async (refId: string, cursor: unknown) => {
|
|
63
|
+
assert.equal(refId, row.id)
|
|
64
|
+
/* 저장소를 흉내 내는 것이라 참조를 그대로 두지 않고 복사해 넣는다 — 같은 객체면 안 적혀도 통과한다. */
|
|
65
|
+
row.liveCursor = JSON.parse(JSON.stringify(cursor))
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const applied = (_id: string, records: unknown[]) => ({ applied: records.length, duplicates: 0, rejected: [] })
|
|
71
|
+
|
|
72
|
+
function push(store: HookStore, seq: number | undefined, opts: { scope?: string; ingest?: any } = {}) {
|
|
73
|
+
return handleHook({
|
|
74
|
+
domainId: DOMAIN,
|
|
75
|
+
source: SOURCE,
|
|
76
|
+
instanceId: TWIN,
|
|
77
|
+
headers: { 'x-twin-hook-secret': SECRET },
|
|
78
|
+
body: { seq, scope: 'scope' in opts ? opts.scope : SCOPE },
|
|
79
|
+
nowMs: Date.parse('2026-08-31T02:00:00.000Z'),
|
|
80
|
+
ingest: opts.ingest ?? applied,
|
|
81
|
+
store
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
test('★ 첫 봉투를 받고 그 번호를 커서에 적는다', async () => {
|
|
86
|
+
const store = makeStore()
|
|
87
|
+
const out = await push(store, 918342)
|
|
88
|
+
|
|
89
|
+
assert.equal(out.status, WEBHOOK_STATUS.ok)
|
|
90
|
+
assert.equal(out.body.sequenceChecked, true)
|
|
91
|
+
assert.equal(out.body.lastSeq, 918342)
|
|
92
|
+
assert.equal(store.row.liveCursor?.streams?.[KEY]?.seq, 918342, '커서에 적히지 않았다')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('★ 건너뛴 번호는 409 로 답하고 그 앞부터 달라고 한다', async () => {
|
|
96
|
+
const store = makeStore({ streams: { [KEY]: { seq: 4 } } })
|
|
97
|
+
const out = await push(store, 7)
|
|
98
|
+
|
|
99
|
+
assert.equal(out.status, WEBHOOK_STATUS.sequenceGap)
|
|
100
|
+
assert.equal(out.body.expectedSeq, 5)
|
|
101
|
+
assert.equal(out.body.lastSeq, 4)
|
|
102
|
+
assert.equal(out.body.scope, SCOPE, '어느 줄의 번호인지 함께 말한다')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('★ 구멍이 났을 때 커서를 올리지 않는다 — 올리면 그 구간이 영영 안 온다', async () => {
|
|
106
|
+
const store = makeStore({ streams: { [KEY]: { seq: 4 } } })
|
|
107
|
+
await push(store, 7)
|
|
108
|
+
assert.equal(store.row.liveCursor.streams[KEY].seq, 4)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('★ 이미 본 번호는 받아들이되 커서를 뒤로 끌지 않는다 — 재전송이 정상 동작이다', async () => {
|
|
112
|
+
const store = makeStore({ streams: { [KEY]: { seq: 9 } } })
|
|
113
|
+
const out = await push(store, 9)
|
|
114
|
+
|
|
115
|
+
assert.equal(out.status, WEBHOOK_STATUS.ok)
|
|
116
|
+
assert.equal(store.row.liveCursor.streams[KEY].seq, 9)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('★ 반영되지 않았으면 커서를 올리지 않는다 — 트윈이 안 도는 사이의 번호를 지나치면 그 구간이 빈다', async () => {
|
|
120
|
+
const store = makeStore({ streams: { [KEY]: { seq: 4 } } })
|
|
121
|
+
const out = await push(store, 5, { ingest: () => ({ applied: 0, duplicates: 0, rejected: [] }) })
|
|
122
|
+
|
|
123
|
+
assert.equal(out.status, WEBHOOK_STATUS.notLive)
|
|
124
|
+
assert.equal(store.row.liveCursor.streams[KEY].seq, 4, '한 건도 안 들어갔는데 번호가 올라갔다')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('★ 번호를 매기지 않는 원본은 확인하지 않고, 확인하지 않았다고 말한다', async () => {
|
|
128
|
+
/* chef · ppms 가 그렇다. 「확인했고 이상 없다」와 같아 보이면 어느 연결이 지켜지는지 알 수 없다. */
|
|
129
|
+
const store = makeStore()
|
|
130
|
+
const out = await push(store, undefined, { scope: undefined })
|
|
131
|
+
|
|
132
|
+
assert.equal(out.status, WEBHOOK_STATUS.ok)
|
|
133
|
+
assert.equal(out.body.sequenceChecked, false)
|
|
134
|
+
assert.equal(store.row.liveCursor, undefined, '번호가 없으면 커서를 만들지 않는다')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('★ 번호만 있고 어느 줄인지 없으면 확인하지 않는다 — 짐작한 열쇠는 줄이 늘면 어긋난다', async () => {
|
|
138
|
+
const store = makeStore()
|
|
139
|
+
const out = await push(store, 918342, { scope: undefined })
|
|
140
|
+
|
|
141
|
+
assert.equal(out.status, WEBHOOK_STATUS.ok)
|
|
142
|
+
assert.equal(out.body.sequenceChecked, false)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('★ 줄마다 번호가 따로 간다 — 두 줄이 한 커서에 섞이면 끊임없이 「빠졌다」가 된다', async () => {
|
|
146
|
+
const store = makeStore()
|
|
147
|
+
await push(store, 5, { scope: 'EVENTS' })
|
|
148
|
+
await push(store, 1, { scope: 'MASTER' })
|
|
149
|
+
|
|
150
|
+
assert.equal(store.row.liveCursor.streams[`${TWIN}::EVENTS`].seq, 5)
|
|
151
|
+
assert.equal(store.row.liveCursor.streams[`${TWIN}::MASTER`].seq, 1)
|
|
152
|
+
|
|
153
|
+
/* MASTER 의 1 이 EVENTS 의 5 를 되돌리지 않았으므로 EVENTS 6 이 그대로 이어진다. */
|
|
154
|
+
assert.equal((await push(store, 6, { scope: 'EVENTS' })).status, WEBHOOK_STATUS.ok)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('★ 재기동을 넘어 이어받는다 — 저장본만 남기고 핸들러를 새로 부른다', async () => {
|
|
158
|
+
const first = makeStore()
|
|
159
|
+
await push(first, 5)
|
|
160
|
+
const saved = JSON.parse(JSON.stringify(first.row.liveCursor))
|
|
161
|
+
|
|
162
|
+
/* 프로세스가 새로 선 것으로 본다 — 앞의 저장소 객체는 버리고 저장본만 물려준다. */
|
|
163
|
+
const second = makeStore(saved)
|
|
164
|
+
|
|
165
|
+
assert.equal((await push(second, 7)).status, WEBHOOK_STATUS.sequenceGap, '이어받지 못하면 7 이 그냥 통과한다')
|
|
166
|
+
assert.equal((await push(second, 6)).status, WEBHOOK_STATUS.ok)
|
|
167
|
+
assert.equal(second.row.liveCursor.streams[KEY].seq, 6)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('★ 커서를 적지 못해도 이미 들어간 사실을 무르지 않는다 — 다음 번호에서 구멍으로 드러난다', async () => {
|
|
171
|
+
const store = makeStore()
|
|
172
|
+
const broken: HookStore = { load: store.load, saveCursor: async () => { throw new Error('disk full') } }
|
|
173
|
+
|
|
174
|
+
const out = await push(broken, 5)
|
|
175
|
+
assert.equal(out.status, WEBHOOK_STATUS.ok, '유입은 이미 끝났다 — 커서를 못 적은 것이 그것을 무를 이유가 아니다')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
/* ── 한 요청에 봉투 여럿 ──────────────────────────────────────────────────── */
|
|
179
|
+
|
|
180
|
+
/** 번호가 붙은 봉투 여럿을 한 요청으로 보낸다 — 밀어 주는 쪽은 한 건씩 보내지 않는다. */
|
|
181
|
+
function pushBatch(store: HookStore, seqs: number[], opts: { scope?: string; ingest?: any } = {}) {
|
|
182
|
+
return handleHook({
|
|
183
|
+
domainId: DOMAIN,
|
|
184
|
+
source: SOURCE,
|
|
185
|
+
instanceId: TWIN,
|
|
186
|
+
headers: { 'x-twin-hook-secret': SECRET },
|
|
187
|
+
body: {
|
|
188
|
+
scope: 'scope' in opts ? opts.scope : SCOPE,
|
|
189
|
+
items: seqs.map(seq => ({ seq, record: { moverId: `eq-${seq}`, kind: 'press', status: 'idle' } }))
|
|
190
|
+
},
|
|
191
|
+
nowMs: Date.parse('2026-08-31T02:00:00.000Z'),
|
|
192
|
+
ingest: opts.ingest ?? applied,
|
|
193
|
+
store
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
test('★ 배치 가운데가 빠지면 그 앞까지만 받는다 — 구멍 뒤를 받으면 그 구멍이 영영 안 메워진다', async () => {
|
|
198
|
+
const store = makeStore({ streams: { [KEY]: { seq: 4 } } })
|
|
199
|
+
const taken: unknown[][] = []
|
|
200
|
+
const out = await pushBatch(store, [5, 6, 9, 10], {
|
|
201
|
+
ingest: (_id: string, records: unknown[]) => {
|
|
202
|
+
taken.push(records)
|
|
203
|
+
return { applied: records.length, duplicates: 0, rejected: [] }
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
assert.equal(taken[0].length, 2, '5·6 만 넣어야 한다')
|
|
208
|
+
assert.equal(out.status, WEBHOOK_STATUS.sequenceGap)
|
|
209
|
+
assert.equal(out.body.expectedSeq, 7)
|
|
210
|
+
assert.equal(out.body.lastSeq, 6, '앞의 것은 받았다는 것을 함께 말한다 — 안 그러면 5·6 을 또 보낸다')
|
|
211
|
+
assert.equal(store.row.liveCursor.streams[KEY].seq, 6)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
test('★ 배치 전체가 이어지면 끝 번호가 커서가 된다', async () => {
|
|
215
|
+
const store = makeStore({ streams: { [KEY]: { seq: 4 } } })
|
|
216
|
+
const out = await pushBatch(store, [5, 6, 7])
|
|
217
|
+
|
|
218
|
+
assert.equal(out.status, WEBHOOK_STATUS.ok)
|
|
219
|
+
assert.equal(out.body.lastSeq, 7)
|
|
220
|
+
assert.equal(store.row.liveCursor.streams[KEY].seq, 7)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
test('★ 첫 봉투부터 구멍이면 아무것도 넣지 않고 커서도 그대로다', async () => {
|
|
224
|
+
const store = makeStore({ streams: { [KEY]: { seq: 4 } } })
|
|
225
|
+
let called = false
|
|
226
|
+
const out = await pushBatch(store, [9, 10], {
|
|
227
|
+
ingest: () => {
|
|
228
|
+
called = true
|
|
229
|
+
return { applied: 2, duplicates: 0, rejected: [] }
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
assert.equal(called, false, '받을 것이 없는데 유입을 불렀다')
|
|
234
|
+
assert.equal(out.status, WEBHOOK_STATUS.sequenceGap)
|
|
235
|
+
assert.equal(out.body.expectedSeq, 5)
|
|
236
|
+
assert.equal(store.row.liveCursor.streams[KEY].seq, 4)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
test('★ 번호가 있는 봉투와 없는 봉투를 섞으면 받지 않는다 — 반쯤 확인한 것을 확인했다고 말하지 않는다', async () => {
|
|
240
|
+
const store = makeStore()
|
|
241
|
+
const out = await handleHook({
|
|
242
|
+
domainId: DOMAIN,
|
|
243
|
+
source: SOURCE,
|
|
244
|
+
instanceId: TWIN,
|
|
245
|
+
headers: { 'x-twin-hook-secret': SECRET },
|
|
246
|
+
body: { scope: SCOPE, items: [{ seq: 1, record: {} }, { record: {} }] },
|
|
247
|
+
nowMs: Date.parse('2026-08-31T02:00:00.000Z'),
|
|
248
|
+
ingest: applied,
|
|
249
|
+
store
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
assert.equal(out.status, WEBHOOK_STATUS.badPayload)
|
|
253
|
+
assert.match(String(out.body.error), /mixed batch/)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
/* ── 일부만 떨어졌을 때 ───────────────────────────────────────────────────── */
|
|
257
|
+
|
|
258
|
+
const REJECT_ONE = (_id: string, records: unknown[]) => ({
|
|
259
|
+
applied: records.length - 1,
|
|
260
|
+
duplicates: 0,
|
|
261
|
+
rejected: [{ record: records[records.length - 1], errors: ['equipment: 계약에 없는 필드 — foo'] }]
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test('★ 일부가 떨어지면 422 로 답하고 무엇이 떨어졌는지 함께 준다', async () => {
|
|
265
|
+
const store = makeStore({ streams: { [KEY]: { seq: 4 } } })
|
|
266
|
+
const out = await pushBatch(store, [5, 6], { ingest: REJECT_ONE })
|
|
267
|
+
|
|
268
|
+
assert.equal(out.status, WEBHOOK_STATUS.partial)
|
|
269
|
+
assert.equal((out.body.rejected as any[]).length, 1)
|
|
270
|
+
assert.match(String((out.body.rejected as any[])[0].errors[0]), /계약에 없는 필드/)
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
test('★ 떨어진 것이 있어도 다시 보내라고 하지 않는다 — 나머지는 이미 들어갔다', async () => {
|
|
274
|
+
assert.equal(webhookSenderAction(WEBHOOK_STATUS.partial), 'partial')
|
|
275
|
+
assert.notEqual(webhookSenderAction(WEBHOOK_STATUS.partial), 'resend-from')
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
test('★ 일부가 떨어져도 받은 데까지 커서가 간다 — 안 그러면 그 구간이 매번 다시 온다', async () => {
|
|
279
|
+
const store = makeStore({ streams: { [KEY]: { seq: 4 } } })
|
|
280
|
+
const out = await pushBatch(store, [5, 6], { ingest: REJECT_ONE })
|
|
281
|
+
|
|
282
|
+
assert.equal(out.body.lastSeq, 6)
|
|
283
|
+
assert.equal(store.row.liveCursor.streams[KEY].seq, 6)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
test('★ 구멍과 떨어진 것이 함께 나면 한 응답에 둘 다 싣는다', async () => {
|
|
287
|
+
/* 두 소식이 갈라져 오면 보내는 쪽은 둘 중 하나만 한다. */
|
|
288
|
+
const store = makeStore({ streams: { [KEY]: { seq: 4 } } })
|
|
289
|
+
const out = await pushBatch(store, [5, 6, 9], { ingest: REJECT_ONE })
|
|
290
|
+
|
|
291
|
+
assert.equal(out.status, WEBHOOK_STATUS.sequenceGap, '연속성이 먼저다 — 그것이 다음에 무엇을 보낼지 정한다')
|
|
292
|
+
assert.equal(out.body.expectedSeq, 7)
|
|
293
|
+
assert.equal(out.body.lastSeq, 6)
|
|
294
|
+
assert.equal((out.body.rejected as any[]).length, 1, '떨어진 것도 함께 알린다')
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
test('전부 통과하면 떨어진 목록이 빈다 — 없는 것과 안 본 것이 같아 보이지 않게', async () => {
|
|
298
|
+
const store = makeStore()
|
|
299
|
+
const out = await pushBatch(store, [1, 2])
|
|
300
|
+
assert.deepEqual(out.body.rejected, [])
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
test('★ 한 연결이 만든 두 트윈이 계수기를 함께 쓰지 않는다', async () => {
|
|
304
|
+
/*
|
|
305
|
+
* 커서는 연결마다 한 행에 산다(`TwinReference.liveCursor`). 그런데 연결 하나가 트윈 여럿을 만든다.
|
|
306
|
+
* 보내는 쪽의 단위 이름만으로 열쇠를 잡으면 두 공장이 한 계수기를 쓰고, B 의 1 이 A 의 5 뒤에서
|
|
307
|
+
* 「이미 본 번호」로 버려진다.
|
|
308
|
+
*/
|
|
309
|
+
const store = makeStore()
|
|
310
|
+
const other = 'twin-b'
|
|
311
|
+
store.row.scopeSpec.produced.push({ siteId: 'site-b', instanceId: other })
|
|
312
|
+
|
|
313
|
+
const send = (instanceId: string, seq: number) =>
|
|
314
|
+
handleHook({
|
|
315
|
+
domainId: DOMAIN,
|
|
316
|
+
source: SOURCE,
|
|
317
|
+
instanceId,
|
|
318
|
+
headers: { 'x-twin-hook-secret': SECRET },
|
|
319
|
+
body: { scope: SCOPE, items: [{ seq, record: { moverId: 'eq-1', kind: 'press', status: 'idle' } }] },
|
|
320
|
+
nowMs: Date.parse('2026-08-31T02:00:00.000Z'),
|
|
321
|
+
ingest: applied,
|
|
322
|
+
store
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
await send(TWIN, 5)
|
|
326
|
+
const first = await send(other, 1)
|
|
327
|
+
assert.equal(first.status, WEBHOOK_STATUS.ok, 'B 공장의 첫 봉투가 A 공장의 번호에 걸려 버려졌다')
|
|
328
|
+
|
|
329
|
+
assert.equal(store.row.liveCursor.streams[`${TWIN}::${SCOPE}`].seq, 5)
|
|
330
|
+
assert.equal(store.row.liveCursor.streams[`${other}::${SCOPE}`].seq, 1)
|
|
331
|
+
|
|
332
|
+
assert.equal((await send(TWIN, 6)).status, WEBHOOK_STATUS.ok, 'A 공장이 B 의 번호에 끌려갔다')
|
|
333
|
+
})
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
import { test } from 'node:test'
|
|
8
8
|
import assert from 'node:assert/strict'
|
|
9
9
|
|
|
10
|
-
import { StateProjector
|
|
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
|
+
})
|
package/test/kpi-fold.test.ts
CHANGED
|
@@ -1345,3 +1345,84 @@ test('셋 다 없으면 이 칸을 만들지 않는다 — 빈 값을 지어내
|
|
|
1345
1345
|
} as any)
|
|
1346
1346
|
assert.equal(k.energy?.billingDemand, undefined)
|
|
1347
1347
|
})
|
|
1348
|
+
|
|
1349
|
+
test('★ 관측한 최고가 청구 기준을 넘으면 얼마나 넘었는지와 언제까지의 값인지를 낸다', () => {
|
|
1350
|
+
/* 기본요금은 이 값으로 다시 정해진다 — 지금 줄여도 되돌아가지 않는다. */
|
|
1351
|
+
const k = foldKpi([usagePeriod(400, 0, 1800, { maxKW: 1200 }), usagePeriod(400, 1800, 3600, { maxKW: 1700 })], WINDOW, {
|
|
1352
|
+
tariff: { demandChargePerKW: 8_320, billingDemandKW: 1650, currency: 'KRW' }
|
|
1353
|
+
} as any)
|
|
1354
|
+
const e = k.energy?.billingDemand?.exceeded
|
|
1355
|
+
assert.equal(e?.peakKW, 1700)
|
|
1356
|
+
assert.equal(e?.overKW, 50)
|
|
1357
|
+
assert.equal(e?.observedTo, at(3600), '이 최고값이 언제까지의 것인지')
|
|
1358
|
+
})
|
|
1359
|
+
|
|
1360
|
+
test('넘지 않으면 그 칸을 만들지 않는다', () => {
|
|
1361
|
+
const k = foldKpi([usagePeriod(400, 0, 3600, { maxKW: 1200 })], WINDOW, {
|
|
1362
|
+
tariff: { demandChargePerKW: 8_320, billingDemandKW: 1650, currency: 'KRW' }
|
|
1363
|
+
} as any)
|
|
1364
|
+
assert.equal(k.energy?.billingDemand?.exceeded, undefined)
|
|
1365
|
+
})
|
|
1366
|
+
|
|
1367
|
+
test('청구서가 말한 기준으로 넘었는지 본다 — 선언이 아니다', () => {
|
|
1368
|
+
const k = foldKpi([usagePeriod(400, 0, 3600, { maxKW: 1300 })], WINDOW, {
|
|
1369
|
+
tariff: { demandChargePerKW: 8_320, billingDemandKW: 1650, currency: 'KRW' },
|
|
1370
|
+
lastBill: { to: at(-86400), billingDemandKW: 1200 }
|
|
1371
|
+
} as any)
|
|
1372
|
+
assert.equal(k.energy?.billingDemand?.kW, 1200)
|
|
1373
|
+
assert.equal(k.energy?.billingDemand?.exceeded?.overKW, 100)
|
|
1374
|
+
})
|
|
1375
|
+
|
|
1376
|
+
/* ── 이용률 (2026-08-30) ───────────────────────────────────────────────────
|
|
1377
|
+
*
|
|
1378
|
+
* ★ 분모가 어느 정격인지가 값의 뜻을 바꾼다. 붙어 있는 원본은 직류로 나눠 2.2% 를 보여 주는데,
|
|
1379
|
+
* 교류로 나누면 4.08% 다. 같은 발전소를 두 화면에서 보는 사람이 두 배 다른 수를 본다.
|
|
1380
|
+
*
|
|
1381
|
+
* 그래서 커널은 교류를 먼저 쓰되(계통으로 나가는 양은 교류 정격을 넘을 수 없다) **어느 쪽으로
|
|
1382
|
+
* 나눴는지를 값과 함께 낸다.**
|
|
1383
|
+
*/
|
|
1384
|
+
|
|
1385
|
+
const generationPeriod = (kWh: number, fromSec: number, toSec: number): KpiEvent => ({
|
|
1386
|
+
eventType: 'energy.generation.period',
|
|
1387
|
+
eventTime: at(toSec),
|
|
1388
|
+
payload: {
|
|
1389
|
+
data: { equipmentId: 'inv-1', kWh, periodStart: at(fromSec), periodEnd: at(toSec), observedFrom: at(fromSec), observedTo: at(toSec) }
|
|
1390
|
+
}
|
|
1391
|
+
})
|
|
1392
|
+
|
|
1393
|
+
test('★ 이용률은 만든 양 ÷ (정격 × 그 기간의 시간)', () => {
|
|
1394
|
+
/* 한 시간에 63.8kWh · 정격 127.6kW → 50% */
|
|
1395
|
+
const k = foldKpi([generationPeriod(63.8, 0, 3600)], WINDOW, {
|
|
1396
|
+
generationRated: { kW: 127.6, basis: 'ac', from: 'location' }
|
|
1397
|
+
} as any)
|
|
1398
|
+
const cf = k.energy?.generated?.capacityFactor
|
|
1399
|
+
assert.equal(cf?.value, 0.5)
|
|
1400
|
+
assert.equal(cf?.ratedKW, 127.6)
|
|
1401
|
+
assert.equal(cf?.hours, 1)
|
|
1402
|
+
})
|
|
1403
|
+
|
|
1404
|
+
test('★ 어느 정격으로 나눴는지 함께 낸다 — 두 화면의 수가 달라도 이유를 말할 수 있게', () => {
|
|
1405
|
+
const k = foldKpi([generationPeriod(63.8, 0, 3600)], WINDOW, {
|
|
1406
|
+
generationRated: { kW: 234.35, basis: 'dc', from: 'location' }
|
|
1407
|
+
} as any)
|
|
1408
|
+
assert.equal(k.energy?.generated?.capacityFactor?.basis, 'dc')
|
|
1409
|
+
assert.equal(k.energy?.generated?.capacityFactor?.from, 'location')
|
|
1410
|
+
})
|
|
1411
|
+
|
|
1412
|
+
test('★ 창 길이가 아니라 센 기간들의 길이로 나눈다', () => {
|
|
1413
|
+
/*
|
|
1414
|
+
* 한 시간짜리 기간 하나만 센 창에서 창 길이(1시간)로 나누는 것과 같지만, 기간이 창보다 짧으면
|
|
1415
|
+
* 달라진다. 창 길이로 나누면 그 기간이 창 전체에 걸쳐 있었던 것처럼 작아진다.
|
|
1416
|
+
*/
|
|
1417
|
+
const k = foldKpi([generationPeriod(63.8, 0, 1800)], WINDOW, {
|
|
1418
|
+
generationRated: { kW: 127.6, basis: 'ac', from: 'location' }
|
|
1419
|
+
} as any)
|
|
1420
|
+
assert.equal(k.energy?.generated?.capacityFactor?.hours, 0.5)
|
|
1421
|
+
assert.equal(k.energy?.generated?.capacityFactor?.value, 1, '30분에 정격만큼 만들었으면 100%다')
|
|
1422
|
+
})
|
|
1423
|
+
|
|
1424
|
+
test('정격이 없으면 이용률을 내지 않는다 — 0 이나 짐작을 만들지 않는다', () => {
|
|
1425
|
+
const k = foldKpi([generationPeriod(63.8, 0, 3600)], WINDOW)
|
|
1426
|
+
assert.equal(k.energy?.generated?.capacityFactor, undefined)
|
|
1427
|
+
assert.equal(k.energy?.generated?.kWh, 63.8, '발전량 자체는 그대로 낸다')
|
|
1428
|
+
})
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
import { test } from 'node:test'
|
|
12
12
|
import assert from 'node:assert/strict'
|
|
13
13
|
|
|
14
|
-
import { WmsKernel, StateProjector, deriveAttentions
|
|
14
|
+
import { WmsKernel, StateProjector, deriveAttentions } from '@operato/twin-kernel'
|
|
15
|
+
/* OEE 공식은 계약에 있다 — 시뮬·미러·MES 셋이 같은 것을 부른다. */
|
|
16
|
+
import { computeOee } from '@operato/ops-contract'
|
|
15
17
|
import { masterToTwin } from '../server/service/reference/reference-master.ts'
|
|
16
18
|
import { buildEntityDeltas } from '../shared/entity-delta.ts'
|
|
17
19
|
import { withLiveAttentions } from '../server/engine/live-attentions.ts'
|
|
@@ -209,12 +211,13 @@ test('★ OEE derive: 공유 공식 computeOee 가 공급된 카운터로 OEE
|
|
|
209
211
|
* 없앴다. 이제 창을 모르면 잰 시간이 없으므로, 재생산에는 창이 함께 와야 한다. 라이브 공급자가
|
|
210
212
|
* 실제로 하는 일이 그것이다(누적기는 첫 이벤트 시각을 창으로 낸다).
|
|
211
213
|
*/
|
|
212
|
-
const planned = o.runMs + o.setupMs + o.downMs + o.
|
|
214
|
+
const planned = o.runMs + o.setupMs + o.downMs + o.delayMs
|
|
213
215
|
const derived = computeOee(
|
|
214
216
|
{ runMs: o.runMs, setupMs: o.setupMs, downMs: o.downMs, goodCount: o.goodCount, scrapCount: o.scrapCount, metricsSinceMs: 0 },
|
|
215
217
|
planned
|
|
216
218
|
)
|
|
217
|
-
|
|
219
|
+
/* 세 비율은 근거가 없으면 없다 — 양쪽 다 없는 것도 재생산이다(계약 0.5.0). */
|
|
220
|
+
const near = (a?: number, b?: number) => (a == null && b == null) || (a != null && b != null && Math.abs(a - b) < 1e-6)
|
|
218
221
|
assert.ok(near(derived.availability, o.availability), `availability 재생산 (${derived.availability} vs ${o.availability})`)
|
|
219
222
|
assert.ok(near(derived.performance, o.performance), 'performance 재생산')
|
|
220
223
|
assert.ok(near(derived.quality, o.quality), 'quality 재생산')
|
|
@@ -327,7 +327,8 @@ test('이 겹의 어휘는 local 이다 — site·facility 로 되돌아가지
|
|
|
327
327
|
test('축의 두 낱말이 계약에 선언돼 있다 — 얹혀 다니는 필드가 아니다', async () => {
|
|
328
328
|
/* 예전에는 source 표식이 커널 계약에 없는 필드로 얹혀 다녔다 — 소비처가 any 로 읽고, 상태에 정말
|
|
329
329
|
실리는지 아무도 검사할 수 없었다. */
|
|
330
|
-
|
|
330
|
+
/* 속성 어휘는 계약에 있다 — 커널은 엔진만 내보낸다(2026-08-30 층 분리). */
|
|
331
|
+
const contract: any = await import('@operato/ops-contract')
|
|
331
332
|
const { model } = applyLocalDeclarations(
|
|
332
333
|
{ locations: [{ id: 'incoming-1', type: 'incoming' }] },
|
|
333
334
|
[{ target: 'location', id: 'incoming-1', properties: [{ id: 'contract.kW', value: '3500' }] }]
|
|
@@ -335,7 +336,7 @@ test('축의 두 낱말이 계약에 선언돼 있다 — 얹혀 다니는 필
|
|
|
335
336
|
const p = (model.locations[0] as any).properties[0]
|
|
336
337
|
/* 계약이 정한 두 값 외에는 쓰지 않는다. */
|
|
337
338
|
assert.ok(['reference', 'local'].includes(p.source), '표식은 reference|local 뿐이다')
|
|
338
|
-
assert.ok(
|
|
339
|
+
assert.ok(contract.EMS_PROPERTY, '계약을 실제로 읽고 있다')
|
|
339
340
|
})
|
|
340
341
|
|
|
341
342
|
test('옛 표식이 구워진 값을 원천 값으로 오인하지 않는다 — 표식의 종류를 따지지 않는다', () => {
|