/* * 여러 구간 동시 비교 — **"어제보다 나아졌다" 를 넘어선다.** * * 비교 축이 한 구간만 볼 때는 판단이 한 문장에서 멈췄다: "어제보다 나아졌다." 어제도 나빴으면 쓸모가 * 없다. 최근 며칠의 같은 시간대를 함께 보면 목표 없이도 "지난 7일 중 가장 낮다" 를 말할 수 있다. * * 이 테스트가 지키는 것은 산수보다 **정직성**이다. 이 도메인에서 값이 없는 것을 0 으로 읽으면 시간 * 지표는 "낮을수록 좋다" 라서 **일이 없던 구간이 최고 성적으로** 보고된다 — 목표 판정에서 이미 겪은 * 결함이라 여기서 되풀이하지 않는다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { MAX_BASELINE_PERIODS, baselineWindows, summarizeBaseline, type MetricSpecLike } from '../server/engine/kpi-baseline.ts' const MS_DAY = 24 * 60 * 60_000 const HOUR = 60 * 60_000 /* 실제 표(`TWIN_METRIC`)의 성질만 흉내 낸 최소 사양 — 어휘는 그쪽이 소유한다. */ const SPECS = { 'throughput.tasks': { direction: 'higher', unit: 'count' }, 'workTime.p50Ms': { direction: 'lower', unit: 'ms' } } satisfies Record type M = keyof typeof SPECS /** 과거 창 표본 — 값만 신경 쓰는 테스트를 짧게 쓰기 위한 도우미. */ const sample = (dayBack: number, values: Partial>) => ({ fromMs: -dayBack * MS_DAY, toMs: -dayBack * MS_DAY + HOUR, values }) test('같은 시간대를 하루씩 뒤로 옮긴다 — 가까운 것부터', () => { const from = 10 * MS_DAY const to = from + HOUR const windows = baselineWindows(from, to, 3) assert.equal(windows.length, 3) assert.deepEqual(windows[0], { fromMs: from - MS_DAY, toMs: to - MS_DAY }, '어제가 먼저') assert.deepEqual(windows[2], { fromMs: from - 3 * MS_DAY, toMs: to - 3 * MS_DAY }) /* 길이는 그대로여야 한다 — 길이가 달라지면 처리량을 비교할 수 없다. */ for (const w of windows) assert.equal(w.toMs - w.fromMs, to - from) }) test('주 단위는 같은 요일을 본다 — 주말·평일이 섞이지 않게', () => { const from = 30 * MS_DAY const windows = baselineWindows(from, from + HOUR, 2, 'week') assert.equal(windows[0].fromMs, from - 7 * MS_DAY) assert.equal(windows[1].fromMs, from - 14 * MS_DAY) }) test('구간 수에 상한이 있고, 이상한 입력은 창을 만들지 않는다', () => { assert.equal(baselineWindows(0, HOUR, 999).length, MAX_BASELINE_PERIODS) assert.equal(baselineWindows(0, HOUR, 0).length, 0) assert.equal(baselineWindows(0, HOUR, -3).length, 0) /* 끝이 시작보다 앞이면 창이 아니다 — 지어내지 않는다. */ assert.equal(baselineWindows(HOUR, 0, 3).length, 0) assert.equal(baselineWindows(Number.NaN, HOUR, 3).length, 0) }) test('분포는 측정된 표본만으로 만들되, **결측 창도 남긴다**', () => { /* 빼고 말하면 "지난 5일" 이 실제로는 3일이었다는 것을 아무도 모른다. */ const [row] = summarizeBaseline({ 'throughput.tasks': SPECS['throughput.tasks'] }, { 'throughput.tasks': 50 }, [ sample(1, { 'throughput.tasks': 40 }), sample(2, {}), // 기록 없음 sample(3, { 'throughput.tasks': 60 }), sample(4, { 'throughput.tasks': undefined }), // 기록 없음 sample(5, { 'throughput.tasks': 80 }) ]) assert.equal(row.requested, 5) assert.equal(row.measuredCount, 3) assert.equal(row.samples.length, 5, '요청한 창 전부를 돌려준다') assert.deepEqual( row.samples.map(s => s.measured), [true, false, true, false, true] ) assert.deepEqual([row.min, row.median, row.max], [40, 60, 80]) }) test('현재 값이 없으면 순위를 내지 않는다 — 결측을 0 으로 읽으면 최고 성적으로 뒤집힌다', () => { /* 시간 지표는 "낮을수록 좋다" 라서 결측을 0 으로 놓으면 **일이 없던 구간이 1등**이 된다. */ const [row] = summarizeBaseline({ 'workTime.p50Ms': SPECS['workTime.p50Ms'] }, {}, [ sample(1, { 'workTime.p50Ms': 30_000 }), sample(2, { 'workTime.p50Ms': 40_000 }) ]) assert.equal(row.current, undefined) assert.equal(row.standing, undefined) assert.equal(row.rank, undefined) assert.equal(row.noPosition, 'no-current') /* 그래도 분포 자체는 낸다 — "평소 얼마였나" 는 여전히 답할 수 있는 사실이다. */ assert.deepEqual([row.min, row.max], [30_000, 40_000]) }) test('표본이 둘 미만이면 순위를 내지 않는다 — 하나를 분포라 부를 수 없다', () => { const one = summarizeBaseline({ 'throughput.tasks': SPECS['throughput.tasks'] }, { 'throughput.tasks': 10 }, [ sample(1, { 'throughput.tasks': 40 }) ])[0] assert.equal(one.noPosition, 'too-few-samples') assert.equal(one.standing, undefined) const none = summarizeBaseline({ 'throughput.tasks': SPECS['throughput.tasks'] }, { 'throughput.tasks': 10 }, [])[0] assert.equal(none.noPosition, 'too-few-samples') assert.equal(none.min, undefined) }) test('방향을 지표가 안다 — 같은 자리가 처리량과 소요시간에서 반대로 읽힌다', () => { const past = [sample(1, { 'throughput.tasks': 40, 'workTime.p50Ms': 40_000 }), sample(2, { 'throughput.tasks': 60, 'workTime.p50Ms': 60_000 }), sample(3, { 'throughput.tasks': 80, 'workTime.p50Ms': 80_000 })] /* 값이 표본 전부보다 크다 — 처리량은 최고, 소요시간은 최악이다. */ const rows = summarizeBaseline(SPECS, { 'throughput.tasks': 90, 'workTime.p50Ms': 90_000 }, past) const by = Object.fromEntries(rows.map(r => [r.metric, r])) assert.equal(by['throughput.tasks'].standing, 'best') assert.equal(by['workTime.p50Ms'].standing, 'worst') /* 순위는 **방향 없는 사실**이다 — 둘 다 오름차순 4번째다. */ assert.equal(by['throughput.tasks'].rank, 4) assert.equal(by['workTime.p50Ms'].rank, 4) assert.equal(by['workTime.p50Ms'].rankOf, 4) }) test('전부보다 나쁘면 worst — 처리량이 지난 3일 중 가장 낮은 경우', () => { const [row] = summarizeBaseline({ 'throughput.tasks': SPECS['throughput.tasks'] }, { 'throughput.tasks': 10 }, [ sample(1, { 'throughput.tasks': 40 }), sample(2, { 'throughput.tasks': 60 }), sample(3, { 'throughput.tasks': 80 }) ]) assert.equal(row.standing, 'worst') assert.equal(row.rank, 1, '오름차순 첫째') }) test('중앙값과 같으면 typical — 임의의 임계를 만들지 않는다', () => { const [row] = summarizeBaseline({ 'throughput.tasks': SPECS['throughput.tasks'] }, { 'throughput.tasks': 60 }, [ sample(1, { 'throughput.tasks': 40 }), sample(2, { 'throughput.tasks': 60 }), sample(3, { 'throughput.tasks': 80 }) ]) assert.equal(row.standing, 'typical') }) test('중앙값 양쪽을 방향에 따라 구분한다', () => { const past = [sample(1, { 'throughput.tasks': 40 }), sample(2, { 'throughput.tasks': 60 }), sample(3, { 'throughput.tasks': 80 })] const spec = { 'throughput.tasks': SPECS['throughput.tasks'] } assert.equal(summarizeBaseline(spec, { 'throughput.tasks': 70 }, past)[0].standing, 'better-than-typical') assert.equal(summarizeBaseline(spec, { 'throughput.tasks': 50 }, past)[0].standing, 'worse-than-typical') /* 소요시간은 반대다 — 중앙값보다 낮은 것이 좋다. */ const lower = { 'workTime.p50Ms': SPECS['workTime.p50Ms'] } const pastMs = [sample(1, { 'workTime.p50Ms': 40 }), sample(2, { 'workTime.p50Ms': 60 }), sample(3, { 'workTime.p50Ms': 80 })] assert.equal(summarizeBaseline(lower, { 'workTime.p50Ms': 50 }, pastMs)[0].standing, 'better-than-typical') assert.equal(summarizeBaseline(lower, { 'workTime.p50Ms': 70 }, pastMs)[0].standing, 'worse-than-typical') }) test('짝수 표본의 중앙값은 가운데 둘의 평균이다', () => { const [row] = summarizeBaseline({ 'throughput.tasks': SPECS['throughput.tasks'] }, { 'throughput.tasks': 55 }, [ sample(1, { 'throughput.tasks': 40 }), sample(2, { 'throughput.tasks': 50 }), sample(3, { 'throughput.tasks': 60 }), sample(4, { 'throughput.tasks': 70 }) ]) assert.equal(row.median, 55) assert.equal(row.standing, 'typical') }) test('열거된 지표만 낸다 — 모르는 이름을 짐작하지 않는다', () => { const rows = summarizeBaseline({ 'throughput.tasks': SPECS['throughput.tasks'] }, { 'throughput.tasks': 10, 'made.up': 99 } as any, [ sample(1, { 'throughput.tasks': 5 } as any) ]) assert.deepEqual(rows.map(r => r.metric), ['throughput.tasks']) }) test('무한·NaN 은 측정으로 세지 않는다', () => { const [row] = summarizeBaseline({ 'throughput.tasks': SPECS['throughput.tasks'] }, { 'throughput.tasks': Number.NaN }, [ sample(1, { 'throughput.tasks': Number.POSITIVE_INFINITY }), sample(2, { 'throughput.tasks': 40 }), sample(3, { 'throughput.tasks': 60 }) ]) assert.equal(row.measuredCount, 2, '무한은 표본이 아니다') assert.equal(row.noPosition, 'no-current', 'NaN 은 값이 아니다') })