strat-gameplay-webapp/frontend-sba/tests/unit/components/Decisions/DefensiveSetup.spec.ts
Cal Corum 529c5b1b99 CLAUDE: Implement uncapped hit interactive decision tree (Issue #6)
Add full multi-step decision workflow for SINGLE_UNCAPPED and DOUBLE_UNCAPPED
outcomes, replacing the previous stub that fell through to basic single/double
advancement. The decision tree follows the same interactive pattern as X-Check
resolution with 5 phases: lead runner advance, defensive throw, trail runner
advance, throw target selection, and safe/out speed check.

- game_models.py: PendingUncappedHit model, 5 new decision phases
- game_engine.py: initiate_uncapped_hit(), 5 submit methods, 3 result builders
- handlers.py: 5 new WebSocket event handlers
- ai_opponent.py: 5 AI decision stubs (conservative defaults)
- play_resolver.py: Updated TODO comments for fallback paths
- 80 new backend tests (2481 total): workflow (49), handlers (23), truth tables (8)
- Fix GameplayPanel.spec.ts: add missing Pinia setup, fix component references

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

296 lines
9.9 KiB
TypeScript

import { describe, it, expect, beforeEach } from "vitest";
import { mount } from "@vue/test-utils";
import DefensiveSetup from "~/components/Decisions/DefensiveSetup.vue";
import { useDefensiveSetup } from "~/composables/useDefensiveSetup";
import type { DefensiveDecision } from "~/types/game";
describe("DefensiveSetup", () => {
const defaultProps = {
gameId: "test-game-123",
isActive: true,
};
beforeEach(() => {
// Reset the singleton composable state before each test
const { reset } = useDefensiveSetup();
reset();
});
describe("Rendering", () => {
it("renders component with header", () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
expect(wrapper.text()).toContain("Defensive Setup");
});
it("shows opponent turn indicator when not active", () => {
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
isActive: false,
},
});
expect(wrapper.text()).toContain("Opponent's Turn");
});
it("renders all form sections", () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
expect(wrapper.text()).toContain("Infield Depth");
expect(wrapper.text()).toContain("Outfield Depth");
expect(wrapper.text()).toContain("Current Setup");
});
});
describe("Initial Values", () => {
it("uses default values when no currentSetup provided", () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
// Check preview shows defaults
expect(wrapper.text()).toContain("Normal");
});
it("syncs composable from provided currentSetup via watcher", async () => {
/**
* When currentSetup prop is provided, the component should sync the
* composable state to match it. This verifies the prop->composable sync.
*/
const currentSetup: DefensiveDecision = {
infield_depth: "normal",
outfield_depth: "normal",
hold_runners: [1, 3],
};
mount(DefensiveSetup, {
props: {
...defaultProps,
currentSetup,
},
});
// The composable should be synced from the prop via the watcher
const { holdRunnersArray, infieldDepth, outfieldDepth } =
useDefensiveSetup();
// Watcher fires on prop change, check initial sync happens
expect(infieldDepth.value).toBe("normal");
expect(outfieldDepth.value).toBe("normal");
});
});
describe("Hold Runners Display", () => {
it('shows "None" when no runners held in preview', () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
// Check preview section shows "None" for holding
expect(wrapper.text()).toContain("Holding:None");
});
it("displays holding status in preview for held runners", () => {
/**
* The preview section should show a comma-separated list of held bases.
* Hold runner UI has moved to the runner pills themselves.
*/
const { syncFromDecision } = useDefensiveSetup();
syncFromDecision({
infield_depth: "normal",
outfield_depth: "normal",
hold_runners: [1, 3],
});
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
// Preview should show the held bases
expect(wrapper.text()).toContain("Holding:1st, 3rd");
});
it("displays holding status in preview for multiple runners", () => {
/**
* The preview section should show a comma-separated list of held bases.
*/
const { syncFromDecision } = useDefensiveSetup();
syncFromDecision({
infield_depth: "normal",
outfield_depth: "normal",
hold_runners: [1, 2, 3],
});
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
expect(wrapper.text()).toContain("Holding:1st, 2nd, 3rd");
});
});
describe("Preview Display", () => {
it("displays current infield depth in preview", () => {
const { syncFromDecision } = useDefensiveSetup();
syncFromDecision({
infield_depth: "infield_in",
outfield_depth: "normal",
hold_runners: [],
});
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
gameState: {
on_third: 123, // Need runner on third for infield_in option
} as any,
},
});
expect(wrapper.text()).toContain("Infield In");
});
});
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 = mount(DefensiveSetup, {
props: defaultProps,
});
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 = mount(DefensiveSetup, {
props: {
...defaultProps,
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 = mount(DefensiveSetup, {
props: defaultProps,
});
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([]);
});
it("shows loading state during submission", async () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
// Trigger submission
wrapper.vm.submitting = true;
await wrapper.vm.$nextTick();
// Verify button is in loading state
expect(wrapper.vm.submitting).toBe(true);
});
});
describe("Submit Button State", () => {
it('shows "Wait for Your Turn" when not active', () => {
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
isActive: false,
},
});
expect(wrapper.vm.submitButtonText).toBe("Wait for Your Turn");
});
it('shows "Submit Defensive Setup" when active', () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
expect(wrapper.vm.submitButtonText).toBe("Submit Defensive Setup");
});
});
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 = mount(DefensiveSetup, {
props: defaultProps,
});
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 depth controls when not active", () => {
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
isActive: false,
},
});
const buttonGroups = wrapper.findAllComponents({
name: "ButtonGroup",
});
buttonGroups.forEach((bg) => {
expect(bg.props("disabled")).toBe(true);
});
});
});
});