diff --git a/frontend/src/features/history/ui/ScoreTrend.test.tsx b/frontend/src/features/history/ui/ScoreTrend.test.tsx new file mode 100644 index 00000000..87592964 --- /dev/null +++ b/frontend/src/features/history/ui/ScoreTrend.test.tsx @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { ScoreTrend } from './ScoreTrend' +import type { UserStats } from '../api/historyApi' + +// recent 는 최신순(첫 항목이 가장 최근). reverse 후 시간순으로 그려진다. +const stats: UserStats = { + totalSessionCount: 2, + completedSessionCount: 2, + averages: { overall: 75, technical: 73, logic: 81, communication: 75 }, + recent: [ + { + sessionId: 2, + overall: 80, + technical: 75, + logic: 82, + communication: 78, + endedAt: '2026-06-02T00:00:00Z', + }, + { + sessionId: 1, + overall: 70, + technical: 72, + logic: 80, + communication: 72, + endedAt: '2026-06-01T00:00:00Z', + }, + ], +} + +describe('ScoreTrend', () => { + it('4개 지표 라벨 + 최신 점수 + 지난번 대비 델타를 보여준다', () => { + render() + expect(screen.getByText('지표별 점수 추이 (최근 2회)')).toBeInTheDocument() + ;['종합', '기술', '논리', '전달력'].forEach((l) => + expect(screen.getByText(l)).toBeInTheDocument(), + ) + // 종합 최신 80, 지난번(70) 대비 ▲10 + expect(screen.getByText('80')).toBeInTheDocument() + expect(screen.getByText('▲10')).toBeInTheDocument() + }) + + it('채점된 면접이 없으면 안내 문구를 보여준다', () => { + render() + expect(screen.getByText('아직 채점된 면접이 없어요.')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/features/history/ui/ScoreTrend.tsx b/frontend/src/features/history/ui/ScoreTrend.tsx index 5c5c63a2..459c9620 100644 --- a/frontend/src/features/history/ui/ScoreTrend.tsx +++ b/frontend/src/features/history/ui/ScoreTrend.tsx @@ -1,22 +1,31 @@ import type { UserStats } from '../api/historyApi' const W = 320 -const H = 140 -const PAD = { l: 26, r: 10, t: 18, b: 10 } +const H = 150 +const PAD = { l: 26, r: 10, t: 14, b: 10 } const IW = W - PAD.l - PAD.r const IH = H - PAD.t - PAD.b const GRID = [0, 50, 100] const clamp = (v: number) => Math.max(0, Math.min(100, v)) -// 종합 점수 추이를 라이브러리 없이 SVG 추세선(라인+영역)으로. recent 는 최신순이라 뒤집어 시간순으로. +type MetricKey = 'overall' | 'technical' | 'logic' | 'communication' +const METRICS: { key: MetricKey; label: string; color: string }[] = [ + { key: 'overall', label: '종합', color: 'var(--color-primary)' }, + { key: 'technical', label: '기술', color: 'var(--color-info)' }, + { key: 'logic', label: '논리', color: 'var(--color-success)' }, + { key: 'communication', label: '전달력', color: 'var(--color-warning)' }, +] + +// 지표별(종합·기술·논리·전달력) 점수 추이를 라이브러리 없이 SVG 멀티 라인으로. +// recent 는 최신순이라 뒤집어 시간순으로, 종합이 채점된 세션을 x축 스파인으로 쓴다. export function ScoreTrend({ stats }: { stats: UserStats }) { - const points = [...(stats.recent ?? [])] + const sessions = [...(stats.recent ?? [])] .reverse() .filter((r) => typeof r.overall === 'number') - .map((r) => ({ sessionId: r.sessionId, score: clamp(r.overall as number) })) + const n = sessions.length - if (points.length === 0) { + if (n === 0) { return (
점수 추이 @@ -25,22 +34,37 @@ export function ScoreTrend({ stats }: { stats: UserStats }) { ) } - const n = points.length const sx = (i: number) => (n <= 1 ? PAD.l + IW / 2 : PAD.l + (IW * i) / (n - 1)) const sy = (s: number) => PAD.t + IH * (1 - s / 100) - const data = points.map((p, i) => ({ ...p, x: sx(i), y: sy(p.score) })) - const linePts = data.map((d) => `${d.x.toFixed(1)},${d.y.toFixed(1)}`).join(' ') - const areaPts = `${data[0].x.toFixed(1)},${PAD.t + IH} ${linePts} ${data[n - 1].x.toFixed(1)},${PAD.t + IH}` + + const series = METRICS.map((m) => { + const pts = sessions + .map((s, i) => + typeof s[m.key] === 'number' + ? { x: sx(i), y: sy(clamp(s[m.key] as number)) } + : null, + ) + .filter((p): p is { x: number; y: number } => p !== null) + const vals = sessions + .map((s) => s[m.key]) + .filter((v): v is number => typeof v === 'number') + const latest = vals.length ? Math.round(vals[vals.length - 1]) : null + const delta = + vals.length >= 2 ? Math.round(vals[vals.length - 1] - vals[vals.length - 2]) : null + return { ...m, pts, latest, delta } + }) return (
- 종합 점수 추이 (최근 {n}회) + 지표별 점수 추이 (최근 {n}회) `${d.score}점`).join(', ')}`} + aria-label={`지표별 점수 추이, 최근 ${n}회. ${series + .map((s) => `${s.label} ${s.latest ?? '미산정'}`) + .join(', ')}`} > {/* y축 가이드라인 + 눈금(0/50/100) */} {GRID.map((g) => { @@ -68,38 +92,54 @@ export function ScoreTrend({ stats }: { stats: UserStats }) { ) })} - {/* 영역 + 추세선 (점 2개 이상일 때) */} - {n >= 2 && ( - <> - - + s.pts.length >= 2 && ( + `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ')} + fill="none" + style={{ stroke: s.color }} + strokeWidth={1.75} + strokeLinejoin="round" + strokeLinecap="round" + /> + ), + )} + {/* 데이터 포인트 */} + {series.map((s) => + s.pts.map((p, i) => ( + - + )), )} + - {/* 데이터 포인트 + 값 라벨 */} - {data.map((d) => ( - - - - {d.score} - - + {/* 범례 — 지표별 최신 점수 + 지난번 대비 델타 */} +
+ {series.map((s) => ( +
+ + {s.label} + {s.latest ?? '—'} + {s.delta != null && s.delta !== 0 && ( + 0 ? 'text-success-700' : 'text-danger-700'}> + {s.delta > 0 ? `▲${s.delta}` : `▼${Math.abs(s.delta)}`} + + )} +
))} - +
) }