Files
ashen-realms/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts
Bastian Wagner f33b0c0e4a feat(combat): rejoin the running combat instead of dead-ending
Attacking while a combat is already active returned COMBAT_ALREADY_ACTIVE
and left the hunt page showing an error the player could not act on, with
no way back into the fight they were already in.

Add GET /api/combats/active so the client can resolve that combat, and
have the hunt page navigate into it when an attack is rejected for this
reason. CombatStore now also exposes the error code so callers can tell
this case apart from a genuinely failed attack.
2026-08-19 22:45:53 +02:00

332 lines
12 KiB
TypeScript

// apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Router, provideRouter } from '@angular/router';
import { vi } from 'vitest';
import type { Combat, CurrentLocationResponse, HuntResult } from '../../../core/api/game-api.models';
import { CombatStore } from '../../combat/combat.store';
import { WorldStore } from '../../world/world.store';
import { HuntingStore } from '../hunting.store';
import { HuntPageComponent } from './hunt-page.component';
const southGate: CurrentLocationResponse = {
id: 'south-gate-id',
key: 'south-gate',
name: 'Südtor von Graufurt',
description: 'Der letzte sichere Schritt vor den Aschenfeldern.',
regionKey: 'ashen-fields',
minRecommendedLevel: 1,
maxRecommendedLevel: 1,
dangerLevel: 0,
isSafe: true,
huntingEnabled: false,
artworkPath: '/images/backgrounds/Suedtor.png',
connections: [],
possibleMonsters: [],
};
const burnedRoad: CurrentLocationResponse = {
...southGate,
id: 'burned-road-id',
key: 'burned-road',
name: 'Verbrannte Straße',
description: 'Die erste Jagdzone zwischen Asche und zerbrochenen Wagen.',
isSafe: false,
huntingEnabled: true,
artworkPath: '/images/backgrounds/Aschestrasse.png',
possibleMonsters: ['Aschenratte', 'Straßenräuber'],
connections: [],
};
const threeEncounterHunt: HuntResult = {
id: 'hunt-id',
location: { id: 'burned-road-id', key: 'burned-road', name: 'Verbrannte Straße' },
encounters: [
{
id: 'encounter-1',
monster: { key: 'ash-rat', name: 'Aschenratte', level: 1, artworkPath: '/images/enemies/AshRat.png' },
dangerRating: 'WEAK',
},
{
id: 'encounter-2',
monster: {
key: 'road-bandit',
name: 'Straßenräuber',
level: 3,
artworkPath: '/images/enemies/RoadBandit.png',
},
dangerRating: 'MATCH',
},
{
id: 'encounter-3',
monster: { key: 'ash-rat', name: 'Aschenratte', level: 1, artworkPath: '/images/enemies/AshRat.png' },
dangerRating: 'WEAK',
},
],
};
const startedCombat: Combat = {
id: 'combat-2',
status: 'ACTIVE',
round: 1,
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100 },
monster: {
key: 'road-bandit',
name: 'Straßenräuber',
level: 3,
maxHp: 75,
currentHp: 75,
artworkPath: '/images/enemies/RoadBandit.png',
},
events: [],
};
describe('HuntPageComponent', () => {
let worldStore: {
currentLocation: ReturnType<typeof signal<CurrentLocationResponse | null>>;
load: ReturnType<typeof vi.fn>;
};
let huntingStore: {
currentHunt: ReturnType<typeof signal<HuntResult | null>>;
loading: ReturnType<typeof signal<boolean>>;
error: ReturnType<typeof signal<string | null>>;
encounters: () => HuntResult['encounters'];
startHunt: ReturnType<typeof vi.fn>;
refreshHunt: ReturnType<typeof vi.fn>;
selectEncounter: ReturnType<typeof vi.fn>;
};
let combatStore: {
combat: ReturnType<typeof signal<Combat | null>>;
error: ReturnType<typeof signal<string | null>>;
errorCode: ReturnType<typeof signal<string | null>>;
startCombat: ReturnType<typeof vi.fn>;
loadActiveCombat: ReturnType<typeof vi.fn>;
clearError: ReturnType<typeof vi.fn>;
};
let router: Router;
async function setup(location: CurrentLocationResponse | null, hunt: HuntResult | null = null) {
worldStore = { currentLocation: signal(location), load: vi.fn(() => Promise.resolve()) };
const currentHunt = signal(hunt);
huntingStore = {
currentHunt,
loading: signal(false),
error: signal<string | null>(null),
encounters: () => currentHunt()?.encounters ?? [],
startHunt: vi.fn(() => Promise.resolve()),
refreshHunt: vi.fn(() => Promise.resolve()),
selectEncounter: vi.fn(),
};
combatStore = {
combat: signal<Combat | null>(null),
error: signal<string | null>(null),
errorCode: signal<string | null>(null),
startCombat: vi.fn(() => Promise.resolve()),
loadActiveCombat: vi.fn(() => Promise.resolve(null)),
clearError: vi.fn(),
};
await TestBed.configureTestingModule({
imports: [HuntPageComponent],
providers: [
provideRouter([]),
{ provide: WorldStore, useValue: worldStore },
{ provide: HuntingStore, useValue: huntingStore },
{ provide: CombatStore, useValue: combatStore },
],
}).compileComponents();
router = TestBed.inject(Router);
vi.spyOn(router, 'navigate').mockResolvedValue(true);
const fixture = TestBed.createComponent(HuntPageComponent);
fixture.detectChanges();
return fixture;
}
it('shows the hunting-unavailable state at the Südtor, with no Jagd beginnen button, and a working Zur Karte action', async () => {
const fixture = await setup(southGate);
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('Keine Jagd verfügbar');
expect(element.textContent).toContain('Am Südtor von Graufurt gibt es keine regulären Jagdgebiete.');
expect(
Array.from(element.querySelectorAll('button')).some(
(button) => button.textContent?.trim() === 'Jagd beginnen',
),
).toBe(false);
const toWorldButton = element.querySelector<HTMLButtonElement>('[data-hunt-to-world]');
expect(toWorldButton?.textContent?.trim()).toBe('Zur Karte');
toWorldButton?.click();
expect(router.navigate).toHaveBeenCalledWith(['/world']);
});
it('calls startHunt when Jagd beginnen is clicked at a hunting-enabled location', async () => {
const fixture = await setup(burnedRoad);
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-hunt-start]')?.click();
expect(huntingStore.startHunt).toHaveBeenCalledOnce();
});
it('renders 3 encounter cards, duplicates included, with the correct data', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
const element = fixture.nativeElement as HTMLElement;
const cards = element.querySelectorAll('app-encounter-card');
expect(cards.length).toBe(3);
expect(element.textContent).toMatch(/Aschenratte[\s\S]*Straßenräuber[\s\S]*Aschenratte/);
expect(element.querySelectorAll('img[src="/images/enemies/AshRat.png"]').length).toBe(2);
expect(element.querySelectorAll('img[src="/images/enemies/RoadBandit.png"]').length).toBe(1);
expect(element.textContent).toContain('Stufe 1');
expect(element.textContent).toContain('Stufe 3');
});
it('calls refreshHunt when Neu suchen is clicked', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-hunt-refresh]')?.click();
expect(huntingStore.refreshHunt).toHaveBeenCalledOnce();
});
it('starts a real combat from the encounter id (not the monster key) and navigates to /combat/:combatId', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
combatStore.startCombat.mockImplementation(async () => {
combatStore.combat.set(startedCombat);
});
const element = fixture.nativeElement as HTMLElement;
const attackButtons = Array.from(element.querySelectorAll('button')).filter(
(button) => button.textContent?.trim() === 'Angreifen',
);
expect(attackButtons.length).toBe(3);
attackButtons[1].click();
await Promise.resolve();
await Promise.resolve();
expect(combatStore.startCombat).toHaveBeenCalledWith('encounter-2');
expect(combatStore.startCombat).not.toHaveBeenCalledWith('road-bandit');
expect(router.navigate).toHaveBeenCalledWith(['/combat', 'combat-2']);
});
it('does not navigate when starting the combat fails', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
const element = fixture.nativeElement as HTMLElement;
const attackButtons = Array.from(element.querySelectorAll('button')).filter(
(button) => button.textContent?.trim() === 'Angreifen',
);
attackButtons[0].click();
await Promise.resolve();
await Promise.resolve();
expect(combatStore.startCombat).toHaveBeenCalledWith('encounter-1');
expect(router.navigate).not.toHaveBeenCalledWith(['/combat', expect.anything()]);
});
it('rejoins the running combat when the attack is rejected with COMBAT_ALREADY_ACTIVE', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
combatStore.startCombat.mockImplementation(async () => {
combatStore.errorCode.set('COMBAT_ALREADY_ACTIVE');
combatStore.error.set('Du befindest dich bereits in einem Kampf.');
});
combatStore.loadActiveCombat.mockResolvedValue({ ...startedCombat, id: 'combat-running' });
const element = fixture.nativeElement as HTMLElement;
const attackButtons = Array.from(element.querySelectorAll('button')).filter(
(button) => button.textContent?.trim() === 'Angreifen',
);
attackButtons[0].click();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
expect(combatStore.loadActiveCombat).toHaveBeenCalledOnce();
expect(router.navigate).toHaveBeenCalledWith(['/combat', 'combat-running']);
});
it('does not look for a running combat when the attack fails for another reason', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
combatStore.startCombat.mockImplementation(async () => {
combatStore.errorCode.set('HUNT_ENCOUNTER_ALREADY_CONSUMED');
combatStore.error.set('Diese Begegnung wurde bereits genutzt.');
});
const element = fixture.nativeElement as HTMLElement;
const attackButtons = Array.from(element.querySelectorAll('button')).filter(
(button) => button.textContent?.trim() === 'Angreifen',
);
attackButtons[0].click();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
expect(combatStore.loadActiveCombat).not.toHaveBeenCalled();
expect(router.navigate).not.toHaveBeenCalledWith(['/combat', expect.anything()]);
});
it('shows a combat-start error and dismisses it', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
combatStore.error.set('Du befindest dich bereits in einem Kampf.');
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const alerts = Array.from(element.querySelectorAll('[role="alert"]'));
expect(alerts.some((alert) => alert.textContent?.includes('Du befindest dich bereits in einem Kampf.'))).toBe(
true,
);
element.querySelector<HTMLButtonElement>('[data-hunt-combat-dismiss]')?.click();
expect(combatStore.clearError).toHaveBeenCalledOnce();
});
it('does not trigger a hunt automatically on page entry', async () => {
await setup(burnedRoad);
expect(huntingStore.startHunt).not.toHaveBeenCalled();
});
it('loads the world state on init when no location has been loaded yet (direct navigation/hard refresh)', async () => {
await setup(null);
expect(worldStore.load).toHaveBeenCalledOnce();
});
it('does not call load again when a location is already present', async () => {
await setup(burnedRoad);
expect(worldStore.load).not.toHaveBeenCalled();
});
it('shows a loading state and disables the triggering action', async () => {
const fixture = await setup(burnedRoad);
huntingStore.loading.set(true);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('Du suchst nach Spuren...');
expect(element.querySelector<HTMLButtonElement>('[data-hunt-start]')?.disabled).toBe(true);
});
it('displays a hunting error and retries via startHunt when there is no current hunt', async () => {
const fixture = await setup(burnedRoad);
huntingStore.error.set('An diesem Ort gibt es keine Jagdgebiete.');
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[role="alert"]')?.textContent).toContain(
'An diesem Ort gibt es keine Jagdgebiete.',
);
element.querySelector<HTMLButtonElement>('[data-hunt-retry]')?.click();
expect(huntingStore.startHunt).toHaveBeenCalledOnce();
});
});