import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { getUserStats } from '../../src/services/stats.service.js'; import { StudyItem } from '../../src/models/StudyItem.js'; import { ReviewLog } from '../../src/models/ReviewLog.js'; vi.mock('../../src/models/StudyItem.js'); vi.mock('../../src/models/ReviewLog.js'); describe('Stats Service', () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date('2023-01-10T12:00:00Z')); }); afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); }); const mockUser = { _id: 'u1', stats: { totalReviews: 10, correctReviews: 8, currentStreak: 5, lastStudyDate: '2023-01-10', lastFreezeDate: '2022-01-01' } }; it('should calculate stats correctly including ghosts, forecast, and unknown SRS levels', async () => { StudyItem.aggregate.mockResolvedValue([ { _id: 1, count: 5 }, { _id: 8, count: 2 } ]); StudyItem.find.mockImplementation(() => ({ select: vi.fn().mockImplementation((fields) => { if (fields.includes('nextReview')) { const now = new Date(); return Promise.resolve([ { nextReview: new Date(now.getTime() - 1000) }, { nextReview: new Date(now.getTime() + 3600000) }, { nextReview: new Date(now.getTime() + 24 * 3600000) } ]); } if (fields.includes('stats')) { const mkGhost = (acc) => ({ char: 'A', meaning: 'B', stats: { correct: acc, total: 100 }, srsLevel: 1, toObject: () => ({ char: 'A', meaning: 'B', stats: { correct: acc, total: 100 }, srsLevel: 1 }) }); return Promise.resolve([mkGhost(60), mkGhost(50), mkGhost(90)]); } if (fields.includes('srsLevel')) { return Promise.resolve([{ srsLevel: 1 }, { srsLevel: 4 }]); } return Promise.resolve([]); }) })); ReviewLog.find.mockResolvedValue([ { date: '2023-01-09', count: 5 } ]); const stats = await getUserStats(mockUser); expect(stats.forecast[0]).toBe(1); expect(stats.forecast[1]).toBe(1); expect(stats.ghosts.length).toBe(2); expect(stats.ghosts[0].accuracy).toBe(50); expect(stats.ghosts[1].accuracy).toBe(60); expect(stats.distribution[1]).toBe(5); expect(stats.distribution[8]).toBeUndefined(); expect(stats.heatmap['2023-01-09']).toBe(5); }); it('should handle queue with same levels (hasLowerLevels = false)', async () => { StudyItem.aggregate.mockResolvedValue([]); StudyItem.find.mockImplementation(() => ({ select: vi.fn().mockImplementation((fields) => { if (fields.includes('stats')) { return Promise.resolve([]); } if (fields.includes('srsLevel')) { return Promise.resolve([{ srsLevel: 2 }, { srsLevel: 2 }]); } return Promise.resolve([]); }) })); ReviewLog.find.mockResolvedValue([]); const stats = await getUserStats(mockUser); expect(stats.hasLowerLevels).toBe(false); }); it('should calculate shield cooldown', async () => { StudyItem.aggregate.mockResolvedValue([]); StudyItem.find.mockReturnValue({ select: vi.fn().mockResolvedValue([]) }); ReviewLog.find.mockResolvedValue([]); const frozenUser = { ...mockUser, stats: { ...mockUser.stats, lastFreezeDate: '2023-01-07' } }; const stats = await getUserStats(frozenUser); expect(stats.streak.shield.ready).toBe(false); expect(stats.streak.shield.cooldown).toBe(4); }); it('should handle streak logic when lastStudyDate is missing or old', async () => { const userNoDate = { ...mockUser, stats: { ...mockUser.stats, lastStudyDate: null, currentStreak: 5 } }; StudyItem.aggregate.mockResolvedValue([]); StudyItem.find.mockReturnValue({ select: vi.fn().mockResolvedValue([]) }); ReviewLog.find.mockResolvedValue([]); const res1 = await getUserStats(userNoDate); expect(res1.streak.current).toBe(5); const userMissed = { ...mockUser, stats: { ...mockUser.stats, lastStudyDate: '2023-01-08' } }; const res2 = await getUserStats(userMissed); expect(res2.streak.current).toBe(0); }); it('should handle missing user stats fields (null checks)', async () => { const emptyUser = { _id: 'u2', stats: {} }; StudyItem.aggregate.mockResolvedValue([]); StudyItem.find.mockReturnValue({ select: vi.fn().mockResolvedValue([]) }); ReviewLog.find.mockResolvedValue([]); const stats = await getUserStats(emptyUser); expect(stats.streak.current).toBe(0); expect(stats.streak.best).toBe(0); expect(stats.accuracy.total).toBe(0); expect(stats.accuracy.correct).toBe(0); expect(stats.streak.shield.ready).toBe(true); }); });