kaboot/tests/constants.test.ts

539 lines
17 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
calculateBasePoints,
calculatePointsWithBreakdown,
getPlayerRank,
POINTS_PER_QUESTION,
QUESTION_TIME_MS,
} from '../constants';
import { DEFAULT_GAME_CONFIG } from '../types';
import type { Player, GameConfig } from '../types';
describe('calculateBasePoints', () => {
const questionTimeMs = QUESTION_TIME_MS;
const maxPoints = POINTS_PER_QUESTION;
describe('happy path - time-based scoring', () => {
it('returns max points for instant answer (< 0.5s)', () => {
const timeLeftMs = questionTimeMs - 100; // 0.1s response time
const points = calculateBasePoints(timeLeftMs, questionTimeMs);
expect(points).toBe(maxPoints);
});
it('returns max points for exactly 0.5s response', () => {
const timeLeftMs = questionTimeMs - 499;
const points = calculateBasePoints(timeLeftMs, questionTimeMs);
expect(points).toBe(maxPoints);
});
it('returns reduced points for slower responses', () => {
const timeLeftMs = questionTimeMs / 2; // Half time remaining
const points = calculateBasePoints(timeLeftMs, questionTimeMs);
expect(points).toBeLessThan(maxPoints);
expect(points).toBeGreaterThan(maxPoints / 2);
});
it('returns reduced points for last-second answer', () => {
const timeLeftMs = 100;
const points = calculateBasePoints(timeLeftMs, questionTimeMs);
expect(points).toBeGreaterThan(0);
expect(points).toBeLessThanOrEqual(510);
});
it('calculates points correctly at exactly half time', () => {
const timeLeftMs = questionTimeMs / 2;
const points = calculateBasePoints(timeLeftMs, questionTimeMs);
// At half time, response time is 10s out of 20s
// (1 - 10/20 / 2) * 1000 = (1 - 0.25) * 1000 = 750
expect(points).toBe(750);
});
});
describe('edge cases', () => {
it('handles zero time left gracefully', () => {
const points = calculateBasePoints(0, questionTimeMs);
expect(points).toBe(500); // (1 - 20/20/2) * 1000 = 500
});
it('handles full time remaining (answered immediately)', () => {
const points = calculateBasePoints(questionTimeMs, questionTimeMs);
expect(points).toBe(maxPoints);
});
it('handles negative time left (should not happen but be safe)', () => {
const points = calculateBasePoints(-1000, questionTimeMs);
expect(points).toBeLessThan(500);
});
it('respects custom maxPoints parameter', () => {
const customMax = 500;
const points = calculateBasePoints(questionTimeMs, questionTimeMs, customMax);
expect(points).toBe(customMax);
});
it('handles very short question times', () => {
const shortQuestionTime = 5000; // 5 seconds
const points = calculateBasePoints(shortQuestionTime, shortQuestionTime);
expect(points).toBe(maxPoints);
});
});
describe('points calculation formula verification', () => {
it('follows linear decay after 0.5s threshold', () => {
// Points at 5s response time
const points5s = calculateBasePoints(questionTimeMs - 5000, questionTimeMs);
// Points at 10s response time
const points10s = calculateBasePoints(questionTimeMs - 10000, questionTimeMs);
// The decay should be linear
const diff1 = maxPoints - points5s;
const diff2 = points5s - points10s;
expect(Math.abs(diff1 - diff2)).toBeLessThan(10); // Allow small rounding difference
});
});
});
describe('calculatePointsWithBreakdown', () => {
const baseParams = {
isCorrect: true,
timeLeftMs: QUESTION_TIME_MS,
questionTimeMs: QUESTION_TIME_MS,
streak: 0,
playerRank: 1,
isFirstCorrect: false,
config: { ...DEFAULT_GAME_CONFIG },
};
describe('correct answers - happy path', () => {
it('returns base points for correct answer with no bonuses', () => {
const result = calculatePointsWithBreakdown(baseParams);
expect(result.basePoints).toBe(POINTS_PER_QUESTION);
expect(result.streakBonus).toBe(0);
expect(result.comebackBonus).toBe(0);
expect(result.firstCorrectBonus).toBe(0);
expect(result.penalty).toBe(0);
expect(result.total).toBe(POINTS_PER_QUESTION);
});
it('calculates time-based points correctly', () => {
const result = calculatePointsWithBreakdown({
...baseParams,
timeLeftMs: QUESTION_TIME_MS / 2,
});
expect(result.basePoints).toBe(750);
expect(result.total).toBe(750);
});
});
describe('incorrect answers', () => {
it('returns zero points for incorrect answer with no penalty', () => {
const result = calculatePointsWithBreakdown({
...baseParams,
isCorrect: false,
config: { ...DEFAULT_GAME_CONFIG, penaltyForWrongAnswer: false },
});
expect(result.basePoints).toBe(0);
expect(result.penalty).toBe(0);
expect(result.total).toBe(0);
});
it('applies penalty for incorrect answer when enabled', () => {
const result = calculatePointsWithBreakdown({
...baseParams,
isCorrect: false,
config: { ...DEFAULT_GAME_CONFIG, penaltyForWrongAnswer: true, penaltyPercent: 25 },
});
expect(result.basePoints).toBe(0);
expect(result.penalty).toBe(250);
expect(result.total).toBe(-250);
});
it('calculates penalty based on penaltyPercent', () => {
const result = calculatePointsWithBreakdown({
...baseParams,
isCorrect: false,
config: { ...DEFAULT_GAME_CONFIG, penaltyForWrongAnswer: true, penaltyPercent: 50 },
});
expect(result.penalty).toBe(500);
expect(result.total).toBe(-500);
});
});
describe('streak bonus', () => {
it('applies no streak bonus when disabled', () => {
const result = calculatePointsWithBreakdown({
...baseParams,
streak: 5,
config: { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: false },
});
expect(result.streakBonus).toBe(0);
});
it('applies no streak bonus below threshold', () => {
const result = calculatePointsWithBreakdown({
...baseParams,
streak: 2, // Below default threshold of 3
config: { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true, streakThreshold: 3 },
});
expect(result.streakBonus).toBe(0);
});
it('applies streak bonus at exactly threshold', () => {
const result = calculatePointsWithBreakdown({
...baseParams,
streak: 3,
config: { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true, streakThreshold: 3, streakMultiplier: 1.2 },
});
// At threshold, streakCount = 0, so multiplier = 1.2 + (0 * 0.2) = 1.2
// 1000 * 1.2 = 1200, bonus = 200
expect(result.streakBonus).toBe(200);
expect(result.total).toBe(1200);
});
it('increases streak bonus above threshold', () => {
const result = calculatePointsWithBreakdown({
...baseParams,
streak: 5,
config: { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true, streakThreshold: 3, streakMultiplier: 1.2 },
});
// streakCount = 5 - 3 = 2
// multiplier = 1.2 + (2 * 0.2) = 1.6
// 1000 * 1.6 = 1600, bonus = 600
expect(result.streakBonus).toBe(600);
expect(result.total).toBe(1600);
});
it('compounds streak multiplier correctly', () => {
const result = calculatePointsWithBreakdown({
...baseParams,
streak: 10,
config: { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true, streakThreshold: 3, streakMultiplier: 1.1 },
});
// streakCount = 10 - 3 = 7
// multiplier = 1.1 + (7 * 0.1) = 1.8
expect(result.basePoints).toBe(1000);
expect(result.total).toBe(1800);
});
});
describe('comeback bonus', () => {
it('applies no comeback bonus when disabled', () => {
const result = calculatePointsWithBreakdown({
...baseParams,
playerRank: 5,
config: { ...DEFAULT_GAME_CONFIG, comebackBonusEnabled: false },
});
expect(result.comebackBonus).toBe(0);
});
it('applies no comeback bonus for top 3 players', () => {
[1, 2, 3].forEach(rank => {
const result = calculatePointsWithBreakdown({
...baseParams,
playerRank: rank,
config: { ...DEFAULT_GAME_CONFIG, comebackBonusEnabled: true, comebackBonusPoints: 100 },
});
expect(result.comebackBonus).toBe(0);
});
});
it('applies comeback bonus for 4th place and below', () => {
const result = calculatePointsWithBreakdown({
...baseParams,
playerRank: 4,
config: { ...DEFAULT_GAME_CONFIG, comebackBonusEnabled: true, comebackBonusPoints: 100 },
});
expect(result.comebackBonus).toBe(100);
expect(result.total).toBe(1100);
});
it('applies same comeback bonus for any rank below 4', () => {
const result = calculatePointsWithBreakdown({
...baseParams,
playerRank: 10,
config: { ...DEFAULT_GAME_CONFIG, comebackBonusEnabled: true, comebackBonusPoints: 150 },
});
expect(result.comebackBonus).toBe(150);
});
});
describe('first correct bonus', () => {
it('applies no first correct bonus when disabled', () => {
const result = calculatePointsWithBreakdown({
...baseParams,
isFirstCorrect: true,
config: { ...DEFAULT_GAME_CONFIG, firstCorrectBonusEnabled: false },
});
expect(result.firstCorrectBonus).toBe(0);
});
it('applies first correct bonus when enabled and is first', () => {
const result = calculatePointsWithBreakdown({
...baseParams,
isFirstCorrect: true,
config: { ...DEFAULT_GAME_CONFIG, firstCorrectBonusEnabled: true, firstCorrectBonusPoints: 50 },
});
expect(result.firstCorrectBonus).toBe(50);
expect(result.total).toBe(1050);
});
it('applies no first correct bonus when not first', () => {
const result = calculatePointsWithBreakdown({
...baseParams,
isFirstCorrect: false,
config: { ...DEFAULT_GAME_CONFIG, firstCorrectBonusEnabled: true, firstCorrectBonusPoints: 50 },
});
expect(result.firstCorrectBonus).toBe(0);
});
});
describe('combined bonuses', () => {
it('stacks all bonuses correctly', () => {
const config: GameConfig = {
...DEFAULT_GAME_CONFIG,
streakBonusEnabled: true,
streakThreshold: 3,
streakMultiplier: 1.2,
comebackBonusEnabled: true,
comebackBonusPoints: 100,
firstCorrectBonusEnabled: true,
firstCorrectBonusPoints: 50,
};
const result = calculatePointsWithBreakdown({
...baseParams,
streak: 4, // Above threshold
playerRank: 5, // Qualifies for comeback
isFirstCorrect: true,
config,
});
expect(result.basePoints).toBe(1000);
expect(result.streakBonus).toBeGreaterThan(0);
expect(result.comebackBonus).toBe(100);
expect(result.firstCorrectBonus).toBe(50);
expect(result.total).toBe(result.basePoints + result.streakBonus + result.comebackBonus + result.firstCorrectBonus);
});
it('does not apply bonuses to incorrect answers', () => {
const config: GameConfig = {
...DEFAULT_GAME_CONFIG,
streakBonusEnabled: true,
streakThreshold: 3,
comebackBonusEnabled: true,
comebackBonusPoints: 100,
firstCorrectBonusEnabled: true,
firstCorrectBonusPoints: 50,
penaltyForWrongAnswer: true,
penaltyPercent: 25,
};
const result = calculatePointsWithBreakdown({
...baseParams,
isCorrect: false,
streak: 5,
playerRank: 5,
isFirstCorrect: true,
config,
});
expect(result.basePoints).toBe(0);
expect(result.streakBonus).toBe(0);
expect(result.comebackBonus).toBe(0);
expect(result.firstCorrectBonus).toBe(0);
expect(result.penalty).toBe(250);
expect(result.total).toBe(-250);
});
});
describe('edge cases', () => {
it('handles zero streak threshold', () => {
const result = calculatePointsWithBreakdown({
...baseParams,
streak: 1,
config: { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true, streakThreshold: 0, streakMultiplier: 1.5 },
});
// streakCount = 1 - 0 = 1
// multiplier = 1.5 + (1 * 0.5) = 2.0
expect(result.total).toBe(2000);
});
it('handles 100% penalty', () => {
const result = calculatePointsWithBreakdown({
...baseParams,
isCorrect: false,
config: { ...DEFAULT_GAME_CONFIG, penaltyForWrongAnswer: true, penaltyPercent: 100 },
});
expect(result.penalty).toBe(1000);
expect(result.total).toBe(-1000);
});
it('handles 0% penalty (same as disabled)', () => {
const result = calculatePointsWithBreakdown({
...baseParams,
isCorrect: false,
config: { ...DEFAULT_GAME_CONFIG, penaltyForWrongAnswer: true, penaltyPercent: 0 },
});
expect(result.penalty).toBe(0);
expect(result.total === 0 || Object.is(result.total, -0)).toBe(true);
});
});
});
describe('getPlayerRank', () => {
const createMockPlayer = (id: string, score: number): Player => ({
id,
name: `Player ${id}`,
score,
previousScore: 0,
streak: 0,
lastAnswerCorrect: null,
selectedShape: null,
pointsBreakdown: null,
isBot: false,
avatarSeed: 0.5,
color: '#000000',
});
describe('happy path', () => {
it('returns 1 for the player with highest score', () => {
const players = [
createMockPlayer('a', 100),
createMockPlayer('b', 200),
createMockPlayer('c', 150),
];
expect(getPlayerRank('b', players)).toBe(1);
});
it('returns correct rank for each player', () => {
const players = [
createMockPlayer('a', 100),
createMockPlayer('b', 200),
createMockPlayer('c', 150),
];
expect(getPlayerRank('b', players)).toBe(1);
expect(getPlayerRank('c', players)).toBe(2);
expect(getPlayerRank('a', players)).toBe(3);
});
it('handles single player', () => {
const players = [createMockPlayer('a', 100)];
expect(getPlayerRank('a', players)).toBe(1);
});
});
describe('tie handling', () => {
it('gives same rank position to tied players based on array order', () => {
const players = [
createMockPlayer('a', 100),
createMockPlayer('b', 100),
createMockPlayer('c', 100),
];
// All have same score, order depends on sort stability and original order
const rankA = getPlayerRank('a', players);
const rankB = getPlayerRank('b', players);
const rankC = getPlayerRank('c', players);
// All should be valid ranks 1-3
expect([rankA, rankB, rankC].sort()).toEqual([1, 2, 3]);
});
it('ranks correctly with some ties', () => {
const players = [
createMockPlayer('a', 200),
createMockPlayer('b', 100),
createMockPlayer('c', 200),
createMockPlayer('d', 50),
];
// a and c are tied for first
const rankA = getPlayerRank('a', players);
const rankC = getPlayerRank('c', players);
expect(rankA).toBeLessThanOrEqual(2);
expect(rankC).toBeLessThanOrEqual(2);
expect(getPlayerRank('b', players)).toBe(3);
expect(getPlayerRank('d', players)).toBe(4);
});
});
describe('edge cases', () => {
it('returns 0 for non-existent player', () => {
const players = [
createMockPlayer('a', 100),
createMockPlayer('b', 200),
];
// findIndex returns -1 for not found, +1 makes it 0
expect(getPlayerRank('nonexistent', players)).toBe(0);
});
it('handles empty players array', () => {
expect(getPlayerRank('any', [])).toBe(0);
});
it('handles zero scores', () => {
const players = [
createMockPlayer('a', 0),
createMockPlayer('b', 0),
createMockPlayer('c', 100),
];
expect(getPlayerRank('c', players)).toBe(1);
// a and b tied at 0
const rankA = getPlayerRank('a', players);
const rankB = getPlayerRank('b', players);
expect(Math.min(rankA, rankB)).toBe(2);
expect(Math.max(rankA, rankB)).toBe(3);
});
it('handles negative scores (from penalties)', () => {
const players = [
createMockPlayer('a', -100),
createMockPlayer('b', 50),
createMockPlayer('c', -50),
];
expect(getPlayerRank('b', players)).toBe(1);
expect(getPlayerRank('c', players)).toBe(2);
expect(getPlayerRank('a', players)).toBe(3);
});
it('does not mutate original array', () => {
const players = [
createMockPlayer('a', 100),
createMockPlayer('b', 200),
createMockPlayer('c', 150),
];
const originalOrder = players.map(p => p.id);
getPlayerRank('b', players);
expect(players.map(p => p.id)).toEqual(originalOrder);
});
});
});