Merge main into PR #164 and resolve SPA shell conflicts
All checks were successful
CI / test-and-quality (push) Successful in 2m18s
CI / test-and-quality (pull_request) Successful in 1m58s

This commit is contained in:
2026-03-01 12:02:40 +00:00
12 changed files with 420 additions and 32 deletions

18
docs/spa-cutover-flag.md Normal file
View File

@@ -0,0 +1,18 @@
# SPA cutover feature flag (`USE_SPA_UI`)
## Formål
`USE_SPA_UI` styrer om host/player UI routes serverer Angular SPA shell eller legacy Django templates.
## Miljø-toggle (uden kodeændring)
Sæt env var pr. miljø:
- `USE_SPA_UI=true` -> `/lobby/ui/host` og `/lobby/ui/player` returnerer SPA shell
- `USE_SPA_UI=false` (default) -> legacy template-flow bruges uændret
Backward compatibility under cutover:
- Hvis `USE_SPA_UI` ikke er sat, bruges `WPP_SPA_ENABLED` som fallback.
## Verifikation
- Flag OFF: `UiScreenTests.test_legacy_templates_are_used_when_spa_flag_is_off`
- Flag ON (host): `UiScreenTests.test_host_screen_can_render_angular_shell_when_feature_flag_enabled`
- Flag ON (player): `UiScreenTests.test_player_screen_can_render_angular_shell_when_feature_flag_enabled`

View File

@@ -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 { export interface ApiClient {
health(): Promise<ApiResult<HealthResponse>>; health(): Promise<ApiResult<HealthResponse>>;
getSession(code: string): Promise<ApiResult<SessionDetailResponse>>; 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 { 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; let response: Response;
try { try {
response = await fetchImpl(`${baseUrl}${path}`, { response = await fetchImpl(`${baseUrl}${path}`, {
method: 'GET', method,
headers: { Accept: 'application/json' } headers: {
Accept: 'application/json',
...(payload === undefined ? {} : { 'Content-Type': 'application/json' })
},
...(payload === undefined ? {} : { body: JSON.stringify(payload) })
}); });
} catch { } catch {
return { return {
@@ -21,9 +35,9 @@ export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch):
}; };
} }
let payload: unknown; let responsePayload: unknown;
try { try {
payload = await response.json(); responsePayload = await response.json();
} catch { } catch {
return { return {
ok: false, ok: false,
@@ -40,17 +54,28 @@ export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch):
kind: 'http', kind: 'http',
status: response.status, status: response.status,
message: `HTTP ${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 { return {
health: () => request<HealthResponse>('/healthz'), health: () => request<HealthResponse>('/healthz', 'GET'),
getSession: (code: string) => 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
)
}; };
} }

View File

@@ -31,9 +31,30 @@ export interface SessionRoundQuestion {
} }
export interface PhaseViewModel { export interface PhaseViewModel {
phase: string; status: string;
available_actions: string[]; round_number: number;
constraints: Record<string, unknown>; 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 { export interface SessionDetailResponse {
@@ -43,6 +64,43 @@ export interface SessionDetailResponse {
phase_view_model: PhaseViewModel; 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 type ApiErrorKind = 'network' | 'http' | 'parse';
export interface ApiFailure { export interface ApiFailure {

View 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
};
}

View File

@@ -7,21 +7,68 @@ let server: Server;
let baseUrl: string; let baseUrl: string;
beforeAll(async () => { beforeAll(async () => {
server = createServer((req: IncomingMessage, res: ServerResponse) => { server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
if (req.url === '/healthz') { if (req.url === '/healthz') {
res.writeHead(200, { 'content-type': 'application/json' }); res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ok: true, service: 'weirsoe-party-protocol' })); res.end(JSON.stringify({ ok: true, service: 'weirsoe-party-protocol' }));
return; return;
} }
if (req.url === '/lobby/sessions/ABCD12') { if (req.url === '/lobby/sessions/ABCD12' && req.method === 'GET') {
res.writeHead(200, { 'content-type': 'application/json' }); res.writeHead(200, { 'content-type': 'application/json' });
res.end( res.end(
JSON.stringify({ 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: [], players: [],
round_question: null, 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; return;
@@ -59,6 +106,20 @@ describe('createApiClient', () => {
expect(session.ok).toBe(true); expect(session.ok).toBe(true);
if (session.ok) { if (session.ok) {
expect(session.data.session.code).toBe('ABCD12'); 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');
} }
}); });

View 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');
});
});

View File

@@ -8,5 +8,5 @@
"lib": ["ES2022", "DOM"], "lib": ["ES2022", "DOM"],
"types": ["vitest/globals", "node"] "types": ["vitest/globals", "node"]
}, },
"include": ["src/api", "tests"] "include": ["src", "tests"]
} }

6
lobby/feature_flags.py Normal file
View File

@@ -0,0 +1,6 @@
from django.conf import settings
def use_spa_ui() -> bool:
"""Central read-point for SPA cutover flag."""
return bool(getattr(settings, "USE_SPA_UI", False))

View File

@@ -2,12 +2,12 @@
<html lang="da"> <html lang="da">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>WPP SPA Shell</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>WPP SPA Shell</title>
<link rel="stylesheet" href="{{ spa_asset_base }}/styles.css"> <link rel="stylesheet" href="{{ spa_asset_base }}/styles.css">
</head> </head>
<body data-wpp-shell-route="{{ shell_route }}" data-wpp-shell-kind="{{ shell_kind }}"> <body data-wpp-shell-route="{{ shell_route }}" data-wpp-shell-kind="{{ shell_kind }}">
<app-root>Indlæser Angular app-shell…</app-root> <app-root data-wpp-shell-route="{{ shell_route }}" data-wpp-shell-kind="{{ shell_kind }}">Indlæser Angular app-shell…</app-root>
<script src="{{ spa_asset_base }}/main.js" type="module"></script> <script type="module" src="{{ spa_asset_base }}/main.js"></script>
</body> </body>
</html> </html>

View File

@@ -973,19 +973,31 @@ class UiScreenTests(TestCase):
self.assertContains(response, "player_shell_runtime_error") self.assertContains(response, "player_shell_runtime_error")
self.assertContains(response, "window.addEventListener(\"error\"") self.assertContains(response, "window.addEventListener(\"error\"")
@override_settings(WPP_SPA_ENABLED=True) @override_settings(USE_SPA_UI=False)
def test_legacy_templates_are_used_when_spa_flag_is_off(self):
self.client.login(username="host_ui", password="secret123")
host_response = self.client.get(reverse("lobby:host_screen"))
player_response = self.client.get(reverse("lobby:player_screen"))
self.assertContains(host_response, "Host panel")
self.assertContains(player_response, "Player panel")
self.assertNotContains(host_response, "<app-root")
self.assertNotContains(player_response, "<app-root")
@override_settings(USE_SPA_UI=True)
def test_host_screen_can_render_angular_shell_when_feature_flag_enabled(self): def test_host_screen_can_render_angular_shell_when_feature_flag_enabled(self):
self.client.login(username="host_ui", password="secret123") self.client.login(username="host_ui", password="secret123")
response = self.client.get(reverse("lobby:host_screen")) response = self.client.get(reverse("lobby:host_screen"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "<app-root>") self.assertContains(response, "<app-root")
self.assertContains(response, "data-wpp-shell-route=\"/host\"") self.assertContains(response, "data-wpp-shell-route=\"/host\"")
self.assertContains(response, "data-wpp-shell-kind=\"host\"") self.assertContains(response, "data-wpp-shell-kind=\"host\"")
self.assertContains(response, "/static/frontend/angular/browser/main.js") self.assertContains(response, "/static/frontend/angular/browser/main.js")
@override_settings(WPP_SPA_ENABLED=True) @override_settings(USE_SPA_UI=True)
def test_host_screen_deeplink_preserves_spa_path_when_feature_flag_enabled(self): def test_host_screen_deeplink_preserves_spa_path_when_feature_flag_enabled(self):
self.client.login(username="host_ui", password="secret123") self.client.login(username="host_ui", password="secret123")
@@ -994,27 +1006,27 @@ class UiScreenTests(TestCase):
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "<app-root>") self.assertContains(response, "<app-root")
self.assertContains(response, "data-wpp-shell-route=\"/host/guess/round-1\"") self.assertContains(response, "data-wpp-shell-route=\"/host/guess/round-1\"")
self.assertContains(response, "data-wpp-shell-kind=\"host\"") self.assertContains(response, "data-wpp-shell-kind=\"host\"")
@override_settings(WPP_SPA_ENABLED=True) @override_settings(USE_SPA_UI=True)
def test_host_screen_deeplink_normalizes_redundant_slashes_when_feature_flag_enabled(self): def test_host_screen_deeplink_normalizes_redundant_slashes_when_feature_flag_enabled(self):
self.client.login(username="host_ui", password="secret123") self.client.login(username="host_ui", password="secret123")
response = self.client.get("/lobby/ui/host//guess///round-1//") response = self.client.get("/lobby/ui/host//guess///round-1//")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "<app-root>") self.assertContains(response, "<app-root")
self.assertContains(response, "data-wpp-shell-route=\"/host/guess/round-1\"") self.assertContains(response, "data-wpp-shell-route=\"/host/guess/round-1\"")
self.assertContains(response, "data-wpp-shell-kind=\"host\"") self.assertContains(response, "data-wpp-shell-kind=\"host\"")
@override_settings(WPP_SPA_ENABLED=True) @override_settings(USE_SPA_UI=True)
def test_player_screen_can_render_angular_shell_when_feature_flag_enabled(self): def test_player_screen_can_render_angular_shell_when_feature_flag_enabled(self):
response = self.client.get(reverse("lobby:player_screen")) response = self.client.get(reverse("lobby:player_screen"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "<app-root>") self.assertContains(response, "<app-root")
self.assertContains(response, "data-wpp-shell-route=\"/player\"") self.assertContains(response, "data-wpp-shell-route=\"/player\"")
self.assertContains(response, "data-wpp-shell-kind=\"player\"") self.assertContains(response, "data-wpp-shell-kind=\"player\"")
self.assertContains(response, "/static/frontend/angular/browser/main.js") self.assertContains(response, "/static/frontend/angular/browser/main.js")

View File

@@ -4,6 +4,8 @@ from django.shortcuts import render
from fupogfakta.models import Category from fupogfakta.models import Category
from .feature_flags import use_spa_ui
def _render_spa_shell(request, shell_route: str, shell_kind: str): def _render_spa_shell(request, shell_route: str, shell_kind: str):
return render( return render(
@@ -19,7 +21,7 @@ def _render_spa_shell(request, shell_route: str, shell_kind: str):
@login_required @login_required
def host_screen(request, spa_path=None): def host_screen(request, spa_path=None):
if settings.WPP_SPA_ENABLED: if use_spa_ui():
host_route = "/host" host_route = "/host"
if spa_path: if spa_path:
normalized_spa_path = "/".join(segment for segment in spa_path.split("/") if segment) normalized_spa_path = "/".join(segment for segment in spa_path.split("/") if segment)
@@ -32,7 +34,7 @@ def host_screen(request, spa_path=None):
def player_screen(request): def player_screen(request):
if settings.WPP_SPA_ENABLED: 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") return render(request, "lobby/player_screen.html")

View File

@@ -99,7 +99,11 @@ STATIC_ROOT = BASE_DIR / 'staticfiles'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
WPP_SPA_ENABLED = env('WPP_SPA_ENABLED', 'false').lower() == 'true' USE_SPA_UI_RAW = env('USE_SPA_UI')
if USE_SPA_UI_RAW is None:
# Backward-compatible fallback while cutover is rolling out.
USE_SPA_UI_RAW = env('WPP_SPA_ENABLED', 'false')
USE_SPA_UI = USE_SPA_UI_RAW.lower() == 'true'
WPP_SPA_ASSET_BASE = env('WPP_SPA_ASSET_BASE', '/static/frontend/angular/browser').rstrip('/') WPP_SPA_ASSET_BASE = env('WPP_SPA_ASSET_BASE', '/static/frontend/angular/browser').rstrip('/')
CHANNEL_REDIS_HOST = env('CHANNEL_REDIS_HOST', '127.0.0.1') CHANNEL_REDIS_HOST = env('CHANNEL_REDIS_HOST', '127.0.0.1')