feat(spa): bootstrap host/player shells from route resolver context
All checks were successful
CI / test-and-quality (push) Successful in 1m59s

This commit is contained in:
2026-03-01 18:01:08 +00:00
parent 177574ae19
commit 5957660802
4 changed files with 55 additions and 15 deletions

View File

@@ -244,4 +244,21 @@ describe('HostShellComponent gameplay wiring', () => {
expect(component.nextRoundError).toContain('Session code is required'); expect(component.nextRoundError).toContain('Session code is required');
expect(component.finishError).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();
});
}); });

View File

@@ -1,10 +1,12 @@
import { CommonModule } from '@angular/common'; 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 { FormsModule } from '@angular/forms';
import { createApiClient } from '../../../../../src/api/client'; import { createApiClient } from '../../../../../src/api/client';
import type { FinishGameResponse, ScoreboardResponse } from '../../../../../src/api/types'; import type { FinishGameResponse, ScoreboardResponse } from '../../../../../src/api/types';
import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice'; import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice';
import type { RouteSessionContext } from '../../session-route-context';
interface SessionDetail { interface SessionDetail {
session: { code: string; status: string; current_round: number }; session: { code: string; status: string; current_round: number };
@@ -80,14 +82,15 @@ export class HostShellComponent implements OnInit {
private readonly api = createApiClient(); private readonly api = createApiClient();
private readonly controller = createVerticalSliceController(this.api); private readonly controller = createVerticalSliceController(this.api);
constructor(@Optional() private readonly route: ActivatedRoute | null = null) {}
ngOnInit(): void { ngOnInit(): void {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return; return;
} }
const hashRoute = window.location.hash.replace(/^#\/?/, ''); const routeContext = this.route?.snapshot.data['routeContext'] as RouteSessionContext | undefined;
const match = hashRoute.match(/^host(?:\/[^/]+)?(?:\/([^/?#]+))?/i); const codeFromRoute = routeContext?.sessionCode ?? '';
const codeFromRoute = match?.[1] ?? '';
const storedCode = window.sessionStorage.getItem('wpp.host-session-code') ?? ''; const storedCode = window.sessionStorage.getItem('wpp.host-session-code') ?? '';
const candidate = codeFromRoute || storedCode; const candidate = codeFromRoute || storedCode;

View File

@@ -254,6 +254,28 @@ describe('PlayerShellComponent gameplay wiring', () => {
expect(component.loadingTransition).toBeNull(); 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', () => { it('returnToJoin clears persisted session context and transient state', () => {
const values = new Map<string, string>(); const values = new Map<string, string>();
const localStorage = { const localStorage = {

View File

@@ -1,10 +1,12 @@
import { CommonModule } from '@angular/common'; 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 { FormsModule } from '@angular/forms';
import { createApiClient } from '../../../../../src/api/client'; import { createApiClient } from '../../../../../src/api/client';
import { createSessionContextStore } from '../../../../../src/spa/session-context-store'; import { createSessionContextStore } from '../../../../../src/spa/session-context-store';
import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice'; import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice';
import type { RouteSessionContext } from '../../session-route-context';
interface SessionDetail { interface SessionDetail {
session: { code: string; status: string; current_round: number }; 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 readonly controller = createVerticalSliceController(createApiClient(), this.sessionContextStore);
private reconnectTimer: ReturnType<typeof setTimeout> | null = null; private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
constructor() { constructor(@Optional() private readonly route: ActivatedRoute | null = null) {
if (typeof navigator !== 'undefined' && !navigator.onLine) { if (typeof navigator !== 'undefined' && !navigator.onLine) {
this.connectionState = 'offline'; this.connectionState = 'offline';
} }
@@ -120,17 +122,13 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
} }
ngOnInit(): void { ngOnInit(): void {
const hashRoute = window.location.hash.replace(/^#\/?/, ''); const routeContext = this.route?.snapshot.data['routeContext'] as RouteSessionContext | undefined;
const match = hashRoute.match(/^player(?:\/[^/]+)?(?:\/([^/?#]+))?/i);
const codeFromRoute = match?.[1] ?? '';
const persistedContext = this.sessionContextStore.get(); 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) { if (!candidate) {
return; return;
} }