feat: add HuntingStore for hunt/encounter state
Mirrors WorldStore's signal-store architecture to own hunt state for Slice 0.2 (currentHunt, selectedEncounterId, loading, error) with a computed encounters accessor and the four hunt error codes mapped to German messages.
This commit is contained in:
209
apps/web/src/app/features/hunting/hunting.store.spec.ts
Normal file
209
apps/web/src/app/features/hunting/hunting.store.spec.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
import type { HuntResult } from '../../core/api/game-api.models';
|
||||
import { GameApiService } from '../../core/api/game-api.service';
|
||||
import { HuntingStore } from './hunting.store';
|
||||
|
||||
const huntResult: HuntResult = {
|
||||
id: 'hunt-id',
|
||||
location: { id: 'origin-id', key: 'south-gate', name: 'Südtor' },
|
||||
encounters: [
|
||||
{
|
||||
id: 'encounter-1',
|
||||
monster: { key: 'wolf', name: 'Wolf', level: 1, artworkPath: '/images/enemies/Wolf.png' },
|
||||
dangerRating: 'MATCH',
|
||||
},
|
||||
{
|
||||
id: 'encounter-2',
|
||||
monster: { key: 'bear', name: 'Bär', level: 3, artworkPath: '/images/enemies/Bear.png' },
|
||||
dangerRating: 'STRONG',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const refreshedHuntResult: HuntResult = {
|
||||
id: 'hunt-id-2',
|
||||
location: { id: 'origin-id', key: 'south-gate', name: 'Südtor' },
|
||||
encounters: [
|
||||
{
|
||||
id: 'encounter-3',
|
||||
monster: { key: 'rat', name: 'Ratte', level: 1, artworkPath: '/images/enemies/Rat.png' },
|
||||
dangerRating: 'WEAK',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('HuntingStore', () => {
|
||||
let api: {
|
||||
startHunt: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let store: HuntingStore;
|
||||
|
||||
beforeEach(() => {
|
||||
api = {
|
||||
startHunt: vi.fn(() => of(huntResult)),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [HuntingStore, { provide: GameApiService, useValue: api }],
|
||||
});
|
||||
store = TestBed.inject(HuntingStore);
|
||||
});
|
||||
|
||||
it('starts a hunt and populates currentHunt and encounters', async () => {
|
||||
await store.startHunt();
|
||||
|
||||
expect(api.startHunt).toHaveBeenCalledOnce();
|
||||
expect(store.currentHunt()).toEqual(huntResult);
|
||||
expect(store.encounters()).toEqual(huntResult.encounters);
|
||||
expect(store.loading()).toBe(false);
|
||||
expect(store.error()).toBeNull();
|
||||
});
|
||||
|
||||
it('clears any previously selected encounter when starting a new hunt', async () => {
|
||||
await store.startHunt();
|
||||
store.selectEncounter('encounter-1');
|
||||
expect(store.selectedEncounterId()).toBe('encounter-1');
|
||||
|
||||
await store.startHunt();
|
||||
|
||||
expect(store.selectedEncounterId()).toBeNull();
|
||||
});
|
||||
|
||||
it('exposes an empty encounters array before any hunt has started', () => {
|
||||
expect(store.currentHunt()).toBeNull();
|
||||
expect(store.encounters()).toEqual([]);
|
||||
});
|
||||
|
||||
it('sets loading while the request is in flight and clears it afterwards', async () => {
|
||||
expect(store.loading()).toBe(false);
|
||||
|
||||
const promise = store.startHunt();
|
||||
expect(store.loading()).toBe(true);
|
||||
|
||||
await promise;
|
||||
|
||||
expect(store.loading()).toBe(false);
|
||||
});
|
||||
|
||||
it('clears loading even when the API call throws', async () => {
|
||||
api.startHunt.mockReturnValue(throwError(() => new Error('Netzwerkfehler')));
|
||||
|
||||
await store.startHunt();
|
||||
|
||||
expect(store.loading()).toBe(false);
|
||||
});
|
||||
|
||||
it('selects an encounter without making a network call', () => {
|
||||
store.selectEncounter('encounter-2');
|
||||
|
||||
expect(store.selectedEncounterId()).toBe('encounter-2');
|
||||
expect(api.startHunt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refreshHunt issues another API call and replaces currentHunt', async () => {
|
||||
await store.startHunt();
|
||||
expect(store.currentHunt()).toEqual(huntResult);
|
||||
|
||||
api.startHunt.mockReturnValue(of(refreshedHuntResult));
|
||||
await store.refreshHunt();
|
||||
|
||||
expect(api.startHunt).toHaveBeenCalledTimes(2);
|
||||
expect(store.currentHunt()).toEqual(refreshedHuntResult);
|
||||
expect(store.encounters()).toEqual(refreshedHuntResult.encounters);
|
||||
});
|
||||
|
||||
it('maps CHARACTER_NOT_FOUND to its German message', async () => {
|
||||
api.startHunt.mockReturnValue(
|
||||
throwError(
|
||||
() =>
|
||||
new HttpErrorResponse({
|
||||
status: 404,
|
||||
error: { statusCode: 404, code: 'CHARACTER_NOT_FOUND', message: 'Character not found.' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await store.startHunt();
|
||||
|
||||
expect(store.error()).toBe('Dein Charakter konnte nicht gefunden werden.');
|
||||
});
|
||||
|
||||
it('maps CHARACTER_TRAVELLING to its German message', async () => {
|
||||
api.startHunt.mockReturnValue(
|
||||
throwError(
|
||||
() =>
|
||||
new HttpErrorResponse({
|
||||
status: 409,
|
||||
error: { statusCode: 409, code: 'CHARACTER_TRAVELLING', message: 'Character is travelling.' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await store.startHunt();
|
||||
|
||||
expect(store.error()).toBe('Du kannst nicht jagen, während du unterwegs bist.');
|
||||
});
|
||||
|
||||
it('maps HUNTING_NOT_AVAILABLE to its German message', async () => {
|
||||
api.startHunt.mockReturnValue(
|
||||
throwError(
|
||||
() =>
|
||||
new HttpErrorResponse({
|
||||
status: 400,
|
||||
error: { statusCode: 400, code: 'HUNTING_NOT_AVAILABLE', message: 'Hunting not available.' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await store.startHunt();
|
||||
|
||||
expect(store.error()).toBe('An diesem Ort gibt es keine Jagdgebiete.');
|
||||
});
|
||||
|
||||
it('maps NO_HUNT_ENCOUNTERS_AVAILABLE to its German message', async () => {
|
||||
api.startHunt.mockReturnValue(
|
||||
throwError(
|
||||
() =>
|
||||
new HttpErrorResponse({
|
||||
status: 404,
|
||||
error: {
|
||||
statusCode: 404,
|
||||
code: 'NO_HUNT_ENCOUNTERS_AVAILABLE',
|
||||
message: 'No encounters available.',
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await store.startHunt();
|
||||
|
||||
expect(store.error()).toBe('Aktuell sind hier keine Gegner zu finden.');
|
||||
});
|
||||
|
||||
it('falls back to the generic message for an HttpErrorResponse with no known code', async () => {
|
||||
api.startHunt.mockReturnValue(
|
||||
throwError(
|
||||
() =>
|
||||
new HttpErrorResponse({
|
||||
status: 500,
|
||||
error: { message: 'Internal server error' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await store.startHunt();
|
||||
|
||||
expect(store.error()).toBe('Weltzustand konnte nicht geladen werden.');
|
||||
});
|
||||
|
||||
it('uses the message of a genuine non-HTTP Error', async () => {
|
||||
api.startHunt.mockReturnValue(throwError(() => new Error('Netzwerkfehler')));
|
||||
|
||||
await store.startHunt();
|
||||
|
||||
expect(store.error()).toBe('Netzwerkfehler');
|
||||
});
|
||||
});
|
||||
65
apps/web/src/app/features/hunting/hunting.store.ts
Normal file
65
apps/web/src/app/features/hunting/hunting.store.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { Injectable, computed, signal } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { HuntResult } from '../../core/api/game-api.models';
|
||||
import { GameApiService } from '../../core/api/game-api.service';
|
||||
|
||||
const GENERIC_ERROR_MESSAGE = 'Weltzustand konnte nicht geladen werden.';
|
||||
|
||||
// Mirrors the hunt error codes returned by `POST /api/hunts`.
|
||||
// Unknown/missing codes fall back to `GENERIC_ERROR_MESSAGE`.
|
||||
const HUNT_ERROR_MESSAGES: Readonly<Record<string, string>> = {
|
||||
CHARACTER_NOT_FOUND: 'Dein Charakter konnte nicht gefunden werden.',
|
||||
CHARACTER_TRAVELLING: 'Du kannst nicht jagen, während du unterwegs bist.',
|
||||
HUNTING_NOT_AVAILABLE: 'An diesem Ort gibt es keine Jagdgebiete.',
|
||||
NO_HUNT_ENCOUNTERS_AVAILABLE: 'Aktuell sind hier keine Gegner zu finden.',
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class HuntingStore {
|
||||
private readonly currentHuntState = signal<HuntResult | null>(null);
|
||||
private readonly selectedEncounterIdState = signal<string | null>(null);
|
||||
private readonly loadingState = signal(false);
|
||||
private readonly errorState = signal<string | null>(null);
|
||||
|
||||
readonly currentHunt = this.currentHuntState.asReadonly();
|
||||
readonly selectedEncounterId = this.selectedEncounterIdState.asReadonly();
|
||||
readonly loading = this.loadingState.asReadonly();
|
||||
readonly error = this.errorState.asReadonly();
|
||||
|
||||
readonly encounters = computed(() => this.currentHuntState()?.encounters ?? []);
|
||||
|
||||
constructor(private readonly api: GameApiService) {}
|
||||
|
||||
async startHunt(): Promise<void> {
|
||||
this.loadingState.set(true);
|
||||
this.errorState.set(null);
|
||||
|
||||
try {
|
||||
const hunt = await firstValueFrom(this.api.startHunt());
|
||||
this.currentHuntState.set(hunt);
|
||||
this.selectedEncounterIdState.set(null);
|
||||
} catch (error) {
|
||||
this.errorState.set(this.toErrorMessage(error));
|
||||
} finally {
|
||||
this.loadingState.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async refreshHunt(): Promise<void> {
|
||||
await this.startHunt();
|
||||
}
|
||||
|
||||
selectEncounter(encounterId: string): void {
|
||||
this.selectedEncounterIdState.set(encounterId);
|
||||
}
|
||||
|
||||
private toErrorMessage(error: unknown): string {
|
||||
if (error instanceof HttpErrorResponse) {
|
||||
const code = (error.error as { code?: string } | null)?.code;
|
||||
return (code && HUNT_ERROR_MESSAGES[code]) || GENERIC_ERROR_MESSAGE;
|
||||
}
|
||||
|
||||
return error instanceof Error ? error.message : GENERIC_ERROR_MESSAGE;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user