import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { ActivatedRoute, convertToParamMap, Router, provideRouter } from '@angular/router'; import { vi } from 'vitest'; import type { Combat } from '../../../core/api/game-api.models'; import { CombatStore } from '../combat.store'; import { CombatPageComponent } from './combat-page.component'; const activeCombat: Combat = { id: 'combat-1', status: 'ACTIVE', round: 2, player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 95 }, monster: { key: 'ash-rat', name: 'Aschenratte', level: 1, maxHp: 45, currentHp: 31, artworkPath: '/images/monsters/ash-rat.png', }, events: [ { round: 1, sequence: 1, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 14 }, { round: 1, sequence: 2, type: 'DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5 }, ], rewards: null, }; const monsterHitLine = 'Aschenratte trifft Aric Duskwalker für 5 Schaden.'; function countOccurrences(haystack: string | null, needle: string): number { return haystack ? haystack.split(needle).length - 1 : 0; } describe('CombatPageComponent', () => { let combatStore: { combat: ReturnType>; loading: ReturnType>; actionPending: ReturnType>; error: ReturnType>; loadCombat: ReturnType; attack: ReturnType; }; let router: Router; async function setup(combat: Combat | null) { combatStore = { combat: signal(combat), loading: signal(false), actionPending: signal(false), error: signal(null), loadCombat: vi.fn(() => Promise.resolve()), attack: vi.fn(() => Promise.resolve()), }; await TestBed.configureTestingModule({ imports: [CombatPageComponent], providers: [ provideRouter([]), { provide: CombatStore, useValue: combatStore }, { provide: ActivatedRoute, useValue: { snapshot: { paramMap: convertToParamMap({ combatId: 'combat-1' }) } }, }, ], }).compileComponents(); router = TestBed.inject(Router); vi.spyOn(router, 'navigate').mockResolvedValue(true); const fixture = TestBed.createComponent(CombatPageComponent); fixture.detectChanges(); // The route load resolves on the microtask queue before the combat renders. await fixture.whenStable(); fixture.detectChanges(); return fixture; } afterEach(() => { vi.useRealTimers(); }); it('loads the combat from the route param on init', async () => { await setup(activeCombat); expect(combatStore.loadCombat).toHaveBeenCalledWith('combat-1'); }); it('shows the player, monster, HP bars, round, and the Angriff action', async () => { const fixture = await setup(activeCombat); const element = fixture.nativeElement as HTMLElement; expect(element.textContent).toContain('Aric Duskwalker'); expect(element.textContent).toContain('95 / 100'); expect(element.textContent).toContain('Aschenratte'); expect(element.textContent).toContain('31 / 45'); expect(element.querySelector('[data-combat-round]')?.textContent).toContain('Runde 2'); expect(element.querySelector('[data-combat-attack]')).toBeTruthy(); }); it('renders the structured events as readable German combat-log entries', async () => { const fixture = await setup(activeCombat); const element = fixture.nativeElement as HTMLElement; expect(element.textContent).toContain('Aric Duskwalker trifft Aschenratte für 14 Schaden.'); expect(element.textContent).toContain('Aschenratte trifft Aric Duskwalker für 5 Schaden.'); }); it('calls combatStore.attack() when Angriff is clicked', async () => { const fixture = await setup(activeCombat); const element = fixture.nativeElement as HTMLElement; element.querySelector('[data-combat-attack]')?.click(); expect(combatStore.attack).toHaveBeenCalledOnce(); }); it('plays the swing, reveals the monster damage, then the recoil a beat later', async () => { const fixture = await setup(activeCombat); const resolvedRound: Combat = { ...activeCombat, round: 3, player: { ...activeCombat.player, currentHp: 90 }, monster: { ...activeCombat.monster, currentHp: 17 }, events: [ ...activeCombat.events, { round: 2, sequence: 3, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 14 }, { round: 2, sequence: 4, type: 'DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5 }, ], }; combatStore.attack.mockImplementation(async () => { combatStore.combat.set(resolvedRound); }); vi.useFakeTimers(); const element = fixture.nativeElement as HTMLElement; const sprite = element.querySelector('.sprite--player'); element.querySelector('[data-combat-attack]')?.click(); fixture.detectChanges(); expect(sprite?.classList.contains('sprite--attacking')).toBe(true); expect(element.textContent).toContain('31 / 45'); expect(element.textContent).toContain('95 / 100'); // Swing lands: the monster loses HP, the player's own loss is held back. await vi.advanceTimersByTimeAsync(540); fixture.detectChanges(); expect(sprite?.classList.contains('sprite--attacking')).toBe(false); expect(element.textContent).toContain('17 / 45'); expect(element.textContent).toContain('95 / 100'); // Only round 1's identical line is logged so far, not round 2's. expect(countOccurrences(element.textContent, monsterHitLine)).toBe(1); // The monster strikes back after the beat. await vi.advanceTimersByTimeAsync(260); fixture.detectChanges(); expect(sprite?.classList.contains('sprite--hit')).toBe(true); expect(element.textContent).toContain('90 / 100'); expect(countOccurrences(element.textContent, monsterHitLine)).toBe(2); await vi.advanceTimersByTimeAsync(540); fixture.detectChanges(); expect(sprite?.classList.contains('sprite--hit')).toBe(false); }); it('skips the recoil when the round ends without the monster striking back', async () => { const fixture = await setup(activeCombat); const won: Combat = { ...activeCombat, status: 'WON', monster: { ...activeCombat.monster, currentHp: 0 }, events: [ ...activeCombat.events, { round: 2, sequence: 3, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 31 }, { round: 2, sequence: 4, type: 'COMBAT_WON', source: 'PLAYER', target: 'MONSTER' }, ], }; combatStore.attack.mockImplementation(async () => { combatStore.combat.set(won); }); vi.useFakeTimers(); const element = fixture.nativeElement as HTMLElement; element.querySelector('[data-combat-attack]')?.click(); await vi.advanceTimersByTimeAsync(540); fixture.detectChanges(); expect(element.querySelector('.sprite--player')?.classList.contains('sprite--hit')).toBe(false); expect(element.querySelector('[data-combat-result="WON"]')).toBeTruthy(); expect(element.textContent).toContain('0 / 45'); }); it('disables Angriff while an action is pending', async () => { const fixture = await setup(activeCombat); combatStore.actionPending.set(true); fixture.detectChanges(); const element = fixture.nativeElement as HTMLElement; expect(element.querySelector('[data-combat-attack]')?.disabled).toBe(true); }); it('shows the victory state and hides Angriff when the combat is WON', async () => { const fixture = await setup({ ...activeCombat, status: 'WON' }); const element = fixture.nativeElement as HTMLElement; expect(element.querySelector('[data-combat-result="WON"]')).toBeTruthy(); expect(element.textContent).toContain('Sieg'); expect(element.querySelector('[data-combat-attack]')).toBeNull(); }); it('shows the defeat state and hides Angriff when the combat is LOST', async () => { const fixture = await setup({ ...activeCombat, status: 'LOST' }); const element = fixture.nativeElement as HTMLElement; expect(element.querySelector('[data-combat-result="LOST"]')).toBeTruthy(); expect(element.textContent).toContain('Niederlage'); expect(element.querySelector('[data-combat-attack]')).toBeNull(); }); it('navigates to /hunt from the victory screen', async () => { const fixture = await setup({ ...activeCombat, status: 'WON' }); const element = fixture.nativeElement as HTMLElement; element.querySelector('[data-combat-to-hunt]')?.click(); expect(router.navigate).toHaveBeenCalledWith(['/hunt']); }); it('shows an error and retries loading the combat', async () => { const fixture = await setup(null); combatStore.error.set('Dieser Kampf wurde nicht gefunden.'); fixture.detectChanges(); const element = fixture.nativeElement as HTMLElement; expect(element.querySelector('[role="alert"]')?.textContent).toContain( 'Dieser Kampf wurde nicht gefunden.', ); element.querySelector('[data-combat-retry]')?.click(); expect(combatStore.loadCombat).toHaveBeenCalledTimes(2); }); });