Files
ashen-realms/apps/web/src/app/features/world/location-page/location-page.component.spec.ts
Bastian Wagner 3a84fd5371 feat(web): show quest markers on location hotspots
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:28:55 +02:00

302 lines
11 KiB
TypeScript

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,
NpcMarker,
} 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>;
markersFor: ReturnType<typeof vi.fn>;
};
/** Markers by NPC key, as the server would have decided them. */
let markers: Record<string, NpcMarker[]>;
async function setup(
current: CurrentLocationResponse | null = burnedRoadFixture(),
npcMarkers: Record<string, NpcMarker[]> = {},
) {
markers = npcMarkers;
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(),
markersFor: vi.fn((npcKey?: string) => markers[npcKey ?? ''] ?? []),
};
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('Burned Road');
expect(element.textContent).toContain('Tier 1');
expect(element.textContent).toContain('Ashen Fields');
expect(element.textContent).toContain(
'An old trade road, burned to ash by fire and war. Charred carts, broken weapons, and silenced cries line the path into the Ashen Fields.',
);
});
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('Location view: Burned Road');
});
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',
'inspect-tracks',
'search-abandoned-wagon',
'wounded-scout',
]);
});
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([
'Begin Hunt',
'Investigate tracks',
'Search surroundings',
'To Map',
]);
});
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: 'Suspicious Tracks',
text: 'Among the ash and broken stones you make out several fresh bootprints. They lead east, toward the abandoned watchpost.',
});
fixture.detectChanges();
expect(element.querySelector('[data-interaction-panel]')?.textContent).toContain(
'Among the ash and broken stones you make out several fresh bootprints. They lead east, toward the abandoned watchpost.',
);
// 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('One 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(
'Loading location',
);
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('Could not load world state.');
fixture.detectChanges();
expect(element.querySelector('[role="alert"]')?.textContent).toContain(
'Could not load location.',
);
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('Graufurt South Gate');
expect(element.querySelectorAll('app-location-poi')).toHaveLength(1);
expect(element.querySelectorAll('[data-action]')).toHaveLength(1);
});
it('badges the hotspot of an NPC with something to ask', async () => {
const { element } = await setup(southGateFixture(southGateContent()), {
'south-gate-warden': ['QUEST_AVAILABLE'],
});
expect(
element.querySelector('[data-poi-badge]')?.textContent?.trim(),
).toBe('!');
});
it('leaves a hotspot that names no NPC unbadged', async () => {
const { element } = await setup(
southGateFixture(southGateContent({ withNpcKey: false })),
{ 'south-gate-warden': ['QUEST_AVAILABLE'] },
);
expect(element.querySelector('[data-poi-badge]')).toBeNull();
expect(store.markersFor).toHaveBeenCalledWith(undefined);
});
});
function southGateContent({
withNpcKey = true,
}: { withNpcKey?: boolean } = {}): Partial<CurrentLocationResponse> {
return {
pointsOfInterest: [
{
key: 'gate-watch',
title: 'Halvik, Warden of the South Gate',
actionLabel: 'Talk',
type: 'NPC',
iconKey: 'speak',
xPercent: 45,
yPercent: 52,
enabled: true,
...(withNpcKey ? { npcKey: 'south-gate-warden' } : {}),
},
],
primaryActions: [
{
key: 'talk-to-watch',
label: 'Talk to the watch',
description: 'Ask about the situation',
type: 'NPC',
iconKey: 'speak',
enabled: true,
poiKey: 'gate-watch',
},
],
};
}