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,116 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import type { LocationPointOfInterest } from '../../../core/api/game-api.models';
|
||||
import { LocationPoiComponent } from './location-poi.component';
|
||||
|
||||
const wagon: LocationPointOfInterest = {
|
||||
key: 'search-abandoned-wagon',
|
||||
title: 'Verlassener Wagen',
|
||||
actionLabel: 'Durchsuchen',
|
||||
type: 'SEARCH',
|
||||
iconKey: 'search',
|
||||
xPercent: 80,
|
||||
yPercent: 68,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
async function setup(
|
||||
poi: LocationPointOfInterest = wagon,
|
||||
busy = false,
|
||||
): Promise<{
|
||||
fixture: ComponentFixture<LocationPoiComponent>;
|
||||
activated: LocationPointOfInterest[];
|
||||
button: HTMLButtonElement;
|
||||
}> {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [LocationPoiComponent],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(LocationPoiComponent);
|
||||
fixture.componentRef.setInput('poi', poi);
|
||||
fixture.componentRef.setInput('busy', busy);
|
||||
|
||||
const activated: LocationPointOfInterest[] = [];
|
||||
fixture.componentInstance.activate.subscribe((value) => activated.push(value));
|
||||
fixture.detectChanges();
|
||||
|
||||
return {
|
||||
fixture,
|
||||
activated,
|
||||
button: fixture.nativeElement.querySelector('button') as HTMLButtonElement,
|
||||
};
|
||||
}
|
||||
|
||||
describe('LocationPoiComponent', () => {
|
||||
it('anchors itself with the percentage coordinates so it scales with the artwork', async () => {
|
||||
const { fixture } = await setup();
|
||||
const host = fixture.nativeElement as HTMLElement;
|
||||
|
||||
expect(host.style.left).toBe('80%');
|
||||
expect(host.style.top).toBe('68%');
|
||||
});
|
||||
|
||||
it('renders title and action label', async () => {
|
||||
const { fixture } = await setup();
|
||||
const text = (fixture.nativeElement as HTMLElement).textContent ?? '';
|
||||
|
||||
expect(text).toContain('Verlassener Wagen');
|
||||
expect(text).toContain('Durchsuchen');
|
||||
});
|
||||
|
||||
it('emits the whole hotspot when clicked', async () => {
|
||||
const { button, activated } = await setup();
|
||||
|
||||
button.click();
|
||||
|
||||
expect(activated).toEqual([wagon]);
|
||||
});
|
||||
|
||||
it('is reachable and activatable from the keyboard', async () => {
|
||||
const { button, activated } = await setup();
|
||||
|
||||
// A native button gives Enter and Space for free; assert it stayed a
|
||||
// button rather than becoming a click-only div.
|
||||
expect(button.tagName).toBe('BUTTON');
|
||||
expect(button.tabIndex).toBe(0);
|
||||
|
||||
button.focus();
|
||||
expect(document.activeElement).toBe(button);
|
||||
|
||||
button.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
|
||||
button.click();
|
||||
|
||||
expect(activated).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does nothing when the hotspot is disabled', async () => {
|
||||
const { button, activated } = await setup({ ...wagon, enabled: false });
|
||||
|
||||
expect(button.disabled).toBe(true);
|
||||
button.click();
|
||||
|
||||
expect(activated).toEqual([]);
|
||||
});
|
||||
|
||||
it('does nothing while an interaction is already running', async () => {
|
||||
const { button, activated } = await setup(wagon, true);
|
||||
|
||||
button.click();
|
||||
|
||||
expect(activated).toEqual([]);
|
||||
});
|
||||
|
||||
it('describes itself with both title and action for screen readers', async () => {
|
||||
const { button } = await setup();
|
||||
|
||||
expect(button.getAttribute('aria-label')).toBe('Verlassener Wagen: Durchsuchen');
|
||||
});
|
||||
|
||||
it('falls back to the title alone when a hotspot has no action label', async () => {
|
||||
const { button } = await setup({
|
||||
...wagon,
|
||||
actionLabel: undefined,
|
||||
});
|
||||
|
||||
expect(button.getAttribute('aria-label')).toBe('Verlassener Wagen');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user