import { describe, test, expect } from 'bun:test'; import { activePlayers, newVsReturning, netGrowth, d1Return, returnWithin, overallReturnRate, streakSurvival, timeToSecondPlayHistogram, guessHistogram, firstTryRate, averageGuesses, entropy, repeatIntervals, bookCoverage, retentionByGuessBucket, weekdayHourHeatmap, cohortTriangle, dailyCompletionsSeries } from '$lib/server/stats/metrics'; import type { CompletionRow } from '$lib/types/stats'; function c(anonymousId: string, date: string, guessCount = 3, hour = 12): CompletionRow { return { anonymousId, date, guessCount, guesses: null, completedAt: new Date(`${date}T${String(hour).padStart(2, '0')}:00:00Z`) }; } // A: 3-day streak. B: plays day 1 and 3. C: one play. D: one play later. const fixture: CompletionRow[] = [ c('A', '2026-09-01'), c('A', '2026-09-02'), c('A', '2026-09-03'), c('B', '2026-09-01'), c('B', '2026-09-03'), c('C', '2026-09-02', 1), c('D', '2026-09-05', 6) ]; describe('activePlayers', () => { test('window of 1 is just that day', () => { expect(activePlayers(fixture, '2026-09-03', 1)).toBe(2); // A, B }); test('window includes the trailing days', () => { expect(activePlayers(fixture, '2026-09-03', 3)).toBe(3); // A, B, C }); test('empty window returns 0', () => { expect(activePlayers(fixture, '2026-08-01', 1)).toBe(0); }); }); describe('newVsReturning', () => { test('day one is all new', () => { expect(newVsReturning(fixture, '2026-09-01')).toEqual({ newPlayers: 2, returning: 0, returningShare: 0 }); }); test('later days are all returning', () => { expect(newVsReturning(fixture, '2026-09-03')).toEqual({ newPlayers: 0, returning: 2, returningShare: 100 }); }); }); describe('return rates', () => { test('d1Return pools the cohort of first-timers', () => { // A first on 09-01 and returned 09-02; B first on 09-01 and did not. expect(d1Return(fixture, '2026-09-01')).toBe(50); }); test('returnWithin counts any return in the window', () => { expect(returnWithin(fixture, '2026-09-01', 2)).toBe(100); }); test('small cohorts return null when minSize is set', () => { expect(returnWithin(fixture, '2026-09-05', 1, 3)).toBeNull(); }); test('overall return rate = players with 2+ plays', () => { expect(overallReturnRate(fixture)).toBe(50); // A & B of A,B,C,D }); }); describe('netGrowth', () => { test('new + resurrected - churned over the window', () => { // Window 09-04..09-05: D is new; A,B,C played the preceding window and stopped. const g = netGrowth(fixture, '2026-09-05', 2, 30); expect(g.newPlayers).toBe(1); expect(g.churned).toBe(3); expect(g.netGrowth).toBe(-2); }); test('a long gap counts as resurrected', () => { const data = [...fixture, c('E', '2026-06-01'), c('E', '2026-09-05')]; const g = netGrowth(data, '2026-09-05', 2, 30); expect(g.resurrected).toBe(1); }); }); describe('streaks', () => { test('survival probability per streak length', () => { const survival = streakSurvival(fixture); const byLength = new Map(survival.map((s) => [s.length, s.probability])); expect(byLength.get(1)).toBe(20); // only A continued from day 1 expect(byLength.get(2)).toBe(100); // A continued to day 3 expect(byLength.get(3)).toBe(0); // A stopped after day 3 }); }); describe('histograms', () => { test('time to second play buckets gaps', () => { const bins = timeToSecondPlayHistogram(fixture); expect(bins.find((b) => b.label === '1 day')?.value).toBe(1); // A expect(bins.find((b) => b.label === '2 days')?.value).toBe(1); // B }); test('guess histogram collapses the tail', () => { const bins = guessHistogram(fixture, 5); const total = bins.reduce((a, b) => a + b.value, 0); expect(total).toBe(fixture.length); expect(bins[bins.length - 1].label).toBe('6+'); }); test('first try rate and average guesses', () => { expect(firstTryRate(fixture)).toBeCloseTo(14.3, 1); expect(typeof averageGuesses(fixture)).toBe('number'); }); }); describe('content metrics', () => { test('entropy of a uniform distribution is log2(n)', () => { expect(entropy([1, 1, 1, 1])).toBe(2); expect(entropy([])).toBe(0); }); test('repeat intervals count gaps per book', () => { const bins = repeatIntervals([ { date: '2026-09-01', bookId: 'GEN' }, { date: '2026-09-03', bookId: 'GEN' }, { date: '2026-10-01', bookId: 'EXO' }, { date: '2026-10-05', bookId: 'EXO' } ]); expect(bins.find((b) => b.label === '1–7d')?.value).toBe(2); }); test('bookCoverage returns a stat for every book', () => { const stats = bookCoverage( [ { date: '2026-09-01', bookId: 'GEN' }, { date: '2026-09-02', bookId: 'GEN' } ], fixture, { start: '2026-09-01', end: '2026-09-03' } ); expect(stats).toHaveLength(66); const gen = stats.find((s) => s.bookId === 'GEN')!; expect(gen.days).toBe(2); expect(gen.plays).toBe(4); // A,B on 09-01 + A,C on 09-02 }); test('retention by guess bucket is grouped', () => { const buckets = retentionByGuessBucket(fixture); expect(buckets).toHaveLength(5); }); test('weekday/hour heatmap has a 7x24 grid', () => { const grid = weekdayHourHeatmap(fixture); expect(grid).toHaveLength(7); expect(grid[0]).toHaveLength(24); }); }); describe('cohortTriangle', () => { test('week 0 is the full cohort and later weeks are subsets', () => { const rows = cohortTriangle(fixture, { start: '2026-08-30', // Sunday weeks: 3, asOf: '2026-09-15', minSize: 1 }); expect(rows).toHaveLength(3); expect(rows[0].size).toBeGreaterThan(0); expect(rows[0].counts[0]).toBe(rows[0].size); }); }); describe('dailyCompletionsSeries', () => { test('fills gaps with zeroes and counts new players', () => { const series = dailyCompletionsSeries(fixture, '2026-09-01', '2026-09-03'); expect(series).toHaveLength(3); expect(series[0]).toEqual({ date: '2026-09-01', completions: 2, newPlayers: 2 }); expect(series[2]).toEqual({ date: '2026-09-03', completions: 2, newPlayers: 0 }); }); });