1384 lines
44 KiB
TypeScript
1384 lines
44 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
describe('URL Routing and Navigation', () => {
|
|
describe('URL Path Generation', () => {
|
|
const getTargetPath = (
|
|
gameState: string,
|
|
gamePin: string | null,
|
|
role: 'HOST' | 'CLIENT',
|
|
currentPath: string,
|
|
sourceQuizId: string | null = null
|
|
): string | null => {
|
|
if (currentPath === '/callback' || currentPath.startsWith('/shared/')) {
|
|
return null;
|
|
}
|
|
|
|
switch (gameState) {
|
|
case 'LANDING':
|
|
if (gamePin && currentPath.startsWith('/play/')) {
|
|
return `/play/${gamePin}`;
|
|
}
|
|
return '/';
|
|
case 'CREATING':
|
|
case 'GENERATING':
|
|
return '/create';
|
|
case 'EDITING':
|
|
return sourceQuizId ? `/edit/${sourceQuizId}` : '/edit/draft';
|
|
case 'LOBBY':
|
|
case 'COUNTDOWN':
|
|
case 'QUESTION':
|
|
case 'REVEAL':
|
|
case 'SCOREBOARD':
|
|
case 'PODIUM':
|
|
case 'HOST_RECONNECTED':
|
|
if (gamePin) {
|
|
return role === 'HOST' ? `/host/${gamePin}` : `/play/${gamePin}`;
|
|
}
|
|
return '/';
|
|
case 'DISCONNECTED':
|
|
case 'WAITING_TO_REJOIN':
|
|
if (gamePin) {
|
|
return `/play/${gamePin}`;
|
|
}
|
|
return '/';
|
|
default:
|
|
return '/';
|
|
}
|
|
};
|
|
|
|
describe('Shared quiz routes', () => {
|
|
it('should return null for /shared/:token routes to skip navigation', () => {
|
|
expect(getTargetPath('LANDING', null, 'HOST', '/shared/abc123')).toBeNull();
|
|
});
|
|
|
|
it('should return null for /shared/:token with complex token', () => {
|
|
expect(getTargetPath('LANDING', null, 'HOST', '/shared/6yC50NixzVuA-E_Z0JhEUA')).toBeNull();
|
|
});
|
|
|
|
it('should return null for /shared/:token regardless of gameState', () => {
|
|
expect(getTargetPath('LOBBY', '123456', 'HOST', '/shared/test-token')).toBeNull();
|
|
expect(getTargetPath('EDITING', null, 'HOST', '/shared/test-token')).toBeNull();
|
|
expect(getTargetPath('PODIUM', '123456', 'HOST', '/shared/test-token')).toBeNull();
|
|
});
|
|
|
|
it('should return null for /callback route', () => {
|
|
expect(getTargetPath('LANDING', null, 'HOST', '/callback')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('LANDING state', () => {
|
|
it('should return "/" for LANDING state without gamePin', () => {
|
|
expect(getTargetPath('LANDING', null, 'HOST', '/')).toBe('/');
|
|
});
|
|
|
|
it('should preserve /play/:pin URL when LANDING with gamePin (join flow)', () => {
|
|
expect(getTargetPath('LANDING', '123456', 'CLIENT', '/play/123456')).toBe('/play/123456');
|
|
});
|
|
|
|
it('should return "/" when LANDING with gamePin but not on /play/ path', () => {
|
|
expect(getTargetPath('LANDING', '123456', 'HOST', '/')).toBe('/');
|
|
});
|
|
});
|
|
|
|
describe('CREATING/GENERATING states', () => {
|
|
it('should return "/create" for CREATING state', () => {
|
|
expect(getTargetPath('CREATING', null, 'HOST', '/')).toBe('/create');
|
|
});
|
|
|
|
it('should return "/create" for GENERATING state', () => {
|
|
expect(getTargetPath('GENERATING', null, 'HOST', '/')).toBe('/create');
|
|
});
|
|
});
|
|
|
|
describe('EDITING state', () => {
|
|
it('should return "/edit/draft" for EDITING state without sourceQuizId', () => {
|
|
expect(getTargetPath('EDITING', null, 'HOST', '/', null)).toBe('/edit/draft');
|
|
});
|
|
|
|
it('should return "/edit/:quizId" for EDITING state with sourceQuizId', () => {
|
|
expect(getTargetPath('EDITING', null, 'HOST', '/', 'quiz-123')).toBe('/edit/quiz-123');
|
|
});
|
|
|
|
it('should handle UUID-style sourceQuizId', () => {
|
|
expect(getTargetPath('EDITING', null, 'HOST', '/', 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'))
|
|
.toBe('/edit/a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
|
});
|
|
});
|
|
|
|
describe('In-game HOST states', () => {
|
|
const hostStates = ['LOBBY', 'COUNTDOWN', 'QUESTION', 'REVEAL', 'SCOREBOARD', 'PODIUM', 'HOST_RECONNECTED'];
|
|
|
|
hostStates.forEach(state => {
|
|
it(`should return "/host/:pin" for ${state} state as HOST`, () => {
|
|
expect(getTargetPath(state, '123456', 'HOST', '/')).toBe('/host/123456');
|
|
});
|
|
});
|
|
|
|
it('should return "/" for HOST states without gamePin', () => {
|
|
expect(getTargetPath('LOBBY', null, 'HOST', '/')).toBe('/');
|
|
});
|
|
});
|
|
|
|
describe('In-game CLIENT states', () => {
|
|
const clientStates = ['LOBBY', 'COUNTDOWN', 'QUESTION', 'REVEAL', 'SCOREBOARD', 'PODIUM'];
|
|
|
|
clientStates.forEach(state => {
|
|
it(`should return "/play/:pin" for ${state} state as CLIENT`, () => {
|
|
expect(getTargetPath(state, '123456', 'CLIENT', '/')).toBe('/play/123456');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Disconnected states', () => {
|
|
it('should return "/play/:pin" for DISCONNECTED state with gamePin', () => {
|
|
expect(getTargetPath('DISCONNECTED', '123456', 'CLIENT', '/')).toBe('/play/123456');
|
|
});
|
|
|
|
it('should return "/play/:pin" for WAITING_TO_REJOIN state with gamePin', () => {
|
|
expect(getTargetPath('WAITING_TO_REJOIN', '123456', 'CLIENT', '/')).toBe('/play/123456');
|
|
});
|
|
|
|
it('should return "/" for DISCONNECTED state without gamePin', () => {
|
|
expect(getTargetPath('DISCONNECTED', null, 'CLIENT', '/')).toBe('/');
|
|
});
|
|
});
|
|
|
|
describe('Unknown states', () => {
|
|
it('should return "/" for unknown game state', () => {
|
|
expect(getTargetPath('UNKNOWN_STATE', '123456', 'HOST', '/')).toBe('/');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('URL Initialization Logic', () => {
|
|
interface StoredSession {
|
|
pin: string;
|
|
role: 'HOST' | 'CLIENT';
|
|
hostSecret?: string;
|
|
playerName?: string;
|
|
playerId?: string;
|
|
}
|
|
|
|
const simulateUrlInit = (
|
|
path: string,
|
|
storedSession: StoredSession | null
|
|
): { action: string; gamePin?: string; quizId?: string; shouldReconnect?: boolean } => {
|
|
if (path === '/callback' || path.startsWith('/shared/')) {
|
|
return { action: 'skipInit' };
|
|
}
|
|
|
|
const hostMatch = path.match(/^\/host\/(\d+)$/);
|
|
const playMatch = path.match(/^\/play\/(\d+)$/);
|
|
|
|
if (hostMatch) {
|
|
const pin = hostMatch[1];
|
|
if (storedSession && storedSession.pin === pin && storedSession.role === 'HOST') {
|
|
return { action: 'reconnectAsHost', shouldReconnect: true };
|
|
}
|
|
return { action: 'navigateHome' };
|
|
}
|
|
|
|
if (playMatch) {
|
|
const pin = playMatch[1];
|
|
if (storedSession && storedSession.pin === pin && storedSession.role === 'CLIENT') {
|
|
return { action: 'reconnectAsClient', shouldReconnect: true };
|
|
}
|
|
return { action: 'setGamePin', gamePin: pin };
|
|
}
|
|
|
|
if (path === '/create') {
|
|
return { action: 'startCreating' };
|
|
}
|
|
|
|
if (path === '/edit/draft') {
|
|
return { action: 'restoreDraft' };
|
|
}
|
|
|
|
const editMatch = path.match(/^\/edit\/([a-zA-Z0-9-]+)$/);
|
|
if (editMatch) {
|
|
const quizId = editMatch[1];
|
|
return { action: 'restoreLibraryQuiz', quizId };
|
|
}
|
|
|
|
if (storedSession) {
|
|
if (storedSession.role === 'HOST') {
|
|
return { action: 'reconnectAsHost', shouldReconnect: true };
|
|
}
|
|
return { action: 'reconnectAsClient', shouldReconnect: true };
|
|
}
|
|
|
|
return { action: 'showLanding' };
|
|
};
|
|
|
|
describe('/shared/:token URL', () => {
|
|
it('should skip initialization for shared quiz routes', () => {
|
|
const result = simulateUrlInit('/shared/abc123', null);
|
|
expect(result.action).toBe('skipInit');
|
|
});
|
|
|
|
it('should skip initialization for shared routes with complex tokens', () => {
|
|
const result = simulateUrlInit('/shared/6yC50NixzVuA-E_Z0JhEUA', null);
|
|
expect(result.action).toBe('skipInit');
|
|
});
|
|
|
|
it('should skip initialization for shared routes even with existing session', () => {
|
|
const session: StoredSession = { pin: '123456', role: 'HOST', hostSecret: 'secret' };
|
|
const result = simulateUrlInit('/shared/test-token', session);
|
|
expect(result.action).toBe('skipInit');
|
|
});
|
|
|
|
it('should skip initialization for /callback route', () => {
|
|
const result = simulateUrlInit('/callback', null);
|
|
expect(result.action).toBe('skipInit');
|
|
});
|
|
});
|
|
|
|
describe('/host/:pin URL', () => {
|
|
it('should reconnect as host when session matches', () => {
|
|
const session: StoredSession = { pin: '123456', role: 'HOST', hostSecret: 'secret' };
|
|
const result = simulateUrlInit('/host/123456', session);
|
|
expect(result.action).toBe('reconnectAsHost');
|
|
expect(result.shouldReconnect).toBe(true);
|
|
});
|
|
|
|
it('should navigate home when no session for host URL', () => {
|
|
const result = simulateUrlInit('/host/123456', null);
|
|
expect(result.action).toBe('navigateHome');
|
|
});
|
|
|
|
it('should navigate home when session pin does not match', () => {
|
|
const session: StoredSession = { pin: '654321', role: 'HOST', hostSecret: 'secret' };
|
|
const result = simulateUrlInit('/host/123456', session);
|
|
expect(result.action).toBe('navigateHome');
|
|
});
|
|
|
|
it('should navigate home when session role is CLIENT not HOST', () => {
|
|
const session: StoredSession = { pin: '123456', role: 'CLIENT', playerName: 'Test' };
|
|
const result = simulateUrlInit('/host/123456', session);
|
|
expect(result.action).toBe('navigateHome');
|
|
});
|
|
});
|
|
|
|
describe('/play/:pin URL', () => {
|
|
it('should reconnect as client when session matches', () => {
|
|
const session: StoredSession = { pin: '123456', role: 'CLIENT', playerName: 'Test' };
|
|
const result = simulateUrlInit('/play/123456', session);
|
|
expect(result.action).toBe('reconnectAsClient');
|
|
expect(result.shouldReconnect).toBe(true);
|
|
});
|
|
|
|
it('should set gamePin when no session (for join flow)', () => {
|
|
const result = simulateUrlInit('/play/123456', null);
|
|
expect(result.action).toBe('setGamePin');
|
|
expect(result.gamePin).toBe('123456');
|
|
});
|
|
|
|
it('should set gamePin when session pin does not match', () => {
|
|
const session: StoredSession = { pin: '654321', role: 'CLIENT', playerName: 'Test' };
|
|
const result = simulateUrlInit('/play/123456', session);
|
|
expect(result.action).toBe('setGamePin');
|
|
expect(result.gamePin).toBe('123456');
|
|
});
|
|
});
|
|
|
|
describe('/create URL', () => {
|
|
it('should start creating mode', () => {
|
|
const result = simulateUrlInit('/create', null);
|
|
expect(result.action).toBe('startCreating');
|
|
});
|
|
|
|
it('should start creating even with existing session', () => {
|
|
const session: StoredSession = { pin: '123456', role: 'HOST', hostSecret: 'secret' };
|
|
const result = simulateUrlInit('/create', session);
|
|
expect(result.action).toBe('startCreating');
|
|
});
|
|
});
|
|
|
|
describe('/edit/draft URL', () => {
|
|
it('should restore draft for AI-generated quizzes', () => {
|
|
const result = simulateUrlInit('/edit/draft', null);
|
|
expect(result.action).toBe('restoreDraft');
|
|
});
|
|
|
|
it('should restore draft regardless of session state', () => {
|
|
const session: StoredSession = { pin: '123456', role: 'HOST', hostSecret: 'secret' };
|
|
const result = simulateUrlInit('/edit/draft', session);
|
|
expect(result.action).toBe('restoreDraft');
|
|
});
|
|
});
|
|
|
|
describe('/edit/:quizId URL', () => {
|
|
it('should restore library quiz with quizId', () => {
|
|
const result = simulateUrlInit('/edit/quiz-abc-123', null);
|
|
expect(result.action).toBe('restoreLibraryQuiz');
|
|
expect(result.quizId).toBe('quiz-abc-123');
|
|
});
|
|
|
|
it('should handle UUID-style quiz IDs', () => {
|
|
const result = simulateUrlInit('/edit/a1b2c3d4-e5f6-7890-abcd-ef1234567890', null);
|
|
expect(result.action).toBe('restoreLibraryQuiz');
|
|
expect(result.quizId).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
|
});
|
|
|
|
it('should not match /edit without ID', () => {
|
|
const result = simulateUrlInit('/edit', null);
|
|
expect(result.action).toBe('showLanding');
|
|
});
|
|
});
|
|
|
|
describe('Root URL "/"', () => {
|
|
it('should show landing when no session', () => {
|
|
const result = simulateUrlInit('/', null);
|
|
expect(result.action).toBe('showLanding');
|
|
});
|
|
|
|
it('should reconnect as host when HOST session exists', () => {
|
|
const session: StoredSession = { pin: '123456', role: 'HOST', hostSecret: 'secret' };
|
|
const result = simulateUrlInit('/', session);
|
|
expect(result.action).toBe('reconnectAsHost');
|
|
});
|
|
|
|
it('should reconnect as client when CLIENT session exists', () => {
|
|
const session: StoredSession = { pin: '123456', role: 'CLIENT', playerName: 'Test' };
|
|
const result = simulateUrlInit('/', session);
|
|
expect(result.action).toBe('reconnectAsClient');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Navigation Functions', () => {
|
|
describe('cancelCreation', () => {
|
|
it('should set gameState to LANDING', () => {
|
|
let gameState = 'CREATING';
|
|
|
|
const cancelCreation = () => {
|
|
gameState = 'LANDING';
|
|
};
|
|
|
|
cancelCreation();
|
|
expect(gameState).toBe('LANDING');
|
|
});
|
|
|
|
it('should trigger URL change to "/"', () => {
|
|
let gameState = 'CREATING';
|
|
let currentPath = '/create';
|
|
|
|
const cancelCreation = () => {
|
|
gameState = 'LANDING';
|
|
};
|
|
|
|
cancelCreation();
|
|
|
|
if (gameState === 'LANDING') {
|
|
currentPath = '/';
|
|
}
|
|
|
|
expect(currentPath).toBe('/');
|
|
});
|
|
});
|
|
|
|
describe('endGame', () => {
|
|
it('should clear session storage', () => {
|
|
const storage = new Map<string, string>();
|
|
storage.set('kaboot_session', JSON.stringify({ pin: '123456', role: 'HOST' }));
|
|
|
|
const clearStoredSession = () => storage.delete('kaboot_session');
|
|
|
|
clearStoredSession();
|
|
expect(storage.has('kaboot_session')).toBe(false);
|
|
});
|
|
|
|
it('should reset gamePin to null', () => {
|
|
let gamePin: string | null = '123456';
|
|
|
|
const endGame = () => {
|
|
gamePin = null;
|
|
};
|
|
|
|
endGame();
|
|
expect(gamePin).toBeNull();
|
|
});
|
|
|
|
it('should reset quiz to null', () => {
|
|
let quiz: any = { title: 'Test Quiz', questions: [] };
|
|
|
|
const endGame = () => {
|
|
quiz = null;
|
|
};
|
|
|
|
endGame();
|
|
expect(quiz).toBeNull();
|
|
});
|
|
|
|
it('should reset players to empty array', () => {
|
|
let players = [{ id: 'player1', name: 'Test', score: 100 }];
|
|
|
|
const endGame = () => {
|
|
players = [];
|
|
};
|
|
|
|
endGame();
|
|
expect(players).toEqual([]);
|
|
});
|
|
|
|
it('should set gameState to LANDING', () => {
|
|
let gameState = 'PODIUM';
|
|
|
|
const endGame = () => {
|
|
gameState = 'LANDING';
|
|
};
|
|
|
|
endGame();
|
|
expect(gameState).toBe('LANDING');
|
|
});
|
|
|
|
it('should navigate to "/" with replace', () => {
|
|
let navigatedTo: string | null = null;
|
|
let replaceUsed = false;
|
|
|
|
const navigate = (path: string, options?: { replace?: boolean }) => {
|
|
navigatedTo = path;
|
|
replaceUsed = options?.replace ?? false;
|
|
};
|
|
|
|
navigate('/', { replace: true });
|
|
|
|
expect(navigatedTo).toBe('/');
|
|
expect(replaceUsed).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('goHomeFromDisconnected', () => {
|
|
it('should clear session storage', () => {
|
|
const storage = new Map<string, string>();
|
|
storage.set('kaboot_session', JSON.stringify({ pin: '123456', role: 'CLIENT' }));
|
|
|
|
const clearStoredSession = () => storage.delete('kaboot_session');
|
|
|
|
clearStoredSession();
|
|
expect(storage.has('kaboot_session')).toBe(false);
|
|
});
|
|
|
|
it('should reset gamePin to null', () => {
|
|
let gamePin: string | null = '123456';
|
|
|
|
const goHomeFromDisconnected = () => {
|
|
gamePin = null;
|
|
};
|
|
|
|
goHomeFromDisconnected();
|
|
expect(gamePin).toBeNull();
|
|
});
|
|
|
|
it('should set gameState to LANDING', () => {
|
|
let gameState = 'DISCONNECTED';
|
|
|
|
const goHomeFromDisconnected = () => {
|
|
gameState = 'LANDING';
|
|
};
|
|
|
|
goHomeFromDisconnected();
|
|
expect(gameState).toBe('LANDING');
|
|
});
|
|
|
|
it('should clear error state', () => {
|
|
let error: string | null = 'Connection lost';
|
|
|
|
const goHomeFromDisconnected = () => {
|
|
error = null;
|
|
};
|
|
|
|
goHomeFromDisconnected();
|
|
expect(error).toBeNull();
|
|
});
|
|
|
|
it('should result in URL change to "/" (not stay on /play/:pin)', () => {
|
|
let gameState = 'DISCONNECTED';
|
|
let gamePin: string | null = '123456';
|
|
|
|
const goHomeFromDisconnected = () => {
|
|
gamePin = null;
|
|
gameState = 'LANDING';
|
|
};
|
|
|
|
goHomeFromDisconnected();
|
|
|
|
const getTargetPath = () => {
|
|
if (gameState === 'LANDING' && !gamePin) {
|
|
return '/';
|
|
}
|
|
return `/play/${gamePin}`;
|
|
};
|
|
|
|
expect(getTargetPath()).toBe('/');
|
|
});
|
|
});
|
|
|
|
describe('backFromEditor', () => {
|
|
it('should reset quiz to null', () => {
|
|
let quiz: any = { title: 'Test', questions: [] };
|
|
|
|
const backFromEditor = () => {
|
|
quiz = null;
|
|
};
|
|
|
|
backFromEditor();
|
|
expect(quiz).toBeNull();
|
|
});
|
|
|
|
it('should reset pendingQuizToSave to null', () => {
|
|
let pendingQuizToSave: any = { quiz: {}, topic: 'Test' };
|
|
|
|
const backFromEditor = () => {
|
|
pendingQuizToSave = null;
|
|
};
|
|
|
|
backFromEditor();
|
|
expect(pendingQuizToSave).toBeNull();
|
|
});
|
|
|
|
it('should reset sourceQuizId to null', () => {
|
|
let sourceQuizId: string | null = 'quiz-123';
|
|
|
|
const backFromEditor = () => {
|
|
sourceQuizId = null;
|
|
};
|
|
|
|
backFromEditor();
|
|
expect(sourceQuizId).toBeNull();
|
|
});
|
|
|
|
it('should set gameState to LANDING', () => {
|
|
let gameState = 'EDITING';
|
|
|
|
const backFromEditor = () => {
|
|
gameState = 'LANDING';
|
|
};
|
|
|
|
backFromEditor();
|
|
expect(gameState).toBe('LANDING');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Non-Happy Path Scenarios', () => {
|
|
describe('Page reload on different URLs', () => {
|
|
it('should NOT get stuck on /create after cancel (regression test)', () => {
|
|
let gameState = 'CREATING';
|
|
let navigatedTo: string | null = null;
|
|
|
|
const cancelCreation = () => {
|
|
gameState = 'LANDING';
|
|
};
|
|
|
|
cancelCreation();
|
|
|
|
if (gameState === 'LANDING') {
|
|
navigatedTo = '/';
|
|
}
|
|
|
|
expect(gameState).toBe('LANDING');
|
|
expect(navigatedTo).toBe('/');
|
|
});
|
|
|
|
it('should NOT get stuck on /host/:pin after endGame (regression test)', () => {
|
|
let gameState = 'PODIUM';
|
|
let gamePin: string | null = '123456';
|
|
let sessionCleared = false;
|
|
let navigatedTo: string | null = null;
|
|
|
|
const endGame = () => {
|
|
sessionCleared = true;
|
|
gamePin = null;
|
|
gameState = 'LANDING';
|
|
navigatedTo = '/';
|
|
};
|
|
|
|
endGame();
|
|
|
|
expect(sessionCleared).toBe(true);
|
|
expect(gamePin).toBeNull();
|
|
expect(gameState).toBe('LANDING');
|
|
expect(navigatedTo).toBe('/');
|
|
});
|
|
|
|
it('should NOT redirect /shared/:token to "/" (regression test)', () => {
|
|
const currentPath = '/shared/6yC50NixzVuA-E_Z0JhEUA';
|
|
const gameState = 'LANDING';
|
|
const gamePin: string | null = null;
|
|
|
|
const getTargetPath = () => {
|
|
if (currentPath.startsWith('/shared/')) {
|
|
return null;
|
|
}
|
|
if (gameState === 'LANDING' && !gamePin) {
|
|
return '/';
|
|
}
|
|
return currentPath;
|
|
};
|
|
|
|
const targetPath = getTargetPath();
|
|
const shouldNavigate = targetPath !== null && currentPath !== targetPath;
|
|
|
|
expect(targetPath).toBeNull();
|
|
expect(shouldNavigate).toBe(false);
|
|
});
|
|
|
|
it('should NOT get stuck on /play/:pin after goHomeFromDisconnected (regression test)', () => {
|
|
let gameState = 'DISCONNECTED';
|
|
let gamePin: string | null = '123456';
|
|
let sessionCleared = false;
|
|
|
|
const goHomeFromDisconnected = () => {
|
|
sessionCleared = true;
|
|
gamePin = null;
|
|
gameState = 'LANDING';
|
|
};
|
|
|
|
goHomeFromDisconnected();
|
|
|
|
expect(gamePin).toBeNull();
|
|
expect(gameState).toBe('LANDING');
|
|
});
|
|
});
|
|
|
|
describe('Missing or invalid sessions', () => {
|
|
it('should redirect to home when accessing /host/:pin without host session', () => {
|
|
const path = '/host/123456';
|
|
const session = null;
|
|
|
|
const hostMatch = path.match(/^\/host\/(\d+)$/);
|
|
let action = 'none';
|
|
|
|
if (hostMatch) {
|
|
if (!session) {
|
|
action = 'navigateHome';
|
|
}
|
|
}
|
|
|
|
expect(action).toBe('navigateHome');
|
|
});
|
|
|
|
it('should redirect to home when accessing /host/:pin with CLIENT session', () => {
|
|
const path = '/host/123456';
|
|
const session: { pin: string; role: 'HOST' | 'CLIENT'; playerName?: string } = {
|
|
pin: '123456',
|
|
role: 'CLIENT',
|
|
playerName: 'Test'
|
|
};
|
|
|
|
const hostMatch = path.match(/^\/host\/(\d+)$/);
|
|
let action = 'none';
|
|
|
|
if (hostMatch) {
|
|
const pin = hostMatch[1];
|
|
if (!session || session.role !== 'HOST' || session.pin !== pin) {
|
|
action = 'navigateHome';
|
|
}
|
|
}
|
|
|
|
expect(action).toBe('navigateHome');
|
|
});
|
|
|
|
it('should show join form when accessing /play/:pin without session (shareable link)', () => {
|
|
const path = '/play/123456';
|
|
const session = null;
|
|
|
|
const playMatch = path.match(/^\/play\/(\d+)$/);
|
|
let gamePin: string | null = null;
|
|
|
|
if (playMatch) {
|
|
const pin = playMatch[1];
|
|
if (!session) {
|
|
gamePin = pin;
|
|
}
|
|
}
|
|
|
|
expect(gamePin).toBe('123456');
|
|
});
|
|
|
|
it('should redirect to home when accessing /edit/draft without draft in storage', () => {
|
|
const path = '/edit/draft';
|
|
const hasDraftInStorage = false;
|
|
|
|
let action = 'none';
|
|
|
|
if (path === '/edit/draft') {
|
|
if (!hasDraftInStorage) {
|
|
action = 'navigateHome';
|
|
} else {
|
|
action = 'restoreDraft';
|
|
}
|
|
}
|
|
|
|
expect(action).toBe('navigateHome');
|
|
});
|
|
|
|
it('should redirect to home when accessing /edit/:quizId with mismatched storage', () => {
|
|
const path = '/edit/quiz-123';
|
|
const storedQuizId = 'quiz-456';
|
|
|
|
const editMatch = path.match(/^\/edit\/([a-zA-Z0-9-]+)$/);
|
|
let action = 'none';
|
|
|
|
if (editMatch) {
|
|
const urlQuizId = editMatch[1];
|
|
if (storedQuizId !== urlQuizId) {
|
|
action = 'navigateHome';
|
|
}
|
|
}
|
|
|
|
expect(action).toBe('navigateHome');
|
|
});
|
|
});
|
|
|
|
describe('Stale session handling', () => {
|
|
it('should handle mismatched PIN in session vs URL', () => {
|
|
const urlPin = '123456';
|
|
const session = { pin: '654321', role: 'HOST' as const, hostSecret: 'secret' };
|
|
|
|
const shouldReconnect = session && session.pin === urlPin && session.role === 'HOST';
|
|
|
|
expect(shouldReconnect).toBe(false);
|
|
});
|
|
|
|
it('should handle expired/deleted game on reconnection attempt', () => {
|
|
const session = { pin: '123456', role: 'HOST' as const, hostSecret: 'secret' };
|
|
const gameExistsOnServer = false;
|
|
|
|
let action = 'none';
|
|
|
|
if (session && !gameExistsOnServer) {
|
|
action = 'clearSessionAndGoHome';
|
|
}
|
|
|
|
expect(action).toBe('clearSessionAndGoHome');
|
|
});
|
|
});
|
|
|
|
describe('URL with search params preservation', () => {
|
|
it('should preserve search params when navigating', () => {
|
|
const currentPath = '/';
|
|
const currentSearch = '?modal=library';
|
|
const targetPath = '/';
|
|
|
|
const fullPath = targetPath + currentSearch;
|
|
|
|
expect(fullPath).toBe('/?modal=library');
|
|
});
|
|
|
|
it('should preserve modal param when gameState changes', () => {
|
|
const searchParams = new URLSearchParams('?modal=settings');
|
|
const modalParam = searchParams.get('modal');
|
|
|
|
expect(modalParam).toBe('settings');
|
|
});
|
|
});
|
|
|
|
describe('Race conditions', () => {
|
|
it('should handle URL init completing before auth loads', () => {
|
|
let authLoaded = false;
|
|
let urlInitComplete = false;
|
|
let gameState = 'LANDING';
|
|
|
|
urlInitComplete = true;
|
|
|
|
setTimeout(() => {
|
|
authLoaded = true;
|
|
}, 100);
|
|
|
|
expect(gameState).toBe('LANDING');
|
|
expect(urlInitComplete).toBe(true);
|
|
});
|
|
|
|
it('should not navigate when isInitializingFromUrl is true', () => {
|
|
let isInitializingFromUrl = true;
|
|
let navigateCalled = false;
|
|
|
|
const urlSyncEffect = () => {
|
|
if (isInitializingFromUrl) return;
|
|
navigateCalled = true;
|
|
};
|
|
|
|
urlSyncEffect();
|
|
|
|
expect(navigateCalled).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Replace vs Push Navigation', () => {
|
|
it('should use replace for transient states (COUNTDOWN, QUESTION, REVEAL, SCOREBOARD)', () => {
|
|
const transientStates = ['COUNTDOWN', 'QUESTION', 'REVEAL', 'SCOREBOARD'];
|
|
|
|
transientStates.forEach(state => {
|
|
const useReplace = ['COUNTDOWN', 'QUESTION', 'REVEAL', 'SCOREBOARD'].includes(state);
|
|
expect(useReplace).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('should use push for non-transient states', () => {
|
|
const nonTransientStates = ['LANDING', 'CREATING', 'EDITING', 'LOBBY', 'PODIUM'];
|
|
|
|
nonTransientStates.forEach(state => {
|
|
const useReplace = ['COUNTDOWN', 'QUESTION', 'REVEAL', 'SCOREBOARD'].includes(state);
|
|
expect(useReplace).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Host/Join Mode URL Parameter Tests', () => {
|
|
describe('Mode state from URL', () => {
|
|
const getModeFromUrl = (searchParams: URLSearchParams, initialPin?: string): 'HOST' | 'JOIN' => {
|
|
if (initialPin) return 'JOIN';
|
|
const modeParam = searchParams.get('mode');
|
|
return modeParam === 'host' ? 'HOST' : 'JOIN';
|
|
};
|
|
|
|
it('should return JOIN by default when no mode param', () => {
|
|
const searchParams = new URLSearchParams('');
|
|
expect(getModeFromUrl(searchParams)).toBe('JOIN');
|
|
});
|
|
|
|
it('should return HOST when ?mode=host', () => {
|
|
const searchParams = new URLSearchParams('?mode=host');
|
|
expect(getModeFromUrl(searchParams)).toBe('HOST');
|
|
});
|
|
|
|
it('should return JOIN when ?mode=join', () => {
|
|
const searchParams = new URLSearchParams('?mode=join');
|
|
expect(getModeFromUrl(searchParams)).toBe('JOIN');
|
|
});
|
|
|
|
it('should return JOIN for invalid mode values', () => {
|
|
const searchParams = new URLSearchParams('?mode=invalid');
|
|
expect(getModeFromUrl(searchParams)).toBe('JOIN');
|
|
});
|
|
|
|
it('should force JOIN mode when initialPin is provided', () => {
|
|
const searchParams = new URLSearchParams('?mode=host');
|
|
expect(getModeFromUrl(searchParams, '123456')).toBe('JOIN');
|
|
});
|
|
|
|
it('should force JOIN mode with initialPin even without mode param', () => {
|
|
const searchParams = new URLSearchParams('');
|
|
expect(getModeFromUrl(searchParams, '123456')).toBe('JOIN');
|
|
});
|
|
});
|
|
|
|
describe('Setting mode URL param', () => {
|
|
it('should set ?mode=host when switching to HOST', () => {
|
|
const searchParams = new URLSearchParams();
|
|
|
|
const setMode = (newMode: 'HOST' | 'JOIN') => {
|
|
if (newMode === 'HOST') {
|
|
searchParams.set('mode', 'host');
|
|
} else {
|
|
searchParams.delete('mode');
|
|
}
|
|
};
|
|
|
|
setMode('HOST');
|
|
expect(searchParams.get('mode')).toBe('host');
|
|
});
|
|
|
|
it('should remove mode param when switching to JOIN', () => {
|
|
const searchParams = new URLSearchParams('?mode=host');
|
|
|
|
const setMode = (newMode: 'HOST' | 'JOIN') => {
|
|
if (newMode === 'HOST') {
|
|
searchParams.set('mode', 'host');
|
|
} else {
|
|
searchParams.delete('mode');
|
|
}
|
|
};
|
|
|
|
setMode('JOIN');
|
|
expect(searchParams.get('mode')).toBeNull();
|
|
});
|
|
|
|
it('should preserve other params when setting mode', () => {
|
|
const searchParams = new URLSearchParams('?modal=library');
|
|
|
|
const setMode = (newMode: 'HOST' | 'JOIN') => {
|
|
if (newMode === 'HOST') {
|
|
searchParams.set('mode', 'host');
|
|
} else {
|
|
searchParams.delete('mode');
|
|
}
|
|
};
|
|
|
|
setMode('HOST');
|
|
expect(searchParams.get('mode')).toBe('host');
|
|
expect(searchParams.get('modal')).toBe('library');
|
|
});
|
|
});
|
|
|
|
describe('Mode with modal params', () => {
|
|
it('should support both mode and modal params together', () => {
|
|
const searchParams = new URLSearchParams('?mode=host&modal=settings');
|
|
|
|
expect(searchParams.get('mode')).toBe('host');
|
|
expect(searchParams.get('modal')).toBe('settings');
|
|
});
|
|
|
|
it('should preserve mode when opening modal', () => {
|
|
const searchParams = new URLSearchParams('?mode=host');
|
|
|
|
searchParams.set('modal', 'library');
|
|
|
|
expect(searchParams.get('mode')).toBe('host');
|
|
expect(searchParams.get('modal')).toBe('library');
|
|
});
|
|
|
|
it('should preserve mode when closing modal', () => {
|
|
const searchParams = new URLSearchParams('?mode=host&modal=library');
|
|
|
|
searchParams.delete('modal');
|
|
|
|
expect(searchParams.get('mode')).toBe('host');
|
|
expect(searchParams.get('modal')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('URL representation', () => {
|
|
it('should generate clean URL for JOIN mode (no mode param)', () => {
|
|
const searchParams = new URLSearchParams();
|
|
const url = '/' + (searchParams.toString() ? '?' + searchParams.toString() : '');
|
|
expect(url).toBe('/');
|
|
});
|
|
|
|
it('should generate correct URL for HOST mode', () => {
|
|
const searchParams = new URLSearchParams();
|
|
searchParams.set('mode', 'host');
|
|
const url = '/?' + searchParams.toString();
|
|
expect(url).toBe('/?mode=host');
|
|
});
|
|
|
|
it('should generate correct URL for HOST mode with modal', () => {
|
|
const searchParams = new URLSearchParams();
|
|
searchParams.set('mode', 'host');
|
|
searchParams.set('modal', 'settings');
|
|
const url = '/?' + searchParams.toString();
|
|
expect(url).toContain('mode=host');
|
|
expect(url).toContain('modal=settings');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Modal URL Parameter Tests', () => {
|
|
describe('Modal state from URL', () => {
|
|
it('should detect library modal from ?modal=library', () => {
|
|
const searchParams = new URLSearchParams('?modal=library');
|
|
const modalParam = searchParams.get('modal');
|
|
|
|
const libraryOpen = modalParam === 'library';
|
|
expect(libraryOpen).toBe(true);
|
|
});
|
|
|
|
it('should detect preferences modal from ?modal=preferences', () => {
|
|
const searchParams = new URLSearchParams('?modal=preferences');
|
|
const modalParam = searchParams.get('modal');
|
|
|
|
const preferencesOpen = modalParam === 'preferences';
|
|
expect(preferencesOpen).toBe(true);
|
|
});
|
|
|
|
it('should detect settings modal from ?modal=settings', () => {
|
|
const searchParams = new URLSearchParams('?modal=settings');
|
|
const modalParam = searchParams.get('modal');
|
|
|
|
const settingsOpen = modalParam === 'settings';
|
|
expect(settingsOpen).toBe(true);
|
|
});
|
|
|
|
it('should detect account modal from ?modal=account', () => {
|
|
const searchParams = new URLSearchParams('?modal=account');
|
|
const modalParam = searchParams.get('modal');
|
|
|
|
const accountOpen = modalParam === 'account';
|
|
expect(accountOpen).toBe(true);
|
|
});
|
|
|
|
it('should return false for all modals when no param', () => {
|
|
const searchParams = new URLSearchParams('');
|
|
const modalParam = searchParams.get('modal');
|
|
|
|
expect(modalParam).toBeNull();
|
|
expect(modalParam === 'library').toBe(false);
|
|
expect(modalParam === 'preferences').toBe(false);
|
|
expect(modalParam === 'settings').toBe(false);
|
|
expect(modalParam === 'account').toBe(false);
|
|
});
|
|
|
|
it('should return false for all modals with unknown param value', () => {
|
|
const searchParams = new URLSearchParams('?modal=unknown');
|
|
const modalParam = searchParams.get('modal');
|
|
|
|
expect(modalParam === 'library').toBe(false);
|
|
expect(modalParam === 'preferences').toBe(false);
|
|
expect(modalParam === 'settings').toBe(false);
|
|
expect(modalParam === 'account').toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Setting modal URL params', () => {
|
|
it('should set ?modal=library when opening library', () => {
|
|
const searchParams = new URLSearchParams();
|
|
|
|
const setLibraryOpen = (open: boolean) => {
|
|
if (open) {
|
|
searchParams.set('modal', 'library');
|
|
} else {
|
|
searchParams.delete('modal');
|
|
}
|
|
};
|
|
|
|
setLibraryOpen(true);
|
|
expect(searchParams.get('modal')).toBe('library');
|
|
});
|
|
|
|
it('should clear modal param when closing modal', () => {
|
|
const searchParams = new URLSearchParams('?modal=library');
|
|
|
|
const setLibraryOpen = (open: boolean) => {
|
|
if (open) {
|
|
searchParams.set('modal', 'library');
|
|
} else {
|
|
searchParams.delete('modal');
|
|
}
|
|
};
|
|
|
|
setLibraryOpen(false);
|
|
expect(searchParams.get('modal')).toBeNull();
|
|
});
|
|
|
|
it('should replace existing modal param when opening different modal', () => {
|
|
const searchParams = new URLSearchParams('?modal=library');
|
|
|
|
searchParams.set('modal', 'settings');
|
|
|
|
expect(searchParams.get('modal')).toBe('settings');
|
|
});
|
|
});
|
|
|
|
describe('Modal with /play/:pin URL', () => {
|
|
it('should work with modal param on /play/:pin URL', () => {
|
|
const pathname = '/play/123456';
|
|
const search = '?modal=preferences';
|
|
const fullUrl = pathname + search;
|
|
|
|
expect(fullUrl).toBe('/play/123456?modal=preferences');
|
|
|
|
const searchParams = new URLSearchParams(search);
|
|
expect(searchParams.get('modal')).toBe('preferences');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('OAuth Callback Handling', () => {
|
|
describe('onSigninCallback redirect', () => {
|
|
it('should redirect to "/" after successful OAuth callback', () => {
|
|
let replacedPath: string | null = null;
|
|
|
|
const mockReplaceState = (_data: unknown, _title: string, url: string) => {
|
|
replacedPath = url;
|
|
};
|
|
|
|
const onSigninCallback = () => {
|
|
mockReplaceState({}, document.title, '/');
|
|
};
|
|
|
|
onSigninCallback();
|
|
|
|
expect(replacedPath).toBe('/');
|
|
});
|
|
|
|
it('should NOT stay on /callback path after signin', () => {
|
|
let currentPath = '/callback';
|
|
|
|
const onSigninCallback = () => {
|
|
currentPath = '/';
|
|
};
|
|
|
|
onSigninCallback();
|
|
|
|
expect(currentPath).not.toBe('/callback');
|
|
expect(currentPath).toBe('/');
|
|
});
|
|
|
|
it('should clear OAuth query params (code, state) from URL', () => {
|
|
const urlWithOAuthParams = '/callback?code=abc123&state=xyz789';
|
|
let finalUrl: string | null = null;
|
|
|
|
const onSigninCallback = () => {
|
|
finalUrl = '/';
|
|
};
|
|
|
|
onSigninCallback();
|
|
|
|
expect(finalUrl).toBe('/');
|
|
expect(finalUrl).not.toContain('code=');
|
|
expect(finalUrl).not.toContain('state=');
|
|
});
|
|
});
|
|
|
|
describe('URL initialization skips /callback', () => {
|
|
const shouldSkipUrlInit = (pathname: string): boolean => {
|
|
return pathname === '/callback';
|
|
};
|
|
|
|
it('should skip URL initialization on /callback path', () => {
|
|
expect(shouldSkipUrlInit('/callback')).toBe(true);
|
|
});
|
|
|
|
it('should NOT skip URL initialization on other paths', () => {
|
|
expect(shouldSkipUrlInit('/')).toBe(false);
|
|
expect(shouldSkipUrlInit('/create')).toBe(false);
|
|
expect(shouldSkipUrlInit('/edit/draft')).toBe(false);
|
|
expect(shouldSkipUrlInit('/edit/quiz-123')).toBe(false);
|
|
expect(shouldSkipUrlInit('/host/123456')).toBe(false);
|
|
expect(shouldSkipUrlInit('/play/123456')).toBe(false);
|
|
});
|
|
|
|
it('should not interfere with normal navigation when not on /callback', () => {
|
|
let urlInitRan = false;
|
|
const pathname: string = '/';
|
|
|
|
const initFromUrl = () => {
|
|
if (pathname === '/callback') return;
|
|
urlInitRan = true;
|
|
};
|
|
|
|
initFromUrl();
|
|
|
|
expect(urlInitRan).toBe(true);
|
|
});
|
|
|
|
it('should prevent URL sync from running while auth is loading', () => {
|
|
let urlSyncRan = false;
|
|
const authIsLoading = true;
|
|
|
|
const syncUrl = () => {
|
|
if (authIsLoading) return;
|
|
urlSyncRan = true;
|
|
};
|
|
|
|
syncUrl();
|
|
|
|
expect(urlSyncRan).toBe(false);
|
|
});
|
|
|
|
it('should navigate away from /callback after auth completes', () => {
|
|
const pathname = '/callback';
|
|
const authIsLoading = false;
|
|
|
|
const getTargetPath = () => {
|
|
if (pathname === '/callback') {
|
|
return '/';
|
|
}
|
|
return pathname;
|
|
};
|
|
|
|
const targetPath = getTargetPath();
|
|
expect(targetPath).toBe('/');
|
|
});
|
|
});
|
|
|
|
describe('localStorage preservation during OAuth', () => {
|
|
it('should NOT clear OIDC state from localStorage on signout', () => {
|
|
const storage = new Map<string, string>();
|
|
storage.set('kaboot_session', JSON.stringify({ pin: '123456' }));
|
|
storage.set('oidc.user:https://auth.example.com:client-id', 'oidc-state');
|
|
storage.set('oidc.some-other-key', 'other-state');
|
|
|
|
const onRemoveUser = () => {
|
|
storage.delete('kaboot_session');
|
|
};
|
|
|
|
onRemoveUser();
|
|
|
|
expect(storage.has('kaboot_session')).toBe(false);
|
|
expect(storage.has('oidc.user:https://auth.example.com:client-id')).toBe(true);
|
|
expect(storage.has('oidc.some-other-key')).toBe(true);
|
|
});
|
|
|
|
it('should only clear kaboot_session on signout', () => {
|
|
const storage = new Map<string, string>();
|
|
storage.set('kaboot_session', 'session-data');
|
|
storage.set('other_app_data', 'preserved');
|
|
storage.set('user_preferences', 'also-preserved');
|
|
|
|
const onRemoveUser = () => {
|
|
storage.delete('kaboot_session');
|
|
};
|
|
|
|
onRemoveUser();
|
|
|
|
expect(storage.has('kaboot_session')).toBe(false);
|
|
expect(storage.get('other_app_data')).toBe('preserved');
|
|
expect(storage.get('user_preferences')).toBe('also-preserved');
|
|
});
|
|
});
|
|
|
|
describe('OAuth callback regression tests', () => {
|
|
it('should NOT get stuck on /callback after login (regression test)', () => {
|
|
let currentPath: string = '/callback?code=auth_code&state=random_state';
|
|
|
|
const onSigninCallback = () => {
|
|
currentPath = '/';
|
|
};
|
|
|
|
onSigninCallback();
|
|
|
|
expect(currentPath).toBe('/');
|
|
expect(currentPath.includes('callback')).toBe(false);
|
|
});
|
|
|
|
it('should NOT show "No matching state found" error by preserving OIDC storage', () => {
|
|
const storage = new Map<string, string>();
|
|
const oidcStateKey = 'oidc.state.abc123';
|
|
storage.set(oidcStateKey, JSON.stringify({ nonce: 'xyz', redirect_uri: '/callback' }));
|
|
storage.set('kaboot_session', 'old-session');
|
|
|
|
const onRemoveUser = () => {
|
|
storage.delete('kaboot_session');
|
|
};
|
|
|
|
onRemoveUser();
|
|
|
|
expect(storage.has(oidcStateKey)).toBe(true);
|
|
});
|
|
|
|
it('should handle multiple rapid signin/signout cycles', () => {
|
|
const storage = new Map<string, string>();
|
|
let signinCount = 0;
|
|
let signoutCount = 0;
|
|
|
|
const onSigninCallback = () => {
|
|
signinCount++;
|
|
storage.set('kaboot_session', `session-${signinCount}`);
|
|
};
|
|
|
|
const onRemoveUser = () => {
|
|
signoutCount++;
|
|
storage.delete('kaboot_session');
|
|
};
|
|
|
|
onSigninCallback();
|
|
onRemoveUser();
|
|
onSigninCallback();
|
|
onRemoveUser();
|
|
onSigninCallback();
|
|
|
|
expect(signinCount).toBe(3);
|
|
expect(signoutCount).toBe(2);
|
|
expect(storage.has('kaboot_session')).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('/play/:pin URL Join Flow', () => {
|
|
describe('Landing PIN sync (regression: PIN field empty on /play/:pin)', () => {
|
|
it('should sync local PIN state when initialPin prop changes after async load', () => {
|
|
let initialPin: string | null = null;
|
|
let localPin = initialPin || '';
|
|
|
|
const syncPin = (newInitialPin: string | null) => {
|
|
if (newInitialPin) {
|
|
localPin = newInitialPin;
|
|
}
|
|
};
|
|
|
|
initialPin = 'TESTPIN';
|
|
syncPin(initialPin);
|
|
|
|
expect(localPin).toBe('TESTPIN');
|
|
});
|
|
|
|
it('should preserve user-typed PIN when initialPin becomes null', () => {
|
|
let localPin = 'USER_TYPED';
|
|
|
|
const syncPin = (newInitialPin: string | null) => {
|
|
if (newInitialPin) {
|
|
localPin = newInitialPin;
|
|
}
|
|
};
|
|
|
|
syncPin(null);
|
|
|
|
expect(localPin).toBe('USER_TYPED');
|
|
});
|
|
|
|
it('should force JOIN mode when initialPin is provided via URL', () => {
|
|
const getModeFromUrl = (searchParams: URLSearchParams, initialPin?: string): 'HOST' | 'JOIN' => {
|
|
if (initialPin) return 'JOIN';
|
|
const modeParam = searchParams.get('mode');
|
|
return modeParam === 'host' ? 'HOST' : 'JOIN';
|
|
};
|
|
|
|
const searchParams = new URLSearchParams('?mode=host');
|
|
expect(getModeFromUrl(searchParams, 'TESTPIN')).toBe('JOIN');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('State Sync with Async Data Loading', () => {
|
|
describe('Settings modal data sync', () => {
|
|
it('should update editingDefaultConfig when defaultConfig loads', () => {
|
|
const DEFAULT_CONFIG = { shuffleQuestions: false };
|
|
const LOADED_CONFIG = { shuffleQuestions: true, streakBonusEnabled: true };
|
|
|
|
let defaultConfig = DEFAULT_CONFIG;
|
|
let editingDefaultConfig = DEFAULT_CONFIG;
|
|
let defaultConfigOpen = true;
|
|
|
|
defaultConfig = LOADED_CONFIG;
|
|
|
|
if (defaultConfigOpen) {
|
|
editingDefaultConfig = defaultConfig;
|
|
}
|
|
|
|
expect(editingDefaultConfig).toEqual(LOADED_CONFIG);
|
|
});
|
|
|
|
it('should not update editingDefaultConfig when modal is closed', () => {
|
|
const DEFAULT_CONFIG = { shuffleQuestions: false };
|
|
const LOADED_CONFIG = { shuffleQuestions: true };
|
|
|
|
let defaultConfig = DEFAULT_CONFIG;
|
|
let editingDefaultConfig: any = null;
|
|
let defaultConfigOpen = false;
|
|
|
|
defaultConfig = LOADED_CONFIG;
|
|
|
|
if (defaultConfigOpen) {
|
|
editingDefaultConfig = defaultConfig;
|
|
} else {
|
|
editingDefaultConfig = null;
|
|
}
|
|
|
|
expect(editingDefaultConfig).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('Preferences modal data sync', () => {
|
|
it('should update localPrefs when preferences loads', () => {
|
|
const DEFAULT_PREFS = { colorScheme: 'blue' };
|
|
const LOADED_PREFS = { colorScheme: 'purple', geminiApiKey: 'key123' };
|
|
|
|
let preferences = DEFAULT_PREFS;
|
|
let localPrefs = DEFAULT_PREFS;
|
|
let isOpen = true;
|
|
|
|
preferences = LOADED_PREFS;
|
|
|
|
if (isOpen) {
|
|
localPrefs = preferences;
|
|
}
|
|
|
|
expect(localPrefs).toEqual(LOADED_PREFS);
|
|
});
|
|
});
|
|
});
|