/** * Score Calculator Unit Tests * * Tests for: * - Points calculation by difficulty * - Streak bonuses * - Hint penalties * - First attempt bonus * - Speed bonus */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ExerciseDifficulty } from '@prisma/client'; // Mock dependencies FIRST before importing vi.mock('../../src/shared/database/prisma.client', () => ({ prisma: { exercise: { findUnique: vi.fn(), }, exerciseAttempt: { findMany: vi.fn(), aggregate: vi.fn(), }, }, })); vi.mock('../../src/shared/utils/logger', () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), }, })); // Mock StreakCalculator to avoid prisma calls vi.mock('../../src/modules/ranking/calculators/streak.calculator', () => ({ StreakCalculator: { calculateStreak: vi.fn().mockResolvedValue({ currentStreak: 0, longestStreak: 0, lastActivityDate: null, isStreakActive: false, daysUntilStreakBreaks: 0, }), getLongestStreak: vi.fn().mockResolvedValue(0), getUserStreakInfo: vi.fn().mockResolvedValue({ currentStreak: 0, hasStreakBonus: false, }), hasActivityOnDate: vi.fn().mockResolvedValue(false), }, })); // Import mocked modules and service import { prisma } from '../../src/shared/database/prisma.client'; import { ScoreCalculator } from '../../src/modules/ranking/calculators/score.calculator'; import { StreakCalculator } from '../../src/modules/ranking/calculators/streak.calculator'; describe('ScoreCalculator', () => { beforeEach(() => { vi.clearAllMocks(); process.env.JWT_SECRET = 'test-jwt-secret'; }); afterEach(() => { vi.restoreAllMocks(); }); // ============================================ // BASE POINTS TESTS // ============================================ describe('calculate - base points', () => { it('should return 10 base points for BASIC difficulty', async () => { (prisma.exercise.findUnique as any).mockResolvedValueOnce({ difficulty: ExerciseDifficulty.BASIC, }); (prisma.exerciseAttempt.findMany as any).mockResolvedValueOnce([]); const result = await ScoreCalculator.calculate({ exerciseId: 'exercise-123', userId: 'user-123', isCorrect: true, timeSpentSeconds: 120, hintsUsed: 0, attemptNumber: 1, }); expect(result.basePoints).toBe(10); expect(result.breakdown).toContain('Base: 10 puntos (BASIC)'); }); it('should return 20 base points for INTERMEDIATE difficulty', async () => { (prisma.exercise.findUnique as any).mockResolvedValueOnce({ difficulty: ExerciseDifficulty.INTERMEDIATE, }); (prisma.exerciseAttempt.findMany as any).mockResolvedValueOnce([]); const result = await ScoreCalculator.calculate({ exerciseId: 'exercise-123', userId: 'user-123', isCorrect: true, timeSpentSeconds: 120, hintsUsed: 0, attemptNumber: 1, }); expect(result.basePoints).toBe(20); }); it('should return 30 base points for ADVANCED difficulty', async () => { (prisma.exercise.findUnique as any).mockResolvedValueOnce({ difficulty: ExerciseDifficulty.ADVANCED, }); (prisma.exerciseAttempt.findMany as any).mockResolvedValueOnce([]); const result = await ScoreCalculator.calculate({ exerciseId: 'exercise-123', userId: 'user-123', isCorrect: true, timeSpentSeconds: 120, hintsUsed: 0, attemptNumber: 1, }); expect(result.basePoints).toBe(30); }); it('should return 40 base points for EXPERT difficulty', async () => { (prisma.exercise.findUnique as any).mockResolvedValueOnce({ difficulty: ExerciseDifficulty.EXPERT, }); (prisma.exerciseAttempt.findMany as any).mockResolvedValueOnce([]); const result = await ScoreCalculator.calculate({ exerciseId: 'exercise-123', userId: 'user-123', isCorrect: true, timeSpentSeconds: 120, hintsUsed: 0, attemptNumber: 1, }); expect(result.basePoints).toBe(40); }); it('should throw error when exercise not found', async () => { (prisma.exercise.findUnique as any).mockResolvedValueOnce(null); await expect( ScoreCalculator.calculate({ exerciseId: 'nonexistent', userId: 'user-123', isCorrect: true, timeSpentSeconds: 30, hintsUsed: 0, attemptNumber: 1, }) ).rejects.toThrow('Exercise nonexistent not found'); }); }); // ============================================ // INCORRECT ANSWER TESTS // ============================================ describe('calculate - incorrect answers', () => { it('should return 0 points for incorrect answer', async () => { (prisma.exercise.findUnique as any).mockResolvedValueOnce({ difficulty: ExerciseDifficulty.ADVANCED, }); (prisma.exerciseAttempt.findMany as any).mockResolvedValueOnce([]); const result = await ScoreCalculator.calculate({ exerciseId: 'exercise-123', userId: 'user-123', isCorrect: false, timeSpentSeconds: 60, hintsUsed: 2, attemptNumber: 1, }); expect(result.finalPoints).toBe(0); expect(result.breakdown).toContain('Incorrecto: 0 puntos'); }); }); // ============================================ // FIRST ATTEMPT BONUS TESTS // ============================================ describe('calculate - first attempt bonus', () => { it('should add 20% bonus for first attempt (correct)', async () => { (prisma.exercise.findUnique as any).mockResolvedValueOnce({ difficulty: ExerciseDifficulty.BASIC, }); (prisma.exerciseAttempt.findMany as any).mockResolvedValueOnce([]); const result = await ScoreCalculator.calculate({ exerciseId: 'exercise-123', userId: 'user-123', isCorrect: true, timeSpentSeconds: 120, hintsUsed: 0, attemptNumber: 1, }); expect(result.firstAttemptMultiplier).toBe(1.2); // Base 10 + 2 (20%) = 12 expect(result.finalPoints).toBe(12); expect(result.breakdown).toContain('Primer intento: +2 puntos (+20%)'); }); it('should NOT add bonus for subsequent attempts', async () => { (prisma.exercise.findUnique as any).mockResolvedValueOnce({ difficulty: ExerciseDifficulty.BASIC, }); (prisma.exerciseAttempt.findMany as any).mockResolvedValueOnce([]); const result = await ScoreCalculator.calculate({ exerciseId: 'exercise-123', userId: 'user-123', isCorrect: true, timeSpentSeconds: 120, hintsUsed: 0, attemptNumber: 3, // Third attempt }); expect(result.firstAttemptMultiplier).toBe(1.0); expect(result.finalPoints).toBe(10); // Only base points }); }); // ============================================ // SPEED BONUS TESTS // ============================================ describe('calculate - speed bonus', () => { it('should add 10% bonus for completing in under 60 seconds', async () => { (prisma.exercise.findUnique as any).mockResolvedValueOnce({ difficulty: ExerciseDifficulty.BASIC, }); (prisma.exerciseAttempt.findMany as any).mockResolvedValueOnce([]); const result = await ScoreCalculator.calculate({ exerciseId: 'exercise-123', userId: 'user-123', isCorrect: true, timeSpentSeconds: 45, // Under 60s threshold hintsUsed: 0, attemptNumber: 1, }); expect(result.speedMultiplier).toBe(1.1); // Base 10 + 2 (first attempt) + 1 (speed) = 13 expect(result.finalPoints).toBe(13); expect(result.breakdown).toContain('Velocidad: +1 puntos (+10%, <60s)'); }); it('should NOT add speed bonus for over 60 seconds', async () => { (prisma.exercise.findUnique as any).mockResolvedValueOnce({ difficulty: ExerciseDifficulty.BASIC, }); (prisma.exerciseAttempt.findMany as any).mockResolvedValueOnce([]); const result = await ScoreCalculator.calculate({ exerciseId: 'exercise-123', userId: 'user-123', isCorrect: true, timeSpentSeconds: 90, // Over 60s hintsUsed: 0, attemptNumber: 1, }); expect(result.speedMultiplier).toBe(1.0); }); }); // ============================================ // STREAK BONUS TESTS // ============================================ describe('calculate - streak bonus', () => { it('should add 50% bonus for 3+ day streak', async () => { (prisma.exercise.findUnique as any).mockResolvedValueOnce({ difficulty: ExerciseDifficulty.BASIC, }); // Mock streak info with 3-day streak (StreakCalculator.getUserStreakInfo as any).mockResolvedValueOnce({ currentStreak: 3, hasStreakBonus: true, }); const result = await ScoreCalculator.calculate({ exerciseId: 'exercise-123', userId: 'user-123', isCorrect: true, timeSpentSeconds: 120, hintsUsed: 0, attemptNumber: 1, }); expect(result.streakMultiplier).toBe(1.5); // Base 10 + 2 (first) + 5 (streak) = 17 expect(result.finalPoints).toBe(17); expect(result.breakdown.some(b => b.includes('Racha') && b.includes('+50%'))).toBe(true); }); it('should NOT add streak bonus for less than 3 days', async () => { (prisma.exercise.findUnique as any).mockResolvedValueOnce({ difficulty: ExerciseDifficulty.BASIC, }); // Mock streak info with 1-day streak (no bonus) (StreakCalculator.getUserStreakInfo as any).mockResolvedValueOnce({ currentStreak: 1, hasStreakBonus: false, }); const result = await ScoreCalculator.calculate({ exerciseId: 'exercise-123', userId: 'user-123', isCorrect: true, timeSpentSeconds: 120, hintsUsed: 0, attemptNumber: 1, }); expect(result.streakMultiplier).toBe(1.0); }); }); // ============================================ // HINT PENALTY TESTS // ============================================ describe('calculate - hint penalty', () => { it('should deduct 2 points per hint used', async () => { (prisma.exercise.findUnique as any).mockResolvedValueOnce({ difficulty: ExerciseDifficulty.BASIC, }); (prisma.exerciseAttempt.findMany as any).mockResolvedValueOnce([]); const result = await ScoreCalculator.calculate({ exerciseId: 'exercise-123', userId: 'user-123', isCorrect: true, timeSpentSeconds: 120, hintsUsed: 3, // 3 hints = -6 points attemptNumber: 1, }); expect(result.hintPenalty).toBe(6); // Base 10 + 2 (first) - 6 (hints) = 6 expect(result.finalPoints).toBe(6); expect(result.breakdown).toContain('Pistas: -6 puntos (-2 cada una)'); }); it('should not return negative points', async () => { (prisma.exercise.findUnique as any).mockResolvedValueOnce({ difficulty: ExerciseDifficulty.BASIC, }); (prisma.exerciseAttempt.findMany as any).mockResolvedValueOnce([]); const result = await ScoreCalculator.calculate({ exerciseId: 'exercise-123', userId: 'user-123', isCorrect: true, timeSpentSeconds: 120, hintsUsed: 10, // 10 hints = -20 points (more than base 10) attemptNumber: 3, // Not first attempt (no bonus) }); expect(result.finalPoints).toBe(0); // Minimum 0 }); }); // ============================================ // COMBINED BONUSES TESTS // ============================================ describe('calculate - combined bonuses', () => { it('should calculate all bonuses combined correctly', async () => { (prisma.exercise.findUnique as any).mockResolvedValueOnce({ difficulty: ExerciseDifficulty.INTERMEDIATE, // Base 20 }); // Mock 3-day streak (StreakCalculator.getUserStreakInfo as any).mockResolvedValueOnce({ currentStreak: 3, hasStreakBonus: true, }); const result = await ScoreCalculator.calculate({ exerciseId: 'exercise-123', userId: 'user-123', isCorrect: true, timeSpentSeconds: 45, // Speed bonus hintsUsed: 2, // -4 points attemptNumber: 1, // First attempt bonus }); // Base: 20 // First attempt: +4 (20% of 20) // Speed: +2 (10% of 20) // Streak: +10 (50% of 20) // Hint penalty: -4 // Total: 20 + 4 + 2 + 10 - 4 = 32 expect(result.basePoints).toBe(20); expect(result.firstAttemptMultiplier).toBe(1.2); expect(result.speedMultiplier).toBe(1.1); expect(result.streakMultiplier).toBe(1.5); expect(result.hintPenalty).toBe(4); expect(result.finalPoints).toBe(32); }); }); // ============================================ // GET USER STREAK TESTS // ============================================ describe('getUserStreak', () => { it('should return streak 0 when no attempts', async () => { (StreakCalculator.getUserStreakInfo as any).mockResolvedValueOnce({ currentStreak: 0, hasStreakBonus: false, }); const result = await ScoreCalculator.getUserStreak('user-123'); expect(result.currentStreak).toBe(0); expect(result.hasStreakBonus).toBe(false); }); it('should break streak if last attempt is older than yesterday', async () => { (StreakCalculator.getUserStreakInfo as any).mockResolvedValueOnce({ currentStreak: 0, hasStreakBonus: false, }); const result = await ScoreCalculator.getUserStreak('user-123'); expect(result.currentStreak).toBe(0); expect(result.hasStreakBonus).toBe(false); }); }); // ============================================ // QUICK SCORE TESTS // ============================================ describe('getQuickScore', () => { it('should return quick score without full calculation', () => { const result = ScoreCalculator.getQuickScore( ExerciseDifficulty.ADVANCED, true, 2 // 2 hints ); // Base 30 - 4 (2 hints * 2) = 26 expect(result).toBe(26); }); it('should return 0 for incorrect answer', () => { const result = ScoreCalculator.getQuickScore( ExerciseDifficulty.ADVANCED, false, 0 ); expect(result).toBe(0); }); it('should not return negative score', () => { const result = ScoreCalculator.getQuickScore( ExerciseDifficulty.BASIC, true, 10 // More hints than base points ); expect(result).toBe(0); }); }); // ============================================ // MAX POSSIBLE POINTS TESTS // ============================================ describe('getMaxPossiblePoints', () => { it('should calculate maximum possible points with streak', () => { const result = ScoreCalculator.getMaxPossiblePoints( ExerciseDifficulty.INTERMEDIATE, true // Has streak ); // Base 20 // First attempt (20%): 24 // Speed (10%): 27 // Streak (50%): 41 expect(result).toBeGreaterThan(30); }); it('should calculate maximum points without streak', () => { const result = ScoreCalculator.getMaxPossiblePoints( ExerciseDifficulty.BASIC, false ); // Base 10, with first attempt and speed only expect(result).toBeGreaterThan(10); }); }); // ============================================ // CALCULATE USER TOTAL POINTS TESTS // ============================================ describe('calculateUserTotalPoints', () => { it('should aggregate all correct attempt points', async () => { (prisma.exerciseAttempt.aggregate as any).mockResolvedValueOnce({ _sum: { pointsEarned: 150 }, }); const result = await ScoreCalculator.calculateUserTotalPoints('user-123'); expect(result).toBe(150); }); it('should return 0 when no points earned', async () => { (prisma.exerciseAttempt.aggregate as any).mockResolvedValueOnce({ _sum: { pointsEarned: null }, }); const result = await ScoreCalculator.calculateUserTotalPoints('user-123'); expect(result).toBe(0); }); }); // ============================================ // CALCULATE USER MODULE POINTS TESTS // ============================================ describe('calculateUserModulePoints', () => { it('should aggregate points for specific module', async () => { (prisma.exerciseAttempt.aggregate as any).mockResolvedValueOnce({ _sum: { pointsEarned: 50 }, }); const result = await ScoreCalculator.calculateUserModulePoints( 'user-123', 'module-123' ); expect(result).toBe(50); expect(prisma.exerciseAttempt.aggregate).toHaveBeenCalledWith({ where: { userId: 'user-123', status: 'CORRECT', exercise: { moduleId: 'module-123' }, }, _sum: { pointsEarned: true }, }); }); }); });