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>; let error: ReturnType>; let interactionResult: ReturnType>; let interactionError: ReturnType>; let interactionPending: ReturnType>; let store: { location: typeof location; loading: ReturnType>; error: typeof error; interactionResult: typeof interactionResult; interactionError: typeof interactionError; interactionPending: typeof interactionPending; load: ReturnType; runInteraction: ReturnType; closeInteraction: ReturnType; markersFor: ReturnType; }; /** Markers by NPC key, as the server would have decided them. */ let markers: Record; async function setup( current: CurrentLocationResponse | null = burnedRoadFixture(), npcMarkers: Record = {}, ) { markers = npcMarkers; location = signal(current); error = signal(null); interactionResult = signal(null); interactionError = signal(null); interactionPending = signal(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('[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('[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('[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('[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('[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('[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('[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('[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('[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('[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 { 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', }, ], }; }