From 595766080248f235eabf6922d465faabb02b5e9e Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Sun, 1 Mar 2026 18:01:08 +0000 Subject: [PATCH] feat(spa): bootstrap host/player shells from route resolver context --- .../host/host-shell.component.spec.ts | 17 ++++++++++++++ .../app/features/host/host-shell.component.ts | 11 ++++++---- .../player/player-shell.component.spec.ts | 22 +++++++++++++++++++ .../features/player/player-shell.component.ts | 20 ++++++++--------- 4 files changed, 55 insertions(+), 15 deletions(-) diff --git a/frontend/angular/src/app/features/host/host-shell.component.spec.ts b/frontend/angular/src/app/features/host/host-shell.component.spec.ts index 66f080d..06a212e 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.spec.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.spec.ts @@ -244,4 +244,21 @@ describe('HostShellComponent gameplay wiring', () => { expect(component.nextRoundError).toContain('Session code is required'); expect(component.finishError).toContain('Session code is required'); }); + + it('bootstraps refresh from resolver route context on init', () => { + vi.stubGlobal('window', { + sessionStorage: { + getItem: vi.fn().mockReturnValue(null), + setItem: vi.fn(), + }, + }); + + const component = new HostShellComponent({ snapshot: { data: { routeContext: { sessionCode: 'ab12' } } } } as never); + const refreshSpy = vi.spyOn(component, 'refreshSession').mockResolvedValue(); + + component.ngOnInit(); + + expect(component.sessionCode).toBe('AB12'); + expect(refreshSpy).toHaveBeenCalledOnce(); + }); }); diff --git a/frontend/angular/src/app/features/host/host-shell.component.ts b/frontend/angular/src/app/features/host/host-shell.component.ts index 899d3d6..011fd5a 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.ts @@ -1,10 +1,12 @@ import { CommonModule } from '@angular/common'; -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, Optional } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { createApiClient } from '../../../../../src/api/client'; import type { FinishGameResponse, ScoreboardResponse } from '../../../../../src/api/types'; import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice'; +import type { RouteSessionContext } from '../../session-route-context'; interface SessionDetail { session: { code: string; status: string; current_round: number }; @@ -80,14 +82,15 @@ export class HostShellComponent implements OnInit { private readonly api = createApiClient(); private readonly controller = createVerticalSliceController(this.api); + constructor(@Optional() private readonly route: ActivatedRoute | null = null) {} + ngOnInit(): void { if (typeof window === 'undefined') { return; } - const hashRoute = window.location.hash.replace(/^#\/?/, ''); - const match = hashRoute.match(/^host(?:\/[^/]+)?(?:\/([^/?#]+))?/i); - const codeFromRoute = match?.[1] ?? ''; + const routeContext = this.route?.snapshot.data['routeContext'] as RouteSessionContext | undefined; + const codeFromRoute = routeContext?.sessionCode ?? ''; const storedCode = window.sessionStorage.getItem('wpp.host-session-code') ?? ''; const candidate = codeFromRoute || storedCode; diff --git a/frontend/angular/src/app/features/player/player-shell.component.spec.ts b/frontend/angular/src/app/features/player/player-shell.component.spec.ts index 6481e4f..2f3d62c 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.spec.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.spec.ts @@ -254,6 +254,28 @@ describe('PlayerShellComponent gameplay wiring', () => { expect(component.loadingTransition).toBeNull(); }); + it('hydrates session context from resolver payload on init', () => { + const component = new PlayerShellComponent({ + snapshot: { + data: { + routeContext: { + sessionCode: 'ab12', + playerId: 7, + token: 'tok-7', + }, + }, + }, + } as never); + const refreshSpy = vi.spyOn(component, 'refreshSession').mockResolvedValue(); + + component.ngOnInit(); + + expect(component.sessionCode).toBe('AB12'); + expect(component.playerId).toBe(7); + expect(component.sessionToken).toBe('tok-7'); + expect(refreshSpy).toHaveBeenCalledOnce(); + }); + it('returnToJoin clears persisted session context and transient state', () => { const values = new Map(); const localStorage = { diff --git a/frontend/angular/src/app/features/player/player-shell.component.ts b/frontend/angular/src/app/features/player/player-shell.component.ts index 11b2f40..3a3e9ba 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.ts @@ -1,10 +1,12 @@ import { CommonModule } from '@angular/common'; -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit, Optional } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { createApiClient } from '../../../../../src/api/client'; import { createSessionContextStore } from '../../../../../src/spa/session-context-store'; import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice'; +import type { RouteSessionContext } from '../../session-route-context'; interface SessionDetail { session: { code: string; status: string; current_round: number }; @@ -108,7 +110,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy { private readonly controller = createVerticalSliceController(createApiClient(), this.sessionContextStore); private reconnectTimer: ReturnType | null = null; - constructor() { + constructor(@Optional() private readonly route: ActivatedRoute | null = null) { if (typeof navigator !== 'undefined' && !navigator.onLine) { this.connectionState = 'offline'; } @@ -120,17 +122,13 @@ export class PlayerShellComponent implements OnInit, OnDestroy { } ngOnInit(): void { - const hashRoute = window.location.hash.replace(/^#\/?/, ''); - const match = hashRoute.match(/^player(?:\/[^/]+)?(?:\/([^/?#]+))?/i); - const codeFromRoute = match?.[1] ?? ''; - + const routeContext = this.route?.snapshot.data['routeContext'] as RouteSessionContext | undefined; const persistedContext = this.sessionContextStore.get(); - if (persistedContext) { - this.playerId = persistedContext.playerId; - this.sessionToken = persistedContext.token; - } - const candidate = codeFromRoute || persistedContext?.sessionCode || ''; + this.playerId = routeContext?.playerId ?? persistedContext?.playerId ?? 0; + this.sessionToken = routeContext?.token ?? persistedContext?.token ?? ''; + + const candidate = routeContext?.sessionCode || persistedContext?.sessionCode || ''; if (!candidate) { return; }