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; activity?: Partial; 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(); }); });