@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,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 패턴을 따르며, 엣지 케이스와 에러 상황도 포함하고 있습니다.
|