/** * EditorStore Unit Tests * * Testing approach: Since EditorStore uses Svelte 5 runes ($state) which require * Svelte compilation, we test the logic by: * 1. Extracting and testing pure validation/helper functions * 2. Mocking localStorage for persistence tests * 3. Testing the store's public API through a mock implementation */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { GameSettings, Team, Round, FinalRound, Question, Category } from '$lib/types/kuldvillak'; import { DEFAULT_SETTINGS } from '$lib/types/kuldvillak'; // ============================================ // Test Helpers - Replicate pure functions from store // ============================================ function generateId(): string { return crypto.randomUUID(); } function createQuestion(points: number): Question { return { id: generateId(), question: "", answer: "", points, isDailyDouble: false, isRevealed: false, }; } function createCategory(settings: GameSettings, multiplier: number = 1): Category { return { id: generateId(), name: "", questions: settings.pointValues.map((p) => createQuestion(p * multiplier)), }; } function createRound(name: string, multiplier: number, settings: GameSettings): Round { return { id: generateId(), name, categories: Array.from({ length: settings.categoriesPerRound }, () => createCategory(settings, multiplier) ), pointMultiplier: multiplier, }; } // Validation function extracted for testing function validateGame( teams: Team[], rounds: Round[], settings: GameSettings, finalRound: FinalRound ): string | null { if (teams.length < 2) { return "At least 2 players required"; } for (let i = 0; i < rounds.length; i++) { const hasQuestions = rounds[i].categories.some((cat) => cat.questions.some((q) => q.question.trim()) ); if (!hasQuestions) { return `Round ${i + 1} needs at least one question`; } } if (settings.enableFinalRound && !finalRound.question.trim()) { return "Final round question is required"; } return null; } // Count daily doubles helper function countDailyDoubles(rounds: Round[], roundIndex: number): number { return rounds[roundIndex]?.categories.reduce( (sum, cat) => sum + cat.questions.filter((q) => q.isDailyDouble).length, 0 ) ?? 0; } // ============================================ // Mock localStorage // ============================================ const AUTOSAVE_KEY = "kuldvillak-editor-autosave"; function createMockLocalStorage() { let store: Record = {}; return { getItem: vi.fn((key: string) => store[key] ?? null), setItem: vi.fn((key: string, value: string) => { store[key] = value; }), removeItem: vi.fn((key: string) => { delete store[key]; }), clear: vi.fn(() => { store = {}; }), get _store() { return store; }, }; } // ============================================ // Tests // ============================================ describe('EditorStore Logic', () => { let mockLocalStorage: ReturnType; beforeEach(() => { mockLocalStorage = createMockLocalStorage(); vi.stubGlobal('localStorage', mockLocalStorage); }); afterEach(() => { vi.unstubAllGlobals(); }); // ============================================ // Helper Function Tests // ============================================ describe('createQuestion', () => { it('should create a question with correct structure', () => { const q = createQuestion(100); expect(q.points).toBe(100); expect(q.question).toBe(""); expect(q.answer).toBe(""); expect(q.isDailyDouble).toBe(false); expect(q.isRevealed).toBe(false); expect(q.id).toBeDefined(); }); it('should generate unique IDs', () => { const q1 = createQuestion(100); const q2 = createQuestion(100); expect(q1.id).not.toBe(q2.id); }); }); describe('createCategory', () => { it('should create category with correct number of questions', () => { const cat = createCategory(DEFAULT_SETTINGS, 1); expect(cat.questions.length).toBe(DEFAULT_SETTINGS.pointValues.length); }); it('should apply multiplier to point values', () => { const cat = createCategory(DEFAULT_SETTINGS, 2); const expectedPoints = DEFAULT_SETTINGS.pointValues.map(p => p * 2); cat.questions.forEach((q, i) => { expect(q.points).toBe(expectedPoints[i]); }); }); }); describe('createRound', () => { it('should create round with correct structure', () => { const round = createRound("Test Round", 1, DEFAULT_SETTINGS); expect(round.name).toBe("Test Round"); expect(round.pointMultiplier).toBe(1); expect(round.categories.length).toBe(DEFAULT_SETTINGS.categoriesPerRound); }); it('should apply multiplier to all questions', () => { const round = createRound("Double", 2, DEFAULT_SETTINGS); round.categories.forEach(cat => { cat.questions.forEach((q, i) => { expect(q.points).toBe(DEFAULT_SETTINGS.pointValues[i] * 2); }); }); }); }); // ============================================ // Validation Tests // ============================================ describe('validateGame', () => { let validTeams: Team[]; let validRounds: Round[]; let validSettings: GameSettings; let validFinalRound: FinalRound; beforeEach(() => { validTeams = [ { id: '1', name: 'Team 1', score: 0 }, { id: '2', name: 'Team 2', score: 0 }, ]; validRounds = [createRound("Round 1", 1, DEFAULT_SETTINGS)]; // Fill at least one question validRounds[0].categories[0].questions[0].question = "Test question?"; validSettings = { ...DEFAULT_SETTINGS, enableFinalRound: false }; validFinalRound = { category: "", question: "", answer: "" }; }); it('should pass with valid minimal configuration', () => { const result = validateGame(validTeams, validRounds, validSettings, validFinalRound); expect(result).toBeNull(); }); it('should fail with fewer than 2 teams', () => { const result = validateGame([validTeams[0]], validRounds, validSettings, validFinalRound); expect(result).toBe("At least 2 players required"); }); it('should fail with 0 teams', () => { const result = validateGame([], validRounds, validSettings, validFinalRound); expect(result).toBe("At least 2 players required"); }); it('should fail when round has no questions', () => { const emptyRounds = [createRound("Empty", 1, DEFAULT_SETTINGS)]; const result = validateGame(validTeams, emptyRounds, validSettings, validFinalRound); expect(result).toBe("Round 1 needs at least one question"); }); it('should fail when second round has no questions', () => { const twoRounds = [ validRounds[0], createRound("Round 2", 2, DEFAULT_SETTINGS), // No questions filled ]; const result = validateGame(validTeams, twoRounds, validSettings, validFinalRound); expect(result).toBe("Round 2 needs at least one question"); }); it('should fail when final round enabled but no question', () => { const settingsWithFinal = { ...validSettings, enableFinalRound: true }; const result = validateGame(validTeams, validRounds, settingsWithFinal, validFinalRound); expect(result).toBe("Final round question is required"); }); it('should pass when final round enabled and has question', () => { const settingsWithFinal = { ...validSettings, enableFinalRound: true }; const finalWithQuestion = { category: "History", question: "What year?", answer: "1990" }; const result = validateGame(validTeams, validRounds, settingsWithFinal, finalWithQuestion); expect(result).toBeNull(); }); it('should pass with whitespace-only question as empty', () => { validRounds[0].categories[0].questions[0].question = " "; const result = validateGame(validTeams, validRounds, validSettings, validFinalRound); expect(result).toBe("Round 1 needs at least one question"); }); }); // ============================================ // Team Management Tests // ============================================ describe('Team Management Logic', () => { it('canAddTeam should be true when under 6 teams', () => { const teams: Team[] = [ { id: '1', name: 'Team 1', score: 0 }, { id: '2', name: 'Team 2', score: 0 }, ]; expect(teams.length < 6).toBe(true); }); it('canAddTeam should be false when at 6 teams', () => { const teams: Team[] = Array.from({ length: 6 }, (_, i) => ({ id: String(i), name: `Team ${i + 1}`, score: 0, })); expect(teams.length < 6).toBe(false); }); it('canRemoveTeam should be true when over 2 teams', () => { const teams: Team[] = [ { id: '1', name: 'Team 1', score: 0 }, { id: '2', name: 'Team 2', score: 0 }, { id: '3', name: 'Team 3', score: 0 }, ]; expect(teams.length > 2).toBe(true); }); it('canRemoveTeam should be false when at 2 teams', () => { const teams: Team[] = [ { id: '1', name: 'Team 1', score: 0 }, { id: '2', name: 'Team 2', score: 0 }, ]; expect(teams.length > 2).toBe(false); }); it('addTeam should generate unique ID', () => { const teams: Team[] = []; const newTeam = { id: generateId(), name: 'New Team', score: 0 }; teams.push(newTeam); expect(newTeam.id).toBeDefined(); expect(typeof newTeam.id).toBe('string'); expect(newTeam.id.length).toBeGreaterThan(0); }); it('removeTeam should filter by ID', () => { const teams: Team[] = [ { id: '1', name: 'Team 1', score: 0 }, { id: '2', name: 'Team 2', score: 0 }, { id: '3', name: 'Team 3', score: 0 }, ]; const filtered = teams.filter(t => t.id !== '2'); expect(filtered.length).toBe(2); expect(filtered.find(t => t.id === '2')).toBeUndefined(); }); }); // ============================================ // Daily Double Tests // ============================================ describe('Daily Double Logic', () => { it('countDailyDoubles should return 0 for round with no DDs', () => { const rounds = [createRound("Test", 1, DEFAULT_SETTINGS)]; expect(countDailyDoubles(rounds, 0)).toBe(0); }); it('countDailyDoubles should count correctly', () => { const rounds = [createRound("Test", 1, DEFAULT_SETTINGS)]; rounds[0].categories[0].questions[0].isDailyDouble = true; rounds[0].categories[1].questions[2].isDailyDouble = true; expect(countDailyDoubles(rounds, 0)).toBe(2); }); it('should respect maxDD limit logic', () => { const maxDD = 2; const currentDD = 2; const canAddMore = currentDD < maxDD; expect(canAddMore).toBe(false); }); it('should allow toggle when under limit', () => { const maxDD = 2; const currentDD = 1; const canAddMore = currentDD < maxDD; expect(canAddMore).toBe(true); }); }); // ============================================ // Persistence Tests // ============================================ describe('Persistence Logic', () => { it('save should serialize data to localStorage', () => { const data = { name: "Test Game", settings: DEFAULT_SETTINGS, teams: [{ id: '1', name: 'Team 1', score: 0 }], rounds: [], finalRound: { category: "", question: "", answer: "" }, }; localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(data)); expect(mockLocalStorage.setItem).toHaveBeenCalledWith( AUTOSAVE_KEY, expect.any(String) ); }); it('load should parse valid localStorage data', () => { const savedData = { name: "Saved Game", settings: { ...DEFAULT_SETTINGS, defaultTimerSeconds: 10 }, teams: [ { id: '1', name: 'Player 1', score: 100 }, { id: '2', name: 'Player 2', score: 200 }, ], rounds: [], finalRound: { category: "History", question: "Q?", answer: "A" }, }; mockLocalStorage._store[AUTOSAVE_KEY] = JSON.stringify(savedData); const loaded = JSON.parse(localStorage.getItem(AUTOSAVE_KEY)!); expect(loaded.name).toBe("Saved Game"); expect(loaded.settings.defaultTimerSeconds).toBe(10); expect(loaded.teams.length).toBe(2); }); it('load should handle invalid JSON gracefully', () => { mockLocalStorage._store[AUTOSAVE_KEY] = "not valid json {{{"; let error: Error | null = null; try { JSON.parse(localStorage.getItem(AUTOSAVE_KEY)!); } catch (e) { error = e as Error; } expect(error).not.toBeNull(); }); it('load should handle missing data gracefully', () => { const result = localStorage.getItem(AUTOSAVE_KEY); expect(result).toBeNull(); }); it('clearSaved should remove from localStorage', () => { mockLocalStorage._store[AUTOSAVE_KEY] = "some data"; localStorage.removeItem(AUTOSAVE_KEY); expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(AUTOSAVE_KEY); }); it('should handle legacy teamColors property', () => { const legacyData = { name: "Legacy Game", settings: { ...DEFAULT_SETTINGS, teamColors: ['red', 'blue'], // Legacy property }, teams: [], rounds: [], finalRound: { category: "", question: "", answer: "" }, }; mockLocalStorage._store[AUTOSAVE_KEY] = JSON.stringify(legacyData); const loaded = JSON.parse(localStorage.getItem(AUTOSAVE_KEY)!); // Should be able to destructure and remove teamColors const { teamColors, ...cleanSettings } = loaded.settings; expect(teamColors).toEqual(['red', 'blue']); expect(cleanSettings.teamColors).toBeUndefined(); }); }); // ============================================ // Export/Import Tests // ============================================ describe('Export/Import Logic', () => { it('exportGame should create valid JSON structure', () => { const gameData = { name: "Export Test", settings: DEFAULT_SETTINGS, teams: [ { id: '1', name: 'Team 1', score: 0 }, { id: '2', name: 'Team 2', score: 0 }, ], rounds: [createRound("Round 1", 1, DEFAULT_SETTINGS)], finalRound: { category: "Cat", question: "Q?", answer: "A" }, }; const json = JSON.stringify(gameData, null, 2); const parsed = JSON.parse(json); expect(parsed.name).toBe("Export Test"); expect(parsed.teams.length).toBe(2); expect(parsed.rounds.length).toBe(1); }); it('exportGame should create Blob with correct type', () => { const json = JSON.stringify({ test: true }); const blob = new Blob([json], { type: "application/json" }); expect(blob.type).toBe("application/json"); expect(blob.size).toBeGreaterThan(0); }); it('importGame should validate required fields', () => { const validData = { settings: DEFAULT_SETTINGS, teams: [], rounds: [], }; const invalidData1 = { teams: [], rounds: [] }; // Missing settings const invalidData2 = { settings: DEFAULT_SETTINGS, rounds: [] }; // Missing teams const invalidData3 = { settings: DEFAULT_SETTINGS, teams: [] }; // Missing rounds const hasRequired = (data: Record) => !!(data.settings && data.teams && data.rounds); expect(hasRequired(validData)).toBe(true); expect(hasRequired(invalidData1)).toBe(false); expect(hasRequired(invalidData2)).toBe(false); expect(hasRequired(invalidData3)).toBe(false); }); it('should generate sanitized filename', () => { const gameName = "My Test Game 2024"; const filename = `${gameName.replace(/\s+/g, "_") || "game"}.json`; expect(filename).toBe("My_Test_Game_2024.json"); }); it('should use default filename when name is empty', () => { const gameName = ""; const filename = `${gameName.replace(/\s+/g, "_") || "game"}.json`; expect(filename).toBe("game.json"); }); }); // ============================================ // Round Management Tests // ============================================ describe('Round Management Logic', () => { it('setRoundCount should reduce to 1 round', () => { let rounds = [ createRound("Round 1", 1, DEFAULT_SETTINGS), createRound("Round 2", 2, DEFAULT_SETTINGS), ]; if (rounds.length > 1) { rounds = [rounds[0]]; } expect(rounds.length).toBe(1); }); it('setRoundCount should add second round', () => { let rounds = [createRound("Round 1", 1, DEFAULT_SETTINGS)]; if (rounds.length === 1) { rounds = [...rounds, createRound("Round 2", 2, DEFAULT_SETTINGS)]; } expect(rounds.length).toBe(2); expect(rounds[1].pointMultiplier).toBe(2); }); it('getPointsForRound should apply multiplier', () => { const base = [10, 20, 30, 40, 50]; const round1Points = base.map(v => v * 1); const round2Points = base.map(v => v * 2); expect(round1Points).toEqual([10, 20, 30, 40, 50]); expect(round2Points).toEqual([20, 40, 60, 80, 100]); }); it('updatePreset should reset to default values', () => { let settings = { ...DEFAULT_SETTINGS, pointValues: [5, 10, 15, 20, 25] }; if (settings.pointValuePreset === "round1") { settings.pointValues = [10, 20, 30, 40, 50]; } // Simulate preset change settings.pointValuePreset = "round1"; settings.pointValues = [10, 20, 30, 40, 50]; expect(settings.pointValues).toEqual([10, 20, 30, 40, 50]); }); }); // ============================================ // Derived State Tests // ============================================ describe('Derived State Logic', () => { it('totalQuestions should count all questions', () => { const rounds = [ createRound("R1", 1, DEFAULT_SETTINGS), createRound("R2", 2, DEFAULT_SETTINGS), ]; const total = rounds.reduce( (total, round) => total + round.categories.reduce( (catTotal, cat) => catTotal + cat.questions.length, 0 ), 0 ); const expectedPerRound = DEFAULT_SETTINGS.categoriesPerRound * DEFAULT_SETTINGS.questionsPerCategory; expect(total).toBe(expectedPerRound * 2); }); it('filledQuestions should count only non-empty', () => { const rounds = [createRound("R1", 1, DEFAULT_SETTINGS)]; rounds[0].categories[0].questions[0].question = "Filled"; rounds[0].categories[0].questions[1].question = "Also filled"; rounds[0].categories[1].questions[0].question = "Third"; const filled = rounds.reduce( (total, round) => total + round.categories.reduce( (catTotal, cat) => catTotal + cat.questions.filter((q) => q.question.trim()).length, 0 ), 0 ); expect(filled).toBe(3); }); }); });