- 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>
383 lines
15 KiB
TypeScript
383 lines
15 KiB
TypeScript
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> = {}): 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<typeof createPinia>;
|
|
|
|
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<GameState> = {}, propsOverrides: Record<string, any> = {}) {
|
|
const gameStore = useGameStore();
|
|
gameStore.setGameState(makeGameState(gameStateOverrides));
|
|
return mount(DefensiveSetup, {
|
|
props: { ...defaultProps, ...propsOverrides },
|
|
global: { plugins: [pinia] },
|
|
});
|
|
}
|
|
|
|
describe("Rendering", () => {
|
|
it("renders component with compact header", () => {
|
|
/**
|
|
* The compact layout uses "Defense" title (not the old "Defensive Setup").
|
|
* No emojis in the header.
|
|
*/
|
|
const wrapper = mountWithGameState();
|
|
expect(wrapper.text()).toContain("Defense");
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|
|
});
|