// 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 { burnedRoadFixture, southGateFixture, } from '../../world/current-location.fixture'; import { WorldStore } from '../../world/world.store'; import { HuntingStore } from '../hunting.store'; import { HuntPageComponent } from './hunt-page.component'; const southGate = southGateFixture({ connections: [] }); const burnedRoad = burnedRoadFixture({ connections: [] }); const threeEncounterHunt: HuntResult = { id: 'hunt-id', location: { id: 'burned-road-id', key: 'burned-road', name: 'Burned Road' }, encounters: [ { id: 'encounter-1', monster: { key: 'ash-rat', name: 'Ash Rat', level: 1, artworkPath: '/images/enemies/AshRat.png' }, dangerRating: 'WEAK', status: 'AVAILABLE', }, { id: 'encounter-2', monster: { key: 'road-bandit', name: 'Road Bandit', level: 3, artworkPath: '/images/enemies/RoadBandit.png', }, dangerRating: 'MATCH', status: 'AVAILABLE', }, { id: 'encounter-3', monster: { key: 'ash-rat', name: 'Ash Rat', level: 1, artworkPath: '/images/enemies/AshRat.png' }, dangerRating: 'WEAK', status: 'AVAILABLE', }, ], }; const startedCombat: Combat = { id: 'combat-2', status: 'ACTIVE', round: 1, player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100, potionsRemaining: 2, potionsMax: 2 }, monster: { key: 'road-bandit', name: 'Road Bandit', level: 3, maxHp: 75, currentHp: 75, artworkPath: '/images/enemies/RoadBandit.png', pendingIntent: null, }, 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; loadActiveHunt: 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()), loadActiveHunt: 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 South Gate, with no Begin Hunt button, and a way back to the location', async () => { const fixture = await setup(southGate); const element = fixture.nativeElement as HTMLElement; expect(element.textContent).toContain('No Hunt Available'); expect(element.textContent).toContain('No hunting ground is available here right now.'); expect( Array.from(element.querySelectorAll('button')).some( (button) => button.textContent?.trim() === 'Begin Hunt', ), ).toBe(false); const backButton = element.querySelector('[data-hunt-to-location]'); expect(backButton?.textContent?.trim()).toBe('Back to Location'); backButton?.click(); expect(router.navigate).toHaveBeenCalledWith(['/location']); }); it('calls startHunt when Begin Hunt 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(/Ash Rat[\s\S]*Road Bandit[\s\S]*Ash Rat/); expect( element.querySelectorAll( '.encounter-card__artwork[src="/images/combat/sprites/ash-rat-760.png"]', ).length, ).toBe(2); expect( element.querySelectorAll( '.encounter-card__artwork[src="/images/combat/sprites/road-bandit-620.png"]', ).length, ).toBe(1); expect(element.textContent).toContain('Level 1'); expect(element.textContent).toContain('Level 3'); }); it('calls refreshHunt when Search Again 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() === 'Attack', ); 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() === 'Attack', ); 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("You're already in a combat."); }); 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() === 'Attack', ); 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('This encounter has already been used.'); }); const element = fixture.nativeElement as HTMLElement; const attackButtons = Array.from(element.querySelectorAll('button')).filter( (button) => button.textContent?.trim() === 'Attack', ); 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("You're already in a combat."); fixture.detectChanges(); const element = fixture.nativeElement as HTMLElement; const alerts = Array.from(element.querySelectorAll('[role="alert"]')); expect(alerts.some((alert) => alert.textContent?.includes("You're already in a combat."))).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('adopts the resumable hunt on page entry so cleared encounters stay marked', async () => { await setup(burnedRoad); expect(huntingStore.loadActiveHunt).toHaveBeenCalledOnce(); expect(huntingStore.startHunt).not.toHaveBeenCalled(); }); it('marks a defeated encounter and takes away its attack action', async () => { const clearedHunt: HuntResult = { ...threeEncounterHunt, encounters: [ { ...threeEncounterHunt.encounters[0], status: 'DEFEATED' }, threeEncounterHunt.encounters[1], threeEncounterHunt.encounters[2], ], }; const fixture = await setup(burnedRoad, clearedHunt); const element = fixture.nativeElement as HTMLElement; const cards = element.querySelectorAll('app-encounter-card'); expect(cards[0].querySelector('.encounter-card__defeated-mark')).not.toBeNull(); expect(cards[1].querySelector('.encounter-card__defeated-mark')).toBeNull(); const attackButtons = Array.from( element.querySelectorAll('.encounter-card__attack'), ); expect(attackButtons[0].disabled).toBe(true); expect(attackButtons[1].disabled).toBe(false); }); it('keeps an in-progress encounter unmarked but locked', async () => { const fightingHunt: HuntResult = { ...threeEncounterHunt, encounters: [ { ...threeEncounterHunt.encounters[0], status: 'IN_PROGRESS' }, threeEncounterHunt.encounters[1], threeEncounterHunt.encounters[2], ], }; const fixture = await setup(burnedRoad, fightingHunt); const element = fixture.nativeElement as HTMLElement; const cards = element.querySelectorAll('app-encounter-card'); expect(cards[0].querySelector('.encounter-card__defeated-mark')).toBeNull(); const attackButtons = Array.from( element.querySelectorAll('.encounter-card__attack'), ); expect(attackButtons[0].disabled).toBe(true); }); it('does not start a combat from a defeated encounter card', async () => { const clearedHunt: HuntResult = { ...threeEncounterHunt, encounters: [ { ...threeEncounterHunt.encounters[0], status: 'DEFEATED' }, threeEncounterHunt.encounters[1], threeEncounterHunt.encounters[2], ], }; const fixture = await setup(burnedRoad, clearedHunt); const element = fixture.nativeElement as HTMLElement; element.querySelectorAll('.encounter-card__attack')[0].click(); await Promise.resolve(); expect(combatStore.startCombat).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('You search for tracks...'); 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("There's no hunting ground at this location."); fixture.detectChanges(); const element = fixture.nativeElement as HTMLElement; expect(element.querySelector('[role="alert"]')?.textContent).toContain( "There's no hunting ground at this location.", ); element.querySelector('[data-hunt-retry]')?.click(); expect(huntingStore.startHunt).toHaveBeenCalledOnce(); }); });