Started working on adding mobile buzzers, a lot of rewriting

This commit is contained in:
AlacrisDevs
2025-12-12 01:47:51 +02:00
parent a3fa056c1f
commit d4a25746b2
47 changed files with 4712 additions and 1656 deletions

View File

@@ -0,0 +1,591 @@
/**
* 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<string, string> = {};
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<typeof createMockLocalStorage>;
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<string, unknown>) =>
!!(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);
});
});
});