strat-gameplay-webapp/frontend-sba/tests/unit/components/Decisions/OffensiveApproach.spec.ts
Cal Corum 187bd1ccae CLAUDE: Fix offensive action conditional rendering, remove emojis, always show hold pills
- OffensiveApproach: read game state from store (fix same prop-passing bug as DefensiveSetup),
  remove steal option (check_jump encompasses it), hide unavailable actions instead of disabling,
  fix conditions (sac/squeeze: <2 outs + runners, hit-and-run: R1/R3 not R2-only)
- Remove all emoji icons from decision components (OffensiveApproach, DefensiveSetup, DecisionPanel)
- RunnerCard: always show hold/not-held pills on occupied bases (status indicator in all phases)
- DecisionPanel: remove dead hasRunnersOnBase computed and prop pass-through
- Rewrite OffensiveApproach tests (32 new tests with Pinia store integration)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 15:47:33 -06:00

382 lines
16 KiB
TypeScript

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> = {}): 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<typeof createPinia>;
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<GameState> = {}, propsOverrides: Record<string, any> = {}) {
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<OffensiveDecision, "steal_attempts">;
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<OffensiveDecision, "steal_attempts">;
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");
});
});
});