// 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: [], rewards: null, }; describe('HuntPageComponent', () => { let worldStore: { currentLocation: ReturnType>; load: ReturnType; }; let huntingStore: { currentHunt: ReturnType>; loading: ReturnType>; error: ReturnType>; encounters: () => HuntResult['encounters']; startHunt: ReturnType; refreshHunt: ReturnType; selectEncounter: ReturnType; }; let combatStore: { combat: ReturnType>; error: ReturnType>; errorCode: ReturnType>; startCombat: ReturnType; loadActiveCombat: ReturnType; clearError: ReturnType; }; 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(null), encounters: () => currentHunt()?.encounters ?? [], startHunt: vi.fn(() => Promise.resolve()), refreshHunt: vi.fn(() => Promise.resolve()), selectEncounter: vi.fn(), }; combatStore = { combat: signal(null), error: signal(null), errorCode: signal(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('[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('[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('[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('[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('[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('[data-hunt-retry]')?.click(); expect(huntingStore.startHunt).toHaveBeenCalledOnce(); }); });