@dssp/dkpi 1.0.0-alpha.53 → 1.0.0-alpha.55
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/KPI-STATISTICS-SERVICE.md +233 -0
- package/dist-client/components/kpi-single-boxplot-chart.js +109 -111
- package/dist-client/components/kpi-single-boxplot-chart.js.map +1 -1
- package/dist-client/pages/sv-project-completed-list.d.ts +3 -0
- package/dist-client/pages/sv-project-completed-list.js +70 -11
- package/dist-client/pages/sv-project-completed-list.js.map +1 -1
- package/dist-client/pages/sv-project-list.d.ts +3 -0
- package/dist-client/pages/sv-project-list.js +69 -11
- package/dist-client/pages/sv-project-list.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/service/index.d.ts +1 -3
- package/dist-server/service/index.js +3 -4
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/kpi-stat/index.d.ts +4 -0
- package/dist-server/service/kpi-stat/index.js +8 -0
- package/dist-server/service/kpi-stat/index.js.map +1 -0
- package/dist-server/service/kpi-stat/kpi-stat-query.d.ts +8 -0
- package/dist-server/service/kpi-stat/kpi-stat-query.js +225 -0
- package/dist-server/service/kpi-stat/kpi-stat-query.js.map +1 -0
- package/dist-server/service/kpi-stat/kpi-stat-types.d.ts +20 -0
- package/dist-server/service/kpi-stat/kpi-stat-types.js +78 -0
- package/dist-server/service/kpi-stat/kpi-stat-types.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/kpi-module-service-tests.md +1286 -0
- package/kpi-module-test-report.md +676 -0
- package/kpi-module-unit-test-detailed-report.md +925 -0
- package/kpi-module-unit-tests-detailed.md +1452 -0
- package/package.json +3 -3
|
@@ -0,0 +1,1286 @@
|
|
|
1
|
+
# KPI 모듈 서비스 레이어 테스트
|
|
2
|
+
|
|
3
|
+
## 4. KPI 서비스 레이어 테스트 (kpi-service.spec.ts)
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
import { describe, it, expect, beforeEach, jest } from '@jest/globals'
|
|
7
|
+
import { Repository } from 'typeorm'
|
|
8
|
+
import { KpiService } from '../server/service/kpi/kpi-service'
|
|
9
|
+
import { Kpi, KpiStatus, KpiVizType } from '../server/service/kpi/kpi'
|
|
10
|
+
import { KpiHistory } from '../server/service/kpi/kpi-history'
|
|
11
|
+
import { Domain } from '@things-factory/shell'
|
|
12
|
+
import { User } from '@things-factory/auth-base'
|
|
13
|
+
|
|
14
|
+
// Mock 인터페이스
|
|
15
|
+
interface MockRepository<T> extends Partial<Repository<T>> {
|
|
16
|
+
find: jest.Mock
|
|
17
|
+
findOne: jest.Mock
|
|
18
|
+
save: jest.Mock
|
|
19
|
+
delete: jest.Mock
|
|
20
|
+
softDelete: jest.Mock
|
|
21
|
+
count: jest.Mock
|
|
22
|
+
createQueryBuilder: jest.Mock
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('KPI Service Layer Tests', () => {
|
|
26
|
+
let kpiService: KpiService
|
|
27
|
+
let mockKpiRepository: MockRepository<Kpi>
|
|
28
|
+
let mockKpiHistoryRepository: MockRepository<KpiHistory>
|
|
29
|
+
let mockDomainRepository: MockRepository<Domain>
|
|
30
|
+
|
|
31
|
+
let testDomain: Domain
|
|
32
|
+
let testUser: User
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
// Mock 리포지토리 생성
|
|
36
|
+
mockKpiRepository = {
|
|
37
|
+
find: jest.fn(),
|
|
38
|
+
findOne: jest.fn(),
|
|
39
|
+
save: jest.fn(),
|
|
40
|
+
delete: jest.fn(),
|
|
41
|
+
softDelete: jest.fn(),
|
|
42
|
+
count: jest.fn(),
|
|
43
|
+
createQueryBuilder: jest.fn()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
mockKpiHistoryRepository = {
|
|
47
|
+
find: jest.fn(),
|
|
48
|
+
findOne: jest.fn(),
|
|
49
|
+
save: jest.fn(),
|
|
50
|
+
delete: jest.fn(),
|
|
51
|
+
softDelete: jest.fn(),
|
|
52
|
+
count: jest.fn(),
|
|
53
|
+
createQueryBuilder: jest.fn()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
mockDomainRepository = {
|
|
57
|
+
find: jest.fn(),
|
|
58
|
+
findOne: jest.fn(),
|
|
59
|
+
save: jest.fn(),
|
|
60
|
+
delete: jest.fn(),
|
|
61
|
+
softDelete: jest.fn(),
|
|
62
|
+
count: jest.fn(),
|
|
63
|
+
createQueryBuilder: jest.fn()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 서비스 생성 (의존성 주입)
|
|
67
|
+
kpiService = new KpiService(
|
|
68
|
+
mockKpiRepository as Repository<Kpi>,
|
|
69
|
+
mockKpiHistoryRepository as Repository<KpiHistory>,
|
|
70
|
+
mockDomainRepository as Repository<Domain>
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
// 테스트용 데이터
|
|
74
|
+
testDomain = {
|
|
75
|
+
id: 'domain-123',
|
|
76
|
+
name: 'TEST_DOMAIN',
|
|
77
|
+
description: 'Test Domain'
|
|
78
|
+
} as Domain
|
|
79
|
+
|
|
80
|
+
testUser = {
|
|
81
|
+
id: 'user-123',
|
|
82
|
+
name: 'Test User',
|
|
83
|
+
email: 'test@example.com',
|
|
84
|
+
domain: testDomain
|
|
85
|
+
} as User
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('KPI 생성 서비스 테스트', () => {
|
|
89
|
+
it('새로운 KPI 생성이 성공해야 한다', async () => {
|
|
90
|
+
// Given
|
|
91
|
+
const newKpiData = {
|
|
92
|
+
name: '안전사고율',
|
|
93
|
+
description: '월별 안전사고 발생률',
|
|
94
|
+
formula: 'accident_count / total_hours * 1000',
|
|
95
|
+
isLeaf: true,
|
|
96
|
+
active: true,
|
|
97
|
+
state: KpiStatus.DRAFT,
|
|
98
|
+
vizType: KpiVizType.GAUGE,
|
|
99
|
+
vizMeta: { unit: '건/천시간', min: 0, max: 10 }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const expectedKpi = {
|
|
103
|
+
id: 'kpi-123',
|
|
104
|
+
...newKpiData,
|
|
105
|
+
version: 1,
|
|
106
|
+
domain: testDomain,
|
|
107
|
+
creator: testUser,
|
|
108
|
+
createdAt: new Date(),
|
|
109
|
+
updatedAt: new Date()
|
|
110
|
+
} as Kpi
|
|
111
|
+
|
|
112
|
+
mockKpiRepository.save.mockResolvedValue(expectedKpi)
|
|
113
|
+
|
|
114
|
+
// When
|
|
115
|
+
const result = await kpiService.createKpi(newKpiData, testUser, testDomain)
|
|
116
|
+
|
|
117
|
+
// Then
|
|
118
|
+
expect(result).toEqual(expectedKpi)
|
|
119
|
+
expect(mockKpiRepository.save).toHaveBeenCalledWith(
|
|
120
|
+
expect.objectContaining({
|
|
121
|
+
name: '안전사고율',
|
|
122
|
+
formula: 'accident_count / total_hours * 1000',
|
|
123
|
+
version: 1,
|
|
124
|
+
domain: testDomain,
|
|
125
|
+
creator: testUser
|
|
126
|
+
})
|
|
127
|
+
)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('부모 KPI가 있는 하위 KPI 생성이 성공해야 한다', async () => {
|
|
131
|
+
// Given
|
|
132
|
+
const parentKpi = {
|
|
133
|
+
id: 'parent-kpi-123',
|
|
134
|
+
name: '전체 안전 지수',
|
|
135
|
+
isLeaf: false
|
|
136
|
+
} as Kpi
|
|
137
|
+
|
|
138
|
+
const childKpiData = {
|
|
139
|
+
name: '안전사고율',
|
|
140
|
+
parent: { id: parentKpi.id },
|
|
141
|
+
isLeaf: true,
|
|
142
|
+
formula: 'accident_count / total_hours'
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
mockKpiRepository.findOne.mockResolvedValue(parentKpi)
|
|
146
|
+
mockKpiRepository.save.mockResolvedValue({
|
|
147
|
+
id: 'child-kpi-123',
|
|
148
|
+
...childKpiData,
|
|
149
|
+
parent: parentKpi,
|
|
150
|
+
domain: testDomain,
|
|
151
|
+
creator: testUser
|
|
152
|
+
} as Kpi)
|
|
153
|
+
|
|
154
|
+
// When
|
|
155
|
+
const result = await kpiService.createKpi(childKpiData, testUser, testDomain)
|
|
156
|
+
|
|
157
|
+
// Then
|
|
158
|
+
expect(result.parent).toEqual(parentKpi)
|
|
159
|
+
expect(mockKpiRepository.findOne).toHaveBeenCalledWith({
|
|
160
|
+
where: { id: parentKpi.id, domain: { id: testDomain.id } }
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('존재하지 않는 부모 KPI 참조 시 에러가 발생해야 한다', async () => {
|
|
165
|
+
// Given
|
|
166
|
+
const invalidChildKpiData = {
|
|
167
|
+
name: '잘못된 하위 KPI',
|
|
168
|
+
parent: { id: 'non-existent-parent' },
|
|
169
|
+
isLeaf: true
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
mockKpiRepository.findOne.mockResolvedValue(null)
|
|
173
|
+
|
|
174
|
+
// When & Then
|
|
175
|
+
await expect(kpiService.createKpi(invalidChildKpiData, testUser, testDomain))
|
|
176
|
+
.rejects.toThrow('Parent KPI not found: non-existent-parent')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('중복된 KPI 이름으로 생성 시 에러가 발생해야 한다', async () => {
|
|
180
|
+
// Given
|
|
181
|
+
const duplicateKpiData = {
|
|
182
|
+
name: '중복 KPI',
|
|
183
|
+
isLeaf: true
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
mockKpiRepository.findOne.mockResolvedValue({
|
|
187
|
+
id: 'existing-kpi-123',
|
|
188
|
+
name: '중복 KPI'
|
|
189
|
+
} as Kpi)
|
|
190
|
+
|
|
191
|
+
// When & Then
|
|
192
|
+
await expect(kpiService.createKpi(duplicateKpiData, testUser, testDomain))
|
|
193
|
+
.rejects.toThrow('KPI with name "중복 KPI" already exists in this domain')
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
describe('KPI 수정 서비스 테스트', () => {
|
|
198
|
+
it('DRAFT 상태 KPI 수정이 성공해야 한다', async () => {
|
|
199
|
+
// Given
|
|
200
|
+
const existingKpi = {
|
|
201
|
+
id: 'kpi-123',
|
|
202
|
+
name: '기존 KPI',
|
|
203
|
+
formula: 'old_formula',
|
|
204
|
+
state: KpiStatus.DRAFT,
|
|
205
|
+
version: 1,
|
|
206
|
+
domain: testDomain
|
|
207
|
+
} as Kpi
|
|
208
|
+
|
|
209
|
+
const updateData = {
|
|
210
|
+
name: '수정된 KPI',
|
|
211
|
+
formula: 'new_formula',
|
|
212
|
+
description: '수정된 설명'
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const updatedKpi = {
|
|
216
|
+
...existingKpi,
|
|
217
|
+
...updateData,
|
|
218
|
+
version: 2,
|
|
219
|
+
updatedAt: new Date()
|
|
220
|
+
} as Kpi
|
|
221
|
+
|
|
222
|
+
mockKpiRepository.findOne.mockResolvedValue(existingKpi)
|
|
223
|
+
mockKpiRepository.save.mockResolvedValue(updatedKpi)
|
|
224
|
+
|
|
225
|
+
// When
|
|
226
|
+
const result = await kpiService.updateKpi('kpi-123', updateData, testUser, testDomain)
|
|
227
|
+
|
|
228
|
+
// Then
|
|
229
|
+
expect(result.name).toBe('수정된 KPI')
|
|
230
|
+
expect(result.formula).toBe('new_formula')
|
|
231
|
+
expect(result.version).toBe(2)
|
|
232
|
+
expect(mockKpiRepository.save).toHaveBeenCalledWith(
|
|
233
|
+
expect.objectContaining({
|
|
234
|
+
name: '수정된 KPI',
|
|
235
|
+
formula: 'new_formula',
|
|
236
|
+
version: 2
|
|
237
|
+
})
|
|
238
|
+
)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('RELEASE 상태 KPI 수정 시 버전이 증가하고 DRAFT로 변경되어야 한다', async () => {
|
|
242
|
+
// Given
|
|
243
|
+
const releasedKpi = {
|
|
244
|
+
id: 'kpi-123',
|
|
245
|
+
name: '릴리즈된 KPI',
|
|
246
|
+
formula: 'released_formula',
|
|
247
|
+
state: KpiStatus.RELEASE,
|
|
248
|
+
version: 3,
|
|
249
|
+
domain: testDomain
|
|
250
|
+
} as Kpi
|
|
251
|
+
|
|
252
|
+
const updateData = {
|
|
253
|
+
formula: 'modified_formula'
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
mockKpiRepository.findOne.mockResolvedValue(releasedKpi)
|
|
257
|
+
mockKpiHistoryRepository.save.mockResolvedValue({} as KpiHistory) // 히스토리 저장
|
|
258
|
+
mockKpiRepository.save.mockResolvedValue({
|
|
259
|
+
...releasedKpi,
|
|
260
|
+
formula: 'modified_formula',
|
|
261
|
+
state: KpiStatus.DRAFT,
|
|
262
|
+
version: 4
|
|
263
|
+
} as Kpi)
|
|
264
|
+
|
|
265
|
+
// When
|
|
266
|
+
const result = await kpiService.updateKpi('kpi-123', updateData, testUser, testDomain)
|
|
267
|
+
|
|
268
|
+
// Then
|
|
269
|
+
expect(result.formula).toBe('modified_formula')
|
|
270
|
+
expect(result.state).toBe(KpiStatus.DRAFT)
|
|
271
|
+
expect(result.version).toBe(4)
|
|
272
|
+
|
|
273
|
+
// 히스토리가 저장되었는지 확인
|
|
274
|
+
expect(mockKpiHistoryRepository.save).toHaveBeenCalledWith(
|
|
275
|
+
expect.objectContaining({
|
|
276
|
+
kpi: expect.objectContaining({ id: 'kpi-123' }),
|
|
277
|
+
version: 3,
|
|
278
|
+
snapshot: expect.any(Object)
|
|
279
|
+
})
|
|
280
|
+
)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('ARCHIVED 상태 KPI는 수정할 수 없어야 한다', async () => {
|
|
284
|
+
// Given
|
|
285
|
+
const archivedKpi = {
|
|
286
|
+
id: 'kpi-123',
|
|
287
|
+
name: '폐기된 KPI',
|
|
288
|
+
state: KpiStatus.ARCHIVED,
|
|
289
|
+
domain: testDomain
|
|
290
|
+
} as Kpi
|
|
291
|
+
|
|
292
|
+
mockKpiRepository.findOne.mockResolvedValue(archivedKpi)
|
|
293
|
+
|
|
294
|
+
// When & Then
|
|
295
|
+
await expect(kpiService.updateKpi('kpi-123', { name: '수정 시도' }, testUser, testDomain))
|
|
296
|
+
.rejects.toThrow('Cannot modify archived KPI')
|
|
297
|
+
})
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
describe('KPI 삭제 서비스 테스트', () => {
|
|
301
|
+
it('하위 KPI가 없는 KPI 삭제가 성공해야 한다', async () => {
|
|
302
|
+
// Given
|
|
303
|
+
const kpiToDelete = {
|
|
304
|
+
id: 'kpi-123',
|
|
305
|
+
name: '삭제할 KPI',
|
|
306
|
+
isLeaf: true,
|
|
307
|
+
domain: testDomain
|
|
308
|
+
} as Kpi
|
|
309
|
+
|
|
310
|
+
mockKpiRepository.findOne.mockResolvedValue(kpiToDelete)
|
|
311
|
+
mockKpiRepository.find.mockResolvedValue([]) // 하위 KPI 없음
|
|
312
|
+
mockKpiRepository.softDelete.mockResolvedValue({ affected: 1 })
|
|
313
|
+
|
|
314
|
+
// When
|
|
315
|
+
const result = await kpiService.deleteKpi('kpi-123', testUser, testDomain)
|
|
316
|
+
|
|
317
|
+
// Then
|
|
318
|
+
expect(result).toBe(true)
|
|
319
|
+
expect(mockKpiRepository.softDelete).toHaveBeenCalledWith('kpi-123')
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
it('하위 KPI가 있는 부모 KPI 삭제 시 에러가 발생해야 한다', async () => {
|
|
323
|
+
// Given
|
|
324
|
+
const parentKpi = {
|
|
325
|
+
id: 'parent-kpi-123',
|
|
326
|
+
name: '부모 KPI',
|
|
327
|
+
isLeaf: false,
|
|
328
|
+
domain: testDomain
|
|
329
|
+
} as Kpi
|
|
330
|
+
|
|
331
|
+
const childKpis = [
|
|
332
|
+
{ id: 'child-1', name: '하위 KPI 1' },
|
|
333
|
+
{ id: 'child-2', name: '하위 KPI 2' }
|
|
334
|
+
] as Kpi[]
|
|
335
|
+
|
|
336
|
+
mockKpiRepository.findOne.mockResolvedValue(parentKpi)
|
|
337
|
+
mockKpiRepository.find.mockResolvedValue(childKpis)
|
|
338
|
+
|
|
339
|
+
// When & Then
|
|
340
|
+
await expect(kpiService.deleteKpi('parent-kpi-123', testUser, testDomain))
|
|
341
|
+
.rejects.toThrow('Cannot delete KPI with child KPIs. Found 2 child KPIs.')
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('존재하지 않는 KPI 삭제 시 에러가 발생해야 한다', async () => {
|
|
345
|
+
// Given
|
|
346
|
+
mockKpiRepository.findOne.mockResolvedValue(null)
|
|
347
|
+
|
|
348
|
+
// When & Then
|
|
349
|
+
await expect(kpiService.deleteKpi('non-existent-kpi', testUser, testDomain))
|
|
350
|
+
.rejects.toThrow('KPI not found: non-existent-kpi')
|
|
351
|
+
})
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
describe('KPI 릴리즈 서비스 테스트', () => {
|
|
355
|
+
it('DRAFT 상태 KPI 릴리즈가 성공해야 한다', async () => {
|
|
356
|
+
// Given
|
|
357
|
+
const draftKpi = {
|
|
358
|
+
id: 'kpi-123',
|
|
359
|
+
name: '드래프트 KPI',
|
|
360
|
+
formula: 'valid_formula',
|
|
361
|
+
state: KpiStatus.DRAFT,
|
|
362
|
+
version: 2,
|
|
363
|
+
domain: testDomain
|
|
364
|
+
} as Kpi
|
|
365
|
+
|
|
366
|
+
mockKpiRepository.findOne.mockResolvedValue(draftKpi)
|
|
367
|
+
mockKpiHistoryRepository.save.mockResolvedValue({} as KpiHistory)
|
|
368
|
+
mockKpiRepository.save.mockResolvedValue({
|
|
369
|
+
...draftKpi,
|
|
370
|
+
state: KpiStatus.RELEASE,
|
|
371
|
+
version: 3
|
|
372
|
+
} as Kpi)
|
|
373
|
+
|
|
374
|
+
// When
|
|
375
|
+
const result = await kpiService.releaseKpi('kpi-123', testUser, testDomain)
|
|
376
|
+
|
|
377
|
+
// Then
|
|
378
|
+
expect(result.state).toBe(KpiStatus.RELEASE)
|
|
379
|
+
expect(result.version).toBe(3)
|
|
380
|
+
|
|
381
|
+
// 릴리즈 전 상태가 히스토리에 저장되었는지 확인
|
|
382
|
+
expect(mockKpiHistoryRepository.save).toHaveBeenCalledWith(
|
|
383
|
+
expect.objectContaining({
|
|
384
|
+
kpi: expect.objectContaining({ id: 'kpi-123' }),
|
|
385
|
+
version: 2,
|
|
386
|
+
action: 'RELEASE',
|
|
387
|
+
snapshot: expect.any(Object)
|
|
388
|
+
})
|
|
389
|
+
)
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
it('수식이 없는 leaf KPI 릴리즈 시 에러가 발생해야 한다', async () => {
|
|
393
|
+
// Given
|
|
394
|
+
const invalidKpi = {
|
|
395
|
+
id: 'kpi-123',
|
|
396
|
+
name: '잘못된 KPI',
|
|
397
|
+
formula: null,
|
|
398
|
+
isLeaf: true,
|
|
399
|
+
state: KpiStatus.DRAFT,
|
|
400
|
+
domain: testDomain
|
|
401
|
+
} as Kpi
|
|
402
|
+
|
|
403
|
+
mockKpiRepository.findOne.mockResolvedValue(invalidKpi)
|
|
404
|
+
|
|
405
|
+
// When & Then
|
|
406
|
+
await expect(kpiService.releaseKpi('kpi-123', testUser, testDomain))
|
|
407
|
+
.rejects.toThrow('Cannot release leaf KPI without formula')
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
it('이미 RELEASE 상태인 KPI 릴리즈 시 에러가 발생해야 한다', async () => {
|
|
411
|
+
// Given
|
|
412
|
+
const releasedKpi = {
|
|
413
|
+
id: 'kpi-123',
|
|
414
|
+
name: '이미 릴리즈된 KPI',
|
|
415
|
+
state: KpiStatus.RELEASE,
|
|
416
|
+
domain: testDomain
|
|
417
|
+
} as Kpi
|
|
418
|
+
|
|
419
|
+
mockKpiRepository.findOne.mockResolvedValue(releasedKpi)
|
|
420
|
+
|
|
421
|
+
// When & Then
|
|
422
|
+
await expect(kpiService.releaseKpi('kpi-123', testUser, testDomain))
|
|
423
|
+
.rejects.toThrow('KPI is already in RELEASE state')
|
|
424
|
+
})
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
describe('KPI 조회 서비스 테스트', () => {
|
|
428
|
+
it('페이지네이션을 포함한 KPI 목록 조회가 성공해야 한다', async () => {
|
|
429
|
+
// Given
|
|
430
|
+
const mockKpis = [
|
|
431
|
+
{ id: 'kpi-1', name: 'KPI 1' },
|
|
432
|
+
{ id: 'kpi-2', name: 'KPI 2' },
|
|
433
|
+
{ id: 'kpi-3', name: 'KPI 3' }
|
|
434
|
+
] as Kpi[]
|
|
435
|
+
|
|
436
|
+
const mockQueryBuilder = {
|
|
437
|
+
where: jest.fn().mockReturnThis(),
|
|
438
|
+
andWhere: jest.fn().mockReturnThis(),
|
|
439
|
+
orderBy: jest.fn().mockReturnThis(),
|
|
440
|
+
skip: jest.fn().mockReturnThis(),
|
|
441
|
+
take: jest.fn().mockReturnThis(),
|
|
442
|
+
getManyAndCount: jest.fn().mockResolvedValue([mockKpis, 15])
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
mockKpiRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder)
|
|
446
|
+
|
|
447
|
+
const queryOptions = {
|
|
448
|
+
page: 1,
|
|
449
|
+
limit: 10,
|
|
450
|
+
sortBy: 'name',
|
|
451
|
+
sortDirection: 'ASC' as const,
|
|
452
|
+
filters: {
|
|
453
|
+
active: true,
|
|
454
|
+
state: KpiStatus.RELEASE
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// When
|
|
459
|
+
const result = await kpiService.getKpiList(testDomain, queryOptions)
|
|
460
|
+
|
|
461
|
+
// Then
|
|
462
|
+
expect(result.items).toEqual(mockKpis)
|
|
463
|
+
expect(result.total).toBe(15)
|
|
464
|
+
expect(result.page).toBe(1)
|
|
465
|
+
expect(result.pageSize).toBe(10)
|
|
466
|
+
expect(result.totalPages).toBe(2)
|
|
467
|
+
|
|
468
|
+
expect(mockQueryBuilder.where).toHaveBeenCalledWith('kpi.domainId = :domainId', { domainId: testDomain.id })
|
|
469
|
+
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('kpi.active = :active', { active: true })
|
|
470
|
+
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('kpi.state = :state', { state: KpiStatus.RELEASE })
|
|
471
|
+
expect(mockQueryBuilder.orderBy).toHaveBeenCalledWith('kpi.name', 'ASC')
|
|
472
|
+
expect(mockQueryBuilder.skip).toHaveBeenCalledWith(0)
|
|
473
|
+
expect(mockQueryBuilder.take).toHaveBeenCalledWith(10)
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
it('계층 구조를 포함한 KPI 상세 조회가 성공해야 한다', async () => {
|
|
477
|
+
// Given
|
|
478
|
+
const mockKpi = {
|
|
479
|
+
id: 'kpi-123',
|
|
480
|
+
name: '테스트 KPI',
|
|
481
|
+
parent: { id: 'parent-123', name: '부모 KPI' },
|
|
482
|
+
children: [
|
|
483
|
+
{ id: 'child-1', name: '하위 KPI 1' },
|
|
484
|
+
{ id: 'child-2', name: '하위 KPI 2' }
|
|
485
|
+
]
|
|
486
|
+
} as Kpi
|
|
487
|
+
|
|
488
|
+
mockKpiRepository.findOne.mockResolvedValue(mockKpi)
|
|
489
|
+
|
|
490
|
+
// When
|
|
491
|
+
const result = await kpiService.getKpiById('kpi-123', testDomain, {
|
|
492
|
+
includeParent: true,
|
|
493
|
+
includeChildren: true,
|
|
494
|
+
includeHistory: false
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
// Then
|
|
498
|
+
expect(result).toEqual(mockKpi)
|
|
499
|
+
expect(mockKpiRepository.findOne).toHaveBeenCalledWith({
|
|
500
|
+
where: { id: 'kpi-123', domain: { id: testDomain.id } },
|
|
501
|
+
relations: ['parent', 'children']
|
|
502
|
+
})
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
it('검색 조건을 포함한 KPI 조회가 성공해야 한다', async () => {
|
|
506
|
+
// Given
|
|
507
|
+
const mockQueryBuilder = {
|
|
508
|
+
where: jest.fn().mockReturnThis(),
|
|
509
|
+
andWhere: jest.fn().mockReturnThis(),
|
|
510
|
+
orderBy: jest.fn().mockReturnThis(),
|
|
511
|
+
getMany: jest.fn().mockResolvedValue([
|
|
512
|
+
{ id: 'kpi-1', name: '안전 관련 KPI' },
|
|
513
|
+
{ id: 'kpi-2', name: '안전사고율' }
|
|
514
|
+
])
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
mockKpiRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder)
|
|
518
|
+
|
|
519
|
+
// When
|
|
520
|
+
const result = await kpiService.searchKpis(testDomain, {
|
|
521
|
+
keyword: '안전',
|
|
522
|
+
category: 'SAFETY',
|
|
523
|
+
isLeaf: true,
|
|
524
|
+
active: true
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
// Then
|
|
528
|
+
expect(result).toHaveLength(2)
|
|
529
|
+
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
|
530
|
+
'kpi.name ILIKE :keyword OR kpi.description ILIKE :keyword',
|
|
531
|
+
{ keyword: '%안전%' }
|
|
532
|
+
)
|
|
533
|
+
})
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
describe('KPI 계층 구조 서비스 테스트', () => {
|
|
537
|
+
it('KPI 트리 구조 조회가 성공해야 한다', async () => {
|
|
538
|
+
// Given
|
|
539
|
+
const mockKpiTree = [
|
|
540
|
+
{
|
|
541
|
+
id: 'root-1',
|
|
542
|
+
name: '루트 KPI 1',
|
|
543
|
+
children: [
|
|
544
|
+
{
|
|
545
|
+
id: 'child-1-1',
|
|
546
|
+
name: '하위 KPI 1-1',
|
|
547
|
+
children: [
|
|
548
|
+
{ id: 'leaf-1-1-1', name: '말단 KPI 1-1-1', children: [] }
|
|
549
|
+
]
|
|
550
|
+
}
|
|
551
|
+
]
|
|
552
|
+
},
|
|
553
|
+
{
|
|
554
|
+
id: 'root-2',
|
|
555
|
+
name: '루트 KPI 2',
|
|
556
|
+
children: []
|
|
557
|
+
}
|
|
558
|
+
]
|
|
559
|
+
|
|
560
|
+
mockKpiRepository.find.mockResolvedValue([
|
|
561
|
+
{ id: 'root-1', name: '루트 KPI 1', parent: null },
|
|
562
|
+
{ id: 'root-2', name: '루트 KPI 2', parent: null },
|
|
563
|
+
{ id: 'child-1-1', name: '하위 KPI 1-1', parent: { id: 'root-1' } },
|
|
564
|
+
{ id: 'leaf-1-1-1', name: '말단 KPI 1-1-1', parent: { id: 'child-1-1' } }
|
|
565
|
+
])
|
|
566
|
+
|
|
567
|
+
// When
|
|
568
|
+
const result = await kpiService.getKpiTree(testDomain)
|
|
569
|
+
|
|
570
|
+
// Then
|
|
571
|
+
expect(result).toHaveLength(2)
|
|
572
|
+
expect(result[0].name).toBe('루트 KPI 1')
|
|
573
|
+
expect(result[0].children).toHaveLength(1)
|
|
574
|
+
expect(result[0].children[0].children).toHaveLength(1)
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
it('특정 KPI의 경로(breadcrumb) 조회가 성공해야 한다', async () => {
|
|
578
|
+
// Given
|
|
579
|
+
const leafKpi = {
|
|
580
|
+
id: 'leaf-123',
|
|
581
|
+
name: '말단 KPI',
|
|
582
|
+
parent: {
|
|
583
|
+
id: 'middle-123',
|
|
584
|
+
name: '중간 KPI',
|
|
585
|
+
parent: {
|
|
586
|
+
id: 'root-123',
|
|
587
|
+
name: '루트 KPI',
|
|
588
|
+
parent: null
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
} as Kpi
|
|
592
|
+
|
|
593
|
+
mockKpiRepository.findOne.mockResolvedValue(leafKpi)
|
|
594
|
+
|
|
595
|
+
// When
|
|
596
|
+
const result = await kpiService.getKpiPath('leaf-123', testDomain)
|
|
597
|
+
|
|
598
|
+
// Then
|
|
599
|
+
expect(result).toEqual([
|
|
600
|
+
{ id: 'root-123', name: '루트 KPI' },
|
|
601
|
+
{ id: 'middle-123', name: '중간 KPI' },
|
|
602
|
+
{ id: 'leaf-123', name: '말단 KPI' }
|
|
603
|
+
])
|
|
604
|
+
})
|
|
605
|
+
|
|
606
|
+
it('순환 참조 검증이 성공해야 한다', async () => {
|
|
607
|
+
// Given - A -> B -> C -> A 순환 참조 시도
|
|
608
|
+
const kpiA = { id: 'kpi-a', name: 'KPI A' } as Kpi
|
|
609
|
+
const kpiB = { id: 'kpi-b', name: 'KPI B', parent: kpiA } as Kpi
|
|
610
|
+
const kpiC = { id: 'kpi-c', name: 'KPI C', parent: kpiB } as Kpi
|
|
611
|
+
|
|
612
|
+
mockKpiRepository.findOne
|
|
613
|
+
.mockResolvedValueOnce(kpiC) // kpi-c 조회
|
|
614
|
+
.mockResolvedValueOnce(kpiB) // kpi-b 조회
|
|
615
|
+
.mockResolvedValueOnce(kpiA) // kpi-a 조회
|
|
616
|
+
|
|
617
|
+
// When & Then - kpi-a의 부모를 kpi-c로 설정하려고 할 때
|
|
618
|
+
await expect(kpiService.updateKpi('kpi-a', { parent: { id: 'kpi-c' } }, testUser, testDomain))
|
|
619
|
+
.rejects.toThrow('Circular reference detected in KPI hierarchy')
|
|
620
|
+
})
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
describe('KPI 복사 서비스 테스트', () => {
|
|
624
|
+
it('KPI 복사가 성공해야 한다', async () => {
|
|
625
|
+
// Given
|
|
626
|
+
const sourceKpi = {
|
|
627
|
+
id: 'source-kpi-123',
|
|
628
|
+
name: '원본 KPI',
|
|
629
|
+
description: '원본 설명',
|
|
630
|
+
formula: 'source_formula',
|
|
631
|
+
vizType: KpiVizType.GAUGE,
|
|
632
|
+
vizMeta: { unit: '%', min: 0, max: 100 },
|
|
633
|
+
state: KpiStatus.RELEASE,
|
|
634
|
+
version: 5,
|
|
635
|
+
domain: testDomain
|
|
636
|
+
} as Kpi
|
|
637
|
+
|
|
638
|
+
const copiedKpi = {
|
|
639
|
+
id: 'copied-kpi-123',
|
|
640
|
+
name: '복사된 KPI',
|
|
641
|
+
description: '원본 설명',
|
|
642
|
+
formula: 'source_formula',
|
|
643
|
+
vizType: KpiVizType.GAUGE,
|
|
644
|
+
vizMeta: { unit: '%', min: 0, max: 100 },
|
|
645
|
+
state: KpiStatus.DRAFT, // 복사본은 DRAFT로 시작
|
|
646
|
+
version: 1,
|
|
647
|
+
domain: testDomain
|
|
648
|
+
} as Kpi
|
|
649
|
+
|
|
650
|
+
mockKpiRepository.findOne.mockResolvedValue(sourceKpi)
|
|
651
|
+
mockKpiRepository.save.mockResolvedValue(copiedKpi)
|
|
652
|
+
|
|
653
|
+
// When
|
|
654
|
+
const result = await kpiService.copyKpi('source-kpi-123', {
|
|
655
|
+
name: '복사된 KPI',
|
|
656
|
+
copyChildren: false
|
|
657
|
+
}, testUser, testDomain)
|
|
658
|
+
|
|
659
|
+
// Then
|
|
660
|
+
expect(result.name).toBe('복사된 KPI')
|
|
661
|
+
expect(result.state).toBe(KpiStatus.DRAFT)
|
|
662
|
+
expect(result.version).toBe(1)
|
|
663
|
+
expect(result.formula).toBe('source_formula')
|
|
664
|
+
expect(result.vizMeta).toEqual({ unit: '%', min: 0, max: 100 })
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
it('하위 KPI를 포함한 복사가 성공해야 한다', async () => {
|
|
668
|
+
// Given
|
|
669
|
+
const sourceParent = {
|
|
670
|
+
id: 'parent-123',
|
|
671
|
+
name: '부모 KPI',
|
|
672
|
+
children: [
|
|
673
|
+
{ id: 'child-1', name: '하위 KPI 1' },
|
|
674
|
+
{ id: 'child-2', name: '하위 KPI 2' }
|
|
675
|
+
]
|
|
676
|
+
} as Kpi
|
|
677
|
+
|
|
678
|
+
mockKpiRepository.findOne.mockResolvedValue(sourceParent)
|
|
679
|
+
mockKpiRepository.save
|
|
680
|
+
.mockResolvedValueOnce({ id: 'copied-parent-123', name: '복사된 부모 KPI' } as Kpi)
|
|
681
|
+
.mockResolvedValueOnce({ id: 'copied-child-1', name: '복사된 하위 KPI 1' } as Kpi)
|
|
682
|
+
.mockResolvedValueOnce({ id: 'copied-child-2', name: '복사된 하위 KPI 2' } as Kpi)
|
|
683
|
+
|
|
684
|
+
// When
|
|
685
|
+
const result = await kpiService.copyKpi('parent-123', {
|
|
686
|
+
name: '복사된 부모 KPI',
|
|
687
|
+
copyChildren: true
|
|
688
|
+
}, testUser, testDomain)
|
|
689
|
+
|
|
690
|
+
// Then
|
|
691
|
+
expect(mockKpiRepository.save).toHaveBeenCalledTimes(3) // 부모 + 자식 2개
|
|
692
|
+
})
|
|
693
|
+
})
|
|
694
|
+
|
|
695
|
+
describe('KPI 벌크 작업 서비스 테스트', () => {
|
|
696
|
+
it('여러 KPI 일괄 활성화가 성공해야 한다', async () => {
|
|
697
|
+
// Given
|
|
698
|
+
const kpiIds = ['kpi-1', 'kpi-2', 'kpi-3']
|
|
699
|
+
const mockQueryBuilder = {
|
|
700
|
+
update: jest.fn().mockReturnThis(),
|
|
701
|
+
set: jest.fn().mockReturnThis(),
|
|
702
|
+
where: jest.fn().mockReturnThis(),
|
|
703
|
+
andWhere: jest.fn().mockReturnThis(),
|
|
704
|
+
execute: jest.fn().mockResolvedValue({ affected: 3 })
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
mockKpiRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder)
|
|
708
|
+
|
|
709
|
+
// When
|
|
710
|
+
const result = await kpiService.bulkUpdateKpis(kpiIds, {
|
|
711
|
+
active: true
|
|
712
|
+
}, testUser, testDomain)
|
|
713
|
+
|
|
714
|
+
// Then
|
|
715
|
+
expect(result.affected).toBe(3)
|
|
716
|
+
expect(mockQueryBuilder.set).toHaveBeenCalledWith({ active: true })
|
|
717
|
+
expect(mockQueryBuilder.where).toHaveBeenCalledWith('id IN (:...ids)', { ids: kpiIds })
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
it('여러 KPI 일괄 삭제가 성공해야 한다', async () => {
|
|
721
|
+
// Given
|
|
722
|
+
const kpiIds = ['kpi-1', 'kpi-2']
|
|
723
|
+
|
|
724
|
+
// 삭제할 KPI들이 하위 KPI를 가지지 않음을 확인
|
|
725
|
+
mockKpiRepository.find.mockResolvedValue([])
|
|
726
|
+
|
|
727
|
+
const mockQueryBuilder = {
|
|
728
|
+
softDelete: jest.fn().mockReturnThis(),
|
|
729
|
+
where: jest.fn().mockReturnThis(),
|
|
730
|
+
andWhere: jest.fn().mockReturnThis(),
|
|
731
|
+
execute: jest.fn().mockResolvedValue({ affected: 2 })
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
mockKpiRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder)
|
|
735
|
+
|
|
736
|
+
// When
|
|
737
|
+
const result = await kpiService.bulkDeleteKpis(kpiIds, testUser, testDomain)
|
|
738
|
+
|
|
739
|
+
// Then
|
|
740
|
+
expect(result.deleted).toBe(2)
|
|
741
|
+
expect(mockQueryBuilder.where).toHaveBeenCalledWith('id IN (:...ids)', { ids: kpiIds })
|
|
742
|
+
})
|
|
743
|
+
|
|
744
|
+
it('하위 KPI가 있는 경우 일괄 삭제가 실패해야 한다', async () => {
|
|
745
|
+
// Given
|
|
746
|
+
const kpiIds = ['parent-kpi-1', 'parent-kpi-2']
|
|
747
|
+
|
|
748
|
+
// 하위 KPI들이 존재
|
|
749
|
+
mockKpiRepository.find.mockResolvedValue([
|
|
750
|
+
{ id: 'child-1', name: '하위 KPI 1', parent: { id: 'parent-kpi-1' } }
|
|
751
|
+
])
|
|
752
|
+
|
|
753
|
+
// When & Then
|
|
754
|
+
await expect(kpiService.bulkDeleteKpis(kpiIds, testUser, testDomain))
|
|
755
|
+
.rejects.toThrow('Cannot delete KPIs with child KPIs')
|
|
756
|
+
})
|
|
757
|
+
})
|
|
758
|
+
|
|
759
|
+
describe('KPI 수식 검증 서비스 테스트', () => {
|
|
760
|
+
it('유효한 수식 검증이 성공해야 한다', async () => {
|
|
761
|
+
// Given
|
|
762
|
+
const validFormulas = [
|
|
763
|
+
'metric1 + metric2',
|
|
764
|
+
'sum(metric1, metric2) / count',
|
|
765
|
+
'if(metric1 > 0, metric2 / metric1 * 100, 0)',
|
|
766
|
+
'round(avg(metric1, metric2, metric3), 2)'
|
|
767
|
+
]
|
|
768
|
+
|
|
769
|
+
// When & Then
|
|
770
|
+
for (const formula of validFormulas) {
|
|
771
|
+
const result = await kpiService.validateFormula(formula, testDomain)
|
|
772
|
+
expect(result.valid).toBe(true)
|
|
773
|
+
expect(result.errors).toHaveLength(0)
|
|
774
|
+
}
|
|
775
|
+
})
|
|
776
|
+
|
|
777
|
+
it('잘못된 수식 검증이 실패해야 한다', async () => {
|
|
778
|
+
// Given
|
|
779
|
+
const invalidFormulas = [
|
|
780
|
+
{ formula: 'metric1 +', expectedError: 'Incomplete expression' },
|
|
781
|
+
{ formula: 'unknown_func(metric1)', expectedError: 'Unknown function: unknown_func' },
|
|
782
|
+
{ formula: 'if(metric1)', expectedError: 'Invalid number of arguments for function: if' },
|
|
783
|
+
{ formula: 'metric1 ** metric2', expectedError: 'Unsupported operator: **' }
|
|
784
|
+
]
|
|
785
|
+
|
|
786
|
+
// When & Then
|
|
787
|
+
for (const testCase of invalidFormulas) {
|
|
788
|
+
const result = await kpiService.validateFormula(testCase.formula, testDomain)
|
|
789
|
+
expect(result.valid).toBe(false)
|
|
790
|
+
expect(result.errors).toContain(testCase.expectedError)
|
|
791
|
+
}
|
|
792
|
+
})
|
|
793
|
+
|
|
794
|
+
it('수식에 사용된 메트릭 추출이 성공해야 한다', async () => {
|
|
795
|
+
// Given
|
|
796
|
+
const formula = 'defect_count / total_count * 100 + adjustment_factor'
|
|
797
|
+
|
|
798
|
+
// When
|
|
799
|
+
const result = await kpiService.extractMetricsFromFormula(formula)
|
|
800
|
+
|
|
801
|
+
// Then
|
|
802
|
+
expect(result).toEqual(['defect_count', 'total_count', 'adjustment_factor'])
|
|
803
|
+
})
|
|
804
|
+
})
|
|
805
|
+
})
|
|
806
|
+
```
|
|
807
|
+
|
|
808
|
+
## 5. KPI 값 계산 서비스 테스트 (kpi-value-calculation.spec.ts)
|
|
809
|
+
|
|
810
|
+
```typescript
|
|
811
|
+
import { describe, it, expect, beforeEach, jest } from '@jest/globals'
|
|
812
|
+
import { KpiValueCalculationService } from '../server/service/kpi-value/kpi-value-calculation.service'
|
|
813
|
+
import { KpiValue } from '../server/service/kpi-value/kpi-value'
|
|
814
|
+
import { Kpi } from '../server/service/kpi/kpi'
|
|
815
|
+
import { KpiMetric } from '../server/service/kpi-metric/kpi-metric'
|
|
816
|
+
import { DataSample } from '@things-factory/dataset'
|
|
817
|
+
|
|
818
|
+
describe('KPI Value Calculation Service Tests', () => {
|
|
819
|
+
let calculationService: KpiValueCalculationService
|
|
820
|
+
let mockValueProvider: jest.Mock
|
|
821
|
+
let mockDataSampleRepository: jest.Mock
|
|
822
|
+
|
|
823
|
+
beforeEach(() => {
|
|
824
|
+
mockValueProvider = jest.fn()
|
|
825
|
+
mockDataSampleRepository = jest.fn()
|
|
826
|
+
|
|
827
|
+
calculationService = new KpiValueCalculationService(
|
|
828
|
+
mockDataSampleRepository,
|
|
829
|
+
mockValueProvider
|
|
830
|
+
)
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
describe('메트릭 기반 KPI 값 계산 테스트', () => {
|
|
834
|
+
it('간단한 비율 계산이 정확해야 한다', async () => {
|
|
835
|
+
// Given
|
|
836
|
+
const kpi = {
|
|
837
|
+
id: 'kpi-123',
|
|
838
|
+
name: '불량률',
|
|
839
|
+
formula: 'defect_count / total_count * 100',
|
|
840
|
+
metrics: [
|
|
841
|
+
{ code: 'defect_count', datasetId: 'quality-ds' },
|
|
842
|
+
{ code: 'total_count', datasetId: 'quality-ds' }
|
|
843
|
+
]
|
|
844
|
+
} as any
|
|
845
|
+
|
|
846
|
+
mockValueProvider
|
|
847
|
+
.mockResolvedValueOnce(25) // defect_count
|
|
848
|
+
.mockResolvedValueOnce(1000) // total_count
|
|
849
|
+
|
|
850
|
+
// When
|
|
851
|
+
const result = await calculationService.calculateKpiValue(kpi, {
|
|
852
|
+
startDate: new Date('2024-01-01'),
|
|
853
|
+
endDate: new Date('2024-01-31'),
|
|
854
|
+
orgScopeId: 'org-123'
|
|
855
|
+
})
|
|
856
|
+
|
|
857
|
+
// Then
|
|
858
|
+
expect(result.value).toBe(2.5) // 25 / 1000 * 100
|
|
859
|
+
expect(result.formula).toBe('defect_count / total_count * 100')
|
|
860
|
+
expect(result.calculatedAt).toBeDefined()
|
|
861
|
+
})
|
|
862
|
+
|
|
863
|
+
it('복합 수식 계산이 정확해야 한다', async () => {
|
|
864
|
+
// Given
|
|
865
|
+
const kpi = {
|
|
866
|
+
id: 'kpi-456',
|
|
867
|
+
name: '종합 성과 지수',
|
|
868
|
+
formula: 'round((quality_score * 0.4 + efficiency_score * 0.3 + safety_score * 0.3), 2)',
|
|
869
|
+
metrics: [
|
|
870
|
+
{ code: 'quality_score', datasetId: 'kpi-ds' },
|
|
871
|
+
{ code: 'efficiency_score', datasetId: 'kpi-ds' },
|
|
872
|
+
{ code: 'safety_score', datasetId: 'kpi-ds' }
|
|
873
|
+
]
|
|
874
|
+
} as any
|
|
875
|
+
|
|
876
|
+
mockValueProvider
|
|
877
|
+
.mockResolvedValueOnce(85) // quality_score
|
|
878
|
+
.mockResolvedValueOnce(90) // efficiency_score
|
|
879
|
+
.mockResolvedValueOnce(88) // safety_score
|
|
880
|
+
|
|
881
|
+
// When
|
|
882
|
+
const result = await calculationService.calculateKpiValue(kpi, {
|
|
883
|
+
startDate: new Date('2024-01-01'),
|
|
884
|
+
endDate: new Date('2024-01-31'),
|
|
885
|
+
orgScopeId: 'org-123'
|
|
886
|
+
})
|
|
887
|
+
|
|
888
|
+
// Then
|
|
889
|
+
// 85*0.4 + 90*0.3 + 88*0.3 = 34 + 27 + 26.4 = 87.4
|
|
890
|
+
expect(result.value).toBe(87.4)
|
|
891
|
+
})
|
|
892
|
+
|
|
893
|
+
it('Zero Division 방지 로직이 동작해야 한다', async () => {
|
|
894
|
+
// Given
|
|
895
|
+
const kpi = {
|
|
896
|
+
id: 'kpi-789',
|
|
897
|
+
name: '생산성',
|
|
898
|
+
formula: 'if(input_hours > 0, output_qty / input_hours, 0)',
|
|
899
|
+
metrics: [
|
|
900
|
+
{ code: 'output_qty', datasetId: 'prod-ds' },
|
|
901
|
+
{ code: 'input_hours', datasetId: 'prod-ds' }
|
|
902
|
+
]
|
|
903
|
+
} as any
|
|
904
|
+
|
|
905
|
+
mockValueProvider
|
|
906
|
+
.mockResolvedValueOnce(1200) // output_qty
|
|
907
|
+
.mockResolvedValueOnce(0) // input_hours (0으로 나누기 상황)
|
|
908
|
+
|
|
909
|
+
// When
|
|
910
|
+
const result = await calculationService.calculateKpiValue(kpi, {
|
|
911
|
+
startDate: new Date('2024-01-01'),
|
|
912
|
+
endDate: new Date('2024-01-31'),
|
|
913
|
+
orgScopeId: 'org-123'
|
|
914
|
+
})
|
|
915
|
+
|
|
916
|
+
// Then
|
|
917
|
+
expect(result.value).toBe(0) // Zero division 방지로 0 반환
|
|
918
|
+
})
|
|
919
|
+
})
|
|
920
|
+
|
|
921
|
+
describe('시계열 집계 계산 테스트', () => {
|
|
922
|
+
it('일별 데이터를 월별로 집계가 정확해야 한다', async () => {
|
|
923
|
+
// Given
|
|
924
|
+
const kpi = {
|
|
925
|
+
id: 'kpi-daily',
|
|
926
|
+
name: '일일 처리량 월평균',
|
|
927
|
+
formula: 'avg(daily_volume)',
|
|
928
|
+
periodType: 'MONTH',
|
|
929
|
+
metrics: [{ code: 'daily_volume', datasetId: 'volume-ds' }]
|
|
930
|
+
} as any
|
|
931
|
+
|
|
932
|
+
const mockDailySamples = [
|
|
933
|
+
{ date: '2024-01-01', value: 100 },
|
|
934
|
+
{ date: '2024-01-02', value: 120 },
|
|
935
|
+
{ date: '2024-01-03', value: 110 },
|
|
936
|
+
// ... 31일간 데이터
|
|
937
|
+
]
|
|
938
|
+
|
|
939
|
+
mockDataSampleRepository.mockResolvedValue(mockDailySamples)
|
|
940
|
+
|
|
941
|
+
// When
|
|
942
|
+
const result = await calculationService.calculateKpiValueWithAggregation(kpi, {
|
|
943
|
+
startDate: new Date('2024-01-01'),
|
|
944
|
+
endDate: new Date('2024-01-31'),
|
|
945
|
+
aggregationType: 'MONTHLY',
|
|
946
|
+
orgScopeId: 'org-123'
|
|
947
|
+
})
|
|
948
|
+
|
|
949
|
+
// Then
|
|
950
|
+
expect(result.aggregationType).toBe('MONTHLY')
|
|
951
|
+
expect(result.sourceDataCount).toBe(mockDailySamples.length)
|
|
952
|
+
expect(result.value).toBeGreaterThan(0)
|
|
953
|
+
})
|
|
954
|
+
|
|
955
|
+
it('주간 단위 집계가 정확해야 한다', async () => {
|
|
956
|
+
// Given
|
|
957
|
+
const kpi = {
|
|
958
|
+
id: 'kpi-weekly',
|
|
959
|
+
name: '주간 안전 점수',
|
|
960
|
+
formula: 'sum(weekly_safety_incidents)',
|
|
961
|
+
periodType: 'WEEK',
|
|
962
|
+
metrics: [{ code: 'weekly_safety_incidents', datasetId: 'safety-ds' }]
|
|
963
|
+
} as any
|
|
964
|
+
|
|
965
|
+
const mockWeeklyData = [
|
|
966
|
+
{ week: 1, value: 2 },
|
|
967
|
+
{ week: 2, value: 1 },
|
|
968
|
+
{ week: 3, value: 3 },
|
|
969
|
+
{ week: 4, value: 0 }
|
|
970
|
+
]
|
|
971
|
+
|
|
972
|
+
mockValueProvider.mockImplementation((metric) => {
|
|
973
|
+
return Promise.resolve(mockWeeklyData.reduce((sum, d) => sum + d.value, 0))
|
|
974
|
+
})
|
|
975
|
+
|
|
976
|
+
// When
|
|
977
|
+
const result = await calculationService.calculateKpiValue(kpi, {
|
|
978
|
+
startDate: new Date('2024-01-01'),
|
|
979
|
+
endDate: new Date('2024-01-28'),
|
|
980
|
+
aggregationType: 'WEEKLY',
|
|
981
|
+
orgScopeId: 'org-123'
|
|
982
|
+
})
|
|
983
|
+
|
|
984
|
+
// Then
|
|
985
|
+
expect(result.value).toBe(6) // 2 + 1 + 3 + 0 = 6
|
|
986
|
+
})
|
|
987
|
+
})
|
|
988
|
+
|
|
989
|
+
describe('성과 점수 계산 테스트', () => {
|
|
990
|
+
it('등급 기반 점수 계산이 정확해야 한다', async () => {
|
|
991
|
+
// Given
|
|
992
|
+
const kpi = {
|
|
993
|
+
id: 'kpi-grade',
|
|
994
|
+
name: '품질 등급',
|
|
995
|
+
grades: {
|
|
996
|
+
'A': { minValue: 90, maxValue: 100, score: 100, color: '#43a047' },
|
|
997
|
+
'B': { minValue: 80, maxValue: 90, score: 80, color: '#66bb6a' },
|
|
998
|
+
'C': { minValue: 70, maxValue: 80, score: 60, color: '#ffeb3b' },
|
|
999
|
+
'D': { minValue: 60, maxValue: 70, score: 40, color: '#ffa726' },
|
|
1000
|
+
'F': { minValue: 0, maxValue: 60, score: 20, color: '#ff4757' }
|
|
1001
|
+
}
|
|
1002
|
+
} as any
|
|
1003
|
+
|
|
1004
|
+
const testCases = [
|
|
1005
|
+
{ value: 95, expectedScore: 100, expectedGrade: 'A' },
|
|
1006
|
+
{ value: 85, expectedScore: 80, expectedGrade: 'B' },
|
|
1007
|
+
{ value: 75, expectedScore: 60, expectedGrade: 'C' },
|
|
1008
|
+
{ value: 65, expectedScore: 40, expectedGrade: 'D' },
|
|
1009
|
+
{ value: 55, expectedScore: 20, expectedGrade: 'F' }
|
|
1010
|
+
]
|
|
1011
|
+
|
|
1012
|
+
for (const testCase of testCases) {
|
|
1013
|
+
// When
|
|
1014
|
+
const result = await calculationService.calculatePerformanceScore(
|
|
1015
|
+
testCase.value,
|
|
1016
|
+
kpi
|
|
1017
|
+
)
|
|
1018
|
+
|
|
1019
|
+
// Then
|
|
1020
|
+
expect(result.score).toBe(testCase.expectedScore)
|
|
1021
|
+
expect(result.grade).toBe(testCase.expectedGrade)
|
|
1022
|
+
expect(result.color).toBeDefined()
|
|
1023
|
+
}
|
|
1024
|
+
})
|
|
1025
|
+
|
|
1026
|
+
it('수식 기반 점수 계산이 정확해야 한다', async () => {
|
|
1027
|
+
// Given
|
|
1028
|
+
const kpi = {
|
|
1029
|
+
id: 'kpi-formula-score',
|
|
1030
|
+
name: '효율성 지수',
|
|
1031
|
+
scoreFormula: 'if(value >= 95, 100, if(value >= 85, value * 0.9 + 15, value * 0.7))'
|
|
1032
|
+
} as any
|
|
1033
|
+
|
|
1034
|
+
const testCases = [
|
|
1035
|
+
{ value: 98, expectedScore: 100 }, // >= 95: 100점
|
|
1036
|
+
{ value: 90, expectedScore: 96 }, // 85~95: 90*0.9+15 = 96
|
|
1037
|
+
{ value: 80, expectedScore: 56 } // < 85: 80*0.7 = 56
|
|
1038
|
+
]
|
|
1039
|
+
|
|
1040
|
+
for (const testCase of testCases) {
|
|
1041
|
+
// When
|
|
1042
|
+
const result = await calculationService.calculatePerformanceScore(
|
|
1043
|
+
testCase.value,
|
|
1044
|
+
kpi
|
|
1045
|
+
)
|
|
1046
|
+
|
|
1047
|
+
// Then
|
|
1048
|
+
expect(result.score).toBe(testCase.expectedScore)
|
|
1049
|
+
}
|
|
1050
|
+
})
|
|
1051
|
+
|
|
1052
|
+
it('성과 지수 함수 기반 점수 계산이 정확해야 한다', async () => {
|
|
1053
|
+
// Given
|
|
1054
|
+
const kpi = {
|
|
1055
|
+
id: 'kpi-performance-index',
|
|
1056
|
+
name: '성과 지수',
|
|
1057
|
+
scoreFormula: 'performance_index(value, 2.5, 3.2, 2.0, 3.5) * 100'
|
|
1058
|
+
} as any
|
|
1059
|
+
|
|
1060
|
+
// When
|
|
1061
|
+
const result = await calculationService.calculatePerformanceScore(0.85, kpi)
|
|
1062
|
+
|
|
1063
|
+
// Then
|
|
1064
|
+
expect(result.score).toBeGreaterThanOrEqual(0)
|
|
1065
|
+
expect(result.score).toBeLessThanOrEqual(100)
|
|
1066
|
+
expect(Number.isFinite(result.score)).toBe(true)
|
|
1067
|
+
})
|
|
1068
|
+
})
|
|
1069
|
+
|
|
1070
|
+
describe('계산 에러 처리 테스트', () => {
|
|
1071
|
+
it('메트릭 데이터가 없을 때 기본값 반환해야 한다', async () => {
|
|
1072
|
+
// Given
|
|
1073
|
+
const kpi = {
|
|
1074
|
+
id: 'kpi-no-data',
|
|
1075
|
+
name: '데이터 없는 KPI',
|
|
1076
|
+
formula: 'missing_metric * 100',
|
|
1077
|
+
metrics: [{ code: 'missing_metric', datasetId: 'empty-ds' }]
|
|
1078
|
+
} as any
|
|
1079
|
+
|
|
1080
|
+
mockValueProvider.mockRejectedValue(new Error('No data found'))
|
|
1081
|
+
|
|
1082
|
+
// When
|
|
1083
|
+
const result = await calculationService.calculateKpiValue(kpi, {
|
|
1084
|
+
startDate: new Date('2024-01-01'),
|
|
1085
|
+
endDate: new Date('2024-01-31'),
|
|
1086
|
+
orgScopeId: 'org-123',
|
|
1087
|
+
defaultValue: 0
|
|
1088
|
+
})
|
|
1089
|
+
|
|
1090
|
+
// Then
|
|
1091
|
+
expect(result.value).toBe(0)
|
|
1092
|
+
expect(result.error).toBeDefined()
|
|
1093
|
+
expect(result.error.message).toContain('No data found')
|
|
1094
|
+
})
|
|
1095
|
+
|
|
1096
|
+
it('수식 계산 오류 시 적절한 에러 메시지를 반환해야 한다', async () => {
|
|
1097
|
+
// Given
|
|
1098
|
+
const kpi = {
|
|
1099
|
+
id: 'kpi-invalid-formula',
|
|
1100
|
+
name: '잘못된 수식 KPI',
|
|
1101
|
+
formula: 'log(-1)', // 음수의 로그 (수학적 오류)
|
|
1102
|
+
metrics: []
|
|
1103
|
+
} as any
|
|
1104
|
+
|
|
1105
|
+
// When & Then
|
|
1106
|
+
await expect(calculationService.calculateKpiValue(kpi, {
|
|
1107
|
+
startDate: new Date('2024-01-01'),
|
|
1108
|
+
endDate: new Date('2024-01-31'),
|
|
1109
|
+
orgScopeId: 'org-123'
|
|
1110
|
+
})).rejects.toThrow('Math domain error')
|
|
1111
|
+
})
|
|
1112
|
+
|
|
1113
|
+
it('타임아웃 처리가 정상 동작해야 한다', async () => {
|
|
1114
|
+
// Given
|
|
1115
|
+
const kpi = {
|
|
1116
|
+
id: 'kpi-timeout',
|
|
1117
|
+
name: '타임아웃 KPI',
|
|
1118
|
+
formula: 'slow_function(metric1)',
|
|
1119
|
+
metrics: [{ code: 'metric1', datasetId: 'slow-ds' }]
|
|
1120
|
+
} as any
|
|
1121
|
+
|
|
1122
|
+
// 10초 후에 응답하는 느린 함수 모킹
|
|
1123
|
+
mockValueProvider.mockImplementation(() =>
|
|
1124
|
+
new Promise(resolve => setTimeout(() => resolve(100), 10000))
|
|
1125
|
+
)
|
|
1126
|
+
|
|
1127
|
+
// When & Then
|
|
1128
|
+
await expect(calculationService.calculateKpiValue(kpi, {
|
|
1129
|
+
startDate: new Date('2024-01-01'),
|
|
1130
|
+
endDate: new Date('2024-01-31'),
|
|
1131
|
+
orgScopeId: 'org-123',
|
|
1132
|
+
timeout: 1000 // 1초 타임아웃
|
|
1133
|
+
})).rejects.toThrow('Calculation timeout')
|
|
1134
|
+
})
|
|
1135
|
+
})
|
|
1136
|
+
|
|
1137
|
+
describe('배치 계산 테스트', () => {
|
|
1138
|
+
it('여러 KPI 일괄 계산이 성공해야 한다', async () => {
|
|
1139
|
+
// Given
|
|
1140
|
+
const kpis = [
|
|
1141
|
+
{
|
|
1142
|
+
id: 'kpi-1',
|
|
1143
|
+
name: 'KPI 1',
|
|
1144
|
+
formula: 'metric1 * 100',
|
|
1145
|
+
metrics: [{ code: 'metric1', datasetId: 'ds1' }]
|
|
1146
|
+
},
|
|
1147
|
+
{
|
|
1148
|
+
id: 'kpi-2',
|
|
1149
|
+
name: 'KPI 2',
|
|
1150
|
+
formula: 'metric2 / 10',
|
|
1151
|
+
metrics: [{ code: 'metric2', datasetId: 'ds2' }]
|
|
1152
|
+
}
|
|
1153
|
+
] as any[]
|
|
1154
|
+
|
|
1155
|
+
mockValueProvider
|
|
1156
|
+
.mockResolvedValueOnce(1.5) // metric1
|
|
1157
|
+
.mockResolvedValueOnce(200) // metric2
|
|
1158
|
+
|
|
1159
|
+
// When
|
|
1160
|
+
const results = await calculationService.calculateMultipleKpis(kpis, {
|
|
1161
|
+
startDate: new Date('2024-01-01'),
|
|
1162
|
+
endDate: new Date('2024-01-31'),
|
|
1163
|
+
orgScopeId: 'org-123'
|
|
1164
|
+
})
|
|
1165
|
+
|
|
1166
|
+
// Then
|
|
1167
|
+
expect(results).toHaveLength(2)
|
|
1168
|
+
expect(results[0].kpiId).toBe('kpi-1')
|
|
1169
|
+
expect(results[0].value).toBe(150) // 1.5 * 100
|
|
1170
|
+
expect(results[1].kpiId).toBe('kpi-2')
|
|
1171
|
+
expect(results[1].value).toBe(20) // 200 / 10
|
|
1172
|
+
})
|
|
1173
|
+
|
|
1174
|
+
it('배치 계산 중 일부 실패해도 나머지는 계속 처리되어야 한다', async () => {
|
|
1175
|
+
// Given
|
|
1176
|
+
const kpis = [
|
|
1177
|
+
{ id: 'kpi-success', formula: 'metric1', metrics: [{ code: 'metric1' }] },
|
|
1178
|
+
{ id: 'kpi-fail', formula: 'missing_metric', metrics: [{ code: 'missing_metric' }] },
|
|
1179
|
+
{ id: 'kpi-success2', formula: 'metric2', metrics: [{ code: 'metric2' }] }
|
|
1180
|
+
] as any[]
|
|
1181
|
+
|
|
1182
|
+
mockValueProvider
|
|
1183
|
+
.mockResolvedValueOnce(100) // metric1 (성공)
|
|
1184
|
+
.mockRejectedValueOnce(new Error('Data not found')) // missing_metric (실패)
|
|
1185
|
+
.mockResolvedValueOnce(200) // metric2 (성공)
|
|
1186
|
+
|
|
1187
|
+
// When
|
|
1188
|
+
const results = await calculationService.calculateMultipleKpis(kpis, {
|
|
1189
|
+
startDate: new Date('2024-01-01'),
|
|
1190
|
+
endDate: new Date('2024-01-31'),
|
|
1191
|
+
orgScopeId: 'org-123',
|
|
1192
|
+
continueOnError: true
|
|
1193
|
+
})
|
|
1194
|
+
|
|
1195
|
+
// Then
|
|
1196
|
+
expect(results).toHaveLength(3)
|
|
1197
|
+
expect(results[0].success).toBe(true)
|
|
1198
|
+
expect(results[0].value).toBe(100)
|
|
1199
|
+
expect(results[1].success).toBe(false)
|
|
1200
|
+
expect(results[1].error).toBeDefined()
|
|
1201
|
+
expect(results[2].success).toBe(true)
|
|
1202
|
+
expect(results[2].value).toBe(200)
|
|
1203
|
+
})
|
|
1204
|
+
})
|
|
1205
|
+
|
|
1206
|
+
describe('캐싱 메커니즘 테스트', () => {
|
|
1207
|
+
it('동일한 조건의 계산 결과가 캐시되어야 한다', async () => {
|
|
1208
|
+
// Given
|
|
1209
|
+
const kpi = {
|
|
1210
|
+
id: 'kpi-cache-test',
|
|
1211
|
+
name: '캐시 테스트 KPI',
|
|
1212
|
+
formula: 'expensive_calculation(metric1)',
|
|
1213
|
+
metrics: [{ code: 'metric1', datasetId: 'cache-ds' }]
|
|
1214
|
+
} as any
|
|
1215
|
+
|
|
1216
|
+
const calculationParams = {
|
|
1217
|
+
startDate: new Date('2024-01-01'),
|
|
1218
|
+
endDate: new Date('2024-01-31'),
|
|
1219
|
+
orgScopeId: 'org-123'
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
mockValueProvider.mockResolvedValue(500)
|
|
1223
|
+
|
|
1224
|
+
// When - 첫 번째 호출
|
|
1225
|
+
const result1 = await calculationService.calculateKpiValue(kpi, calculationParams)
|
|
1226
|
+
|
|
1227
|
+
// When - 동일한 조건으로 두 번째 호출
|
|
1228
|
+
const result2 = await calculationService.calculateKpiValue(kpi, calculationParams)
|
|
1229
|
+
|
|
1230
|
+
// Then
|
|
1231
|
+
expect(result1.value).toBe(result2.value)
|
|
1232
|
+
expect(result2.fromCache).toBe(true)
|
|
1233
|
+
expect(mockValueProvider).toHaveBeenCalledTimes(1) // 첫 번째만 실제 계산
|
|
1234
|
+
})
|
|
1235
|
+
|
|
1236
|
+
it('캐시 만료 후 재계산이 수행되어야 한다', async () => {
|
|
1237
|
+
// Given
|
|
1238
|
+
const kpi = {
|
|
1239
|
+
id: 'kpi-cache-expire',
|
|
1240
|
+
name: '캐시 만료 테스트',
|
|
1241
|
+
formula: 'time_sensitive_metric',
|
|
1242
|
+
cacheExpiry: 100 // 100ms 후 만료
|
|
1243
|
+
} as any
|
|
1244
|
+
|
|
1245
|
+
mockValueProvider
|
|
1246
|
+
.mockResolvedValueOnce(100) // 첫 번째 호출
|
|
1247
|
+
.mockResolvedValueOnce(200) // 캐시 만료 후 두 번째 호출
|
|
1248
|
+
|
|
1249
|
+
const params = {
|
|
1250
|
+
startDate: new Date('2024-01-01'),
|
|
1251
|
+
endDate: new Date('2024-01-31'),
|
|
1252
|
+
orgScopeId: 'org-123'
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
// When
|
|
1256
|
+
const result1 = await calculationService.calculateKpiValue(kpi, params)
|
|
1257
|
+
|
|
1258
|
+
// 캐시 만료 대기
|
|
1259
|
+
await new Promise(resolve => setTimeout(resolve, 150))
|
|
1260
|
+
|
|
1261
|
+
const result2 = await calculationService.calculateKpiValue(kpi, params)
|
|
1262
|
+
|
|
1263
|
+
// Then
|
|
1264
|
+
expect(result1.value).toBe(100)
|
|
1265
|
+
expect(result2.value).toBe(200)
|
|
1266
|
+
expect(result2.fromCache).toBe(false)
|
|
1267
|
+
expect(mockValueProvider).toHaveBeenCalledTimes(2)
|
|
1268
|
+
})
|
|
1269
|
+
})
|
|
1270
|
+
})
|
|
1271
|
+
```
|
|
1272
|
+
|
|
1273
|
+
단위 테스트를 매우 상세하게 작성했습니다. 주요 특징:
|
|
1274
|
+
|
|
1275
|
+
1. **Mock 활용**: 외부 의존성을 모킹하여 격리된 테스트 환경 구성
|
|
1276
|
+
2. **Given-When-Then 패턴**: 명확한 테스트 구조
|
|
1277
|
+
3. **Edge Case 포함**: Zero Division, 타임아웃, 에러 처리 등
|
|
1278
|
+
4. **실제 사용 시나리오**: 복합 수식, 계층 구조, 배치 처리 등
|
|
1279
|
+
5. **성능 테스트**: 대용량 데이터, 캐싱 메커니즘
|
|
1280
|
+
6. **검증 로직**: 입력 검증, 순환 참조 검사 등
|
|
1281
|
+
|
|
1282
|
+
각 테스트는 실제 production 환경에서 발생할 수 있는 상황들을 다루고 있어 실용적입니다.
|
|
1283
|
+
|
|
1284
|
+
<function_calls>
|
|
1285
|
+
<invoke name="TodoWrite">
|
|
1286
|
+
<parameter name="todos">[{"content": "Create detailed unit test code examples for KPI module", "status": "completed", "activeForm": "Creating detailed unit test code examples for KPI module"}, {"content": "Write comprehensive entity validation tests", "status": "completed", "activeForm": "Writing comprehensive entity validation tests"}, {"content": "Create formula calculator test cases with edge cases", "status": "completed", "activeForm": "Creating formula calculator test cases with edge cases"}, {"content": "Write KPI value management test scenarios", "status": "completed", "activeForm": "Writing KPI value management test scenarios"}, {"content": "Add service layer unit tests with mocking", "status": "completed", "activeForm": "Adding service layer unit tests with mocking"}]
|