The server resolves a whole round in one call, so both blows used to land at the same instant. The page now keeps its own view of the combat and plays the round back: the swing animates, the monster's HP and log line land, then after a beat the monster strikes and the player recoils. Both animations are six-frame sprite sheets driven by steps(6), which is why the phase durations mirror the stylesheet. The status row reflows on the stage's own width via a container query — the side rails can squeeze it narrow while the viewport is still wide, which previously overlapped the round marker with the player's name. The component-style budget moves to 12kB to fit this screen's stylesheet.
242 lines
9.1 KiB
TypeScript
242 lines
9.1 KiB
TypeScript
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 },
|
|
],
|
|
};
|
|
|
|
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<typeof signal<Combat | null>>;
|
|
loading: ReturnType<typeof signal<boolean>>;
|
|
actionPending: ReturnType<typeof signal<boolean>>;
|
|
error: ReturnType<typeof signal<string | null>>;
|
|
loadCombat: ReturnType<typeof vi.fn>;
|
|
attack: ReturnType<typeof vi.fn>;
|
|
};
|
|
let router: Router;
|
|
|
|
async function setup(combat: Combat | null) {
|
|
combatStore = {
|
|
combat: signal(combat),
|
|
loading: signal(false),
|
|
actionPending: signal(false),
|
|
error: signal<string | null>(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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[data-combat-retry]')?.click();
|
|
|
|
expect(combatStore.loadCombat).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|