@things-factory/headless-twin 10.0.4 → 10.0.6
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/index.d.ts +2 -0
- package/dist-server/engine/index.js +2 -0
- package/dist-server/engine/index.js.map +1 -1
- package/dist-server/engine/kpi-fold.d.ts +179 -0
- package/dist-server/engine/kpi-fold.js +270 -0
- package/dist-server/engine/kpi-fold.js.map +1 -0
- package/dist-server/engine/kpi-query.d.ts +156 -0
- package/dist-server/engine/kpi-query.js +287 -0
- package/dist-server/engine/kpi-query.js.map +1 -0
- package/dist-server/engine/twin-engine.d.ts +29 -2
- package/dist-server/engine/twin-engine.js +78 -21
- package/dist-server/engine/twin-engine.js.map +1 -1
- package/dist-server/engine/warm-start.d.ts +39 -0
- package/dist-server/engine/warm-start.js +37 -0
- package/dist-server/engine/warm-start.js.map +1 -0
- package/dist-server/index.js +8 -0
- package/dist-server/index.js.map +1 -1
- package/dist-server/service/twin-event/backfill-keys.d.ts +11 -0
- package/dist-server/service/twin-event/backfill-keys.js +63 -0
- package/dist-server/service/twin-event/backfill-keys.js.map +1 -0
- package/dist-server/service/twin-event/twin-event-keys.d.ts +35 -0
- package/dist-server/service/twin-event/twin-event-keys.js +95 -0
- package/dist-server/service/twin-event/twin-event-keys.js.map +1 -0
- package/dist-server/service/twin-event/twin-event-type.d.ts +6 -0
- package/dist-server/service/twin-event/twin-event-type.js +32 -0
- package/dist-server/service/twin-event/twin-event-type.js.map +1 -0
- package/dist-server/service/twin-event/twin-event.d.ts +5 -0
- package/dist-server/service/twin-event/twin-event.js +45 -0
- package/dist-server/service/twin-event/twin-event.js.map +1 -1
- package/dist-server/service/twin-journal/twin-journal-query.d.ts +43 -0
- package/dist-server/service/twin-journal/twin-journal-query.js +127 -0
- package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/server/engine/index.ts +2 -0
- package/server/engine/kpi-fold.ts +417 -0
- package/server/engine/kpi-query.ts +435 -0
- package/server/engine/twin-engine.ts +95 -28
- package/server/engine/warm-start.ts +53 -0
- package/server/index.ts +9 -0
- package/server/service/twin-event/backfill-keys.ts +72 -0
- package/server/service/twin-event/twin-event-keys.ts +102 -0
- package/server/service/twin-event/twin-event-type.ts +27 -0
- package/server/service/twin-event/twin-event.ts +48 -0
- package/server/service/twin-journal/twin-journal-query.ts +136 -2
- package/test/kpi-fold.test.ts +469 -0
- package/test/twin-event-keys.test.ts +108 -0
- package/test/warm-start.test.ts +78 -0
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 업무 KPI 폴드 검증 — 저널을 접어 시간창 지표를 만드는 규칙을 고정한다.
|
|
3
|
+
*
|
|
4
|
+
* 무엇을 지키는가:
|
|
5
|
+
* ① **성과는 완료 시점으로 센다** — 시작 시점으로 세면 진행 중인 것이 섞여 처리량이 부풀려진다.
|
|
6
|
+
* ② **셈에서 빠진 것을 밝힌다**(unpaired) — 시작 이벤트를 못 찾은 완료를 조용히 버리면 평균이 거짓이 된다.
|
|
7
|
+
* ③ **없는 것을 0 으로 꾸미지 않는다** — 저널이 비었으면 eventsSeen 0 으로 그 사실이 보인다.
|
|
8
|
+
* ④ 시간축은 eventTime 차분 — sim 클록 오프셋이 있어도 차분은 정확하다.
|
|
9
|
+
*/
|
|
10
|
+
import { test } from 'node:test'
|
|
11
|
+
import assert from 'node:assert/strict'
|
|
12
|
+
|
|
13
|
+
import { crossGroups, deltaP50, foldKpi, foldKpiBuckets, shiftWindow, type KpiEvent } from '../server/engine/kpi-fold.ts'
|
|
14
|
+
|
|
15
|
+
const T0 = Date.parse('2026-07-30T00:00:00Z')
|
|
16
|
+
const at = (sec: number) => new Date(T0 + sec * 1000).toISOString()
|
|
17
|
+
|
|
18
|
+
/** task.status 전이 한 줄. */
|
|
19
|
+
const task = (taskId: string, status: string, sec: number, resourceRef?: string): KpiEvent => ({
|
|
20
|
+
eventType: 'task.status',
|
|
21
|
+
eventTime: at(sec),
|
|
22
|
+
payload: { data: { taskId, status, resourceRef } }
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
/** 축을 가진 전이 한 줄 — kind·orderId·toNode 까지(실제 TaskStatusDelta 모양). */
|
|
26
|
+
const taskAt = (taskId: string, status: string, sec: number, facets: Record<string, unknown>): KpiEvent => ({
|
|
27
|
+
eventType: 'task.status',
|
|
28
|
+
eventTime: at(sec),
|
|
29
|
+
payload: { data: { taskId, status, ...facets } }
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const order = (orderId: string, status: string, sec: number): KpiEvent => ({
|
|
33
|
+
eventType: 'order.status',
|
|
34
|
+
eventTime: at(sec),
|
|
35
|
+
payload: { data: { orderId, status } }
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const WINDOW = { fromMs: T0, toMs: T0 + 3600 * 1000 } // 1시간
|
|
39
|
+
|
|
40
|
+
test('처리량: 창 안에 완료된 작업만 센다(진행 중은 세지 않는다)', () => {
|
|
41
|
+
const r = foldKpi(
|
|
42
|
+
[
|
|
43
|
+
task('t1', 'created', 0),
|
|
44
|
+
task('t1', 'in-progress', 10),
|
|
45
|
+
task('t1', 'completed', 30),
|
|
46
|
+
task('t2', 'created', 20),
|
|
47
|
+
task('t2', 'in-progress', 25) // 완료 없음 — 진행 중
|
|
48
|
+
],
|
|
49
|
+
WINDOW
|
|
50
|
+
)
|
|
51
|
+
assert.equal(r.throughput.tasks, 1)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('처리량: 창 밖에 완료된 것은 세지 않는다 — 창 경계는 완료 시각으로 판정', () => {
|
|
55
|
+
const r = foldKpi(
|
|
56
|
+
[task('t1', 'created', -100), task('t1', 'in-progress', -90), task('t1', 'completed', -10)],
|
|
57
|
+
WINDOW
|
|
58
|
+
)
|
|
59
|
+
assert.equal(r.throughput.tasks, 0)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('리드타임·작업시간·대기시간을 각각 낸다 — 셋은 다른 질문에 답한다', () => {
|
|
63
|
+
/* 생성 0s → 착수 10s → 완료 40s : 리드 40s, 작업 30s, 대기 10s */
|
|
64
|
+
const r = foldKpi([task('t1', 'created', 0), task('t1', 'in-progress', 10), task('t1', 'completed', 40)], WINDOW)
|
|
65
|
+
assert.equal(r.leadTime.avgMs, 40000)
|
|
66
|
+
assert.equal(r.workTime.avgMs, 30000)
|
|
67
|
+
assert.equal(r.waitTime.avgMs, 10000)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('분위수: p50·p90 을 낸다(작은 표본에서 과장하지 않는다)', () => {
|
|
71
|
+
const events: KpiEvent[] = []
|
|
72
|
+
/* 완료 소요를 1..10초로 열 건 */
|
|
73
|
+
for (let i = 1; i <= 10; i++) {
|
|
74
|
+
events.push(task(`t${i}`, 'created', 100 + i * 20))
|
|
75
|
+
events.push(task(`t${i}`, 'in-progress', 100 + i * 20))
|
|
76
|
+
events.push(task(`t${i}`, 'completed', 100 + i * 20 + i))
|
|
77
|
+
}
|
|
78
|
+
const r = foldKpi(events, WINDOW)
|
|
79
|
+
assert.equal(r.workTime.count, 10)
|
|
80
|
+
assert.equal(r.workTime.p50Ms, 6000)
|
|
81
|
+
assert.equal(r.workTime.p90Ms, 10000)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('시작 이벤트를 못 찾은 완료는 unpaired 로 밝힌다 — 평균에서 조용히 빠지지 않게', () => {
|
|
85
|
+
const r = foldKpi([task('t1', 'completed', 30)], WINDOW)
|
|
86
|
+
assert.equal(r.throughput.tasks, 1, '완료는 센다')
|
|
87
|
+
assert.equal(r.leadTime.count, 0, '리드타임은 계산할 수 없다')
|
|
88
|
+
assert.equal(r.unpaired.leadTime, 1)
|
|
89
|
+
assert.equal(r.unpaired.workTime, 1)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('창보다 앞선 시작도 짝지어진다 — 호출부가 앞 구간을 넉넉히 넘기면 리드타임이 살아난다', () => {
|
|
93
|
+
const r = foldKpi([task('t1', 'created', -600), task('t1', 'in-progress', -300), task('t1', 'completed', 60)], WINDOW)
|
|
94
|
+
assert.equal(r.leadTime.avgMs, 660000)
|
|
95
|
+
assert.equal(r.unpaired.leadTime, 0)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('자원 점유: 작업에 붙어 있던 시간의 합과 창 대비 비율', () => {
|
|
99
|
+
const r = foldKpi(
|
|
100
|
+
[
|
|
101
|
+
task('t1', 'created', 0),
|
|
102
|
+
task('t1', 'in-progress', 0, 'fk-1'),
|
|
103
|
+
task('t1', 'completed', 1800, 'fk-1'), // 30분
|
|
104
|
+
task('t2', 'created', 0),
|
|
105
|
+
task('t2', 'in-progress', 0, 'fk-2'),
|
|
106
|
+
task('t2', 'completed', 900, 'fk-2') // 15분
|
|
107
|
+
],
|
|
108
|
+
WINDOW
|
|
109
|
+
)
|
|
110
|
+
assert.deepEqual(
|
|
111
|
+
r.utilization.map(u => [u.resourceId, u.busyMs]),
|
|
112
|
+
[
|
|
113
|
+
['fk-1', 1800000],
|
|
114
|
+
['fk-2', 900000]
|
|
115
|
+
],
|
|
116
|
+
'점유가 큰 자원부터'
|
|
117
|
+
)
|
|
118
|
+
assert.equal(Math.round(r.utilization[0].ratio * 100), 50, '1시간 창에서 30분 = 50%')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('오더 완료: 도메인 어휘를 넓게 받는다(completed/fulfilled/done)', () => {
|
|
122
|
+
const r = foldKpi([order('o1', 'completed', 10), order('o2', 'fulfilled', 20), order('o3', 'in-progress', 30)], WINDOW)
|
|
123
|
+
assert.equal(r.throughput.orders, 2)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('저널이 비면 0 이 아니라 "본 이벤트 없음" 으로 드러난다', () => {
|
|
127
|
+
const r = foldKpi([], WINDOW)
|
|
128
|
+
assert.equal(r.eventsSeen, 0)
|
|
129
|
+
assert.equal(r.eventsInWindow, 0)
|
|
130
|
+
assert.equal(r.throughput.tasks, 0)
|
|
131
|
+
assert.equal(r.leadTime.count, 0)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('시각이 없거나 깨진 줄은 버린다 — 0 으로 세지 않는다', () => {
|
|
135
|
+
const r = foldKpi(
|
|
136
|
+
[
|
|
137
|
+
{ eventType: 'task.status', payload: { data: { taskId: 'x', status: 'completed' } } },
|
|
138
|
+
{ eventType: 'task.status', eventTime: 'not-a-date', payload: { data: { taskId: 'y', status: 'completed' } } }
|
|
139
|
+
],
|
|
140
|
+
WINDOW
|
|
141
|
+
)
|
|
142
|
+
assert.equal(r.eventsSeen, 0)
|
|
143
|
+
assert.equal(r.throughput.tasks, 0)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('payload 가 envelope 없이 델타만 와도 읽는다 — 저장 형태 차이에 걸리지 않게', () => {
|
|
147
|
+
const r = foldKpi(
|
|
148
|
+
[
|
|
149
|
+
{ eventType: 'task.status', eventTime: at(0), payload: { taskId: 't1', status: 'in-progress' } },
|
|
150
|
+
{ eventType: 'task.status', eventTime: at(20), payload: { taskId: 't1', status: 'completed' } }
|
|
151
|
+
],
|
|
152
|
+
WINDOW
|
|
153
|
+
)
|
|
154
|
+
assert.equal(r.workTime.avgMs, 20000)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('완료가 재전송되면 한 건으로 센다 — 처리량·자원 점유가 부풀려지지 않게', () => {
|
|
158
|
+
/* 공정 타임라인과 KPI 가 같은 저널을 다르게 짝지어 다른 답을 냈다(타임라인 1건 / KPI 2건).
|
|
159
|
+
* 두 구현이 있었기 때문이고, 규칙은 하나여야 한다: **완료는 작업당 하나, 나중 것이 사실.** */
|
|
160
|
+
const r = foldKpi(
|
|
161
|
+
[
|
|
162
|
+
task('t1', 'created', 0),
|
|
163
|
+
task('t1', 'in-progress', 10, 'fk-1'),
|
|
164
|
+
task('t1', 'completed', 40, 'fk-1'),
|
|
165
|
+
task('t1', 'completed', 45, 'fk-1') // 재전송
|
|
166
|
+
],
|
|
167
|
+
WINDOW
|
|
168
|
+
)
|
|
169
|
+
assert.equal(r.throughput.tasks, 1, '한 건이다')
|
|
170
|
+
assert.equal(r.workTime.count, 1)
|
|
171
|
+
assert.equal(r.workTime.avgMs, 35000, '나중 완료(45s) 기준')
|
|
172
|
+
assert.deepEqual(
|
|
173
|
+
r.utilization.map(u => [u.resourceId, u.busyMs]),
|
|
174
|
+
[['fk-1', 35000]],
|
|
175
|
+
'점유도 한 번만 더한다(예전엔 65초로 부풀려졌다)'
|
|
176
|
+
)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('같은 전이가 두 번 와도 착수는 처음 것, 생성은 마지막 것 — 재시도에 흔들리지 않게', () => {
|
|
180
|
+
const r = foldKpi(
|
|
181
|
+
[
|
|
182
|
+
task('t1', 'created', 0),
|
|
183
|
+
task('t1', 'in-progress', 10),
|
|
184
|
+
task('t1', 'in-progress', 15), // 재전송
|
|
185
|
+
task('t1', 'completed', 40)
|
|
186
|
+
],
|
|
187
|
+
WINDOW
|
|
188
|
+
)
|
|
189
|
+
assert.equal(r.workTime.avgMs, 30000, '처음 착수(10s) 기준')
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
/* ── 구간 쪼개기(추세) ─────────────────────────────────────────────────────
|
|
193
|
+
* 화면의 추세선이 여기서 나온다. 조회는 한 번이고 창만 바꿔 다시 접는다.
|
|
194
|
+
*/
|
|
195
|
+
test('구간 쪼개기: N 등분해 구간별 처리량을 낸다', () => {
|
|
196
|
+
/* 1시간 창을 4등분(15분씩). 각 구간에 한 건씩 완료되게 놓는다. */
|
|
197
|
+
const events: KpiEvent[] = []
|
|
198
|
+
for (let i = 0; i < 4; i++) {
|
|
199
|
+
const sec = i * 900 + 100 // 100s, 1000s, 1900s, 2800s
|
|
200
|
+
events.push(task(`t${i}`, 'created', sec - 30), task(`t${i}`, 'in-progress', sec - 20), task(`t${i}`, 'completed', sec))
|
|
201
|
+
}
|
|
202
|
+
const buckets = foldKpiBuckets(events, WINDOW, 4)
|
|
203
|
+
assert.equal(buckets.length, 4)
|
|
204
|
+
assert.deepEqual(buckets.map(b => b.tasks), [1, 1, 1, 1])
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('구간 쪼개기: 구간 합이 전체 창의 처리량과 같다 — 경계에서 새거나 겹치지 않는다', () => {
|
|
208
|
+
const events: KpiEvent[] = []
|
|
209
|
+
for (let i = 0; i < 20; i++) {
|
|
210
|
+
const sec = 50 + i * 170
|
|
211
|
+
events.push(task(`t${i}`, 'created', sec - 10), task(`t${i}`, 'in-progress', sec - 5), task(`t${i}`, 'completed', sec))
|
|
212
|
+
}
|
|
213
|
+
const whole = foldKpi(events, WINDOW)
|
|
214
|
+
const buckets = foldKpiBuckets(events, WINDOW, 6)
|
|
215
|
+
const sum = buckets.reduce((a, b) => a + b.tasks, 0)
|
|
216
|
+
assert.equal(sum, whole.throughput.tasks)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
test('구간 쪼개기: 마지막 구간이 창의 끝까지 덮는다 — 나눗셈 오차로 마지막 완료가 빠지지 않게', () => {
|
|
220
|
+
/* 3등분(나누어떨어지지 않는 창) + 창의 마지막 순간에 완료 */
|
|
221
|
+
const win = { fromMs: WINDOW.fromMs, toMs: WINDOW.fromMs + 1000 * 1000 } // 1000초
|
|
222
|
+
const events = [task('t1', 'created', 0), task('t1', 'in-progress', 10), task('t1', 'completed', 1000)]
|
|
223
|
+
const buckets = foldKpiBuckets(events, win, 3)
|
|
224
|
+
assert.equal(buckets[buckets.length - 1].toMs, win.toMs)
|
|
225
|
+
assert.equal(buckets.reduce((a, b) => a + b.tasks, 0), 1)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
test('구간 쪼개기: 1 이하면 쪼개지 않는다(추세 없음)', () => {
|
|
229
|
+
assert.deepEqual(foldKpiBuckets([], WINDOW, 1), [])
|
|
230
|
+
assert.deepEqual(foldKpiBuckets([], WINDOW, 0), [])
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
test('구간 쪼개기: 구간별 작업시간 중앙값을 낸다 — "느려지고 있는가" 를 본다', () => {
|
|
234
|
+
const events: KpiEvent[] = [
|
|
235
|
+
/* 앞 구간: 10초 작업 */
|
|
236
|
+
task('a', 'created', 100), task('a', 'in-progress', 100), task('a', 'completed', 110),
|
|
237
|
+
/* 뒤 구간: 40초 작업 */
|
|
238
|
+
task('b', 'created', 2000), task('b', 'in-progress', 2000), task('b', 'completed', 2040)
|
|
239
|
+
]
|
|
240
|
+
const buckets = foldKpiBuckets(events, WINDOW, 2)
|
|
241
|
+
assert.equal(buckets[0].workP50Ms, 10000)
|
|
242
|
+
assert.equal(buckets[1].workP50Ms, 40000)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
/* ── 관점 축(groupBy) ───────────────────────────────────────────────────────
|
|
246
|
+
* 왜 계산 층에 있는가: 고정된 한 렌즈만 낼 수 있으면 "구역별로는?" 이라는 질문에 화면도 AI 도 답할 수
|
|
247
|
+
* 없다. **사실 계산이 다각도를 못 내면 AI 도 못 낸다** — 그래서 축은 폴드의 능력이어야 한다.
|
|
248
|
+
*/
|
|
249
|
+
const three = (id: string, sec: number, facets: Record<string, unknown>): KpiEvent[] => [
|
|
250
|
+
taskAt(id, 'created', sec - 20, facets),
|
|
251
|
+
taskAt(id, 'in-progress', sec - 10, facets),
|
|
252
|
+
taskAt(id, 'completed', sec, facets)
|
|
253
|
+
]
|
|
254
|
+
|
|
255
|
+
test('축 없이는 groups 를 내지 않는다 — 묻지 않은 것을 실어 보내지 않는다', () => {
|
|
256
|
+
const r = foldKpi(three('t1', 100, { kind: 'move' }), WINDOW)
|
|
257
|
+
assert.equal(r.groups, undefined)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
test('작업 종류별로 쪼갠다 — 전체 통계와 같은 규칙으로', () => {
|
|
261
|
+
const events = [
|
|
262
|
+
...three('a', 100, { kind: 'move' }),
|
|
263
|
+
...three('b', 200, { kind: 'move' }),
|
|
264
|
+
...three('c', 300, { kind: 'pick' })
|
|
265
|
+
]
|
|
266
|
+
const r = foldKpi(events, WINDOW, { groupBy: 'taskKind' })
|
|
267
|
+
assert.equal(r.groups?.by, 'taskKind')
|
|
268
|
+
assert.deepEqual(
|
|
269
|
+
r.groups?.items.map(g => [g.key, g.tasks]),
|
|
270
|
+
[
|
|
271
|
+
['move', 2],
|
|
272
|
+
['pick', 1]
|
|
273
|
+
],
|
|
274
|
+
'건수 큰 축부터'
|
|
275
|
+
)
|
|
276
|
+
const move = r.groups!.items[0]
|
|
277
|
+
assert.equal(move.workTime.avgMs, 10000, '착수→완료 10초')
|
|
278
|
+
assert.equal(move.leadTime.avgMs, 20000, '생성→완료 20초')
|
|
279
|
+
assert.equal(move.busyMs, 20000, '두 건 × 10초')
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
test('축의 합이 전체 처리량과 같다 — 어느 축에서도 건이 새지 않는다', () => {
|
|
283
|
+
const events = [
|
|
284
|
+
...three('a', 100, { kind: 'move', orderId: 'o1', toNode: 'dock-1', resourceRef: 'fk-1' }),
|
|
285
|
+
...three('b', 200, { kind: 'pick', orderId: 'o1', toNode: 'rack-1', resourceRef: 'fk-2' }),
|
|
286
|
+
...three('c', 300, { kind: 'pick', orderId: 'o2', toNode: 'rack-1' })
|
|
287
|
+
]
|
|
288
|
+
const whole = foldKpi(events, WINDOW)
|
|
289
|
+
for (const by of ['resource', 'taskKind', 'node', 'order'] as const) {
|
|
290
|
+
const r = foldKpi(events, WINDOW, { groupBy: by })
|
|
291
|
+
const sum = r.groups!.items.reduce((a, g) => a + g.tasks, 0)
|
|
292
|
+
assert.equal(sum, whole.throughput.tasks, `${by} 축의 합`)
|
|
293
|
+
}
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
test('축의 값이 없던 건은 unknown 으로 모인다 — 조용히 버리지 않는다', () => {
|
|
297
|
+
const events = [...three('a', 100, { kind: 'move', resourceRef: 'fk-1' }), ...three('b', 200, { kind: 'move' })]
|
|
298
|
+
const r = foldKpi(events, WINDOW, { groupBy: 'resource' })
|
|
299
|
+
assert.deepEqual(r.groups?.items.map(g => g.key).sort(), ['fk-1', 'unknown'])
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
test('축의 값은 다른 전이에서 와도 읽는다 — 완료 이벤트에 kind 가 없어도 축이 비지 않는다', () => {
|
|
303
|
+
/* 생성에만 kind 가 실리는 구현이 실제로 있다. 완료 줄만 보면 축이 통째로 unknown 이 된다. */
|
|
304
|
+
const events = [
|
|
305
|
+
taskAt('a', 'created', 80, { kind: 'putaway', orderId: 'o9' }),
|
|
306
|
+
taskAt('a', 'in-progress', 90, {}),
|
|
307
|
+
taskAt('a', 'completed', 100, {})
|
|
308
|
+
]
|
|
309
|
+
const r = foldKpi(events, WINDOW, { groupBy: 'taskKind' })
|
|
310
|
+
assert.deepEqual(r.groups?.items.map(g => g.key), ['putaway'])
|
|
311
|
+
const byOrder = foldKpi(events, WINDOW, { groupBy: 'order' })
|
|
312
|
+
assert.deepEqual(byOrder.groups?.items.map(g => g.key), ['o9'])
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
test('노드 축은 가장 최근 도착지 — 진행에 따라 바뀌는 값은 마지막 것이 사실', () => {
|
|
316
|
+
const events = [
|
|
317
|
+
taskAt('a', 'created', 80, { toNode: 'dock-1' }),
|
|
318
|
+
taskAt('a', 'in-progress', 90, { fromNode: 'dock-1', toNode: 'rack-7' }),
|
|
319
|
+
taskAt('a', 'completed', 100, { fromNode: 'dock-1', toNode: 'rack-7' })
|
|
320
|
+
]
|
|
321
|
+
const r = foldKpi(events, WINDOW, { groupBy: 'node' })
|
|
322
|
+
assert.deepEqual(r.groups?.items.map(g => g.key), ['rack-7'])
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
test('구역 축은 노드→구역 지도를 받아야 낸다 — 지도가 없으면 노드 id 로 대체하지 않는다', () => {
|
|
326
|
+
const events = [...three('a', 100, { toNode: 'rack-1' }), ...three('b', 200, { toNode: 'rack-2' })]
|
|
327
|
+
const blind = foldKpi(events, WINDOW, { groupBy: 'area' })
|
|
328
|
+
assert.deepEqual(blind.groups?.items.map(g => [g.key, g.tasks]), [['unknown', 2]], '지도 없으면 unknown 하나')
|
|
329
|
+
|
|
330
|
+
const mapped = foldKpi(events, WINDOW, { groupBy: 'area', nodeArea: { 'rack-1': 'zone-a', 'rack-2': 'zone-a' } })
|
|
331
|
+
assert.deepEqual(mapped.groups?.items.map(g => [g.key, g.tasks]), [['zone-a', 2]], '두 노드가 한 구역으로 모인다')
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
test('축이 많으면 잘라내고 잘린 축의 수와 그 안의 건수를 함께 알린다 — 조용한 절단 금지', () => {
|
|
335
|
+
/* 축마다 건수를 다르게 둔다: 축의 수만 알리면 "그 외 20개" 가 20건인지 60건인지 알 수 없다. */
|
|
336
|
+
const events: KpiEvent[] = []
|
|
337
|
+
let t = 0
|
|
338
|
+
for (let i = 0; i < 25; i++) {
|
|
339
|
+
const n = i < 5 ? 3 : 1 // 앞 5개 축은 3건씩, 나머지 20개 축은 1건씩
|
|
340
|
+
for (let k = 0; k < n; k++) events.push(...three(`t${t++}`, 100 + t * 10, { orderId: `o${i}` }))
|
|
341
|
+
}
|
|
342
|
+
const r = foldKpi(events, WINDOW, { groupBy: 'order', groupLimit: 5 })
|
|
343
|
+
assert.equal(r.groups?.items.length, 5)
|
|
344
|
+
assert.equal(r.groups?.truncated, 20, '잘린 축의 수')
|
|
345
|
+
assert.equal(r.groups?.truncatedTasks, 20, '잘린 축들이 안고 있던 건수')
|
|
346
|
+
const whole = foldKpi(events, WINDOW)
|
|
347
|
+
assert.equal(
|
|
348
|
+
r.groups!.items.reduce((a, g) => a + g.tasks, 0) + r.groups!.truncatedTasks!,
|
|
349
|
+
whole.throughput.tasks,
|
|
350
|
+
'보여준 건수 + 잘린 건수 = 전체(축의 수가 아니라 건수로 맞는다)'
|
|
351
|
+
)
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
test('짝을 못 찾은 건도 축에서 센다 — 건수는 세고 소요만 비운다', () => {
|
|
355
|
+
const r = foldKpi([taskAt('a', 'completed', 100, { kind: 'move' })], WINDOW, { groupBy: 'taskKind' })
|
|
356
|
+
const g = r.groups!.items[0]
|
|
357
|
+
assert.equal(g.tasks, 1)
|
|
358
|
+
assert.equal(g.workTime.count, 0)
|
|
359
|
+
assert.equal(g.busyMs, 0)
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
/* ── 시간 비교 ──────────────────────────────────────────────────────────────
|
|
363
|
+
* "29초" 만으로는 좋아졌는지 나빠졌는지 알 수 없다. 창을 옮겨 한 번 더 접는 규칙을 고정한다.
|
|
364
|
+
*/
|
|
365
|
+
test('직전 구간: 같은 길이로 바로 앞 — 경계가 맞물린다(겹치거나 벌어지지 않게)', () => {
|
|
366
|
+
const w = shiftWindow(WINDOW.fromMs, WINDOW.toMs, 'previous')
|
|
367
|
+
assert.equal(w.toMs, WINDOW.fromMs, '앞 구간의 끝 = 현재 구간의 시작')
|
|
368
|
+
assert.equal(w.toMs - w.fromMs, WINDOW.toMs - WINDOW.fromMs, '길이가 같다')
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
test('어제 같은 시간: 정확히 24시간 전 같은 자리', () => {
|
|
372
|
+
const w = shiftWindow(WINDOW.fromMs, WINDOW.toMs, 'yesterday')
|
|
373
|
+
assert.equal(WINDOW.fromMs - w.fromMs, 24 * 60 * 60 * 1000)
|
|
374
|
+
assert.equal(w.toMs - w.fromMs, WINDOW.toMs - WINDOW.fromMs)
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
test('중앙값 차이: 현재 − 비교구간', () => {
|
|
378
|
+
const now = { count: 5, p50Ms: 40000, p90Ms: 0, avgMs: 0 }
|
|
379
|
+
const prev = { count: 5, p50Ms: 30000, p90Ms: 0, avgMs: 0 }
|
|
380
|
+
assert.equal(deltaP50(now, prev), 10000, '10초 느려졌다')
|
|
381
|
+
assert.equal(deltaP50(prev, now), -10000, '부호로 방향을 말한다(좋고 나쁨은 판단하지 않는다)')
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
test('중앙값 차이: 한쪽이 결측이면 차이는 없다 — 0 으로 내면 "변화 없음" 이라는 거짓이 된다', () => {
|
|
385
|
+
const measured = { count: 5, p50Ms: 40000, p90Ms: 0, avgMs: 0 }
|
|
386
|
+
const missing = { count: 0, p50Ms: 0, p90Ms: 0, avgMs: 0 }
|
|
387
|
+
assert.equal(deltaP50(measured, missing), undefined)
|
|
388
|
+
assert.equal(deltaP50(missing, measured), undefined)
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
test('비교: 옮긴 창으로 접으면 앞 구간의 성과가 나온다 — 같은 폴드, 창만 다르다', () => {
|
|
392
|
+
/* 현재 구간에 2건, 직전 구간에 1건. */
|
|
393
|
+
const events = [
|
|
394
|
+
...three('now-1', 100, { kind: 'move' }),
|
|
395
|
+
...three('now-2', 200, { kind: 'move' }),
|
|
396
|
+
...three('prev-1', -1800, { kind: 'move' })
|
|
397
|
+
]
|
|
398
|
+
const now = foldKpi(events, WINDOW)
|
|
399
|
+
const prev = foldKpi(events, shiftWindow(WINDOW.fromMs, WINDOW.toMs, 'previous'))
|
|
400
|
+
assert.equal(now.throughput.tasks, 2)
|
|
401
|
+
assert.equal(prev.throughput.tasks, 1)
|
|
402
|
+
assert.equal(now.throughput.tasks - prev.throughput.tasks, 1, '처리량이 1건 늘었다')
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
test('창 앞 구간만 읽었으면 "측정된 기록 있음" 이 아니다 — lookback 을 성과로 착각하지 않게', () => {
|
|
406
|
+
/* 호출부는 창보다 앞까지 읽어 넘긴다(완료 작업의 시작이 창 밖일 수 있어서). 그 앞 구간 기록만
|
|
407
|
+
* 있는데 eventsSeen 으로 판단하면 "0건 처리" 라고 말하게 된다 — 실제로 가능했던 오류다. */
|
|
408
|
+
const before = [task('t1', 'created', -1200), task('t1', 'in-progress', -1100), task('t1', 'completed', -1000)]
|
|
409
|
+
const r = foldKpi(before, WINDOW)
|
|
410
|
+
assert.equal(r.eventsSeen, 3, '넘겨받은 것은 3줄')
|
|
411
|
+
assert.equal(r.eventsInWindow, 0, '창 안에는 아무것도 없다 → "기록 없음"')
|
|
412
|
+
assert.equal(r.throughput.tasks, 0)
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
test('창 안 기록만 센다 — 앞 구간이 섞여도 창 안 개수는 정확하다', () => {
|
|
416
|
+
const events = [
|
|
417
|
+
task('old', 'completed', -500), // 창 밖(앞)
|
|
418
|
+
...three('in-1', 100, { kind: 'move' }), // 창 안 3줄
|
|
419
|
+
task('late', 'completed', 4000) // 창 밖(뒤)
|
|
420
|
+
]
|
|
421
|
+
const r = foldKpi(events, WINDOW)
|
|
422
|
+
assert.equal(r.eventsSeen, 5)
|
|
423
|
+
assert.equal(r.eventsInWindow, 3)
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
/* ── 관점 × 비교 교차 ───────────────────────────────────────────────────────
|
|
427
|
+
* 전체는 비슷한데 **한 구역만 나빠진** 경우를 전체 숫자로는 절대 못 본다. 그 교차가 운영에서 가장
|
|
428
|
+
* 값 있는 질문이다.
|
|
429
|
+
*/
|
|
430
|
+
const g = (key: string, tasks: number, workMs = 10000) => {
|
|
431
|
+
const events = []
|
|
432
|
+
for (let i = 0; i < tasks; i++) {
|
|
433
|
+
const sec = 100 + i * 30
|
|
434
|
+
events.push(
|
|
435
|
+
taskAt(`${key}-${i}`, 'created', sec - 20, { kind: key }),
|
|
436
|
+
taskAt(`${key}-${i}`, 'in-progress', sec, { kind: key }),
|
|
437
|
+
taskAt(`${key}-${i}`, 'completed', sec + workMs / 1000, { kind: key })
|
|
438
|
+
)
|
|
439
|
+
}
|
|
440
|
+
return foldKpi(events, WINDOW, { groupBy: 'taskKind' }).groups!.items[0]
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
test('교차: 두 구간에 모두 있는 축은 delta 를 낸다', () => {
|
|
444
|
+
const c = crossGroups([g('pick', 10, 20000)], [g('pick', 7, 10000)])
|
|
445
|
+
const it = c.items[0]
|
|
446
|
+
assert.equal(it.prevTasks, 7)
|
|
447
|
+
assert.equal(it.deltaTasks, 3)
|
|
448
|
+
assert.equal(it.deltaWorkP50Ms, 10000, '작업시간이 10초 늘었다')
|
|
449
|
+
assert.deepEqual(c.disappeared, [])
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
test('교차: 지금만 있는 축은 isNew — delta 를 건수와 같게 적으면 "늘었다" 로 오독된다', () => {
|
|
453
|
+
const c = crossGroups([g('putaway', 5)], [g('pick', 5)])
|
|
454
|
+
const nw = c.items.find(x => x.key === 'putaway')!
|
|
455
|
+
assert.equal(nw.isNew, true)
|
|
456
|
+
assert.equal(nw.deltaTasks, undefined)
|
|
457
|
+
assert.equal(nw.prevTasks, undefined)
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
test('교차: 사라진 축을 따로 알린다 — 멈춘 구역이 표에서 조용히 빠지지 않게', () => {
|
|
461
|
+
const c = crossGroups([g('pick', 5)], [g('pick', 5), g('putaway', 12)])
|
|
462
|
+
assert.deepEqual(c.disappeared, [{ key: 'putaway', tasks: 12 }])
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
test('교차: 비교 구간이 비면 전부 isNew 로 — 없던 것과 줄어든 것을 섞지 않는다', () => {
|
|
466
|
+
const c = crossGroups([g('pick', 3), g('putaway', 4)], [])
|
|
467
|
+
assert.equal(c.items.every(x => x.isNew === true), true)
|
|
468
|
+
assert.deepEqual(c.disappeared, [])
|
|
469
|
+
})
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ★ 저널 검색 키 승격 검증 — payload(JSON) 안의 값을 인덱스 가능한 실컬럼으로 꺼내는 규칙.
|
|
3
|
+
*
|
|
4
|
+
* 이 규칙이 틀리면 "저널에는 있는데 검색으로는 안 나오는 이벤트" 가 생긴다. 화면에서는 그냥
|
|
5
|
+
* **없는 것으로 보인다** — 저널에서 가장 나쁜 결함이라 규칙을 테스트로 고정한다.
|
|
6
|
+
*/
|
|
7
|
+
import { test } from 'node:test'
|
|
8
|
+
import assert from 'node:assert/strict'
|
|
9
|
+
|
|
10
|
+
import { bizStepOf, epcOf, orderOf, locationOf, moverOf, twinEventKeys } from '../server/service/twin-event/twin-event-keys.ts'
|
|
11
|
+
|
|
12
|
+
/** EPCIS ObjectEvent 한 건(입고) — 실제 인제스트가 싣는 모양. */
|
|
13
|
+
const EPCIS_OBJECT = {
|
|
14
|
+
eventType: 'epcis.ObjectEvent',
|
|
15
|
+
eventTime: '2026-07-31T02:10:00.000Z',
|
|
16
|
+
data: {
|
|
17
|
+
bizStep: 'urn:epcglobal:cbv:bizstep:receiving',
|
|
18
|
+
disposition: 'urn:epcglobal:cbv:disp:in_progress',
|
|
19
|
+
epcList: ['urn:epc:id:sgtin:0614141.107346.2018'],
|
|
20
|
+
readPoint: { id: 'urn:epc:id:sgln:0614141.00777.DOCK-3' },
|
|
21
|
+
bizLocation: { id: 'urn:epc:id:sgln:0614141.00888.0' },
|
|
22
|
+
bizTransactionList: [{ type: 'urn:epcglobal:cbv:btt:po', bizTransaction: 'urn:epc:id:gdti:0614141.402.2' }]
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test('EPCIS 이벤트에서 네 축이 모두 뽑힌다', () => {
|
|
27
|
+
const k = twinEventKeys(EPCIS_OBJECT)
|
|
28
|
+
assert.equal(k.bizStep, 'receiving')
|
|
29
|
+
assert.equal(k.epc, 'urn:epc:id:sgtin:0614141.107346.2018')
|
|
30
|
+
assert.equal(k.orderId, 'urn:epc:id:gdti:0614141.402.2')
|
|
31
|
+
assert.equal(k.locationId, 'urn:epc:id:sgln:0614141.00777.DOCK-3')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('식별자는 끝마디가 아니라 전체를 저장한다 — 부분일치 검색이 양방향으로 성립해야 한다', () => {
|
|
35
|
+
const k = twinEventKeys(EPCIS_OBJECT)
|
|
36
|
+
/* 화면은 '2018' 로 축약해 보여주지만 색인은 원본을 갖는다. contains 검색이라
|
|
37
|
+
* 끝마디로도 URN 전체로도 찾힌다 — 끝마디만 저장하면 URN 경로가 사라진다. */
|
|
38
|
+
assert.ok(k.epc!.includes('2018'), '끝마디로 찾을 수 있어야 한다')
|
|
39
|
+
assert.ok(k.epc!.startsWith('urn:epc:id:sgtin'), 'URN 전체로도 찾을 수 있어야 한다')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('readPoint 가 없으면 bizLocation 으로 내려간다', () => {
|
|
43
|
+
const ev = { ...EPCIS_OBJECT, data: { ...EPCIS_OBJECT.data, readPoint: undefined } }
|
|
44
|
+
assert.equal(locationOf(ev), 'urn:epc:id:sgln:0614141.00888.0')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('bizStep 이 없으면 이벤트 타입에서 유추한다 — 운영 델타도 단계를 말할 수 있어야 한다', () => {
|
|
48
|
+
assert.equal(bizStepOf({ eventType: 'task.status', data: { task: 't1' } }), 'task.status')
|
|
49
|
+
assert.equal(bizStepOf({ eventType: 'epcis.AggregationEvent', data: {} }), 'AggregationEvent')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('집합 이벤트는 parentID 를, 수량 이벤트는 epcClass 를 품목으로 쓴다', () => {
|
|
53
|
+
assert.equal(epcOf({ data: { parentID: 'urn:epc:id:sscc:0614141.1234567890' } }), 'urn:epc:id:sscc:0614141.1234567890')
|
|
54
|
+
assert.equal(epcOf({ data: { quantityList: [{ epcClass: 'urn:epc:idpat:sgtin:0614141.107346.*', quantity: 40 }] } }), 'urn:epc:idpat:sgtin:0614141.107346.*')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('운영 델타의 오더는 bizTransactionList 가 아니라 order 필드에 있다', () => {
|
|
58
|
+
assert.equal(orderOf({ eventType: 'order.status', data: { order: 'SO-10021', status: 'released' } }), 'SO-10021')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('없는 값은 빈 문자열이 아니라 undefined — 결측과 빈값을 섞지 않는다', () => {
|
|
62
|
+
const k = twinEventKeys({ eventType: 'equipment.status', data: { equipment: 'fork-1', status: 'idle' } })
|
|
63
|
+
assert.equal(k.epc, undefined)
|
|
64
|
+
assert.equal(k.orderId, undefined)
|
|
65
|
+
assert.equal(k.locationId, undefined)
|
|
66
|
+
assert.equal(k.moverId, undefined)
|
|
67
|
+
/* bizStep 은 이벤트 타입에서 유추되므로 남는다 — 이건 결측이 아니다. */
|
|
68
|
+
assert.equal(k.bizStep, 'equipment.status')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('설비·무버 축 — 운영 델타의 moverId 를 뽑는다', () => {
|
|
72
|
+
const ev = { eventType: 'equipment.status', data: { moverId: 'fork-3', status: 'down', location: 'rack-1' } }
|
|
73
|
+
assert.equal(moverOf(ev), 'fork-3')
|
|
74
|
+
const k = twinEventKeys(ev)
|
|
75
|
+
assert.equal(k.moverId, 'fork-3')
|
|
76
|
+
/* 운영 델타는 readPoint/bizLocation 이 없다 — 평범한 location 을 위치로 받는다.
|
|
77
|
+
* 빠뜨리면 "이 설비가 어디서 무엇을 했나" 가 위치 축에서 통째로 사라진다. */
|
|
78
|
+
assert.equal(k.locationId, 'rack-1')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('EPCIS 의 readPoint 는 운영 델타의 location 보다 우선한다', () => {
|
|
82
|
+
assert.equal(locationOf({ data: { readPoint: { id: 'DOCK-1' }, location: 'rack-9' } }), 'DOCK-1')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('payload 가 data 로 감싸이지 않은 모양도 받는다', () => {
|
|
86
|
+
assert.equal(epcOf({ epcList: ['urn:epc:id:sgtin:1.2.3'] }), 'urn:epc:id:sgtin:1.2.3')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('상한을 넘는 값은 조용히 사라지지 않는다 — 잘리되 경고가 남는다', () => {
|
|
90
|
+
const long = 'urn:epc:id:sgtin:' + 'x'.repeat(400)
|
|
91
|
+
const warned: string[] = []
|
|
92
|
+
const orig = console.warn
|
|
93
|
+
console.warn = (...a: unknown[]) => void warned.push(String(a[0]))
|
|
94
|
+
try {
|
|
95
|
+
const k = twinEventKeys({ data: { epcList: [long] } })
|
|
96
|
+
assert.equal(k.epc!.length, 255)
|
|
97
|
+
assert.equal(warned.length, 1, '잘렸다는 사실이 어딘가에는 남아야 한다')
|
|
98
|
+
assert.ok(warned[0].includes('epc'))
|
|
99
|
+
} finally {
|
|
100
|
+
console.warn = orig
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('빈 봉투에도 터지지 않는다', () => {
|
|
105
|
+
const empty = { bizStep: undefined, epc: undefined, orderId: undefined, locationId: undefined, moverId: undefined }
|
|
106
|
+
assert.deepEqual(twinEventKeys(undefined), empty)
|
|
107
|
+
assert.deepEqual(twinEventKeys({}), empty)
|
|
108
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ★ 웜스타트 판정 검증 — 재기동하는 커널에 직전 관측 상태를 심을지.
|
|
3
|
+
*
|
|
4
|
+
* 이 판정은 **양쪽으로 조용히 틀린다**: 심어야 할 때 안 심으면 있는 재고가 0 으로 보이고
|
|
5
|
+
* (2026-07-31 hatiolab-wms — 저널에 입고 540·출고 94 인데 재고 화면이 비어 있었다),
|
|
6
|
+
* 벤치에 심으면 용량 측정이 오염된다. 둘 다 화면에는 아무 이상이 없어 보인다.
|
|
7
|
+
*/
|
|
8
|
+
import { test } from 'node:test'
|
|
9
|
+
import assert from 'node:assert/strict'
|
|
10
|
+
|
|
11
|
+
import { planWarmStart } from '../server/engine/warm-start.ts'
|
|
12
|
+
|
|
13
|
+
const STATE = {
|
|
14
|
+
nodes: [{ id: 'dock-recv' }, { id: 'rack-1' }],
|
|
15
|
+
items: [{ epc: 'a' }, { epc: 'b' }, { epc: 'c' }],
|
|
16
|
+
movers: [{ id: 'fork-1' }],
|
|
17
|
+
orders: [{ id: 'SO-1', kind: 'outbound', status: 'created', progress: 0.5 }],
|
|
18
|
+
tasks: [{ id: 't1' }]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test('상태가 있으면 심는다 — 재고·노드·무버', () => {
|
|
22
|
+
const plan = planWarmStart(STATE, undefined, true)
|
|
23
|
+
assert.equal(plan.action, 'hydrate')
|
|
24
|
+
if (plan.action !== 'hydrate') return
|
|
25
|
+
assert.equal(plan.itemCount, 3)
|
|
26
|
+
assert.equal(plan.moverCount, 1)
|
|
27
|
+
assert.deepEqual(plan.seed.nodes, STATE.nodes)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('오더는 심지 않는다 — 스냅샷에 requested/fulfilled 가 없어 지어내야 하기 때문', () => {
|
|
31
|
+
const plan = planWarmStart(STATE, undefined, true)
|
|
32
|
+
assert.equal(plan.action, 'hydrate')
|
|
33
|
+
if (plan.action !== 'hydrate') return
|
|
34
|
+
assert.deepEqual(Object.keys(plan.seed).sort(), ['items', 'movers', 'nodes'])
|
|
35
|
+
assert.equal((plan.seed as any).orders, undefined)
|
|
36
|
+
assert.equal((plan.seed as any).tasks, undefined)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('벤치 트윈에는 심지 않는다 — 새 시작에서 용량을 재는 것이 목적이다', () => {
|
|
40
|
+
const plan = planWarmStart(STATE, 'bench', true)
|
|
41
|
+
assert.deepEqual(plan, { action: 'skip', reason: 'bench' })
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('벤치 판정은 상태 유무보다 먼저다 — 상태가 없어도 이유는 bench 여야 한다', () => {
|
|
45
|
+
/* 이유가 뒤바뀌면 로그가 "심을 게 없었다" 로 남아, 벤치를 의도적으로 비웠다는 사실이 사라진다. */
|
|
46
|
+
assert.deepEqual(planWarmStart(null, 'bench', true), { action: 'skip', reason: 'bench' })
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('심을 상태가 없으면 조용히 넘어간다 — 처음 만든 트윈의 정상 경로다', () => {
|
|
50
|
+
assert.deepEqual(planWarmStart(null, undefined, true), { action: 'skip', reason: 'no-state' })
|
|
51
|
+
assert.deepEqual(planWarmStart(undefined, undefined, true), { action: 'skip', reason: 'no-state' })
|
|
52
|
+
assert.deepEqual(planWarmStart({ nodes: [], items: [], movers: [] }, undefined, true), { action: 'skip', reason: 'no-state' })
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('노드만 있어도 심는다 — 구조 배치가 복원되면 화면이 살아난다', () => {
|
|
56
|
+
const plan = planWarmStart({ nodes: [{ id: 'n1' }], items: [], movers: [] }, undefined, true)
|
|
57
|
+
assert.equal(plan.action, 'hydrate')
|
|
58
|
+
if (plan.action !== 'hydrate') return
|
|
59
|
+
assert.equal(plan.itemCount, 0)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('커널이 주입을 지원하지 않으면 알린다 — 조용히 빈 채로 뜨면 안 된다', () => {
|
|
63
|
+
assert.deepEqual(planWarmStart(STATE, undefined, false), { action: 'skip', reason: 'unsupported' })
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('심을 게 없으면 미지원이어도 경고할 일이 아니다', () => {
|
|
67
|
+
/* 미지원 경고가 매 기동마다 뜨면 진짜 경고가 묻힌다. */
|
|
68
|
+
assert.deepEqual(planWarmStart(null, undefined, false), { action: 'skip', reason: 'no-state' })
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('빠진 배열은 빈 배열로 다뤄지되 없는 것을 지어내지 않는다', () => {
|
|
72
|
+
const plan = planWarmStart({ items: [{ epc: 'a' }] } as any, undefined, true)
|
|
73
|
+
assert.equal(plan.action, 'hydrate')
|
|
74
|
+
if (plan.action !== 'hydrate') return
|
|
75
|
+
assert.deepEqual(plan.seed.nodes, [])
|
|
76
|
+
assert.deepEqual(plan.seed.movers, [])
|
|
77
|
+
assert.equal(plan.itemCount, 1)
|
|
78
|
+
})
|