feat(web): add the local location view at /location
The screen a player stands on between activities: name, region, scene artwork with hotspots pinned by percentage, a four-button action bar and a context sidebar covering identity, danger, encounters, interactions and rewards. It owns no knowledge of any particular place. Hotspots and actions are routed by interaction type: HUNT and MAP hand off to the existing hunt and map screens, and everything that reveals text goes through the server-authoritative interaction endpoint. A second location therefore renders by supplying different content, which the Südtor case in the page spec exercises. The shell drops its generic area rail on /location, where the screen's own sidebar says the same thing better, and Ort joins the navigation as its first entry. Root and unknown routes now land on the location rather than the map: arriving somewhere should mean arriving at a place. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Router, provideRouter } from '@angular/router';
|
||||
import { vi } from 'vitest';
|
||||
import type {
|
||||
CurrentLocationResponse,
|
||||
LocationInteractionResult,
|
||||
} from '../../../core/api/game-api.models';
|
||||
import { burnedRoadFixture, southGateFixture } from '../current-location.fixture';
|
||||
import { LocalLocationStore } from '../local-location.store';
|
||||
import { LocationPageComponent } from './location-page.component';
|
||||
|
||||
describe('LocationPageComponent', () => {
|
||||
let location: ReturnType<typeof signal<CurrentLocationResponse | null>>;
|
||||
let error: ReturnType<typeof signal<string | null>>;
|
||||
let interactionResult: ReturnType<typeof signal<LocationInteractionResult | null>>;
|
||||
let interactionError: ReturnType<typeof signal<string | null>>;
|
||||
let interactionPending: ReturnType<typeof signal<string | null>>;
|
||||
let store: {
|
||||
location: typeof location;
|
||||
loading: ReturnType<typeof signal<boolean>>;
|
||||
error: typeof error;
|
||||
interactionResult: typeof interactionResult;
|
||||
interactionError: typeof interactionError;
|
||||
interactionPending: typeof interactionPending;
|
||||
load: ReturnType<typeof vi.fn>;
|
||||
runInteraction: ReturnType<typeof vi.fn>;
|
||||
closeInteraction: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
async function setup(current: CurrentLocationResponse | null = burnedRoadFixture()) {
|
||||
location = signal(current);
|
||||
error = signal<string | null>(null);
|
||||
interactionResult = signal<LocationInteractionResult | null>(null);
|
||||
interactionError = signal<string | null>(null);
|
||||
interactionPending = signal<string | null>(null);
|
||||
store = {
|
||||
location,
|
||||
loading: signal(false),
|
||||
error,
|
||||
interactionResult,
|
||||
interactionError,
|
||||
interactionPending,
|
||||
load: vi.fn().mockResolvedValue(undefined),
|
||||
runInteraction: vi.fn().mockResolvedValue(undefined),
|
||||
closeInteraction: vi.fn(),
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [LocationPageComponent],
|
||||
providers: [provideRouter([]), { provide: LocalLocationStore, useValue: store }],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(LocationPageComponent);
|
||||
const router = TestBed.inject(Router);
|
||||
vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
return { fixture, router, element: fixture.nativeElement as HTMLElement };
|
||||
}
|
||||
|
||||
it('names the place and its region straight away', async () => {
|
||||
const { element } = await setup();
|
||||
|
||||
expect(element.querySelector('h1')?.textContent).toContain('Verbrannte Straße');
|
||||
expect(element.textContent).toContain('Gebiet 1');
|
||||
expect(element.textContent).toContain('Aschenfelder');
|
||||
expect(element.textContent).toContain(
|
||||
'Ein alter Handelsweg, der durch Feuer und Krieg in Asche gelegt wurde.',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the location artwork, not the composition mockup', async () => {
|
||||
const { element } = await setup();
|
||||
const image = element.querySelector('img') as HTMLImageElement;
|
||||
|
||||
expect(image.getAttribute('src')).toBe('/images/backgrounds/Aschestrasse.png');
|
||||
expect(image.getAttribute('alt')).toBe('Ortsansicht: Verbrannte Straße');
|
||||
});
|
||||
|
||||
it('places every hotspot on the artwork', async () => {
|
||||
const { element } = await setup();
|
||||
const hotspots = element.querySelectorAll('app-location-poi');
|
||||
|
||||
expect(hotspots).toHaveLength(4);
|
||||
expect([...hotspots].map((poi) => poi.querySelector('button')?.dataset['poi'])).toEqual([
|
||||
'hunt-area',
|
||||
'wounded-scout',
|
||||
'inspect-tracks',
|
||||
'search-abandoned-wagon',
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders the four primary actions in the authored order', async () => {
|
||||
const { element } = await setup();
|
||||
const actions = element.querySelectorAll('[data-action]');
|
||||
|
||||
expect([...actions].map((action) => action.querySelector('.location-action__label')?.textContent?.trim())).toEqual([
|
||||
'Jagd beginnen',
|
||||
'Spuren untersuchen',
|
||||
'Umgebung durchsuchen',
|
||||
'Zur Karte',
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders the context sidebar', async () => {
|
||||
const { element } = await setup();
|
||||
|
||||
expect(element.querySelector('app-location-sidebar')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('hands a hunt hotspot to the existing hunt screen without rolling encounters', async () => {
|
||||
const { element, router } = await setup();
|
||||
|
||||
element.querySelector<HTMLButtonElement>('[data-poi="hunt-area"]')?.click();
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/hunt']);
|
||||
expect(store.runInteraction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends the hunt action to the hunt screen too', async () => {
|
||||
const { element, router } = await setup();
|
||||
|
||||
element.querySelector<HTMLButtonElement>('[data-action="start-hunt"]')?.click();
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/hunt']);
|
||||
});
|
||||
|
||||
it('opens the existing map route without completing any travel itself', async () => {
|
||||
const { element, router } = await setup();
|
||||
|
||||
element.querySelector<HTMLButtonElement>('[data-action="open-map"]')?.click();
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/world']);
|
||||
expect(store.runInteraction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('asks the server what investigating the tracks reveals', async () => {
|
||||
const { element, router } = await setup();
|
||||
|
||||
element.querySelector<HTMLButtonElement>('[data-poi="inspect-tracks"]')?.click();
|
||||
|
||||
expect(store.runInteraction).toHaveBeenCalledWith('inspect-tracks');
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('asks the server what searching the wagon reveals', async () => {
|
||||
const { element } = await setup();
|
||||
|
||||
element.querySelector<HTMLButtonElement>('[data-poi="search-abandoned-wagon"]')?.click();
|
||||
|
||||
expect(store.runInteraction).toHaveBeenCalledWith('search-abandoned-wagon');
|
||||
});
|
||||
|
||||
it('asks the server what the scout says', async () => {
|
||||
const { element } = await setup();
|
||||
|
||||
element.querySelector<HTMLButtonElement>('[data-poi="wounded-scout"]')?.click();
|
||||
|
||||
expect(store.runInteraction).toHaveBeenCalledWith('wounded-scout');
|
||||
});
|
||||
|
||||
it('routes an action through the hotspot it mirrors, so both show the same text', async () => {
|
||||
const { element } = await setup();
|
||||
|
||||
element.querySelector<HTMLButtonElement>('[data-action="investigate-tracks"]')?.click();
|
||||
|
||||
expect(store.runInteraction).toHaveBeenCalledWith('inspect-tracks');
|
||||
});
|
||||
|
||||
it('shows the interaction result over the still-visible location', async () => {
|
||||
const { fixture, element } = await setup();
|
||||
|
||||
interactionResult.set({
|
||||
interactionKey: 'inspect-tracks',
|
||||
title: 'Verdächtige Spuren',
|
||||
text: 'Frische Stiefelabdrücke führen nach Osten.',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(element.querySelector('[data-interaction-panel]')?.textContent).toContain(
|
||||
'Frische Stiefelabdrücke führen nach Osten.',
|
||||
);
|
||||
// The scene is not replaced by the panel.
|
||||
expect(element.querySelector('img')).not.toBeNull();
|
||||
expect(element.querySelectorAll('app-location-poi')).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('closes the panel without navigating', async () => {
|
||||
const { fixture, element, router } = await setup();
|
||||
|
||||
interactionResult.set({ interactionKey: 'k', title: 'T', text: 'X' });
|
||||
fixture.detectChanges();
|
||||
element.querySelector<HTMLButtonElement>('[data-interaction-close]')?.click();
|
||||
|
||||
expect(store.closeInteraction).toHaveBeenCalledTimes(1);
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disables the controls while an interaction is running', async () => {
|
||||
const { fixture, element } = await setup();
|
||||
|
||||
interactionPending.set('inspect-tracks');
|
||||
fixture.detectChanges();
|
||||
|
||||
const actions = element.querySelectorAll<HTMLButtonElement>('[data-action]');
|
||||
expect([...actions].every((action) => action.disabled)).toBe(true);
|
||||
expect(element.textContent).toContain('Einen Moment…');
|
||||
});
|
||||
|
||||
it('renders a restrained loading state that shows no misplaced hotspots', async () => {
|
||||
const { element } = await setup(null);
|
||||
|
||||
expect(element.querySelector('[role="status"]')?.textContent).toContain(
|
||||
'Der Ort wird geladen',
|
||||
);
|
||||
expect(element.querySelectorAll('app-location-poi')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('offers a retry when the location could not be loaded', async () => {
|
||||
const { fixture, element } = await setup(null);
|
||||
|
||||
error.set('Weltzustand konnte nicht geladen werden.');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(element.querySelector('[role="alert"]')?.textContent).toContain(
|
||||
'Ort konnte nicht geladen werden.',
|
||||
);
|
||||
|
||||
element.querySelector<HTMLButtonElement>('[data-location-retry]')?.click();
|
||||
expect(store.load).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders a second location from its own data alone', async () => {
|
||||
const { element } = await setup(southGateFixture(southGateContent()));
|
||||
|
||||
expect(element.querySelector('h1')?.textContent).toContain('Südtor von Graufurt');
|
||||
expect(element.querySelectorAll('app-location-poi')).toHaveLength(1);
|
||||
expect(element.querySelectorAll('[data-action]')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
function southGateContent(): Partial<CurrentLocationResponse> {
|
||||
return {
|
||||
pointsOfInterest: [
|
||||
{
|
||||
key: 'gate-watch',
|
||||
title: 'Torwache',
|
||||
actionLabel: 'Sprechen',
|
||||
type: 'NPC',
|
||||
iconKey: 'speak',
|
||||
xPercent: 45,
|
||||
yPercent: 52,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
primaryActions: [
|
||||
{
|
||||
key: 'talk-to-watch',
|
||||
label: 'Wache ansprechen',
|
||||
description: 'Lage erfragen',
|
||||
type: 'NPC',
|
||||
iconKey: 'speak',
|
||||
enabled: true,
|
||||
poiKey: 'gate-watch',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user