- 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>
382 lines
16 KiB
TypeScript
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");
|
|
});
|
|
});
|
|
});
|