592 lines
21 KiB
TypeScript
592 lines
21 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|
|
});
|