import { describe, expect, it } from 'bun:test'; import { classifyRisk, lastAttendedAt, monthlyAttendanceRate, trailingAbsentStreak, } from '@/modules/analytics/lib/risk'; const day = 24 * 60 * 60 * 1000; const at = (offsetDays: number) => new Date(Date.now() - offsetDays * day); function entry(status: 'PRESENT' | 'ABSENT' | 'EXCUSED', offsetDays: number) { return { status, occurredAt: at(offsetDays) }; } describe('trailingAbsentStreak', () => { it('devuelve 0 sin registros', () => { expect(trailingAbsentStreak([])).toBe(0); }); it('cuenta ausencias consecutivas sin justificar desde la más reciente', () => { expect(trailingAbsentStreak([entry('ABSENT', 0)])).toBe(1); expect(trailingAbsentStreak([entry('ABSENT', 0), entry('ABSENT', 7), entry('ABSENT', 14)])).toBe(3); }); it('rompe la cadena con PRESENT o EXCUSED', () => { expect( trailingAbsentStreak([entry('ABSENT', 0), entry('ABSENT', 7), entry('PRESENT', 14)]), ).toBe(2); expect(trailingAbsentStreak([entry('EXCUSED', 0), entry('ABSENT', 7)])).toBe(0); expect(trailingAbsentStreak([entry('PRESENT', 0)])).toBe(0); }); }); describe('lastAttendedAt', () => { it('devuelve la clase PRESENT más reciente', () => { const result = lastAttendedAt([ entry('PRESENT', 0), entry('ABSENT', 7), entry('ABSENT', 14), ]); expect(result).not.toBeNull(); expect(result?.getTime()).toBe(at(0).getTime()); }); it('devuelve null si nunca asistió', () => { expect(lastAttendedAt([entry('ABSENT', 0), entry('EXCUSED', 7)])).toBeNull(); expect(lastAttendedAt([])).toBeNull(); }); }); describe('monthlyAttendanceRate', () => { it('razón de PRESENT sobre el total de sesiones', () => { expect(monthlyAttendanceRate([entry('PRESENT', 0), entry('PRESENT', 7), entry('ABSENT', 14)], 5)).toBe(40); }); it('devuelve 0 sin sesiones o sin presentes', () => { expect(monthlyAttendanceRate([], 3)).toBe(0); expect(monthlyAttendanceRate([entry('ABSENT', 0)], 3)).toBe(0); }); }); describe('classifyRisk', () => { it('HIGH con 3 o más ausencias consecutivas', () => { expect(classifyRisk(3, 80)).toBe('HIGH'); expect(classifyRisk(5, 20)).toBe('HIGH'); }); it('MEDIUM con 2 ausencias consecutivas o asistencia menor al 50%', () => { expect(classifyRisk(2, 90)).toBe('MEDIUM'); expect(classifyRisk(1, 40)).toBe('MEDIUM'); expect(classifyRisk(2, 40)).toBe('MEDIUM'); }); it('sin riesgo si no cumple ningún umbral', () => { expect(classifyRisk(1, 60)).toBeNull(); expect(classifyRisk(0, 60)).toBeNull(); }); });