Files
bibdle/tests/stats-signals.test.ts
T
George Powell a562ee3cc9 fix(stats): finalize snapshots on the verse-day window
A verse date is a player's local calendar day, spanning D-1 10:00Z
(UTC+14) through D+1 12:00Z (UTC-12). The old UTC end-of-day cutoff both
dropped late-night finishes in negative timezones and could persist a
partial day.

- Scope snapshot loads by the verse date column; use completedBefore only
  to pin an in-progress point-in-time build.
- Gate persistence on isDayClosed (D+1 12:00Z); open days are computed
  live and never written.
- Build the cron snapshot for latestClosedDate instead of EST yesterday,
  and read the same date for the community page.
- Add all-time best single day to snapshot totals.
- Bump PAYLOAD_SCHEMA_VERSION to 4 and extend snapshot/window tests.
2026-09-28 20:21:47 -04:00

141 lines
4.5 KiB
TypeScript

import { describe, test, expect } from 'bun:test';
import { evaluateSignals, detectAnomaly, sortSignals } from '$lib/server/stats/signals';
import { SETTINGS_DEFAULTS, type SnapshotPayload } from '$lib/types/stats';
function makePayload(overrides: {
kpis?: Partial<SnapshotPayload['kpis']>;
activity?: Partial<SnapshotPayload['activity']>;
dailySeries?: SnapshotPayload['dailySeries'];
} = {}): SnapshotPayload {
return {
asOfDate: '2026-09-14',
activity: {
dau: 100,
wau: 300,
mau: 800,
core: 50,
returningShare: 40,
newPlayers: 20,
resurrected: 5,
churned: 18,
netGrowth: 7,
corePrevious: 45,
previous: { dau: 95, wau: 290, mau: 790, core: 45 },
...overrides.activity
},
dailySeries: overrides.dailySeries ?? [],
kpis: {
d1Return: 25,
return7: 30,
overallReturnRate: 22,
completionRate: 80,
shareRate: 30,
firstTryRate: 25,
avgGuesses: 4.1,
...overrides.kpis
},
retention: {
cohorts: [],
curves: { d1: [], d7: [], d30: [] },
timeToSecondPlay: [],
segmentMix: []
},
growth: { lifecycleFunnel: [], signups: [] },
totals: { completions: 0, players: 0, yesterdayCompletions: 0, yesterdayAvgGuesses: null, bestDay: null },
verseMix: {
coverage: [],
testamentMix: [],
entropy: [],
repeatIntervals: [],
firstTryByBook: [],
retentionByBook: [],
retentionByGuessBucket: [],
achievability: []
},
habit: { streakSurvival: [], streakLengths: [], calendar: [], weekdayHour: [] },
signals: [],
meta: { hasStartedData: true, hasShareData: true, firstEventDate: null }
};
}
const settings = { ...SETTINGS_DEFAULTS };
describe('evaluateSignals', () => {
test('all-good payload yields no warn/bad signals', () => {
const signals = evaluateSignals(makePayload(), settings);
expect(signals.some((s) => s.severity === 'bad' || s.severity === 'warn')).toBe(false);
});
test('low D1 return is bad and recommends an action', () => {
const signals = evaluateSignals(makePayload({ kpis: { d1Return: 9 } }), settings);
const d1 = signals.find((s) => s.id === 'd1_return')!;
expect(d1.severity).toBe('bad');
expect(d1.action).toBeTruthy();
});
test('missed-but-not-red targets are warnings', () => {
const signals = evaluateSignals(makePayload({ kpis: { d1Return: 16 } }), settings);
expect(signals.find((s) => s.id === 'd1_return')!.severity).toBe('warn');
});
test('first-try rate outside the band is flagged', () => {
const tooEasy = evaluateSignals(makePayload({ kpis: { firstTryRate: 60 } }), settings);
expect(tooEasy.find((s) => s.id === 'first_try')!.severity).toBe('bad');
const tooHard = evaluateSignals(makePayload({ kpis: { firstTryRate: 5 } }), settings);
expect(tooHard.find((s) => s.id === 'first_try')!.severity).toBe('bad');
});
test('missing metrics surface as info, not failure', () => {
const signals = evaluateSignals(
makePayload({ kpis: { completionRate: null, shareRate: null } }),
settings
);
expect(signals.find((s) => s.id === 'completion')!.severity).toBe('info');
expect(signals.find((s) => s.id === 'share_rate')!.severity).toBe('info');
});
test('declining core players is a warning', () => {
const signals = evaluateSignals(
makePayload({ activity: { core: 30, corePrevious: 50 } }),
settings
);
expect(signals.find((s) => s.id === 'core_players')!.severity).toBe('warn');
});
test('sortSignals puts bad first', () => {
const signals = sortSignals(evaluateSignals(makePayload({ kpis: { d1Return: 5 } }), settings));
expect(signals[0].severity).toBe('bad');
});
});
describe('detectAnomaly', () => {
function series(values: number[], startWeekdayOffset = 0) {
// Build consecutive days starting 2026-08-01 so weekdays repeat every 7.
const out = values.map((value, i) => {
const d = new Date('2026-08-01T00:00:00Z');
d.setUTCDate(d.getUTCDate() + i);
return { date: d.toISOString().slice(0, 10), value };
});
void startWeekdayOffset;
return out;
}
test('flags a large weekday-aware spike', () => {
// 5 weeks of varying same-weekday values, then a spike.
const base: number[] = [];
for (let i = 0; i < 35; i++) base.push(100 + ((i * 13) % 17));
const spike = [...base, 500];
const anomaly = detectAnomaly(series(spike), 2);
expect(anomaly).not.toBeNull();
expect(anomaly!.value).toBe(500);
});
test('returns null for a flat series', () => {
expect(detectAnomaly(series(Array(35).fill(100)), 2)).toBeNull();
});
test('returns null without enough history', () => {
expect(detectAnomaly(series([1, 2, 3]), 2)).toBeNull();
});
});