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:
139
apps/web/src/app/features/world/local-location.store.spec.ts
Normal file
139
apps/web/src/app/features/world/local-location.store.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Subject, of, throwError } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
import { GameApiService } from '../../core/api/game-api.service';
|
||||
import { LocalLocationStore } from './local-location.store';
|
||||
import { WorldStore } from './world.store';
|
||||
|
||||
const trackResult = {
|
||||
interactionKey: 'inspect-tracks',
|
||||
title: 'Verdächtige Spuren',
|
||||
text: 'Frische Stiefelabdrücke führen nach Osten.',
|
||||
};
|
||||
|
||||
function setup(api: Partial<GameApiService> = {}, world: Partial<WorldStore> = {}) {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
LocalLocationStore,
|
||||
{ provide: GameApiService, useValue: api },
|
||||
{ provide: WorldStore, useValue: { load: vi.fn(), ...world } },
|
||||
],
|
||||
});
|
||||
|
||||
return TestBed.inject(LocalLocationStore);
|
||||
}
|
||||
|
||||
describe('LocalLocationStore', () => {
|
||||
it('loads the location through the world store rather than a second fetch path', async () => {
|
||||
const load = vi.fn().mockResolvedValue(undefined);
|
||||
const store = setup({}, { load });
|
||||
|
||||
await store.load();
|
||||
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('opens the panel with the result the server returned', async () => {
|
||||
const store = setup({
|
||||
runLocationInteraction: vi.fn().mockReturnValue(of(trackResult)),
|
||||
});
|
||||
|
||||
await store.runInteraction('inspect-tracks');
|
||||
|
||||
expect(store.interactionResult()).toEqual(trackResult);
|
||||
expect(store.interactionError()).toBeNull();
|
||||
expect(store.interactionOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it('marks only the running interaction as pending', async () => {
|
||||
const pending = new Subject<typeof trackResult>();
|
||||
const store = setup({
|
||||
runLocationInteraction: vi.fn().mockReturnValue(pending),
|
||||
});
|
||||
|
||||
const running = store.runInteraction('inspect-tracks');
|
||||
expect(store.interactionPending()).toBe('inspect-tracks');
|
||||
|
||||
pending.next(trackResult);
|
||||
pending.complete();
|
||||
await running;
|
||||
|
||||
expect(store.interactionPending()).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores a second interaction while one is still running', async () => {
|
||||
const pending = new Subject<typeof trackResult>();
|
||||
const runLocationInteraction = vi.fn().mockReturnValue(pending);
|
||||
const store = setup({ runLocationInteraction });
|
||||
|
||||
const running = store.runInteraction('inspect-tracks');
|
||||
await store.runInteraction('search-abandoned-wagon');
|
||||
|
||||
expect(runLocationInteraction).toHaveBeenCalledTimes(1);
|
||||
expect(runLocationInteraction).toHaveBeenCalledWith('inspect-tracks');
|
||||
|
||||
pending.next(trackResult);
|
||||
pending.complete();
|
||||
await running;
|
||||
});
|
||||
|
||||
it('translates a rejected interaction into a readable message without navigating', async () => {
|
||||
const store = setup({
|
||||
runLocationInteraction: vi.fn().mockReturnValue(
|
||||
throwError(
|
||||
() =>
|
||||
new HttpErrorResponse({
|
||||
status: 400,
|
||||
error: { code: 'LOCATION_INTERACTION_UNAVAILABLE' },
|
||||
}),
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
await store.runInteraction('inspect-tracks');
|
||||
|
||||
expect(store.interactionError()).toBe('Hier gibt es dazu nichts zu entdecken.');
|
||||
expect(store.interactionResult()).toBeNull();
|
||||
expect(store.interactionOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to a generic message for an unmapped failure', async () => {
|
||||
const store = setup({
|
||||
runLocationInteraction: vi
|
||||
.fn()
|
||||
.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 500 }))),
|
||||
});
|
||||
|
||||
await store.runInteraction('inspect-tracks');
|
||||
|
||||
expect(store.interactionError()).toBe('Diese Handlung ist gerade nicht möglich.');
|
||||
});
|
||||
|
||||
it('closes the panel without clearing the location', async () => {
|
||||
const store = setup({
|
||||
runLocationInteraction: vi.fn().mockReturnValue(of(trackResult)),
|
||||
});
|
||||
|
||||
await store.runInteraction('inspect-tracks');
|
||||
store.closeInteraction();
|
||||
|
||||
expect(store.interactionOpen()).toBe(false);
|
||||
expect(store.interactionResult()).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves the hotspot an action mirrors', () => {
|
||||
const store = setup();
|
||||
|
||||
expect(
|
||||
store.interactionKeyOf({
|
||||
key: 'investigate-tracks',
|
||||
label: 'Spuren untersuchen',
|
||||
type: 'INVESTIGATE',
|
||||
iconKey: 'investigate',
|
||||
enabled: true,
|
||||
poiKey: 'inspect-tracks',
|
||||
}),
|
||||
).toBe('inspect-tracks');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user