238 lines
7.9 KiB
TypeScript
238 lines
7.9 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
createVerticalSliceController,
|
|
type SessionContext,
|
|
type SessionContextStore
|
|
} from '../src/spa/vertical-slice';
|
|
import type { ApiClient } from '../src/api/client';
|
|
|
|
function makeApiMock(overrides?: Partial<ApiClient>): ApiClient {
|
|
const base: ApiClient = {
|
|
health: vi.fn(),
|
|
createSession: vi.fn(),
|
|
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,
|
|
reveal: 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
|
|
}
|
|
}
|
|
}
|
|
}),
|
|
joinSession: vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
status: 201,
|
|
data: { player: { id: 9, nickname: 'Maja', session_token: 'token-1', score: 0 }, session: { code: 'ABCD12', status: 'lobby' } }
|
|
}),
|
|
startRound: vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
status: 201,
|
|
data: {
|
|
session: { code: 'ABCD12', status: 'lie', current_round: 1 },
|
|
round: { number: 1, category: { slug: 'history', name: 'History' } }
|
|
}
|
|
}),
|
|
showQuestion: vi.fn(),
|
|
mixAnswers: vi.fn(),
|
|
calculateScores: vi.fn(),
|
|
getScoreboard: vi.fn(),
|
|
startNextRound: vi.fn(),
|
|
finishGame: vi.fn(),
|
|
submitLie: vi.fn(),
|
|
submitGuess: vi.fn()
|
|
};
|
|
|
|
return { ...base, ...overrides };
|
|
}
|
|
|
|
function makeSessionContextStore(initial: SessionContext | null = null): SessionContextStore {
|
|
let value = initial;
|
|
return {
|
|
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<typeof import('../src/spa/session-context-store')>('../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);
|
|
|
|
const beforeJoinPromise = controller.joinLobby('abcd12', 'Maja');
|
|
expect(controller.getState().joinState).toBe('loading');
|
|
await beforeJoinPromise;
|
|
|
|
const postJoin = controller.getState();
|
|
expect(postJoin.joinState).toBe('success');
|
|
expect(postJoin.session?.session.code).toBe('ABCD12');
|
|
|
|
const beforeStartPromise = controller.startRound('abcd12', 'history');
|
|
expect(controller.getState().startRoundState).toBe('loading');
|
|
await beforeStartPromise;
|
|
|
|
const postStart = controller.getState();
|
|
expect(postStart.startRoundState).toBe('success');
|
|
});
|
|
|
|
it('persists session context after join and syncs normalized session code', async () => {
|
|
const api = makeApiMock();
|
|
const sessionContextStore = makeSessionContextStore();
|
|
const controller = createVerticalSliceController(api, sessionContextStore);
|
|
|
|
await controller.joinLobby('abcd12', 'Maja');
|
|
|
|
expect(sessionContextStore.set).toHaveBeenCalledWith({
|
|
sessionCode: 'ABCD12',
|
|
playerId: 9,
|
|
token: 'token-1'
|
|
});
|
|
expect(controller.getState().sessionCode).toBe('ABCD12');
|
|
});
|
|
|
|
it('uses stored session code as fallback for join + hydrate flow when input code is empty', async () => {
|
|
const api = makeApiMock();
|
|
const sessionContextStore = makeSessionContextStore({
|
|
sessionCode: 'wxyz99',
|
|
playerId: 5,
|
|
token: 'token-old'
|
|
});
|
|
const controller = createVerticalSliceController(api, sessionContextStore);
|
|
|
|
await controller.joinLobby(' ', 'Maja');
|
|
|
|
expect(api.joinSession).toHaveBeenCalledWith({ code: 'WXYZ99', nickname: 'Maja' });
|
|
expect(api.getSession).toHaveBeenCalledWith('ABCD12');
|
|
});
|
|
|
|
it('surfaces a friendly error when join fails', async () => {
|
|
const api = makeApiMock({
|
|
joinSession: vi.fn().mockResolvedValue({
|
|
ok: false,
|
|
status: 404,
|
|
error: { kind: 'http', status: 404, message: 'HTTP 404', payload: { error: 'Session not found', error_code: 'session_not_found' } }
|
|
})
|
|
});
|
|
|
|
const controller = createVerticalSliceController(api);
|
|
await controller.joinLobby('missing', 'Maja');
|
|
|
|
const state = controller.getState();
|
|
expect(state.joinState).toBe('error');
|
|
expect(state.errorMessage).toBe('Session code is invalid or the session no longer exists.');
|
|
});
|
|
|
|
it('surfaces a friendly error when round start fails', async () => {
|
|
const api = makeApiMock({
|
|
startRound: vi.fn().mockResolvedValue({
|
|
ok: false,
|
|
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' } }
|
|
})
|
|
});
|
|
|
|
const controller = createVerticalSliceController(api);
|
|
await controller.startRound('ABCD12', 'history');
|
|
|
|
const state = controller.getState();
|
|
expect(state.startRoundState).toBe('error');
|
|
expect(state.errorMessage).toBe('Could not start round. Refresh the lobby and try again.');
|
|
});
|
|
|
|
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 code is required.');
|
|
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 code is required.');
|
|
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');
|
|
});
|
|
});
|