63 Commits

Author SHA1 Message Date
acc3420a86 test(angular): cover host/player gameplay transitions and retry paths
All checks were successful
CI / test-and-quality (push) Successful in 2m5s
2026-03-01 14:40:12 +00:00
2f6a21de9c feat(spa): wire lie-guess-reveal-scoreboard gameplay flow (#172)
All checks were successful
CI / test-and-quality (push) Successful in 2m10s
CI / test-and-quality (pull_request) Successful in 2m10s
2026-03-01 14:35:00 +00:00
9e54aa0ab2 Merge pull request '[SPA][P2] Angular API-client lag fix for health + session read (#168)' (#178) from dev/issue-168-angular-api-client-lagfix into main
All checks were successful
CI / test-and-quality (push) Successful in 1m56s
2026-03-01 15:02:04 +01:00
58f7f02af3 fix(spa): normalize angular api client base URL for django endpoints
All checks were successful
CI / test-and-quality (push) Successful in 2m11s
CI / test-and-quality (pull_request) Successful in 2m11s
2026-03-01 13:52:32 +00:00
c9c2ec23a2 Merge pull request '[SPA][P4] #169 Gameplay MVP del 1: lobby join + start round flow (v2)' (#176) from issue-169-spa-lobby-join-start-round into main
All checks were successful
CI / test-and-quality (push) Successful in 1m58s
2026-03-01 14:44:42 +01:00
749997a8fb fix(spa): guard empty session code before hydrate/start
All checks were successful
CI / test-and-quality (pull_request) Successful in 2m7s
CI / test-and-quality (push) Successful in 2m9s
2026-03-01 13:32:17 +00:00
85e970b90c fix(frontend): restore default session context store in vertical slice
All checks were successful
CI / test-and-quality (push) Successful in 2m0s
CI / test-and-quality (pull_request) Successful in 2m31s
2026-03-01 13:24:46 +00:00
b52896d137 test(spa): cover lobby->start-round flow without reload (#169)
All checks were successful
CI / test-and-quality (push) Successful in 2m4s
CI / test-and-quality (pull_request) Successful in 2m4s
2026-03-01 13:17:38 +00:00
538368de99 fix(frontend): restore session context behavior in vertical slice
All checks were successful
CI / test-and-quality (push) Successful in 2m8s
CI / test-and-quality (pull_request) Successful in 2m9s
2026-03-01 13:05:34 +00:00
cab5c47759 feat(spa): wire join/start round in Angular API client for lobby flow 2026-03-01 13:05:26 +00:00
68325944c1 Merge pull request '[SPA] Angular API client for health + session read (#168)' (#170) from dev/issue-168-angular-api-client into main
All checks were successful
CI / test-and-quality (push) Successful in 1m56s
2026-03-01 13:30:50 +01:00
d1e1ef0fde Merge pull request '[SPA][P3] Session context store for SPA flow (#159)' (#171) from dev/issue-159-spa-session-context-store into main
Some checks failed
CI / test-and-quality (push) Has been cancelled
2026-03-01 13:30:22 +01:00
07a8c9568d feat(frontend): wire SPA flow to session context store
All checks were successful
CI / test-and-quality (push) Successful in 2m25s
CI / test-and-quality (pull_request) Successful in 2m7s
2026-03-01 12:21:50 +00:00
4a6acd79c1 feat(frontend): add robust session context store for SPA 2026-03-01 12:21:46 +00:00
b6617fc356 feat(spa): add Angular API client for health and session read
All checks were successful
CI / test-and-quality (pull_request) Successful in 2m9s
CI / test-and-quality (push) Successful in 1m47s
2026-03-01 12:21:18 +00:00
b647db2048 Merge pull request '[SPA] Non-blocking loading skeletons for host+player critical views (issue #150)' (#167) from dev/issue-150-spa-loading-skeletons into main
All checks were successful
CI / test-and-quality (push) Successful in 2m1s
2026-03-01 13:14:45 +01:00
29ef754389 Merge pull request '[SPA] Angular app-shell + host/player routing skeleton (#157)' (#164) from dev/issue-157-angular-shell-v2 into main
Some checks failed
CI / test-and-quality (push) Has been cancelled
2026-03-01 13:14:29 +01:00
702ab6b9ee 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
2026-03-01 12:02:40 +00:00
7294ad409c feat(spa): add non-blocking host/player loading skeletons
All checks were successful
CI / test-and-quality (pull_request) Successful in 2m15s
CI / test-and-quality (push) Successful in 2m16s
2026-03-01 12:01:41 +00:00
2f142aeb24 fix(lobby): normalize host SPA deeplink path segments
All checks were successful
CI / test-and-quality (push) Successful in 2m7s
CI / test-and-quality (pull_request) Successful in 2m6s
2026-03-01 11:25:54 +00:00
fa6c5e30c9 Merge pull request '[SPA] MVP vertical slice: Lobby -> Join -> Start round (#160)' (#165) from dev/issue-160-spa-vertical-slice-v2 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m48s
2026-03-01 12:22:34 +01:00
cd3c604ba6 Merge pull request '[SPA] Cutover feature-flag USE_SPA_UI med sikker fallback (#152)' (#166) from dev/issue-152-spa-cutover-flag into main
Some checks failed
CI / test-and-quality (push) Has been cancelled
2026-03-01 12:22:14 +01:00
84438b2880 Fix host SPA deeplink route propagation
All checks were successful
CI / test-and-quality (push) Successful in 2m6s
CI / test-and-quality (pull_request) Successful in 2m7s
2026-03-01 11:17:46 +00:00
1aa296c45c feat(spa): add USE_SPA_UI cutover flag with legacy fallback
All checks were successful
CI / test-and-quality (push) Successful in 2m17s
CI / test-and-quality (pull_request) Successful in 2m5s
2026-03-01 11:14:38 +00:00
ea8954e702 feat(spa): add lobby-join-start vertical slice controller
All checks were successful
CI / test-and-quality (push) Successful in 2m6s
CI / test-and-quality (pull_request) Successful in 2m11s
2026-03-01 11:13:48 +00:00
ea82f920b1 test(lobby): cover SPA shell rendering for host/player
All checks were successful
CI / test-and-quality (push) Successful in 2m5s
CI / test-and-quality (pull_request) Successful in 2m9s
2026-03-01 11:06:59 +00:00
5bdbdbd837 feat(lobby): gate Angular SPA shell behind feature flag 2026-03-01 11:06:55 +00:00
7180cc9b0d feat(spa): scaffold Angular app shell with host/player routes 2026-03-01 11:06:33 +00:00
61eb08ad73 Merge pull request '[SPA] API-client baseline for health + session read (#158)' (#163) from dev/issue-158-spa-api-client-baseline into main
All checks were successful
CI / test-and-quality (push) Successful in 1m46s
Merged by integrator-runner
2026-03-01 12:05:30 +01:00
37b86d7065 test(spa): add integration coverage for API client error mapping
All checks were successful
CI / test-and-quality (push) Successful in 1m54s
CI / test-and-quality (pull_request) Successful in 1m54s
2026-03-01 11:02:00 +00:00
2e25d32ba1 feat(spa): add baseline API client for health and session read 2026-03-01 11:01:58 +00:00
825f8c599b Merge pull request '[SPA] Shared contract for lobby/game phase view-model' (#155) from dev/issue-149-phase-view-model into main
All checks were successful
CI / test-and-quality (push) Successful in 1m41s
2026-03-01 11:55:25 +01:00
87ba42c68a Merge pull request '[SPA] Error boundary + recover actions on top-level app shell' (#156) from dev/issue-151-app-shell-error-boundary into main
All checks were successful
CI / test-and-quality (push) Successful in 1m38s
2026-03-01 11:50:18 +01:00
2882a7737b feat(spa): add top-level app-shell error boundary recover actions (#151)
All checks were successful
CI / test-and-quality (push) Successful in 1m56s
CI / test-and-quality (pull_request) Successful in 1m56s
2026-03-01 10:46:48 +00:00
a9868ae450 feat(lobby): add shared phase view-model contract
All checks were successful
CI / test-and-quality (push) Successful in 2m12s
CI / test-and-quality (pull_request) Successful in 2m11s
2026-03-01 10:41:16 +00:00
d6c7a36730 Merge pull request '[SPA] Host shell route-guards + deep-link fallback (#147)' (#154) from dev/issue-147-host-shell-route-guards into main
All checks were successful
CI / test-and-quality (push) Successful in 1m41s
2026-03-01 11:34:46 +01:00
de99e456c7 merge(main): resolve host_screen deep-link route guard conflict
All checks were successful
CI / test-and-quality (pull_request) Successful in 1m58s
CI / test-and-quality (push) Successful in 1m58s
2026-03-01 10:29:07 +00:00
79c4734fe6 Merge pull request '[SPA] Player reconnect UX-state (lost connection banner + retry)' (#153) from dev/issue-148-player-reconnect-ux into main
All checks were successful
CI / test-and-quality (push) Successful in 1m41s
2026-03-01 11:24:15 +01:00
c8c27346a8 fix(host-ui): accept deep-link routes and normalize shell path
All checks were successful
CI / test-and-quality (push) Successful in 2m14s
CI / test-and-quality (pull_request) Successful in 1m55s
2026-03-01 10:20:32 +00:00
994e2930d5 chore(ci): retrigger checks after stale review pending gate
All checks were successful
CI / test-and-quality (push) Successful in 2m27s
CI / test-and-quality (pull_request) Successful in 2m31s
2026-03-01 10:17:31 +00:00
3e0cb9cee7 feat(lobby): add host SPA deep-link fallback and route guards
All checks were successful
CI / test-and-quality (push) Successful in 2m12s
CI / test-and-quality (pull_request) Successful in 2m12s
2026-03-01 10:13:26 +00:00
64bff4efb3 feat(player): show reconnect banner with retry action
Some checks failed
CI / test-and-quality (push) Failing after 2m3s
CI / test-and-quality (pull_request) Failing after 2m16s
2026-03-01 10:11:29 +00:00
c58b1e8102 Merge pull request '[Execution] Add staging gameplay smoke artifact template (#144)' (#146) from dev/staging-gameplay-smoke-artifact-144 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m38s
2026-03-01 10:33:48 +01:00
7aae8f3798 chore(ci): retrigger pipeline for PR #146
All checks were successful
CI / test-and-quality (pull_request) Successful in 2m38s
CI / test-and-quality (push) Successful in 2m39s
2026-03-01 09:11:41 +00:00
0c0d27cc52 docs: add staging gameplay smoke evidence artifact template (#144)
Some checks failed
CI / test-and-quality (push) Failing after 24s
CI / test-and-quality (pull_request) Successful in 1m59s
2026-03-01 07:08:44 +00:00
164416e4a9 Merge pull request 'Issue #144: staging gameplay smoke artifact output' (#145) from dev/issue-144-smoke-artifact into main
Some checks failed
CI / test-and-quality (push) Failing after 25s
2026-03-01 07:51:09 +01:00
b782f73f49 Add staging gameplay smoke artifact output
All checks were successful
CI / test-and-quality (pull_request) Successful in 2m2s
CI / test-and-quality (push) Successful in 2m8s
2026-03-01 06:39:51 +00:00
9d1e41ef3b Merge pull request 'Fix #129: normalize session code input across host/player flows' (#143) from feature/issue-129-normalize-session-code into main
All checks were successful
CI / test-and-quality (push) Successful in 1m27s
2026-02-28 21:37:49 +01:00
046212d29a Normalize session code input in join and lookup flows
All checks were successful
CI / test-and-quality (push) Successful in 1m42s
CI / test-and-quality (pull_request) Successful in 1m42s
2026-02-28 20:22:58 +00:00
6fd57d1714 Merge pull request 'fix(devops): harden staging deploy health check race' (#142) from fix/141-staging-healthcheck-retry into main
All checks were successful
CI / test-and-quality (push) Successful in 1m27s
2026-02-28 18:40:44 +01:00
c4ea5ca208 fix(staging): retry health check after restart
All checks were successful
CI / test-and-quality (push) Successful in 1m30s
CI / test-and-quality (pull_request) Successful in 1m36s
2026-02-28 17:36:14 +00:00
ab08303fc3 Merge pull request 'fix(smoke): load staging env before migration/gameplay checks' (#140) from fix/smoke-env-load-130 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m24s
2026-02-28 17:50:33 +01:00
8b6f115759 fix(smoke): load staging env before migrate/smoke checks (refs #130 #90)
All checks were successful
CI / test-and-quality (push) Successful in 1m35s
CI / test-and-quality (pull_request) Successful in 1m36s
2026-02-28 16:35:25 +00:00
c75189deb9 Merge pull request 'fix(staging): keep /opt/wpp-staging/app writable for wpp runtime (fix #138)' (#139) from feature/fix-138-staging-app-ownership into main
All checks were successful
CI / test-and-quality (push) Successful in 1m23s
2026-02-28 17:29:29 +01:00
30c22d2f0c fix(staging): enforce writable app ownership during deploy
All checks were successful
CI / test-and-quality (push) Successful in 1m37s
CI / test-and-quality (pull_request) Successful in 1m38s
2026-02-28 16:06:58 +00:00
30e3f1c77f Merge pull request 'fix(smoke): schema-drift guard + token-aware staging smoke flow (refs #130 #90)' (#137) from fix/staging-promote-after-migrate-130 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m23s
2026-02-28 16:48:31 +01:00
abb656d50b fix(smoke): guard staging schema and include player session tokens (refs #130 #90)
All checks were successful
CI / test-and-quality (push) Successful in 1m34s
CI / test-and-quality (pull_request) Successful in 1m36s
2026-02-28 15:36:29 +00:00
b1e89b135a Merge pull request 'fix(staging): avoid schema/code drift after failed deploy' (#136) from fix/staging-deploy-schema-drift-130 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m22s
2026-02-28 16:25:47 +01:00
e9104cdc44 fix(staging): prevent app/schema drift on failed deploy (refs #130 #90)
All checks were successful
CI / test-and-quality (push) Successful in 1m30s
CI / test-and-quality (pull_request) Successful in 1m32s
2026-02-28 15:04:06 +00:00
65ee2fc0db Merge pull request 'fix(staging): remove tracked sqlite artifact from deploy archives (fixes #131)' (#135) from fix/staging-sqlite-artifact-131 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m21s
2026-02-28 15:55:12 +01:00
12fc12f955 fix(staging): remove tracked sqlite artifact from deploy archives (refs #131 #130)
All checks were successful
CI / test-and-quality (push) Successful in 1m34s
CI / test-and-quality (pull_request) Successful in 1m34s
2026-02-28 14:33:31 +00:00
e8f13646f9 Merge pull request 'feat(staging): enforce MySQL-only staging deploy (fixes #133)' (#134) from feat/staging-mysql-133 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m22s
2026-02-28 15:14:56 +01:00
a36221ae4b staging deploy: load env before manage.py checks
All checks were successful
CI / test-and-quality (push) Successful in 1m36s
CI / test-and-quality (pull_request) Successful in 1m36s
2026-02-28 14:02:39 +00:00
51 changed files with 12678 additions and 65 deletions

Binary file not shown.

View File

@@ -0,0 +1,43 @@
# Staging gameplay smoke artifact (Issue #144)
Formål: levere et lille, ensartet evidensformat for release-nær gameplay-smoke i #16/#90-sporet uden scope-udvidelse.
## Guardrails (MVP)
- Hold scope inden for #16 (execution board) og #17 (scope guardrail).
- Kun verifikation af eksisterende flow; ingen nye features/polish.
## Hvornår bruges artifacten
- Efter staging-smoke af gameplay-flowet: lobby -> join -> start -> runde -> scoreboard -> next/final.
- Resultatet logges i issue/PR-kommentar med denne skabelon.
## Evidence template (kopiér i PR/issue-kommentar)
```markdown
### Staging gameplay smoke evidence
- Timestamp (UTC): <YYYY-MM-DD HH:MM>
- Environment: staging
- Commit/Head SHA: <sha>
- Linked scope: #16 #17 #90 #129 #144
#### Setup
- Host authenticated in Django admin: <yes/no>
- Active category/questions present: <yes/no>
- Participants: host + <N> players
#### Checks (PASS/FAIL)
1. Lobby -> join -> start
- Mixed-case + whitespace session code accepted: <pass/fail>
2. One full round to scoreboard
- submit lie -> mix -> submit guess -> calculate score -> show scoreboard: <pass/fail>
3. Next-round + game-end sanity
- next round transitions: <pass/fail>
- final leaderboard visible: <pass/fail>
#### Evidence pointers
- Command(s): `<exact command(s)>`
- UI notes/screenshots/log refs: <short refs>
- Result: <PASS/FAIL>
- If FAIL: blocker issue link + shortest repro
```
## Anti-stall minimum
Hvis der ikke er ny kode at ændre, er denne artifact-skabelon den mindste gyldige leverance for at sikre ensartet, reviewbar smoke-evidens i staging.

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`

1
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules/

12
frontend/README.md Normal file
View File

@@ -0,0 +1,12 @@
# Frontend API client baseline
Dette er baseline-klientlaget for SPA-sporet.
## Kører checks lokalt
```bash
cd frontend
npm install
npm test
npm run build
```

3
frontend/angular/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
dist/
.angular/

View File

@@ -0,0 +1,36 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"projects": {
"wpp-angular-shell": {
"projectType": "application",
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"outputPath": "dist/wpp-angular-shell",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json",
"assets": [],
"styles": ["src/styles.css"],
"outputHashing": "none"
}
},
"serve": {
"builder": "@angular/build:dev-server",
"options": {
"buildTarget": "wpp-angular-shell:build"
}
}
}
}
},
"cli": {
"analytics": false
}
}

8577
frontend/angular/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
{
"name": "wpp-angular-shell",
"version": "0.0.0",
"private": true,
"scripts": {
"start": "ng serve",
"build": "ng build",
"test": "vitest run"
},
"dependencies": {
"@angular/common": "^19.2.0",
"@angular/compiler": "^19.2.0",
"@angular/core": "^19.2.0",
"@angular/forms": "^19.2.0",
"@angular/platform-browser": "^19.2.0",
"@angular/router": "^19.2.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular/build": "^19.2.0",
"@angular/cli": "^19.2.0",
"@angular/compiler-cli": "^19.2.0",
"typescript": "~5.7.2",
"vitest": "^2.1.9"
}
}

View File

@@ -0,0 +1,4 @@
.shell { font-family: Arial, sans-serif; margin: 1rem; }
.shell__header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #ddd; padding-bottom: 0.75rem; }
.shell__header nav { display: flex; gap: 0.75rem; }
.shell__content { margin-top: 1rem; }

View File

@@ -0,0 +1,13 @@
<main class="shell">
<header class="shell__header">
<h1>WPP Angular Shell</h1>
<nav>
<a routerLink="/host">Host</a>
<a routerLink="/player">Player</a>
</nav>
</header>
<section class="shell__content">
<router-outlet></router-outlet>
</section>
</main>

View File

@@ -0,0 +1,20 @@
import { Component, inject } from '@angular/core';
import { Router, RouterLink, RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, RouterLink],
templateUrl: './app.component.html',
styleUrl: './app.component.css',
})
export class AppComponent {
private readonly router = inject(Router);
constructor() {
const shellRoute = document.body.dataset['wppShellRoute'];
if (shellRoute?.startsWith('/host') || shellRoute?.startsWith('/player')) {
void this.router.navigateByUrl(shellRoute);
}
}
}

View File

@@ -0,0 +1,7 @@
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withHashLocation } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes, withHashLocation())],
};

View File

@@ -0,0 +1,30 @@
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: 'host',
loadComponent: () => import('./features/host/host-shell.component').then((m) => m.HostShellComponent),
},
{
path: 'host/:phase',
loadComponent: () => import('./features/host/host-shell.component').then((m) => m.HostShellComponent),
},
{
path: 'host/:phase/:context',
loadComponent: () => import('./features/host/host-shell.component').then((m) => m.HostShellComponent),
},
{
path: 'player',
loadComponent: () => import('./features/player/player-shell.component').then((m) => m.PlayerShellComponent),
},
{
path: 'player/:phase',
loadComponent: () => import('./features/player/player-shell.component').then((m) => m.PlayerShellComponent),
},
{
path: 'player/:phase/:context',
loadComponent: () => import('./features/player/player-shell.component').then((m) => m.PlayerShellComponent),
},
{ path: '', pathMatch: 'full', redirectTo: 'player' },
{ path: '**', redirectTo: 'player' },
];

View File

@@ -0,0 +1,74 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HostShellComponent } from './host-shell.component';
type FetchMock = ReturnType<typeof vi.fn>;
function jsonResponse(status: number, body: unknown) {
return {
ok: status >= 200 && status < 300,
status,
json: vi.fn().mockResolvedValue(body),
} as unknown as Response;
}
describe('HostShellComponent gameplay wiring', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('runs startRound transition and refreshes session details', async () => {
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(201, { ok: true }))
.mockResolvedValueOnce(
jsonResponse(200, {
session: { code: 'ABCD12', status: 'lie', current_round: 2 },
round_question: { id: 41, prompt: 'Q?', answers: [] },
players: [],
})
);
vi.stubGlobal('fetch', fetchMock);
const component = new HostShellComponent();
component.sessionCode = ' abcd12 ';
component.categorySlug = ' history ';
await component.startRound();
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'/lobby/sessions/ABCD12/rounds/start',
expect.objectContaining({ method: 'POST', body: JSON.stringify({ category_slug: 'history' }) })
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'/lobby/sessions/ABCD12',
expect.objectContaining({ method: 'GET' })
);
expect(component.session?.session.status).toBe('lie');
expect(component.roundQuestionId).toBe('41');
expect(component.loading).toBe(false);
});
it('captures scoreboard error for retry path', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(
jsonResponse(500, { error: 'Scoreboard unavailable' })
);
vi.stubGlobal('fetch', fetchMock);
const component = new HostShellComponent();
component.sessionCode = 'ABCD12';
await component.loadScoreboard();
expect(fetchMock).toHaveBeenCalledWith(
'/lobby/sessions/ABCD12/scoreboard',
expect.objectContaining({ method: 'GET' })
);
expect(component.scoreboardError).toContain('Scoreboard failed: Scoreboard unavailable');
expect(component.loading).toBe(false);
});
});

View File

@@ -0,0 +1,156 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
interface SessionDetail {
session: { code: string; status: string; current_round: number };
round_question: { id: number; prompt: string; answers: Array<{ text: string }> } | null;
players: Array<{ id: number; nickname: string; score: number }>;
}
@Component({
selector: 'app-host-shell',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<h2>Host SPA gameplay flow</h2>
<div class="panel">
<label>Session code <input [(ngModel)]="sessionCode" /></label>
<label>Category <input [(ngModel)]="categorySlug" /></label>
<button (click)="refreshSession()" [disabled]="loading">Refresh</button>
<button (click)="startRound()" [disabled]="loading">Start round</button>
<button (click)="showQuestion()" [disabled]="loading || !roundQuestionId">Show question</button>
<button (click)="mixAnswers()" [disabled]="loading || !roundQuestionId">Mix answers → guess</button>
<button (click)="calculateScores()" [disabled]="loading || !roundQuestionId">Calculate scores → reveal</button>
<button (click)="loadScoreboard()" [disabled]="loading">Load scoreboard</button>
<button *ngIf="scoreboardError" (click)="loadScoreboard()" [disabled]="loading">Retry scoreboard</button>
</div>
<p *ngIf="error" class="error">{{ error }}</p>
<p *ngIf="scoreboardError" class="error">{{ scoreboardError }}</p>
<div *ngIf="session" class="panel">
<p><strong>Status:</strong> {{ session.session.status }} · round {{ session.session.current_round }}</p>
<p><strong>Round question id:</strong> {{ roundQuestionId || '-' }}</p>
<p *ngIf="session.round_question"><strong>Prompt:</strong> {{ session.round_question.prompt }}</p>
<ul>
<li *ngFor="let p of session.players">{{ p.nickname }}: {{ p.score }}</li>
</ul>
<pre *ngIf="scoreboardPayload">{{ scoreboardPayload }}</pre>
</div>
`,
})
export class HostShellComponent {
sessionCode = '';
categorySlug = 'general';
roundQuestionId = '';
loading = false;
error = '';
scoreboardError = '';
scoreboardPayload = '';
session: SessionDetail | null = null;
private normalizeCode(value: string): string {
return value.trim().toUpperCase();
}
private async request<T>(path: string, method: 'GET' | 'POST', payload?: unknown): Promise<T> {
const response = await fetch(path, {
method,
headers: {
Accept: 'application/json',
...(payload === undefined ? {} : { 'Content-Type': 'application/json' }),
},
...(payload === undefined ? {} : { body: JSON.stringify(payload) }),
credentials: 'same-origin',
});
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error((body as { error?: string }).error ?? `HTTP ${response.status}`);
}
return body as T;
}
async refreshSession(): Promise<void> {
this.loading = true;
this.error = '';
this.scoreboardError = '';
try {
const code = this.normalizeCode(this.sessionCode);
this.session = await this.request<SessionDetail>(`/lobby/sessions/${encodeURIComponent(code)}`, 'GET');
this.sessionCode = this.session.session.code;
this.roundQuestionId = this.session.round_question?.id ? String(this.session.round_question.id) : '';
} catch (error) {
this.error = `Session refresh failed: ${(error as Error).message}`;
} finally {
this.loading = false;
}
}
async startRound(): Promise<void> {
await this.runAction(async () => {
const code = this.normalizeCode(this.sessionCode);
await this.request(`/lobby/sessions/${encodeURIComponent(code)}/rounds/start`, 'POST', {
category_slug: this.categorySlug.trim(),
});
await this.refreshSession();
});
}
async showQuestion(): Promise<void> {
await this.runAction(async () => {
const code = this.normalizeCode(this.sessionCode);
await this.request(`/lobby/sessions/${encodeURIComponent(code)}/questions/show`, 'POST', {});
await this.refreshSession();
});
}
async mixAnswers(): Promise<void> {
await this.runAction(async () => {
const code = this.normalizeCode(this.sessionCode);
const roundQuestionId = this.roundQuestionId.trim();
await this.request(`/lobby/sessions/${encodeURIComponent(code)}/questions/${roundQuestionId}/answers/mix`, 'POST', {});
await this.refreshSession();
});
}
async calculateScores(): Promise<void> {
await this.runAction(async () => {
const code = this.normalizeCode(this.sessionCode);
const roundQuestionId = this.roundQuestionId.trim();
await this.request(`/lobby/sessions/${encodeURIComponent(code)}/questions/${roundQuestionId}/scores/calculate`, 'POST', {});
await this.refreshSession();
});
}
async loadScoreboard(): Promise<void> {
this.loading = true;
this.scoreboardError = '';
this.error = '';
try {
const code = this.normalizeCode(this.sessionCode);
const payload = await this.request<unknown>(`/lobby/sessions/${encodeURIComponent(code)}/scoreboard`, 'GET');
this.scoreboardPayload = JSON.stringify(payload, null, 2);
await this.refreshSession();
} catch (error) {
this.scoreboardError = `Scoreboard failed: ${(error as Error).message}`;
} finally {
this.loading = false;
}
}
private async runAction(action: () => Promise<void>): Promise<void> {
this.loading = true;
this.error = '';
try {
await action();
} catch (error) {
this.error = (error as Error).message;
} finally {
this.loading = false;
}
}
}

View File

@@ -0,0 +1,78 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PlayerShellComponent } from './player-shell.component';
type FetchMock = ReturnType<typeof vi.fn>;
function jsonResponse(status: number, body: unknown) {
return {
ok: status >= 200 && status < 300,
status,
json: vi.fn().mockResolvedValue(body),
} as unknown as Response;
}
describe('PlayerShellComponent gameplay wiring', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('clears selected guess when refreshed status is no longer guess', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(
jsonResponse(200, {
session: { code: 'ABCD12', status: 'reveal', current_round: 1 },
round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }] },
})
);
vi.stubGlobal('fetch', fetchMock);
const component = new PlayerShellComponent();
component.sessionCode = 'abcd12';
component.selectedGuess = 'A';
await component.refreshSession();
expect(fetchMock).toHaveBeenCalledWith(
'/lobby/sessions/ABCD12',
expect.objectContaining({ method: 'GET' })
);
expect(component.selectedGuess).toBe('');
});
it('surfaces lie submit error and allows retry success flow', async () => {
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(500, { error: 'Temporary submit outage' }))
.mockResolvedValueOnce(jsonResponse(200, { ok: true }))
.mockResolvedValueOnce(
jsonResponse(200, {
session: { code: 'ABCD12', status: 'guess', 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.lieText = 'my lie';
component.session = {
session: { code: 'ABCD12', status: 'lie', current_round: 1 },
round_question: { id: 11, prompt: 'Q?', answers: [] },
};
await component.submitLie();
expect(component.submitError?.kind).toBe('lie');
expect(component.submitError?.message).toContain('Lie submit failed: Temporary submit outage');
await component.submitLie();
expect(component.submitError).toBeNull();
expect(component.session?.session.status).toBe('guess');
expect(fetchMock).toHaveBeenCalledTimes(3);
});
});

View File

@@ -0,0 +1,173 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
interface SessionDetail {
session: { code: string; status: string; current_round: number };
round_question: { id: number; prompt: string; answers: Array<{ text: string }> } | null;
}
@Component({
selector: 'app-player-shell',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<h2>Player SPA gameplay flow</h2>
<div class="panel">
<label>Session code <input [(ngModel)]="sessionCode" /></label>
<label>Nickname <input [(ngModel)]="nickname" /></label>
<button (click)="refreshSession()" [disabled]="loading">Refresh</button>
<button (click)="joinSession()" [disabled]="loading">Join</button>
</div>
<div class="panel" *ngIf="session">
<p><strong>Status:</strong> {{ session.session.status }}</p>
<p *ngIf="session.round_question"><strong>Prompt:</strong> {{ session.round_question.prompt }}</p>
<label>Løgn <input [(ngModel)]="lieText" [disabled]="loading || session.session.status !== 'lie'" /></label>
<button (click)="submitLie()" [disabled]="loading || session.session.status !== 'lie'">Submit lie</button>
<button *ngIf="submitError?.kind === 'lie'" (click)="submitLie()" [disabled]="loading">Retry lie submit</button>
<div class="answers" *ngIf="session.round_question?.answers?.length">
<button
type="button"
*ngFor="let answer of session.round_question?.answers"
(click)="selectedGuess = answer.text"
[class.active]="selectedGuess === answer.text"
[disabled]="loading || session.session.status !== 'guess'"
>
{{ answer.text }}
</button>
</div>
<button (click)="submitGuess()" [disabled]="loading || session.session.status !== 'guess' || !selectedGuess">Submit guess</button>
<button *ngIf="submitError?.kind === 'guess'" (click)="submitGuess()" [disabled]="loading">Retry guess submit</button>
</div>
<p *ngIf="error" class="error">{{ error }}</p>
<p *ngIf="submitError" class="error">{{ submitError.message }}</p>
`,
})
export class PlayerShellComponent {
sessionCode = '';
nickname = '';
playerId = 0;
sessionToken = '';
lieText = '';
selectedGuess = '';
loading = false;
error = '';
submitError: { kind: 'lie' | 'guess'; message: string } | null = null;
session: SessionDetail | null = null;
private normalizeCode(value: string): string {
return value.trim().toUpperCase();
}
private async request<T>(path: string, method: 'GET' | 'POST', payload?: unknown): Promise<T> {
const response = await fetch(path, {
method,
headers: {
Accept: 'application/json',
...(payload === undefined ? {} : { 'Content-Type': 'application/json' }),
},
...(payload === undefined ? {} : { body: JSON.stringify(payload) }),
credentials: 'same-origin',
});
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error((body as { error?: string }).error ?? `HTTP ${response.status}`);
}
return body as T;
}
async refreshSession(): Promise<void> {
this.loading = true;
this.error = '';
try {
const code = this.normalizeCode(this.sessionCode);
this.session = await this.request<SessionDetail>(`/lobby/sessions/${encodeURIComponent(code)}`, 'GET');
this.sessionCode = this.session.session.code;
if (this.session.session.status !== 'guess') {
this.selectedGuess = '';
}
} catch (error) {
this.error = `Session refresh failed: ${(error as Error).message}`;
} finally {
this.loading = false;
}
}
async joinSession(): Promise<void> {
this.loading = true;
this.error = '';
try {
const payload = await this.request<{
player: { id: number; session_token: string };
session: { code: string };
}>('/lobby/sessions/join', 'POST', {
code: this.normalizeCode(this.sessionCode),
nickname: this.nickname.trim(),
});
this.playerId = payload.player.id;
this.sessionToken = payload.player.session_token;
this.sessionCode = payload.session.code;
await this.refreshSession();
} catch (error) {
this.error = `Join failed: ${(error as Error).message}`;
} finally {
this.loading = false;
}
}
async submitLie(): Promise<void> {
if (!this.session?.round_question?.id) {
return;
}
this.loading = true;
this.submitError = null;
try {
await this.request(
`/lobby/sessions/${encodeURIComponent(this.normalizeCode(this.sessionCode))}/questions/${this.session.round_question.id}/lies/submit`,
'POST',
{
player_id: this.playerId,
session_token: this.sessionToken,
text: this.lieText,
}
);
await this.refreshSession();
} catch (error) {
this.submitError = { kind: 'lie', message: `Lie submit failed: ${(error as Error).message}` };
} finally {
this.loading = false;
}
}
async submitGuess(): Promise<void> {
if (!this.session?.round_question?.id || !this.selectedGuess) {
return;
}
this.loading = true;
this.submitError = null;
try {
await this.request(
`/lobby/sessions/${encodeURIComponent(this.normalizeCode(this.sessionCode))}/questions/${this.session.round_question.id}/guesses/submit`,
'POST',
{
player_id: this.playerId,
session_token: this.sessionToken,
selected_text: this.selectedGuess,
}
);
await this.refreshSession();
} catch (error) {
this.submitError = { kind: 'guess', message: `Guess submit failed: ${(error as Error).message}` };
} finally {
this.loading = false;
}
}
}

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>WPP Angular Shell</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<app-root></app-root>
</body>
</html>

View File

@@ -0,0 +1,7 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';
bootstrapApplication(AppComponent, appConfig).catch((error) => {
console.error(error);
});

View File

@@ -0,0 +1,4 @@
html, body {
margin: 0;
padding: 0;
}

View File

@@ -0,0 +1 @@
import '@angular/compiler';

View File

@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": ["src/main.ts"],
"include": ["src/**/*.d.ts"]
}

View File

@@ -0,0 +1,22 @@
{
"compileOnSave": false,
"compilerOptions": {
"strict": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"sourceMap": true,
"declaration": false,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022", "dom"]
},
"angularCompilerOptions": {
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.spec.ts'],
setupFiles: ['src/test-setup.ts'],
},
});

1454
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

15
frontend/package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "wpp-frontend-api-client-baseline",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"test": "vitest run",
"build": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "^22.13.10",
"typescript": "^5.7.3",
"vitest": "^2.1.9"
}
}

View File

@@ -0,0 +1,105 @@
import type {
ApiFailure,
ApiResult,
HealthResponse,
JoinSessionRequest,
JoinSessionResponse,
SessionDetailResponse,
StartRoundRequest,
StartRoundResponse
} from './types';
export interface AngularHttpError {
status?: number;
message?: string;
error?: unknown;
}
export interface AngularHttpClientLike {
get<T>(url: string, options?: { withCredentials?: boolean }): Promise<T>;
post<T>(url: string, body: unknown, options?: { withCredentials?: boolean }): Promise<T>;
}
export interface AngularApiClient {
health(): Promise<ApiResult<HealthResponse>>;
getSession(code: string): Promise<ApiResult<SessionDetailResponse>>;
joinSession(payload: JoinSessionRequest): Promise<ApiResult<JoinSessionResponse>>;
startRound(code: string, payload: StartRoundRequest): Promise<ApiResult<StartRoundResponse>>;
}
function toFailure(error: unknown): ApiFailure {
const candidate = (error ?? {}) as AngularHttpError;
const status = typeof candidate.status === 'number' ? candidate.status : 0;
const payload = candidate.error;
if (status === 0) {
return {
kind: 'network',
status: 0,
message: candidate.message ?? 'Network error while contacting API'
};
}
return {
kind: 'http',
status,
message: candidate.message ?? `HTTP ${status}`,
...(payload === undefined ? {} : { payload })
};
}
function normalizeCode(code: string): string {
return code.trim().toUpperCase();
}
function normalizeBaseUrl(baseUrl: string): string {
return baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
}
function buildUrl(baseUrl: string, path: string): string {
return `${normalizeBaseUrl(baseUrl)}${path}`;
}
async function wrap<T>(call: () => Promise<T>): Promise<ApiResult<T>> {
try {
const data = await call();
return { ok: true, status: 200, data };
} catch (error: unknown) {
return {
ok: false,
status: typeof (error as AngularHttpError)?.status === 'number' ? (error as AngularHttpError).status! : 0,
error: toFailure(error)
};
}
}
export function createAngularApiClient(http: AngularHttpClientLike, baseUrl = ''): AngularApiClient {
return {
health: () => wrap(() => http.get<HealthResponse>(buildUrl(baseUrl, '/healthz'), { withCredentials: true })),
getSession: (code: string) =>
wrap(() =>
http.get<SessionDetailResponse>(buildUrl(baseUrl, `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}`), {
withCredentials: true
})
),
joinSession: (payload: JoinSessionRequest) =>
wrap(() =>
http.post<JoinSessionResponse>(
buildUrl(baseUrl, '/lobby/sessions/join'),
{
code: normalizeCode(payload.code),
nickname: payload.nickname.trim()
},
{ withCredentials: true }
)
),
startRound: (code: string, payload: StartRoundRequest) =>
wrap(() =>
http.post<StartRoundResponse>(
buildUrl(baseUrl, `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/rounds/start`),
payload,
{ withCredentials: true }
)
)
};
}

View File

@@ -0,0 +1,81 @@
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, method: 'GET' | 'POST', payload?: unknown): Promise<ApiResult<T>> {
let response: Response;
try {
response = await fetchImpl(`${baseUrl}${path}`, {
method,
headers: {
Accept: 'application/json',
...(payload === undefined ? {} : { 'Content-Type': 'application/json' })
},
...(payload === undefined ? {} : { body: JSON.stringify(payload) })
});
} catch {
return {
ok: false,
status: 0,
error: { kind: 'network', status: 0, message: 'Network error while contacting API' }
};
}
let responsePayload: unknown;
try {
responsePayload = await response.json();
} catch {
return {
ok: false,
status: response.status,
error: { kind: 'parse', status: response.status, message: 'Invalid JSON response from API' }
};
}
if (!response.ok) {
return {
ok: false,
status: response.status,
error: {
kind: 'http',
status: response.status,
message: `HTTP ${response.status}`,
payload: responsePayload
}
};
}
return { ok: true, status: response.status, data: responsePayload as T };
}
return {
health: () => request<HealthResponse>('/healthz', 'GET'),
getSession: (code: string) =>
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
)
};
}

115
frontend/src/api/types.ts Normal file
View File

@@ -0,0 +1,115 @@
export interface HealthResponse {
ok: boolean;
service: string;
}
export interface SessionSummary {
code: string;
status: string;
host_id: number | null;
current_round: number;
players_count: number;
}
export interface SessionPlayer {
id: number;
nickname: string;
score: number;
is_connected: boolean;
}
export interface SessionAnswer {
text: string;
}
export interface SessionRoundQuestion {
id: number;
round_number: number;
prompt: string;
shown_at: string;
answers: SessionAnswer[];
}
export interface PhaseViewModel {
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 {
session: SessionSummary;
players: SessionPlayer[];
round_question: SessionRoundQuestion | null;
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 {
kind: ApiErrorKind;
message: string;
status: number;
payload?: unknown;
}
export type ApiResult<T> =
| { ok: true; status: number; data: T }
| { ok: false; status: number; error: ApiFailure };

View File

@@ -0,0 +1,123 @@
export interface SessionContext {
sessionCode: string;
playerId: number;
token: string;
}
export interface SessionContextInput {
sessionCode: string;
playerId: number;
token: string;
}
export interface SessionContextStore {
get(): SessionContext | null;
set(input: SessionContextInput): SessionContext;
clear(): void;
}
export interface StorageLike {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
const DEFAULT_STORAGE_KEY = 'wpp.session-context';
function normalizeSessionCode(value: string): string {
return value.trim().toUpperCase();
}
function normalizeToken(value: string): string {
return value.trim();
}
function toContext(input: SessionContextInput): SessionContext {
const sessionCode = normalizeSessionCode(input.sessionCode);
const token = normalizeToken(input.token);
if (!sessionCode) {
throw new Error('sessionCode is required');
}
if (!Number.isInteger(input.playerId) || input.playerId <= 0) {
throw new Error('playerId must be a positive integer');
}
if (!token) {
throw new Error('token is required');
}
return {
sessionCode,
playerId: input.playerId,
token
};
}
function safeParse(raw: string): SessionContext | null {
try {
const data = JSON.parse(raw) as Partial<SessionContextInput>;
if (typeof data.sessionCode !== 'string' || typeof data.playerId !== 'number' || typeof data.token !== 'string') {
return null;
}
return toContext({
sessionCode: data.sessionCode,
playerId: data.playerId,
token: data.token
});
} catch {
return null;
}
}
export function createSessionContextStore(storage?: StorageLike, storageKey = DEFAULT_STORAGE_KEY): SessionContextStore {
let current: SessionContext | null = null;
function getFromStorage(): SessionContext | null {
if (!storage) {
return null;
}
const raw = storage.getItem(storageKey);
if (!raw) {
return null;
}
const parsed = safeParse(raw);
if (!parsed) {
storage.removeItem(storageKey);
return null;
}
return parsed;
}
return {
get(): SessionContext | null {
if (current) {
return { ...current };
}
const fromStorage = getFromStorage();
if (fromStorage) {
current = fromStorage;
return { ...current };
}
return null;
},
set(input: SessionContextInput): SessionContext {
const normalized = toContext(input);
current = normalized;
if (storage) {
storage.setItem(storageKey, JSON.stringify(normalized));
}
return { ...normalized };
},
clear(): void {
current = null;
if (storage) {
storage.removeItem(storageKey);
}
}
};
}

View File

@@ -0,0 +1,140 @@
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';
export type AsyncState = 'idle' | 'loading' | 'success' | 'error';
export type SessionContextStore = Pick<PersistedSessionContextStore, 'get' | 'set'>;
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,
sessionContextStore: SessionContextStore = createSessionContextStore()
): VerticalSliceController {
const persistedContext = sessionContextStore.get();
const state: VerticalSliceState = {
sessionCode: persistedContext?.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;
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;
if (!result.ok) {
state.errorMessage = 'Kunne ikke hente lobby-status.';
return { ...state };
}
state.session = result.data;
state.sessionCode = normalizeCode(result.data.session.code);
if (persistedContext && state.sessionCode === normalizeCode(persistedContext.sessionCode)) {
sessionContextStore.set({ ...persistedContext, sessionCode: state.sessionCode });
}
return { ...state };
}
async function joinLobby(sessionCode: string, nickname: string): Promise<VerticalSliceState> {
state.joinState = 'loading';
state.errorMessage = null;
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) {
state.joinState = 'error';
state.errorMessage = 'Join fejlede. Tjek kode eller nickname og prøv igen.';
return { ...state };
}
state.joinState = 'success';
state.sessionCode = normalizeCode(join.data.session.code || requestCode);
const nextContext: SessionContextInput = {
sessionCode: state.sessionCode,
playerId: join.data.player.id,
token: join.data.player.session_token
};
sessionContextStore.set(nextContext);
return hydrateLobby(state.sessionCode);
}
async function startRound(sessionCode: string, categorySlug: string): Promise<VerticalSliceState> {
state.startRoundState = 'loading';
state.errorMessage = null;
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) {
state.startRoundState = 'error';
state.errorMessage = 'Kunne ikke starte runden. Opdatér lobbyen og prøv igen.';
return { ...state };
}
state.startRoundState = 'success';
return hydrateLobby(codeToUse);
}
return {
getState: () => ({ ...state }),
hydrateLobby,
joinLobby,
startRound
};
}
export type { SessionContext };

View File

@@ -0,0 +1,221 @@
import { describe, expect, it, vi } from 'vitest';
import { createAngularApiClient, type AngularHttpClientLike } from '../src/api/angular-client';
describe('createAngularApiClient', () => {
it('reads health and session detail using Django-compatible endpoints', async () => {
const get = vi.fn<AngularHttpClientLike['get']>(async <T>(url: string) => {
if (url === '/healthz') {
return { ok: true, service: 'partyhub' } as T;
}
if (url === '/lobby/sessions/ABCD12') {
return {
session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 2 },
players: [
{ id: 2, nickname: 'Maja', score: 0, is_connected: true },
{ id: 3, nickname: 'Bo', score: 0, is_connected: false }
],
round_question: null,
phase_view_model: {
status: 'lobby',
round_number: 1,
players_count: 2,
constraints: {
min_players_to_start: 2,
max_players_mvp: 8,
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
}
}
} as T;
}
throw { status: 404, error: { error: 'Not found' } };
});
const post = vi.fn<AngularHttpClientLike['post']>(async <T>(url: string, body: unknown) => {
if (url === '/lobby/sessions/join') {
expect(body).toEqual({ code: 'ABCD12', nickname: 'Maja' });
return {
player: { id: 9, nickname: 'Maja', session_token: 'token-1', score: 0 },
session: { code: 'ABCD12', status: 'lobby' }
} as T;
}
if (url === '/lobby/sessions/ABCD12/rounds/start') {
expect(body).toEqual({ category_slug: 'history' });
return {
session: { code: 'ABCD12', status: 'lie', current_round: 1 },
round: { number: 1, category: { slug: 'history', name: 'History' } }
} as T;
}
throw { status: 404, error: { error: 'Not found' } };
});
const http = { get, post };
const client = createAngularApiClient(http as AngularHttpClientLike);
const health = await client.health();
expect(health.ok).toBe(true);
if (health.ok) {
expect(health.data.ok).toBe(true);
expect(health.data.service).toBe('partyhub');
}
const session = await client.getSession(' abcd12 ');
expect(session.ok).toBe(true);
if (session.ok) {
expect(session.data.session.code).toBe('ABCD12');
expect(session.data.session.host_id).toBe(1);
expect(session.data.phase_view_model.host.can_start_round).toBe(true);
}
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);
expect(get).toHaveBeenNthCalledWith(1, '/healthz', { withCredentials: true });
expect(get).toHaveBeenNthCalledWith(2, '/lobby/sessions/ABCD12', { withCredentials: true });
expect(post).toHaveBeenNthCalledWith(
1,
'/lobby/sessions/join',
{ code: 'ABCD12', nickname: 'Maja' },
{ withCredentials: true }
);
expect(post).toHaveBeenNthCalledWith(
2,
'/lobby/sessions/ABCD12/rounds/start',
{ category_slug: 'history' },
{ withCredentials: true }
);
});
it('normalizes baseUrl with trailing slash to keep Django endpoint paths canonical', async () => {
const get = vi.fn<AngularHttpClientLike['get']>(async <T>(url: string) => {
if (url === '/api/healthz') {
return { ok: true, service: 'partyhub' } as T;
}
if (url === '/api/lobby/sessions/ABCD12') {
return {
session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 2 },
players: [],
round_question: null,
phase_view_model: {
status: 'lobby',
round_number: 1,
players_count: 2,
constraints: {
min_players_to_start: 2,
max_players_mvp: 8,
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
}
}
} as T;
}
throw { status: 404, error: { error: 'Not found' } };
});
const post = vi.fn<AngularHttpClientLike['post']>(async <T>(url: string) => {
if (url === '/api/lobby/sessions/join') {
return {
player: { id: 9, nickname: 'Maja', session_token: 'token-1', score: 0 },
session: { code: 'ABCD12', status: 'lobby' }
} as T;
}
if (url === '/api/lobby/sessions/ABCD12/rounds/start') {
return {
session: { code: 'ABCD12', status: 'lie', current_round: 1 },
round: { number: 1, category: { slug: 'history', name: 'History' } }
} as T;
}
throw { status: 404, error: { error: 'Not found' } };
});
const client = createAngularApiClient({ get, post } as AngularHttpClientLike, '/api/');
await client.health();
await client.getSession('abcd12');
await client.joinSession({ code: 'abcd12', nickname: 'Maja' });
await client.startRound('abcd12', { category_slug: 'history' });
expect(get).toHaveBeenNthCalledWith(1, '/api/healthz', { withCredentials: true });
expect(get).toHaveBeenNthCalledWith(2, '/api/lobby/sessions/ABCD12', { withCredentials: true });
expect(post).toHaveBeenNthCalledWith(
1,
'/api/lobby/sessions/join',
{ code: 'ABCD12', nickname: 'Maja' },
{ withCredentials: true }
);
expect(post).toHaveBeenNthCalledWith(
2,
'/api/lobby/sessions/ABCD12/rounds/start',
{ category_slug: 'history' },
{ withCredentials: true }
);
});
it('maps HttpErrorResponse-style failures to ApiResult errors', async () => {
const http = {
get: vi.fn<AngularHttpClientLike['get']>(async () => {
throw { status: 503, message: 'Service unavailable', error: { error: 'maintenance' } };
}),
post: vi.fn<AngularHttpClientLike['post']>(async () => {
throw { status: 403, message: 'Forbidden', error: { error: 'Only host can start round' } };
})
};
const client = createAngularApiClient(http as AngularHttpClientLike);
const health = await client.health();
expect(health.ok).toBe(false);
if (!health.ok) {
expect(health.status).toBe(503);
expect(health.error.kind).toBe('http');
expect(health.error.payload).toEqual({ error: 'maintenance' });
expect(health.error.message).toContain('Service unavailable');
}
const start = await client.startRound('ABCD12', { category_slug: 'history' });
expect(start.ok).toBe(false);
if (!start.ok) {
expect(start.status).toBe(403);
expect(start.error.kind).toBe('http');
expect(start.error.payload).toEqual({ error: 'Only host can start round' });
}
});
});

View File

@@ -0,0 +1,148 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { AddressInfo } from 'node:net';
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import { createApiClient } from '../src/api/client';
let server: Server;
let baseUrl: string;
beforeAll(async () => {
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' && 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: 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
}
}
})
);
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;
}
if (req.url?.startsWith('/lobby/sessions/')) {
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'Session not found' }));
return;
}
res.writeHead(500, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'unexpected route' }));
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
const { port } = server.address() as AddressInfo;
baseUrl = `http://127.0.0.1:${port}`;
});
afterAll(async () => {
await new Promise<void>((resolve, reject) =>
server.close((err?: Error) => (err ? reject(err) : resolve()))
);
});
describe('createApiClient', () => {
it('reads health + session detail through typed wrappers', async () => {
const client = createApiClient(baseUrl);
const health = await client.health();
expect(health.ok).toBe(true);
const session = await client.getSession('abcd12');
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');
}
});
it('returns consistent HTTP error shape for 4xx/5xx', async () => {
const client = createApiClient(baseUrl);
const missing = await client.getSession('missing');
expect(missing.ok).toBe(false);
if (!missing.ok) {
expect(missing.status).toBe(404);
expect(missing.error.kind).toBe('http');
expect(missing.error.payload).toEqual({ error: 'Session not found' });
}
});
it('returns consistent network error shape', async () => {
const client = createApiClient('http://127.0.0.1:9');
const health = await client.health();
expect(health.ok).toBe(false);
if (!health.ok) {
expect(health.error.kind).toBe('network');
expect(health.status).toBe(0);
}
});
});

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import { createSessionContextStore, type StorageLike } from '../src/spa/session-context-store';
function makeMemoryStorage(seed?: Record<string, string>): StorageLike {
const memory = new Map<string, string>(Object.entries(seed ?? {}));
return {
getItem: (key: string) => memory.get(key) ?? null,
setItem: (key: string, value: string) => {
memory.set(key, value);
},
removeItem: (key: string) => {
memory.delete(key);
}
};
}
describe('session context store', () => {
it('normalizes and persists sessionCode/playerId/token', () => {
const storage = makeMemoryStorage();
const store = createSessionContextStore(storage, 'ctx');
const value = store.set({ sessionCode: ' abcd12 ', playerId: 12, token: ' token-1 ' });
expect(value).toEqual({ sessionCode: 'ABCD12', playerId: 12, token: 'token-1' });
expect(store.get()).toEqual({ sessionCode: 'ABCD12', playerId: 12, token: 'token-1' });
expect(storage.getItem('ctx')).toBe('{"sessionCode":"ABCD12","playerId":12,"token":"token-1"}');
});
it('loads from storage and clears invalid payloads', () => {
const storage = makeMemoryStorage({ ctx: '{"sessionCode":"","playerId":0,"token":""}' });
const store = createSessionContextStore(storage, 'ctx');
expect(store.get()).toBeNull();
expect(storage.getItem('ctx')).toBeNull();
});
it('supports clear()', () => {
const storage = makeMemoryStorage();
const store = createSessionContextStore(storage, 'ctx');
store.set({ sessionCode: 'ABCD12', playerId: 3, token: 'token-3' });
store.clear();
expect(store.get()).toBeNull();
expect(storage.getItem('ctx')).toBeNull();
});
it('rejects invalid context writes', () => {
const store = createSessionContextStore();
expect(() => store.set({ sessionCode: '', playerId: 1, token: 'token-1' })).toThrow('sessionCode is required');
expect(() => store.set({ sessionCode: 'ABCD12', playerId: 0, token: 'token-1' })).toThrow(
'playerId must be a positive integer'
);
expect(() => store.set({ sessionCode: 'ABCD12', playerId: 2, token: ' ' })).toThrow('token is required');
});
});

View File

@@ -0,0 +1,226 @@
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(),
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 };
}
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' } }
})
});
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');
});
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');
});
});

12
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"lib": ["ES2022", "DOM"],
"types": ["vitest/globals", "node"]
},
"include": ["src", "tests"]
}

View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
exclude: ['**/node_modules/**']
}
});

View File

@@ -22,7 +22,11 @@ Forventet:
- service er active
- healthz returnerer JSON med ok=true
Efter deploy vil scriptet også verificere at `DB_ENGINE` ikke er `django.db.backends.sqlite3` før migrations køres.
Smoke-suite skriver nu et gameplay-artifact som JSON under `/opt/wpp-staging/app/artifacts/smoke/` (kan overrides via `ARTIFACT_DIR`/`ARTIFACT_FILE`).
Efter deploy validerer scriptet, at `DB_ENGINE` ikke er `django.db.backends.sqlite3` før migrations køres.
Deploy-scriptet bruger en release-candidate mappe og promoverer først til `/opt/wpp-staging/app` efter succesfuld `migrate`. Det reducerer schema/code drift ved afbrudte deploys (issue #130) og understøtter release-readiness gate (issue #90).
## Deploy (canonical execution context)
Deploy skal altid køres via Proxmox host over SSH (ikke fra lokal coder-shell med direkte sudo pct).

View File

@@ -9,42 +9,88 @@ PROXMOX_HOST="${PROXMOX_HOST:-proxmox-lan}"
echo "[deploy] host=${PROXMOX_HOST} CT_ID=${CT_ID} REF=${REF_NAME}"
echo "[deploy] extracting source + installing deps + migrate + restart"
ssh "${PROXMOX_HOST}" "sudo -n /usr/sbin/pct exec ${CT_ID} -- bash -lc \"set -euo pipefail
mkdir -p /opt/wpp-staging/releases/src
cd /opt/wpp-staging/releases
curl -fsSL \\\"${ARCHIVE_URL}\\\" -o app.tar.gz
rm -rf src && mkdir src
tar -xzf app.tar.gz -C src --strip-components=1
rm -rf /opt/wpp-staging/app/*
cp -a src/. /opt/wpp-staging/app/
# Ensure deploy artifact copied as root does not leave app tree non-writable for wpp.
chown -R wpp:wpp /opt/wpp-staging/app
# Staging must not run on SQLite (issue #133). Remove bundled sqlite artifact.
rm -f /opt/wpp-staging/app/db.sqlite3
cd /opt/wpp-staging/app
runuser -u wpp -- python3 -m venv .venv
runuser -u wpp -- .venv/bin/pip install -U pip >/dev/null
runuser -u wpp -- .venv/bin/pip install -r requirements.txt >/dev/null
STAGING_ENV_FILE=\"\"
for candidate in \
/opt/wpp-staging/app/infra/staging/.env.staging \
/opt/wpp-staging/app/infra/env/.env.staging \
/opt/wpp-staging/.env.staging; do
if [ -f \"\$candidate\" ]; then
STAGING_ENV_FILE=\"\$candidate\"
ssh "${PROXMOX_HOST}" sudo -n /usr/sbin/pct exec "${CT_ID}" -- bash -s -- "${ARCHIVE_URL}" <<'REMOTE'
set -euo pipefail
ARCHIVE_URL="$1"
RELEASES_DIR=/opt/wpp-staging/releases
CANDIDATE_DIR="${RELEASES_DIR}/src"
APP_DIR=/opt/wpp-staging/app
install -d -m 0755 -o wpp -g wpp "${RELEASES_DIR}" "${APP_DIR}"
mkdir -p "${CANDIDATE_DIR}"
cd "${RELEASES_DIR}"
curl -fsSL "${ARCHIVE_URL}" -o app.tar.gz
rm -rf "${CANDIDATE_DIR}" && mkdir -p "${CANDIDATE_DIR}"
tar -xzf app.tar.gz -C "${CANDIDATE_DIR}" --strip-components=1
# Ensure deploy artifact copied as root does not leave candidate tree non-writable for wpp.
chown -R wpp:wpp "${CANDIDATE_DIR}"
# Staging must not run on SQLite (issue #133). Remove bundled sqlite artifact from candidate.
rm -f "${CANDIDATE_DIR}/db.sqlite3"
cd "${CANDIDATE_DIR}"
# Load staging env before any manage.py call (issue #133 follow-up).
ENV_LOADED=0
for ENV_FILE in \
/opt/wpp-staging/.env.staging \
/opt/wpp-staging/.env \
/opt/wpp-staging/env/wpp_staging.env \
/opt/wpp-staging/secrets/wpp_staging.env
do
if [ -f "${ENV_FILE}" ]; then
set -a
. "${ENV_FILE}"
set +a
echo "[deploy] loaded staging env: ${ENV_FILE}"
ENV_LOADED=1
break
fi
done
if [ -z \"\$STAGING_ENV_FILE\" ]; then
echo \"[deploy] ERROR: staging env file not found (.env.staging)\" >&2
if [ "${ENV_LOADED}" -ne 1 ]; then
echo "[deploy] ERROR: no staging env file found"
exit 1
fi
set -a
. \"\$STAGING_ENV_FILE\"
set +a
runuser -u wpp -- .venv/bin/python manage.py shell -c \"from django.conf import settings; import sys; engine = settings.DATABASES['default']['ENGINE']; print(f'DB_ENGINE={engine}'); sys.exit(0 if engine != 'django.db.backends.sqlite3' else 1)\"
runuser -u wpp -- python3 -m venv .venv
runuser -u wpp -- .venv/bin/pip install -U pip >/dev/null
runuser -u wpp -- .venv/bin/pip install -r requirements.txt >/dev/null
runuser -u wpp -- .venv/bin/python manage.py shell -c "from django.conf import settings; import sys; engine = settings.DATABASES['default']['ENGINE']; print(f'DB_ENGINE={engine}'); sys.exit(0 if engine != 'django.db.backends.sqlite3' else 1)"
runuser -u wpp -- .venv/bin/python manage.py migrate --noinput
# Promote candidate only after migrations succeed (issue #130): avoid code/schema drift after failed deploy.
# Use find instead of rm "${APP_DIR}"/* to reliably remove dotfiles between deploys.
find "${APP_DIR}" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
cp -a "${CANDIDATE_DIR}"/. "${APP_DIR}/"
chown -R wpp:wpp "${APP_DIR}"
runuser -u wpp -- test -w "${APP_DIR}"
systemctl restart wpp-staging.service
curl -fsS http://127.0.0.1:8000/healthz\""
HEALTH_URL="http://127.0.0.1:8000/healthz"
MAX_ATTEMPTS=7
SLEEP_SECONDS=1
ATTEMPT=1
until curl -fsS "${HEALTH_URL}" >/dev/null; do
if [ "${ATTEMPT}" -ge "${MAX_ATTEMPTS}" ]; then
echo "[deploy] ERROR: health check failed after ${MAX_ATTEMPTS} attempts: ${HEALTH_URL}" >&2
echo "[deploy] service status (wpp-staging.service):" >&2
systemctl --no-pager --full status wpp-staging.service >&2 || true
echo "[deploy] recent service logs (wpp-staging.service):" >&2
journalctl --no-pager -u wpp-staging.service -n 80 >&2 || true
exit 1
fi
echo "[deploy] health check not ready (attempt ${ATTEMPT}/${MAX_ATTEMPTS}); retrying in ${SLEEP_SECONDS}s"
sleep "${SLEEP_SECONDS}"
ATTEMPT=$((ATTEMPT + 1))
if [ "${SLEEP_SECONDS}" -lt 8 ]; then
SLEEP_SECONDS=$((SLEEP_SECONDS * 2))
fi
done
echo "[deploy] health check passed: ${HEALTH_URL}"
REMOTE
echo "[deploy] OK: staging deploy complete for CT ${CT_ID} (${REF_NAME})"

View File

@@ -50,10 +50,30 @@ PY
echo "[smoke] healthz check: ${BASE_URL}/healthz"
curl -fsS "${BASE_URL}/healthz" >/dev/null || { SMOKE_FAIL_MESSAGE="healthz check failed" fail "healthz check failed"; }
echo "[smoke] gameplay flow via management command"
(
cd "${APP_DIR}"
.venv/bin/python manage.py smoke_staging
) || { SMOKE_FAIL_MESSAGE="manage.py smoke_staging failed" fail "manage.py smoke_staging failed"; }
ENV_FILE="${ENV_FILE:-/etc/wpp/staging.env}"
run_manage() {
local cmd="$1"
(
cd "${APP_DIR}"
if [[ -f "${ENV_FILE}" ]]; then
set -a
# shellcheck disable=SC1090
source "${ENV_FILE}"
set +a
fi
.venv/bin/python manage.py ${cmd}
)
}
echo "[smoke] migration consistency check"
run_manage "migrate --check --noinput" || { SMOKE_FAIL_MESSAGE="schema drift: unapplied migrations in staging" fail "schema drift: unapplied migrations in staging"; }
ARTIFACT_DIR="${ARTIFACT_DIR:-${APP_DIR}/artifacts/smoke}"
ARTIFACT_FILE="${ARTIFACT_FILE:-${ARTIFACT_DIR}/smoke-$(date -u +%Y%m%dT%H%M%SZ).json}"
echo "[smoke] gameplay flow via management command"
run_manage "smoke_staging --artifact ${ARTIFACT_FILE}" || { SMOKE_FAIL_MESSAGE="manage.py smoke_staging failed" fail "manage.py smoke_staging failed"; }
echo "[smoke] artifact: ${ARTIFACT_FILE}"
echo "[smoke] OK"

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

@@ -1,4 +1,6 @@
import json
from datetime import datetime, timezone
from pathlib import Path
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
@@ -10,6 +12,12 @@ from fupogfakta.models import Category, GameSession, Player, Question, RoundQues
class Command(BaseCommand):
help = "Run minimal staging smoke flow for lobby gameplay"
def add_arguments(self, parser):
parser.add_argument(
"--artifact",
help="Optional path to write smoke result artifact as JSON",
)
def handle(self, *args, **options):
GameSession.objects.all().delete()
Player.objects.all().delete()
@@ -72,7 +80,13 @@ class Command(BaseCommand):
nick = player["nickname"]
lie_res = Client().post(
f"/lobby/sessions/{code}/questions/{round_question_id}/lies/submit",
data=json.dumps({"player_id": player["id"], "text": f"Lie from {nick}"}),
data=json.dumps(
{
"player_id": player["id"],
"session_token": player["session_token"],
"text": f"Lie from {nick}",
}
),
content_type="application/json",
)
if lie_res.status_code != 201:
@@ -94,7 +108,13 @@ class Command(BaseCommand):
selected = next((a for a in answers if a.get("player_id") != player["id"]), answers[0])
guess_res = Client().post(
f"/lobby/sessions/{code}/questions/{round_question_id}/guesses/submit",
data=json.dumps({"player_id": player["id"], "selected_text": selected["text"]}),
data=json.dumps(
{
"player_id": player["id"],
"session_token": player["session_token"],
"selected_text": selected["text"],
}
),
content_type="application/json",
)
if guess_res.status_code != 201:
@@ -115,4 +135,30 @@ class Command(BaseCommand):
if finish_res.status_code != 200:
raise CommandError(f"finish_game failed: {finish_res.status_code}")
artifact_path = options.get("artifact")
if artifact_path:
artifact = {
"ok": True,
"command": "smoke_staging",
"generated_at": datetime.now(timezone.utc).isoformat(),
"session_code": code,
"players": [player["nickname"] for player in players],
"round_question_id": round_question_id,
"steps": [
"create_session",
"join_players",
"start_round",
"show_question",
"submit_lies",
"mix_answers",
"submit_guesses",
"calculate_scores",
"reveal_scoreboard",
"finish_game",
],
}
output_path = Path(artifact_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(artifact, indent=2) + "\n", encoding="utf-8")
self.stdout.write(self.style.SUCCESS(f"Smoke flow OK for session {code}"))

View File

@@ -1,5 +1,16 @@
<!doctype html>
<html lang="da"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>WPP Host</title></head>
<html lang="da"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>WPP Host</title>
<style>
.critical-card { margin: 10px 0 12px; padding: 10px; border-radius: 10px; border: 1px solid #cbd5e1; background: #f8fafc; }
.critical-card h2 { margin: 0 0 6px; font-size: 1rem; }
.critical-card p { margin: 0; color: #334155; }
.skeleton-line { position: relative; overflow: hidden; height: 12px; border-radius: 999px; margin-top: 8px; background: #e2e8f0; }
.skeleton-line:first-of-type { margin-top: 4px; }
.skeleton-line.short { width: 60%; }
.skeleton-line::after { content: ""; position: absolute; inset: 0; transform: translateX(-100%); background: linear-gradient(90deg, rgba(226,232,240,0) 0%, rgba(255,255,255,0.85) 45%, rgba(226,232,240,0) 100%); animation: wppShimmer 1.2s infinite; }
@keyframes wppShimmer { to { transform: translateX(100%); } }
</style>
</head>
<body>
<h1>Host panel (MVP)</h1>
<p>Kræver login som host-bruger.</p>
@@ -28,6 +39,22 @@
<button id="autoRefreshToggleBtn" onclick="toggleAutoRefresh()">Auto-refresh: OFF</button>
<p id="autoRefreshHint">Auto-refresh er slået fra.</p>
<p id="lastRefreshStatus">Session-data ikke opdateret endnu.</p>
<div id="hostShellErrorBoundary" style="display:none;margin:8px 0 10px;padding:8px 10px;border-radius:8px;border:1px solid #b91c1c;background:#fee2e2;color:#7f1d1d;">Der opstod en kritisk fejl i app-skallen.
<button id="hostRecoverRetryBtn" type="button" onclick="recoverHostShell('retry')">Prøv gendan</button>
<button id="hostRecoverReloadBtn" type="button" onclick="recoverHostShell('reload')">Genindlæs siden</button>
</div>
<section id="hostCriticalSkeleton" class="critical-card" aria-live="polite">
<h2>Indlæser host-overblik…</h2>
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
</section>
<section id="hostCriticalView" class="critical-card" style="display:none" aria-live="polite">
<h2>Host-overblik</h2>
<p id="hostCriticalPhase">Fase: afventer</p>
<p id="hostCriticalPlayers">Spillere: afventer</p>
<p id="hostCriticalRound">Aktiv round question: afventer</p>
</section>
<pre id="out">Klar.</pre>
<script>
var currentSessionStatus="";
@@ -37,13 +64,31 @@ var hostActionInFlight=false;
var lastRefreshAtLabel="";
var lastRefreshFailed=false;
var sessionDetailInFlight=false;
var hostShellRouteHint="";
var HOST_SHELL_ROUTES={lobby:"lobby",lie:"lie",guess:"guess",reveal:"reveal",finished:"finished"};
var hostShellFatalError=false;
var hostShellRecoverInFlight=false;
var hostCriticalHydrated=false;
function setHostCriticalLoading(isLoading){var skeleton=document.getElementById("hostCriticalSkeleton");var view=document.getElementById("hostCriticalView");if(!skeleton||!view){return;}skeleton.style.display=isLoading?"block":"none";view.style.display=isLoading?"none":"block";}
function hydrateHostCriticalView(data){var session=(data&&data.session)||{};var phaseEl=document.getElementById("hostCriticalPhase");var playersEl=document.getElementById("hostCriticalPlayers");var roundEl=document.getElementById("hostCriticalRound");if(phaseEl){phaseEl.textContent="Fase: "+phaseLabel(currentSessionStatus||session.status||"");}
if(playersEl){playersEl.textContent="Spillere: "+(typeof session.players_count==="number"?session.players_count:"ukendt");}
if(roundEl){roundEl.textContent="Aktiv round question: "+(rq()||"ikke valgt");}
hostCriticalHydrated=true;
setHostCriticalLoading(false);
}
function csrf(){var m=document.cookie.match(/csrftoken=([^;]+)/);return m?m[1]:"";}
function updateHostShellErrorBoundary(){var panel=document.getElementById("hostShellErrorBoundary");var retryBtn=document.getElementById("hostRecoverRetryBtn");var reloadBtn=document.getElementById("hostRecoverReloadBtn");if(!panel||!retryBtn||!reloadBtn){return;}panel.style.display=hostShellFatalError?"block":"none";retryBtn.disabled=hostShellRecoverInFlight||sessionDetailInFlight||!code();reloadBtn.disabled=hostShellRecoverInFlight;}
function setHostShellFatalError(detail){hostShellFatalError=true;var out=document.getElementById("out");if(out){out.textContent=JSON.stringify({status:0,data:{error:"host_shell_runtime_error",detail:detail||"Ukendt runtime-fejl"}},null,2);}var hint=document.getElementById("hostErrorHint");if(hint){hint.textContent="Fejl: Kritisk app-fejl. Brug recover-handlingerne for at fortsætte.";}updateHostShellErrorBoundary();}
function clearHostShellFatalError(){hostShellFatalError=false;hostShellRecoverInFlight=false;updateHostShellErrorBoundary();}
function recoverHostShell(mode){if(hostShellRecoverInFlight){return Promise.resolve({error:"recover_in_flight"});}hostShellRecoverInFlight=true;updateHostShellErrorBoundary();if(mode==="reload"){window.location.reload();return Promise.resolve({ok:true});}if(!code()){hostShellRecoverInFlight=false;updateHostShellErrorBoundary();return Promise.resolve({error:"missing_session_code"});}return sessionDetail().then(function(result){clearHostShellFatalError();return result;}).catch(function(err){hostShellRecoverInFlight=false;updateHostShellErrorBoundary();throw err;});}
function code(){return document.getElementById("code").value.trim().toUpperCase();}
function rq(){return document.getElementById("roundQuestionId").value.trim();}
function saveHostContext(){try{localStorage.setItem("wppHostContext",JSON.stringify({code:code(),round_question_id:rq(),session_status:currentSessionStatus||"",auto_refresh:autoRefreshEnabled}));}catch(_e){}}
function restoreHostContext(){try{var raw=localStorage.getItem("wppHostContext");if(!raw){return false;}var ctx=JSON.parse(raw);if(ctx.code){document.getElementById("code").value=(ctx.code||"").toUpperCase();}if(ctx.round_question_id){document.getElementById("roundQuestionId").value=ctx.round_question_id;}if(ctx.session_status){currentSessionStatus=ctx.session_status;}autoRefreshEnabled=!!ctx.auto_refresh;updateAutoRefreshUi();return !!ctx.code;}catch(_e){return false;}}
function phaseLabel(status){if(status==="lobby"){return"Lobby";}if(status==="lie"){return"Løgn";}if(status==="guess"){return"Gæt";}if(status==="reveal"){return"Reveal";}if(status==="finished"){return"Afsluttet";}return"Ukendt";}
function hostShellRouteFromPath(){var marker="/lobby/ui/host";var path=(window.location.pathname||"").toLowerCase();var idx=path.indexOf(marker);if(idx===-1){return"";}var remainder=path.slice(idx+marker.length).replace(/^\/+|\/+$/g,"");if(!remainder){return"";}var route=remainder.split("/")[0];return HOST_SHELL_ROUTES[route]?route:"";}
function expectedHostShellRoute(){return HOST_SHELL_ROUTES[currentSessionStatus]||"";}
function syncHostShellRoute(){var currentRoute=hostShellRouteFromPath();var expectedRoute=expectedHostShellRoute();if(!currentRoute||!expectedRoute){hostShellRouteHint="";return;}if(currentRoute===expectedRoute){hostShellRouteHint="";return;}var nextPath="/lobby/ui/host/"+expectedRoute;window.history.replaceState(null,"",nextPath);hostShellRouteHint="Deep-link route guard: omdirigeret fra /"+currentRoute+" til /"+expectedRoute+" for fase "+phaseLabel(currentSessionStatus)+".";}
function updateAutoRefreshUi(){var btn=document.getElementById("autoRefreshToggleBtn");var hint=document.getElementById("autoRefreshHint");if(btn){btn.textContent="Auto-refresh: "+(autoRefreshEnabled?"ON":"OFF");btn.disabled=hostActionInFlight||sessionDetailInFlight||!code();}if(!hint){return;}if(hostActionInFlight){hint.textContent="Auto-refresh-lås: afvent aktiv host-handling.";return;}if(sessionDetailInFlight){hint.textContent="Auto-refresh-lås: afvent aktiv session-opdatering.";return;}if(!autoRefreshEnabled){hint.textContent="Auto-refresh er slået fra.";return;}if(!code()){hint.textContent="Auto-refresh venter: angiv sessionkode.";return;}if(currentSessionStatus==="finished"){hint.textContent="Auto-refresh stoppet: spillet er afsluttet.";return;}hint.textContent="Auto-refresh aktiv (10s) for lobby-status.";}
function stopAutoRefresh(reason){autoRefreshEnabled=false;if(autoRefreshTimer){clearInterval(autoRefreshTimer);autoRefreshTimer=null;}if(reason){var hint=document.getElementById("autoRefreshHint");if(hint){hint.textContent=reason;}}updateAutoRefreshUi();saveHostContext();}
function startAutoRefresh(){if(!code()){updateAutoRefreshUi();return;}autoRefreshEnabled=true;if(autoRefreshTimer){clearInterval(autoRefreshTimer);}autoRefreshTimer=setInterval(function(){if(!code()||sessionDetailInFlight){return;}if(currentSessionStatus==="finished"){stopAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");return;}sessionDetail();},10000);updateAutoRefreshUi();saveHostContext();}
@@ -54,12 +99,14 @@ function updateLastRefreshStatus(){var el=document.getElementById("lastRefreshSt
function normalizeApiError(data){if(!data||typeof data!=="object"){return"";}return (data.error_code||data.error||"").toString();}
function mapUiErrorMessage(errorKey){if(!errorKey){return"";}var key=errorKey.toLowerCase();if(key.indexOf("phase")!==-1){return"Ugyldig fase for handlingen. Opdatér session-status og prøv igen.";}if(key.indexOf("token")!==-1||key.indexOf("auth")!==-1){return"Session-token er ugyldig eller udløbet. Rejoin sessionen og prøv igen.";}if(key.indexOf("round")!==-1||key.indexOf("question")!==-1||key.indexOf("state")!==-1){return"Runde-kontekst matcher ikke længere. Opdatér session-status før næste handling.";}if(key.indexOf("session")!==-1){return"Sessionkoden er ugyldig eller sessionen findes ikke længere.";}return"Handling fejlede. Opdatér session-status og prøv igen.";}
function updateErrorHint(status,data){var el=document.getElementById("hostErrorHint");if(!el){return;}if(status>=200&&status<300){el.textContent="Ingen fejl.";return;}var errKey=normalizeApiError(data);el.textContent="Fejl: "+mapUiErrorMessage(errKey)+" ("+(errKey||("http_"+status))+")";}
function updateCreateSessionState(){var btn=document.getElementById("createSessionBtn");var hint=document.getElementById("createSessionHint");if(btn){btn.disabled=hostActionInFlight||sessionDetailInFlight;}if(!hint){return;}if(hostActionInFlight){hint.textContent="Opret session er låst mens en host-handling kører.";return;}if(sessionDetailInFlight){hint.textContent="Opret session er låst mens session-opdatering kører.";return;}hint.textContent="Opret session er klar.";}function updateSessionDetailState(){var btn=document.getElementById("sessionDetailBtn");var hint=document.getElementById("sessionDetailHint");var codeInput=document.getElementById("code");if(btn){btn.disabled=sessionDetailInFlight||hostActionInFlight||!code();}if(codeInput){codeInput.readOnly=sessionDetailInFlight||hostActionInFlight;}if(!hint){return;}if(sessionDetailInFlight){hint.textContent="Opdaterer session-status…";return;}if(hostActionInFlight){hint.textContent="Session-opdatering er låst mens en host-handling kører.";return;}if(!code()){hint.textContent="Angiv sessionkode for at opdatere session-status.";return;}hint.textContent="Session-opdatering klar.";}
function updateCreateSessionState(){var btn=document.getElementById("createSessionBtn");var hint=document.getElementById("createSessionHint");if(btn){btn.disabled=hostActionInFlight||sessionDetailInFlight;}if(!hint){return;}if(hostActionInFlight){hint.textContent="Opret session er låst mens en host-handling kører.";return;}if(sessionDetailInFlight){hint.textContent="Opret session er låst mens session-opdatering kører.";return;}hint.textContent="Opret session er klar.";}function updateSessionDetailState(){var btn=document.getElementById("sessionDetailBtn");var hint=document.getElementById("sessionDetailHint");var codeInput=document.getElementById("code");if(btn){btn.disabled=sessionDetailInFlight||hostActionInFlight||!code();}if(codeInput){codeInput.readOnly=sessionDetailInFlight||hostActionInFlight;}if(!hint){return;}if(sessionDetailInFlight){hint.textContent="Opdaterer session-status…";return;}if(hostActionInFlight){hint.textContent="Session-opdatering er låst mens en host-handling kører.";return;}if(!code()){hint.textContent="Angiv sessionkode for at opdatere session-status.";updateHostShellErrorBoundary();return;}hint.textContent="Session-opdatering klar.";updateHostShellErrorBoundary();}
function updatePhaseStatus(){var el=document.getElementById("phaseStatus");if(!el){return;}if(!currentSessionStatus){el.textContent="Fase: ukendt (opdatér session-status).";return;}el.textContent="Fase: "+phaseLabel(currentSessionStatus)+" ("+currentSessionStatus+")";}
function updatePhaseStatus(){var el=document.getElementById("phaseStatus");syncHostShellRoute();if(!el){return;}if(!currentSessionStatus){el.textContent="Fase: ukendt (opdatér session-status).";return;}el.textContent="Fase: "+phaseLabel(currentSessionStatus)+" ("+currentSessionStatus+")";}
function syncStartRoundGuard(data){var btn=document.getElementById("startRoundBtn");var hint=document.getElementById("startRoundHint");var status=document.getElementById("playerCountStatus");if(!btn||!hint||!status){return;}var count=(data&&data.session&&typeof data.session.players_count==="number")?data.session.players_count:null;var phase=currentSessionStatus||"";if(phase&&phase!=="lobby"){btn.disabled=true;status.textContent=count===null?"Spillere i session: ukendt":"Spillere i session: "+count;hint.textContent="Start runde er kun tilladt i lobby-fasen.";return;}if(count===null){btn.disabled=true;status.textContent="Spillere i session: ukendt";hint.textContent="Opdatér session-status for at validere 3-5 spillere.";return;}status.textContent="Spillere i session: "+count;if(count<3){btn.disabled=true;hint.textContent="Mangler spillere: kræver mindst 3 for at starte runde.";return;}if(count>5){btn.disabled=true;hint.textContent="For mange spillere: maks 5 i MVP før runde-start.";return;}btn.disabled=false;hint.textContent="Klar: spillerantal er indenfor 3-5 til runde-start.";}
function updateHostActionState(){updateCreateSessionState();var hasCode=!!code();var hasRound=!!rq();var phase=currentSessionStatus||"";var showQuestionBtn=document.getElementById("showQuestionBtn");var mixAnswersBtn=document.getElementById("mixAnswersBtn");var calcScoresBtn=document.getElementById("calcScoresBtn");var showScoreboardBtn=document.getElementById("showScoreboardBtn");var nextRoundBtn=document.getElementById("nextRoundBtn");var finishGameBtn=document.getElementById("finishGameBtn");var roundQuestionInput=document.getElementById("roundQuestionId");var roundQuestionGuardHint=document.getElementById("roundQuestionGuardHint");var categorySelect=document.getElementById("category");var categoryGuardHint=document.getElementById("categoryGuardHint");var hint=document.getElementById("hostActionHint");if(showQuestionBtn){showQuestionBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="lie";}if(showScoreboardBtn){showScoreboardBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}if(nextRoundBtn){nextRoundBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}if(finishGameBtn){finishGameBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}if(mixAnswersBtn){mixAnswersBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||!hasRound||(phase!=="lie"&&phase!=="guess");}if(calcScoresBtn){calcScoresBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||!hasRound||phase!=="guess";}var canEditRoundQuestion=!!hasCode&&(phase==="lie"||phase==="guess"||phase==="reveal");if(roundQuestionInput){roundQuestionInput.disabled=hostActionInFlight||sessionDetailInFlight||!canEditRoundQuestion;}if(roundQuestionGuardHint){if(hostActionInFlight){roundQuestionGuardHint.textContent="Round question-id er låst mens en handling kører.";}else if(sessionDetailInFlight){roundQuestionGuardHint.textContent="Round question-id er låst mens session-opdatering kører.";}else if(!hasCode){roundQuestionGuardHint.textContent="Angiv sessionkode for at redigere round question-id.";}else if(!phase){roundQuestionGuardHint.textContent="Opdatér session-status for round question-id.";}else if(canEditRoundQuestion){roundQuestionGuardHint.textContent="Round question-id kan redigeres i fase: "+phaseLabel(phase)+".";}else{roundQuestionGuardHint.textContent="Round question-id er låst i fase: "+phaseLabel(phase)+".";}}if(categorySelect){categorySelect.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="lobby";}if(categoryGuardHint){if(hostActionInFlight){categoryGuardHint.textContent="Kategori er midlertidigt låst mens en handling kører.";}else if(sessionDetailInFlight){categoryGuardHint.textContent="Kategori er låst mens session-opdatering kører.";}else if(!hasCode){categoryGuardHint.textContent="Angiv sessionkode for at låse kategori til lobby-fasen.";}else if(phase==="lobby"){categoryGuardHint.textContent="Kategori kan vælges i lobby-fasen.";}else if(!phase){categoryGuardHint.textContent="Opdatér session-status for at validere kategori-lås.";}else{categoryGuardHint.textContent="Kategori er låst udenfor lobby-fasen.";}}if(!hint){return;}if(hostActionInFlight){hint.textContent="Handling kører… afvent svar før næste klik.";return;}if(sessionDetailInFlight){hint.textContent="Host-actions er låst mens session-opdatering kører.";return;}if(!hasCode){hint.textContent="Angiv sessionkode for at aktivere host-actions.";return;}if(!phase){hint.textContent="Opdatér session-status for fasebaserede host-actions.";return;}if(phase==="finished"){hint.textContent="Spillet er afsluttet: gameplay-actions er låst.";return;}if((phase==="lie"||phase==="guess")&&!hasRound){hint.textContent="Round question id mangler: mix/beregn score er låst.";return;}hint.textContent="Host-actions er klar for fase: "+phaseLabel(phase)+".";}
async function api(path,method,payload){var o={method:method||"GET",headers:{"Accept":"application/json"}};if(payload!==null){o.headers["Content-Type"]="application/json";o.headers["X-CSRFToken"]=csrf();o.body=JSON.stringify(payload);}var r=await fetch(path,o);var d=await r.json().catch(function(){return {};});var isSessionDetailRead=(method||"GET")==="GET"&&/^\/lobby\/sessions\/[A-Z0-9]+$/.test(path);if(isSessionDetailRead){markSessionRefresh(r.status);}document.getElementById("out").textContent=JSON.stringify({status:r.status,data:d},null,2);if(d.session&&d.session.code){document.getElementById("code").value=d.session.code;}if(d.session&&d.session.status){currentSessionStatus=d.session.status;}if(d.round_question&&d.round_question.id){document.getElementById("roundQuestionId").value=d.round_question.id;}updateErrorHint(r.status,d);updatePhaseStatus();syncStartRoundGuard(d);updateHostActionState();if(currentSessionStatus==="finished"&&autoRefreshEnabled){stopAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");}else{updateAutoRefreshUi();}saveHostContext();return d;}
function updateHostActionState(){updateCreateSessionState();var hasCode=!!code();var hasRound=!!rq();var phase=currentSessionStatus||"";var showQuestionBtn=document.getElementById("showQuestionBtn");var mixAnswersBtn=document.getElementById("mixAnswersBtn");var calcScoresBtn=document.getElementById("calcScoresBtn");var showScoreboardBtn=document.getElementById("showScoreboardBtn");var nextRoundBtn=document.getElementById("nextRoundBtn");var finishGameBtn=document.getElementById("finishGameBtn");var roundQuestionInput=document.getElementById("roundQuestionId");var roundQuestionGuardHint=document.getElementById("roundQuestionGuardHint");var categorySelect=document.getElementById("category");var categoryGuardHint=document.getElementById("categoryGuardHint");var hint=document.getElementById("hostActionHint");if(showQuestionBtn){showQuestionBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="lie";}if(showScoreboardBtn){showScoreboardBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}if(nextRoundBtn){nextRoundBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}if(finishGameBtn){finishGameBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}if(mixAnswersBtn){mixAnswersBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||!hasRound||(phase!=="lie"&&phase!=="guess");}if(calcScoresBtn){calcScoresBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||!hasRound||phase!=="guess";}var canEditRoundQuestion=!!hasCode&&(phase==="lie"||phase==="guess"||phase==="reveal");if(roundQuestionInput){roundQuestionInput.disabled=hostActionInFlight||sessionDetailInFlight||!canEditRoundQuestion;}if(roundQuestionGuardHint){if(hostActionInFlight){roundQuestionGuardHint.textContent="Round question-id er låst mens en handling kører.";}else if(sessionDetailInFlight){roundQuestionGuardHint.textContent="Round question-id er låst mens session-opdatering kører.";}else if(!hasCode){roundQuestionGuardHint.textContent="Angiv sessionkode for at redigere round question-id.";}else if(!phase){roundQuestionGuardHint.textContent="Opdatér session-status for round question-id.";}else if(canEditRoundQuestion){roundQuestionGuardHint.textContent="Round question-id kan redigeres i fase: "+phaseLabel(phase)+".";}else{roundQuestionGuardHint.textContent="Round question-id er låst i fase: "+phaseLabel(phase)+".";}}if(categorySelect){categorySelect.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="lobby";}if(categoryGuardHint){if(hostActionInFlight){categoryGuardHint.textContent="Kategori er midlertidigt låst mens en handling kører.";}else if(sessionDetailInFlight){categoryGuardHint.textContent="Kategori er låst mens session-opdatering kører.";}else if(!hasCode){categoryGuardHint.textContent="Angiv sessionkode for at låse kategori til lobby-fasen.";}else if(phase==="lobby"){categoryGuardHint.textContent="Kategori kan vælges i lobby-fasen.";}else if(!phase){categoryGuardHint.textContent="Opdatér session-status for at validere kategori-lås.";}else{categoryGuardHint.textContent="Kategori er låst udenfor lobby-fasen.";}}if(!hint){return;}if(hostActionInFlight){hint.textContent="Handling kører… afvent svar før næste klik.";return;}if(sessionDetailInFlight){hint.textContent="Host-actions er låst mens session-opdatering kører.";return;}if(!hasCode){hint.textContent="Angiv sessionkode for at aktivere host-actions.";return;}if(!phase){hint.textContent="Opdatér session-status for fasebaserede host-actions.";return;}if(phase==="finished"){hint.textContent="Spillet er afsluttet: gameplay-actions er låst.";return;}if((phase==="lie"||phase==="guess")&&!hasRound){hint.textContent="Round question id mangler: mix/beregn score er låst.";return;}if(hostShellRouteHint){hint.textContent=hostShellRouteHint;return;}hint.textContent="Host-actions er klar for fase: "+phaseLabel(phase)+".";}
async function api(path,method,payload){var o={method:method||"GET",headers:{"Accept":"application/json"}};if(payload!==null){o.headers["Content-Type"]="application/json";o.headers["X-CSRFToken"]=csrf();o.body=JSON.stringify(payload);}var r=await fetch(path,o);var d=await r.json().catch(function(){return {};});var isSessionDetailRead=(method||"GET")==="GET"&&/^\/lobby\/sessions\/[A-Z0-9]+$/.test(path);if(isSessionDetailRead){markSessionRefresh(r.status);}document.getElementById("out").textContent=JSON.stringify({status:r.status,data:d},null,2);if(d.session&&d.session.code){document.getElementById("code").value=d.session.code;}if(d.session&&d.session.status){currentSessionStatus=d.session.status;}if(d.round_question&&d.round_question.id){document.getElementById("roundQuestionId").value=d.round_question.id;}if(d.session){hydrateHostCriticalView(d);}updateErrorHint(r.status,d);updatePhaseStatus();syncStartRoundGuard(d);updateHostActionState();if(currentSessionStatus==="finished"&&autoRefreshEnabled){stopAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");}else{updateAutoRefreshUi();}if(hostShellFatalError){clearHostShellFatalError();}saveHostContext();return d;}
function withHostActionLock(fn){if(hostActionInFlight){return Promise.resolve({error:"host_action_in_flight"});}hostActionInFlight=true;updateHostActionState();return Promise.resolve().then(fn).finally(function(){hostActionInFlight=false;updateHostActionState();});}
function createSession(){return withHostActionLock(function(){return api("/lobby/sessions/create","POST",{});});}
function sessionDetail(){if(!code()){updateSessionDetailState();return Promise.resolve({error:"missing_session_code"});}if(sessionDetailInFlight){return Promise.resolve({error:"session_detail_in_flight"});}sessionDetailInFlight=true;updateSessionDetailState();return api("/lobby/sessions/"+code(),"GET",null).finally(function(){sessionDetailInFlight=false;updateSessionDetailState();});}
@@ -72,7 +119,10 @@ function nextRound(){return withHostActionLock(function(){return api("/lobby/ses
function finishGame(){return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/finish","POST",{});});}
["code","roundQuestionId"].forEach(function(fieldId){var field=document.getElementById(fieldId);if(!field){return;}field.addEventListener("input",function(){syncStartRoundGuard(null);updateHostActionState();updateSessionDetailState();saveHostContext();});field.addEventListener("change",function(){syncStartRoundGuard(null);updateHostActionState();updateSessionDetailState();saveHostContext();});});
updatePhaseStatus();syncStartRoundGuard(null);updateHostActionState();updateCreateSessionState();updateSessionDetailState();updateAutoRefreshUi();updateLastRefreshStatus();
if(restoreHostContext()){updatePhaseStatus();if(autoRefreshEnabled){startAutoRefresh();}sessionDetail();}else{saveHostContext();}
window.addEventListener("error",function(event){setHostShellFatalError((event&&event.message)||"Ukendt runtime-fejl");});
window.addEventListener("unhandledrejection",function(event){var reason=event&&event.reason;var detail=(reason&&reason.message)||String(reason||"Unhandled promise rejection");setHostShellFatalError(detail);});
setHostCriticalLoading(true);
updatePhaseStatus();syncHostShellRoute();syncStartRoundGuard(null);updateHostActionState();updateCreateSessionState();updateSessionDetailState();updateAutoRefreshUi();updateLastRefreshStatus();updateHostShellErrorBoundary();
if(restoreHostContext()){updatePhaseStatus();syncHostShellRoute();if(autoRefreshEnabled){startAutoRefresh();}sessionDetail();}else{saveHostContext();setTimeout(function(){if(!hostCriticalHydrated){hydrateHostCriticalView({session:{status:currentSessionStatus,players_count:null}});}},350);}
</script>
</body></html>

View File

@@ -9,8 +9,22 @@
#joinStatus { margin: 6px 0 10px; font-size: 0.95rem; color: #333; }
#contextLockHint { margin: 6px 0 10px; font-size: 0.95rem; color: #333; }
#phaseStatus { margin: 6px 0 10px; font-size: 0.95rem; color: #333; }
#connectionBanner { margin: 8px 0 10px; padding: 8px 10px; border-radius: 8px; border: 1px solid #b91c1c; background: #fee2e2; color: #7f1d1d; display: none; }
#connectionBanner button { margin-left: 8px; border: 1px solid #991b1b; background: #fff; color: #7f1d1d; border-radius: 6px; padding: 4px 8px; cursor: pointer; }
#connectionBanner button[disabled] { opacity: 0.55; cursor: not-allowed; }
#playerShellErrorBoundary { margin: 8px 0 10px; padding: 8px 10px; border-radius: 8px; border: 1px solid #b91c1c; background: #fee2e2; color: #7f1d1d; display: none; }
#playerShellErrorBoundary button { margin-left: 8px; border: 1px solid #991b1b; background: #fff; color: #7f1d1d; border-radius: 6px; padding: 4px 8px; cursor: pointer; }
#playerShellErrorBoundary button[disabled] { opacity: 0.55; cursor: not-allowed; }
#lieStatus.locked { color: #0a5f2d; font-weight: 600; }
#guessStatus.locked { color: #0a5f2d; font-weight: 600; }
.critical-card { margin: 10px 0 12px; padding: 10px; border-radius: 10px; border: 1px solid #cbd5e1; background: #f8fafc; }
.critical-card h2 { margin: 0 0 6px; font-size: 1rem; }
.critical-card p { margin: 0; color: #334155; }
.skeleton-line { position: relative; overflow: hidden; height: 12px; border-radius: 999px; margin-top: 8px; background: #e2e8f0; }
.skeleton-line:first-of-type { margin-top: 4px; }
.skeleton-line.short { width: 60%; }
.skeleton-line::after { content: ""; position: absolute; inset: 0; transform: translateX(-100%); background: linear-gradient(90deg, rgba(226,232,240,0) 0%, rgba(255,255,255,0.85) 45%, rgba(226,232,240,0) 100%); animation: wppShimmer 1.2s infinite; }
@keyframes wppShimmer { to { transform: translateX(100%); } }
</style>
</head>
<body>
@@ -38,6 +52,23 @@
<p id="playerLastRefreshStatus">Session-data ikke opdateret endnu.</p>
<p id="phaseStatus">Fase: ukendt (opdatér session-status).</p>
<p id="playerErrorHint">Ingen fejl.</p>
<div id="connectionBanner">Forbindelsen til serveren blev afbrudt.<button id="connectionRetryBtn" type="button" onclick="retryConnection()">Prøv igen</button></div>
<div id="playerShellErrorBoundary">Der opstod en kritisk fejl i app-skallen.
<button id="playerRecoverRetryBtn" type="button" onclick="recoverPlayerShell('retry')">Prøv gendan</button>
<button id="playerRecoverReloadBtn" type="button" onclick="recoverPlayerShell('reload')">Genindlæs siden</button>
</div>
<section id="playerCriticalSkeleton" class="critical-card" aria-live="polite">
<h2>Indlæser spiller-overblik…</h2>
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
</section>
<section id="playerCriticalView" class="critical-card" style="display:none" aria-live="polite">
<h2>Spiller-overblik</h2>
<p id="playerCriticalPhase">Fase: afventer</p>
<p id="playerCriticalRound">Round question: afventer</p>
<p id="playerCriticalJoin">Join-status: afventer</p>
</section>
<pre id="out">Klar.</pre>
<script>
var availableAnswers=[];
@@ -53,7 +84,23 @@ var playerAutoRefreshTimer=null;
var sessionDetailInFlight=false;
var playerLastRefreshAtLabel="";
var playerLastRefreshFailed=false;
var connectionLost=false;
var connectionRetryInFlight=false;
var playerShellFatalError=false;
var playerShellRecoverInFlight=false;
var playerCriticalHydrated=false;
function setPlayerCriticalLoading(isLoading){var skeleton=document.getElementById("playerCriticalSkeleton");var view=document.getElementById("playerCriticalView");if(!skeleton||!view){return;}skeleton.style.display=isLoading?"block":"none";view.style.display=isLoading?"none":"block";}
function hydratePlayerCriticalView(data){var phaseEl=document.getElementById("playerCriticalPhase");var roundEl=document.getElementById("playerCriticalRound");var joinEl=document.getElementById("playerCriticalJoin");if(phaseEl){phaseEl.textContent="Fase: "+phaseLabel(currentSessionStatus||((data&&data.session&&data.session.status)||""));}
if(roundEl){roundEl.textContent="Round question: "+(rq()||"afventer");}
if(joinEl){joinEl.textContent="Join-status: "+(isPlayerContextLocked()?"låst/aktiv":"ikke låst");}
playerCriticalHydrated=true;
setPlayerCriticalLoading(false);
}
function code(){return document.getElementById("code").value.trim().toUpperCase();}
function updatePlayerShellErrorBoundary(){var panel=document.getElementById("playerShellErrorBoundary");var retryBtn=document.getElementById("playerRecoverRetryBtn");var reloadBtn=document.getElementById("playerRecoverReloadBtn");if(!panel||!retryBtn||!reloadBtn){return;}panel.style.display=playerShellFatalError?"block":"none";retryBtn.disabled=playerShellRecoverInFlight||sessionDetailInFlight||joinInFlight||!code();reloadBtn.disabled=playerShellRecoverInFlight;}
function setPlayerShellFatalError(detail){playerShellFatalError=true;var out=document.getElementById("out");if(out){out.textContent=JSON.stringify({status:0,data:{error:"player_shell_runtime_error",detail:detail||"Ukendt runtime-fejl"}},null,2);}var hint=document.getElementById("playerErrorHint");if(hint){hint.textContent="Fejl: Kritisk app-fejl. Brug recover-handlingerne for at fortsætte.";}updatePlayerShellErrorBoundary();}
function clearPlayerShellFatalError(){playerShellFatalError=false;playerShellRecoverInFlight=false;updatePlayerShellErrorBoundary();}
function recoverPlayerShell(mode){if(playerShellRecoverInFlight){return Promise.resolve({error:"recover_in_flight"});}playerShellRecoverInFlight=true;updatePlayerShellErrorBoundary();if(mode==="reload"){window.location.reload();return Promise.resolve({ok:true});}if(!code()){playerShellRecoverInFlight=false;updatePlayerShellErrorBoundary();return Promise.resolve({error:"missing_session_code"});}return sessionDetail().then(function(result){clearPlayerShellFatalError();return result;}).catch(function(err){playerShellRecoverInFlight=false;updatePlayerShellErrorBoundary();throw err;});}
function pid(){return document.getElementById("playerId").value.trim();}
function rq(){return document.getElementById("roundQuestionId").value.trim();}
function phaseLabel(status){if(status==="lobby"){return"Lobby";}if(status==="lie"){return"Løgn";}if(status==="guess"){return"Gæt";}if(status==="reveal"){return"Reveal";}if(status==="finished"){return"Afsluttet";}return"Ukendt";}
@@ -75,11 +122,13 @@ function markPlayerSessionRefresh(status){if(status>=200&&status<300){playerLast
function updatePlayerLastRefreshStatus(){var el=document.getElementById("playerLastRefreshStatus");if(!el){return;}if(!playerLastRefreshAtLabel){el.textContent=playerLastRefreshFailed?"Session-data kan være forældet (ingen succesfuld opdatering endnu).":"Session-data ikke opdateret endnu.";return;}if(playerLastRefreshFailed){el.textContent="Session-data kan være forældet (seneste succes: "+playerLastRefreshAtLabel+").";return;}el.textContent="Sidst opdateret: "+playerLastRefreshAtLabel+".";}
function updatePlayerAutoRefreshUi(){var btn=document.getElementById("playerAutoRefreshToggleBtn");var hint=document.getElementById("playerAutoRefreshHint");if(btn){btn.textContent="Auto-refresh: "+(playerAutoRefreshEnabled?"ON":"OFF");btn.disabled=sessionDetailInFlight||joinInFlight||!code();}if(!hint){return;}if(sessionDetailInFlight){hint.textContent="Auto-refresh-lås: afvent aktiv session-opdatering.";return;}if(joinInFlight){hint.textContent="Auto-refresh-lås: afvent aktiv join.";return;}if(!code()){hint.textContent="Auto-refresh kræver sessionkode.";return;}if(!playerAutoRefreshEnabled){hint.textContent="Auto-refresh er slået fra.";return;}if(currentSessionStatus==="finished"){hint.textContent="Auto-refresh stoppet: spillet er afsluttet.";return;}hint.textContent="Auto-refresh aktiv (10s) for spillerstatus.";}
function stopPlayerAutoRefresh(reason){playerAutoRefreshEnabled=false;if(playerAutoRefreshTimer){clearInterval(playerAutoRefreshTimer);playerAutoRefreshTimer=null;}if(reason){var hint=document.getElementById("playerAutoRefreshHint");if(hint){hint.textContent=reason;}}updatePlayerAutoRefreshUi();savePlayerContext();}
function startPlayerAutoRefresh(){if(!code()){updatePlayerAutoRefreshUi();return;}playerAutoRefreshEnabled=true;if(playerAutoRefreshTimer){clearInterval(playerAutoRefreshTimer);}playerAutoRefreshTimer=setInterval(function(){if(!code()||sessionDetailInFlight){return;}if(currentSessionStatus==="finished"){stopPlayerAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");return;}sessionDetail();},10000);updatePlayerAutoRefreshUi();savePlayerContext();}
function startPlayerAutoRefresh(){if(!code()){updatePlayerAutoRefreshUi();return;}playerAutoRefreshEnabled=true;if(playerAutoRefreshTimer){clearInterval(playerAutoRefreshTimer);}playerAutoRefreshTimer=setInterval(function(){if(!code()||sessionDetailInFlight){return;}if(currentSessionStatus==="finished"){stopPlayerAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");return;}sessionDetail().catch(function(){});},10000);updatePlayerAutoRefreshUi();savePlayerContext();}
function togglePlayerAutoRefresh(){if(joinInFlight){updatePlayerAutoRefreshUi();return;}if(playerAutoRefreshEnabled){stopPlayerAutoRefresh();return;}startPlayerAutoRefresh();}
function updatePlayerErrorHint(status,data){var el=document.getElementById("playerErrorHint");if(!el){return;}if(status>=200&&status<300){el.textContent="Ingen fejl.";return;}var errKey=normalizeApiError(data);el.textContent="Fejl: "+mapUiErrorMessage(errKey)+" ("+(errKey||("http_"+status))+")";}
function updateSessionDetailState(){var btn=document.getElementById("sessionDetailBtn");var hint=document.getElementById("sessionRefreshHint");var submitInFlight=lieSubmitInFlight||guessSubmitInFlight;if(btn){btn.disabled=sessionDetailInFlight||joinInFlight||submitInFlight||!code();}if(!hint){updatePlayerAutoRefreshUi();return;}if(sessionDetailInFlight){hint.textContent="Opdaterer session-status…";updatePlayerAutoRefreshUi();return;}if(joinInFlight){hint.textContent="Session-opdatering er låst mens join kører.";updatePlayerAutoRefreshUi();return;}if(submitInFlight){hint.textContent="Session-opdatering er låst mens submit kører.";updatePlayerAutoRefreshUi();return;}if(!code()){hint.textContent="Angiv sessionkode for at opdatere session-status.";updatePlayerAutoRefreshUi();return;}hint.textContent="Session-opdatering klar.";updatePlayerAutoRefreshUi();}
function updateJoinState(){var btn=document.getElementById("joinBtn");var status=document.getElementById("joinStatus");var joined=!!(pid()&&document.getElementById("sessionToken").value.trim());var canJoin=canAttemptJoin();if(btn){btn.disabled=joinInFlight||sessionDetailInFlight||joined||!canJoin;}if(!status){updateContextLockState();return;}if(joinInFlight){status.textContent="Joiner…";updateContextLockState();return;}if(sessionDetailInFlight&&!joined){status.textContent="Afvent aktiv session-opdatering før join.";updateContextLockState();return;}if(joined){status.textContent="Join gennemført.";updateContextLockState();return;}if(!canJoin){status.textContent="Udfyld kode og nickname for at join.";updateContextLockState();return;}status.textContent="Klar til join.";updateContextLockState();}
function updateConnectionBanner(){var banner=document.getElementById("connectionBanner");var retryBtn=document.getElementById("connectionRetryBtn");if(!banner||!retryBtn){return;}banner.style.display=connectionLost?"block":"none";retryBtn.disabled=connectionRetryInFlight||sessionDetailInFlight||joinInFlight||!code();}
function setConnectionLost(isLost){connectionLost=!!isLost;updateConnectionBanner();}
function updatePlayerErrorHint(status,data){var el=document.getElementById("playerErrorHint");if(!el){return;}if(status>=200&&status<300){el.textContent="Ingen fejl.";setConnectionLost(false);return;}var errKey=normalizeApiError(data);el.textContent="Fejl: "+mapUiErrorMessage(errKey)+" ("+(errKey||("http_"+status))+")";}
function updateSessionDetailState(){var btn=document.getElementById("sessionDetailBtn");var hint=document.getElementById("sessionRefreshHint");var submitInFlight=lieSubmitInFlight||guessSubmitInFlight;if(btn){btn.disabled=sessionDetailInFlight||joinInFlight||submitInFlight||!code();}if(!hint){updatePlayerAutoRefreshUi();updateConnectionBanner();return;}if(sessionDetailInFlight){hint.textContent="Opdaterer session-status…";updatePlayerAutoRefreshUi();updateConnectionBanner();return;}if(joinInFlight){hint.textContent="Session-opdatering er låst mens join kører.";updatePlayerAutoRefreshUi();updateConnectionBanner();return;}if(submitInFlight){hint.textContent="Session-opdatering er låst mens submit kører.";updatePlayerAutoRefreshUi();updateConnectionBanner();return;}if(!code()){hint.textContent="Angiv sessionkode for at opdatere session-status.";updatePlayerAutoRefreshUi();updateConnectionBanner();updatePlayerShellErrorBoundary();return;}hint.textContent="Session-opdatering klar.";updatePlayerAutoRefreshUi();updateConnectionBanner();updatePlayerShellErrorBoundary();}
function updateJoinState(){var btn=document.getElementById("joinBtn");var status=document.getElementById("joinStatus");var joined=!!(pid()&&document.getElementById("sessionToken").value.trim());var canJoin=canAttemptJoin();if(btn){btn.disabled=joinInFlight||sessionDetailInFlight||joined||!canJoin;}if(!status){updateContextLockState();updateConnectionBanner();return;}if(joinInFlight){status.textContent="Joiner…";updateContextLockState();updateConnectionBanner();return;}if(sessionDetailInFlight&&!joined){status.textContent="Afvent aktiv session-opdatering før join.";updateContextLockState();updateConnectionBanner();return;}if(joined){status.textContent="Join gennemført.";updateContextLockState();updateConnectionBanner();return;}if(!canJoin){status.textContent="Udfyld kode og nickname for at join.";updateContextLockState();updateConnectionBanner();return;}status.textContent="Klar til join.";updateContextLockState();updateConnectionBanner();}
function guessStorageKey(){var c=code();var p=pid();var q=rq();if(!c||!p||!q){return "";}return ["wppGuess",c,p,q].join(":");}
function lieStorageKey(){var c=code();var p=pid();var q=rq();if(!c||!p||!q){return "";}return ["wppLie",c,p,q].join(":");}
@@ -94,19 +143,25 @@ function updateGuessSubmitState(){var selected=document.getElementById("guessTex
function setGuess(text,submitted){document.getElementById("guessText").value=text||"";if(typeof submitted==="boolean"){guessSubmitted=submitted;}var buttons=document.querySelectorAll("#answerOptions button");buttons.forEach(function(btn){btn.classList.toggle("active",btn.dataset.answer===text);});updateGuessSubmitState();
updateJoinState();}
function renderAnswerOptions(roundQuestion){var wrap=document.getElementById("answerOptions");wrap.innerHTML="";availableAnswers=[];guessSubmitted=false;setGuess("",false);lieSubmitted=false;setLieState("",false);if(!roundQuestion||!Array.isArray(roundQuestion.answers)){updateGuessSubmitState();updateLieSubmitState();return;}roundQuestion.answers.forEach(function(item){if(!item||!item.text){return;}availableAnswers.push(item.text);var btn=document.createElement("button");btn.type="button";btn.dataset.answer=item.text;btn.textContent=item.text;btn.onclick=function(){if(guessSubmitted){return;}setGuess(item.text,false);persistGuessState(item.text,false);};wrap.appendChild(btn);});var saved=loadGuessState();if(saved&&availableAnswers.indexOf(saved.selected_text)!==-1){setGuess(saved.selected_text,!!saved.submitted);}updateGuessSubmitState();}
async function api(path,method,payload){var o={method:method||"GET",headers:{"Accept":"application/json"}};if(payload!==null){o.headers["Content-Type"]="application/json";o.body=JSON.stringify(payload);}var r=await fetch(path,o);var d=await r.json().catch(function(){return {};});var isSessionDetailRead=(method||"GET")==="GET"&&/^\/lobby\/sessions\/[A-Z0-9]+$/.test(path);if(isSessionDetailRead){markPlayerSessionRefresh(r.status);}document.getElementById("out").textContent=JSON.stringify({status:r.status,data:d},null,2);if(d.player&&d.player.id){document.getElementById("playerId").value=d.player.id;}if(d.player&&d.player.session_token){document.getElementById("sessionToken").value=d.player.session_token;}if(d.round_question&&d.round_question.id){document.getElementById("roundQuestionId").value=d.round_question.id;}if(d.session&&d.session.status){currentSessionStatus=d.session.status;}if(d.round_question){renderAnswerOptions(d.round_question);var savedLie=loadLieState();if(savedLie){setLieState(savedLie.text||"",!!savedLie.submitted);}}else{document.getElementById("roundQuestionId").value="";renderAnswerOptions(null);}if(d.guess&&d.guess.round_question_id){document.getElementById("roundQuestionId").value=d.guess.round_question_id;setGuess(d.guess.selected_text||"",true);persistGuessState(d.guess.selected_text||"",true);}updateRoundContextHint();updatePlayerErrorHint(r.status,d);updatePhaseStatus();updateLieSubmitState();updateGuessSubmitState();if(currentSessionStatus==="finished"&&playerAutoRefreshEnabled){stopPlayerAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");}else{updatePlayerAutoRefreshUi();}savePlayerContext();return d;}
async function api(path,method,payload){var o={method:method||"GET",headers:{"Accept":"application/json"}};if(payload!==null){o.headers["Content-Type"]="application/json";o.body=JSON.stringify(payload);}try{var r=await fetch(path,o);var d=await r.json().catch(function(){return {};});var isSessionDetailRead=(method||"GET")==="GET"&&/^\/lobby\/sessions\/[A-Z0-9]+$/.test(path);if(isSessionDetailRead){markPlayerSessionRefresh(r.status);}document.getElementById("out").textContent=JSON.stringify({status:r.status,data:d},null,2);if(d.player&&d.player.id){document.getElementById("playerId").value=d.player.id;}if(d.player&&d.player.session_token){document.getElementById("sessionToken").value=d.player.session_token;}if(d.round_question&&d.round_question.id){document.getElementById("roundQuestionId").value=d.round_question.id;}if(d.session&&d.session.status){currentSessionStatus=d.session.status;}if(d.round_question){renderAnswerOptions(d.round_question);var savedLie=loadLieState();if(savedLie){setLieState(savedLie.text||"",!!savedLie.submitted);}}else{document.getElementById("roundQuestionId").value="";renderAnswerOptions(null);}if(d.guess&&d.guess.round_question_id){document.getElementById("roundQuestionId").value=d.guess.round_question_id;setGuess(d.guess.selected_text||"",true);persistGuessState(d.guess.selected_text||"",true);}hydratePlayerCriticalView(d);updateRoundContextHint();updatePlayerErrorHint(r.status,d);updatePhaseStatus();updateLieSubmitState();updateGuessSubmitState();if(currentSessionStatus==="finished"&&playerAutoRefreshEnabled){stopPlayerAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");}else{updatePlayerAutoRefreshUi();}if(playerShellFatalError){clearPlayerShellFatalError();}savePlayerContext();return d;}catch(err){setConnectionLost(true);if((method||"GET")==="GET"&&/^\/lobby\/sessions\/[A-Z0-9]+$/.test(path)){markPlayerSessionRefresh(0);}document.getElementById("out").textContent=JSON.stringify({status:0,data:{error:"connection_lost",detail:"Kunne ikke kontakte serveren."}},null,2);document.getElementById("playerErrorHint").textContent="Fejl: Mistede forbindelsen til serveren. Prøv igen.";updateSessionDetailState();throw err;}}
function joinSession(){if(joinInFlight){return Promise.resolve({error:"join_in_flight"});}if(!canAttemptJoin()){updateJoinState();return Promise.resolve({error:"missing_join_input"});}if(pid()&&document.getElementById("sessionToken").value.trim()){updateJoinState();return Promise.resolve({error:"already_joined_client"});}joinInFlight=true;updateJoinState();updatePlayerAutoRefreshUi();return api("/lobby/sessions/join","POST",{code:code(),nickname:document.getElementById("nickname").value.trim()}).then(function(d){joinInFlight=false;if(d&&d.player&&d.player.id){updateJoinState();updatePlayerAutoRefreshUi();return d;}updateJoinState();updatePlayerAutoRefreshUi();document.getElementById("joinStatus").textContent="Join fejlede prøv igen.";return d;}).catch(function(err){joinInFlight=false;updateJoinState();updatePlayerAutoRefreshUi();document.getElementById("joinStatus").textContent="Join fejlede prøv igen.";throw err;});}
function sessionDetail(){if(!code()){updateSessionDetailState();return Promise.resolve({error:"missing_session_code"});}if(sessionDetailInFlight){return Promise.resolve({error:"session_detail_in_flight"});}sessionDetailInFlight=true;updateSessionDetailState();return api("/lobby/sessions/"+code(),"GET",null).finally(function(){sessionDetailInFlight=false;updateSessionDetailState();});}
function retryConnection(){if(connectionRetryInFlight||!code()){updateConnectionBanner();return Promise.resolve({error:"retry_unavailable"});}connectionRetryInFlight=true;updateConnectionBanner();return sessionDetail().then(function(result){setConnectionLost(false);return result;}).finally(function(){connectionRetryInFlight=false;updateConnectionBanner();});}
function submitLie(){if(lieSubmitted){return Promise.resolve({error:"lie_already_submitted_client"});}if(lieSubmitInFlight){return Promise.resolve({error:"lie_submit_in_flight"});}if(!hasSubmitContext()){updateLieSubmitState();return Promise.resolve({error:"missing_submit_context"});}var text=(document.getElementById("lieText").value||"").trim();if(!text){updateLieSubmitState();return Promise.resolve({error:"empty_lie_text"});}lieSubmitInFlight=true;updateLieSubmitState();return api("/lobby/sessions/"+code()+"/questions/"+rq()+"/lies/submit","POST",{player_id:parseInt(pid(),10),session_token:document.getElementById("sessionToken").value,text:text}).then(function(d){if(d&&d.lie&&d.lie.id){lieSubmitted=true;persistLieState(text,true);}return d;}).finally(function(){lieSubmitInFlight=false;updateLieSubmitState();});}
document.getElementById("lieText").addEventListener("input",function(){if(!lieSubmitted){updateLieSubmitState();persistLieState(document.getElementById("lieText").value,false);}});updateLieSubmitState();
function submitGuess(){if(guessSubmitted){return Promise.resolve({error:"guess_already_submitted_client"});}if(guessSubmitInFlight){return Promise.resolve({error:"guess_submit_in_flight"});}if(!hasSubmitContext()){updateGuessSubmitState();document.getElementById("out").textContent=JSON.stringify({status:400,data:{error:"Join først for at aktivere gæt"}},null,2);return Promise.resolve({error:"missing_submit_context"});}var selected=document.getElementById("guessText").value;if(availableAnswers.indexOf(selected)===-1){document.getElementById("out").textContent=JSON.stringify({status:400,data:{error:"Vælg et af de viste svarmuligheder"}},null,2);return Promise.resolve({error:"invalid_client_guess"});}guessSubmitInFlight=true;updateGuessSubmitState();return api("/lobby/sessions/"+code()+"/questions/"+rq()+"/guesses/submit","POST",{player_id:parseInt(pid(),10),session_token:document.getElementById("sessionToken").value,selected_text:selected}).finally(function(){guessSubmitInFlight=false;updateGuessSubmitState();});}
["code","nickname","playerId","sessionToken","roundQuestionId"].forEach(function(fieldId){var field=document.getElementById(fieldId);if(!field){return;}field.addEventListener("input",function(){if(fieldId!=="roundQuestionId"){resetRoundContextForManualChange();}updateLieSubmitState();updateGuessSubmitState();updateJoinState();updateSessionDetailState();savePlayerContext();});field.addEventListener("change",function(){if(fieldId!=="roundQuestionId"){resetRoundContextForManualChange();}updateLieSubmitState();updateGuessSubmitState();updateJoinState();updateSessionDetailState();savePlayerContext();});});
window.addEventListener("error",function(event){setPlayerShellFatalError((event&&event.message)||"Ukendt runtime-fejl");});
window.addEventListener("unhandledrejection",function(event){var reason=event&&event.reason;var detail=(reason&&reason.message)||String(reason||"Unhandled promise rejection");setPlayerShellFatalError(detail);});
setPlayerCriticalLoading(true);
updatePhaseStatus();
updateGuessSubmitState();
updateJoinState();
updatePlayerAutoRefreshUi();
updatePlayerLastRefreshStatus();
updateRoundContextHint();
if(restorePlayerContext()){if(playerAutoRefreshEnabled){startPlayerAutoRefresh();}sessionDetail();}else{savePlayerContext();}
updateConnectionBanner();
updatePlayerShellErrorBoundary();
if(restorePlayerContext()){if(playerAutoRefreshEnabled){startPlayerAutoRefresh();}sessionDetail().catch(function(){});}else{savePlayerContext();setTimeout(function(){if(!playerCriticalHydrated){hydratePlayerCriticalView({session:{status:currentSessionStatus}});}},350);}
</script>
</body></html>

View File

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

View File

@@ -1,7 +1,11 @@
import json
import tempfile
from datetime import timedelta
from pathlib import Path
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.core.management import call_command
from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
@@ -59,6 +63,28 @@ class LobbyFlowTests(TestCase):
self.assertTrue(body["player"]["session_token"])
self.assertTrue(Player.objects.filter(session=session, nickname="Luna").exists())
def test_player_can_join_with_trimmed_code(self):
session = GameSession.objects.create(host=self.host, code="ABCD23")
response = self.client.post(
reverse("lobby:join_session"),
data={"code": " abcd23 ", "nickname": "Luna"},
content_type="application/json",
)
self.assertEqual(response.status_code, 201)
self.assertTrue(Player.objects.filter(session=session, nickname="Luna").exists())
def test_join_rejects_code_empty_after_trim(self):
response = self.client.post(
reverse("lobby:join_session"),
data={"code": " ", "nickname": "Luna"},
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error"], "Session code is required")
def test_join_rejects_duplicate_nickname_case_insensitive(self):
session = GameSession.objects.create(host=self.host, code="QWER12")
Player.objects.create(session=session, nickname="Luna")
@@ -130,6 +156,21 @@ class StartRoundTests(TestCase):
round_config = RoundConfig.objects.get(session=self.session, number=1)
self.assertEqual(round_config.category, self.category)
def test_host_start_round_uses_normalized_session_code_from_path(self):
self.client.login(username="host", password="secret123")
response = self.client.post(
reverse("lobby:start_round", kwargs={"code": " abcd23 "}),
data={"category_slug": self.category.slug},
content_type="application/json",
)
self.assertEqual(response.status_code, 201)
self.session.refresh_from_db()
self.assertEqual(self.session.status, GameSession.Status.LIE)
round_config = RoundConfig.objects.get(session=self.session, number=1)
self.assertEqual(round_config.category, self.category)
def test_start_round_requires_host(self):
self.client.login(username="other", password="secret123")
@@ -825,6 +866,27 @@ class UiScreenTests(TestCase):
self.assertContains(response, "Round question-id er låst mens session-opdatering kører.")
self.assertContains(response, "Kategori er låst mens session-opdatering kører.")
self.assertContains(response, "categorySelect.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!==")
self.assertContains(response, "hostShellRouteFromPath")
self.assertContains(response, "syncHostShellRoute")
self.assertContains(response, "Deep-link route guard: omdirigeret")
self.assertContains(response, "id=\"hostShellErrorBoundary\"")
self.assertContains(response, "recoverHostShell('retry')")
self.assertContains(response, "recoverHostShell('reload')")
self.assertContains(response, "setHostShellFatalError")
self.assertContains(response, "clearHostShellFatalError")
self.assertContains(response, "updateHostShellErrorBoundary")
self.assertContains(response, "host_shell_runtime_error")
self.assertContains(response, "window.addEventListener(\"unhandledrejection\"")
def test_host_screen_deeplink_requires_login(self):
response = self.client.get(reverse("lobby:host_screen_deeplink", kwargs={"spa_path": "guess"}))
self.assertEqual(response.status_code, 302)
def test_host_screen_deeplink_renders_for_logged_in_user(self):
self.client.login(username="host_ui", password="secret123")
response = self.client.get(reverse("lobby:host_screen_deeplink", kwargs={"spa_path": "guess"}))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Host panel")
def test_player_screen_is_public(self):
response = self.client.get(reverse("lobby:player_screen"))
@@ -857,6 +919,11 @@ class UiScreenTests(TestCase):
self.assertContains(response, "Udfyld kode og nickname for at join.")
self.assertContains(response, "Afvent aktiv session-opdatering før join.")
self.assertContains(response, "btn.disabled=joinInFlight||sessionDetailInFlight||joined||!canJoin")
self.assertContains(response, "id=\"connectionBanner\"")
self.assertContains(response, "id=\"connectionRetryBtn\"")
self.assertContains(response, "retryConnection")
self.assertContains(response, "setConnectionLost")
self.assertContains(response, "connection_lost")
self.assertContains(response, "id=\"contextLockHint\"")
self.assertContains(response, "updateContextLockState")
self.assertContains(response, "Spillerkontekst er låst efter join.")
@@ -897,6 +964,72 @@ class UiScreenTests(TestCase):
self.assertContains(response, "Session-data ikke opdateret endnu.")
self.assertContains(response, "Sidst opdateret:")
self.assertContains(response, "Session-data kan være forældet")
self.assertContains(response, "id=\"playerShellErrorBoundary\"")
self.assertContains(response, "recoverPlayerShell('retry')")
self.assertContains(response, "recoverPlayerShell('reload')")
self.assertContains(response, "setPlayerShellFatalError")
self.assertContains(response, "clearPlayerShellFatalError")
self.assertContains(response, "updatePlayerShellErrorBoundary")
self.assertContains(response, "player_shell_runtime_error")
self.assertContains(response, "window.addEventListener(\"error\"")
@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):
self.client.login(username="host_ui", password="secret123")
response = self.client.get(reverse("lobby:host_screen"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<app-root")
self.assertContains(response, "data-wpp-shell-route=\"/host\"")
self.assertContains(response, "data-wpp-shell-kind=\"host\"")
self.assertContains(response, "/static/frontend/angular/browser/main.js")
@override_settings(USE_SPA_UI=True)
def test_host_screen_deeplink_preserves_spa_path_when_feature_flag_enabled(self):
self.client.login(username="host_ui", password="secret123")
response = self.client.get(
reverse("lobby:host_screen_deeplink", kwargs={"spa_path": "guess/round-1"})
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<app-root")
self.assertContains(response, "data-wpp-shell-route=\"/host/guess/round-1\"")
self.assertContains(response, "data-wpp-shell-kind=\"host\"")
@override_settings(USE_SPA_UI=True)
def test_host_screen_deeplink_normalizes_redundant_slashes_when_feature_flag_enabled(self):
self.client.login(username="host_ui", password="secret123")
response = self.client.get("/lobby/ui/host//guess///round-1//")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<app-root")
self.assertContains(response, "data-wpp-shell-route=\"/host/guess/round-1\"")
self.assertContains(response, "data-wpp-shell-kind=\"host\"")
@override_settings(USE_SPA_UI=True)
def test_player_screen_can_render_angular_shell_when_feature_flag_enabled(self):
response = self.client.get(reverse("lobby:player_screen"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<app-root")
self.assertContains(response, "data-wpp-shell-route=\"/player\"")
self.assertContains(response, "data-wpp-shell-kind=\"player\"")
self.assertContains(response, "/static/frontend/angular/browser/main.js")
class SessionDetailRoundQuestionTests(TestCase):
@@ -925,3 +1058,119 @@ class SessionDetailRoundQuestionTests(TestCase):
payload = response.json()
self.assertEqual(payload["round_question"]["id"], round_question.id)
self.assertEqual(payload["round_question"]["prompt"], self.question.prompt)
class SessionDetailPhaseViewModelTests(TestCase):
def setUp(self):
self.host = User.objects.create_user(username="host_phase", password="secret123")
self.session = GameSession.objects.create(host=self.host, code="PHASE1", status=GameSession.Status.LOBBY)
def test_session_detail_includes_shared_phase_view_model_contract(self):
Player.objects.create(session=self.session, nickname="P1")
Player.objects.create(session=self.session, nickname="P2")
Player.objects.create(session=self.session, nickname="P3")
response = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code}))
self.assertEqual(response.status_code, 200)
phase = response.json()["phase_view_model"]
self.assertEqual(phase["status"], GameSession.Status.LOBBY)
self.assertEqual(phase["round_number"], 1)
self.assertEqual(phase["players_count"], 3)
self.assertEqual(phase["constraints"]["min_players_to_start"], 3)
self.assertEqual(phase["constraints"]["max_players_mvp"], 5)
self.assertTrue(phase["constraints"]["min_players_reached"])
self.assertTrue(phase["constraints"]["max_players_allowed"])
self.assertTrue(phase["host"]["can_start_round"])
self.assertFalse(phase["host"]["can_show_question"])
self.assertTrue(phase["player"]["can_join"])
self.assertFalse(phase["player"]["can_submit_lie"])
self.assertFalse(phase["player"]["can_submit_guess"])
def test_phase_view_model_flags_change_with_round_phase(self):
category = Category.objects.create(name="Kultur", slug="kultur", is_active=True)
question = Question.objects.create(
category=category,
prompt="Hvilket land kommer sushi fra?",
correct_answer="Japan",
is_active=True,
)
round_question = RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=question,
correct_answer="Japan",
)
self.session.status = GameSession.Status.LIE
self.session.save(update_fields=["status"])
lie_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
lie_phase = lie_payload["phase_view_model"]
self.assertFalse(lie_phase["host"]["can_show_question"])
self.assertTrue(lie_phase["host"]["can_mix_answers"])
self.assertTrue(lie_phase["player"]["can_submit_lie"])
self.assertFalse(lie_phase["player"]["can_submit_guess"])
self.session.status = GameSession.Status.GUESS
self.session.save(update_fields=["status"])
guess_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
guess_phase = guess_payload["phase_view_model"]
self.assertTrue(guess_phase["host"]["can_mix_answers"])
self.assertTrue(guess_phase["host"]["can_calculate_scores"])
self.assertFalse(guess_phase["player"]["can_submit_lie"])
self.assertTrue(guess_phase["player"]["can_submit_guess"])
round_question.delete()
self.session.status = GameSession.Status.REVEAL
self.session.save(update_fields=["status"])
reveal_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
reveal_phase = reveal_payload["phase_view_model"]
self.assertTrue(reveal_phase["host"]["can_reveal_scoreboard"])
self.assertTrue(reveal_phase["host"]["can_start_next_round"])
self.assertTrue(reveal_phase["host"]["can_finish_game"])
self.assertFalse(reveal_phase["player"]["can_view_final_result"])
self.session.status = GameSession.Status.FINISHED
self.session.save(update_fields=["status"])
finished_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
finished_phase = finished_payload["phase_view_model"]
self.assertFalse(finished_phase["player"]["can_join"])
self.assertTrue(finished_phase["player"]["can_view_final_result"])
class SmokeStagingCommandTests(TestCase):
def test_smoke_staging_command_runs_full_flow(self):
call_command("smoke_staging")
session = GameSession.objects.latest("created_at")
self.assertEqual(session.status, GameSession.Status.FINISHED)
self.assertEqual(Player.objects.filter(session=session).count(), 3)
def test_smoke_staging_writes_artifact_when_requested(self):
with tempfile.TemporaryDirectory() as tmp_dir:
artifact_path = Path(tmp_dir) / "smoke.json"
call_command("smoke_staging", artifact=str(artifact_path))
self.assertTrue(artifact_path.exists())
payload = json.loads(artifact_path.read_text(encoding="utf-8"))
self.assertTrue(payload["ok"])
self.assertEqual(payload["command"], "smoke_staging")
self.assertEqual(payload["players"], ["P1", "P2", "P3"])
self.assertIn("generated_at", payload)
self.assertIn("session_code", payload)
self.assertEqual(
payload["steps"],
[
"create_session",
"join_players",
"start_round",
"show_question",
"submit_lies",
"mix_answers",
"submit_guesses",
"calculate_scores",
"reveal_scoreboard",
"finish_game",
],
)

View File

@@ -1,14 +1,40 @@
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.shortcuts import render
from fupogfakta.models import Category
from .feature_flags import use_spa_ui
def _render_spa_shell(request, shell_route: str, shell_kind: str):
return render(
request,
"lobby/spa_shell.html",
{
"shell_route": shell_route,
"shell_kind": shell_kind,
"spa_asset_base": settings.WPP_SPA_ASSET_BASE,
},
)
@login_required
def host_screen(request):
def host_screen(request, spa_path=None):
if use_spa_ui():
host_route = "/host"
if spa_path:
normalized_spa_path = "/".join(segment for segment in spa_path.split("/") if segment)
if normalized_spa_path:
host_route = f"/host/{normalized_spa_path}"
return _render_spa_shell(request, host_route, "host")
categories = Category.objects.filter(is_active=True).order_by("name")
return render(request, "lobby/host_screen.html", {"categories": categories})
def player_screen(request):
if use_spa_ui():
return _render_spa_shell(request, "/player", "player")
return render(request, "lobby/player_screen.html")

View File

@@ -6,6 +6,7 @@ app_name = "lobby"
urlpatterns = [
path("ui/host", ui_views.host_screen, name="host_screen"),
path("ui/host/<path:spa_path>", ui_views.host_screen, name="host_screen_deeplink"),
path("ui/player", ui_views.player_screen, name="player_screen"),
path("sessions/create", views.create_session, name="create_session"),
path("sessions/join", views.join_session, name="join_session"),

View File

@@ -45,6 +45,10 @@ def _generate_session_code() -> str:
return "".join(random.choices(SESSION_CODE_ALPHABET, k=SESSION_CODE_LENGTH))
def _normalize_session_code(code: str) -> str:
return code.strip().upper()
def _create_unique_session_code() -> str:
for _ in range(MAX_CODE_GENERATION_ATTEMPTS):
code = _generate_session_code()
@@ -54,6 +58,45 @@ def _create_unique_session_code() -> str:
raise RuntimeError("Could not generate unique session code")
def _build_phase_view_model(session: GameSession, *, players_count: int, has_round_question: bool) -> dict:
status = session.status
in_lobby = status == GameSession.Status.LOBBY
in_lie = status == GameSession.Status.LIE
in_guess = status == GameSession.Status.GUESS
in_reveal = status == GameSession.Status.REVEAL
in_finished = status == GameSession.Status.FINISHED
min_players_reached = players_count >= 3
max_players_allowed = players_count <= 5
return {
"status": status,
"round_number": session.current_round,
"players_count": players_count,
"constraints": {
"min_players_to_start": 3,
"max_players_mvp": 5,
"min_players_reached": min_players_reached,
"max_players_allowed": max_players_allowed,
},
"host": {
"can_start_round": in_lobby and min_players_reached and max_players_allowed,
"can_show_question": in_lie and not has_round_question,
"can_mix_answers": in_lie or in_guess,
"can_calculate_scores": in_guess,
"can_reveal_scoreboard": in_reveal,
"can_start_next_round": in_reveal,
"can_finish_game": in_reveal,
},
"player": {
"can_join": status in JOINABLE_STATUSES,
"can_submit_lie": in_lie and has_round_question,
"can_submit_guess": in_guess and has_round_question,
"can_view_final_result": in_finished,
},
}
@require_POST
@login_required
def create_session(request: HttpRequest) -> JsonResponse:
@@ -77,7 +120,7 @@ def create_session(request: HttpRequest) -> JsonResponse:
def join_session(request: HttpRequest) -> JsonResponse:
payload = _json_body(request)
code = str(payload.get("code", "")).strip().upper()
code = _normalize_session_code(str(payload.get("code", "")))
nickname = str(payload.get("nickname", "")).strip()
if not code:
@@ -118,7 +161,7 @@ def join_session(request: HttpRequest) -> JsonResponse:
@require_GET
def session_detail(request: HttpRequest, code: str) -> JsonResponse:
session_code = code.strip().upper()
session_code = _normalize_session_code(code)
try:
session = GameSession.objects.get(code=session_code)
@@ -151,6 +194,12 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
"answers": [{"text": text} for text in (current_round_question.mixed_answers or [])],
}
phase_view_model = _build_phase_view_model(
session,
players_count=len(players),
has_round_question=bool(current_round_question),
)
return JsonResponse(
{
"session": {
@@ -162,6 +211,7 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
},
"players": players,
"round_question": round_question_payload,
"phase_view_model": phase_view_model,
}
)
@@ -175,7 +225,7 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
if not category_slug:
return JsonResponse({"error": "category_slug is required"}, status=400)
session_code = code.strip().upper()
session_code = _normalize_session_code(code)
try:
session = GameSession.objects.get(code=session_code)
@@ -234,7 +284,7 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
@require_POST
@login_required
def show_question(request: HttpRequest, code: str) -> JsonResponse:
session_code = code.strip().upper()
session_code = _normalize_session_code(code)
try:
session = GameSession.objects.get(code=session_code)
@@ -294,7 +344,7 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse:
@require_POST
def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse:
payload = _json_body(request)
session_code = code.strip().upper()
session_code = _normalize_session_code(code)
player_id = payload.get("player_id")
session_token = str(payload.get("session_token", "")).strip()
@@ -367,7 +417,7 @@ def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonR
@require_POST
@login_required
def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse:
session_code = code.strip().upper()
session_code = _normalize_session_code(code)
try:
session = GameSession.objects.get(code=session_code)
@@ -437,7 +487,7 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json
@require_POST
def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse:
payload = _json_body(request)
session_code = code.strip().upper()
session_code = _normalize_session_code(code)
player_id = payload.get("player_id")
session_token = str(payload.get("session_token", "")).strip()
@@ -543,7 +593,7 @@ def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> Jso
@require_GET
@login_required
def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse:
session_code = code.strip().upper()
session_code = _normalize_session_code(code)
try:
session = GameSession.objects.get(code=session_code)
@@ -577,7 +627,7 @@ def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse:
@require_POST
@login_required
def start_next_round(request: HttpRequest, code: str) -> JsonResponse:
session_code = code.strip().upper()
session_code = _normalize_session_code(code)
try:
session = GameSession.objects.get(code=session_code)
@@ -609,7 +659,7 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse:
@require_POST
@login_required
def finish_game(request: HttpRequest, code: str) -> JsonResponse:
session_code = code.strip().upper()
session_code = _normalize_session_code(code)
try:
session = GameSession.objects.get(code=session_code)
@@ -651,7 +701,7 @@ def finish_game(request: HttpRequest, code: str) -> JsonResponse:
@require_POST
@login_required
def calculate_scores(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse:
session_code = code.strip().upper()
session_code = _normalize_session_code(code)
try:
session = GameSession.objects.get(code=session_code)

View File

@@ -99,6 +99,13 @@ STATIC_ROOT = BASE_DIR / 'staticfiles'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
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('/')
CHANNEL_REDIS_HOST = env('CHANNEL_REDIS_HOST', '127.0.0.1')
CHANNEL_REDIS_PORT = int(env('CHANNEL_REDIS_PORT', '6379'))
CHANNEL_LAYERS = {