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, CombatRewardItem, LootCapacity, } from '../../../core/api/game-api.models'; import { CombatStore } from '../combat.store'; import { WorldStore } from '../../world/world.store'; import { CombatPageComponent } from './combat-page.component'; // Roomy enough that no test trips the "full" styling unless it asks to. const FULL_CAPACITIES: LootCapacity[] = [ { category: 'HIDE', current: 1, capacity: 5, bag: null }, { category: 'RAIDER_TROPHY', current: 0, capacity: 5, bag: null }, ]; const activeCombat: Combat = { id: 'combat-1', status: 'ACTIVE', round: 2, player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 95, potionsRemaining: 2, potionsMax: 2, statusEffects: [], }, monster: { key: 'ash-rat', name: 'Ash Rat', level: 1, maxHp: 45, currentHp: 31, artworkPath: '/images/monsters/ash-rat.png', pendingIntent: null, guardRemainingRounds: null, enraged: false, }, 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 = 'Ash Rat hits Aric Duskwalker for 5 damage.'; 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; performAction: ReturnType; }; let worldStore: { refreshCharacter: 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()), performAction: vi.fn(() => Promise.resolve()), }; worldStore = { refreshCharacter: vi.fn(() => Promise.resolve()) }; await TestBed.configureTestingModule({ imports: [CombatPageComponent], providers: [ provideRouter([]), { provide: CombatStore, useValue: combatStore }, { provide: WorldStore, useValue: worldStore }, { 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 Attack 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('Ash Rat'); expect(element.textContent).toContain('31 / 45'); expect(element.querySelector('[data-combat-round]')?.textContent).toContain('Round 2'); expect(element.querySelector('[data-combat-attack]')).toBeTruthy(); }); it('shows all five combat actions with their English labels', async () => { const fixture = await setup(activeCombat); const element = fixture.nativeElement as HTMLElement; expect(element.querySelector('[data-combat-attack]')?.textContent).toContain('Attack'); expect(element.querySelector('[data-combat-heavy-strike]')?.textContent).toContain('Heavy Strike'); expect(element.querySelector('[data-combat-shield-bash]')?.textContent).toContain('Shield Bash'); expect(element.querySelector('[data-combat-defend]')?.textContent).toContain('Defend'); expect(element.querySelector('[data-combat-potion]')?.textContent).toContain('Potion 2/2'); }); it('sends HEAVY_STRIKE when Heavy Strike is clicked', async () => { const fixture = await setup(activeCombat); const element = fixture.nativeElement as HTMLElement; element.querySelector('[data-combat-heavy-strike]')?.click(); expect(combatStore.performAction).toHaveBeenCalledWith('HEAVY_STRIKE'); }); it('sends SHIELD_BASH when Shield Bash is clicked', async () => { const fixture = await setup(activeCombat); const element = fixture.nativeElement as HTMLElement; element.querySelector('[data-combat-shield-bash]')?.click(); expect(combatStore.performAction).toHaveBeenCalledWith('SHIELD_BASH'); }); it('sends DEFEND when Defend is clicked', async () => { const fixture = await setup(activeCombat); const element = fixture.nativeElement as HTMLElement; element.querySelector('[data-combat-defend]')?.click(); expect(combatStore.performAction).toHaveBeenCalledWith('DEFEND'); }); it('sends POTION when Potion is clicked', async () => { const fixture = await setup(activeCombat); const element = fixture.nativeElement as HTMLElement; element.querySelector('[data-combat-potion]')?.click(); expect(combatStore.performAction).toHaveBeenCalledWith('POTION'); }); it('disables the potion button once both potions are used', async () => { const fixture = await setup({ ...activeCombat, player: { ...activeCombat.player, potionsRemaining: 0 }, }); const element = fixture.nativeElement as HTMLElement; expect(element.querySelector('[data-combat-potion]')?.disabled).toBe(true); expect(element.querySelector('[data-combat-attack]')?.disabled).toBe(false); }); it('shows a prominent telegraph banner when the monster has a pending Heavy Attack', async () => { const fixture = await setup({ ...activeCombat, monster: { ...activeCombat.monster, pendingIntent: 'HEAVY_ATTACK' }, }); const element = fixture.nativeElement as HTMLElement; expect(element.querySelector('[data-combat-telegraph]')?.textContent).toContain( 'Ash Rat is winding up a Heavy Strike.', ); }); it('shows no telegraph banner when nothing is pending', async () => { const fixture = await setup(activeCombat); const element = fixture.nativeElement as HTMLElement; expect(element.querySelector('[data-combat-telegraph]')).toBeNull(); }); const veteran: Combat = { ...activeCombat, monster: { ...activeCombat.monster, name: 'Raider Veteran' }, }; it('announces a raised guard with the rounds it still covers', async () => { const fixture = await setup({ ...veteran, monster: { ...veteran.monster, guardRemainingRounds: 2 }, }); const banner: HTMLElement | null = fixture.nativeElement.querySelector( '[data-combat-guard]', ); expect(banner?.textContent).toContain('Raider Veteran'); expect(banner?.textContent).toContain('2'); }); it('says nothing about a guard when the monster is open', async () => { const fixture = await setup(activeCombat); expect( fixture.nativeElement.querySelector('[data-combat-guard]'), ).toBeNull(); }); it('marks an enraged monster', async () => { const fixture = await setup({ ...activeCombat, monster: { ...activeCombat.monster, enraged: true }, }); expect( fixture.nativeElement.querySelector('[data-combat-enraged]'), ).not.toBeNull(); }); it('reads the new events back in the log', async () => { const fixture = await setup({ ...veteran, events: [ { round: 1, sequence: 1, type: 'GUARD_RAISED', source: 'MONSTER', target: 'MONSTER', amount: 2 }, { round: 1, sequence: 2, type: 'ENRAGED', source: 'MONSTER', target: 'MONSTER' }, { round: 2, sequence: 1, type: 'GUARD_ENDED', source: 'MONSTER', target: 'MONSTER' }, ], }); const log = ( fixture.nativeElement as HTMLElement ).querySelector('.combat__log')?.textContent; expect(log).toContain('Raider Veteran raises its guard.'); expect(log).toContain('Raider Veteran turns savage.'); expect(log).toContain("Raider Veteran's guard drops."); }); it('renders HEAL, DEFEND, TELEGRAPH, and INTERRUPT log lines', async () => { const fixture = await setup({ ...activeCombat, events: [ { round: 1, sequence: 1, type: 'HEAL', source: 'PLAYER', target: 'PLAYER', amount: 35 }, { round: 1, sequence: 2, type: 'DEFEND', source: 'PLAYER', target: 'PLAYER' }, { round: 1, sequence: 3, type: 'TELEGRAPH', source: 'MONSTER', target: 'PLAYER' }, { round: 1, sequence: 4, type: 'INTERRUPT', source: 'PLAYER', target: 'MONSTER' }, ], }); const element = fixture.nativeElement as HTMLElement; expect(element.textContent).toContain('Aric Duskwalker drinks a potion and heals 35 HP.'); expect(element.textContent).toContain('Aric Duskwalker braces to defend.'); expect(element.textContent).toContain('Ash Rat is winding up a Heavy Strike.'); expect(element.textContent).toContain("Aric Duskwalker interrupts Ash Rat's attack."); }); it('renders the structured events as readable English combat-log entries', async () => { const fixture = await setup(activeCombat); const element = fixture.nativeElement as HTMLElement; expect(element.textContent).toContain('Aric Duskwalker hits Ash Rat for 14 damage.'); expect(element.textContent).toContain('Ash Rat hits Aric Duskwalker for 5 damage.'); }); it('calls combatStore.performAction("ATTACK") when Attack is clicked', async () => { const fixture = await setup(activeCombat); const element = fixture.nativeElement as HTMLElement; element.querySelector('[data-combat-attack]')?.click(); expect(combatStore.performAction).toHaveBeenCalledWith('ATTACK'); }); 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.performAction.mockImplementation(async () => { combatStore.combat.set(resolvedRound); }); vi.useFakeTimers(); const element = fixture.nativeElement as HTMLElement; const sprite = element.querySelector('.sprite--player'); const monster = element.querySelector('.sprite--monster'); const stage = element.querySelector('.combat__stage'); element.querySelector('[data-combat-attack]')?.click(); fixture.detectChanges(); expect(sprite?.classList.contains('sprite--attacking')).toBe(true); expect(monster?.classList.contains('sprite--flinch')).toBe(false); expect(element.textContent).toContain('31 / 45'); expect(element.textContent).toContain('95 / 100'); // Swing lands: the monster loses HP and flinches, its own loss is held back. await vi.advanceTimersByTimeAsync(540); fixture.detectChanges(); expect(sprite?.classList.contains('sprite--attacking')).toBe(false); expect(monster?.classList.contains('sprite--flinch')).toBe(true); expect(monster?.classList.contains('sprite--lunge')).toBe(false); expect(stage?.classList.contains('combat__stage--shaken')).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(1260); fixture.detectChanges(); expect(sprite?.classList.contains('sprite--hit')).toBe(true); expect(monster?.classList.contains('sprite--lunge')).toBe(true); expect(monster?.classList.contains('sprite--flinch')).toBe(false); // The stage jolt is wired up but no longer fires on an ordinary hit. expect(stage?.classList.contains('combat__stage--shaken')).toBe(false); 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); expect(monster?.classList.contains('sprite--lunge')).toBe(false); expect(stage?.classList.contains('combat__stage--shaken')).toBe(false); }); it('reveals the telegraph banner only after the reply beat, and never lunges for it', async () => { const fixture = await setup(activeCombat); const telegraphed: Combat = { ...activeCombat, round: 3, events: [ ...activeCombat.events, { round: 2, sequence: 3, type: 'DEFEND', source: 'PLAYER', target: 'PLAYER' }, { round: 2, sequence: 4, type: 'TELEGRAPH', source: 'MONSTER', target: 'PLAYER' }, ], monster: { ...activeCombat.monster, pendingIntent: 'HEAVY_ATTACK' }, }; combatStore.performAction.mockImplementation(async () => { combatStore.combat.set(telegraphed); }); vi.useFakeTimers(); const element = fixture.nativeElement as HTMLElement; const monster = element.querySelector('.sprite--monster'); element.querySelector('[data-combat-defend]')?.click(); fixture.detectChanges(); await vi.advanceTimersByTimeAsync(540); fixture.detectChanges(); expect(element.querySelector('[data-combat-telegraph]')).toBeNull(); await vi.advanceTimersByTimeAsync(1260); fixture.detectChanges(); expect(element.querySelector('[data-combat-telegraph]')?.textContent).toContain('is winding up a Heavy Strike'); expect(monster?.classList.contains('sprite--lunge')).toBe(false); }); it('shows the INTERRUPT log line immediately and skips the lunge when SHIELD_BASH interrupts', async () => { const fixture = await setup({ ...activeCombat, monster: { ...activeCombat.monster, pendingIntent: 'HEAVY_ATTACK' }, }); const interrupted: Combat = { ...activeCombat, round: 3, monster: { ...activeCombat.monster, currentHp: 21, pendingIntent: null }, events: [ ...activeCombat.events, { round: 2, sequence: 3, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 10 }, { round: 2, sequence: 4, type: 'INTERRUPT', source: 'PLAYER', target: 'MONSTER' }, ], }; combatStore.performAction.mockImplementation(async () => { combatStore.combat.set(interrupted); }); vi.useFakeTimers(); const element = fixture.nativeElement as HTMLElement; const monster = element.querySelector('.sprite--monster'); element.querySelector('[data-combat-shield-bash]')?.click(); await vi.advanceTimersByTimeAsync(540); fixture.detectChanges(); expect(element.textContent).toContain("Aric Duskwalker interrupts Ash Rat's attack."); expect(element.querySelector('[data-combat-telegraph]')).toBeNull(); expect(monster?.classList.contains('sprite--lunge')).toBe(false); }); it('shows the potion heal at the first checkpoint instead of waiting for the riposte reveal', async () => { const fixture = await setup({ ...activeCombat, player: { ...activeCombat.player, currentHp: 70 }, }); const healed: Combat = { ...activeCombat, round: 3, player: { ...activeCombat.player, currentHp: 80, potionsRemaining: 1 }, events: [ ...activeCombat.events, { round: 2, sequence: 3, type: 'HEAL', source: 'PLAYER', target: 'PLAYER', amount: 15 }, { round: 2, sequence: 4, type: 'DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5 }, ], }; combatStore.performAction.mockImplementation(async () => { combatStore.combat.set(healed); }); vi.useFakeTimers(); const element = fixture.nativeElement as HTMLElement; element.querySelector('[data-combat-potion]')?.click(); fixture.detectChanges(); // Before the checkpoint: the pre-heal HP and potion count still show. expect(element.textContent).toContain('70 / 100'); expect(element.querySelector('[data-combat-potion]')?.textContent).toContain('Potion 2/2'); // First checkpoint: the heal already landed from the player's own action, // so the HP bar and potion count update here -- well before the monster's // held-back reply resolves. await vi.advanceTimersByTimeAsync(540); fixture.detectChanges(); expect(element.textContent).toContain('85 / 100'); expect(element.querySelector('[data-combat-potion]')?.textContent).toContain('Potion 1/2'); expect(element.textContent).toContain('Aric Duskwalker drinks a potion and heals 15 HP.'); // The monster's reply is still held back at this point. expect(countOccurrences(element.textContent, monsterHitLine)).toBe(1); await vi.advanceTimersByTimeAsync(1260); fixture.detectChanges(); expect(element.textContent).toContain('80 / 100'); }); 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.performAction.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(); const monster = element.querySelector('.sprite--monster'); expect(element.querySelector('.sprite--player')?.classList.contains('sprite--hit')).toBe(false); // The killing blow still registers on the monster, it just never lunges back. expect(monster?.classList.contains('sprite--flinch')).toBe(true); expect(monster?.classList.contains('sprite--lunge')).toBe(false); expect(element.querySelector('[data-combat-result="WON"]')).toBeTruthy(); expect(element.textContent).toContain('0 / 45'); await vi.advanceTimersByTimeAsync(800); fixture.detectChanges(); expect(monster?.classList.contains('sprite--lunge')).toBe(false); }); it('refreshes the character from the server once a combat is won', async () => { const fixture = await setup(activeCombat); combatStore.performAction.mockImplementation(async () => { combatStore.combat.set({ ...activeCombat, status: 'WON', monster: { ...activeCombat.monster, currentHp: 0 }, rewards: { items: [], capacities: FULL_CAPACITIES }, 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' }, ], }); }); vi.useFakeTimers(); const element = fixture.nativeElement as HTMLElement; element.querySelector('[data-combat-attack]')?.click(); await vi.advanceTimersByTimeAsync(540); fixture.detectChanges(); expect(worldStore.refreshCharacter).toHaveBeenCalledOnce(); }); it('disables Attack 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 Attack 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('Victory'); expect(element.querySelector('[data-combat-attack]')).toBeNull(); }); it('shows the defeat state and hides Attack 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('Defeat'); expect(element.querySelector('[data-combat-attack]')).toBeNull(); }); it('keeps the one-click hunt loop 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('also offers the way back to the location from the victory screen', async () => { const fixture = await setup({ ...activeCombat, status: 'WON' }); const element = fixture.nativeElement as HTMLElement; element.querySelector('[data-combat-to-location]')?.click(); expect(router.navigate).toHaveBeenCalledWith(['/location']); }); it('offers the same two ways out after a defeat', async () => { const fixture = await setup({ ...activeCombat, status: 'LOST' }); const element = fixture.nativeElement as HTMLElement; expect(element.querySelector('[data-combat-to-hunt]')).not.toBeNull(); element.querySelector('[data-combat-to-location]')?.click(); expect(router.navigate).toHaveBeenCalledWith(['/location']); }); it('navigates to /inventory from the victory screen', async () => { const fixture = await setup({ ...activeCombat, status: 'WON' }); const element = fixture.nativeElement as HTMLElement; element.querySelector('[data-combat-to-inventory]')?.click(); expect(router.navigate).toHaveBeenCalledWith(['/inventory']); }); it('shows an error and retries loading the combat', async () => { const fixture = await setup(null); combatStore.error.set('This combat could not be found.'); fixture.detectChanges(); const element = fixture.nativeElement as HTMLElement; expect(element.querySelector('[role="alert"]')?.textContent).toContain( 'This combat could not be found.', ); element.querySelector('[data-combat-retry]')?.click(); expect(combatStore.loadCombat).toHaveBeenCalledTimes(2); }); describe('Bleeding (spec §4)', () => { it('shows an active effect with the rounds it still has to run', async () => { const fixture = await setup({ ...activeCombat, player: { ...activeCombat.player, statusEffects: [{ type: 'BLEED', remainingRounds: 2, damagePerRound: 5 }], }, }); const element = fixture.nativeElement as HTMLElement; const badge = element.querySelector('[data-combat-status="BLEED"]'); expect(badge).toBeTruthy(); expect(badge?.textContent).toContain('Bleeding'); expect(badge?.textContent).toContain('2'); }); it('shows no effect strip while the player is unafflicted', async () => { const fixture = await setup(activeCombat); const element = fixture.nativeElement as HTMLElement; expect(element.querySelector('[data-combat-statuses]')).toBeNull(); }); it('reads the status events in the combat log', async () => { const fixture = await setup({ ...activeCombat, player: { ...activeCombat.player, statusEffects: [{ type: 'BLEED', remainingRounds: 1, damagePerRound: 5 }], }, events: [ { round: 1, sequence: 1, type: 'STATUS_APPLIED', source: 'MONSTER', target: 'PLAYER', amount: 2, statusEffect: 'BLEED', }, { round: 1, sequence: 2, type: 'STATUS_DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5, statusEffect: 'BLEED', }, { round: 1, sequence: 3, type: 'STATUS_EXPIRED', source: 'MONSTER', target: 'PLAYER', statusEffect: 'BLEED', }, ], }); const element = fixture.nativeElement as HTMLElement; const log = element.querySelector('.combat__log-body')?.textContent ?? ''; expect(log).toContain('inflicts Bleeding'); expect(log).toContain('Bleeding costs Aric Duskwalker 5 HP'); expect(log).toContain('Bleeding fades'); }); }); const wonWithRewards: Combat = { ...activeCombat, status: 'WON', monster: { ...activeCombat.monster, currentHp: 0 }, rewards: { items: [], capacities: FULL_CAPACITIES }, }; const ashenPelt: CombatRewardItem = { characterItemId: 'character-item-pelt', item: { key: 'ash-pelt', name: 'Ashen Pelt', type: 'TRADE_GOOD', lootCategory: 'HIDE', rarity: 'COMMON', iconPath: '/images/items/ash-pelt.png', }, quantity: 1, quantityLeftBehind: 0, }; const banditBlade: CombatRewardItem = { characterItemId: 'character-item-blade', item: { key: 'bandit-blade', name: 'Bandit Blade', type: 'EQUIPMENT', lootCategory: null, rarity: 'COMMON', iconPath: '/images/items/bandit-blade.png', }, quantity: 1, quantityLeftBehind: 0, }; const healingPotion: CombatRewardItem = { characterItemId: 'character-item-potion', item: { key: 'small-healing-potion', name: 'Small Healing Potion', type: 'CONSUMABLE', lootCategory: null, rarity: 'COMMON', iconPath: '/images/items/small-healing-potion.png', }, quantity: 1, quantityLeftBehind: 0, }; it('shows the reward panel without any currency row', async () => { const fixture = await setup({ ...wonWithRewards, rewards: { items: [ashenPelt], capacities: FULL_CAPACITIES } }); const element = fixture.nativeElement as HTMLElement; expect(element.querySelector('[data-combat-rewards]')).toBeTruthy(); // Spec §9: no XP or Silver rows. Both blocks are removed from the markup // rather than hidden, so guard against either reappearing. expect(element.querySelector('[data-reward-silver]')).toBeNull(); expect(element.querySelector('[data-reward-experience]')).toBeNull(); }); it('separates trade goods, equipment, and consumables in the loot summary', async () => { const fixture = await setup({ ...wonWithRewards, rewards: { items: [banditBlade, ashenPelt, healingPotion], capacities: FULL_CAPACITIES }, }); const element = fixture.nativeElement as HTMLElement; const groups = [...element.querySelectorAll('[data-reward-group]')]; expect(groups.map((group) => group.getAttribute('data-reward-group'))).toEqual([ 'trade-goods', 'equipment', 'consumables', ]); expect(groups.map((group) => group.textContent?.trim())).toEqual([ 'Trade Goods', 'Equipment', 'Consumables', ]); }); it('omits a category the fight did not drop anything for', async () => { const fixture = await setup({ ...wonWithRewards, rewards: { items: [ashenPelt], capacities: FULL_CAPACITIES } }); const element = fixture.nativeElement as HTMLElement; const groups = [...element.querySelectorAll('[data-reward-group]')]; expect(groups).toHaveLength(1); expect(groups[0].getAttribute('data-reward-group')).toBe('trade-goods'); }); it('files a trophy with the trade goods, because both are merchant fodder', async () => { const insignia: CombatRewardItem = { characterItemId: 'character-item-insignia', item: { key: 'bandit-insignia', name: 'Raider Insignia', type: 'TROPHY', lootCategory: 'RAIDER_TROPHY', rarity: 'COMMON', iconPath: '/images/items/bandit-insignia.png', }, quantity: 1, quantityLeftBehind: 0, }; const fixture = await setup({ ...wonWithRewards, rewards: { items: [insignia], capacities: FULL_CAPACITIES } }); const element = fixture.nativeElement as HTMLElement; expect( element.querySelector('[data-reward-group="trade-goods"]'), ).toBeTruthy(); expect(element.querySelector('[data-item-name]')?.textContent).toContain( 'Raider Insignia', ); }); describe('full loot bags (slice 0.7.5 §9, §10)', () => { const refusedPelt: CombatRewardItem = { ...ashenPelt, characterItemId: null, quantity: 0, quantityLeftBehind: 1, }; const fullHides: LootCapacity[] = [ { category: 'HIDE', current: 5, capacity: 5, bag: null }, { category: 'RAIDER_TROPHY', current: 0, capacity: 5, bag: null }, ]; it('names what the bag refused instead of dropping it silently', async () => { const fixture = await setup({ ...wonWithRewards, rewards: { items: [refusedPelt], capacities: fullHides }, }); const element = fixture.nativeElement as HTMLElement; const refused = element.querySelector('[data-reward-left-behind]'); expect(refused).toBeTruthy(); expect(refused?.textContent).toContain('Ashen Pelt'); expect(refused?.textContent).toContain('1'); }); it('keeps a fully refused drop out of the loot the player actually got', async () => { const fixture = await setup({ ...wonWithRewards, rewards: { items: [refusedPelt], capacities: fullHides }, }); const element = fixture.nativeElement as HTMLElement; // Nothing was banked, so no Trade Goods heading and no item card. expect(element.querySelector('[data-reward-group="trade-goods"]')).toBeNull(); expect(element.querySelector('[data-item-name]')).toBeNull(); }); it('lists a partial grant under both loot and left behind', async () => { const fixture = await setup({ ...wonWithRewards, rewards: { items: [{ ...ashenPelt, quantity: 1, quantityLeftBehind: 1 }], capacities: fullHides, }, }); const element = fixture.nativeElement as HTMLElement; // §10: granted 1, left behind 1 -- the player has to see both halves. expect(element.querySelector('[data-item-name]')?.textContent).toContain( 'Ashen Pelt', ); expect( element.querySelector('[data-reward-refused="ash-pelt"]')?.textContent, ).toContain('1'); }); it('still shows equipment that landed while the hide bag was full', async () => { const fixture = await setup({ ...wonWithRewards, rewards: { items: [refusedPelt, banditBlade], capacities: fullHides }, }); const element = fixture.nativeElement as HTMLElement; // §9: equipment must not be lost to a full trade-good bag. expect(element.querySelector('[data-reward-group="equipment"]')).toBeTruthy(); expect(element.querySelector('[data-item-name]')?.textContent).toContain( 'Bandit Blade', ); expect(element.querySelector('[data-reward-left-behind]')).toBeTruthy(); }); it('says nothing about left-behind loot when everything fit', async () => { const fixture = await setup({ ...wonWithRewards, rewards: { items: [ashenPelt], capacities: FULL_CAPACITIES }, }); const element = fixture.nativeElement as HTMLElement; expect(element.querySelector('[data-reward-left-behind]')).toBeNull(); }); it('shows the carrying state after the fight', async () => { const fixture = await setup({ ...wonWithRewards, rewards: { items: [refusedPelt], capacities: fullHides }, }); const element = fixture.nativeElement as HTMLElement; // §11/§12: the player learns the trip is over on the victory screen, // without a second request. expect(element.querySelector('[data-loot-capacities]')).toBeTruthy(); expect( element.querySelector('[data-loot-capacity="HIDE"] [data-loot-capacity-full]'), ).toBeTruthy(); }); }); it('renders a dropped item with its icon, name, and rarity', async () => { const fixture = await setup({ ...wonWithRewards, monster: { ...activeCombat.monster, key: 'road-bandit', name: 'Road Bandit', currentHp: 0 }, rewards: { items: [banditBlade], capacities: FULL_CAPACITIES }, }); const element = fixture.nativeElement as HTMLElement; expect(element.querySelector('[data-item-icon]')?.getAttribute('src')).toBe( '/images/items/bandit-blade.png', ); expect(element.querySelector('[data-item-name]')?.textContent).toContain('Bandit Blade'); expect(element.querySelector('[data-item-rarity]')?.textContent).toContain('Common'); expect(element.querySelector('[data-reward-empty]')).toBeNull(); }); it('treats a victory without loot as complete, not as a failure', async () => { const fixture = await setup(wonWithRewards); const element = fixture.nativeElement as HTMLElement; expect(element.querySelector('[data-reward-empty]')?.textContent).toContain( 'No notable loot found.', ); expect(element.querySelector('[role="alert"]')).toBeNull(); }); it('renders the persisted rewards of an already-won combat loaded from the server', async () => { // Simulates a browser refresh: the page loads the combat by id and shows // exactly what the server persisted, without rerolling anything. const fixture = await setup({ ...wonWithRewards, rewards: { items: [ashenPelt], capacities: FULL_CAPACITIES }, }); const element = fixture.nativeElement as HTMLElement; expect(combatStore.loadCombat).toHaveBeenCalledWith('combat-1'); expect(element.querySelector('[data-item-name]')?.textContent).toContain('Ashen Pelt'); }); it('still shows a plain victory when the server reports no reward record', async () => { const fixture = await setup({ ...wonWithRewards, rewards: null }); const element = fixture.nativeElement as HTMLElement; expect(element.querySelector('[data-combat-result="WON"]')).toBeTruthy(); expect(element.querySelector('[data-combat-rewards]')).toBeNull(); }); });