@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.
Files changed (28) hide show
  1. package/KPI-STATISTICS-SERVICE.md +233 -0
  2. package/dist-client/components/kpi-single-boxplot-chart.js +109 -111
  3. package/dist-client/components/kpi-single-boxplot-chart.js.map +1 -1
  4. package/dist-client/pages/sv-project-completed-list.d.ts +3 -0
  5. package/dist-client/pages/sv-project-completed-list.js +70 -11
  6. package/dist-client/pages/sv-project-completed-list.js.map +1 -1
  7. package/dist-client/pages/sv-project-list.d.ts +3 -0
  8. package/dist-client/pages/sv-project-list.js +69 -11
  9. package/dist-client/pages/sv-project-list.js.map +1 -1
  10. package/dist-client/tsconfig.tsbuildinfo +1 -1
  11. package/dist-server/service/index.d.ts +1 -3
  12. package/dist-server/service/index.js +3 -4
  13. package/dist-server/service/index.js.map +1 -1
  14. package/dist-server/service/kpi-stat/index.d.ts +4 -0
  15. package/dist-server/service/kpi-stat/index.js +8 -0
  16. package/dist-server/service/kpi-stat/index.js.map +1 -0
  17. package/dist-server/service/kpi-stat/kpi-stat-query.d.ts +8 -0
  18. package/dist-server/service/kpi-stat/kpi-stat-query.js +225 -0
  19. package/dist-server/service/kpi-stat/kpi-stat-query.js.map +1 -0
  20. package/dist-server/service/kpi-stat/kpi-stat-types.d.ts +20 -0
  21. package/dist-server/service/kpi-stat/kpi-stat-types.js +78 -0
  22. package/dist-server/service/kpi-stat/kpi-stat-types.js.map +1 -0
  23. package/dist-server/tsconfig.tsbuildinfo +1 -1
  24. package/kpi-module-service-tests.md +1286 -0
  25. package/kpi-module-test-report.md +676 -0
  26. package/kpi-module-unit-test-detailed-report.md +925 -0
  27. package/kpi-module-unit-tests-detailed.md +1452 -0
  28. package/package.json +3 -3
@@ -0,0 +1,1452 @@
1
+ # KPI 모듈 단위 테스트 상세 코드
2
+
3
+ ## 개요
4
+ 본 문서는 Things Factory KPI 모듈의 단위 테스트 코드를 상세하게 제공합니다.
5
+
6
+ ## 1. KPI 엔티티 테스트 (kpi.spec.ts)
7
+
8
+ ```typescript
9
+ import { describe, it, expect, beforeEach } from '@jest/globals'
10
+ import { Repository, DataSource } from 'typeorm'
11
+ import { Kpi, KpiStatus, KpiVizType, KpiPeriodType } from '../server/service/kpi/kpi'
12
+ import { Domain } from '@things-factory/shell'
13
+ import { User } from '@things-factory/auth-base'
14
+ import { createTestingModule } from './test-utils'
15
+
16
+ describe('KPI Entity Tests', () => {
17
+ let dataSource: DataSource
18
+ let kpiRepository: Repository<Kpi>
19
+ let domainRepository: Repository<Domain>
20
+ let userRepository: Repository<User>
21
+ let testDomain: Domain
22
+ let testUser: User
23
+
24
+ beforeEach(async () => {
25
+ const module = await createTestingModule()
26
+ dataSource = module.get<DataSource>(DataSource)
27
+ kpiRepository = dataSource.getRepository(Kpi)
28
+ domainRepository = dataSource.getRepository(Domain)
29
+ userRepository = dataSource.getRepository(User)
30
+
31
+ // 테스트용 도메인 생성
32
+ testDomain = await domainRepository.save({
33
+ name: 'TEST_DOMAIN',
34
+ description: 'Test Domain for KPI Tests'
35
+ })
36
+
37
+ // 테스트용 사용자 생성
38
+ testUser = await userRepository.save({
39
+ name: 'Test User',
40
+ email: 'test@example.com',
41
+ domain: testDomain
42
+ })
43
+ })
44
+
45
+ describe('KPI 생성 테스트', () => {
46
+ it('기본 KPI 생성이 성공해야 한다', async () => {
47
+ // Given
48
+ const kpiData = {
49
+ name: '안전사고율',
50
+ description: '월별 안전사고 발생률',
51
+ domain: testDomain,
52
+ creator: testUser,
53
+ isLeaf: true,
54
+ formula: 'accident_count / total_hours * 1000',
55
+ active: true,
56
+ state: KpiStatus.DRAFT,
57
+ vizType: KpiVizType.GAUGE,
58
+ vizMeta: {
59
+ unit: '건/천시간',
60
+ min: 0,
61
+ max: 10,
62
+ thresholds: [2, 5, 8],
63
+ color: '#ff6b6b'
64
+ },
65
+ periodType: KpiPeriodType.MONTH,
66
+ weight: 1.0
67
+ }
68
+
69
+ // When
70
+ const savedKpi = await kpiRepository.save(kpiData)
71
+
72
+ // Then
73
+ expect(savedKpi.id).toBeDefined()
74
+ expect(savedKpi.name).toBe('안전사고율')
75
+ expect(savedKpi.version).toBe(1)
76
+ expect(savedKpi.isLeaf).toBe(true)
77
+ expect(savedKpi.formula).toBe('accident_count / total_hours * 1000')
78
+ expect(savedKpi.active).toBe(true)
79
+ expect(savedKpi.state).toBe(KpiStatus.DRAFT)
80
+ expect(savedKpi.vizType).toBe(KpiVizType.GAUGE)
81
+ expect(savedKpi.vizMeta.unit).toBe('건/천시간')
82
+ expect(savedKpi.periodType).toBe(KpiPeriodType.MONTH)
83
+ expect(savedKpi.weight).toBe(1.0)
84
+ expect(savedKpi.createdAt).toBeDefined()
85
+ })
86
+
87
+ it('필수 필드가 누락된 경우 에러가 발생해야 한다', async () => {
88
+ // Given
89
+ const invalidKpiData = {
90
+ description: '이름이 없는 KPI',
91
+ domain: testDomain
92
+ // name 필드가 누락됨
93
+ }
94
+
95
+ // When & Then
96
+ await expect(kpiRepository.save(invalidKpiData))
97
+ .rejects.toThrow()
98
+ })
99
+
100
+ it('중복된 이름의 KPI 생성이 실패해야 한다', async () => {
101
+ // Given
102
+ const kpiData = {
103
+ name: '중복_KPI',
104
+ domain: testDomain,
105
+ creator: testUser
106
+ }
107
+
108
+ await kpiRepository.save(kpiData)
109
+
110
+ // When & Then
111
+ await expect(kpiRepository.save({...kpiData}))
112
+ .rejects.toThrow(/duplicate key value violates unique constraint/)
113
+ })
114
+ })
115
+
116
+ describe('KPI 계층 구조 테스트', () => {
117
+ it('부모-자식 KPI 관계가 정상 설정되어야 한다', async () => {
118
+ // Given - 부모 KPI 생성
119
+ const parentKpi = await kpiRepository.save({
120
+ name: '전체 안전 지수',
121
+ domain: testDomain,
122
+ creator: testUser,
123
+ isLeaf: false,
124
+ formula: 'avg(child_kpis)',
125
+ active: true
126
+ })
127
+
128
+ // When - 자식 KPI 생성
129
+ const childKpi = await kpiRepository.save({
130
+ name: '안전사고율',
131
+ domain: testDomain,
132
+ creator: testUser,
133
+ parent: parentKpi,
134
+ isLeaf: true,
135
+ formula: 'accident_count / total_hours * 1000',
136
+ active: true
137
+ })
138
+
139
+ // Then
140
+ const savedChildKpi = await kpiRepository.findOne({
141
+ where: { id: childKpi.id },
142
+ relations: ['parent']
143
+ })
144
+
145
+ expect(savedChildKpi.parent).toBeDefined()
146
+ expect(savedChildKpi.parent.id).toBe(parentKpi.id)
147
+ expect(savedChildKpi.parent.name).toBe('전체 안전 지수')
148
+
149
+ // 부모에서 자식 조회
150
+ const savedParentKpi = await kpiRepository.findOne({
151
+ where: { id: parentKpi.id },
152
+ relations: ['children']
153
+ })
154
+
155
+ expect(savedParentKpi.children).toHaveLength(1)
156
+ expect(savedParentKpi.children[0].id).toBe(childKpi.id)
157
+ })
158
+
159
+ it('3단계 계층 구조가 정상 동작해야 한다', async () => {
160
+ // Given - 레벨 1 (최상위)
161
+ const level1Kpi = await kpiRepository.save({
162
+ name: '종합 성과 지수',
163
+ domain: testDomain,
164
+ creator: testUser,
165
+ isLeaf: false
166
+ })
167
+
168
+ // 레벨 2 (중간)
169
+ const level2Kpi = await kpiRepository.save({
170
+ name: '안전 성과',
171
+ domain: testDomain,
172
+ creator: testUser,
173
+ parent: level1Kpi,
174
+ isLeaf: false
175
+ })
176
+
177
+ // 레벨 3 (말단)
178
+ const level3Kpi = await kpiRepository.save({
179
+ name: '안전사고율',
180
+ domain: testDomain,
181
+ creator: testUser,
182
+ parent: level2Kpi,
183
+ isLeaf: true,
184
+ formula: 'accident_count / total_hours'
185
+ })
186
+
187
+ // When - 전체 계층 조회
188
+ const hierarchyKpi = await kpiRepository.findOne({
189
+ where: { id: level1Kpi.id },
190
+ relations: ['children', 'children.children']
191
+ })
192
+
193
+ // Then
194
+ expect(hierarchyKpi.children).toHaveLength(1)
195
+ expect(hierarchyKpi.children[0].name).toBe('안전 성과')
196
+ expect(hierarchyKpi.children[0].children).toHaveLength(1)
197
+ expect(hierarchyKpi.children[0].children[0].name).toBe('안전사고율')
198
+ expect(hierarchyKpi.children[0].children[0].isLeaf).toBe(true)
199
+ })
200
+ })
201
+
202
+ describe('KPI 상태 변경 테스트', () => {
203
+ it('DRAFT에서 RELEASE로 상태 변경이 성공해야 한다', async () => {
204
+ // Given
205
+ const kpi = await kpiRepository.save({
206
+ name: '품질지수',
207
+ domain: testDomain,
208
+ creator: testUser,
209
+ state: KpiStatus.DRAFT,
210
+ version: 1
211
+ })
212
+
213
+ // When
214
+ kpi.state = KpiStatus.RELEASE
215
+ const updatedKpi = await kpiRepository.save(kpi)
216
+
217
+ // Then
218
+ expect(updatedKpi.state).toBe(KpiStatus.RELEASE)
219
+ expect(updatedKpi.version).toBe(2) // 버전이 증가해야 함
220
+ })
221
+
222
+ it('RELEASE에서 수정 시 버전이 증가하고 DRAFT로 변경되어야 한다', async () => {
223
+ // Given
224
+ const kpi = await kpiRepository.save({
225
+ name: '생산성지수',
226
+ domain: testDomain,
227
+ creator: testUser,
228
+ state: KpiStatus.RELEASE,
229
+ version: 1,
230
+ formula: 'output / input'
231
+ })
232
+
233
+ // When - 수식 변경
234
+ kpi.formula = 'output / (input * 1.2)'
235
+ const updatedKpi = await kpiRepository.save(kpi)
236
+
237
+ // Then
238
+ expect(updatedKpi.state).toBe(KpiStatus.DRAFT)
239
+ expect(updatedKpi.version).toBe(2)
240
+ expect(updatedKpi.formula).toBe('output / (input * 1.2)')
241
+ })
242
+
243
+ it('ARCHIVED 상태의 KPI는 수정할 수 없어야 한다', async () => {
244
+ // Given
245
+ const kpi = await kpiRepository.save({
246
+ name: '폐기_KPI',
247
+ domain: testDomain,
248
+ creator: testUser,
249
+ state: KpiStatus.ARCHIVED,
250
+ active: false
251
+ })
252
+
253
+ // When & Then
254
+ kpi.formula = 'new_formula'
255
+ await expect(kpiRepository.save(kpi))
256
+ .rejects.toThrow(/cannot modify archived KPI/)
257
+ })
258
+ })
259
+
260
+ describe('KPI 수식 유효성 검사', () => {
261
+ it('유효한 수식이 저장되어야 한다', async () => {
262
+ const validFormulas = [
263
+ 'metric1 + metric2',
264
+ 'sum(metric1, metric2, metric3)',
265
+ 'if(metric1 > 0, metric2 / metric1 * 100, 0)',
266
+ 'performance_index(value, 2.5, 3.2, 2.0, 3.5)',
267
+ 'round(avg(metric1, metric2), 2)'
268
+ ]
269
+
270
+ for (const formula of validFormulas) {
271
+ const kpi = await kpiRepository.save({
272
+ name: `Test_KPI_${formula.replace(/[^a-zA-Z0-9]/g, '_')}`,
273
+ domain: testDomain,
274
+ creator: testUser,
275
+ formula: formula
276
+ })
277
+
278
+ expect(kpi.formula).toBe(formula)
279
+ }
280
+ })
281
+
282
+ it('잘못된 수식은 에러를 발생시켜야 한다', async () => {
283
+ const invalidFormulas = [
284
+ 'metric1 +', // 불완전한 수식
285
+ 'unknown_func(metric1)', // 정의되지 않은 함수
286
+ 'metric1 / 0', // Zero division (런타임에서 체크)
287
+ 'if(metric1)', // 인수 개수 불일치
288
+ 'metric1 ** metric2' // 지원하지 않는 연산자
289
+ ]
290
+
291
+ for (const formula of invalidFormulas) {
292
+ await expect(kpiRepository.save({
293
+ name: `Invalid_KPI_${Math.random()}`,
294
+ domain: testDomain,
295
+ creator: testUser,
296
+ formula: formula
297
+ })).rejects.toThrow()
298
+ }
299
+ })
300
+ })
301
+
302
+ describe('KPI 시각화 메타데이터 테스트', () => {
303
+ it('게이지 차트 메타데이터가 정상 저장되어야 한다', async () => {
304
+ // Given
305
+ const gaugeVizMeta = {
306
+ unit: '%',
307
+ min: 0,
308
+ max: 100,
309
+ thresholds: [30, 70, 90],
310
+ colors: ['#ff4757', '#ffa726', '#66bb6a', '#43a047'],
311
+ showTarget: true,
312
+ targetValue: 85,
313
+ decimals: 1
314
+ }
315
+
316
+ // When
317
+ const kpi = await kpiRepository.save({
318
+ name: '성과율',
319
+ domain: testDomain,
320
+ creator: testUser,
321
+ vizType: KpiVizType.GAUGE,
322
+ vizMeta: gaugeVizMeta
323
+ })
324
+
325
+ // Then
326
+ expect(kpi.vizMeta.unit).toBe('%')
327
+ expect(kpi.vizMeta.thresholds).toEqual([30, 70, 90])
328
+ expect(kpi.vizMeta.colors).toHaveLength(4)
329
+ expect(kpi.vizMeta.targetValue).toBe(85)
330
+ })
331
+
332
+ it('라인 차트 메타데이터가 정상 저장되어야 한다', async () => {
333
+ // Given
334
+ const lineVizMeta = {
335
+ dateRange: '30d',
336
+ aggregation: 'daily',
337
+ showTrend: true,
338
+ trendType: 'linear',
339
+ yAxisRange: { min: 0, max: 'auto' },
340
+ colors: {
341
+ line: '#2196f3',
342
+ area: 'rgba(33, 150, 243, 0.1)'
343
+ }
344
+ }
345
+
346
+ // When
347
+ const kpi = await kpiRepository.save({
348
+ name: '일일 처리량',
349
+ domain: testDomain,
350
+ creator: testUser,
351
+ vizType: KpiVizType.LINE,
352
+ vizMeta: lineVizMeta
353
+ })
354
+
355
+ // Then
356
+ expect(kpi.vizMeta.dateRange).toBe('30d')
357
+ expect(kpi.vizMeta.aggregation).toBe('daily')
358
+ expect(kpi.vizMeta.showTrend).toBe(true)
359
+ expect(kpi.vizMeta.colors.line).toBe('#2196f3')
360
+ })
361
+ })
362
+ })
363
+ ```
364
+
365
+ ## 2. 수식 계산기 테스트 (calculator.spec.ts)
366
+
367
+ ```typescript
368
+ import { describe, it, expect, beforeEach } from '@jest/globals'
369
+ import { parseFormula } from '../server/calculator/parser'
370
+ import { evaluateFormula } from '../server/calculator/evaluator'
371
+ import { builtinFunctions } from '../server/calculator/functions'
372
+ import { ValueProvider } from '../server/calculator/provider'
373
+
374
+ class MockValueProvider implements ValueProvider {
375
+ private values = new Map<string, number>()
376
+
377
+ set(key: string, value: number) {
378
+ this.values.set(key, value)
379
+ }
380
+
381
+ async get(key: string): Promise<number> {
382
+ const value = this.values.get(key)
383
+ if (value === undefined) {
384
+ throw new Error(`Variable not found: ${key}`)
385
+ }
386
+ return value
387
+ }
388
+ }
389
+
390
+ describe('Formula Calculator Tests', () => {
391
+ let provider: MockValueProvider
392
+
393
+ beforeEach(() => {
394
+ provider = new MockValueProvider()
395
+ })
396
+
397
+ describe('기본 사칙연산 테스트', () => {
398
+ it('덧셈 연산이 정확해야 한다', async () => {
399
+ // Given
400
+ const formula = '10 + 5'
401
+ const node = parseFormula(formula)
402
+
403
+ // When
404
+ const result = await evaluateFormula(node, {
405
+ functions: builtinFunctions,
406
+ provider: provider
407
+ })
408
+
409
+ // Then
410
+ expect(result).toBe(15)
411
+ })
412
+
413
+ it('뺄셈 연산이 정확해야 한다', async () => {
414
+ const formula = '20 - 8'
415
+ const node = parseFormula(formula)
416
+
417
+ const result = await evaluateFormula(node, {
418
+ functions: builtinFunctions,
419
+ provider: provider
420
+ })
421
+
422
+ expect(result).toBe(12)
423
+ })
424
+
425
+ it('곱셈 연산이 정확해야 한다', async () => {
426
+ const formula = '7 * 6'
427
+ const node = parseFormula(formula)
428
+
429
+ const result = await evaluateFormula(node, {
430
+ functions: builtinFunctions,
431
+ provider: provider
432
+ })
433
+
434
+ expect(result).toBe(42)
435
+ })
436
+
437
+ it('나눗셈 연산이 정확해야 한다', async () => {
438
+ const formula = '100 / 4'
439
+ const node = parseFormula(formula)
440
+
441
+ const result = await evaluateFormula(node, {
442
+ functions: builtinFunctions,
443
+ provider: provider
444
+ })
445
+
446
+ expect(result).toBe(25)
447
+ })
448
+
449
+ it('복합 사칙연산의 우선순위가 정확해야 한다', async () => {
450
+ const testCases = [
451
+ { formula: '2 + 3 * 4', expected: 14 }, // 곱셈 우선
452
+ { formula: '(2 + 3) * 4', expected: 20 }, // 괄호 우선
453
+ { formula: '10 - 6 / 2', expected: 7 }, // 나눗셈 우선
454
+ { formula: '(10 - 6) / 2', expected: 2 }, // 괄호 우선
455
+ { formula: '2 * 3 + 4 * 5', expected: 26 } // 곱셈들 먼저
456
+ ]
457
+
458
+ for (const testCase of testCases) {
459
+ const node = parseFormula(testCase.formula)
460
+ const result = await evaluateFormula(node, {
461
+ functions: builtinFunctions,
462
+ provider: provider
463
+ })
464
+ expect(result).toBe(testCase.expected)
465
+ }
466
+ })
467
+
468
+ it('음수 연산이 정확해야 한다', async () => {
469
+ const formula = '-5 + 3'
470
+ const node = parseFormula(formula)
471
+
472
+ const result = await evaluateFormula(node, {
473
+ functions: builtinFunctions,
474
+ provider: provider
475
+ })
476
+
477
+ expect(result).toBe(-2)
478
+ })
479
+ })
480
+
481
+ describe('변수 참조 테스트', () => {
482
+ it('단일 변수 참조가 정확해야 한다', async () => {
483
+ // Given
484
+ provider.set('total_count', 100)
485
+ const formula = 'total_count'
486
+ const node = parseFormula(formula)
487
+
488
+ // When
489
+ const result = await evaluateFormula(node, {
490
+ functions: builtinFunctions,
491
+ provider: provider
492
+ })
493
+
494
+ // Then
495
+ expect(result).toBe(100)
496
+ })
497
+
498
+ it('여러 변수를 사용한 연산이 정확해야 한다', async () => {
499
+ // Given
500
+ provider.set('defect_count', 5)
501
+ provider.set('total_count', 100)
502
+ const formula = 'defect_count / total_count * 100'
503
+ const node = parseFormula(formula)
504
+
505
+ // When
506
+ const result = await evaluateFormula(node, {
507
+ functions: builtinFunctions,
508
+ provider: provider
509
+ })
510
+
511
+ // Then
512
+ expect(result).toBe(5) // 5% 불량률
513
+ })
514
+
515
+ it('정의되지 않은 변수 참조 시 에러가 발생해야 한다', async () => {
516
+ // Given
517
+ const formula = 'undefined_variable'
518
+ const node = parseFormula(formula)
519
+
520
+ // When & Then
521
+ await expect(evaluateFormula(node, {
522
+ functions: builtinFunctions,
523
+ provider: provider
524
+ })).rejects.toThrow('Variable not found: undefined_variable')
525
+ })
526
+
527
+ it('변수명에 언더스코어와 숫자가 포함된 경우도 동작해야 한다', async () => {
528
+ // Given
529
+ provider.set('metric_1', 10)
530
+ provider.set('metric_2', 20)
531
+ provider.set('total_sum_123', 30)
532
+
533
+ const formula = 'metric_1 + metric_2 + total_sum_123'
534
+ const node = parseFormula(formula)
535
+
536
+ // When
537
+ const result = await evaluateFormula(node, {
538
+ functions: builtinFunctions,
539
+ provider: provider
540
+ })
541
+
542
+ // Then
543
+ expect(result).toBe(60)
544
+ })
545
+ })
546
+
547
+ describe('함수 호출 테스트', () => {
548
+ it('sum 함수가 정확해야 한다', async () => {
549
+ const testCases = [
550
+ { args: [1, 2, 3, 4, 5], expected: 15 },
551
+ { args: [10, 20], expected: 30 },
552
+ { args: [0, 0, 0], expected: 0 },
553
+ { args: [-5, 10, -3], expected: 2 },
554
+ { args: [100], expected: 100 } // 단일 인수
555
+ ]
556
+
557
+ for (const testCase of testCases) {
558
+ const result = builtinFunctions.sum(...testCase.args)
559
+ expect(result).toBe(testCase.expected)
560
+ }
561
+ })
562
+
563
+ it('avg 함수가 정확해야 한다', async () => {
564
+ const testCases = [
565
+ { args: [1, 2, 3, 4, 5], expected: 3 },
566
+ { args: [10, 20], expected: 15 },
567
+ { args: [0, 0, 0], expected: 0 },
568
+ { args: [100], expected: 100 },
569
+ { args: [], expected: 0 } // 빈 배열
570
+ ]
571
+
572
+ for (const testCase of testCases) {
573
+ const result = builtinFunctions.avg(...testCase.args)
574
+ expect(result).toBe(testCase.expected)
575
+ }
576
+ })
577
+
578
+ it('min/max 함수가 정확해야 한다', async () => {
579
+ expect(builtinFunctions.min(5, 2, 8, 1, 9)).toBe(1)
580
+ expect(builtinFunctions.max(5, 2, 8, 1, 9)).toBe(9)
581
+ expect(builtinFunctions.min(-5, -2, -8)).toBe(-8)
582
+ expect(builtinFunctions.max(-5, -2, -8)).toBe(-2)
583
+ })
584
+
585
+ it('round 함수가 정확해야 한다', async () => {
586
+ expect(builtinFunctions.round(3.14159, 2)).toBe(3.14)
587
+ expect(builtinFunctions.round(3.14159, 0)).toBe(3)
588
+ expect(builtinFunctions.round(3.14159)).toBe(3) // 기본값 0
589
+ expect(builtinFunctions.round(3.9)).toBe(4)
590
+ expect(builtinFunctions.round(123.456, 1)).toBe(123.5)
591
+ })
592
+
593
+ it('수식에서 함수 호출이 정확해야 한다', async () => {
594
+ // Given
595
+ provider.set('value1', 10)
596
+ provider.set('value2', 20)
597
+ provider.set('value3', 30)
598
+
599
+ const formula = 'avg(value1, value2, value3) + sum(1, 2, 3)'
600
+ const node = parseFormula(formula)
601
+
602
+ // When
603
+ const result = await evaluateFormula(node, {
604
+ functions: builtinFunctions,
605
+ provider: provider
606
+ })
607
+
608
+ // Then
609
+ expect(result).toBe(26) // avg(10,20,30) + sum(1,2,3) = 20 + 6 = 26
610
+ })
611
+ })
612
+
613
+ describe('조건부 함수 테스트', () => {
614
+ it('if 함수의 참 조건이 정확해야 한다', async () => {
615
+ const result = builtinFunctions.if(true, 'YES', 'NO')
616
+ expect(result).toBe('YES')
617
+ })
618
+
619
+ it('if 함수의 거짓 조건이 정확해야 한다', async () => {
620
+ const result = builtinFunctions.if(false, 'YES', 'NO')
621
+ expect(result).toBe('NO')
622
+ })
623
+
624
+ it('if 함수의 숫자 조건이 정확해야 한다', async () => {
625
+ expect(builtinFunctions.if(1, 'TRUE', 'FALSE')).toBe('TRUE') // 1은 참
626
+ expect(builtinFunctions.if(0, 'TRUE', 'FALSE')).toBe('FALSE') // 0은 거짓
627
+ expect(builtinFunctions.if(-1, 'TRUE', 'FALSE')).toBe('TRUE') // -1은 참
628
+ })
629
+
630
+ it('중첩된 if 함수가 정확해야 한다', async () => {
631
+ // Given
632
+ provider.set('score', 85)
633
+ const formula = 'if(score >= 90, "A", if(score >= 80, "B", if(score >= 70, "C", "F")))'
634
+ const node = parseFormula(formula)
635
+
636
+ // When
637
+ const result = await evaluateFormula(node, {
638
+ functions: builtinFunctions,
639
+ provider: provider
640
+ })
641
+
642
+ // Then
643
+ expect(result).toBe('B') // 85점은 B등급
644
+ })
645
+
646
+ it('Zero Division 방지를 위한 if 사용이 정확해야 한다', async () => {
647
+ // Given
648
+ provider.set('numerator', 100)
649
+ provider.set('denominator', 0)
650
+
651
+ const formula = 'if(denominator > 0, numerator / denominator, 0)'
652
+ const node = parseFormula(formula)
653
+
654
+ // When
655
+ const result = await evaluateFormula(node, {
656
+ functions: builtinFunctions,
657
+ provider: provider
658
+ })
659
+
660
+ // Then
661
+ expect(result).toBe(0) // 0으로 나누기 방지
662
+ })
663
+ })
664
+
665
+ describe('성과지수 함수 테스트', () => {
666
+ it('performance_index 함수가 올바른 범위의 값을 반환해야 한다', async () => {
667
+ const testCases = [
668
+ { x: 0.1, alpha1: 2, beta1: 3, alpha2: 2, beta2: 3 },
669
+ { x: 0.5, alpha1: 2, beta1: 3, alpha2: 2, beta2: 3 },
670
+ { x: 0.9, alpha1: 2, beta1: 3, alpha2: 2, beta2: 3 }
671
+ ]
672
+
673
+ for (const testCase of testCases) {
674
+ const result = builtinFunctions.performance_index(
675
+ testCase.x, testCase.alpha1, testCase.beta1,
676
+ testCase.alpha2, testCase.beta2
677
+ )
678
+
679
+ expect(result).toBeGreaterThanOrEqual(0)
680
+ expect(result).toBeLessThanOrEqual(1)
681
+ expect(typeof result).toBe('number')
682
+ expect(Number.isFinite(result)).toBe(true)
683
+ }
684
+ })
685
+
686
+ it('베타 분포 관련 함수들이 수치적으로 안정해야 한다', async () => {
687
+ // incomplete_beta 함수 테스트
688
+ const incBeta = builtinFunctions.incomplete_beta(0.5, 2, 3)
689
+ expect(Number.isFinite(incBeta)).toBe(true)
690
+ expect(incBeta).toBeGreaterThanOrEqual(0)
691
+
692
+ // complete_beta 함수 테스트
693
+ const compBeta = builtinFunctions.complete_beta(2, 3)
694
+ expect(Number.isFinite(compBeta)).toBe(true)
695
+ expect(compBeta).toBeGreaterThan(0)
696
+ })
697
+ })
698
+
699
+ describe('지수감쇠 함수 테스트', () => {
700
+ it('exponential_decay 함수가 정확해야 한다', async () => {
701
+ const testCases = [
702
+ { value: 0, scale: 1, power: 1, expected: 1 }, // exp(-0) = 1
703
+ { value: 1, scale: 1, power: 1, expected: Math.exp(-1) }, // exp(-1)
704
+ { value: 2, scale: 1, power: 2, expected: Math.exp(-4) } // exp(-2^2)
705
+ ]
706
+
707
+ for (const testCase of testCases) {
708
+ const result = builtinFunctions.exponential_decay(
709
+ testCase.value, testCase.scale, testCase.power
710
+ )
711
+ expect(result).toBeCloseTo(testCase.expected, 10)
712
+ }
713
+ })
714
+
715
+ it('지수 함수들이 정확해야 한다', async () => {
716
+ expect(builtinFunctions.exp(0)).toBe(1)
717
+ expect(builtinFunctions.exp(1)).toBeCloseTo(Math.E, 10)
718
+ expect(builtinFunctions.log(Math.E)).toBeCloseTo(1, 10)
719
+ expect(builtinFunctions.pow(2, 3)).toBe(8)
720
+ expect(builtinFunctions.pow(3, 2)).toBe(9)
721
+ })
722
+ })
723
+
724
+ describe('복합 수식 테스트', () => {
725
+ it('실제 KPI 수식이 정확하게 계산되어야 한다', async () => {
726
+ // Given - 불량률 KPI
727
+ provider.set('defect_count', 25)
728
+ provider.set('total_count', 1000)
729
+
730
+ const formula = 'if(total_count > 0, round(defect_count / total_count * 100, 2), 0)'
731
+ const node = parseFormula(formula)
732
+
733
+ // When
734
+ const result = await evaluateFormula(node, {
735
+ functions: builtinFunctions,
736
+ provider: provider
737
+ })
738
+
739
+ // Then
740
+ expect(result).toBe(2.5) // 2.5% 불량률
741
+ })
742
+
743
+ it('생산성 지수 계산이 정확해야 한다', async () => {
744
+ // Given
745
+ provider.set('output_qty', 1200)
746
+ provider.set('input_hours', 40)
747
+ provider.set('standard_rate', 25)
748
+
749
+ const formula = 'round((output_qty / input_hours) / standard_rate * 100, 1)'
750
+ const node = parseFormula(formula)
751
+
752
+ // When
753
+ const result = await evaluateFormula(node, {
754
+ functions: builtinFunctions,
755
+ provider: provider
756
+ })
757
+
758
+ // Then
759
+ expect(result).toBe(120) // 120% 생산성 (30/25 * 100)
760
+ })
761
+
762
+ it('가중 평균 계산이 정확해야 한다', async () => {
763
+ // Given
764
+ provider.set('kpi1_value', 80)
765
+ provider.set('kpi1_weight', 0.4)
766
+ provider.set('kpi2_value', 90)
767
+ provider.set('kpi2_weight', 0.6)
768
+
769
+ const formula = 'kpi1_value * kpi1_weight + kpi2_value * kpi2_weight'
770
+ const node = parseFormula(formula)
771
+
772
+ // When
773
+ const result = await evaluateFormula(node, {
774
+ functions: builtinFunctions,
775
+ provider: provider
776
+ })
777
+
778
+ // Then
779
+ expect(result).toBe(86) // 80*0.4 + 90*0.6 = 32 + 54 = 86
780
+ })
781
+
782
+ it('안전지수 복합 계산이 정확해야 한다', async () => {
783
+ // Given
784
+ provider.set('accident_count', 2)
785
+ provider.set('near_miss_count', 5)
786
+ provider.set('total_hours', 10000)
787
+ provider.set('safety_training_completion', 0.95)
788
+
789
+ const formula = `
790
+ if(total_hours > 0,
791
+ round(
792
+ (1 - (accident_count + near_miss_count * 0.1) / (total_hours / 1000)) *
793
+ safety_training_completion * 100,
794
+ 2
795
+ ),
796
+ 0
797
+ )
798
+ `.replace(/\s+/g, ' ').trim()
799
+
800
+ const node = parseFormula(formula)
801
+
802
+ // When
803
+ const result = await evaluateFormula(node, {
804
+ functions: builtinFunctions,
805
+ provider: provider
806
+ })
807
+
808
+ // Then
809
+ // (1 - (2 + 5*0.1) / 10) * 0.95 * 100 = (1 - 2.5/10) * 0.95 * 100 = 0.75 * 0.95 * 100 = 71.25
810
+ expect(result).toBe(71.25)
811
+ })
812
+ })
813
+
814
+ describe('에러 처리 테스트', () => {
815
+ it('잘못된 함수명 사용 시 에러가 발생해야 한다', async () => {
816
+ // Given
817
+ const formula = 'unknown_function(10, 20)'
818
+ const node = parseFormula(formula)
819
+
820
+ // When & Then
821
+ await expect(evaluateFormula(node, {
822
+ functions: builtinFunctions,
823
+ provider: provider
824
+ })).rejects.toThrow('Unknown function: unknown_function')
825
+ })
826
+
827
+ it('잘못된 연산자 사용 시 에러가 발생해야 한다', async () => {
828
+ // Given - 파싱 단계에서 에러 발생
829
+ expect(() => parseFormula('10 ** 20')).toThrow()
830
+ expect(() => parseFormula('10 % 3')).toThrow()
831
+ })
832
+
833
+ it('불완전한 수식에서 파싱 에러가 발생해야 한다', async () => {
834
+ const invalidFormulas = [
835
+ '10 +', // 불완전한 이항 연산
836
+ '(10 + 20', // 닫히지 않은 괄호
837
+ '10 + 20)', // 열리지 않은 괄호
838
+ 'sum(10, 20,', // 불완전한 함수 호출
839
+ '' // 빈 수식
840
+ ]
841
+
842
+ for (const formula of invalidFormulas) {
843
+ expect(() => parseFormula(formula)).toThrow()
844
+ }
845
+ })
846
+ })
847
+ })
848
+ ```
849
+
850
+ ## 3. KPI 값 관리 테스트 (kpi-value.spec.ts)
851
+
852
+ ```typescript
853
+ import { describe, it, expect, beforeEach } from '@jest/globals'
854
+ import { Repository, DataSource } from 'typeorm'
855
+ import { KpiValue, KpiValueInputType } from '../server/service/kpi-value/kpi-value'
856
+ import { Kpi, KpiPeriodType } from '../server/service/kpi/kpi'
857
+ import { KpiOrgScope } from '../server/service/kpi-org-scope/kpi-org-scope'
858
+ import { Domain } from '@things-factory/shell'
859
+ import { User } from '@things-factory/auth-base'
860
+ import { createTestingModule } from './test-utils'
861
+
862
+ describe('KPI Value Management Tests', () => {
863
+ let dataSource: DataSource
864
+ let kpiValueRepository: Repository<KpiValue>
865
+ let kpiRepository: Repository<Kpi>
866
+ let domainRepository: Repository<Domain>
867
+ let userRepository: Repository<User>
868
+ let kpiOrgScopeRepository: Repository<KpiOrgScope>
869
+
870
+ let testDomain: Domain
871
+ let testUser: User
872
+ let testKpi: Kpi
873
+ let testOrgScope: KpiOrgScope
874
+
875
+ beforeEach(async () => {
876
+ const module = await createTestingModule()
877
+ dataSource = module.get<DataSource>(DataSource)
878
+ kpiValueRepository = dataSource.getRepository(KpiValue)
879
+ kpiRepository = dataSource.getRepository(Kpi)
880
+ domainRepository = dataSource.getRepository(Domain)
881
+ userRepository = dataSource.getRepository(User)
882
+ kpiOrgScopeRepository = dataSource.getRepository(KpiOrgScope)
883
+
884
+ // 테스트 데이터 준비
885
+ testDomain = await domainRepository.save({
886
+ name: 'TEST_DOMAIN',
887
+ description: 'Test Domain for KPI Value Tests'
888
+ })
889
+
890
+ testUser = await userRepository.save({
891
+ name: 'Test User',
892
+ email: 'test@example.com',
893
+ domain: testDomain
894
+ })
895
+
896
+ testKpi = await kpiRepository.save({
897
+ name: '안전사고율',
898
+ description: '월별 안전사고 발생률',
899
+ domain: testDomain,
900
+ creator: testUser,
901
+ isLeaf: true,
902
+ formula: 'accident_count / total_hours * 1000',
903
+ periodType: KpiPeriodType.MONTH,
904
+ active: true
905
+ })
906
+
907
+ testOrgScope = await kpiOrgScopeRepository.save({
908
+ name: '본사',
909
+ code: 'HQ',
910
+ domain: testDomain
911
+ })
912
+ })
913
+
914
+ describe('KPI 값 생성 테스트', () => {
915
+ it('기본 KPI 값 생성이 성공해야 한다', async () => {
916
+ // Given
917
+ const kpiValueData = {
918
+ kpi: testKpi,
919
+ orgScope: testOrgScope,
920
+ valueDate: new Date('2024-01-01'),
921
+ value: 2.5,
922
+ score: 85.0,
923
+ periodType: KpiPeriodType.MONTH,
924
+ inputType: KpiValueInputType.MANUAL,
925
+ source: 'Safety Department',
926
+ creator: testUser,
927
+ domain: testDomain
928
+ }
929
+
930
+ // When
931
+ const savedKpiValue = await kpiValueRepository.save(kpiValueData)
932
+
933
+ // Then
934
+ expect(savedKpiValue.id).toBeDefined()
935
+ expect(savedKpiValue.kpi.id).toBe(testKpi.id)
936
+ expect(savedKpiValue.orgScope.id).toBe(testOrgScope.id)
937
+ expect(savedKpiValue.value).toBe(2.5)
938
+ expect(savedKpiValue.score).toBe(85.0)
939
+ expect(savedKpiValue.inputType).toBe(KpiValueInputType.MANUAL)
940
+ expect(savedKpiValue.source).toBe('Safety Department')
941
+ expect(savedKpiValue.createdAt).toBeDefined()
942
+ })
943
+
944
+ it('자동 입력 KPI 값이 정상 생성되어야 한다', async () => {
945
+ // Given
946
+ const autoKpiValue = {
947
+ kpi: testKpi,
948
+ orgScope: testOrgScope,
949
+ valueDate: new Date('2024-01-01'),
950
+ value: 1.8,
951
+ score: 92.0,
952
+ periodType: KpiPeriodType.MONTH,
953
+ inputType: KpiValueInputType.AUTO,
954
+ source: 'SYSTEM_BATCH_JOB',
955
+ creator: testUser,
956
+ domain: testDomain
957
+ }
958
+
959
+ // When
960
+ const savedValue = await kpiValueRepository.save(autoKpiValue)
961
+
962
+ // Then
963
+ expect(savedValue.inputType).toBe(KpiValueInputType.AUTO)
964
+ expect(savedValue.source).toBe('SYSTEM_BATCH_JOB')
965
+ expect(savedValue.value).toBe(1.8)
966
+ })
967
+
968
+ it('필수 필드가 누락된 경우 에러가 발생해야 한다', async () => {
969
+ // Given
970
+ const invalidKpiValue = {
971
+ orgScope: testOrgScope,
972
+ valueDate: new Date('2024-01-01'),
973
+ value: 2.5
974
+ // kpi 필드가 누락됨
975
+ }
976
+
977
+ // When & Then
978
+ await expect(kpiValueRepository.save(invalidKpiValue))
979
+ .rejects.toThrow()
980
+ })
981
+
982
+ it('동일한 KPI, 조직, 날짜의 값이 중복 생성되면 기존값이 업데이트되어야 한다', async () => {
983
+ // Given - 첫 번째 값 생성
984
+ const firstValue = await kpiValueRepository.save({
985
+ kpi: testKpi,
986
+ orgScope: testOrgScope,
987
+ valueDate: new Date('2024-01-01'),
988
+ value: 2.0,
989
+ periodType: KpiPeriodType.MONTH,
990
+ inputType: KpiValueInputType.MANUAL,
991
+ creator: testUser,
992
+ domain: testDomain
993
+ })
994
+
995
+ // When - 동일한 조건으로 두 번째 값 생성
996
+ const secondValue = await kpiValueRepository.save({
997
+ kpi: testKpi,
998
+ orgScope: testOrgScope,
999
+ valueDate: new Date('2024-01-01'),
1000
+ value: 3.0,
1001
+ periodType: KpiPeriodType.MONTH,
1002
+ inputType: KpiValueInputType.MANUAL,
1003
+ creator: testUser,
1004
+ domain: testDomain
1005
+ })
1006
+
1007
+ // Then - 하나의 값만 존재해야 함
1008
+ const values = await kpiValueRepository.find({
1009
+ where: {
1010
+ kpi: { id: testKpi.id },
1011
+ orgScope: { id: testOrgScope.id },
1012
+ valueDate: new Date('2024-01-01')
1013
+ }
1014
+ })
1015
+
1016
+ expect(values).toHaveLength(1)
1017
+ expect(values[0].value).toBe(3.0) // 최신 값으로 업데이트됨
1018
+ })
1019
+ })
1020
+
1021
+ describe('KPI 값 업데이트 테스트', () => {
1022
+ it('기존 KPI 값 수정이 성공해야 한다', async () => {
1023
+ // Given
1024
+ const originalValue = await kpiValueRepository.save({
1025
+ kpi: testKpi,
1026
+ orgScope: testOrgScope,
1027
+ valueDate: new Date('2024-01-01'),
1028
+ value: 2.5,
1029
+ score: 80.0,
1030
+ periodType: KpiPeriodType.MONTH,
1031
+ inputType: KpiValueInputType.MANUAL,
1032
+ creator: testUser,
1033
+ domain: testDomain
1034
+ })
1035
+
1036
+ // When
1037
+ originalValue.value = 3.2
1038
+ originalValue.score = 75.0
1039
+ originalValue.source = 'Updated by Manager'
1040
+ const updatedValue = await kpiValueRepository.save(originalValue)
1041
+
1042
+ // Then
1043
+ expect(updatedValue.id).toBe(originalValue.id)
1044
+ expect(updatedValue.value).toBe(3.2)
1045
+ expect(updatedValue.score).toBe(75.0)
1046
+ expect(updatedValue.source).toBe('Updated by Manager')
1047
+ expect(updatedValue.updatedAt).toBeDefined()
1048
+ expect(updatedValue.updatedAt > updatedValue.createdAt).toBe(true)
1049
+ })
1050
+
1051
+ it('입력 타입 변경이 가능해야 한다', async () => {
1052
+ // Given
1053
+ const kpiValue = await kpiValueRepository.save({
1054
+ kpi: testKpi,
1055
+ orgScope: testOrgScope,
1056
+ valueDate: new Date('2024-01-01'),
1057
+ value: 2.0,
1058
+ inputType: KpiValueInputType.AUTO,
1059
+ creator: testUser,
1060
+ domain: testDomain
1061
+ })
1062
+
1063
+ // When - AUTO에서 MANUAL로 변경
1064
+ kpiValue.inputType = KpiValueInputType.MANUAL
1065
+ kpiValue.source = 'Manual Override'
1066
+ const updatedValue = await kpiValueRepository.save(kpiValue)
1067
+
1068
+ // Then
1069
+ expect(updatedValue.inputType).toBe(KpiValueInputType.MANUAL)
1070
+ expect(updatedValue.source).toBe('Manual Override')
1071
+ })
1072
+ })
1073
+
1074
+ describe('KPI 값 삭제 테스트', () => {
1075
+ it('KPI 값 소프트 삭제가 성공해야 한다', async () => {
1076
+ // Given
1077
+ const kpiValue = await kpiValueRepository.save({
1078
+ kpi: testKpi,
1079
+ orgScope: testOrgScope,
1080
+ valueDate: new Date('2024-01-01'),
1081
+ value: 2.5,
1082
+ creator: testUser,
1083
+ domain: testDomain
1084
+ })
1085
+
1086
+ // When
1087
+ await kpiValueRepository.softDelete(kpiValue.id)
1088
+
1089
+ // Then
1090
+ const deletedValue = await kpiValueRepository.findOne({
1091
+ where: { id: kpiValue.id },
1092
+ withDeleted: true
1093
+ })
1094
+
1095
+ expect(deletedValue.deletedAt).toBeDefined()
1096
+
1097
+ // 일반 조회에서는 보이지 않아야 함
1098
+ const normalValue = await kpiValueRepository.findOne({
1099
+ where: { id: kpiValue.id }
1100
+ })
1101
+ expect(normalValue).toBeNull()
1102
+ })
1103
+
1104
+ it('KPI 값 하드 삭제가 성공해야 한다', async () => {
1105
+ // Given
1106
+ const kpiValue = await kpiValueRepository.save({
1107
+ kpi: testKpi,
1108
+ orgScope: testOrgScope,
1109
+ valueDate: new Date('2024-01-01'),
1110
+ value: 2.5,
1111
+ creator: testUser,
1112
+ domain: testDomain
1113
+ })
1114
+
1115
+ // When
1116
+ await kpiValueRepository.delete(kpiValue.id)
1117
+
1118
+ // Then
1119
+ const deletedValue = await kpiValueRepository.findOne({
1120
+ where: { id: kpiValue.id },
1121
+ withDeleted: true
1122
+ })
1123
+ expect(deletedValue).toBeNull()
1124
+ })
1125
+ })
1126
+
1127
+ describe('KPI 값 쿼리 테스트', () => {
1128
+ beforeEach(async () => {
1129
+ // 테스트용 KPI 값들 생성
1130
+ const dates = [
1131
+ new Date('2024-01-01'),
1132
+ new Date('2024-02-01'),
1133
+ new Date('2024-03-01'),
1134
+ new Date('2024-04-01')
1135
+ ]
1136
+
1137
+ for (let i = 0; i < dates.length; i++) {
1138
+ await kpiValueRepository.save({
1139
+ kpi: testKpi,
1140
+ orgScope: testOrgScope,
1141
+ valueDate: dates[i],
1142
+ value: 2.0 + i * 0.5, // 2.0, 2.5, 3.0, 3.5
1143
+ score: 90 - i * 5, // 90, 85, 80, 75
1144
+ periodType: KpiPeriodType.MONTH,
1145
+ inputType: i % 2 === 0 ? KpiValueInputType.AUTO : KpiValueInputType.MANUAL,
1146
+ creator: testUser,
1147
+ domain: testDomain
1148
+ })
1149
+ }
1150
+ })
1151
+
1152
+ it('날짜 범위로 KPI 값 조회가 정확해야 한다', async () => {
1153
+ // Given
1154
+ const startDate = new Date('2024-01-01')
1155
+ const endDate = new Date('2024-03-01')
1156
+
1157
+ // When
1158
+ const values = await kpiValueRepository
1159
+ .createQueryBuilder('kv')
1160
+ .where('kv.valueDate >= :startDate', { startDate })
1161
+ .andWhere('kv.valueDate <= :endDate', { endDate })
1162
+ .andWhere('kv.kpiId = :kpiId', { kpiId: testKpi.id })
1163
+ .orderBy('kv.valueDate', 'ASC')
1164
+ .getMany()
1165
+
1166
+ // Then
1167
+ expect(values).toHaveLength(3) // 1월, 2월, 3월
1168
+ expect(values[0].value).toBe(2.0)
1169
+ expect(values[1].value).toBe(2.5)
1170
+ expect(values[2].value).toBe(3.0)
1171
+ })
1172
+
1173
+ it('입력 타입별 KPI 값 조회가 정확해야 한다', async () => {
1174
+ // When - 자동 입력값만 조회
1175
+ const autoValues = await kpiValueRepository.find({
1176
+ where: {
1177
+ kpi: { id: testKpi.id },
1178
+ inputType: KpiValueInputType.AUTO
1179
+ },
1180
+ order: { valueDate: 'ASC' }
1181
+ })
1182
+
1183
+ const manualValues = await kpiValueRepository.find({
1184
+ where: {
1185
+ kpi: { id: testKpi.id },
1186
+ inputType: KpiValueInputType.MANUAL
1187
+ },
1188
+ order: { valueDate: 'ASC' }
1189
+ })
1190
+
1191
+ // Then
1192
+ expect(autoValues).toHaveLength(2) // 짝수 인덱스 (0, 2)
1193
+ expect(manualValues).toHaveLength(2) // 홀수 인덱스 (1, 3)
1194
+ expect(autoValues[0].value).toBe(2.0)
1195
+ expect(autoValues[1].value).toBe(3.0)
1196
+ })
1197
+
1198
+ it('KPI별 최신값 조회가 정확해야 한다', async () => {
1199
+ // When
1200
+ const latestValue = await kpiValueRepository
1201
+ .createQueryBuilder('kv')
1202
+ .where('kv.kpiId = :kpiId', { kpiId: testKpi.id })
1203
+ .orderBy('kv.valueDate', 'DESC')
1204
+ .getOne()
1205
+
1206
+ // Then
1207
+ expect(latestValue.valueDate).toEqual(new Date('2024-04-01'))
1208
+ expect(latestValue.value).toBe(3.5)
1209
+ expect(latestValue.score).toBe(75)
1210
+ })
1211
+
1212
+ it('조직별 KPI 값 집계가 정확해야 한다', async () => {
1213
+ // Given - 추가 조직 생성
1214
+ const subOrg = await kpiOrgScopeRepository.save({
1215
+ name: '지사',
1216
+ code: 'BRANCH',
1217
+ domain: testDomain
1218
+ })
1219
+
1220
+ // 지사용 KPI 값 생성
1221
+ await kpiValueRepository.save({
1222
+ kpi: testKpi,
1223
+ orgScope: subOrg,
1224
+ valueDate: new Date('2024-01-01'),
1225
+ value: 1.5,
1226
+ score: 95,
1227
+ creator: testUser,
1228
+ domain: testDomain
1229
+ })
1230
+
1231
+ // When - 조직별 평균 조회
1232
+ const orgStats = await kpiValueRepository
1233
+ .createQueryBuilder('kv')
1234
+ .select('org.name', 'orgName')
1235
+ .addSelect('AVG(kv.value)', 'avgValue')
1236
+ .addSelect('AVG(kv.score)', 'avgScore')
1237
+ .addSelect('COUNT(kv.id)', 'count')
1238
+ .innerJoin('kv.orgScope', 'org')
1239
+ .where('kv.kpiId = :kpiId', { kpiId: testKpi.id })
1240
+ .groupBy('org.id, org.name')
1241
+ .getRawMany()
1242
+
1243
+ // Then
1244
+ expect(orgStats).toHaveLength(2)
1245
+
1246
+ const hqStats = orgStats.find(s => s.orgName === '본사')
1247
+ const branchStats = orgStats.find(s => s.orgName === '지사')
1248
+
1249
+ expect(hqStats.count).toBe('4')
1250
+ expect(parseFloat(hqStats.avgValue)).toBe(2.75) // (2.0+2.5+3.0+3.5)/4
1251
+ expect(branchStats.count).toBe('1')
1252
+ expect(parseFloat(branchStats.avgValue)).toBe(1.5)
1253
+ })
1254
+ })
1255
+
1256
+ describe('점수 계산 테스트', () => {
1257
+ it('등급 기반 점수 계산이 정확해야 한다', async () => {
1258
+ // Given - 등급 정보가 있는 KPI
1259
+ const gradeKpi = await kpiRepository.save({
1260
+ name: '품질지수',
1261
+ domain: testDomain,
1262
+ creator: testUser,
1263
+ grades: {
1264
+ '1': { minValue: 0, maxValue: 60, score: 20, color: '#ff4757' },
1265
+ '2': { minValue: 60, maxValue: 70, score: 40, color: '#ffa726' },
1266
+ '3': { minValue: 70, maxValue: 80, score: 60, color: '#ffeb3b' },
1267
+ '4': { minValue: 80, maxValue: 90, score: 80, color: '#66bb6a' },
1268
+ '5': { minValue: 90, maxValue: 100, score: 100, color: '#43a047' }
1269
+ }
1270
+ })
1271
+
1272
+ const testCases = [
1273
+ { value: 55, expectedScore: 20 }, // 1등급
1274
+ { value: 65, expectedScore: 40 }, // 2등급
1275
+ { value: 75, expectedScore: 60 }, // 3등급
1276
+ { value: 85, expectedScore: 80 }, // 4등급
1277
+ { value: 95, expectedScore: 100 } // 5등급
1278
+ ]
1279
+
1280
+ for (const testCase of testCases) {
1281
+ // When
1282
+ const kpiValue = await kpiValueRepository.save({
1283
+ kpi: gradeKpi,
1284
+ orgScope: testOrgScope,
1285
+ valueDate: new Date(`2024-0${testCases.indexOf(testCase) + 1}-01`),
1286
+ value: testCase.value,
1287
+ creator: testUser,
1288
+ domain: testDomain
1289
+ })
1290
+
1291
+ // 점수 계산 로직 실행 (실제로는 서비스 레이어에서 처리)
1292
+ let calculatedScore = 0
1293
+ for (const grade in gradeKpi.grades) {
1294
+ const gradeInfo = gradeKpi.grades[grade]
1295
+ if (testCase.value >= gradeInfo.minValue && testCase.value < gradeInfo.maxValue) {
1296
+ calculatedScore = gradeInfo.score
1297
+ break
1298
+ }
1299
+ }
1300
+
1301
+ kpiValue.score = calculatedScore
1302
+ const savedValue = await kpiValueRepository.save(kpiValue)
1303
+
1304
+ // Then
1305
+ expect(savedValue.score).toBe(testCase.expectedScore)
1306
+ }
1307
+ })
1308
+
1309
+ it('수식 기반 점수 계산이 정확해야 한다', async () => {
1310
+ // Given - 점수 계산 수식이 있는 KPI
1311
+ const formulaKpi = await kpiRepository.save({
1312
+ name: '효율성지수',
1313
+ domain: testDomain,
1314
+ creator: testUser,
1315
+ scoreFormula: 'if(value >= 90, 100, if(value >= 80, value * 0.9, value * 0.7))'
1316
+ })
1317
+
1318
+ const testCases = [
1319
+ { value: 95, expectedScore: 100 }, // >= 90이면 100
1320
+ { value: 85, expectedScore: 76.5 }, // 80~90이면 value * 0.9
1321
+ { value: 75, expectedScore: 52.5 } // < 80이면 value * 0.7
1322
+ ]
1323
+
1324
+ for (const testCase of testCases) {
1325
+ // When - 실제 점수 계산은 서비스 레이어에서 수행
1326
+ let calculatedScore = 0
1327
+ if (testCase.value >= 90) {
1328
+ calculatedScore = 100
1329
+ } else if (testCase.value >= 80) {
1330
+ calculatedScore = testCase.value * 0.9
1331
+ } else {
1332
+ calculatedScore = testCase.value * 0.7
1333
+ }
1334
+
1335
+ const kpiValue = await kpiValueRepository.save({
1336
+ kpi: formulaKpi,
1337
+ orgScope: testOrgScope,
1338
+ valueDate: new Date(`2024-0${testCases.indexOf(testCase) + 1}-01`),
1339
+ value: testCase.value,
1340
+ score: calculatedScore,
1341
+ creator: testUser,
1342
+ domain: testDomain
1343
+ })
1344
+
1345
+ // Then
1346
+ expect(kpiValue.score).toBe(testCase.expectedScore)
1347
+ }
1348
+ })
1349
+
1350
+ it('점수가 0-100 범위를 벗어나면 에러가 발생해야 한다', async () => {
1351
+ const invalidScores = [-10, 150, -1, 101]
1352
+
1353
+ for (const invalidScore of invalidScores) {
1354
+ await expect(kpiValueRepository.save({
1355
+ kpi: testKpi,
1356
+ orgScope: testOrgScope,
1357
+ valueDate: new Date('2024-01-01'),
1358
+ value: 50,
1359
+ score: invalidScore,
1360
+ creator: testUser,
1361
+ domain: testDomain
1362
+ })).rejects.toThrow(/score must be between 0 and 100/)
1363
+ }
1364
+ })
1365
+ })
1366
+
1367
+ describe('배치 처리 테스트', () => {
1368
+ it('대용량 KPI 값 배치 삽입이 성공해야 한다', async () => {
1369
+ // Given
1370
+ const batchSize = 1000
1371
+ const kpiValues = []
1372
+
1373
+ for (let i = 0; i < batchSize; i++) {
1374
+ kpiValues.push({
1375
+ kpi: testKpi,
1376
+ orgScope: testOrgScope,
1377
+ valueDate: new Date(`2024-01-${String(i % 28 + 1).padStart(2, '0')}`),
1378
+ value: Math.random() * 10,
1379
+ score: Math.random() * 100,
1380
+ inputType: KpiValueInputType.AUTO,
1381
+ source: 'BATCH_JOB',
1382
+ creator: testUser,
1383
+ domain: testDomain
1384
+ })
1385
+ }
1386
+
1387
+ // When
1388
+ const startTime = Date.now()
1389
+ await kpiValueRepository
1390
+ .createQueryBuilder()
1391
+ .insert()
1392
+ .into(KpiValue)
1393
+ .values(kpiValues)
1394
+ .execute()
1395
+ const endTime = Date.now()
1396
+
1397
+ // Then
1398
+ const insertedCount = await kpiValueRepository.count({
1399
+ where: { source: 'BATCH_JOB' }
1400
+ })
1401
+
1402
+ expect(insertedCount).toBe(batchSize)
1403
+ expect(endTime - startTime).toBeLessThan(5000) // 5초 이내
1404
+ })
1405
+
1406
+ it('배치 업데이트가 효율적으로 처리되어야 한다', async () => {
1407
+ // Given - 기존 데이터 준비
1408
+ const existingValues = []
1409
+ for (let i = 0; i < 100; i++) {
1410
+ const value = await kpiValueRepository.save({
1411
+ kpi: testKpi,
1412
+ orgScope: testOrgScope,
1413
+ valueDate: new Date(`2024-01-${String(i % 28 + 1).padStart(2, '0')}`),
1414
+ value: i,
1415
+ score: i,
1416
+ creator: testUser,
1417
+ domain: testDomain
1418
+ })
1419
+ existingValues.push(value)
1420
+ }
1421
+
1422
+ // When - 배치 업데이트
1423
+ const startTime = Date.now()
1424
+ await kpiValueRepository
1425
+ .createQueryBuilder()
1426
+ .update(KpiValue)
1427
+ .set({ score: () => 'value * 2' }) // 점수를 값의 2배로 업데이트
1428
+ .where('kpiId = :kpiId', { kpiId: testKpi.id })
1429
+ .execute()
1430
+ const endTime = Date.now()
1431
+
1432
+ // Then
1433
+ const updatedValues = await kpiValueRepository.find({
1434
+ where: { kpi: { id: testKpi.id } },
1435
+ order: { valueDate: 'ASC' }
1436
+ })
1437
+
1438
+ expect(updatedValues).toHaveLength(100)
1439
+ expect(updatedValues[50].score).toBe(updatedValues[50].value * 2)
1440
+ expect(endTime - startTime).toBeLessThan(1000) // 1초 이내
1441
+ })
1442
+ })
1443
+ })
1444
+ ```
1445
+
1446
+ 이제 단위 테스트가 훨씬 더 상세하게 작성되었습니다. 각 테스트는 실제 코드 예제와 함께 다음 항목들을 다룹니다:
1447
+
1448
+ 1. **KPI 엔티티 테스트**: 생성, 계층 구조, 상태 변경, 수식 검증, 시각화 메타데이터
1449
+ 2. **수식 계산기 테스트**: 사칙연산, 변수 참조, 함수 호출, 조건문, 성과지수, 복합 수식, 에러 처리
1450
+ 3. **KPI 값 관리 테스트**: CRUD 작업, 쿼리, 점수 계산, 배치 처리
1451
+
1452
+ 각 테스트는 Given-When-Then 패턴을 따르며, 엣지 케이스와 에러 상황도 포함하고 있습니다.