import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { vi } from 'vitest'; import type { CharacterResponse, EquipmentResponse, InventoryItem, InventoryResponse } from '../../core/api/game-api.models'; import { WorldStore } from '../world/world.store'; import { InventoryDetailPanelComponent } from './inventory-detail-panel.component'; import { InventoryPageComponent } from './inventory-page.component'; import { InventoryStore } from './inventory.store'; const inventory: InventoryResponse = { items: [ { id: 'item-sword', quantity: 1, equipped: true, item: { key: 'worn-short-sword', name: 'Abgenutztes Kurzschwert', rarity: 'COMMON', equipmentSlot: 'WEAPON', weaponDamage: 8, bonusAttack: 0, bonusHp: 0, bonusArmor: 0, iconPath: '/images/items/worn-short-sword.png', }, }, { id: 'item-blade', quantity: 1, equipped: false, item: { key: 'bandit-blade', name: 'Räuberklinge', rarity: 'COMMON', equipmentSlot: 'WEAPON', weaponDamage: 11, bonusAttack: 1, bonusHp: 0, bonusArmor: 0, iconPath: '/images/items/bandit-blade.png', }, }, ], }; const equipment: EquipmentResponse = { slots: { WEAPON: { characterItemId: 'item-sword', item: { key: 'worn-short-sword', name: 'Abgenutztes Kurzschwert', rarity: 'COMMON', iconPath: '/images/items/worn-short-sword.png' } }, HEAD: null, CHEST: null, HANDS: null, LEGS: null, FEET: null, AMULET: null, }, stats: { maxHp: 100, attack: 6, weaponDamage: 8, armor: 0 }, }; const character: CharacterResponse = { id: 'character-1', name: 'Aric Duskwalker', renown: 0, silver: 0, currentHp: 100, maxHp: 100, attack: 6, currentLocation: { id: 'loc-1', key: 'south-gate', name: 'Südtor' }, }; interface SetupOptions { inventoryData?: InventoryResponse; selectedItemId?: string | null; selectedItem?: InventoryItem | null; character?: CharacterResponse | null; } async function setup(options: SetupOptions = {}) { const inventoryStore = { inventory: signal(options.inventoryData ?? inventory), equipment: signal(equipment), selectedItemId: signal(options.selectedItemId ?? null), loading: signal(false), equipping: signal(false), error: signal(null), load: vi.fn(() => Promise.resolve()), selectItem: vi.fn(), selectedItem: vi.fn(() => options.selectedItem ?? null), equip: vi.fn(() => Promise.resolve()), }; const worldStore = { character: signal(options.character === undefined ? character : options.character), load: vi.fn(() => Promise.resolve()), }; await TestBed.configureTestingModule({ imports: [InventoryPageComponent], providers: [ { provide: InventoryStore, useValue: inventoryStore }, { provide: WorldStore, useValue: worldStore }, ], }).compileComponents(); const fixture = TestBed.createComponent(InventoryPageComponent); fixture.detectChanges(); return { fixture, inventoryStore, worldStore }; } describe('InventoryPageComponent', () => { it('loads the inventory on init', async () => { const { inventoryStore } = await setup(); expect(inventoryStore.load).toHaveBeenCalledOnce(); }); it('loads the world state on init when no character has been loaded yet (direct navigation/hard refresh)', async () => { const { worldStore } = await setup({ character: null }); expect(worldStore.load).toHaveBeenCalledOnce(); }); it('does not call world load again when a character is already present', async () => { const { worldStore } = await setup(); expect(worldStore.load).not.toHaveBeenCalled(); }); it('renders one tile per owned item', async () => { const { fixture } = await setup(); const tiles = (fixture.nativeElement as HTMLElement).querySelectorAll('.inventory-page__slot'); expect(tiles.length).toBe(2); }); it('marks the equipped item with a badge', async () => { const { fixture } = await setup(); expect((fixture.nativeElement as HTMLElement).querySelector('[data-slot-equipped]')).not.toBeNull(); }); it('selects an item when its tile is clicked', async () => { const { fixture, inventoryStore } = await setup(); (fixture.nativeElement as HTMLElement).querySelectorAll('.inventory-page__slot')[1].click(); expect(inventoryStore.selectItem).toHaveBeenCalledWith('item-blade'); }); it('shows the equipment overview with all seven slots and empty ones as Leer', async () => { const { fixture } = await setup(); const text = (fixture.nativeElement as HTMLElement).querySelector('.inventory-page__equipment-list')?.textContent ?? ''; expect(text).toContain('Waffe'); expect(text).toContain('Abgenutztes Kurzschwert'); expect(text).toContain('Kopf'); expect(text).toContain('Leer'); }); it('shows the effective stats summary from the equipment response', async () => { const { fixture } = await setup(); const text = (fixture.nativeElement as HTMLElement).querySelector('[data-inventory-stats]')?.textContent ?? ''; expect(text).toContain('100'); expect(text).toContain('6'); expect(text).toContain('8'); }); it('passes the equipped item from the SAME slot as the selection — not just any equipped item — to the detail panel', async () => { // item-helm (HEAD, equipped) is placed before item-sword (WEAPON, equipped) so that a // regression which drops the equipmentSlot match (i.e. "find the first equipped item") // would surface item-helm instead of item-sword, and this test would fail. const threeItemInventory: InventoryResponse = { items: [ { id: 'item-helm', quantity: 1, equipped: true, item: { key: 'iron-helm', name: 'Eiserner Helm', rarity: 'COMMON', equipmentSlot: 'HEAD', weaponDamage: 0, bonusAttack: 0, bonusHp: 5, bonusArmor: 2, iconPath: '/images/items/iron-helm.png', }, }, inventory.items[0], // item-sword, WEAPON, equipped inventory.items[1], // item-blade, WEAPON, not equipped — this is the selection ], }; const { fixture } = await setup({ inventoryData: threeItemInventory, selectedItemId: 'item-blade', selectedItem: threeItemInventory.items[2], }); const panel = fixture.debugElement.query(By.directive(InventoryDetailPanelComponent)) .componentInstance as InventoryDetailPanelComponent; expect(panel.equippedItemInSlot()?.id).toBe('item-sword'); }); });