Compare commits
1 Commits
dev/issue-
...
pr-181
| Author | SHA1 | Date | |
|---|---|---|---|
| acc3420a86 |
@@ -71,50 +71,4 @@ describe('HostShellComponent gameplay wiring', () => {
|
|||||||
expect(component.scoreboardError).toContain('Scoreboard failed: Scoreboard unavailable');
|
expect(component.scoreboardError).toContain('Scoreboard failed: Scoreboard unavailable');
|
||||||
expect(component.loading).toBe(false);
|
expect(component.loading).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('wires showQuestion, mixAnswers and calculateScores with expected request payloads', async () => {
|
|
||||||
const sessionAfterAction = {
|
|
||||||
session: { code: 'ABCD12', status: 'guess', current_round: 1 },
|
|
||||||
round_question: { id: 77, prompt: 'Q?', answers: [] },
|
|
||||||
players: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchMock: FetchMock = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValueOnce(jsonResponse(200, { ok: true }))
|
|
||||||
.mockResolvedValueOnce(jsonResponse(200, sessionAfterAction))
|
|
||||||
.mockResolvedValueOnce(jsonResponse(200, { ok: true }))
|
|
||||||
.mockResolvedValueOnce(jsonResponse(200, sessionAfterAction))
|
|
||||||
.mockResolvedValueOnce(jsonResponse(200, { ok: true }))
|
|
||||||
.mockResolvedValueOnce(jsonResponse(200, sessionAfterAction));
|
|
||||||
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
|
|
||||||
const component = new HostShellComponent();
|
|
||||||
component.sessionCode = ' abcd12 ';
|
|
||||||
component.roundQuestionId = ' 77 ';
|
|
||||||
|
|
||||||
await component.showQuestion();
|
|
||||||
await component.mixAnswers();
|
|
||||||
await component.calculateScores();
|
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
'/lobby/sessions/ABCD12/questions/show',
|
|
||||||
expect.objectContaining({ method: 'POST', body: JSON.stringify({}) })
|
|
||||||
);
|
|
||||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
||||||
3,
|
|
||||||
'/lobby/sessions/ABCD12/questions/77/answers/mix',
|
|
||||||
expect.objectContaining({ method: 'POST', body: JSON.stringify({}) })
|
|
||||||
);
|
|
||||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
||||||
5,
|
|
||||||
'/lobby/sessions/ABCD12/questions/77/scores/calculate',
|
|
||||||
expect.objectContaining({ method: 'POST', body: JSON.stringify({}) })
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(component.error).toBe('');
|
|
||||||
expect(component.loading).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -66,14 +66,6 @@ describe('PlayerShellComponent gameplay wiring', () => {
|
|||||||
|
|
||||||
await component.submitLie();
|
await component.submitLie();
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
'/lobby/sessions/ABCD12/questions/11/lies/submit',
|
|
||||||
expect.objectContaining({
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ player_id: 9, session_token: 'token-1', text: 'my lie' }),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(component.submitError?.kind).toBe('lie');
|
expect(component.submitError?.kind).toBe('lie');
|
||||||
expect(component.submitError?.message).toContain('Lie submit failed: Temporary submit outage');
|
expect(component.submitError?.message).toContain('Lie submit failed: Temporary submit outage');
|
||||||
|
|
||||||
@@ -83,49 +75,4 @@ describe('PlayerShellComponent gameplay wiring', () => {
|
|||||||
expect(component.session?.session.status).toBe('guess');
|
expect(component.session?.session.status).toBe('guess');
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('surfaces guess submit error and retries with selected answer payload', async () => {
|
|
||||||
const fetchMock: FetchMock = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValueOnce(jsonResponse(503, { error: 'Guess queue busy' }))
|
|
||||||
.mockResolvedValueOnce(jsonResponse(200, { ok: true }))
|
|
||||||
.mockResolvedValueOnce(
|
|
||||||
jsonResponse(200, {
|
|
||||||
session: { code: 'ABCD12', status: 'reveal', current_round: 1 },
|
|
||||||
round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] },
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
|
|
||||||
const component = new PlayerShellComponent();
|
|
||||||
component.sessionCode = ' abcd12 ';
|
|
||||||
component.playerId = 9;
|
|
||||||
component.sessionToken = 'token-1';
|
|
||||||
component.selectedGuess = 'B';
|
|
||||||
component.session = {
|
|
||||||
session: { code: 'ABCD12', status: 'guess', current_round: 1 },
|
|
||||||
round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] },
|
|
||||||
};
|
|
||||||
|
|
||||||
await component.submitGuess();
|
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
'/lobby/sessions/ABCD12/questions/11/guesses/submit',
|
|
||||||
expect.objectContaining({
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ player_id: 9, session_token: 'token-1', selected_text: 'B' }),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(component.submitError?.kind).toBe('guess');
|
|
||||||
expect(component.submitError?.message).toContain('Guess submit failed: Guess queue busy');
|
|
||||||
|
|
||||||
await component.submitGuess();
|
|
||||||
|
|
||||||
expect(component.submitError).toBeNull();
|
|
||||||
expect(component.session?.session.status).toBe('reveal');
|
|
||||||
expect(component.selectedGuess).toBe('');
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
import type { SessionDetailResponse } from '../api/types';
|
|
||||||
|
|
||||||
export type GameplayPhase = 'lie' | 'guess' | 'reveal' | 'scoreboard';
|
|
||||||
|
|
||||||
export type GameplayPhaseEvent =
|
|
||||||
| 'LIES_LOCKED'
|
|
||||||
| 'GUESSES_LOCKED'
|
|
||||||
| 'SCOREBOARD_READY'
|
|
||||||
| 'NEXT_ROUND';
|
|
||||||
|
|
||||||
export interface GameplayTransitionResult {
|
|
||||||
phase: GameplayPhase;
|
|
||||||
changed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TRANSITIONS: Record<GameplayPhase, Partial<Record<GameplayPhaseEvent, GameplayPhase>>> = {
|
|
||||||
lie: {
|
|
||||||
LIES_LOCKED: 'guess'
|
|
||||||
},
|
|
||||||
guess: {
|
|
||||||
GUESSES_LOCKED: 'reveal'
|
|
||||||
},
|
|
||||||
reveal: {
|
|
||||||
SCOREBOARD_READY: 'scoreboard'
|
|
||||||
},
|
|
||||||
scoreboard: {
|
|
||||||
NEXT_ROUND: 'lie'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export function transitionGameplayPhase(phase: GameplayPhase, event: GameplayPhaseEvent): GameplayTransitionResult {
|
|
||||||
const next = TRANSITIONS[phase][event] ?? phase;
|
|
||||||
return {
|
|
||||||
phase: next,
|
|
||||||
changed: next !== phase
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function allowedGameplayEvents(phase: GameplayPhase): GameplayPhaseEvent[] {
|
|
||||||
return Object.keys(TRANSITIONS[phase]) as GameplayPhaseEvent[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deriveGameplayPhase(session: SessionDetailResponse | null): GameplayPhase | null {
|
|
||||||
const status = session?.session.status;
|
|
||||||
if (!status) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === 'lie' || status === 'guess' || status === 'reveal') {
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === 'finished') {
|
|
||||||
return 'scoreboard';
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import lobbyCatalog from '../../../shared/i18n/lobby.json';
|
|
||||||
|
|
||||||
type FrontendErrorKey = keyof typeof lobbyCatalog.frontend.errors;
|
|
||||||
|
|
||||||
const frontendErrors = lobbyCatalog.frontend.errors;
|
|
||||||
|
|
||||||
const apiErrorMap: Record<string, FrontendErrorKey> = {
|
|
||||||
session_code_required: 'session_code_required',
|
|
||||||
session_not_found: 'session_not_found',
|
|
||||||
nickname_invalid: 'nickname_invalid',
|
|
||||||
nickname_taken: 'nickname_taken'
|
|
||||||
};
|
|
||||||
|
|
||||||
export function lobbyMessage(key: FrontendErrorKey): string {
|
|
||||||
return frontendErrors[key] ?? frontendErrors.unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function lobbyMessageFromApiPayload(payload: unknown, fallbackKey: FrontendErrorKey): string {
|
|
||||||
if (!payload || typeof payload !== 'object') {
|
|
||||||
return lobbyMessage(fallbackKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
const record = payload as Record<string, unknown>;
|
|
||||||
const code = typeof record.error_code === 'string' ? record.error_code : '';
|
|
||||||
const mappedKey = apiErrorMap[code];
|
|
||||||
if (!mappedKey) {
|
|
||||||
return lobbyMessage(fallbackKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
return lobbyMessage(mappedKey);
|
|
||||||
}
|
|
||||||
@@ -6,8 +6,6 @@ import {
|
|||||||
type SessionContextInput,
|
type SessionContextInput,
|
||||||
type SessionContextStore as PersistedSessionContextStore
|
type SessionContextStore as PersistedSessionContextStore
|
||||||
} from './session-context-store';
|
} from './session-context-store';
|
||||||
import { deriveGameplayPhase, type GameplayPhase } from './gameplay-phase-machine';
|
|
||||||
import { lobbyMessage, lobbyMessageFromApiPayload } from './lobby-i18n';
|
|
||||||
|
|
||||||
export type AsyncState = 'idle' | 'loading' | 'success' | 'error';
|
export type AsyncState = 'idle' | 'loading' | 'success' | 'error';
|
||||||
|
|
||||||
@@ -16,7 +14,6 @@ export type SessionContextStore = Pick<PersistedSessionContextStore, 'get' | 'se
|
|||||||
export interface VerticalSliceState {
|
export interface VerticalSliceState {
|
||||||
sessionCode: string;
|
sessionCode: string;
|
||||||
session: SessionDetailResponse | null;
|
session: SessionDetailResponse | null;
|
||||||
gameplayPhase: GameplayPhase | null;
|
|
||||||
joinState: AsyncState;
|
joinState: AsyncState;
|
||||||
startRoundState: AsyncState;
|
startRoundState: AsyncState;
|
||||||
loadingSession: boolean;
|
loadingSession: boolean;
|
||||||
@@ -39,7 +36,6 @@ export function createVerticalSliceController(
|
|||||||
const state: VerticalSliceState = {
|
const state: VerticalSliceState = {
|
||||||
sessionCode: persistedContext?.sessionCode ?? '',
|
sessionCode: persistedContext?.sessionCode ?? '',
|
||||||
session: null,
|
session: null,
|
||||||
gameplayPhase: null,
|
|
||||||
joinState: 'idle',
|
joinState: 'idle',
|
||||||
startRoundState: 'idle',
|
startRoundState: 'idle',
|
||||||
loadingSession: false,
|
loadingSession: false,
|
||||||
@@ -58,7 +54,7 @@ export function createVerticalSliceController(
|
|||||||
|
|
||||||
if (!state.sessionCode) {
|
if (!state.sessionCode) {
|
||||||
state.loadingSession = false;
|
state.loadingSession = false;
|
||||||
state.errorMessage = lobbyMessage('session_code_required');
|
state.errorMessage = 'Session-kode mangler.';
|
||||||
return { ...state };
|
return { ...state };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,13 +62,11 @@ export function createVerticalSliceController(
|
|||||||
state.loadingSession = false;
|
state.loadingSession = false;
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
state.errorMessage = lobbyMessageFromApiPayload(result.error.payload, 'session_fetch_failed');
|
state.errorMessage = 'Kunne ikke hente lobby-status.';
|
||||||
state.gameplayPhase = null;
|
|
||||||
return { ...state };
|
return { ...state };
|
||||||
}
|
}
|
||||||
|
|
||||||
state.session = result.data;
|
state.session = result.data;
|
||||||
state.gameplayPhase = deriveGameplayPhase(result.data);
|
|
||||||
state.sessionCode = normalizeCode(result.data.session.code);
|
state.sessionCode = normalizeCode(result.data.session.code);
|
||||||
|
|
||||||
if (persistedContext && state.sessionCode === normalizeCode(persistedContext.sessionCode)) {
|
if (persistedContext && state.sessionCode === normalizeCode(persistedContext.sessionCode)) {
|
||||||
@@ -93,7 +87,7 @@ export function createVerticalSliceController(
|
|||||||
const join = await api.joinSession({ code: requestCode, nickname });
|
const join = await api.joinSession({ code: requestCode, nickname });
|
||||||
if (!join.ok) {
|
if (!join.ok) {
|
||||||
state.joinState = 'error';
|
state.joinState = 'error';
|
||||||
state.errorMessage = lobbyMessageFromApiPayload(join.error.payload, 'join_failed');
|
state.errorMessage = 'Join fejlede. Tjek kode eller nickname og prøv igen.';
|
||||||
return { ...state };
|
return { ...state };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,14 +114,14 @@ export function createVerticalSliceController(
|
|||||||
|
|
||||||
if (!codeToUse) {
|
if (!codeToUse) {
|
||||||
state.startRoundState = 'error';
|
state.startRoundState = 'error';
|
||||||
state.errorMessage = lobbyMessage('session_code_required');
|
state.errorMessage = 'Session-kode mangler.';
|
||||||
return { ...state };
|
return { ...state };
|
||||||
}
|
}
|
||||||
|
|
||||||
const start = await api.startRound(codeToUse, { category_slug: categorySlug });
|
const start = await api.startRound(codeToUse, { category_slug: categorySlug });
|
||||||
if (!start.ok) {
|
if (!start.ok) {
|
||||||
state.startRoundState = 'error';
|
state.startRoundState = 'error';
|
||||||
state.errorMessage = lobbyMessageFromApiPayload(start.error.payload, 'start_round_failed');
|
state.errorMessage = 'Kunne ikke starte runden. Opdatér lobbyen og prøv igen.';
|
||||||
return { ...state };
|
return { ...state };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import {
|
|
||||||
allowedGameplayEvents,
|
|
||||||
deriveGameplayPhase,
|
|
||||||
transitionGameplayPhase,
|
|
||||||
type GameplayPhase
|
|
||||||
} from '../src/spa/gameplay-phase-machine';
|
|
||||||
|
|
||||||
describe('gameplay phase machine skeleton', () => {
|
|
||||||
it('supports canonical phase progression lie -> guess -> reveal -> scoreboard -> lie', () => {
|
|
||||||
let phase: GameplayPhase = 'lie';
|
|
||||||
|
|
||||||
phase = transitionGameplayPhase(phase, 'LIES_LOCKED').phase;
|
|
||||||
expect(phase).toBe('guess');
|
|
||||||
|
|
||||||
phase = transitionGameplayPhase(phase, 'GUESSES_LOCKED').phase;
|
|
||||||
expect(phase).toBe('reveal');
|
|
||||||
|
|
||||||
phase = transitionGameplayPhase(phase, 'SCOREBOARD_READY').phase;
|
|
||||||
expect(phase).toBe('scoreboard');
|
|
||||||
|
|
||||||
phase = transitionGameplayPhase(phase, 'NEXT_ROUND').phase;
|
|
||||||
expect(phase).toBe('lie');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps state unchanged for invalid transition events', () => {
|
|
||||||
const transition = transitionGameplayPhase('lie', 'NEXT_ROUND');
|
|
||||||
expect(transition.phase).toBe('lie');
|
|
||||||
expect(transition.changed).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('exposes allowed events per phase', () => {
|
|
||||||
expect(allowedGameplayEvents('guess')).toEqual(['GUESSES_LOCKED']);
|
|
||||||
expect(allowedGameplayEvents('scoreboard')).toEqual(['NEXT_ROUND']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('derives gameplay phase from session detail status', () => {
|
|
||||||
expect(
|
|
||||||
deriveGameplayPhase({
|
|
||||||
session: { code: 'ABCD12', status: 'lie', host_id: 1, current_round: 1, players_count: 3 },
|
|
||||||
players: [],
|
|
||||||
round_question: null,
|
|
||||||
phase_view_model: {
|
|
||||||
status: 'lie',
|
|
||||||
round_number: 1,
|
|
||||||
players_count: 3,
|
|
||||||
constraints: {
|
|
||||||
min_players_to_start: 3,
|
|
||||||
max_players_mvp: 5,
|
|
||||||
min_players_reached: true,
|
|
||||||
max_players_allowed: true
|
|
||||||
},
|
|
||||||
host: {
|
|
||||||
can_start_round: false,
|
|
||||||
can_show_question: true,
|
|
||||||
can_mix_answers: true,
|
|
||||||
can_calculate_scores: false,
|
|
||||||
can_reveal_scoreboard: false,
|
|
||||||
can_start_next_round: false,
|
|
||||||
can_finish_game: false
|
|
||||||
},
|
|
||||||
player: {
|
|
||||||
can_join: false,
|
|
||||||
can_submit_lie: true,
|
|
||||||
can_submit_guess: false,
|
|
||||||
can_view_final_result: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
).toBe('lie');
|
|
||||||
|
|
||||||
expect(
|
|
||||||
deriveGameplayPhase({
|
|
||||||
session: { code: 'ABCD12', status: 'finished', host_id: 1, current_round: 1, players_count: 3 },
|
|
||||||
players: [],
|
|
||||||
round_question: null,
|
|
||||||
phase_view_model: {
|
|
||||||
status: 'finished',
|
|
||||||
round_number: 1,
|
|
||||||
players_count: 3,
|
|
||||||
constraints: {
|
|
||||||
min_players_to_start: 3,
|
|
||||||
max_players_mvp: 5,
|
|
||||||
min_players_reached: true,
|
|
||||||
max_players_allowed: true
|
|
||||||
},
|
|
||||||
host: {
|
|
||||||
can_start_round: false,
|
|
||||||
can_show_question: false,
|
|
||||||
can_mix_answers: false,
|
|
||||||
can_calculate_scores: false,
|
|
||||||
can_reveal_scoreboard: false,
|
|
||||||
can_start_next_round: false,
|
|
||||||
can_finish_game: false
|
|
||||||
},
|
|
||||||
player: {
|
|
||||||
can_join: false,
|
|
||||||
can_submit_lie: false,
|
|
||||||
can_submit_guess: false,
|
|
||||||
can_view_final_result: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
).toBe('scoreboard');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -105,7 +105,6 @@ describe('vertical slice controller: lobby -> join -> start round', () => {
|
|||||||
vi.doUnmock('../src/spa/session-context-store');
|
vi.doUnmock('../src/spa/session-context-store');
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tracks loading and success state for join + start flow', async () => {
|
it('tracks loading and success state for join + start flow', async () => {
|
||||||
const api = makeApiMock();
|
const api = makeApiMock();
|
||||||
const controller = createVerticalSliceController(api);
|
const controller = createVerticalSliceController(api);
|
||||||
@@ -161,7 +160,7 @@ describe('vertical slice controller: lobby -> join -> start round', () => {
|
|||||||
joinSession: vi.fn().mockResolvedValue({
|
joinSession: vi.fn().mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 404,
|
status: 404,
|
||||||
error: { kind: 'http', status: 404, message: 'HTTP 404', payload: { error: 'Session not found', error_code: 'session_not_found' } }
|
error: { kind: 'http', status: 404, message: 'HTTP 404', payload: { error: 'Session not found' } }
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -170,7 +169,7 @@ describe('vertical slice controller: lobby -> join -> start round', () => {
|
|||||||
|
|
||||||
const state = controller.getState();
|
const state = controller.getState();
|
||||||
expect(state.joinState).toBe('error');
|
expect(state.joinState).toBe('error');
|
||||||
expect(state.errorMessage).toBe('Session code is invalid or the session no longer exists.');
|
expect(state.errorMessage).toContain('Join fejlede');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('surfaces a friendly error when round start fails', async () => {
|
it('surfaces a friendly error when round start fails', async () => {
|
||||||
@@ -178,7 +177,7 @@ describe('vertical slice controller: lobby -> join -> start round', () => {
|
|||||||
startRound: vi.fn().mockResolvedValue({
|
startRound: vi.fn().mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 400,
|
status: 400,
|
||||||
error: { kind: 'http', status: 400, message: 'HTTP 400', payload: { error: 'Round can only be started from lobby', error_code: 'round_start_invalid_phase' } }
|
error: { kind: 'http', status: 400, message: 'HTTP 400', payload: { error: 'Round can only be started from lobby' } }
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -187,7 +186,7 @@ describe('vertical slice controller: lobby -> join -> start round', () => {
|
|||||||
|
|
||||||
const state = controller.getState();
|
const state = controller.getState();
|
||||||
expect(state.startRoundState).toBe('error');
|
expect(state.startRoundState).toBe('error');
|
||||||
expect(state.errorMessage).toBe('Could not start round. Refresh the lobby and try again.');
|
expect(state.errorMessage).toContain('Kunne ikke starte runden');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows local validation error and avoids API call when hydrating without any session code', async () => {
|
it('shows local validation error and avoids API call when hydrating without any session code', async () => {
|
||||||
@@ -197,7 +196,7 @@ describe('vertical slice controller: lobby -> join -> start round', () => {
|
|||||||
await controller.hydrateLobby(' ');
|
await controller.hydrateLobby(' ');
|
||||||
|
|
||||||
const state = controller.getState();
|
const state = controller.getState();
|
||||||
expect(state.errorMessage).toBe('Session code is required.');
|
expect(state.errorMessage).toBe('Session-kode mangler.');
|
||||||
expect(state.loadingSession).toBe(false);
|
expect(state.loadingSession).toBe(false);
|
||||||
expect(api.getSession).not.toHaveBeenCalled();
|
expect(api.getSession).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -210,7 +209,7 @@ describe('vertical slice controller: lobby -> join -> start round', () => {
|
|||||||
|
|
||||||
const state = controller.getState();
|
const state = controller.getState();
|
||||||
expect(state.startRoundState).toBe('error');
|
expect(state.startRoundState).toBe('error');
|
||||||
expect(state.errorMessage).toBe('Session code is required.');
|
expect(state.errorMessage).toBe('Session-kode mangler.');
|
||||||
expect(api.startRound).not.toHaveBeenCalled();
|
expect(api.startRound).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,10 @@
|
|||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "ES2022",
|
"module": "ES2022",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"lib": ["ES2022", "DOM"],
|
"lib": ["ES2022", "DOM"],
|
||||||
"types": ["vitest/globals", "node"]
|
"types": ["vitest/globals", "node"]
|
||||||
},
|
},
|
||||||
"include": ["src", "tests", "../shared/i18n/*.json"]
|
"include": ["src", "tests"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
import json
|
|
||||||
from functools import lru_cache
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from django.http import JsonResponse
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
|
||||||
def lobby_i18n_catalog() -> dict:
|
|
||||||
catalog_path = Path(__file__).resolve().parents[1] / "shared" / "i18n" / "lobby.json"
|
|
||||||
with catalog_path.open(encoding="utf-8") as handle:
|
|
||||||
return json.load(handle)
|
|
||||||
|
|
||||||
|
|
||||||
def lobby_i18n_errors() -> dict:
|
|
||||||
return lobby_i18n_catalog().get("backend", {}).get("error_codes", {})
|
|
||||||
|
|
||||||
|
|
||||||
def api_error(*, code: str, message: str, status: int) -> JsonResponse:
|
|
||||||
return JsonResponse({"error": message, "error_code": code}, status=status)
|
|
||||||
@@ -5,7 +5,6 @@ from django.shortcuts import render
|
|||||||
from fupogfakta.models import Category
|
from fupogfakta.models import Category
|
||||||
|
|
||||||
from .feature_flags import use_spa_ui
|
from .feature_flags import use_spa_ui
|
||||||
from .i18n import lobby_i18n_catalog
|
|
||||||
|
|
||||||
|
|
||||||
def _render_spa_shell(request, shell_route: str, shell_kind: str):
|
def _render_spa_shell(request, shell_route: str, shell_kind: str):
|
||||||
@@ -16,7 +15,6 @@ def _render_spa_shell(request, shell_route: str, shell_kind: str):
|
|||||||
"shell_route": shell_route,
|
"shell_route": shell_route,
|
||||||
"shell_kind": shell_kind,
|
"shell_kind": shell_kind,
|
||||||
"spa_asset_base": settings.WPP_SPA_ASSET_BASE,
|
"spa_asset_base": settings.WPP_SPA_ASSET_BASE,
|
||||||
"lobby_i18n": lobby_i18n_catalog(),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,18 +30,11 @@ def host_screen(request, spa_path=None):
|
|||||||
return _render_spa_shell(request, host_route, "host")
|
return _render_spa_shell(request, host_route, "host")
|
||||||
|
|
||||||
categories = Category.objects.filter(is_active=True).order_by("name")
|
categories = Category.objects.filter(is_active=True).order_by("name")
|
||||||
return render(
|
return render(request, "lobby/host_screen.html", {"categories": categories})
|
||||||
request,
|
|
||||||
"lobby/host_screen.html",
|
|
||||||
{
|
|
||||||
"categories": categories,
|
|
||||||
"lobby_i18n": lobby_i18n_catalog(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def player_screen(request):
|
def player_screen(request):
|
||||||
if use_spa_ui():
|
if use_spa_ui():
|
||||||
return _render_spa_shell(request, "/player", "player")
|
return _render_spa_shell(request, "/player", "player")
|
||||||
|
|
||||||
return render(request, "lobby/player_screen.html", {"lobby_i18n": lobby_i18n_catalog()})
|
return render(request, "lobby/player_screen.html")
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ from fupogfakta.models import (
|
|||||||
ScoreEvent,
|
ScoreEvent,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .i18n import api_error, lobby_i18n_errors
|
|
||||||
|
|
||||||
SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
SESSION_CODE_LENGTH = 6
|
SESSION_CODE_LENGTH = 6
|
||||||
MAX_CODE_GENERATION_ATTEMPTS = 20
|
MAX_CODE_GENERATION_ATTEMPTS = 20
|
||||||
@@ -31,7 +29,6 @@ JOINABLE_STATUSES = {
|
|||||||
GameSession.Status.GUESS,
|
GameSession.Status.GUESS,
|
||||||
GameSession.Status.REVEAL,
|
GameSession.Status.REVEAL,
|
||||||
}
|
}
|
||||||
ERROR_CODES = lobby_i18n_errors()
|
|
||||||
|
|
||||||
|
|
||||||
def _json_body(request: HttpRequest) -> dict:
|
def _json_body(request: HttpRequest) -> dict:
|
||||||
@@ -127,41 +124,21 @@ def join_session(request: HttpRequest) -> JsonResponse:
|
|||||||
nickname = str(payload.get("nickname", "")).strip()
|
nickname = str(payload.get("nickname", "")).strip()
|
||||||
|
|
||||||
if not code:
|
if not code:
|
||||||
return api_error(
|
return JsonResponse({"error": "Session code is required"}, status=400)
|
||||||
code=ERROR_CODES.get("session_code_required", "session_code_required"),
|
|
||||||
message="Session code is required",
|
|
||||||
status=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(nickname) < 2 or len(nickname) > 40:
|
if len(nickname) < 2 or len(nickname) > 40:
|
||||||
return api_error(
|
return JsonResponse({"error": "Nickname must be between 2 and 40 characters"}, status=400)
|
||||||
code=ERROR_CODES.get("nickname_invalid", "nickname_invalid"),
|
|
||||||
message="Nickname must be between 2 and 40 characters",
|
|
||||||
status=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
session = GameSession.objects.get(code=code)
|
session = GameSession.objects.get(code=code)
|
||||||
except GameSession.DoesNotExist:
|
except GameSession.DoesNotExist:
|
||||||
return api_error(
|
return JsonResponse({"error": "Session not found"}, status=404)
|
||||||
code=ERROR_CODES.get("session_not_found", "session_not_found"),
|
|
||||||
message="Session not found",
|
|
||||||
status=404,
|
|
||||||
)
|
|
||||||
|
|
||||||
if session.status not in JOINABLE_STATUSES:
|
if session.status not in JOINABLE_STATUSES:
|
||||||
return api_error(
|
return JsonResponse({"error": "Session is not joinable"}, status=400)
|
||||||
code=ERROR_CODES.get("session_not_joinable", "session_not_joinable"),
|
|
||||||
message="Session is not joinable",
|
|
||||||
status=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
if Player.objects.filter(session=session, nickname__iexact=nickname).exists():
|
if Player.objects.filter(session=session, nickname__iexact=nickname).exists():
|
||||||
return api_error(
|
return JsonResponse({"error": "Nickname already taken"}, status=409)
|
||||||
code=ERROR_CODES.get("nickname_taken", "nickname_taken"),
|
|
||||||
message="Nickname already taken",
|
|
||||||
status=409,
|
|
||||||
)
|
|
||||||
|
|
||||||
player = Player.objects.create(session=session, nickname=nickname)
|
player = Player.objects.create(session=session, nickname=nickname)
|
||||||
|
|
||||||
@@ -189,11 +166,7 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
try:
|
try:
|
||||||
session = GameSession.objects.get(code=session_code)
|
session = GameSession.objects.get(code=session_code)
|
||||||
except GameSession.DoesNotExist:
|
except GameSession.DoesNotExist:
|
||||||
return api_error(
|
return JsonResponse({"error": "Session not found"}, status=404)
|
||||||
code=ERROR_CODES.get("session_not_found", "session_not_found"),
|
|
||||||
message="Session not found",
|
|
||||||
status=404,
|
|
||||||
)
|
|
||||||
|
|
||||||
players = list(
|
players = list(
|
||||||
session.players.order_by("nickname").values(
|
session.players.order_by("nickname").values(
|
||||||
@@ -250,41 +223,25 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
category_slug = str(payload.get("category_slug", "")).strip()
|
category_slug = str(payload.get("category_slug", "")).strip()
|
||||||
|
|
||||||
if not category_slug:
|
if not category_slug:
|
||||||
return api_error(
|
return JsonResponse({"error": "category_slug is required"}, status=400)
|
||||||
code=ERROR_CODES.get("category_slug_required", "category_slug_required"),
|
|
||||||
message="category_slug is required",
|
|
||||||
status=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
session_code = _normalize_session_code(code)
|
session_code = _normalize_session_code(code)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
session = GameSession.objects.get(code=session_code)
|
session = GameSession.objects.get(code=session_code)
|
||||||
except GameSession.DoesNotExist:
|
except GameSession.DoesNotExist:
|
||||||
return api_error(
|
return JsonResponse({"error": "Session not found"}, status=404)
|
||||||
code=ERROR_CODES.get("session_not_found", "session_not_found"),
|
|
||||||
message="Session not found",
|
|
||||||
status=404,
|
|
||||||
)
|
|
||||||
|
|
||||||
if session.host_id != request.user.id:
|
if session.host_id != request.user.id:
|
||||||
return JsonResponse({"error": "Only host can start round"}, status=403)
|
return JsonResponse({"error": "Only host can start round"}, status=403)
|
||||||
|
|
||||||
if session.status != GameSession.Status.LOBBY:
|
if session.status != GameSession.Status.LOBBY:
|
||||||
return api_error(
|
return JsonResponse({"error": "Round can only be started from lobby"}, status=400)
|
||||||
code=ERROR_CODES.get("round_start_invalid_phase", "round_start_invalid_phase"),
|
|
||||||
message="Round can only be started from lobby",
|
|
||||||
status=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
category = Category.objects.get(slug=category_slug, is_active=True)
|
category = Category.objects.get(slug=category_slug, is_active=True)
|
||||||
except Category.DoesNotExist:
|
except Category.DoesNotExist:
|
||||||
return api_error(
|
return JsonResponse({"error": "Category not found"}, status=404)
|
||||||
code=ERROR_CODES.get("category_not_found", "category_not_found"),
|
|
||||||
message="Category not found",
|
|
||||||
status=404,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not Question.objects.filter(category=category, is_active=True).exists():
|
if not Question.objects.filter(category=category, is_active=True).exists():
|
||||||
return JsonResponse({"error": "Category has no active questions"}, status=400)
|
return JsonResponse({"error": "Category has no active questions"}, status=400)
|
||||||
@@ -292,11 +249,7 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
session = GameSession.objects.select_for_update().get(pk=session.pk)
|
session = GameSession.objects.select_for_update().get(pk=session.pk)
|
||||||
if session.status != GameSession.Status.LOBBY:
|
if session.status != GameSession.Status.LOBBY:
|
||||||
return api_error(
|
return JsonResponse({"error": "Round can only be started from lobby"}, status=400)
|
||||||
code=ERROR_CODES.get("round_start_invalid_phase", "round_start_invalid_phase"),
|
|
||||||
message="Round can only be started from lobby",
|
|
||||||
status=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
round_config, created = RoundConfig.objects.get_or_create(
|
round_config, created = RoundConfig.objects.get_or_create(
|
||||||
session=session,
|
session=session,
|
||||||
@@ -304,11 +257,7 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
defaults={"category": category},
|
defaults={"category": category},
|
||||||
)
|
)
|
||||||
if not created:
|
if not created:
|
||||||
return api_error(
|
return JsonResponse({"error": "Round already configured"}, status=409)
|
||||||
code=ERROR_CODES.get("round_already_configured", "round_already_configured"),
|
|
||||||
message="Round already configured",
|
|
||||||
status=409,
|
|
||||||
)
|
|
||||||
|
|
||||||
session.status = GameSession.Status.LIE
|
session.status = GameSession.Status.LIE
|
||||||
session.save(update_fields=["status"])
|
session.save(update_fields=["status"])
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"frontend": {
|
|
||||||
"errors": {
|
|
||||||
"session_code_required": "Session code is required.",
|
|
||||||
"session_fetch_failed": "Could not load lobby status.",
|
|
||||||
"join_failed": "Join failed. Check code or nickname and try again.",
|
|
||||||
"start_round_failed": "Could not start round. Refresh the lobby and try again.",
|
|
||||||
"session_not_found": "Session code is invalid or the session no longer exists.",
|
|
||||||
"nickname_invalid": "Nickname must be between 2 and 40 characters.",
|
|
||||||
"nickname_taken": "Nickname is already taken.",
|
|
||||||
"unknown": "Action failed. Refresh status and try again."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"backend": {
|
|
||||||
"error_codes": {
|
|
||||||
"session_code_required": "session_code_required",
|
|
||||||
"nickname_invalid": "nickname_invalid",
|
|
||||||
"session_not_found": "session_not_found",
|
|
||||||
"session_not_joinable": "session_not_joinable",
|
|
||||||
"nickname_taken": "nickname_taken",
|
|
||||||
"category_slug_required": "category_slug_required",
|
|
||||||
"category_not_found": "category_not_found",
|
|
||||||
"round_start_invalid_phase": "round_start_invalid_phase",
|
|
||||||
"round_already_configured": "round_already_configured"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user