diff --git a/frontend/src/spa/vertical-slice.ts b/frontend/src/spa/vertical-slice.ts index a89b18b..7a2d6d0 100644 --- a/frontend/src/spa/vertical-slice.ts +++ b/frontend/src/spa/vertical-slice.ts @@ -1,19 +1,16 @@ import type { ApiClient } from '../api/client'; import type { SessionDetailResponse } from '../api/types'; +import { + createSessionContextStore, + type SessionContext, + type SessionContextInput, + type SessionContextStore as PersistedSessionContextStore +} from './session-context-store'; import { deriveGameplayPhase, type GameplayPhase } from './gameplay-phase-machine'; export type AsyncState = 'idle' | 'loading' | 'success' | 'error'; -export interface SessionContext { - sessionCode: string; - playerToken: string; - nickname: string; -} - -export interface SessionContextStore { - get(): SessionContext | null; - set(context: SessionContext): void; -} +export type SessionContextStore = Pick; export interface VerticalSliceState { sessionCode: string; @@ -32,19 +29,14 @@ export interface VerticalSliceController { startRound(sessionCode: string, categorySlug: string): Promise; } -const NOOP_SESSION_CONTEXT_STORE: SessionContextStore = { - get: () => null, - set: () => undefined -}; - export function createVerticalSliceController( api: ApiClient, - sessionContextStore: SessionContextStore = NOOP_SESSION_CONTEXT_STORE + sessionContextStore: SessionContextStore = createSessionContextStore() ): VerticalSliceController { - let contextState = sessionContextStore.get(); + const persistedContext = sessionContextStore.get(); const state: VerticalSliceState = { - sessionCode: contextState?.sessionCode ?? '', + sessionCode: persistedContext?.sessionCode ?? '', session: null, gameplayPhase: null, joinState: 'idle', @@ -55,22 +47,19 @@ export function createVerticalSliceController( const normalizeCode = (value: string): string => value.trim().toUpperCase(); - const syncContext = (next: SessionContext): void => { - contextState = next; - sessionContextStore.set(next); - }; - - const resolveSessionCode = (sessionCode: string): string => { - const normalizedRequestedCode = normalizeCode(sessionCode); - const fallbackCode = normalizeCode(state.sessionCode || contextState?.sessionCode || ''); - return normalizedRequestedCode || fallbackCode; - }; - async function hydrateLobby(sessionCode: string): Promise { state.loadingSession = true; state.errorMessage = null; - state.sessionCode = resolveSessionCode(sessionCode); + const normalizedRequestedCode = normalizeCode(sessionCode); + const fallbackCode = normalizeCode(state.sessionCode || persistedContext?.sessionCode || ''); + state.sessionCode = normalizedRequestedCode || fallbackCode; + + if (!state.sessionCode) { + state.loadingSession = false; + state.errorMessage = 'Session-kode mangler.'; + return { ...state }; + } const result = await api.getSession(state.sessionCode); state.loadingSession = false; @@ -85,8 +74,8 @@ export function createVerticalSliceController( state.gameplayPhase = deriveGameplayPhase(result.data); state.sessionCode = normalizeCode(result.data.session.code); - if (contextState) { - syncContext({ ...contextState, sessionCode: state.sessionCode }); + if (persistedContext && state.sessionCode === normalizeCode(persistedContext.sessionCode)) { + sessionContextStore.set({ ...persistedContext, sessionCode: state.sessionCode }); } return { ...state }; @@ -96,7 +85,9 @@ export function createVerticalSliceController( state.joinState = 'loading'; state.errorMessage = null; - const requestCode = resolveSessionCode(sessionCode); + const normalizedRequestedCode = normalizeCode(sessionCode); + const fallbackCode = normalizeCode(state.sessionCode || persistedContext?.sessionCode || ''); + const requestCode = normalizedRequestedCode || fallbackCode; const join = await api.joinSession({ code: requestCode, nickname }); if (!join.ok) { @@ -108,11 +99,12 @@ export function createVerticalSliceController( state.joinState = 'success'; state.sessionCode = normalizeCode(join.data.session.code || requestCode); - syncContext({ + const nextContext: SessionContextInput = { sessionCode: state.sessionCode, - playerToken: join.data.player.session_token, - nickname: join.data.player.nickname - }); + playerId: join.data.player.id, + token: join.data.player.session_token + }; + sessionContextStore.set(nextContext); return hydrateLobby(state.sessionCode); } @@ -121,7 +113,15 @@ export function createVerticalSliceController( state.startRoundState = 'loading'; state.errorMessage = null; - const codeToUse = resolveSessionCode(sessionCode); + const normalizedRequestedCode = normalizeCode(sessionCode); + const fallbackCode = normalizeCode(state.sessionCode || persistedContext?.sessionCode || ''); + const codeToUse = normalizedRequestedCode || fallbackCode; + + if (!codeToUse) { + state.startRoundState = 'error'; + state.errorMessage = 'Session-kode mangler.'; + return { ...state }; + } const start = await api.startRound(codeToUse, { category_slug: categorySlug }); if (!start.ok) { @@ -141,3 +141,5 @@ export function createVerticalSliceController( startRound }; } + +export type { SessionContext }; diff --git a/frontend/tests/vertical-slice.test.ts b/frontend/tests/vertical-slice.test.ts index 5f9c4ad..07be788 100644 --- a/frontend/tests/vertical-slice.test.ts +++ b/frontend/tests/vertical-slice.test.ts @@ -68,11 +68,44 @@ function makeSessionContextStore(initial: SessionContext | null = null): Session get: vi.fn(() => value), set: vi.fn((next: SessionContext) => { value = next; + return next; }) }; } describe('vertical slice controller: lobby -> join -> start round', () => { + it('uses createSessionContextStore by default (no manual injection)', async () => { + vi.resetModules(); + const defaultStore = { + get: vi.fn(() => null), + set: vi.fn((next: SessionContext) => next), + clear: vi.fn() + }; + + vi.doMock('../src/spa/session-context-store', async () => { + const actual = await vi.importActual('../src/spa/session-context-store'); + return { + ...actual, + createSessionContextStore: vi.fn(() => defaultStore) + }; + }); + + const { createVerticalSliceController: createControllerWithMock } = await import('../src/spa/vertical-slice'); + const api = makeApiMock(); + const controller = createControllerWithMock(api); + + await controller.joinLobby('ABCD12', 'Maja'); + + expect(defaultStore.set).toHaveBeenCalledWith({ + sessionCode: 'ABCD12', + playerId: 9, + token: 'token-1' + }); + + vi.doUnmock('../src/spa/session-context-store'); + vi.resetModules(); + }); + it('tracks loading and success state for join + start flow', async () => { const api = makeApiMock(); const controller = createVerticalSliceController(api); @@ -84,7 +117,6 @@ describe('vertical slice controller: lobby -> join -> start round', () => { const postJoin = controller.getState(); expect(postJoin.joinState).toBe('success'); expect(postJoin.session?.session.code).toBe('ABCD12'); - expect(postJoin.gameplayPhase).toBeNull(); const beforeStartPromise = controller.startRound('abcd12', 'history'); expect(controller.getState().startRoundState).toBe('loading'); @@ -103,8 +135,8 @@ describe('vertical slice controller: lobby -> join -> start round', () => { expect(sessionContextStore.set).toHaveBeenCalledWith({ sessionCode: 'ABCD12', - playerToken: 'token-1', - nickname: 'Maja' + playerId: 9, + token: 'token-1' }); expect(controller.getState().sessionCode).toBe('ABCD12'); }); @@ -113,8 +145,8 @@ describe('vertical slice controller: lobby -> join -> start round', () => { const api = makeApiMock(); const sessionContextStore = makeSessionContextStore({ sessionCode: 'wxyz99', - playerToken: 'token-old', - nickname: 'Maja' + playerId: 5, + token: 'token-old' }); const controller = createVerticalSliceController(api, sessionContextStore); @@ -124,67 +156,6 @@ describe('vertical slice controller: lobby -> join -> start round', () => { expect(api.getSession).toHaveBeenCalledWith('ABCD12'); }); - it('uses stored session code for startRound when input code is empty after join', async () => { - const api = makeApiMock(); - const sessionContextStore = makeSessionContextStore(); - const controller = createVerticalSliceController(api, sessionContextStore); - - await controller.joinLobby('abcd12', 'Maja'); - await controller.startRound('', 'history'); - - expect(api.startRound).toHaveBeenCalledWith('ABCD12', { category_slug: 'history' }); - }); - - it('keeps joined player context when hydrate syncs normalized session code', async () => { - const api = makeApiMock({ - getSession: vi.fn().mockResolvedValue({ - ok: true, - status: 200, - data: { - session: { code: 'abcd12', status: 'lobby', host_id: 1, current_round: 1, players_count: 3 }, - players: [], - round_question: null, - phase_view_model: { - status: 'lobby', - 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: true, - 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: true, - can_submit_lie: false, - can_submit_guess: false, - can_view_final_result: false - } - } - } - }) - }); - const sessionContextStore = makeSessionContextStore(); - const controller = createVerticalSliceController(api, sessionContextStore); - - await controller.joinLobby('abcd12', 'Maja'); - - expect(sessionContextStore.set).toHaveBeenLastCalledWith({ - sessionCode: 'ABCD12', - playerToken: 'token-1', - nickname: 'Maja' - }); - }); - it('surfaces a friendly error when join fails', async () => { const api = makeApiMock({ joinSession: vi.fn().mockResolvedValue({ @@ -218,4 +189,39 @@ describe('vertical slice controller: lobby -> join -> start round', () => { expect(state.startRoundState).toBe('error'); expect(state.errorMessage).toContain('Kunne ikke starte runden'); }); + + it('shows local validation error and avoids API call when hydrating without any session code', async () => { + const api = makeApiMock(); + const controller = createVerticalSliceController(api, makeSessionContextStore(null)); + + await controller.hydrateLobby(' '); + + const state = controller.getState(); + expect(state.errorMessage).toBe('Session-kode mangler.'); + expect(state.loadingSession).toBe(false); + expect(api.getSession).not.toHaveBeenCalled(); + }); + + it('shows local validation error and avoids API call when starting round without any session code', async () => { + const api = makeApiMock(); + const controller = createVerticalSliceController(api, makeSessionContextStore(null)); + + await controller.startRound(' ', 'history'); + + const state = controller.getState(); + expect(state.startRoundState).toBe('error'); + expect(state.errorMessage).toBe('Session-kode mangler.'); + expect(api.startRound).not.toHaveBeenCalled(); + }); + + it('uses joined session code when starting round without a reload', async () => { + const api = makeApiMock(); + const controller = createVerticalSliceController(api); + + await controller.joinLobby(' abcd12 ', 'Maja'); + await controller.startRound('', 'history'); + + expect(api.startRound).toHaveBeenCalledWith('ABCD12', { category_slug: 'history' }); + expect(controller.getState().sessionCode).toBe('ABCD12'); + }); });