From 97ddc44336ca6945b467dd45b676308e8e08a0fb Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Sun, 1 Feb 2026 20:51:51 -0600 Subject: [PATCH] Add Phase F4 game UI overlay components - Add TurnIndicator to show current turn and phase - Add AttackMenu for selecting Pokemon attacks - Add GameOverlay container for positioning UI over Phaser - Add GameOverModal for end-game display - Add ForcedActionModal for required player actions - Add PhaseActions for phase-specific buttons - Include component tests Co-Authored-By: Claude Sonnet 4.5 --- .../src/components/game/AttackMenu.spec.ts | 754 ++++++++++++++++++ frontend/src/components/game/AttackMenu.vue | 709 ++++++++++++++++ .../components/game/ForcedActionModal.spec.ts | 606 ++++++++++++++ .../src/components/game/ForcedActionModal.vue | 509 ++++++++++++ .../src/components/game/GameOverModal.spec.ts | 528 ++++++++++++ .../src/components/game/GameOverModal.vue | 295 +++++++ .../src/components/game/GameOverlay.spec.ts | 186 +++++ frontend/src/components/game/GameOverlay.vue | 139 ++++ .../src/components/game/PhaseActions.spec.ts | 519 ++++++++++++ frontend/src/components/game/PhaseActions.vue | 319 ++++++++ .../src/components/game/TurnIndicator.spec.ts | 292 +++++++ .../src/components/game/TurnIndicator.vue | 265 ++++++ 12 files changed, 5121 insertions(+) create mode 100644 frontend/src/components/game/AttackMenu.spec.ts create mode 100644 frontend/src/components/game/AttackMenu.vue create mode 100644 frontend/src/components/game/ForcedActionModal.spec.ts create mode 100644 frontend/src/components/game/ForcedActionModal.vue create mode 100644 frontend/src/components/game/GameOverModal.spec.ts create mode 100644 frontend/src/components/game/GameOverModal.vue create mode 100644 frontend/src/components/game/GameOverlay.spec.ts create mode 100644 frontend/src/components/game/GameOverlay.vue create mode 100644 frontend/src/components/game/PhaseActions.spec.ts create mode 100644 frontend/src/components/game/PhaseActions.vue create mode 100644 frontend/src/components/game/TurnIndicator.spec.ts create mode 100644 frontend/src/components/game/TurnIndicator.vue diff --git a/frontend/src/components/game/AttackMenu.spec.ts b/frontend/src/components/game/AttackMenu.spec.ts new file mode 100644 index 0000000..7edf229 --- /dev/null +++ b/frontend/src/components/game/AttackMenu.spec.ts @@ -0,0 +1,754 @@ +/** + * Tests for AttackMenu component. + * + * Verifies attack selection UI, energy validation, status condition checks, + * and action dispatching functionality. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, VueWrapper } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import AttackMenu from './AttackMenu.vue' +import { useGameStore } from '@/stores/game' +import type { VisibleGameState, CardInstance, CardDefinition, Attack } from '@/types' + +// Mock the useGameActions composable +vi.mock('@/composables/useGameActions', () => ({ + useGameActions: () => ({ + attack: vi.fn().mockResolvedValue(undefined), + isPending: { value: false }, + }), +})) + +// Mock the energy colors utility +vi.mock('@/utils/energyColors', () => ({ + getEnergyColor: (type: string) => `#${type}`, +})) + +// --------------------------------------------------------------------------- +// Test Helpers +// --------------------------------------------------------------------------- + +/** + * Create a mock CardInstance for testing. + */ +function createMockCardInstance(overrides: Partial = {}): CardInstance { + return { + instance_id: 'test-instance-1', + definition_id: 'pikachu-base', + damage: 0, + attached_energy: [], + attached_tools: [], + status_conditions: [], + ability_uses_this_turn: {}, + evolution_turn: null, + ...overrides, + } +} + +/** + * Create a mock CardDefinition for a Pokemon with attacks. + */ +function createMockPokemonDefinition(attacks: Attack[]): CardDefinition { + return { + id: 'pikachu-base', + name: 'Pikachu', + card_type: 'pokemon', + stage: 'basic', + hp: 60, + pokemon_type: 'lightning', + attacks, + } +} + +/** + * Create a mock VisibleGameState for testing. + */ +function createMockGameState( + activePokemon: CardInstance | null, + cardRegistry: Record +): VisibleGameState { + return { + game_id: 'test-game', + viewer_id: 'player1', + players: { + player1: { + player_id: 'player1', + is_current_player: true, + deck_count: 30, + hand: { count: 5, cards: [], zone_type: 'hand' }, + prizes_count: 3, + energy_deck_count: 10, + active: { + count: activePokemon ? 1 : 0, + cards: activePokemon ? [activePokemon] : [], + zone_type: 'active', + }, + bench: { count: 0, cards: [], zone_type: 'bench' }, + discard: { count: 0, cards: [], zone_type: 'discard' }, + energy_zone: { count: 0, cards: [], zone_type: 'energy_zone' }, + score: 0, + gx_attack_used: false, + vstar_power_used: false, + }, + player2: { + player_id: 'player2', + is_current_player: false, + deck_count: 30, + hand: { count: 5, cards: [], zone_type: 'hand' }, + prizes_count: 3, + energy_deck_count: 10, + active: { count: 0, cards: [], zone_type: 'active' }, + bench: { count: 0, cards: [], zone_type: 'bench' }, + discard: { count: 0, cards: [], zone_type: 'discard' }, + energy_zone: { count: 0, cards: [], zone_type: 'energy_zone' }, + score: 0, + gx_attack_used: false, + vstar_power_used: false, + }, + }, + current_player_id: 'player1', + turn_number: 1, + phase: 'attack', + is_my_turn: true, + winner_id: null, + end_reason: null, + stadium_in_play: null, + stadium_owner_id: null, + forced_action_player: null, + forced_action_type: null, + forced_action_reason: null, + card_registry: cardRegistry, + } +} + +/** + * Mount AttackMenu with game state injection. + */ +function mountAttackMenu(props: { show: boolean }, gameState: VisibleGameState | null) { + const pinia = createPinia() + setActivePinia(pinia) + + const store = useGameStore() + if (gameState) { + store.setGameState(gameState) + } + + return mount(AttackMenu, { + props, + global: { + plugins: [pinia], + provide: { + gameState: { value: gameState }, + isMyTurn: { value: gameState?.is_my_turn ?? false }, + currentPhase: { value: gameState?.phase ?? null }, + }, + }, + }) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('AttackMenu', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + describe('rendering', () => { + it('renders when show prop is true', () => { + /** + * Test that the attack menu renders when the show prop is true. + * + * The attack menu should be visible to allow the player to select + * an attack during the attack phase. + */ + const attacks: Attack[] = [ + { name: 'Thunder Shock', cost: ['lightning'], damage: 20 }, + ] + const activePokemon = createMockCardInstance() + const pokemonDef = createMockPokemonDefinition(attacks) + const gameState = createMockGameState(activePokemon, { + [pokemonDef.id]: pokemonDef, + }) + + const wrapper = mountAttackMenu({ show: true }, gameState) + + expect(wrapper.find('[data-testid="attack-menu"]').exists()).toBe(true) + }) + + it('does not render when show prop is false', () => { + /** + * Test that the attack menu does not render when the show prop is false. + * + * The menu should be hidden when not in use to avoid cluttering + * the game interface. + */ + const attacks: Attack[] = [ + { name: 'Thunder Shock', cost: ['lightning'], damage: 20 }, + ] + const activePokemon = createMockCardInstance() + const pokemonDef = createMockPokemonDefinition(attacks) + const gameState = createMockGameState(activePokemon, { + [pokemonDef.id]: pokemonDef, + }) + + const wrapper = mountAttackMenu({ show: false }, gameState) + + expect(wrapper.find('[data-testid="attack-menu"]').exists()).toBe(false) + }) + + it('displays active Pokemon name', () => { + /** + * Test that the attack menu displays the active Pokemon's name. + * + * Players need to see which Pokemon is attacking to make informed + * decisions about which attack to use. + */ + const attacks: Attack[] = [ + { name: 'Thunder Shock', cost: ['lightning'], damage: 20 }, + ] + const activePokemon = createMockCardInstance() + const pokemonDef = createMockPokemonDefinition(attacks) + const gameState = createMockGameState(activePokemon, { + [pokemonDef.id]: pokemonDef, + }) + + const wrapper = mountAttackMenu({ show: true }, gameState) + + expect(wrapper.text()).toContain('Pikachu') + }) + + it('displays all available attacks', () => { + /** + * Test that the attack menu displays all attacks available to the active Pokemon. + * + * Players must be able to see all attack options to choose the most + * strategic move for the current game state. + */ + const attacks: Attack[] = [ + { name: 'Thunder Shock', cost: ['lightning'], damage: 20 }, + { name: 'Thunderbolt', cost: ['lightning', 'lightning'], damage: 50 }, + ] + const activePokemon = createMockCardInstance() + const pokemonDef = createMockPokemonDefinition(attacks) + const gameState = createMockGameState(activePokemon, { + [pokemonDef.id]: pokemonDef, + }) + + const wrapper = mountAttackMenu({ show: true }, gameState) + + const attackList = wrapper.find('[data-testid="attack-list"]') + expect(attackList.text()).toContain('Thunder Shock') + expect(attackList.text()).toContain('Thunderbolt') + }) + + it('displays attack damage values', () => { + /** + * Test that the attack menu displays damage values for each attack. + * + * Damage values are critical information for making strategic + * attack decisions. + */ + const attacks: Attack[] = [ + { name: 'Thunder Shock', cost: ['lightning'], damage: 20 }, + { name: 'Thunderbolt', cost: ['lightning', 'lightning'], damage: 50 }, + ] + const activePokemon = createMockCardInstance() + const pokemonDef = createMockPokemonDefinition(attacks) + const gameState = createMockGameState(activePokemon, { + [pokemonDef.id]: pokemonDef, + }) + + const wrapper = mountAttackMenu({ show: true }, gameState) + + expect(wrapper.text()).toContain('20') + expect(wrapper.text()).toContain('50') + }) + + it('displays effect descriptions', () => { + /** + * Test that the attack menu displays effect descriptions for attacks. + * + * Effect descriptions help players understand what additional + * effects an attack might have beyond damage. + */ + const attacks: Attack[] = [ + { + name: 'Thunder Shock', + cost: ['lightning'], + damage: 20, + effect_description: 'Flip a coin. If heads, the Defending PokΓ©mon is now Paralyzed.', + }, + ] + const activePokemon = createMockCardInstance() + const pokemonDef = createMockPokemonDefinition(attacks) + const gameState = createMockGameState(activePokemon, { + [pokemonDef.id]: pokemonDef, + }) + + const wrapper = mountAttackMenu({ show: true }, gameState) + + expect(wrapper.text()).toContain('Flip a coin') + }) + + it('displays energy cost icons', () => { + /** + * Test that the attack menu displays energy cost icons for each attack. + * + * Visual energy cost display helps players quickly identify + * which attacks they can afford to use. + */ + const attacks: Attack[] = [ + { name: 'Thunder Shock', cost: ['lightning', 'colorless'], damage: 20 }, + ] + const activePokemon = createMockCardInstance() + const pokemonDef = createMockPokemonDefinition(attacks) + const gameState = createMockGameState(activePokemon, { + [pokemonDef.id]: pokemonDef, + }) + + const wrapper = mountAttackMenu({ show: true }, gameState) + + const energyIcons = wrapper.findAll('.energy-icon') + expect(energyIcons).toHaveLength(2) + }) + }) + + describe('energy validation', () => { + it('enables attack when enough energy is attached', () => { + /** + * Test that attacks are enabled when the Pokemon has enough energy. + * + * Players should be able to use attacks when they have satisfied + * the energy cost requirements. + */ + const attacks: Attack[] = [ + { name: 'Thunder Shock', cost: ['lightning'], damage: 20 }, + ] + const energyCard = createMockCardInstance({ + instance_id: 'energy-1', + definition_id: 'lightning-energy', + }) + const activePokemon = createMockCardInstance({ + attached_energy: [energyCard], + }) + const pokemonDef = createMockPokemonDefinition(attacks) + const energyDef: CardDefinition = { + id: 'lightning-energy', + name: 'Lightning Energy', + card_type: 'energy', + energy_type: 'lightning', + } + const gameState = createMockGameState(activePokemon, { + [pokemonDef.id]: pokemonDef, + [energyDef.id]: energyDef, + }) + + const wrapper = mountAttackMenu({ show: true }, gameState) + + const attackButton = wrapper.find('[data-testid="attack-0"]') + expect(attackButton.attributes('disabled')).toBeUndefined() + }) + + it('disables attack when not enough energy is attached', () => { + /** + * Test that attacks are disabled when the Pokemon lacks sufficient energy. + * + * Preventing invalid attacks ensures game rules are enforced + * and prevents frustrating action failures. + */ + const attacks: Attack[] = [ + { name: 'Thunderbolt', cost: ['lightning', 'lightning'], damage: 50 }, + ] + const energyCard = createMockCardInstance({ + instance_id: 'energy-1', + definition_id: 'lightning-energy', + }) + const activePokemon = createMockCardInstance({ + attached_energy: [energyCard], // Only 1 energy, needs 2 + }) + const pokemonDef = createMockPokemonDefinition(attacks) + const energyDef: CardDefinition = { + id: 'lightning-energy', + name: 'Lightning Energy', + card_type: 'energy', + energy_type: 'lightning', + } + const gameState = createMockGameState(activePokemon, { + [pokemonDef.id]: pokemonDef, + [energyDef.id]: energyDef, + }) + + const wrapper = mountAttackMenu({ show: true }, gameState) + + const attackButton = wrapper.find('[data-testid="attack-0"]') + expect(attackButton.attributes('disabled')).toBeDefined() + }) + + it('shows "Not enough energy" reason for disabled attacks', async () => { + /** + * Test that the menu shows a clear reason when attacks are disabled due to energy. + * + * Clear feedback helps players understand why an attack is unavailable + * and what they need to do to enable it. + */ + const attacks: Attack[] = [ + { name: 'Thunderbolt', cost: ['lightning', 'lightning'], damage: 50 }, + ] + const activePokemon = createMockCardInstance({ + attached_energy: [], // No energy + }) + const pokemonDef = createMockPokemonDefinition(attacks) + const gameState = createMockGameState(activePokemon, { + [pokemonDef.id]: pokemonDef, + }) + + const wrapper = mountAttackMenu({ show: true }, gameState) + + const attackButton = wrapper.find('[data-testid="attack-0"]') + // Hover to show tooltip + await attackButton.trigger('mouseenter') + + const tooltip = wrapper.find('[data-testid="disabled-reason"]') + expect(tooltip.text()).toContain('Not enough energy') + }) + + it('validates colorless energy can be satisfied by any type', () => { + /** + * Test that colorless energy cost can be satisfied by any energy type. + * + * Colorless energy is a core game mechanic that allows flexible + * energy usage across different Pokemon types. + */ + const attacks: Attack[] = [ + { name: 'Tackle', cost: ['colorless'], damage: 10 }, + ] + const fireEnergy = createMockCardInstance({ + instance_id: 'energy-1', + definition_id: 'fire-energy', + }) + const activePokemon = createMockCardInstance({ + attached_energy: [fireEnergy], + }) + const pokemonDef = createMockPokemonDefinition(attacks) + const energyDef: CardDefinition = { + id: 'fire-energy', + name: 'Fire Energy', + card_type: 'energy', + energy_type: 'fire', + } + const gameState = createMockGameState(activePokemon, { + [pokemonDef.id]: pokemonDef, + [energyDef.id]: energyDef, + }) + + const wrapper = mountAttackMenu({ show: true }, gameState) + + const attackButton = wrapper.find('[data-testid="attack-0"]') + expect(attackButton.attributes('disabled')).toBeUndefined() + }) + + it('validates specific energy types are required', () => { + /** + * Test that specific energy type requirements are enforced. + * + * Ensuring correct energy types are attached is a fundamental + * game rule that affects strategic deck building. + */ + const attacks: Attack[] = [ + { name: 'Ember', cost: ['fire'], damage: 30 }, + ] + const waterEnergy = createMockCardInstance({ + instance_id: 'energy-1', + definition_id: 'water-energy', + }) + const activePokemon = createMockCardInstance({ + attached_energy: [waterEnergy], // Wrong type + }) + const pokemonDef = createMockPokemonDefinition(attacks) + const energyDef: CardDefinition = { + id: 'water-energy', + name: 'Water Energy', + card_type: 'energy', + energy_type: 'water', + } + const gameState = createMockGameState(activePokemon, { + [pokemonDef.id]: pokemonDef, + [energyDef.id]: energyDef, + }) + + const wrapper = mountAttackMenu({ show: true }, gameState) + + const attackButton = wrapper.find('[data-testid="attack-0"]') + expect(attackButton.attributes('disabled')).toBeDefined() + }) + }) + + describe('status condition validation', () => { + it('disables all attacks when Pokemon is paralyzed', () => { + /** + * Test that all attacks are disabled when the active Pokemon is paralyzed. + * + * Paralysis is a status condition that prevents attacks, which is + * a critical game mechanic that must be enforced. + */ + const attacks: Attack[] = [ + { name: 'Thunder Shock', cost: ['lightning'], damage: 20 }, + ] + const energyCard = createMockCardInstance({ + instance_id: 'energy-1', + definition_id: 'lightning-energy', + }) + const activePokemon = createMockCardInstance({ + attached_energy: [energyCard], + status_conditions: ['paralyzed'], + }) + const pokemonDef = createMockPokemonDefinition(attacks) + const energyDef: CardDefinition = { + id: 'lightning-energy', + name: 'Lightning Energy', + card_type: 'energy', + energy_type: 'lightning', + } + const gameState = createMockGameState(activePokemon, { + [pokemonDef.id]: pokemonDef, + [energyDef.id]: energyDef, + }) + + const wrapper = mountAttackMenu({ show: true }, gameState) + + const attackButton = wrapper.find('[data-testid="attack-0"]') + expect(attackButton.attributes('disabled')).toBeDefined() + }) + + it('shows paralysis warning message', () => { + /** + * Test that a warning message is displayed when the Pokemon is paralyzed. + * + * Clear communication about why actions are disabled helps players + * understand the game state and plan their next moves. + */ + const attacks: Attack[] = [ + { name: 'Thunder Shock', cost: ['lightning'], damage: 20 }, + ] + const activePokemon = createMockCardInstance({ + status_conditions: ['paralyzed'], + }) + const pokemonDef = createMockPokemonDefinition(attacks) + const gameState = createMockGameState(activePokemon, { + [pokemonDef.id]: pokemonDef, + }) + + const wrapper = mountAttackMenu({ show: true }, gameState) + + const warning = wrapper.find('[data-testid="disabled-warning"]') + expect(warning.text()).toContain('paralyzed') + }) + }) + + describe('user interactions', () => { + it('emits close event when cancel button is clicked', async () => { + /** + * Test that the close event is emitted when the cancel button is clicked. + * + * Players need a way to close the menu without selecting an attack, + * allowing them to reconsider their strategy. + */ + const attacks: Attack[] = [ + { name: 'Thunder Shock', cost: ['lightning'], damage: 20 }, + ] + const activePokemon = createMockCardInstance() + const pokemonDef = createMockPokemonDefinition(attacks) + const gameState = createMockGameState(activePokemon, { + [pokemonDef.id]: pokemonDef, + }) + + const wrapper = mountAttackMenu({ show: true }, gameState) + + await wrapper.find('[data-testid="cancel-button"]').trigger('click') + + expect(wrapper.emitted('close')).toBeTruthy() + }) + + it('emits close event when close button is clicked', async () => { + /** + * Test that the close event is emitted when the X close button is clicked. + * + * The close button provides an intuitive way to dismiss the modal, + * which is a standard UI pattern users expect. + */ + const attacks: Attack[] = [ + { name: 'Thunder Shock', cost: ['lightning'], damage: 20 }, + ] + const activePokemon = createMockCardInstance() + const pokemonDef = createMockPokemonDefinition(attacks) + const gameState = createMockGameState(activePokemon, { + [pokemonDef.id]: pokemonDef, + }) + + const wrapper = mountAttackMenu({ show: true }, gameState) + + await wrapper.find('[data-testid="close-button"]').trigger('click') + + expect(wrapper.emitted('close')).toBeTruthy() + }) + + it('emits close event when backdrop is clicked', async () => { + /** + * Test that the close event is emitted when clicking outside the menu. + * + * Clicking outside a modal is a common UX pattern for dismissal + * that users expect to work. + */ + const attacks: Attack[] = [ + { name: 'Thunder Shock', cost: ['lightning'], damage: 20 }, + ] + const activePokemon = createMockCardInstance() + const pokemonDef = createMockPokemonDefinition(attacks) + const gameState = createMockGameState(activePokemon, { + [pokemonDef.id]: pokemonDef, + }) + + const wrapper = mountAttackMenu({ show: true }, gameState) + + await wrapper.find('[data-testid="attack-menu-backdrop"]').trigger('click') + + expect(wrapper.emitted('close')).toBeTruthy() + }) + + it('emits attackSelected event when attack is clicked', async () => { + /** + * Test that the attackSelected event is emitted with the correct attack index. + * + * The parent component needs to know which attack was selected to + * handle any UI state changes before the action is dispatched. + */ + const attacks: Attack[] = [ + { name: 'Thunder Shock', cost: ['lightning'], damage: 20 }, + ] + const energyCard = createMockCardInstance({ + instance_id: 'energy-1', + definition_id: 'lightning-energy', + }) + const activePokemon = createMockCardInstance({ + attached_energy: [energyCard], + }) + const pokemonDef = createMockPokemonDefinition(attacks) + const energyDef: CardDefinition = { + id: 'lightning-energy', + name: 'Lightning Energy', + card_type: 'energy', + energy_type: 'lightning', + } + const gameState = createMockGameState(activePokemon, { + [pokemonDef.id]: pokemonDef, + [energyDef.id]: energyDef, + }) + + const wrapper = mountAttackMenu({ show: true }, gameState) + + await wrapper.find('[data-testid="attack-0"]').trigger('click') + + expect(wrapper.emitted('attackSelected')).toBeTruthy() + expect(wrapper.emitted('attackSelected')![0]).toEqual([0]) + }) + + it('does not emit attackSelected when clicking disabled attack', async () => { + /** + * Test that clicking a disabled attack does not emit the attackSelected event. + * + * Disabled attacks should not trigger any action to prevent + * invalid game state changes and user frustration. + */ + const attacks: Attack[] = [ + { name: 'Thunderbolt', cost: ['lightning', 'lightning'], damage: 50 }, + ] + const activePokemon = createMockCardInstance({ + attached_energy: [], // No energy + }) + const pokemonDef = createMockPokemonDefinition(attacks) + const gameState = createMockGameState(activePokemon, { + [pokemonDef.id]: pokemonDef, + }) + + const wrapper = mountAttackMenu({ show: true }, gameState) + + await wrapper.find('[data-testid="attack-0"]').trigger('click') + + expect(wrapper.emitted('attackSelected')).toBeFalsy() + }) + }) + + describe('edge cases', () => { + it('shows empty message when Pokemon has no attacks', () => { + /** + * Test that an appropriate message is shown when the Pokemon has no attacks. + * + * While rare, some Pokemon cards might not have attacks, and the + * UI should gracefully handle this case. + */ + const attacks: Attack[] = [] + const activePokemon = createMockCardInstance() + const pokemonDef = createMockPokemonDefinition(attacks) + const gameState = createMockGameState(activePokemon, { + [pokemonDef.id]: pokemonDef, + }) + + const wrapper = mountAttackMenu({ show: true }, gameState) + + expect(wrapper.text()).toContain('No attacks available') + }) + + it('handles Pokemon with no active card gracefully', () => { + /** + * Test that the component handles the case where there is no active Pokemon. + * + * This edge case might occur during forced action states or + * due to timing issues, and should not cause errors. + */ + const gameState = createMockGameState(null, {}) + + const wrapper = mountAttackMenu({ show: true }, gameState) + + expect(wrapper.find('[data-testid="attack-menu"]').exists()).toBe(true) + expect(wrapper.text()).toContain('No attacks available') + }) + + it('disables attacks when not player turn', () => { + /** + * Test that all attacks are disabled when it's not the player's turn. + * + * Turn-based gameplay requires strict enforcement to maintain + * fair play and prevent out-of-turn actions. + */ + const attacks: Attack[] = [ + { name: 'Thunder Shock', cost: ['lightning'], damage: 20 }, + ] + const energyCard = createMockCardInstance({ + instance_id: 'energy-1', + definition_id: 'lightning-energy', + }) + const activePokemon = createMockCardInstance({ + attached_energy: [energyCard], + }) + const pokemonDef = createMockPokemonDefinition(attacks) + const energyDef: CardDefinition = { + id: 'lightning-energy', + name: 'Lightning Energy', + card_type: 'energy', + energy_type: 'lightning', + } + const gameState = createMockGameState(activePokemon, { + [pokemonDef.id]: pokemonDef, + [energyDef.id]: energyDef, + }) + gameState.is_my_turn = false + + const wrapper = mountAttackMenu({ show: true }, gameState) + + const attackButton = wrapper.find('[data-testid="attack-0"]') + expect(attackButton.attributes('disabled')).toBeDefined() + }) + }) +}) diff --git a/frontend/src/components/game/AttackMenu.vue b/frontend/src/components/game/AttackMenu.vue new file mode 100644 index 0000000..c94355a --- /dev/null +++ b/frontend/src/components/game/AttackMenu.vue @@ -0,0 +1,709 @@ + + + + + diff --git a/frontend/src/components/game/ForcedActionModal.spec.ts b/frontend/src/components/game/ForcedActionModal.spec.ts new file mode 100644 index 0000000..5ff6a3f --- /dev/null +++ b/frontend/src/components/game/ForcedActionModal.spec.ts @@ -0,0 +1,606 @@ +/** + * Tests for ForcedActionModal component. + * + * Verifies that the forced action modal correctly handles: + * - Prize card selection after knocking out opponent's Pokemon + * - New active Pokemon selection when active is knocked out + * - Discard card selection when required by effects + * - Cannot be dismissed until action is completed + * - Proper action submission to the backend + * - Error handling and display + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { setActivePinia, createPinia } from 'pinia' +import { nextTick } from 'vue' +import ForcedActionModal from './ForcedActionModal.vue' +import { useGameStore } from '@/stores/game' +import * as useGameActionsModule from '@/composables/useGameActions' +import type { CardInstance, VisibleGameState } from '@/types' + +/** + * Helper to create a minimal card instance for testing. + */ +function createMockCard( + instanceId: string, + definitionId: string = 'pikachu-001' +): CardInstance { + return { + instance_id: instanceId, + definition_id: definitionId, + damage: 0, + attached_energy: [], + attached_tools: [], + status_conditions: [], + ability_uses_this_turn: {}, + evolution_turn: null, + } +} + +/** + * Helper to create a mock game state with forced action. + */ +function createMockGameState( + forcedActionType: string | null, + forcedActionReason: string | null = null, + benchCards: CardInstance[] = [], + handCards: CardInstance[] = [], + prizeCount: number = 6 +): VisibleGameState { + return { + game_id: 'test-game-123', + viewer_id: 'player-1', + players: { + 'player-1': { + player_id: 'player-1', + is_current_player: true, + deck_count: 40, + hand: { count: handCards.length, cards: handCards, zone_type: 'hand' }, + prizes_count: prizeCount, + energy_deck_count: 10, + active: { count: 0, cards: [], zone_type: 'active' }, + bench: { count: benchCards.length, cards: benchCards, zone_type: 'bench' }, + discard: { count: 0, cards: [], zone_type: 'discard' }, + energy_zone: { count: 0, cards: [], zone_type: 'energy_zone' }, + score: 0, + gx_attack_used: false, + vstar_power_used: false, + }, + }, + current_player_id: 'player-1', + turn_number: 1, + phase: 'main', + is_my_turn: true, + winner_id: null, + end_reason: null, + stadium_in_play: null, + stadium_owner_id: null, + forced_action_player: 'player-1', + forced_action_type: forcedActionType, + forced_action_reason: forcedActionReason, + card_registry: { + 'pikachu-001': { + id: 'pikachu-001', + name: 'Pikachu', + card_type: 'pokemon', + image_url: '/cards/pikachu.png', + hp: 60, + }, + 'charizard-001': { + id: 'charizard-001', + name: 'Charizard', + card_type: 'pokemon', + image_url: '/cards/charizard.png', + hp: 120, + }, + 'professor-oak-001': { + id: 'professor-oak-001', + name: 'Professor Oak', + card_type: 'trainer', + image_url: '/cards/oak.png', + }, + }, + } +} + +/** + * Mount ForcedActionModal with mocked dependencies. + */ +function mountModal( + forcedActionType: string | null = null, + forcedActionReason: string | null = null, + benchCards: CardInstance[] = [], + handCards: CardInstance[] = [], + prizeCount: number = 6 +) { + const gameStore = useGameStore() + const gameState = createMockGameState( + forcedActionType, + forcedActionReason, + benchCards, + handCards, + prizeCount + ) + gameStore.setGameState(gameState) + + return mount(ForcedActionModal, { + global: { + stubs: { + Teleport: true, // Stub Teleport for testing + }, + }, + }) +} + +describe('ForcedActionModal', () => { + let mockSelectPrize: ReturnType + let mockSelectNewActive: ReturnType + let mockDiscardFromHand: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + + // Mock the useGameActions composable + mockSelectPrize = vi.fn().mockResolvedValue(undefined) + mockSelectNewActive = vi.fn().mockResolvedValue(undefined) + mockDiscardFromHand = vi.fn().mockResolvedValue(undefined) + + vi.spyOn(useGameActionsModule, 'useGameActions').mockReturnValue({ + selectPrize: mockSelectPrize, + selectNewActive: mockSelectNewActive, + discardFromHand: mockDiscardFromHand, + // Other methods not used in this component + playCard: vi.fn(), + attachEnergy: vi.fn(), + evolve: vi.fn(), + attack: vi.fn(), + retreat: vi.fn(), + useAbility: vi.fn(), + endTurn: vi.fn(), + pendingAction: { value: null }, + isPending: { value: false }, + }) + }) + + describe('Prize Selection', () => { + it('displays prize selection UI when forced action is prize_selection', () => { + /** + * Test that the modal shows prize selection interface. + * + * When a player knocks out an opponent's Pokemon, they must select + * a prize card. The UI should display all available prize positions. + */ + const wrapper = mountModal('prize_selection', 'Select a prize card', [], [], 6) + + expect(wrapper.text()).toContain('Required Action') + expect(wrapper.text()).toContain('Select a prize card') + expect(wrapper.findAll('button').filter(btn => btn.text().includes('Prize')).length).toBe(6) + }) + + it('allows selecting a prize card', async () => { + /** + * Test that clicking a prize card selects it. + * + * Players need visual feedback showing which prize they've selected + * before confirming. + */ + const wrapper = mountModal('prize_selection', 'Select a prize card', [], [], 6) + + const prizeButtons = wrapper.findAll('button').filter(btn => btn.text().includes('Prize')) + await prizeButtons[2].trigger('click') // Select prize 3 + + await nextTick() + + // Check that the selected prize has the selected class/indicator + const selectedPrize = prizeButtons[2] + expect(selectedPrize.html()).toContain('ring-primary') + }) + + it('enables confirm button after prize selection', async () => { + /** + * Test that the confirm button becomes enabled after selection. + * + * Users should not be able to confirm without making a selection, + * preventing accidental empty submissions. + */ + const wrapper = mountModal('prize_selection', 'Select a prize card', [], [], 6) + + const confirmButton = wrapper.findAll('button').find(btn => btn.text().includes('Confirm'))! + expect(confirmButton.attributes('disabled')).toBeDefined() + + const prizeButtons = wrapper.findAll('button').filter(btn => btn.text().includes('Prize')) + await prizeButtons[0].trigger('click') + await nextTick() + + expect(confirmButton.attributes('disabled')).toBeUndefined() + }) + + it('calls selectPrize action with correct index on confirm', async () => { + /** + * Test that confirming sends the correct prize selection to the server. + * + * The backend needs the exact prize index to know which prize card + * to reveal and add to the player's hand. + */ + const wrapper = mountModal('prize_selection', 'Select a prize card', [], [], 6) + + const prizeButtons = wrapper.findAll('button').filter(btn => btn.text().includes('Prize')) + await prizeButtons[3].trigger('click') // Select prize 4 (index 3) + await nextTick() + + const confirmButton = wrapper.findAll('button').find(btn => btn.text().includes('Confirm'))! + await confirmButton.trigger('click') + await nextTick() + + expect(mockSelectPrize).toHaveBeenCalledWith(3) + }) + + it('displays correct number of prizes based on prize count', () => { + /** + * Test that only remaining prizes are shown. + * + * If a player has already claimed some prizes, only the remaining + * prizes should be available for selection. + */ + const wrapper = mountModal('prize_selection', 'Select a prize card', [], [], 3) + + const prizeButtons = wrapper.findAll('button').filter(btn => btn.text().includes('Prize')) + expect(prizeButtons.length).toBe(3) + }) + }) + + describe('New Active Selection', () => { + it('displays bench Pokemon selection when forced action is new_active_selection', () => { + /** + * Test that the modal shows bench Pokemon for selection. + * + * When the active Pokemon is knocked out, the player must select + * a new active from their bench. + */ + const benchCards = [ + createMockCard('bench-1', 'pikachu-001'), + createMockCard('bench-2', 'charizard-001'), + ] + const wrapper = mountModal( + 'new_active_selection', + 'Your active Pokemon was knocked out', + benchCards + ) + + expect(wrapper.text()).toContain('Required Action') + expect(wrapper.text()).toContain('knocked out') + expect(wrapper.text()).toContain('Pikachu') + expect(wrapper.text()).toContain('Charizard') + }) + + it('allows selecting a bench Pokemon', async () => { + /** + * Test that clicking a bench Pokemon selects it. + * + * Players need to see which Pokemon they've selected before confirming + * to avoid accidental selections. + */ + const benchCards = [ + createMockCard('bench-1', 'pikachu-001'), + createMockCard('bench-2', 'charizard-001'), + ] + const wrapper = mountModal('new_active_selection', 'Select new active', benchCards) + + const pokemonButtons = wrapper.findAll('button').filter(btn => !btn.text().includes('Confirm')) + await pokemonButtons[1].trigger('click') // Select second Pokemon + await nextTick() + + expect(pokemonButtons[1].html()).toContain('ring-primary') + }) + + it('calls selectNewActive action with correct Pokemon ID on confirm', async () => { + /** + * Test that confirming sends the correct Pokemon selection to the server. + * + * The backend needs the instance ID to move the correct Pokemon + * from bench to active position. + */ + const benchCards = [ + createMockCard('bench-1', 'pikachu-001'), + createMockCard('bench-2', 'charizard-001'), + ] + const wrapper = mountModal('new_active_selection', 'Select new active', benchCards) + + const pokemonButtons = wrapper.findAll('button').filter(btn => !btn.text().includes('Confirm')) + await pokemonButtons[0].trigger('click') + await nextTick() + + const confirmButton = wrapper.findAll('button').find(btn => btn.text().includes('Confirm'))! + await confirmButton.trigger('click') + await nextTick() + + expect(mockSelectNewActive).toHaveBeenCalledWith('bench-1') + }) + + it('displays warning when no bench Pokemon available', () => { + /** + * Test edge case: empty bench when active is knocked out. + * + * If the player has no benched Pokemon, they automatically lose. + * The UI should communicate this clearly. + */ + const wrapper = mountModal('new_active_selection', 'Select new active', []) + + expect(wrapper.text()).toContain('No Pokemon on bench') + expect(wrapper.text()).toContain('lose the game') + }) + }) + + describe('Discard Selection', () => { + it('displays hand cards for discard selection', () => { + /** + * Test that the modal shows hand cards for discard selection. + * + * Some card effects require discarding cards from hand. + * All hand cards should be available for selection. + */ + const handCards = [ + createMockCard('hand-1', 'pikachu-001'), + createMockCard('hand-2', 'charizard-001'), + createMockCard('hand-3', 'professor-oak-001'), + ] + const wrapper = mountModal('discard_selection', 'Discard 2 cards', [], handCards) + + expect(wrapper.text()).toContain('Required Action') + expect(wrapper.text()).toContain('Select 2 cards to discard') + expect(wrapper.text()).toContain('Pikachu') + expect(wrapper.text()).toContain('Charizard') + expect(wrapper.text()).toContain('Professor Oak') + }) + + it('parses required discard count from reason text', () => { + /** + * Test that the component extracts the discard count from the reason. + * + * The forced_action_reason contains the number of cards to discard. + * This must be parsed correctly to validate selection. + */ + const handCards = [ + createMockCard('hand-1', 'pikachu-001'), + createMockCard('hand-2', 'charizard-001'), + createMockCard('hand-3', 'professor-oak-001'), + ] + const wrapper = mountModal('discard_selection', 'Discard 2 cards from your hand', [], handCards) + + // Initially 0 selected + expect(wrapper.text()).toContain('0 / 2') + }) + + it('allows selecting cards up to required count', async () => { + /** + * Test that users can select exactly the required number of cards. + * + * Selection should be limited to the required count to prevent + * over-selection. + */ + const handCards = [ + createMockCard('hand-1', 'pikachu-001'), + createMockCard('hand-2', 'charizard-001'), + createMockCard('hand-3', 'professor-oak-001'), + ] + const wrapper = mountModal('discard_selection', 'Discard 2 cards', [], handCards) + + const cardButtons = wrapper.findAll('button').filter(btn => !btn.text().includes('Confirm')) + + // Select first card + await cardButtons[0].trigger('click') + await nextTick() + expect(wrapper.text()).toContain('1 / 2') + + // Select second card + await cardButtons[1].trigger('click') + await nextTick() + expect(wrapper.text()).toContain('2 / 2') + + // Try to select third card (should not be allowed) + const thirdButton = cardButtons[2] + expect(thirdButton.attributes('disabled')).toBeDefined() + }) + + it('allows deselecting cards', async () => { + /** + * Test that users can change their selection before confirming. + * + * Players should be able to deselect cards to try different + * combinations before finalizing. + */ + const handCards = [ + createMockCard('hand-1', 'pikachu-001'), + createMockCard('hand-2', 'charizard-001'), + ] + const wrapper = mountModal('discard_selection', 'Discard 1 card', [], handCards) + + const cardButtons = wrapper.findAll('button').filter(btn => !btn.text().includes('Confirm')) + + // Select first card + await cardButtons[0].trigger('click') + await nextTick() + expect(wrapper.text()).toContain('1 / 1') + + // Deselect first card + await cardButtons[0].trigger('click') + await nextTick() + expect(wrapper.text()).toContain('0 / 1') + }) + + it('enables confirm only when exact count is selected', async () => { + /** + * Test that confirm requires exactly the right number of cards. + * + * Prevents partial selections from being submitted. + */ + const handCards = [ + createMockCard('hand-1', 'pikachu-001'), + createMockCard('hand-2', 'charizard-001'), + createMockCard('hand-3', 'professor-oak-001'), + ] + const wrapper = mountModal('discard_selection', 'Discard 2 cards', [], handCards) + + let confirmButton = wrapper.findAll('button').find(btn => btn.text().includes('Confirm'))! + const cardButtons = wrapper.findAll('button').filter(btn => !btn.text().includes('Confirm')) + + // Initially disabled + expect(confirmButton.attributes('disabled')).toBeDefined() + + // Select one card - still disabled + await cardButtons[0].trigger('click') + await nextTick() + confirmButton = wrapper.findAll('button').find(btn => btn.text().includes('Confirm'))! + expect(confirmButton.attributes('disabled')).toBeDefined() + + // Select second card - now enabled + await cardButtons[1].trigger('click') + await nextTick() + confirmButton = wrapper.findAll('button').find(btn => btn.text().includes('Confirm'))! + expect(confirmButton.attributes('disabled')).toBeUndefined() + }) + + it('calls discardFromHand with selected card IDs on confirm', async () => { + /** + * Test that confirming sends the correct card IDs to discard. + * + * The backend needs the exact instance IDs to remove the correct + * cards from the player's hand. + */ + const handCards = [ + createMockCard('hand-1', 'pikachu-001'), + createMockCard('hand-2', 'charizard-001'), + ] + const wrapper = mountModal('discard_selection', 'Discard 2 cards', [], handCards) + + const cardButtons = wrapper.findAll('button').filter(btn => !btn.text().includes('Confirm')) + await cardButtons[0].trigger('click') + await cardButtons[1].trigger('click') + await nextTick() + + const confirmButton = wrapper.findAll('button').find(btn => btn.text().includes('Confirm'))! + await confirmButton.trigger('click') + await nextTick() + + expect(mockDiscardFromHand).toHaveBeenCalledWith( + expect.arrayContaining(['hand-1', 'hand-2']) + ) + }) + }) + + describe('General Modal Behavior', () => { + it('is not visible when no forced action is set', () => { + /** + * Test that the modal hides when there's no forced action. + * + * The modal should only appear when the player has a required action, + * not during normal gameplay. + */ + const wrapper = mountModal(null) + + const modal = wrapper.find('[role="dialog"]') + expect(modal.exists()).toBe(false) + }) + + it('is visible when forced action is set for current player', () => { + /** + * Test that the modal shows when a forced action is required. + * + * The modal must block gameplay until the required action is completed. + */ + const wrapper = mountModal('prize_selection', 'Select a prize') + + const modal = wrapper.find('[role="dialog"]') + expect(modal.exists()).toBe(true) + }) + + it('displays instruction text from forced_action_reason', () => { + /** + * Test that custom instruction text is displayed. + * + * The backend provides context-specific instructions that help + * players understand why the action is required. + */ + const wrapper = mountModal('prize_selection', 'You knocked out Pikachu! Select a prize card.') + + expect(wrapper.text()).toContain('You knocked out Pikachu! Select a prize card.') + }) + + it('shows loading state while submitting action', async () => { + /** + * Test that the UI indicates when an action is being processed. + * + * Network requests may take time; users need feedback that their + * action is being submitted. + */ + // Make the action take time + mockSelectPrize.mockReturnValue(new Promise(() => {})) + + const wrapper = mountModal('prize_selection', 'Select a prize', [], [], 6) + + const prizeButtons = wrapper.findAll('button').filter(btn => btn.text().includes('Prize')) + await prizeButtons[0].trigger('click') + await nextTick() + + const confirmButton = wrapper.findAll('button').find(btn => btn.text().includes('Confirm'))! + await confirmButton.trigger('click') + await nextTick() + + expect(confirmButton.text()).toContain('Confirming') + expect(confirmButton.attributes('disabled')).toBeDefined() + }) + + it('displays error message when action fails', async () => { + /** + * Test that action errors are shown to the user. + * + * If the server rejects the action, the player needs to see why + * so they can correct the issue. + */ + mockSelectPrize.mockRejectedValue(new Error('Invalid prize selection')) + + const wrapper = mountModal('prize_selection', 'Select a prize', [], [], 6) + + const prizeButtons = wrapper.findAll('button').filter(btn => btn.text().includes('Prize')) + await prizeButtons[0].trigger('click') + await nextTick() + + const confirmButton = wrapper.findAll('button').find(btn => btn.text().includes('Confirm'))! + await confirmButton.trigger('click') + await nextTick() + await nextTick() // Wait for error to be set + + expect(wrapper.text()).toContain('Invalid prize selection') + }) + + it('resets selection when modal opens', async () => { + /** + * Test that selections are cleared when a new forced action appears. + * + * Previous selections should not carry over to new forced actions + * to prevent confusion. + */ + const gameStore = useGameStore() + + // Start with prize selection + const wrapper = mountModal('prize_selection', 'Select a prize', [], [], 6) + + const prizeButtons = wrapper.findAll('button').filter(btn => btn.text().includes('Prize')) + await prizeButtons[2].trigger('click') + await nextTick() + + // Change to different forced action + const newState = createMockGameState( + 'new_active_selection', + 'Select new active', + [createMockCard('bench-1', 'pikachu-001')] + ) + gameStore.setGameState(newState) + await nextTick() + + // Confirm button should be disabled (no selection in new context) + const confirmButton = wrapper.findAll('button').find(btn => btn.text().includes('Confirm'))! + expect(confirmButton.attributes('disabled')).toBeDefined() + }) + }) +}) diff --git a/frontend/src/components/game/ForcedActionModal.vue b/frontend/src/components/game/ForcedActionModal.vue new file mode 100644 index 0000000..bf200bb --- /dev/null +++ b/frontend/src/components/game/ForcedActionModal.vue @@ -0,0 +1,509 @@ + + + diff --git a/frontend/src/components/game/GameOverModal.spec.ts b/frontend/src/components/game/GameOverModal.spec.ts new file mode 100644 index 0000000..1f8ef38 --- /dev/null +++ b/frontend/src/components/game/GameOverModal.spec.ts @@ -0,0 +1,528 @@ +/** + * Tests for GameOverModal component. + * + * Verifies that the game over modal correctly handles: + * - Victory display with appropriate styling and messaging + * - Defeat display with appropriate styling and messaging + * - Draw display when there's no winner + * - Different end reasons with correct text + * - Game statistics display (turn count, scores) + * - Return to lobby navigation + * - Keyboard support (Escape to close) + * - Only shows when game is over + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, VueWrapper } from '@vue/test-utils' +import { setActivePinia, createPinia } from 'pinia' +import { nextTick } from 'vue' +import GameOverModal from './GameOverModal.vue' +import { useGameStore } from '@/stores/game' +import type { VisibleGameState, GameEndReason } from '@/types' + +// Mock vue-router +const mockPush = vi.fn() +vi.mock('vue-router', () => ({ + useRouter: () => ({ + push: mockPush, + }), +})) + +/** + * Helper to create a mock game state with game over status. + */ +function createMockGameState( + viewerId: string, + winnerId: string | null, + endReason: GameEndReason | null, + turnNumber: number = 10, + player1Score: number = 3, + player2Score: number = 2 +): VisibleGameState { + return { + game_id: 'test-game-123', + viewer_id: viewerId, + players: { + 'player-1': { + player_id: 'player-1', + is_current_player: true, + deck_count: 20, + hand: { count: 5, cards: [], zone_type: 'hand' }, + prizes_count: 3, + energy_deck_count: 10, + active: { count: 1, cards: [], zone_type: 'active' }, + bench: { count: 3, cards: [], zone_type: 'bench' }, + discard: { count: 10, cards: [], zone_type: 'discard' }, + energy_zone: { count: 5, cards: [], zone_type: 'energy_zone' }, + score: player1Score, + gx_attack_used: false, + vstar_power_used: false, + }, + 'player-2': { + player_id: 'player-2', + is_current_player: false, + deck_count: 15, + hand: { count: 4, cards: [], zone_type: 'hand' }, + prizes_count: 4, + energy_deck_count: 8, + active: { count: 1, cards: [], zone_type: 'active' }, + bench: { count: 2, cards: [], zone_type: 'bench' }, + discard: { count: 8, cards: [], zone_type: 'discard' }, + energy_zone: { count: 3, cards: [], zone_type: 'energy_zone' }, + score: player2Score, + gx_attack_used: false, + vstar_power_used: false, + }, + }, + current_player_id: 'player-1', + turn_number: turnNumber, + phase: 'main', + is_my_turn: true, + winner_id: winnerId, + end_reason: endReason, + stadium_in_play: null, + stadium_owner_id: null, + forced_action_player: null, + forced_action_type: null, + forced_action_reason: null, + card_registry: {}, + } +} + +describe('GameOverModal', () => { + let wrapper: VueWrapper + + beforeEach(() => { + setActivePinia(createPinia()) + mockPush.mockClear() + }) + + afterEach(() => { + if (wrapper) { + wrapper.unmount() + } + }) + + it('does not show when game is not over', () => { + /** + * Test that the modal is hidden when the game is ongoing. + * + * The modal should only appear when gameStore.isGameOver is true + * (i.e., when winner_id is set). This prevents premature display. + */ + const gameStore = useGameStore() + const ongoingState = createMockGameState('player-1', null, null) + ongoingState.winner_id = null // Explicitly no winner + gameStore.setGameState(ongoingState) + + wrapper = mount(GameOverModal, { + global: { + stubs: { + Teleport: true, + }, + }, + }) + + expect(wrapper.find('[role="dialog"]').exists()).toBe(false) + }) + + it('shows victory screen when viewer wins', async () => { + /** + * Test that the modal displays a victory screen for the winning player. + * + * When the viewing player is the winner, the modal should: + * - Display "Victory!" title + * - Use success styling (green colors) + * - Show celebratory elements (trophy icon, animations) + */ + const gameStore = useGameStore() + const victoryState = createMockGameState('player-1', 'player-1', 'prizes_taken') + gameStore.setGameState(victoryState) + + wrapper = mount(GameOverModal, { + global: { + stubs: { + Teleport: true, + }, + }, + }) + + await nextTick() + + const dialog = wrapper.find('[role="dialog"]') + expect(dialog.exists()).toBe(true) + + const title = wrapper.find('#game-over-title') + expect(title.text()).toBe('Victory!') + expect(title.classes()).toContain('text-success') + + // Trophy icon for victory + expect(wrapper.html()).toContain('πŸ†') + }) + + it('shows defeat screen when opponent wins', async () => { + /** + * Test that the modal displays a defeat screen when the opponent wins. + * + * When the viewing player loses, the modal should: + * - Display "Defeat" title + * - Use error styling (red colors) + * - Show subdued elements (no celebration) + */ + const gameStore = useGameStore() + const defeatState = createMockGameState('player-1', 'player-2', 'prizes_taken') + gameStore.setGameState(defeatState) + + wrapper = mount(GameOverModal, { + global: { + stubs: { + Teleport: true, + }, + }, + }) + + await nextTick() + + const title = wrapper.find('#game-over-title') + expect(title.text()).toBe('Defeat') + expect(title.classes()).toContain('text-error') + + // Sad icon for defeat + expect(wrapper.html()).toContain('πŸ˜”') + }) + + it('shows draw screen when game ends in draw', async () => { + /** + * Test that the modal displays a draw screen when there's no winner. + * + * When winner_id is empty string and end_reason is 'draw', the modal should: + * - Display "Draw" title + * - Use neutral styling + * - Show neutral icon + * + * Note: Backend sets winner_id to "" (empty string) for draws, not null. + */ + const gameStore = useGameStore() + const drawState = createMockGameState('player-1', '', 'draw') + gameStore.setGameState(drawState) + + wrapper = mount(GameOverModal, { + global: { + stubs: { + Teleport: true, + }, + }, + }) + + await nextTick() + + const dialog = wrapper.find('[role="dialog"]') + expect(dialog.exists()).toBe(true) + + const title = wrapper.find('#game-over-title') + expect(title.exists()).toBe(true) + expect(title.text()).toBe('Draw') + expect(title.classes()).toContain('text-text-muted') + + // Handshake icon for draw + expect(wrapper.html()).toContain('🀝') + }) + + it('displays correct end reason text for prizes_taken', async () => { + /** + * Test that the end reason is displayed with correct human-readable text. + * + * The 'prizes_taken' reason should display as "All prize cards claimed", + * which is more user-friendly than the internal enum value. + */ + const gameStore = useGameStore() + const state = createMockGameState('player-1', 'player-1', 'prizes_taken') + gameStore.setGameState(state) + + wrapper = mount(GameOverModal, { + global: { + stubs: { + Teleport: true, + }, + }, + }) + + await nextTick() + + expect(wrapper.text()).toContain('All prize cards claimed') + }) + + it('displays correct end reason text for deck_empty', async () => { + /** + * Test that the deck_empty end reason displays appropriate text. + * + * This reason occurs when a player cannot draw at the start of their turn. + */ + const gameStore = useGameStore() + const state = createMockGameState('player-1', 'player-2', 'deck_empty') + gameStore.setGameState(state) + + wrapper = mount(GameOverModal, { + global: { + stubs: { + Teleport: true, + }, + }, + }) + + await nextTick() + + expect(wrapper.text()).toContain('Deck ran out of cards') + }) + + it('displays correct end reason text for resignation', async () => { + /** + * Test that the resignation end reason displays appropriate text. + * + * This reason occurs when a player explicitly resigns from the game. + */ + const gameStore = useGameStore() + const state = createMockGameState('player-1', 'player-1', 'resignation') + gameStore.setGameState(state) + + wrapper = mount(GameOverModal, { + global: { + stubs: { + Teleport: true, + }, + }, + }) + + await nextTick() + + expect(wrapper.text()).toContain('Opponent resigned') + }) + + it('displays correct end reason text for no_pokemon', async () => { + /** + * Test that the no_pokemon end reason displays appropriate text. + * + * This reason occurs when a player has no Pokemon in play + * (active or bench) at any point. + */ + const gameStore = useGameStore() + const state = createMockGameState('player-1', 'player-1', 'no_pokemon') + gameStore.setGameState(state) + + wrapper = mount(GameOverModal, { + global: { + stubs: { + Teleport: true, + }, + }, + }) + + await nextTick() + + expect(wrapper.text()).toContain('Opponent has no Pokemon in play') + }) + + it('displays correct end reason text for timeout', async () => { + /** + * Test that the timeout end reason displays appropriate text. + * + * This reason occurs when the turn timer expires. + */ + const gameStore = useGameStore() + const state = createMockGameState('player-1', 'player-2', 'timeout') + gameStore.setGameState(state) + + wrapper = mount(GameOverModal, { + global: { + stubs: { + Teleport: true, + }, + }, + }) + + await nextTick() + + expect(wrapper.text()).toContain('Turn timer expired') + }) + + it('displays game statistics correctly', async () => { + /** + * Test that game statistics are displayed accurately. + * + * The modal should show: + * - Total turns played + * - Viewer's prize count (score) + * - Opponent's prize count (score) + * + * These stats help players understand how the game progressed. + */ + const gameStore = useGameStore() + const state = createMockGameState('player-1', 'player-1', 'prizes_taken', 15, 6, 2) + gameStore.setGameState(state) + + wrapper = mount(GameOverModal, { + global: { + stubs: { + Teleport: true, + }, + }, + }) + + await nextTick() + + const text = wrapper.text() + expect(text).toContain('Turns Played') + expect(text).toContain('15') // turn count + + expect(text).toContain('Your Prizes') + expect(text).toContain('6') // my score + + expect(text).toContain("Opponent's Prizes") + expect(text).toContain('2') // opponent score + }) + + it('navigates to lobby when return button clicked', async () => { + /** + * Test that clicking the return button navigates to the play lobby. + * + * The return button should: + * - Clear the game state from the store + * - Navigate to the /play route + * - Trigger the close animation + */ + const gameStore = useGameStore() + const state = createMockGameState('player-1', 'player-1', 'prizes_taken') + gameStore.setGameState(state) + + wrapper = mount(GameOverModal, { + global: { + stubs: { + Teleport: true, + }, + }, + }) + + await nextTick() + + const returnButton = wrapper.find('button') + expect(returnButton.text()).toContain('Return to Lobby') + + await returnButton.trigger('click') + + // Wait for animation delay + await new Promise(resolve => setTimeout(resolve, 250)) + + expect(mockPush).toHaveBeenCalledWith('/play') + expect(gameStore.gameState).toBeNull() + }) + + it('closes modal when Escape key is pressed', async () => { + /** + * Test that pressing Escape triggers the close/return action. + * + * This provides keyboard accessibility and allows users to + * quickly dismiss the modal without clicking the button. + */ + const gameStore = useGameStore() + const state = createMockGameState('player-1', 'player-1', 'prizes_taken') + gameStore.setGameState(state) + + wrapper = mount(GameOverModal, { + global: { + stubs: { + Teleport: true, + }, + }, + }) + + await nextTick() + + const dialog = wrapper.find('[role="dialog"]') + await dialog.trigger('keydown', { key: 'Escape' }) + + // Wait for animation delay + await new Promise(resolve => setTimeout(resolve, 250)) + + expect(mockPush).toHaveBeenCalledWith('/play') + expect(gameStore.gameState).toBeNull() + }) + + it('focuses return button when modal opens', async () => { + /** + * Test that the return button receives focus when the modal opens. + * + * This ensures keyboard users can immediately interact with the + * primary action without needing to tab through the modal. + * + * Note: Focus behavior is challenging to test with Teleport stubs, + * so we verify the button exists and has the ref attribute that + * would enable focus in a real browser environment. + */ + const gameStore = useGameStore() + const state = createMockGameState('player-1', 'player-1', 'prizes_taken') + + // Set game over state first + gameStore.setGameState(state) + + wrapper = mount(GameOverModal, { + attachTo: document.body, + global: { + stubs: { + Teleport: true, + }, + }, + }) + + await nextTick() + await nextTick() // Extra tick for watcher + + const returnButton = wrapper.find('button') + expect(returnButton.exists()).toBe(true) + expect(returnButton.text()).toContain('Return to Lobby') + + // In a real browser, the returnButtonRef would receive focus + // This is difficult to test with stubbed Teleport, so we verify + // the button structure is correct + }) + + it('applies correct styling based on victory state', async () => { + /** + * Test that CSS classes are applied correctly based on game outcome. + * + * Victory should use success colors, defeat should use error colors, + * and draws should use neutral colors. This provides visual feedback + * that matches the game result. + */ + const gameStore = useGameStore() + + // Test victory styling + const victoryState = createMockGameState('player-1', 'player-1', 'prizes_taken') + gameStore.setGameState(victoryState) + + wrapper = mount(GameOverModal, { + global: { + stubs: { + Teleport: true, + }, + }, + }) + + await nextTick() + + let modalContainer = wrapper.find('.rounded-3xl') + expect(modalContainer.classes()).toContain('border-success') + + let returnButton = wrapper.find('button') + expect(returnButton.classes()).toContain('bg-success') + + // Test defeat styling + const defeatState = createMockGameState('player-1', 'player-2', 'prizes_taken') + gameStore.setGameState(defeatState) + await nextTick() + + modalContainer = wrapper.find('.rounded-3xl') + expect(modalContainer.classes()).toContain('border-error') + + returnButton = wrapper.find('button') + expect(returnButton.classes()).toContain('bg-primary') // Not success for defeat + }) +}) diff --git a/frontend/src/components/game/GameOverModal.vue b/frontend/src/components/game/GameOverModal.vue new file mode 100644 index 0000000..e999808 --- /dev/null +++ b/frontend/src/components/game/GameOverModal.vue @@ -0,0 +1,295 @@ + + + diff --git a/frontend/src/components/game/GameOverlay.spec.ts b/frontend/src/components/game/GameOverlay.spec.ts new file mode 100644 index 0000000..65b3b81 --- /dev/null +++ b/frontend/src/components/game/GameOverlay.spec.ts @@ -0,0 +1,186 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { mount, VueWrapper } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import GameOverlay from './GameOverlay.vue' +import { useGameStore } from '@/stores/game' +import type { VisibleGameState } from '@/types' + +describe('GameOverlay', () => { + let wrapper: VueWrapper + let gameStore: ReturnType + + beforeEach(() => { + // Set up fresh Pinia instance for each test + setActivePinia(createPinia()) + gameStore = useGameStore() + }) + + it('renders overlay container', () => { + /** + * Test that the overlay container renders with correct structure. + * + * The overlay container must be positioned over the Phaser canvas + * with proper z-index and pointer-events handling. + */ + wrapper = mount(GameOverlay) + + const container = wrapper.find('[data-testid="game-overlay"]') + expect(container.exists()).toBe(true) + expect(container.classes()).toContain('game-overlay-container') + }) + + it('provides game state to child components', () => { + /** + * Test that game state is provided via inject for child components. + * + * Child overlay components need access to game state without + * prop drilling through multiple layers. + */ + const mockState: Partial = { + game_id: 'test-game', + current_turn_player_id: 'player-1', + phase: 'MAIN' + } + gameStore.setGameState(mockState as VisibleGameState) + + // Create a test child component that injects game state + const TestChild = { + template: '
{{ gameState?.game_id }}
', + inject: ['gameState'], + } + + wrapper = mount(GameOverlay, { + slots: { + default: TestChild + } + }) + + expect(wrapper.text()).toContain('test-game') + }) + + it('has pointer-events none on container', () => { + /** + * Test that container has pointer-events: none. + * + * This allows clicks to pass through to the Phaser canvas + * in areas not covered by interactive overlay components. + */ + wrapper = mount(GameOverlay) + + const container = wrapper.find('.game-overlay-container') + + // Note: In JSDOM, computed styles won't work as expected, + // but we can verify the correct class is applied + expect(container.classes()).toContain('game-overlay-container') + }) + + it('renders all overlay zones', () => { + /** + * Test that all predefined overlay zones are rendered. + * + * Each zone is positioned for a specific type of overlay component + * (turn indicator, phase actions, attack menu, etc.). + */ + wrapper = mount(GameOverlay) + + const zones = wrapper.findAll('.overlay-zone') + + // Should have 6 zones: + // - top-center (turn indicator) + // - bottom-right (phase actions) + // - center (attack menu) + // - fullscreen (forced action) + // - fullscreen (game over) + // - default (additional slot) + expect(zones.length).toBeGreaterThanOrEqual(6) + }) + + it('renders slotted content in correct zones', () => { + /** + * Test that slot content is rendered in the correct positioned zones. + * + * Each named slot should render within its designated zone with + * proper positioning. + */ + wrapper = mount(GameOverlay, { + slots: { + 'turn-indicator': '
Turn Info
', + 'phase-actions': '
Actions
', + 'attack-menu': '
Attack
', + } + }) + + // Verify slotted content is rendered + expect(wrapper.find('.test-turn-indicator').exists()).toBe(true) + expect(wrapper.find('.test-phase-actions').exists()).toBe(true) + expect(wrapper.find('.test-attack-menu').exists()).toBe(true) + + // Verify content is in correct zones + const topCenterZone = wrapper.find('.overlay-zone--top-center') + expect(topCenterZone.find('.test-turn-indicator').exists()).toBe(true) + + const bottomRightZone = wrapper.find('.overlay-zone--bottom-right') + expect(bottomRightZone.find('.test-phase-actions').exists()).toBe(true) + + const centerZone = wrapper.find('.overlay-zone--center') + expect(centerZone.find('.test-attack-menu').exists()).toBe(true) + }) + + it('provides all required computed properties to children', () => { + /** + * Test that all required game state properties are provided. + * + * Child components expect gameState, isMyTurn, currentPhase, + * and isGameOver to be available via inject. + */ + const mockState: Partial = { + game_id: 'test-game', + current_turn_player_id: 'player-1', + phase: 'ATTACK', + my_player_id: 'player-1', + is_my_turn: true, // Backend calculates this + winner_id: null, // Game not over + } + gameStore.setGameState(mockState as VisibleGameState) + + const TestChild = { + template: ` +
+ {{ gameState?.game_id }} + {{ isMyTurn }} + {{ currentPhase }} + {{ isGameOver }} +
+ `, + inject: ['gameState', 'isMyTurn', 'currentPhase', 'isGameOver'], + } + + wrapper = mount(GameOverlay, { + slots: { + default: TestChild + } + }) + + expect(wrapper.find('.game-state').text()).toBe('test-game') + expect(wrapper.find('.is-my-turn').text()).toBe('true') + expect(wrapper.find('.current-phase').text()).toBe('ATTACK') + expect(wrapper.find('.is-game-over').text()).toBe('false') + }) + + it('allows empty slots to be optional', () => { + /** + * Test that the component renders correctly with no slotted content. + * + * Overlay zones should exist even when empty, ready to receive + * content from parent components. + */ + wrapper = mount(GameOverlay) + + // Component should render without errors + expect(wrapper.find('[data-testid="game-overlay"]').exists()).toBe(true) + + // All zones should exist but be empty + const zones = wrapper.findAll('.overlay-zone') + expect(zones.length).toBeGreaterThan(0) + }) +}) diff --git a/frontend/src/components/game/GameOverlay.vue b/frontend/src/components/game/GameOverlay.vue new file mode 100644 index 0000000..99801c0 --- /dev/null +++ b/frontend/src/components/game/GameOverlay.vue @@ -0,0 +1,139 @@ + + + + + diff --git a/frontend/src/components/game/PhaseActions.spec.ts b/frontend/src/components/game/PhaseActions.spec.ts new file mode 100644 index 0000000..04f36d5 --- /dev/null +++ b/frontend/src/components/game/PhaseActions.spec.ts @@ -0,0 +1,519 @@ +/** + * Tests for PhaseActions component. + * + * Verifies that the phase actions component correctly: + * - Shows appropriate buttons based on current phase + * - Enables/disables buttons based on turn and game state + * - Dispatches actions correctly when buttons are clicked + * - Handles status conditions that prevent certain actions + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { computed, ref } from 'vue' +import PhaseActions from './PhaseActions.vue' +import type { VisibleGameState, TurnPhase, CardInstance } from '@/types' + +// Mock the useGameActions composable +const mockEndTurn = vi.fn() +const mockRetreat = vi.fn() +const mockIsPending = ref(false) + +vi.mock('@/composables/useGameActions', () => ({ + useGameActions: () => ({ + endTurn: mockEndTurn, + retreat: mockRetreat, + isPending: computed(() => mockIsPending.value), + }), +})) + +/** + * Helper function to create a mock card instance. + */ +function createMockCard( + instanceId: string, + definitionId: string, + statusConditions: string[] = [] +): CardInstance { + return { + instance_id: instanceId, + definition_id: definitionId, + damage: 0, + attached_energy: [], + attached_tools: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + status_conditions: statusConditions as any, + ability_uses_this_turn: {}, + evolution_turn: null, + } +} + +/** + * Helper function to create a mock game state. + */ +function createMockGameState( + phase: TurnPhase = 'main', + isMyTurn: boolean = true, + hasActive: boolean = true, + hasBench: boolean = true, + activeStatusConditions: string[] = [] +): VisibleGameState { + const activePokemon = hasActive + ? createMockCard('active-1', 'pikachu', activeStatusConditions) + : null + const benchPokemon = hasBench ? [createMockCard('bench-1', 'charmander')] : [] + + return { + game_id: 'test-game-123', + viewer_id: 'player-1', + players: { + 'player-1': { + player_id: 'player-1', + is_current_player: isMyTurn, + deck_count: 40, + hand: { count: 7, cards: [], zone_type: 'hand' }, + prizes_count: 6, + energy_deck_count: 10, + active: { + count: hasActive ? 1 : 0, + cards: activePokemon ? [activePokemon] : [], + zone_type: 'active', + }, + bench: { + count: benchPokemon.length, + cards: benchPokemon, + zone_type: 'bench', + }, + discard: { count: 0, cards: [], zone_type: 'discard' }, + energy_zone: { count: 0, cards: [], zone_type: 'energy_zone' }, + score: 0, + gx_attack_used: false, + vstar_power_used: false, + }, + 'player-2': { + player_id: 'player-2', + is_current_player: !isMyTurn, + deck_count: 40, + hand: { count: 7, cards: [], zone_type: 'hand' }, + prizes_count: 6, + energy_deck_count: 10, + active: { count: 1, cards: [createMockCard('opp-active', 'bulbasaur')], zone_type: 'active' }, + bench: { count: 0, cards: [], zone_type: 'bench' }, + discard: { count: 0, cards: [], zone_type: 'discard' }, + energy_zone: { count: 0, cards: [], zone_type: 'energy_zone' }, + score: 0, + gx_attack_used: false, + vstar_power_used: false, + }, + }, + current_player_id: isMyTurn ? 'player-1' : 'player-2', + turn_number: 1, + phase, + is_my_turn: isMyTurn, + winner_id: null, + end_reason: null, + stadium_in_play: null, + stadium_owner_id: null, + forced_action_player: null, + forced_action_type: null, + forced_action_reason: null, + card_registry: {}, + } +} + +/** + * Mount PhaseActions with proper provide/inject context. + */ +function mountPhaseActions( + phase: TurnPhase = 'main', + isMyTurn: boolean = true, + hasActive: boolean = true, + hasBench: boolean = true, + activeStatusConditions: string[] = [] +) { + const gameState = ref(createMockGameState(phase, isMyTurn, hasActive, hasBench, activeStatusConditions)) + + return mount(PhaseActions, { + global: { + provide: { + gameState: computed(() => gameState.value), + isMyTurn: computed(() => gameState.value.is_my_turn), + currentPhase: computed(() => gameState.value.phase), + }, + }, + }) +} + +describe('PhaseActions', () => { + beforeEach(() => { + // Reset mocks before each test + mockEndTurn.mockReset() + mockRetreat.mockReset() + mockIsPending.value = false + }) + + describe('Button Visibility', () => { + it('shows End Turn button during MAIN phase when it is your turn', () => { + /** + * Test that the End Turn button appears during the main phase. + * + * The main phase is when players can take most actions, and they + * need a way to signal they're done and ready to attack or end. + */ + const wrapper = mountPhaseActions('main', true) + const endTurnButton = wrapper.find('[data-testid="end-turn-button"]') + expect(endTurnButton.exists()).toBe(true) + }) + + it('hides End Turn button when not in MAIN phase', () => { + /** + * Test that End Turn button is hidden during non-main phases. + * + * Players cannot end turn during other phases as the game flow + * is controlled by the server during those phases. + */ + const wrapper = mountPhaseActions('attack', true) + const endTurnButton = wrapper.find('[data-testid="end-turn-button"]') + expect(endTurnButton.exists()).toBe(false) + }) + + it('hides End Turn button when it is opponent\'s turn', () => { + /** + * Test that End Turn button is hidden during opponent's turn. + * + * Players cannot end the opponent's turn - they must wait for + * their own turn to take actions. + */ + const wrapper = mountPhaseActions('main', false) + const endTurnButton = wrapper.find('[data-testid="end-turn-button"]') + expect(endTurnButton.exists()).toBe(false) + }) + + it('shows Retreat button during MAIN phase when active and bench exist', () => { + /** + * Test that Retreat button appears when retreat is possible. + * + * Retreating requires both an active Pokemon and at least one + * benched Pokemon to switch with. + */ + const wrapper = mountPhaseActions('main', true, true, true) + const retreatButton = wrapper.find('[data-testid="retreat-button"]') + expect(retreatButton.exists()).toBe(true) + }) + + it('hides Retreat button when no active Pokemon', () => { + /** + * Test that Retreat button is hidden without an active Pokemon. + * + * Cannot retreat if there's no active Pokemon to retreat. + */ + const wrapper = mountPhaseActions('main', true, false, true) + const retreatButton = wrapper.find('[data-testid="retreat-button"]') + expect(retreatButton.exists()).toBe(false) + }) + + it('hides Retreat button when bench is empty', () => { + /** + * Test that Retreat button is hidden when bench is empty. + * + * Cannot retreat if there's no benched Pokemon to switch with. + */ + const wrapper = mountPhaseActions('main', true, true, false) + const retreatButton = wrapper.find('[data-testid="retreat-button"]') + expect(retreatButton.exists()).toBe(false) + }) + + it('hides Retreat button when not in MAIN phase', () => { + /** + * Test that Retreat can only happen during main phase. + * + * Retreat is a main phase action - it cannot be done during + * attack or other phases. + */ + const wrapper = mountPhaseActions('attack', true, true, true) + const retreatButton = wrapper.find('[data-testid="retreat-button"]') + expect(retreatButton.exists()).toBe(false) + }) + + it('hides all buttons during opponent\'s turn', () => { + /** + * Test that no action buttons appear during opponent's turn. + * + * Players cannot take any phase actions during the opponent's turn. + */ + const wrapper = mountPhaseActions('main', false, true, true) + const container = wrapper.find('[data-testid="phase-actions"]') + expect(container.exists()).toBe(false) + }) + }) + + describe('Button States', () => { + it('enables End Turn button when it is your turn', () => { + /** + * Test that End Turn button is enabled during your turn. + * + * Players should be able to end their turn when it's active. + */ + const wrapper = mountPhaseActions('main', true) + const endTurnButton = wrapper.find('[data-testid="end-turn-button"]') + expect(endTurnButton.attributes('disabled')).toBeUndefined() + }) + + it('disables End Turn button when action is pending', () => { + /** + * Test that End Turn is disabled during action processing. + * + * Prevents multiple simultaneous actions that could cause + * race conditions or invalid game states. + */ + mockIsPending.value = true + const wrapper = mountPhaseActions('main', true) + const endTurnButton = wrapper.find('[data-testid="end-turn-button"]') + expect(endTurnButton.attributes('disabled')).toBeDefined() + }) + + it('disables Retreat button when active Pokemon is paralyzed', () => { + /** + * Test that paralyzed Pokemon cannot retreat. + * + * Paralyzed status prevents retreat - this is a core TCG rule + * that must be enforced by the UI. + */ + const wrapper = mountPhaseActions('main', true, true, true, ['paralyzed']) + const retreatButton = wrapper.find('[data-testid="retreat-button"]') + expect(retreatButton.attributes('disabled')).toBeDefined() + }) + + it('disables Retreat button when active Pokemon is asleep', () => { + /** + * Test that sleeping Pokemon cannot retreat. + * + * Sleep status prevents retreat - another core TCG rule. + */ + const wrapper = mountPhaseActions('main', true, true, true, ['asleep']) + const retreatButton = wrapper.find('[data-testid="retreat-button"]') + expect(retreatButton.attributes('disabled')).toBeDefined() + }) + + it('enables Retreat button when active Pokemon has other status conditions', () => { + /** + * Test that non-blocking status conditions don't prevent retreat. + * + * Only paralyzed and asleep prevent retreat - other conditions + * like poisoned or burned do not. + */ + const wrapper = mountPhaseActions('main', true, true, true, ['poisoned', 'burned']) + const retreatButton = wrapper.find('[data-testid="retreat-button"]') + expect(retreatButton.attributes('disabled')).toBeUndefined() + }) + + it('disables Retreat button when action is pending', () => { + /** + * Test that Retreat is disabled during action processing. + * + * Prevents multiple simultaneous actions. + */ + mockIsPending.value = true + const wrapper = mountPhaseActions('main', true, true, true) + const retreatButton = wrapper.find('[data-testid="retreat-button"]') + expect(retreatButton.attributes('disabled')).toBeDefined() + }) + }) + + describe('Action Dispatching', () => { + it('calls endTurn when End Turn button is clicked', async () => { + /** + * Test that clicking End Turn dispatches the action. + * + * The button must trigger the actual game action to end the turn. + */ + const wrapper = mountPhaseActions('main', true) + const endTurnButton = wrapper.find('[data-testid="end-turn-button"]') + + await endTurnButton.trigger('click') + + expect(mockEndTurn).toHaveBeenCalledOnce() + }) + + it('emits retreatRequested when Retreat button is clicked', async () => { + /** + * Test that clicking Retreat emits the event. + * + * Retreat requires additional UI (selecting which bench Pokemon), + * so it emits an event for the parent to handle. + */ + const wrapper = mountPhaseActions('main', true, true, true) + const retreatButton = wrapper.find('[data-testid="retreat-button"]') + + await retreatButton.trigger('click') + + expect(wrapper.emitted('retreatRequested')).toBeTruthy() + expect(wrapper.emitted('retreatRequested')).toHaveLength(1) + }) + + it('does not call endTurn when button is disabled', async () => { + /** + * Test that disabled End Turn button doesn't dispatch action. + * + * Disabled buttons should not respond to clicks to prevent + * invalid actions. + */ + mockIsPending.value = true + const wrapper = mountPhaseActions('main', true) + const endTurnButton = wrapper.find('[data-testid="end-turn-button"]') + + await endTurnButton.trigger('click') + + expect(mockEndTurn).not.toHaveBeenCalled() + }) + + it('handles endTurn errors gracefully', async () => { + /** + * Test that errors during action dispatch are handled. + * + * Network errors or server rejections should be caught and logged + * without crashing the component. + */ + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + mockEndTurn.mockRejectedValueOnce(new Error('Network error')) + + const wrapper = mountPhaseActions('main', true) + const endTurnButton = wrapper.find('[data-testid="end-turn-button"]') + + await endTurnButton.trigger('click') + await wrapper.vm.$nextTick() + + expect(consoleError).toHaveBeenCalledWith( + 'Failed to end turn:', + expect.any(Error) + ) + + consoleError.mockRestore() + }) + }) + + describe('Game Over State', () => { + it('disables End Turn button when game is over', () => { + /** + * Test that End Turn is disabled when game has ended. + * + * No actions should be possible after the game ends. + */ + const gameState = ref(createMockGameState('main', true)) + gameState.value.winner_id = 'player-1' + + const wrapper = mount(PhaseActions, { + global: { + provide: { + gameState: computed(() => gameState.value), + isMyTurn: computed(() => gameState.value.is_my_turn), + currentPhase: computed(() => gameState.value.phase), + }, + }, + }) + + const endTurnButton = wrapper.find('[data-testid="end-turn-button"]') + expect(endTurnButton.attributes('disabled')).toBeDefined() + }) + + it('disables Retreat button when game is over', () => { + /** + * Test that Retreat is disabled when game has ended. + * + * No actions should be possible after the game ends. + */ + const gameState = ref(createMockGameState('main', true, true, true)) + gameState.value.winner_id = 'player-2' + + const wrapper = mount(PhaseActions, { + global: { + provide: { + gameState: computed(() => gameState.value), + isMyTurn: computed(() => gameState.value.is_my_turn), + currentPhase: computed(() => gameState.value.phase), + }, + }, + }) + + const retreatButton = wrapper.find('[data-testid="retreat-button"]') + expect(retreatButton.attributes('disabled')).toBeDefined() + }) + }) + + describe('Error Handling', () => { + it('throws error when used outside GameOverlay (missing inject)', () => { + /** + * Test that the component fails fast with clear error when missing context. + * + * This component requires GameOverlay's provide/inject context. + * Failing fast helps developers catch integration errors early. + */ + expect(() => { + mount(PhaseActions) + }).toThrow('PhaseActions must be used within GameOverlay') + }) + }) + + describe('Reactivity', () => { + it('shows buttons when phase changes to MAIN', async () => { + /** + * Test that buttons appear when transitioning to main phase. + * + * Component must react to phase changes to show appropriate actions. + */ + const gameState = ref(createMockGameState('draw', true, true, true)) + + const wrapper = mount(PhaseActions, { + global: { + provide: { + gameState: computed(() => gameState.value), + isMyTurn: computed(() => gameState.value.is_my_turn), + currentPhase: computed(() => gameState.value.phase), + }, + }, + }) + + // Initially no buttons during draw phase + expect(wrapper.find('[data-testid="phase-actions"]').exists()).toBe(false) + + // Change to main phase + gameState.value = createMockGameState('main', true, true, true) + await wrapper.vm.$nextTick() + + // Now buttons should appear + expect(wrapper.find('[data-testid="phase-actions"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="end-turn-button"]').exists()).toBe(true) + }) + + it('enables Retreat button when status is removed', async () => { + /** + * Test that Retreat becomes enabled when blocking status is cleared. + * + * Status conditions can change during gameplay - button states + * must update reactively. + */ + const gameState = ref(createMockGameState('main', true, true, true, ['paralyzed'])) + + const wrapper = mount(PhaseActions, { + global: { + provide: { + gameState: computed(() => gameState.value), + isMyTurn: computed(() => gameState.value.is_my_turn), + currentPhase: computed(() => gameState.value.phase), + }, + }, + }) + + // Initially disabled due to paralysis + let retreatButton = wrapper.find('[data-testid="retreat-button"]') + expect(retreatButton.attributes('disabled')).toBeDefined() + + // Remove paralysis + gameState.value = createMockGameState('main', true, true, true, []) + await wrapper.vm.$nextTick() + + // Now should be enabled + retreatButton = wrapper.find('[data-testid="retreat-button"]') + expect(retreatButton.attributes('disabled')).toBeUndefined() + }) + }) +}) diff --git a/frontend/src/components/game/PhaseActions.vue b/frontend/src/components/game/PhaseActions.vue new file mode 100644 index 0000000..1921f77 --- /dev/null +++ b/frontend/src/components/game/PhaseActions.vue @@ -0,0 +1,319 @@ + + + + + diff --git a/frontend/src/components/game/TurnIndicator.spec.ts b/frontend/src/components/game/TurnIndicator.spec.ts new file mode 100644 index 0000000..7d4542c --- /dev/null +++ b/frontend/src/components/game/TurnIndicator.spec.ts @@ -0,0 +1,292 @@ +/** + * Tests for TurnIndicator component. + * + * Verifies that the turn indicator correctly displays: + * - Current phase label + * - Turn ownership (your turn vs opponent's turn) + * - Turn number + * - Appropriate styling based on turn state + */ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import { computed, ref } from 'vue' +import TurnIndicator from './TurnIndicator.vue' +import type { VisibleGameState, TurnPhase } from '@/types' + +/** + * Helper function to create a mock game state. + */ +function createMockGameState( + phase: TurnPhase = 'main', + turnNumber: number = 1, + isMyTurn: boolean = true +): VisibleGameState { + return { + game_id: 'test-game-123', + viewer_id: 'player-1', + players: { + 'player-1': { + player_id: 'player-1', + is_current_player: isMyTurn, + deck_count: 40, + hand: { count: 7, cards: [], zone_type: 'hand' }, + prizes_count: 6, + energy_deck_count: 10, + active: { count: 0, cards: [], zone_type: 'active' }, + bench: { count: 0, cards: [], zone_type: 'bench' }, + discard: { count: 0, cards: [], zone_type: 'discard' }, + energy_zone: { count: 0, cards: [], zone_type: 'energy_zone' }, + score: 0, + gx_attack_used: false, + vstar_power_used: false, + }, + 'player-2': { + player_id: 'player-2', + is_current_player: !isMyTurn, + deck_count: 40, + hand: { count: 7, cards: [], zone_type: 'hand' }, + prizes_count: 6, + energy_deck_count: 10, + active: { count: 0, cards: [], zone_type: 'active' }, + bench: { count: 0, cards: [], zone_type: 'bench' }, + discard: { count: 0, cards: [], zone_type: 'discard' }, + energy_zone: { count: 0, cards: [], zone_type: 'energy_zone' }, + score: 0, + gx_attack_used: false, + vstar_power_used: false, + }, + }, + current_player_id: isMyTurn ? 'player-1' : 'player-2', + turn_number: turnNumber, + phase, + is_my_turn: isMyTurn, + winner_id: null, + end_reason: null, + stadium_in_play: null, + stadium_owner_id: null, + forced_action_player: null, + forced_action_type: null, + forced_action_reason: null, + card_registry: {}, + } +} + +/** + * Mount TurnIndicator with proper provide/inject context. + */ +function mountTurnIndicator( + phase: TurnPhase = 'main', + turnNumber: number = 1, + isMyTurn: boolean = true +) { + const gameState = ref(createMockGameState(phase, turnNumber, isMyTurn)) + + return mount(TurnIndicator, { + global: { + provide: { + gameState: computed(() => gameState.value), + isMyTurn: computed(() => gameState.value.is_my_turn), + currentPhase: computed(() => gameState.value.phase), + }, + }, + }) +} + +describe('TurnIndicator', () => { + it('displays "Your Turn" when it is the player\'s turn', () => { + /** + * Test that the component correctly indicates when it's the player's turn. + * + * Players need clear visual feedback about whose turn it is to know + * when they can take actions. + */ + const wrapper = mountTurnIndicator('main', 1, true) + const turnOwner = wrapper.find('[data-testid="turn-owner"]') + expect(turnOwner.text()).toBe('Your Turn') + }) + + it('displays "Opponent\'s Turn" when it is the opponent\'s turn', () => { + /** + * Test that the component correctly indicates when it's the opponent's turn. + * + * Clear indication of waiting states helps prevent player confusion + * and accidental attempts to act out of turn. + */ + const wrapper = mountTurnIndicator('main', 1, false) + const turnOwner = wrapper.find('[data-testid="turn-owner"]') + expect(turnOwner.text()).toBe("Opponent's Turn") + }) + + it('displays the current phase label correctly', () => { + /** + * Test that phase labels are displayed correctly for each phase. + * + * Phase indicators help players understand what actions are available + * and what's happening in the game flow. + */ + const phases: TurnPhase[] = ['setup', 'draw', 'main', 'attack', 'end'] + const expectedLabels = ['Setup', 'Draw', 'Main', 'Attack', 'End'] + + phases.forEach((phase, index) => { + const wrapper = mountTurnIndicator(phase, 1, true) + const phaseBadge = wrapper.find('[data-testid="phase-badge"]') + expect(phaseBadge.text()).toBe(expectedLabels[index]) + }) + }) + + it('displays the current turn number', () => { + /** + * Test that the turn number is displayed correctly. + * + * Turn tracking helps players gauge game progression and is useful + * for effects that trigger on specific turns. + */ + const wrapper = mountTurnIndicator('main', 5, true) + const turnNumber = wrapper.find('[data-testid="turn-number"]') + expect(turnNumber.text()).toBe('Turn 5') + }) + + it('applies "my-turn" class when it is the player\'s turn', () => { + /** + * Test that the component applies the correct CSS class for styling. + * + * The my-turn class triggers accent color highlighting to draw + * attention when the player can act. + */ + const wrapper = mountTurnIndicator('main', 1, true) + const container = wrapper.find('[data-testid="turn-indicator"]') + expect(container.classes()).toContain('turn-indicator--my-turn') + expect(container.classes()).not.toContain('turn-indicator--opponent-turn') + }) + + it('applies "opponent-turn" class when it is the opponent\'s turn', () => { + /** + * Test that the component applies the correct CSS class for opponent turn. + * + * The opponent-turn class triggers muted styling to indicate + * a waiting state. + */ + const wrapper = mountTurnIndicator('main', 1, false) + const container = wrapper.find('[data-testid="turn-indicator"]') + expect(container.classes()).toContain('turn-indicator--opponent-turn') + expect(container.classes()).not.toContain('turn-indicator--my-turn') + }) + + it('applies phase-specific badge class for each phase', () => { + /** + * Test that phase badges receive phase-specific CSS classes. + * + * Phase-specific styling (colors) helps players quickly identify + * the current phase at a glance. + */ + const phases: TurnPhase[] = ['setup', 'draw', 'main', 'attack', 'end'] + + phases.forEach((phase) => { + const wrapper = mountTurnIndicator(phase, 1, true) + const phaseBadge = wrapper.find('[data-testid="phase-badge"]') + expect(phaseBadge.classes()).toContain(`phase-badge--${phase}`) + }) + }) + + it('handles turn 0 correctly', () => { + /** + * Test edge case: turn 0 (game setup). + * + * Ensures the component doesn't crash or display incorrectly + * during initial game setup before turn 1. + */ + const wrapper = mountTurnIndicator('setup', 0, true) + const turnNumber = wrapper.find('[data-testid="turn-number"]') + expect(turnNumber.text()).toBe('Turn 0') + }) + + it('handles high turn numbers correctly', () => { + /** + * Test that high turn numbers display without issues. + * + * Long games should continue to display turn information correctly + * without layout breaks or overflow. + */ + const wrapper = mountTurnIndicator('main', 99, true) + const turnNumber = wrapper.find('[data-testid="turn-number"]') + expect(turnNumber.text()).toBe('Turn 99') + }) + + it('throws error when used outside GameOverlay (missing inject)', () => { + /** + * Test that the component fails fast with clear error when missing context. + * + * This component requires GameOverlay's provide/inject context. + * Failing fast helps developers catch integration errors early. + */ + expect(() => { + mount(TurnIndicator) + }).toThrow('TurnIndicator must be used within GameOverlay') + }) + + it('updates display when phase changes', async () => { + /** + * Test that the component reactively updates when phase changes. + * + * During gameplay, phases transition frequently. The component must + * react to these changes to keep players informed. + */ + const gameState = ref(createMockGameState('draw', 1, true)) + + const wrapper = mount(TurnIndicator, { + global: { + provide: { + gameState: computed(() => gameState.value), + isMyTurn: computed(() => gameState.value.is_my_turn), + currentPhase: computed(() => gameState.value.phase), + }, + }, + }) + + // Initial state + let phaseBadge = wrapper.find('[data-testid="phase-badge"]') + expect(phaseBadge.text()).toBe('Draw') + + // Update to main phase + gameState.value = createMockGameState('main', 1, true) + await wrapper.vm.$nextTick() + + phaseBadge = wrapper.find('[data-testid="phase-badge"]') + expect(phaseBadge.text()).toBe('Main') + }) + + it('updates styling when turn ownership changes', async () => { + /** + * Test that styling updates when turn changes between players. + * + * Visual feedback must update immediately when turn transitions + * to keep players aware of current game state. + */ + const gameState = ref(createMockGameState('main', 1, true)) + + const wrapper = mount(TurnIndicator, { + global: { + provide: { + gameState: computed(() => gameState.value), + isMyTurn: computed(() => gameState.value.is_my_turn), + currentPhase: computed(() => gameState.value.phase), + }, + }, + }) + + // Initial: my turn + let container = wrapper.find('[data-testid="turn-indicator"]') + expect(container.classes()).toContain('turn-indicator--my-turn') + + // Change to opponent's turn + gameState.value = createMockGameState('main', 2, false) + await wrapper.vm.$nextTick() + + container = wrapper.find('[data-testid="turn-indicator"]') + expect(container.classes()).toContain('turn-indicator--opponent-turn') + expect(container.classes()).not.toContain('turn-indicator--my-turn') + + const turnOwner = wrapper.find('[data-testid="turn-owner"]') + expect(turnOwner.text()).toBe("Opponent's Turn") + }) +}) diff --git a/frontend/src/components/game/TurnIndicator.vue b/frontend/src/components/game/TurnIndicator.vue new file mode 100644 index 0000000..2d4fbc6 --- /dev/null +++ b/frontend/src/components/game/TurnIndicator.vue @@ -0,0 +1,265 @@ + + + + +