@things-factory/headless-twin 10.0.3 → 10.0.5

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.
@@ -0,0 +1,447 @@
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
+ const r = foldKpi(
159
+ [
160
+ task('t1', 'created', 0),
161
+ task('t1', 'in-progress', 10),
162
+ task('t1', 'in-progress', 15), // 재전송
163
+ task('t1', 'completed', 40)
164
+ ],
165
+ WINDOW
166
+ )
167
+ assert.equal(r.workTime.avgMs, 30000, '처음 착수(10s) 기준')
168
+ })
169
+
170
+ /* ── 구간 쪼개기(추세) ─────────────────────────────────────────────────────
171
+ * 화면의 추세선이 여기서 나온다. 조회는 한 번이고 창만 바꿔 다시 접는다.
172
+ */
173
+ test('구간 쪼개기: N 등분해 구간별 처리량을 낸다', () => {
174
+ /* 1시간 창을 4등분(15분씩). 각 구간에 한 건씩 완료되게 놓는다. */
175
+ const events: KpiEvent[] = []
176
+ for (let i = 0; i < 4; i++) {
177
+ const sec = i * 900 + 100 // 100s, 1000s, 1900s, 2800s
178
+ events.push(task(`t${i}`, 'created', sec - 30), task(`t${i}`, 'in-progress', sec - 20), task(`t${i}`, 'completed', sec))
179
+ }
180
+ const buckets = foldKpiBuckets(events, WINDOW, 4)
181
+ assert.equal(buckets.length, 4)
182
+ assert.deepEqual(buckets.map(b => b.tasks), [1, 1, 1, 1])
183
+ })
184
+
185
+ test('구간 쪼개기: 구간 합이 전체 창의 처리량과 같다 — 경계에서 새거나 겹치지 않는다', () => {
186
+ const events: KpiEvent[] = []
187
+ for (let i = 0; i < 20; i++) {
188
+ const sec = 50 + i * 170
189
+ events.push(task(`t${i}`, 'created', sec - 10), task(`t${i}`, 'in-progress', sec - 5), task(`t${i}`, 'completed', sec))
190
+ }
191
+ const whole = foldKpi(events, WINDOW)
192
+ const buckets = foldKpiBuckets(events, WINDOW, 6)
193
+ const sum = buckets.reduce((a, b) => a + b.tasks, 0)
194
+ assert.equal(sum, whole.throughput.tasks)
195
+ })
196
+
197
+ test('구간 쪼개기: 마지막 구간이 창의 끝까지 덮는다 — 나눗셈 오차로 마지막 완료가 빠지지 않게', () => {
198
+ /* 3등분(나누어떨어지지 않는 창) + 창의 마지막 순간에 완료 */
199
+ const win = { fromMs: WINDOW.fromMs, toMs: WINDOW.fromMs + 1000 * 1000 } // 1000초
200
+ const events = [task('t1', 'created', 0), task('t1', 'in-progress', 10), task('t1', 'completed', 1000)]
201
+ const buckets = foldKpiBuckets(events, win, 3)
202
+ assert.equal(buckets[buckets.length - 1].toMs, win.toMs)
203
+ assert.equal(buckets.reduce((a, b) => a + b.tasks, 0), 1)
204
+ })
205
+
206
+ test('구간 쪼개기: 1 이하면 쪼개지 않는다(추세 없음)', () => {
207
+ assert.deepEqual(foldKpiBuckets([], WINDOW, 1), [])
208
+ assert.deepEqual(foldKpiBuckets([], WINDOW, 0), [])
209
+ })
210
+
211
+ test('구간 쪼개기: 구간별 작업시간 중앙값을 낸다 — "느려지고 있는가" 를 본다', () => {
212
+ const events: KpiEvent[] = [
213
+ /* 앞 구간: 10초 작업 */
214
+ task('a', 'created', 100), task('a', 'in-progress', 100), task('a', 'completed', 110),
215
+ /* 뒤 구간: 40초 작업 */
216
+ task('b', 'created', 2000), task('b', 'in-progress', 2000), task('b', 'completed', 2040)
217
+ ]
218
+ const buckets = foldKpiBuckets(events, WINDOW, 2)
219
+ assert.equal(buckets[0].workP50Ms, 10000)
220
+ assert.equal(buckets[1].workP50Ms, 40000)
221
+ })
222
+
223
+ /* ── 관점 축(groupBy) ───────────────────────────────────────────────────────
224
+ * 왜 계산 층에 있는가: 고정된 한 렌즈만 낼 수 있으면 "구역별로는?" 이라는 질문에 화면도 AI 도 답할 수
225
+ * 없다. **사실 계산이 다각도를 못 내면 AI 도 못 낸다** — 그래서 축은 폴드의 능력이어야 한다.
226
+ */
227
+ const three = (id: string, sec: number, facets: Record<string, unknown>): KpiEvent[] => [
228
+ taskAt(id, 'created', sec - 20, facets),
229
+ taskAt(id, 'in-progress', sec - 10, facets),
230
+ taskAt(id, 'completed', sec, facets)
231
+ ]
232
+
233
+ test('축 없이는 groups 를 내지 않는다 — 묻지 않은 것을 실어 보내지 않는다', () => {
234
+ const r = foldKpi(three('t1', 100, { kind: 'move' }), WINDOW)
235
+ assert.equal(r.groups, undefined)
236
+ })
237
+
238
+ test('작업 종류별로 쪼갠다 — 전체 통계와 같은 규칙으로', () => {
239
+ const events = [
240
+ ...three('a', 100, { kind: 'move' }),
241
+ ...three('b', 200, { kind: 'move' }),
242
+ ...three('c', 300, { kind: 'pick' })
243
+ ]
244
+ const r = foldKpi(events, WINDOW, { groupBy: 'taskKind' })
245
+ assert.equal(r.groups?.by, 'taskKind')
246
+ assert.deepEqual(
247
+ r.groups?.items.map(g => [g.key, g.tasks]),
248
+ [
249
+ ['move', 2],
250
+ ['pick', 1]
251
+ ],
252
+ '건수 큰 축부터'
253
+ )
254
+ const move = r.groups!.items[0]
255
+ assert.equal(move.workTime.avgMs, 10000, '착수→완료 10초')
256
+ assert.equal(move.leadTime.avgMs, 20000, '생성→완료 20초')
257
+ assert.equal(move.busyMs, 20000, '두 건 × 10초')
258
+ })
259
+
260
+ test('축의 합이 전체 처리량과 같다 — 어느 축에서도 건이 새지 않는다', () => {
261
+ const events = [
262
+ ...three('a', 100, { kind: 'move', orderId: 'o1', toNode: 'dock-1', resourceRef: 'fk-1' }),
263
+ ...three('b', 200, { kind: 'pick', orderId: 'o1', toNode: 'rack-1', resourceRef: 'fk-2' }),
264
+ ...three('c', 300, { kind: 'pick', orderId: 'o2', toNode: 'rack-1' })
265
+ ]
266
+ const whole = foldKpi(events, WINDOW)
267
+ for (const by of ['resource', 'taskKind', 'node', 'order'] as const) {
268
+ const r = foldKpi(events, WINDOW, { groupBy: by })
269
+ const sum = r.groups!.items.reduce((a, g) => a + g.tasks, 0)
270
+ assert.equal(sum, whole.throughput.tasks, `${by} 축의 합`)
271
+ }
272
+ })
273
+
274
+ test('축의 값이 없던 건은 unknown 으로 모인다 — 조용히 버리지 않는다', () => {
275
+ const events = [...three('a', 100, { kind: 'move', resourceRef: 'fk-1' }), ...three('b', 200, { kind: 'move' })]
276
+ const r = foldKpi(events, WINDOW, { groupBy: 'resource' })
277
+ assert.deepEqual(r.groups?.items.map(g => g.key).sort(), ['fk-1', 'unknown'])
278
+ })
279
+
280
+ test('축의 값은 다른 전이에서 와도 읽는다 — 완료 이벤트에 kind 가 없어도 축이 비지 않는다', () => {
281
+ /* 생성에만 kind 가 실리는 구현이 실제로 있다. 완료 줄만 보면 축이 통째로 unknown 이 된다. */
282
+ const events = [
283
+ taskAt('a', 'created', 80, { kind: 'putaway', orderId: 'o9' }),
284
+ taskAt('a', 'in-progress', 90, {}),
285
+ taskAt('a', 'completed', 100, {})
286
+ ]
287
+ const r = foldKpi(events, WINDOW, { groupBy: 'taskKind' })
288
+ assert.deepEqual(r.groups?.items.map(g => g.key), ['putaway'])
289
+ const byOrder = foldKpi(events, WINDOW, { groupBy: 'order' })
290
+ assert.deepEqual(byOrder.groups?.items.map(g => g.key), ['o9'])
291
+ })
292
+
293
+ test('노드 축은 가장 최근 도착지 — 진행에 따라 바뀌는 값은 마지막 것이 사실', () => {
294
+ const events = [
295
+ taskAt('a', 'created', 80, { toNode: 'dock-1' }),
296
+ taskAt('a', 'in-progress', 90, { fromNode: 'dock-1', toNode: 'rack-7' }),
297
+ taskAt('a', 'completed', 100, { fromNode: 'dock-1', toNode: 'rack-7' })
298
+ ]
299
+ const r = foldKpi(events, WINDOW, { groupBy: 'node' })
300
+ assert.deepEqual(r.groups?.items.map(g => g.key), ['rack-7'])
301
+ })
302
+
303
+ test('구역 축은 노드→구역 지도를 받아야 낸다 — 지도가 없으면 노드 id 로 대체하지 않는다', () => {
304
+ const events = [...three('a', 100, { toNode: 'rack-1' }), ...three('b', 200, { toNode: 'rack-2' })]
305
+ const blind = foldKpi(events, WINDOW, { groupBy: 'area' })
306
+ assert.deepEqual(blind.groups?.items.map(g => [g.key, g.tasks]), [['unknown', 2]], '지도 없으면 unknown 하나')
307
+
308
+ const mapped = foldKpi(events, WINDOW, { groupBy: 'area', nodeArea: { 'rack-1': 'zone-a', 'rack-2': 'zone-a' } })
309
+ assert.deepEqual(mapped.groups?.items.map(g => [g.key, g.tasks]), [['zone-a', 2]], '두 노드가 한 구역으로 모인다')
310
+ })
311
+
312
+ test('축이 많으면 잘라내고 잘린 축의 수와 그 안의 건수를 함께 알린다 — 조용한 절단 금지', () => {
313
+ /* 축마다 건수를 다르게 둔다: 축의 수만 알리면 "그 외 20개" 가 20건인지 60건인지 알 수 없다. */
314
+ const events: KpiEvent[] = []
315
+ let t = 0
316
+ for (let i = 0; i < 25; i++) {
317
+ const n = i < 5 ? 3 : 1 // 앞 5개 축은 3건씩, 나머지 20개 축은 1건씩
318
+ for (let k = 0; k < n; k++) events.push(...three(`t${t++}`, 100 + t * 10, { orderId: `o${i}` }))
319
+ }
320
+ const r = foldKpi(events, WINDOW, { groupBy: 'order', groupLimit: 5 })
321
+ assert.equal(r.groups?.items.length, 5)
322
+ assert.equal(r.groups?.truncated, 20, '잘린 축의 수')
323
+ assert.equal(r.groups?.truncatedTasks, 20, '잘린 축들이 안고 있던 건수')
324
+ const whole = foldKpi(events, WINDOW)
325
+ assert.equal(
326
+ r.groups!.items.reduce((a, g) => a + g.tasks, 0) + r.groups!.truncatedTasks!,
327
+ whole.throughput.tasks,
328
+ '보여준 건수 + 잘린 건수 = 전체(축의 수가 아니라 건수로 맞는다)'
329
+ )
330
+ })
331
+
332
+ test('짝을 못 찾은 건도 축에서 센다 — 건수는 세고 소요만 비운다', () => {
333
+ const r = foldKpi([taskAt('a', 'completed', 100, { kind: 'move' })], WINDOW, { groupBy: 'taskKind' })
334
+ const g = r.groups!.items[0]
335
+ assert.equal(g.tasks, 1)
336
+ assert.equal(g.workTime.count, 0)
337
+ assert.equal(g.busyMs, 0)
338
+ })
339
+
340
+ /* ── 시간 비교 ──────────────────────────────────────────────────────────────
341
+ * "29초" 만으로는 좋아졌는지 나빠졌는지 알 수 없다. 창을 옮겨 한 번 더 접는 규칙을 고정한다.
342
+ */
343
+ test('직전 구간: 같은 길이로 바로 앞 — 경계가 맞물린다(겹치거나 벌어지지 않게)', () => {
344
+ const w = shiftWindow(WINDOW.fromMs, WINDOW.toMs, 'previous')
345
+ assert.equal(w.toMs, WINDOW.fromMs, '앞 구간의 끝 = 현재 구간의 시작')
346
+ assert.equal(w.toMs - w.fromMs, WINDOW.toMs - WINDOW.fromMs, '길이가 같다')
347
+ })
348
+
349
+ test('어제 같은 시간: 정확히 24시간 전 같은 자리', () => {
350
+ const w = shiftWindow(WINDOW.fromMs, WINDOW.toMs, 'yesterday')
351
+ assert.equal(WINDOW.fromMs - w.fromMs, 24 * 60 * 60 * 1000)
352
+ assert.equal(w.toMs - w.fromMs, WINDOW.toMs - WINDOW.fromMs)
353
+ })
354
+
355
+ test('중앙값 차이: 현재 − 비교구간', () => {
356
+ const now = { count: 5, p50Ms: 40000, p90Ms: 0, avgMs: 0 }
357
+ const prev = { count: 5, p50Ms: 30000, p90Ms: 0, avgMs: 0 }
358
+ assert.equal(deltaP50(now, prev), 10000, '10초 느려졌다')
359
+ assert.equal(deltaP50(prev, now), -10000, '부호로 방향을 말한다(좋고 나쁨은 판단하지 않는다)')
360
+ })
361
+
362
+ test('중앙값 차이: 한쪽이 결측이면 차이는 없다 — 0 으로 내면 "변화 없음" 이라는 거짓이 된다', () => {
363
+ const measured = { count: 5, p50Ms: 40000, p90Ms: 0, avgMs: 0 }
364
+ const missing = { count: 0, p50Ms: 0, p90Ms: 0, avgMs: 0 }
365
+ assert.equal(deltaP50(measured, missing), undefined)
366
+ assert.equal(deltaP50(missing, measured), undefined)
367
+ })
368
+
369
+ test('비교: 옮긴 창으로 접으면 앞 구간의 성과가 나온다 — 같은 폴드, 창만 다르다', () => {
370
+ /* 현재 구간에 2건, 직전 구간에 1건. */
371
+ const events = [
372
+ ...three('now-1', 100, { kind: 'move' }),
373
+ ...three('now-2', 200, { kind: 'move' }),
374
+ ...three('prev-1', -1800, { kind: 'move' })
375
+ ]
376
+ const now = foldKpi(events, WINDOW)
377
+ const prev = foldKpi(events, shiftWindow(WINDOW.fromMs, WINDOW.toMs, 'previous'))
378
+ assert.equal(now.throughput.tasks, 2)
379
+ assert.equal(prev.throughput.tasks, 1)
380
+ assert.equal(now.throughput.tasks - prev.throughput.tasks, 1, '처리량이 1건 늘었다')
381
+ })
382
+
383
+ test('창 앞 구간만 읽었으면 "측정된 기록 있음" 이 아니다 — lookback 을 성과로 착각하지 않게', () => {
384
+ /* 호출부는 창보다 앞까지 읽어 넘긴다(완료 작업의 시작이 창 밖일 수 있어서). 그 앞 구간 기록만
385
+ * 있는데 eventsSeen 으로 판단하면 "0건 처리" 라고 말하게 된다 — 실제로 가능했던 오류다. */
386
+ const before = [task('t1', 'created', -1200), task('t1', 'in-progress', -1100), task('t1', 'completed', -1000)]
387
+ const r = foldKpi(before, WINDOW)
388
+ assert.equal(r.eventsSeen, 3, '넘겨받은 것은 3줄')
389
+ assert.equal(r.eventsInWindow, 0, '창 안에는 아무것도 없다 → "기록 없음"')
390
+ assert.equal(r.throughput.tasks, 0)
391
+ })
392
+
393
+ test('창 안 기록만 센다 — 앞 구간이 섞여도 창 안 개수는 정확하다', () => {
394
+ const events = [
395
+ task('old', 'completed', -500), // 창 밖(앞)
396
+ ...three('in-1', 100, { kind: 'move' }), // 창 안 3줄
397
+ task('late', 'completed', 4000) // 창 밖(뒤)
398
+ ]
399
+ const r = foldKpi(events, WINDOW)
400
+ assert.equal(r.eventsSeen, 5)
401
+ assert.equal(r.eventsInWindow, 3)
402
+ })
403
+
404
+ /* ── 관점 × 비교 교차 ───────────────────────────────────────────────────────
405
+ * 전체는 비슷한데 **한 구역만 나빠진** 경우를 전체 숫자로는 절대 못 본다. 그 교차가 운영에서 가장
406
+ * 값 있는 질문이다.
407
+ */
408
+ const g = (key: string, tasks: number, workMs = 10000) => {
409
+ const events = []
410
+ for (let i = 0; i < tasks; i++) {
411
+ const sec = 100 + i * 30
412
+ events.push(
413
+ taskAt(`${key}-${i}`, 'created', sec - 20, { kind: key }),
414
+ taskAt(`${key}-${i}`, 'in-progress', sec, { kind: key }),
415
+ taskAt(`${key}-${i}`, 'completed', sec + workMs / 1000, { kind: key })
416
+ )
417
+ }
418
+ return foldKpi(events, WINDOW, { groupBy: 'taskKind' }).groups!.items[0]
419
+ }
420
+
421
+ test('교차: 두 구간에 모두 있는 축은 delta 를 낸다', () => {
422
+ const c = crossGroups([g('pick', 10, 20000)], [g('pick', 7, 10000)])
423
+ const it = c.items[0]
424
+ assert.equal(it.prevTasks, 7)
425
+ assert.equal(it.deltaTasks, 3)
426
+ assert.equal(it.deltaWorkP50Ms, 10000, '작업시간이 10초 늘었다')
427
+ assert.deepEqual(c.disappeared, [])
428
+ })
429
+
430
+ test('교차: 지금만 있는 축은 isNew — delta 를 건수와 같게 적으면 "늘었다" 로 오독된다', () => {
431
+ const c = crossGroups([g('putaway', 5)], [g('pick', 5)])
432
+ const nw = c.items.find(x => x.key === 'putaway')!
433
+ assert.equal(nw.isNew, true)
434
+ assert.equal(nw.deltaTasks, undefined)
435
+ assert.equal(nw.prevTasks, undefined)
436
+ })
437
+
438
+ test('교차: 사라진 축을 따로 알린다 — 멈춘 구역이 표에서 조용히 빠지지 않게', () => {
439
+ const c = crossGroups([g('pick', 5)], [g('pick', 5), g('putaway', 12)])
440
+ assert.deepEqual(c.disappeared, [{ key: 'putaway', tasks: 12 }])
441
+ })
442
+
443
+ test('교차: 비교 구간이 비면 전부 isNew 로 — 없던 것과 줄어든 것을 섞지 않는다', () => {
444
+ const c = crossGroups([g('pick', 3), g('putaway', 4)], [])
445
+ assert.equal(c.items.every(x => x.isNew === true), true)
446
+ assert.deepEqual(c.disappeared, [])
447
+ })