import { describe, it, expect, beforeEach } from "vitest"; import { mount } from "@vue/test-utils"; import { createPinia, setActivePinia } from "pinia"; import OffensiveApproach from "~/components/Decisions/OffensiveApproach.vue"; import { useGameStore } from "~/store/game"; import type { GameState, OffensiveDecision } from "~/types/game"; /** * Creates a minimal GameState for testing, with sensible defaults. * Only override the fields you care about per test. */ function makeGameState(overrides: Partial = {}): GameState { return { game_id: "test-game-123", league_id: "sba", home_team_id: 1, away_team_id: 2, home_team_is_ai: false, away_team_is_ai: false, creator_discord_id: null, auto_mode: false, status: "active", inning: 5, half: "top", outs: 0, balls: 0, strikes: 0, home_score: 3, away_score: 2, on_first: null, on_second: null, on_third: null, current_batter: { lineup_id: 10, card_id: 100, position: "LF", batting_order: 1, is_active: true }, current_pitcher: { lineup_id: 20, card_id: 200, position: "P", batting_order: null, is_active: true }, current_catcher: null, current_on_base_code: 0, away_team_batter_idx: 0, home_team_batter_idx: 0, pending_decision: null, decision_phase: "awaiting_offensive", decisions_this_play: {}, pending_defensive_decision: null, pending_offensive_decision: null, pending_manual_roll: null, pending_x_check: null, pending_uncapped_hit: null, play_count: 0, last_play_result: null, created_at: "2025-01-01T00:00:00Z", started_at: "2025-01-01T00:00:00Z", completed_at: null, ...overrides, } as GameState; } /** Helper to create a mock runner LineupPlayerState */ function makeRunner(lineupId: number, position: string = "LF") { return { lineup_id: lineupId, card_id: lineupId * 10, position, batting_order: 1, is_active: true }; } describe("OffensiveApproach", () => { let pinia: ReturnType; const defaultProps = { gameId: "test-game-123", isActive: true, }; beforeEach(() => { pinia = createPinia(); setActivePinia(pinia); }); /** * Helper: mount with game state injected into the store. * Sets up Pinia and populates gameStore.gameState before mounting. */ function mountWithGameState(gameStateOverrides: Partial = {}, propsOverrides: Record = {}) { const gameStore = useGameStore(); gameStore.setGameState(makeGameState(gameStateOverrides)); return mount(OffensiveApproach, { props: { ...defaultProps, ...propsOverrides }, global: { plugins: [pinia] }, }); } describe("Rendering", () => { it("always shows Swing Away option", () => { /** * Swing Away is the default action and should always be visible * regardless of game state. */ const wrapper = mountWithGameState(); expect(wrapper.text()).toContain("Swing Away"); }); it("renders offensive action header", () => { const wrapper = mountWithGameState(); expect(wrapper.text()).toContain("Offensive Action"); }); it("shows Opponent's Turn badge when not active", () => { const wrapper = mountWithGameState({}, { isActive: false }); expect(wrapper.text()).toContain("Opponent's Turn"); }); it("hides Opponent's Turn badge when active", () => { const wrapper = mountWithGameState(); expect(wrapper.text()).not.toContain("Opponent's Turn"); }); }); describe("Check Jump (requires any runner on base)", () => { it("hides Check Jump when bases empty", () => { /** * Check Jump requires at least one runner on base. * With empty bases it should not appear at all. */ const wrapper = mountWithGameState({ on_first: null, on_second: null, on_third: null }); expect(wrapper.text()).not.toContain("Check Jump"); }); it("shows Check Jump with runner on first", () => { const wrapper = mountWithGameState({ on_first: makeRunner(10) }); expect(wrapper.text()).toContain("Check Jump"); }); it("shows Check Jump with runner on second only", () => { /** * Even though hit-and-run is NOT available with runner on 2nd only, * check jump IS available with any runner. */ const wrapper = mountWithGameState({ on_second: makeRunner(20) }); expect(wrapper.text()).toContain("Check Jump"); }); it("shows Check Jump with runner on third", () => { const wrapper = mountWithGameState({ on_third: makeRunner(30) }); expect(wrapper.text()).toContain("Check Jump"); }); }); describe("Hit and Run (requires runner on 1st and/or 3rd)", () => { it("hides Hit and Run when bases empty", () => { const wrapper = mountWithGameState(); expect(wrapper.text()).not.toContain("Hit and Run"); }); it("hides Hit and Run with runner on second only", () => { /** * Hit and Run should NOT be available when only a runner on 2nd. * The user's rule: "runner on first and/or third". */ const wrapper = mountWithGameState({ on_second: makeRunner(20) }); expect(wrapper.text()).not.toContain("Hit and Run"); }); it("shows Hit and Run with runner on first", () => { const wrapper = mountWithGameState({ on_first: makeRunner(10) }); expect(wrapper.text()).toContain("Hit and Run"); }); it("shows Hit and Run with runner on third", () => { const wrapper = mountWithGameState({ on_third: makeRunner(30) }); expect(wrapper.text()).toContain("Hit and Run"); }); it("shows Hit and Run with runners on first and third", () => { const wrapper = mountWithGameState({ on_first: makeRunner(10), on_third: makeRunner(30), }); expect(wrapper.text()).toContain("Hit and Run"); }); it("shows Hit and Run with runner on first and second (first qualifies)", () => { /** * Runner on 1st satisfies the condition even if 2nd is also occupied. */ const wrapper = mountWithGameState({ on_first: makeRunner(10), on_second: makeRunner(20), }); expect(wrapper.text()).toContain("Hit and Run"); }); }); describe("Sacrifice Bunt (requires < 2 outs AND runners on base)", () => { it("hides Sac Bunt when bases empty", () => { /** * Sac bunt requires on_base_code > 0 (runners on base). */ const wrapper = mountWithGameState({ outs: 0 }); expect(wrapper.text()).not.toContain("Sacrifice Bunt"); }); it("hides Sac Bunt with 2 outs", () => { const wrapper = mountWithGameState({ outs: 2, on_first: makeRunner(10) }); expect(wrapper.text()).not.toContain("Sacrifice Bunt"); }); it("shows Sac Bunt with 0 outs and runner on base", () => { const wrapper = mountWithGameState({ outs: 0, on_first: makeRunner(10) }); expect(wrapper.text()).toContain("Sacrifice Bunt"); }); it("shows Sac Bunt with 1 out and runner on base", () => { const wrapper = mountWithGameState({ outs: 1, on_second: makeRunner(20) }); expect(wrapper.text()).toContain("Sacrifice Bunt"); }); }); describe("Squeeze Bunt (requires < 2 outs AND runner on 3rd)", () => { it("hides Squeeze Bunt when no runner on third", () => { const wrapper = mountWithGameState({ outs: 0, on_first: makeRunner(10) }); expect(wrapper.text()).not.toContain("Squeeze Bunt"); }); it("hides Squeeze Bunt with 2 outs even if runner on third", () => { const wrapper = mountWithGameState({ outs: 2, on_third: makeRunner(30) }); expect(wrapper.text()).not.toContain("Squeeze Bunt"); }); it("shows Squeeze Bunt with 0 outs and runner on third", () => { const wrapper = mountWithGameState({ outs: 0, on_third: makeRunner(30) }); expect(wrapper.text()).toContain("Squeeze Bunt"); }); it("shows Squeeze Bunt with 1 out and runner on third", () => { const wrapper = mountWithGameState({ outs: 1, on_third: makeRunner(30) }); expect(wrapper.text()).toContain("Squeeze Bunt"); }); }); describe("Steal option removed", () => { it("does not show Steal option even with runners on base", () => { /** * The steal option was removed — check jump encompasses steal behavior. */ const wrapper = mountWithGameState({ on_first: makeRunner(10) }); const actionLabels = wrapper.findAll("button[type='button']").map(b => b.text()); const hasStealLabel = actionLabels.some(label => label.includes("Steal") && !label.includes("Bunt")); expect(hasStealLabel).toBe(false); }); }); describe("Full scenario: all options visible", () => { it("shows all 5 options with 0 outs and runners on 1st and 3rd", () => { /** * With 0 outs, runner on 1st AND 3rd: all conditions are met. * Should show: Swing Away, Check Jump, Hit and Run, Sac Bunt, Squeeze Bunt. */ const wrapper = mountWithGameState({ outs: 0, on_first: makeRunner(10), on_third: makeRunner(30), }); expect(wrapper.text()).toContain("Swing Away"); expect(wrapper.text()).toContain("Check Jump"); expect(wrapper.text()).toContain("Hit and Run"); expect(wrapper.text()).toContain("Sacrifice Bunt"); expect(wrapper.text()).toContain("Squeeze Bunt"); }); }); describe("Action Selection", () => { it("selects swing_away by default", () => { const wrapper = mountWithGameState({ on_first: makeRunner(10) }); // The checkmark should be next to Swing Away const buttons = wrapper.findAll("button[type='button']"); const swingBtn = buttons.find(b => b.text().includes("Swing Away")); expect(swingBtn?.text()).toContain("✓"); }); it("can select check_jump when available", async () => { const wrapper = mountWithGameState({ on_first: makeRunner(10) }); const buttons = wrapper.findAll("button[type='button']"); const checkJumpBtn = buttons.find(b => b.text().includes("Check Jump")); expect(checkJumpBtn).toBeTruthy(); await checkJumpBtn!.trigger("click"); // Checkmark should move to Check Jump expect(checkJumpBtn!.text()).toContain("✓"); }); it("does not change action when not active", async () => { const wrapper = mountWithGameState({ on_first: makeRunner(10) }, { isActive: false }); const buttons = wrapper.findAll("button[type='button']"); const checkJumpBtn = buttons.find(b => b.text().includes("Check Jump")); await checkJumpBtn!.trigger("click"); // Swing Away should still be selected (checkmark stays) const swingBtn = buttons.find(b => b.text().includes("Swing Away")); expect(swingBtn?.text()).toContain("✓"); }); }); describe("Auto-reset when action becomes unavailable", () => { it("resets to swing_away when selected action disappears", async () => { /** * If a user selects sac_bunt but then the game state changes * (e.g., outs increase to 2), the selection should reset to swing_away. */ const gameStore = useGameStore(); gameStore.setGameState(makeGameState({ outs: 0, on_first: makeRunner(10) })); const wrapper = mount(OffensiveApproach, { props: defaultProps, global: { plugins: [pinia] }, }); // Select sac bunt const sacBuntBtn = wrapper.findAll("button[type='button']").find(b => b.text().includes("Sacrifice Bunt")); await sacBuntBtn!.trigger("click"); // Now change game state to 2 outs — sac bunt should disappear gameStore.setGameState(makeGameState({ outs: 2, on_first: makeRunner(10) })); await wrapper.vm.$nextTick(); // Action should auto-reset to swing_away const swingBtn = wrapper.findAll("button[type='button']").find(b => b.text().includes("Swing Away")); expect(swingBtn?.text()).toContain("✓"); expect(wrapper.text()).not.toContain("Sacrifice Bunt"); }); }); describe("Form Submission", () => { it("emits submit with current action", async () => { const wrapper = mountWithGameState({ on_first: makeRunner(10) }); // Select check_jump const checkJumpBtn = wrapper.findAll("button[type='button']").find(b => b.text().includes("Check Jump")); await checkJumpBtn!.trigger("click"); await wrapper.find("form").trigger("submit.prevent"); expect(wrapper.emitted("submit")).toBeTruthy(); const emitted = wrapper.emitted("submit")![0][0] as Omit; expect(emitted.action).toBe("check_jump"); }); it("does not submit when not active", async () => { const wrapper = mountWithGameState({}, { isActive: false }); await wrapper.find("form").trigger("submit.prevent"); expect(wrapper.emitted("submit")).toBeFalsy(); }); it("submits default swing_away when no changes made", async () => { const wrapper = mountWithGameState(); await wrapper.find("form").trigger("submit.prevent"); expect(wrapper.emitted("submit")).toBeTruthy(); const emitted = wrapper.emitted("submit")![0][0] as Omit; expect(emitted.action).toBe("swing_away"); }); }); describe("Game State from Store (bug fix verification)", () => { it("reads game state from store, not from props", () => { /** * The old OffensiveApproach used props like runnerOnFirst, runnerOnThird, * and outs that were never passed by DecisionPanel (all defaulted to false/0). * The new version reads from useGameStore() directly. * * This test verifies the fix by setting store state with runners and outs * WITHOUT passing any runner/outs props, and expecting correct filtering. */ const wrapper = mountWithGameState({ outs: 0, on_first: makeRunner(10), on_third: makeRunner(30), }); // No runner props passed — component reads from store expect(wrapper.text()).toContain("Check Jump"); expect(wrapper.text()).toContain("Hit and Run"); expect(wrapper.text()).toContain("Sacrifice Bunt"); expect(wrapper.text()).toContain("Squeeze Bunt"); }); }); });