import { describe, it, expect, beforeEach } from "vitest"; import { mount } from "@vue/test-utils"; import { createPinia, setActivePinia } from "pinia"; import DefensiveSetup from "~/components/Decisions/DefensiveSetup.vue"; import { useDefensiveSetup } from "~/composables/useDefensiveSetup"; import { useGameStore } from "~/store/game"; import type { DefensiveDecision, GameState } 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_defensive", 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("DefensiveSetup", () => { let pinia: ReturnType; const defaultProps = { gameId: "test-game-123", isActive: true, }; beforeEach(() => { pinia = createPinia(); setActivePinia(pinia); // Reset the singleton composable state before each test const { reset } = useDefensiveSetup(); reset(); }); /** * 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(DefensiveSetup, { props: { ...defaultProps, ...propsOverrides }, global: { plugins: [pinia] }, }); } describe("Rendering", () => { it("renders component with compact header and glove emoji", () => { /** * The compact layout uses 🧤 emoji and "Defense" title (not the old * "Defensive Setup" with 🛡️). */ const wrapper = mountWithGameState(); expect(wrapper.text()).toContain("Defense"); expect(wrapper.text()).toContain("🧤"); expect(wrapper.text()).not.toContain("🛡️"); }); it("renders prominent full-width confirm button", () => { /** * The confirm button should be a large, full-width green button * matching the Roll Dice button style. */ const wrapper = mountWithGameState(); expect(wrapper.text()).toContain("Confirm Defense"); }); it("does not render old preview box or ButtonGroup", () => { /** * The compact layout removes the "Current Setup" preview box * and replaces ButtonGroup with inline segmented buttons. */ const wrapper = mountWithGameState(); expect(wrapper.text()).not.toContain("Current Setup"); expect(wrapper.text()).not.toContain("Infield Depth"); expect(wrapper.text()).not.toContain("Outfield Depth"); expect(wrapper.findAllComponents({ name: "ButtonGroup" })).toHaveLength(0); }); it("always shows infield row with Normal option", () => { /** * Infield segmented control always renders with at least "Normal". */ const wrapper = mountWithGameState(); expect(wrapper.text()).toContain("Infield"); expect(wrapper.text()).toContain("Normal"); }); }); describe("Infield Options (runner on 3rd)", () => { it("shows only Normal when no runner on 3rd", () => { /** * When there's no runner on 3rd base, infield should only show "Normal". * IF In and Corners should NOT appear. */ const wrapper = mountWithGameState({ on_third: null }); expect(wrapper.text()).toContain("Normal"); expect(wrapper.text()).not.toContain("IF In"); expect(wrapper.text()).not.toContain("Corners"); }); it("shows all three infield options when runner on 3rd", () => { /** * When there IS a runner on 3rd, infield should show Normal, IF In, and Corners. * This is driven by game state from the store (not a prop). */ const wrapper = mountWithGameState({ on_third: makeRunner(30) }); expect(wrapper.text()).toContain("Normal"); expect(wrapper.text()).toContain("IF In"); expect(wrapper.text()).toContain("Corners"); }); it("can select infield_in when runner on 3rd", async () => { /** * Clicking the IF In button should update the composable's infieldDepth value. */ const wrapper = mountWithGameState({ on_third: makeRunner(30) }); const buttons = wrapper.findAll('button[type="button"]'); const ifInBtn = buttons.find(b => b.text() === "IF In"); expect(ifInBtn).toBeTruthy(); await ifInBtn!.trigger("click"); const { infieldDepth } = useDefensiveSetup(); expect(infieldDepth.value).toBe("infield_in"); }); }); describe("Outfield Row (walk-off scenario)", () => { it("hides outfield row in normal game situations", () => { /** * Outfield row should be hidden when it's NOT a walk-off scenario. * A normal mid-game state should not show "Outfield" or "Shallow". */ const wrapper = mountWithGameState({ inning: 5, half: "top", on_first: makeRunner(10), }); expect(wrapper.text()).not.toContain("Outfield"); expect(wrapper.text()).not.toContain("Shallow"); }); it("shows outfield row in walk-off scenario", () => { /** * Walk-off scenario: bottom of 9th+, home team losing/tied, runners on base. * This should show the outfield row with Normal and Shallow options. */ const wrapper = mountWithGameState({ inning: 9, half: "bottom", home_score: 3, away_score: 4, on_third: makeRunner(30), }); expect(wrapper.text()).toContain("Outfield"); expect(wrapper.text()).toContain("Normal"); expect(wrapper.text()).toContain("Shallow"); }); it("hides outfield row when home is winning in bottom of 9th", () => { /** * If home team is already ahead, it's not a walk-off scenario. */ const wrapper = mountWithGameState({ inning: 9, half: "bottom", home_score: 5, away_score: 3, on_first: makeRunner(10), }); expect(wrapper.text()).not.toContain("Outfield"); expect(wrapper.text()).not.toContain("Shallow"); }); }); describe("Hold Runner Toggles", () => { it("shows hold pills only for occupied bases", () => { /** * Hold toggle pills should only appear for bases that have runners. * Empty bases should not show a pill at all (not disabled, just absent). */ const wrapper = mountWithGameState({ on_first: makeRunner(10), on_third: makeRunner(30), }); expect(wrapper.text()).toContain("Hold"); expect(wrapper.text()).toContain("1B"); expect(wrapper.text()).toContain("3B"); expect(wrapper.text()).not.toContain("2B"); }); it("hides hold row when bases are empty", () => { /** * When no runners on base, the hold row shouldn't render at all. */ const wrapper = mountWithGameState({ on_first: null, on_second: null, on_third: null, }); expect(wrapper.text()).not.toContain("Hold"); expect(wrapper.text()).not.toContain("1B"); expect(wrapper.text()).not.toContain("2B"); expect(wrapper.text()).not.toContain("3B"); }); it("shows all three hold pills when bases loaded", () => { const wrapper = mountWithGameState({ on_first: makeRunner(10), on_second: makeRunner(20), on_third: makeRunner(30), }); expect(wrapper.text()).toContain("1B"); expect(wrapper.text()).toContain("2B"); expect(wrapper.text()).toContain("3B"); }); it("toggles hold state when pill is clicked", async () => { /** * Clicking a hold pill should toggle that base's hold state * in the shared useDefensiveSetup composable. */ const wrapper = mountWithGameState({ on_first: makeRunner(10) }); const holdBtn = wrapper.findAll('button[type="button"]').find(b => b.text() === "1B"); expect(holdBtn).toBeTruthy(); await holdBtn!.trigger("click"); const { isHeld } = useDefensiveSetup(); expect(isHeld(1)).toBe(true); // Click again to toggle off await holdBtn!.trigger("click"); expect(isHeld(1)).toBe(false); }); }); describe("Form Submission", () => { it("emits submit event with composable state", async () => { /** * On submit, the component should call getDecision() from the composable * and emit the full DefensiveDecision. */ const { syncFromDecision } = useDefensiveSetup(); syncFromDecision({ infield_depth: "normal", outfield_depth: "normal", hold_runners: [2], }); const wrapper = mountWithGameState(); await wrapper.find("form").trigger("submit.prevent"); expect(wrapper.emitted("submit")).toBeTruthy(); const emitted = wrapper.emitted("submit")![0][0] as DefensiveDecision; expect(emitted.infield_depth).toBe("normal"); expect(emitted.outfield_depth).toBe("normal"); expect(emitted.hold_runners).toEqual([2]); }); 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("allows submit with default setup", async () => { /** * Submitting with defaults should emit a valid DefensiveDecision * with normal depth and no held runners. */ const wrapper = mountWithGameState(); await wrapper.find("form").trigger("submit.prevent"); expect(wrapper.emitted("submit")).toBeTruthy(); const emitted = wrapper.emitted("submit")![0][0] as DefensiveDecision; expect(emitted.infield_depth).toBe("normal"); expect(emitted.outfield_depth).toBe("normal"); expect(emitted.hold_runners).toEqual([]); }); }); describe("Prop Updates", () => { it("syncs composable state when currentSetup prop changes", async () => { /** * When the parent updates the currentSetup prop (e.g. from server state), * the composable should be synced to match. */ const wrapper = mountWithGameState(); const newSetup: DefensiveDecision = { infield_depth: "infield_in", outfield_depth: "normal", hold_runners: [1, 2, 3], }; await wrapper.setProps({ currentSetup: newSetup }); const { infieldDepth, outfieldDepth, holdRunnersArray } = useDefensiveSetup(); expect(infieldDepth.value).toBe("infield_in"); expect(outfieldDepth.value).toBe("normal"); expect(holdRunnersArray.value).toEqual([1, 2, 3]); }); }); describe("Disabled State", () => { it("disables all interactive buttons when not active", () => { /** * When isActive is false, all segmented control buttons and hold pills * should be disabled. */ const wrapper = mountWithGameState( { on_first: makeRunner(10) }, { isActive: false }, ); const buttons = wrapper.findAll('button[type="button"]'); buttons.forEach((btn) => { expect(btn.attributes("disabled")).toBeDefined(); }); }); }); describe("Game State from Store (bug fix verification)", () => { it("reads game state from store, not from props", () => { /** * The old DefensiveSetup used a gameState prop that was never passed by * DecisionPanel, so conditional options (infield_in, corners_in, shallow) * never appeared. The new version reads from useGameStore() directly. * * This test verifies the fix by setting store state with runner on 3rd * WITHOUT passing any gameState prop, and expecting infield options to appear. */ const wrapper = mountWithGameState({ on_third: makeRunner(30) }); // No gameState prop passed — component reads from store expect(wrapper.text()).toContain("IF In"); expect(wrapper.text()).toContain("Corners"); }); }); });