[SPA] MVP vertical slice: Lobby -> Join -> Start round (#160) #165
@@ -1,17 +1,31 @@
|
||||
import type { ApiResult, HealthResponse, SessionDetailResponse } from './types';
|
||||
import type {
|
||||
ApiResult,
|
||||
HealthResponse,
|
||||
JoinSessionRequest,
|
||||
JoinSessionResponse,
|
||||
SessionDetailResponse,
|
||||
StartRoundRequest,
|
||||
StartRoundResponse
|
||||
} from './types';
|
||||
|
||||
export interface ApiClient {
|
||||
health(): Promise<ApiResult<HealthResponse>>;
|
||||
getSession(code: string): Promise<ApiResult<SessionDetailResponse>>;
|
||||
joinSession(payload: JoinSessionRequest): Promise<ApiResult<JoinSessionResponse>>;
|
||||
startRound(code: string, payload: StartRoundRequest): Promise<ApiResult<StartRoundResponse>>;
|
||||
}
|
||||
|
||||
export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch): ApiClient {
|
||||
async function request<T>(path: string): Promise<ApiResult<T>> {
|
||||
async function request<T>(path: string, method: 'GET' | 'POST', payload?: unknown): Promise<ApiResult<T>> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchImpl(`${baseUrl}${path}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' }
|
||||
method,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(payload === undefined ? {} : { 'Content-Type': 'application/json' })
|
||||
},
|
||||
...(payload === undefined ? {} : { body: JSON.stringify(payload) })
|
||||
});
|
||||
} catch {
|
||||
return {
|
||||
@@ -21,9 +35,9 @@ export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch):
|
||||
};
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
let responsePayload: unknown;
|
||||
try {
|
||||
payload = await response.json();
|
||||
responsePayload = await response.json();
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -40,17 +54,28 @@ export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch):
|
||||
kind: 'http',
|
||||
status: response.status,
|
||||
message: `HTTP ${response.status}`,
|
||||
payload
|
||||
payload: responsePayload
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, status: response.status, data: payload as T };
|
||||
return { ok: true, status: response.status, data: responsePayload as T };
|
||||
}
|
||||
|
||||
return {
|
||||
health: () => request<HealthResponse>('/healthz'),
|
||||
health: () => request<HealthResponse>('/healthz', 'GET'),
|
||||
getSession: (code: string) =>
|
||||
request<SessionDetailResponse>(`/lobby/sessions/${encodeURIComponent(code.trim().toUpperCase())}`)
|
||||
request<SessionDetailResponse>(`/lobby/sessions/${encodeURIComponent(code.trim().toUpperCase())}`, 'GET'),
|
||||
joinSession: (payload: JoinSessionRequest) =>
|
||||
request<JoinSessionResponse>('/lobby/sessions/join', 'POST', {
|
||||
code: payload.code.trim().toUpperCase(),
|
||||
nickname: payload.nickname.trim()
|
||||
}),
|
||||
startRound: (code: string, payload: StartRoundRequest) =>
|
||||
request<StartRoundResponse>(
|
||||
`/lobby/sessions/${encodeURIComponent(code.trim().toUpperCase())}/rounds/start`,
|
||||
'POST',
|
||||
payload
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -31,9 +31,30 @@ export interface SessionRoundQuestion {
|
||||
}
|
||||
|
||||
export interface PhaseViewModel {
|
||||
phase: string;
|
||||
available_actions: string[];
|
||||
constraints: Record<string, unknown>;
|
||||
status: string;
|
||||
round_number: number;
|
||||
players_count: number;
|
||||
constraints: {
|
||||
min_players_to_start: number;
|
||||
max_players_mvp: number;
|
||||
min_players_reached: boolean;
|
||||
max_players_allowed: boolean;
|
||||
};
|
||||
host: {
|
||||
can_start_round: boolean;
|
||||
can_show_question: boolean;
|
||||
can_mix_answers: boolean;
|
||||
can_calculate_scores: boolean;
|
||||
can_reveal_scoreboard: boolean;
|
||||
can_start_next_round: boolean;
|
||||
can_finish_game: boolean;
|
||||
};
|
||||
player: {
|
||||
can_join: boolean;
|
||||
can_submit_lie: boolean;
|
||||
can_submit_guess: boolean;
|
||||
can_view_final_result: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SessionDetailResponse {
|
||||
@@ -43,6 +64,43 @@ export interface SessionDetailResponse {
|
||||
phase_view_model: PhaseViewModel;
|
||||
}
|
||||
|
||||
export interface JoinSessionRequest {
|
||||
code: string;
|
||||
nickname: string;
|
||||
}
|
||||
|
||||
export interface JoinSessionResponse {
|
||||
player: {
|
||||
id: number;
|
||||
nickname: string;
|
||||
session_token: string;
|
||||
score: number;
|
||||
};
|
||||
session: {
|
||||
code: string;
|
||||
status: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StartRoundRequest {
|
||||
category_slug: string;
|
||||
}
|
||||
|
||||
export interface StartRoundResponse {
|
||||
session: {
|
||||
code: string;
|
||||
status: string;
|
||||
current_round: number;
|
||||
};
|
||||
round: {
|
||||
number: number;
|
||||
category: {
|
||||
slug: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export type ApiErrorKind = 'network' | 'http' | 'parse';
|
||||
|
||||
export interface ApiFailure {
|
||||
|
||||
87
frontend/src/spa/vertical-slice.ts
Normal file
87
frontend/src/spa/vertical-slice.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { ApiClient } from '../api/client';
|
||||
import type { SessionDetailResponse } from '../api/types';
|
||||
|
||||
export type AsyncState = 'idle' | 'loading' | 'success' | 'error';
|
||||
|
||||
export interface VerticalSliceState {
|
||||
sessionCode: string;
|
||||
session: SessionDetailResponse | null;
|
||||
joinState: AsyncState;
|
||||
startRoundState: AsyncState;
|
||||
loadingSession: boolean;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
export interface VerticalSliceController {
|
||||
getState(): VerticalSliceState;
|
||||
hydrateLobby(sessionCode: string): Promise<VerticalSliceState>;
|
||||
joinLobby(sessionCode: string, nickname: string): Promise<VerticalSliceState>;
|
||||
startRound(sessionCode: string, categorySlug: string): Promise<VerticalSliceState>;
|
||||
}
|
||||
|
||||
export function createVerticalSliceController(api: ApiClient): VerticalSliceController {
|
||||
const state: VerticalSliceState = {
|
||||
sessionCode: '',
|
||||
session: null,
|
||||
joinState: 'idle',
|
||||
startRoundState: 'idle',
|
||||
loadingSession: false,
|
||||
errorMessage: null
|
||||
};
|
||||
|
||||
const normalizeCode = (value: string): string => value.trim().toUpperCase();
|
||||
|
||||
async function hydrateLobby(sessionCode: string): Promise<VerticalSliceState> {
|
||||
state.loadingSession = true;
|
||||
state.errorMessage = null;
|
||||
state.sessionCode = normalizeCode(sessionCode);
|
||||
|
||||
const result = await api.getSession(state.sessionCode);
|
||||
state.loadingSession = false;
|
||||
|
||||
if (!result.ok) {
|
||||
state.errorMessage = 'Kunne ikke hente lobby-status.';
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
state.session = result.data;
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
async function joinLobby(sessionCode: string, nickname: string): Promise<VerticalSliceState> {
|
||||
state.joinState = 'loading';
|
||||
state.errorMessage = null;
|
||||
|
||||
const join = await api.joinSession({ code: sessionCode, nickname });
|
||||
if (!join.ok) {
|
||||
state.joinState = 'error';
|
||||
state.errorMessage = 'Join fejlede. Tjek kode eller nickname og prøv igen.';
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
state.joinState = 'success';
|
||||
return hydrateLobby(sessionCode);
|
||||
}
|
||||
|
||||
async function startRound(sessionCode: string, categorySlug: string): Promise<VerticalSliceState> {
|
||||
state.startRoundState = 'loading';
|
||||
state.errorMessage = null;
|
||||
|
||||
const start = await api.startRound(sessionCode, { category_slug: categorySlug });
|
||||
if (!start.ok) {
|
||||
state.startRoundState = 'error';
|
||||
state.errorMessage = 'Kunne ikke starte runden. Opdatér lobbyen og prøv igen.';
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
state.startRoundState = 'success';
|
||||
return hydrateLobby(sessionCode);
|
||||
}
|
||||
|
||||
return {
|
||||
getState: () => ({ ...state }),
|
||||
hydrateLobby,
|
||||
joinLobby,
|
||||
startRound
|
||||
};
|
||||
}
|
||||
@@ -7,21 +7,68 @@ let server: Server;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
||||
server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
||||
if (req.url === '/healthz') {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true, service: 'weirsoe-party-protocol' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === '/lobby/sessions/ABCD12') {
|
||||
if (req.url === '/lobby/sessions/ABCD12' && req.method === 'GET') {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 2 },
|
||||
session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 3 },
|
||||
players: [],
|
||||
round_question: null,
|
||||
phase_view_model: { phase: 'lobby', available_actions: ['start_round'], constraints: {} }
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === '/lobby/sessions/join' && req.method === 'POST') {
|
||||
res.writeHead(201, { 'content-type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
player: { id: 9, nickname: 'Maja', session_token: 'token-1', score: 0 },
|
||||
session: { code: 'ABCD12', status: 'lobby' }
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === '/lobby/sessions/ABCD12/rounds/start' && req.method === 'POST') {
|
||||
res.writeHead(201, { 'content-type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
session: { code: 'ABCD12', status: 'lie', current_round: 1 },
|
||||
round: { number: 1, category: { slug: 'history', name: 'History' } }
|
||||
})
|
||||
);
|
||||
return;
|
||||
@@ -59,6 +106,20 @@ describe('createApiClient', () => {
|
||||
expect(session.ok).toBe(true);
|
||||
if (session.ok) {
|
||||
expect(session.data.session.code).toBe('ABCD12');
|
||||
expect(session.data.phase_view_model.host.can_start_round).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('supports join + start round writes for lobby vertical slice', async () => {
|
||||
const client = createApiClient(baseUrl);
|
||||
|
||||
const join = await client.joinSession({ code: 'abcd12', nickname: 'Maja' });
|
||||
expect(join.ok).toBe(true);
|
||||
|
||||
const start = await client.startRound('abcd12', { category_slug: 'history' });
|
||||
expect(start.ok).toBe(true);
|
||||
if (start.ok) {
|
||||
expect(start.data.session.status).toBe('lie');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
115
frontend/tests/vertical-slice.test.ts
Normal file
115
frontend/tests/vertical-slice.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createVerticalSliceController } from '../src/spa/vertical-slice';
|
||||
import type { ApiClient } from '../src/api/client';
|
||||
|
||||
function makeApiMock(overrides?: Partial<ApiClient>): ApiClient {
|
||||
const base: ApiClient = {
|
||||
health: 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,
|
||||
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' } }
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
|
||||
describe('vertical slice controller: lobby -> join -> start round', () => {
|
||||
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('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' } }
|
||||
})
|
||||
});
|
||||
|
||||
const controller = createVerticalSliceController(api);
|
||||
await controller.joinLobby('missing', 'Maja');
|
||||
|
||||
const state = controller.getState();
|
||||
expect(state.joinState).toBe('error');
|
||||
expect(state.errorMessage).toContain('Join fejlede');
|
||||
});
|
||||
|
||||
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' } }
|
||||
})
|
||||
});
|
||||
|
||||
const controller = createVerticalSliceController(api);
|
||||
await controller.startRound('ABCD12', 'history');
|
||||
|
||||
const state = controller.getState();
|
||||
expect(state.startRoundState).toBe('error');
|
||||
expect(state.errorMessage).toContain('Kunne ikke starte runden');
|
||||
});
|
||||
});
|
||||
@@ -8,5 +8,5 @@
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["vitest/globals", "node"]
|
||||
},
|
||||
"include": ["src/api", "tests"]
|
||||
"include": ["src", "tests"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user