diff --git a/src/renderer/src/lib/achievements.test.ts b/src/renderer/src/lib/achievements.test.ts new file mode 100644 index 0000000..7a89d32 --- /dev/null +++ b/src/renderer/src/lib/achievements.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest' +import type { Exercise, HistoryEntry } from '@shared/types' +import { computeAchievements } from './achievements' + +const MS_DAY = 24 * 60 * 60 * 1000 + +function ex(id: string, reps: number): Exercise { + return { + id, + name: id, + reps, + icon: 'Activity', + intervalMinutes: 30, + enabled: true, + nextFireAt: 0 + } +} + +function done(exerciseId: string, ts: number, reps?: number): HistoryEntry { + const e: HistoryEntry = { exerciseId, ts, action: 'done' } + if (reps !== undefined) e.reps = reps + return e +} + +describe('computeAchievements', () => { + it('first_day unlocks on first done', () => { + const exs = [ex('a', 10)] + const hist = [done('a', Date.now())] + const out = computeAchievements(hist, exs) + const first = out.find((a) => a.def.id === 'first_day') + expect(first?.unlocked).toBe(true) + }) + + it('first_day locked when no entries', () => { + const out = computeAchievements([], []) + const first = out.find((a) => a.def.id === 'first_day') + expect(first?.unlocked).toBe(false) + }) + + it('reps_100 unlocks at exactly 100 total reps', () => { + const exs = [ex('a', 10)] + // 10 done entries × 10 reps = 100 + const hist = Array.from({ length: 10 }, (_, i) => + done('a', Date.now() - i * 1000) + ) + const out = computeAchievements(hist, exs) + const reps100 = out.find((a) => a.def.id === 'reps_100') + expect(reps100?.unlocked).toBe(true) + expect(reps100?.current).toBe(100) + }) + + it('reps_500 locked at 100 with progress', () => { + const exs = [ex('a', 10)] + const hist = Array.from({ length: 10 }, (_, i) => + done('a', Date.now() - i * 1000) + ) + const out = computeAchievements(hist, exs) + const reps500 = out.find((a) => a.def.id === 'reps_500') + expect(reps500?.unlocked).toBe(false) + expect(reps500?.current).toBe(100) + expect(reps500?.target).toBe(500) + }) + + it('streak_3 unlocks with 3 consecutive days ending today', () => { + const exs = [ex('a', 10)] + const today = new Date() + today.setHours(12, 0, 0, 0) + const hist = [ + done('a', today.getTime()), + done('a', today.getTime() - MS_DAY), + done('a', today.getTime() - 2 * MS_DAY) + ] + const out = computeAchievements(hist, exs) + const s3 = out.find((a) => a.def.id === 'streak_3') + expect(s3?.unlocked).toBe(true) + }) + + it('streak_3 locked with gap in days', () => { + const exs = [ex('a', 10)] + const today = new Date() + today.setHours(12, 0, 0, 0) + const hist = [ + done('a', today.getTime()), + done('a', today.getTime() - MS_DAY) + // отсутствует день -2 — стрик прерван + ] + const out = computeAchievements(hist, exs) + const s3 = out.find((a) => a.def.id === 'streak_3') + expect(s3?.unlocked).toBe(false) + expect(s3?.current).toBe(2) + }) + + it('today_quad unlocks at 40+ reps today', () => { + const exs = [ex('a', 50)] + const hist = [done('a', Date.now())] + const out = computeAchievements(hist, exs) + const q = out.find((a) => a.def.id === 'today_quad') + expect(q?.unlocked).toBe(true) + expect(q?.current).toBe(50) + }) + + it('today_quad locked at 30 reps', () => { + const exs = [ex('a', 30)] + const hist = [done('a', Date.now())] + const out = computeAchievements(hist, exs) + const q = out.find((a) => a.def.id === 'today_quad') + expect(q?.unlocked).toBe(false) + expect(q?.current).toBe(30) + expect(q?.target).toBe(40) + }) + + it('counts match-challenges via entry.reps snapshot', () => { + // Match challenge entries имеют exerciseId='challenge:' и snapshot + // reps в поле reps. computeAchievements должен их учитывать. + const exs = [ex('a', 10)] + const today = Date.now() + const hist: HistoryEntry[] = [ + // 100 reps от обычных + ...Array.from({ length: 10 }, (_, i) => done('a', today - i * 1000)), + // +50 от челленджа + { + exerciseId: 'challenge:abc', + ts: today, + action: 'done', + actualReps: 50, + reps: 50, + source: 'match' + } + ] + const out = computeAchievements(hist, exs) + const reps100 = out.find((a) => a.def.id === 'reps_100') + expect(reps100?.current).toBe(150) // 100 + 50 + }) + + it('returns deterministic order matching DEFINITIONS', () => { + const out = computeAchievements([], []) + // Не пустой массив, есть все определённые достижения. + expect(out.length).toBeGreaterThan(5) + // reps_100 идёт раньше streak_3 (порядок DEFINITIONS). + const reps100Idx = out.findIndex((a) => a.def.id === 'reps_100') + const streak3Idx = out.findIndex((a) => a.def.id === 'streak_3') + expect(reps100Idx).toBeLessThan(streak3Idx) + }) +})