import { HttpErrorResponse } from '@angular/common/http'; import { TestBed } from '@angular/core/testing'; import { from, of, throwError } from 'rxjs'; import { vi } from 'vitest'; import type { Combat } from '../../core/api/game-api.models'; import { GameApiService } from '../../core/api/game-api.service'; import { CombatStore } from './combat.store'; const startedCombat: Combat = { id: 'combat-1', status: 'ACTIVE', round: 1, player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100 }, monster: { key: 'ash-rat', name: 'Aschenratte', level: 1, maxHp: 45, currentHp: 45, artworkPath: '/images/monsters/ash-rat.png', }, events: [], rewards: null, }; const afterAttack: Combat = { ...startedCombat, round: 2, player: { ...startedCombat.player, currentHp: 95 }, monster: { ...startedCombat.monster, currentHp: 31 }, 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 }, ], }; describe('CombatStore', () => { let api: { startCombat: ReturnType; getCombat: ReturnType; getActiveCombat: ReturnType; performCombatAction: ReturnType; }; let store: CombatStore; beforeEach(() => { api = { startCombat: vi.fn(() => of(startedCombat)), getCombat: vi.fn(() => of(startedCombat)), getActiveCombat: vi.fn(() => of(startedCombat)), performCombatAction: vi.fn(() => of(afterAttack)), }; TestBed.configureTestingModule({ providers: [CombatStore, { provide: GameApiService, useValue: api }], }); store = TestBed.inject(CombatStore); }); it('starts a combat and stores it', async () => { await store.startCombat('encounter-1'); expect(api.startCombat).toHaveBeenCalledWith('encounter-1'); expect(store.combat()).toEqual(startedCombat); expect(store.error()).toBeNull(); }); it('clears any previous combat and reports the mapped error when starting fails', async () => { api.startCombat.mockReturnValue( throwError( () => new HttpErrorResponse({ status: 409, error: { statusCode: 409, code: 'COMBAT_ALREADY_ACTIVE', message: 'Active.' }, }), ), ); await store.startCombat('encounter-1'); expect(store.combat()).toBeNull(); expect(store.error()).toBe('Du befindest dich bereits in einem Kampf.'); expect(store.errorCode()).toBe('COMBAT_ALREADY_ACTIVE'); }); it('loads the running combat and clears the error that sent us looking for it', async () => { api.startCombat.mockReturnValue( throwError( () => new HttpErrorResponse({ status: 409, error: { statusCode: 409, code: 'COMBAT_ALREADY_ACTIVE', message: 'Active.' }, }), ), ); await store.startCombat('encounter-1'); const active = await store.loadActiveCombat(); expect(api.getActiveCombat).toHaveBeenCalledOnce(); expect(active).toEqual(startedCombat); expect(store.combat()).toEqual(startedCombat); expect(store.error()).toBeNull(); expect(store.errorCode()).toBeNull(); }); it('resolves null and keeps the combat empty when no fight is running', async () => { api.getActiveCombat.mockReturnValue(of(null)); const active = await store.loadActiveCombat(); expect(active).toBeNull(); expect(store.combat()).toBeNull(); }); it('loads a combat by id', async () => { await store.loadCombat('combat-1'); expect(api.getCombat).toHaveBeenCalledWith('combat-1'); expect(store.combat()).toEqual(startedCombat); }); it('reports the mapped error when loading an unknown combat', async () => { api.getCombat.mockReturnValue( throwError( () => new HttpErrorResponse({ status: 404, error: { statusCode: 404, code: 'COMBAT_NOT_FOUND', message: 'Not found.' }, }), ), ); await store.loadCombat('unknown'); expect(store.error()).toBe('Dieser Kampf wurde nicht gefunden.'); }); it('sends only the ATTACK action and replaces combat with the server response', async () => { await store.startCombat('encounter-1'); await store.attack(); expect(api.performCombatAction).toHaveBeenCalledWith('combat-1', 'ATTACK'); expect(store.combat()).toEqual(afterAttack); }); it('does nothing when attacking without a loaded combat', async () => { await store.attack(); expect(api.performCombatAction).not.toHaveBeenCalled(); }); it('ignores a second attack while the first is still pending', async () => { await store.startCombat('encounter-1'); let resolveAttack!: (value: Combat) => void; api.performCombatAction.mockReturnValue( from( new Promise((resolve) => { resolveAttack = resolve; }), ), ); const first = store.attack(); expect(store.actionPending()).toBe(true); const second = store.attack(); resolveAttack(afterAttack); await Promise.all([first, second]); expect(api.performCombatAction).toHaveBeenCalledOnce(); }); it('clears actionPending after a failed attack and keeps the previous combat state', async () => { await store.startCombat('encounter-1'); api.performCombatAction.mockReturnValue(throwError(() => new Error('Netzwerkfehler'))); await store.attack(); expect(store.actionPending()).toBe(false); expect(store.combat()).toEqual(startedCombat); expect(store.error()).toBe('Netzwerkfehler'); }); it('clears the error message', async () => { api.startCombat.mockReturnValue(throwError(() => new Error('x'))); await store.startCombat('encounter-1'); expect(store.error()).not.toBeNull(); store.clearError(); expect(store.error()).toBeNull(); }); });