302 lines
11 KiB
TypeScript
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',
|
|
},
|
|
],
|
|
};
|
|
}
|