test: покрытие achievements + release-notes (+18 тестов)
This commit is contained in:
144
src/renderer/src/lib/achievements.test.ts
Normal file
144
src/renderer/src/lib/achievements.test.ts
Normal file
@@ -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:<id>' и 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user