Files
bibdle/tests/stats-metrics.test.ts
T

194 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 });
});
});