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, activityBuckets, weeklyFlows, projectWau } 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 }); }); }); describe('activityBuckets', () => { // 2026-09-10 is a Thursday, so its ISO week (Mon 09-07 .. Sun 09-13) is // still in progress: the most recent completed week ends 2026-09-06 (W36). const asOf = '2026-09-10'; test('weeks are ISO Monday–Sunday and stop at the last completed week', () => { const rows: CompletionRow[] = [ c('mon', '2026-08-31'), // first day of the week c('sun', '2026-09-06') // last day of the week ]; const weeks = activityBuckets(rows, asOf, 'week'); expect(weeks).toHaveLength(1); expect(weeks[0].date).toBe('2026-09-06'); expect(weeks[0].label).toBe('W36'); expect(weeks[0].wau).toBe(2); }); test('spans every completed week back to the first week with data', () => { const rows: CompletionRow[] = [ c('p1', '2026-08-20'), c('p2', '2026-08-27'), c('p3', '2026-09-03') ]; const weeks = activityBuckets(rows, asOf, 'week'); expect(weeks.map((b) => b.date)).toEqual(['2026-08-23', '2026-08-30', '2026-09-06']); expect(weeks.map((b) => b.label)).toEqual(['W34', 'W35', 'W36']); expect(weeks.map((b) => b.wau)).toEqual([1, 1, 1]); // The trailing 30-day window reaches further back, so it grows per bucket. expect(weeks.map((b) => b.mau)).toEqual([1, 2, 3]); }); test('excludes the in-progress week so a partial period cannot drag the series down', () => { const rows: CompletionRow[] = [c('current', '2026-09-08'), c('done', '2026-09-04')]; const weeks = activityBuckets(rows, asOf, 'week'); expect(weeks).toHaveLength(1); expect(weeks[0].date).toBe('2026-09-06'); expect(weeks[0].wau).toBe(1); }); test('monthly buckets are whole calendar months up to the last completed one', () => { const rows: CompletionRow[] = [ c('jun', '2026-06-01'), c('jul', '2026-07-15'), c('aug', '2026-08-31') ]; const months = activityBuckets(rows, asOf, 'month'); expect(months.map((b) => b.date)).toEqual(['2026-06-30', '2026-07-31', '2026-08-31']); expect(months.map((b) => b.label)).toEqual(['Jun', 'Jul', 'Aug']); // A whole calendar month, not a trailing 30-day window: Jun 1 is included. expect(months[0].mau).toBe(1); }); test('a month ending on the as-of day counts as completed', () => { const rows: CompletionRow[] = [c('a', '2026-09-01'), c('b', '2026-09-30')]; const months = activityBuckets(rows, '2026-09-30', 'month'); expect(months.map((b) => b.date)).toEqual(['2026-09-30']); expect(months[0].mau).toBe(2); }); test('adds the week-year only when the range spans week-years', () => { const rows: CompletionRow[] = [c('a', '2025-12-01'), c('b', '2026-01-15')]; const weeks = activityBuckets(rows, '2026-02-10', 'week'); expect(weeks[0].label).toBe("W49 '25"); expect(weeks.at(-1)?.label).toBe("W6 '26"); }); test('adds the year to month labels when the range spans years', () => { const rows: CompletionRow[] = [c('a', '2025-11-01'), c('b', '2026-01-15')]; const months = activityBuckets(rows, '2026-02-10', 'month'); expect(months.map((b) => b.label)).toEqual(['Nov 25', 'Dec 25', 'Jan 26']); }); test('labels January with its year even inside a single-year range', () => { const rows: CompletionRow[] = [c('a', '2026-01-01'), c('b', '2026-03-15')]; const months = activityBuckets(rows, '2026-04-15', 'month'); expect(months.map((b) => b.label)).toEqual(['Jan 26', 'Feb', 'Mar']); }); test('returns nothing when every period with data is still in progress', () => { const rows: CompletionRow[] = [c('a', '2026-09-08')]; expect(activityBuckets(rows, asOf, 'month')).toEqual([]); }); }); describe('weeklyFlows / projectWau', () => { // Three consecutive completed ISO weeks ending Sun 08-23, 08-30, 09-06. // p2/p3 return, p4/p5 arrive: 2 retained and 1 inflow each week. const rows: CompletionRow[] = [ c('p1', '2026-08-18'), c('p2', '2026-08-18'), c('p3', '2026-08-18'), c('p2', '2026-08-25'), c('p3', '2026-08-25'), c('p4', '2026-08-25'), c('p3', '2026-09-01'), c('p4', '2026-09-01'), c('p5', '2026-09-01') ]; const asOf = '2026-09-10'; test('weeklyFlows accounts for retained vs inflow players', () => { const flows = weeklyFlows(rows, asOf); expect(flows.map((f) => f.end)).toEqual(['2026-08-23', '2026-08-30', '2026-09-06']); expect(flows.map((f) => f.wau)).toEqual([3, 3, 3]); expect(flows.map((f) => f.retained)).toEqual([0, 2, 2]); expect(flows.map((f) => f.inflow)).toEqual([3, 1, 1]); expect(flows.map((f) => f.newPlayers)).toEqual([3, 1, 1]); expect(flows.map((f) => f.resurrected)).toEqual([0, 0, 0]); }); test('the flow model holds a flat base steady instead of overshooting', () => { const projection = projectWau(rows, asOf, 4); expect(projection).not.toBeNull(); expect(projection?.sampleWeeks).toBe(3); expect(projection?.retention).toBeCloseTo(0.667, 3); expect(projection?.inflow).toBe(1); // retention 2/3 * 3 + 1 = 3 every week. expect(projection?.flow.map((p) => p.value)).toEqual([3, 3, 3, 3]); expect(projection?.flow).toHaveLength(4); expect(projection?.flow[0].label).toBe('W37'); }); test('every model returns a bracketed band', () => { const projection = projectWau(rows, asOf, 4); for (const key of ['flow', 'regression', 'seasonal'] as const) { expect(projection?.[key]).toHaveLength(4); for (const point of projection?.[key] ?? []) { expect(point.low).toBeLessThanOrEqual(point.value); expect(point.value).toBeLessThanOrEqual(point.high); } } }); test('fits regression and seasonal models once there is enough history', () => { const start = new Date('2026-01-05T00:00:00Z'); // Monday const rows: CompletionRow[] = []; for (let w = 0; w < 30; w++) { const weekStart = new Date(start); weekStart.setUTCDate(weekStart.getUTCDate() + w * 7); for (let p = 0; p < 3; p++) { const d = new Date(weekStart); d.setUTCDate(d.getUTCDate() + ((w + p) % 7)); rows.push(c(`core-${p}`, d.toISOString().slice(0, 10))); } for (let p = 0; p < 2; p++) { const d = new Date(weekStart); d.setUTCDate(d.getUTCDate() + ((w + p + 3) % 7)); rows.push(c(`new-${w}-${p}`, d.toISOString().slice(0, 10))); } } const after = new Date(start); after.setUTCDate(after.getUTCDate() + 30 * 7); const projection = projectWau(rows, after.toISOString().slice(0, 10), 4); expect(projection?.sampleWeeks).toBe(30); expect(projection?.regression).toHaveLength(4); expect(projection?.seasonal).toHaveLength(4); expect(projection?.retention).toBeGreaterThan(0); expect(projection?.retention).toBeLessThanOrEqual(1); }); test('the corrected regression does not collapse toward zero', () => { const start = new Date('2026-01-05T00:00:00Z'); // Monday const rows: CompletionRow[] = []; for (let w = 0; w < 30; w++) { const weekStart = new Date(start); weekStart.setUTCDate(weekStart.getUTCDate() + w * 7); for (let p = 0; p < 4; p++) { const d = new Date(weekStart); d.setUTCDate(d.getUTCDate() + ((w + p) % 7)); rows.push(c(`core-${p}`, d.toISOString().slice(0, 10))); } } const after = new Date(start); after.setUTCDate(after.getUTCDate() + 30 * 7); const projection = projectWau(rows, after.toISOString().slice(0, 10), 4); const last = projection?.regression.at(-1)?.value ?? 0; // A stable 4-player base should not decay away. expect(last).toBeGreaterThanOrEqual(3); }); test('needs at least three completed weeks', () => { const few = [c('a', '2026-09-01'), c('b', '2026-09-02')]; expect(projectWau(few, asOf)).toBeNull(); }); });