mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-10-22 05:54:10 -04:00
194 lines
5.9 KiB
TypeScript
194 lines
5.9 KiB
TypeScript
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 });
|
||
});
|
||
});
|