Repo-wide grep sweep (apps/web/src, apps/api/src) for leftover German content strings missed by Tasks 1-9, mostly in spec fixtures/assertions that mirror already-translated seed content (monster/item names, POI titles and action labels, location names/descriptions) plus a few real source-file gaps: - inventory-detail-panel.component.ts: STAT_LABELS (Waffenschaden, Angriff, Leben, Rüstung) were never translated by Task 6; now match the identical English labels already used in inventory-page.component.html. - app-shell.component.html: aria-label="Spielinhalt" -> "Game content" (this file was outside every prior task's file list). - location-interaction-panel.component.spec.ts: dead NPC-quote fixture translated to match the real wounded-scout POI text. Code comments referencing German source-spec section titles or not-yet-seeded faction names, and inline calculation-documentation comments, are left as-is per the source spec's scope (dev-facing comments may stay German). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ACkMEDYiwtcfchqKkiUJNX
395 lines
14 KiB
TypeScript
395 lines
14 KiB
TypeScript
// 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<typeof signal<CurrentLocationResponse | null>>;
|
|
load: ReturnType<typeof vi.fn>;
|
|
};
|
|
let huntingStore: {
|
|
currentHunt: ReturnType<typeof signal<HuntResult | null>>;
|
|
loading: ReturnType<typeof signal<boolean>>;
|
|
error: ReturnType<typeof signal<string | null>>;
|
|
encounters: () => HuntResult['encounters'];
|
|
startHunt: ReturnType<typeof vi.fn>;
|
|
refreshHunt: ReturnType<typeof vi.fn>;
|
|
loadActiveHunt: ReturnType<typeof vi.fn>;
|
|
selectEncounter: ReturnType<typeof vi.fn>;
|
|
};
|
|
let combatStore: {
|
|
combat: ReturnType<typeof signal<Combat | null>>;
|
|
error: ReturnType<typeof signal<string | null>>;
|
|
errorCode: ReturnType<typeof signal<string | null>>;
|
|
startCombat: ReturnType<typeof vi.fn>;
|
|
loadActiveCombat: ReturnType<typeof vi.fn>;
|
|
clearError: ReturnType<typeof vi.fn>;
|
|
};
|
|
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<string | null>(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<Combat | null>(null),
|
|
error: signal<string | null>(null),
|
|
errorCode: signal<string | null>(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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('.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<HTMLButtonElement>('.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<HTMLButtonElement>('.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<HTMLButtonElement>('[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<HTMLButtonElement>('[data-hunt-retry]')?.click();
|
|
|
|
expect(huntingStore.startHunt).toHaveBeenCalledOnce();
|
|
});
|
|
});
|