feat: Uncapped hit decision tree, x-check workflow, baserunner UI #8

Merged
cal merged 11 commits from feature/uncapped-hit-decision-tree into main 2026-02-12 15:37:34 +00:00
8 changed files with 767 additions and 376 deletions
Showing only changes of commit 7c54bfd26b - Show all commits

View File

@ -22,7 +22,7 @@
Infield Depth
</label>
<ButtonGroup
v-model="localSetup.infield_depth"
v-model="infieldDepth"
:options="infieldDepthOptions"
:disabled="!isActive"
size="md"
@ -37,7 +37,7 @@
Outfield Depth
</label>
<ButtonGroup
v-model="localSetup.outfield_depth"
v-model="outfieldDepth"
:options="outfieldDepthOptions"
:disabled="!isActive"
size="md"
@ -45,33 +45,6 @@
/>
</div>
<!-- Hold Runners -->
<div>
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Hold Runners
</label>
<div class="space-y-2">
<ToggleSwitch
v-model="holdFirst"
label="Hold runner at 1st base"
:disabled="!isActive"
size="md"
/>
<ToggleSwitch
v-model="holdSecond"
label="Hold runner at 2nd base"
:disabled="!isActive"
size="md"
/>
<ToggleSwitch
v-model="holdThird"
label="Hold runner at 3rd base"
:disabled="!isActive"
size="md"
/>
</div>
</div>
<!-- Visual Preview -->
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-gray-700 dark:to-gray-600 rounded-lg p-4">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
@ -113,14 +86,14 @@ import { ref, computed, watch } from 'vue'
import type { DefensiveDecision, GameState } from '~/types/game'
import ButtonGroup from '~/components/UI/ButtonGroup.vue'
import type { ButtonGroupOption } from '~/components/UI/ButtonGroup.vue'
import ToggleSwitch from '~/components/UI/ToggleSwitch.vue'
import ActionButton from '~/components/UI/ActionButton.vue'
import { useDefensiveSetup } from '~/composables/useDefensiveSetup'
interface Props {
gameId: string
isActive: boolean
currentSetup?: DefensiveDecision
gameState?: GameState // Added for smart filtering
gameState?: GameState
}
const props = withDefaults(defineProps<Props>(), {
@ -131,27 +104,10 @@ const emit = defineEmits<{
submit: [setup: DefensiveDecision]
}>()
const { infieldDepth, outfieldDepth, holdRunnersArray, getDecision, syncFromDecision } = useDefensiveSetup()
// Local state
const submitting = ref(false)
const localSetup = ref<DefensiveDecision>({
infield_depth: props.currentSetup?.infield_depth || 'normal',
outfield_depth: props.currentSetup?.outfield_depth || 'normal',
hold_runners: props.currentSetup?.hold_runners || [],
})
// Hold runner toggles
const holdFirst = ref(localSetup.value.hold_runners.includes(1))
const holdSecond = ref(localSetup.value.hold_runners.includes(2))
const holdThird = ref(localSetup.value.hold_runners.includes(3))
// Watch hold toggles and update hold_runners array
watch([holdFirst, holdSecond, holdThird], () => {
const runners: number[] = []
if (holdFirst.value) runners.push(1)
if (holdSecond.value) runners.push(2)
if (holdThird.value) runners.push(3)
localSetup.value.hold_runners = runners
})
// Dynamic options based on game state
const infieldDepthOptions = computed<ButtonGroupOption[]>(() => {
@ -194,18 +150,19 @@ const outfieldDepthOptions = computed<ButtonGroupOption[]>(() => {
// Display helpers
const infieldDisplay = computed(() => {
const option = infieldDepthOptions.value.find(opt => opt.value === localSetup.value.infield_depth)
const option = infieldDepthOptions.value.find(opt => opt.value === infieldDepth.value)
return option?.label || 'Normal'
})
const outfieldDisplay = computed(() => {
const option = outfieldDepthOptions.value.find(opt => opt.value === localSetup.value.outfield_depth)
const option = outfieldDepthOptions.value.find(opt => opt.value === outfieldDepth.value)
return option?.label || 'Normal'
})
const holdingDisplay = computed(() => {
if (localSetup.value.hold_runners.length === 0) return 'None'
return localSetup.value.hold_runners.map(base => {
const arr = holdRunnersArray.value
if (arr.length === 0) return 'None'
return arr.map(base => {
if (base === 1) return '1st'
if (base === 2) return '2nd'
if (base === 3) return '3rd'
@ -213,19 +170,8 @@ const holdingDisplay = computed(() => {
}).join(', ')
})
// Check if setup has changed from initial (for display only)
const hasChanges = computed(() => {
if (!props.currentSetup) return true
return (
localSetup.value.infield_depth !== props.currentSetup.infield_depth ||
localSetup.value.outfield_depth !== props.currentSetup.outfield_depth ||
JSON.stringify(localSetup.value.hold_runners) !== JSON.stringify(props.currentSetup.hold_runners)
)
})
const submitButtonText = computed(() => {
if (!props.isActive) return 'Wait for Your Turn'
if (!hasChanges.value) return 'Submit (Keep Setup)'
return 'Submit Defensive Setup'
})
@ -235,19 +181,16 @@ const handleSubmit = async () => {
submitting.value = true
try {
emit('submit', { ...localSetup.value })
emit('submit', getDecision())
} finally {
submitting.value = false
}
}
// Watch for prop changes and update local state
// Sync composable state from prop when it changes (e.g. server-confirmed state)
watch(() => props.currentSetup, (newSetup) => {
if (newSetup) {
localSetup.value = { ...newSetup }
holdFirst.value = newSetup.hold_runners.includes(1)
holdSecond.value = newSetup.hold_runners.includes(2)
holdThird.value = newSetup.hold_runners.includes(3)
syncFromDecision(newSetup)
}
}, { deep: true })
</script>

View File

@ -79,6 +79,9 @@
:fielding-team-color="fieldingTeamColor"
:batting-team-abbrev="batterTeamAbbrev"
:fielding-team-abbrev="pitcherTeamAbbrev"
:hold-runners="defensiveSetup.holdRunnersArray.value"
:hold-interactive="holdInteractive"
@toggle-hold="handleToggleHold"
/>
<!-- Decision Panel (Phase F3) -->
@ -146,6 +149,9 @@
:fielding-team-color="fieldingTeamColor"
:batting-team-abbrev="batterTeamAbbrev"
:fielding-team-abbrev="pitcherTeamAbbrev"
:hold-runners="defensiveSetup.holdRunnersArray.value"
:hold-interactive="holdInteractive"
@toggle-hold="handleToggleHold"
/>
<!-- Decision Panel (Phase F3) -->
@ -328,6 +334,7 @@ import { useAuthStore } from '~/store/auth'
import { useUiStore } from '~/store/ui'
import { useWebSocket } from '~/composables/useWebSocket'
import { useGameActions } from '~/composables/useGameActions'
import { useDefensiveSetup } from '~/composables/useDefensiveSetup'
import CurrentSituation from '~/components/Game/CurrentSituation.vue'
import RunnersOnBase from '~/components/Game/RunnersOnBase.vue'
import PlayByPlay from '~/components/Game/PlayByPlay.vue'
@ -363,6 +370,9 @@ const actions = useGameActions(props.gameId)
// Destructure undoLastPlay for the undo button
const { undoLastPlay } = actions
// Defensive setup composable (shared with DefensiveSetup.vue and RunnersOnBase)
const defensiveSetup = useDefensiveSetup()
// Game state from store
const gameState = computed(() => {
const state = gameStore.gameState
@ -531,6 +541,9 @@ const decisionPhase = computed(() => {
return 'idle'
})
// Hold runner toggles are interactive only during defensive decision phase
const holdInteractive = computed(() => needsDefensiveDecision.value && isMyTurn.value)
// Phase F6: Conditional panel rendering
const showDecisions = computed(() => {
// Don't show decision panels if there's a result pending dismissal
@ -643,6 +656,10 @@ const handleStealAttemptsSubmit = (attempts: number[]) => {
gameStore.setPendingStealAttempts(attempts)
}
const handleToggleHold = (base: number) => {
defensiveSetup.toggleHold(base)
}
// Undo handler
const handleUndoLastPlay = () => {
console.log('[GamePlay] Undoing last play')
@ -715,6 +732,18 @@ watch(gameState, (state, oldState) => {
}
}, { immediate: true })
// Reset defensive setup composable when entering a new defensive decision phase
watch(needsDefensiveDecision, (needs) => {
if (needs) {
// Sync from existing setup if available, otherwise reset to defaults
if (pendingDefensiveSetup.value) {
defensiveSetup.syncFromDecision(pendingDefensiveSetup.value)
} else {
defensiveSetup.reset()
}
}
})
// Quality of Life: Auto-submit default decisions when bases are empty
watch([needsDefensiveDecision, needsOffensiveDecision, basesEmpty], ([defensive, offensive, empty]) => {
// Only auto-submit if it's the player's turn and bases are empty

View File

@ -3,7 +3,8 @@
:class="[
'runner-pill',
runner ? 'occupied' : 'empty',
isSelected ? 'selected' : ''
isSelected ? 'selected' : '',
isHeld ? 'held' : ''
]"
@click="handleClick"
>
@ -24,6 +25,30 @@
<div class="text-xs font-bold text-gray-900 truncate">{{ runnerName }}</div>
<div class="text-[10px] text-gray-500">{{ base }}</div>
</div>
<!-- Hold runner icon -->
<button
v-if="holdInteractive || isHeld"
type="button"
:class="[
'hold-icon flex-shrink-0 w-10 rounded-lg flex items-center justify-center transition-all duration-200 px-1 self-stretch gap-0.5',
holdInteractive ? 'cursor-pointer' : 'cursor-default',
isHeld
? 'bg-amber-500 text-white shadow-sm ring-1 ring-amber-400'
: 'bg-gray-200 text-gray-400 hover:bg-gray-300'
]"
:title="isHeld ? 'Release runner' : 'Hold runner'"
:disabled="!holdInteractive"
@click.stop="handleToggleHold"
>
<template v-if="isHeld">
<span class="text-[9px] font-extrabold leading-[0.85] tracking-tighter" style="writing-mode: vertical-rl; text-orientation: upright;">HELD</span>
</template>
<template v-else>
<span class="text-[9px] font-extrabold leading-[0.85] tracking-tighter" style="writing-mode: vertical-rl; text-orientation: upright;">NOT</span>
<span class="text-[9px] font-extrabold leading-[0.85] tracking-tighter" style="writing-mode: vertical-rl; text-orientation: upright;">HELD</span>
</template>
</button>
</template>
<template v-else>
<!-- Empty base -->
@ -45,11 +70,18 @@ interface Props {
runner: LineupPlayerState | null
isSelected: boolean
teamColor: string
isHeld?: boolean
holdInteractive?: boolean
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
isHeld: false,
holdInteractive: false,
})
const emit = defineEmits<{
click: []
toggleHold: []
}>()
const gameStore = useGameStore()
@ -66,26 +98,17 @@ const runnerName = computed(() => {
return runnerPlayer.value.name
})
const runnerNumber = computed(() => {
// Try to extract jersey number from player data if available
// For now, default to a placeholder based on lineup_id
return props.runner?.lineup_id?.toString().padStart(2, '0') ?? '00'
})
const getRunnerInitials = computed(() => {
if (!runnerPlayer.value) return '?'
const parts = runnerPlayer.value.name.split(' ')
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
}
return runnerPlayer.value.name.substring(0, 2).toUpperCase()
})
function handleClick() {
if (props.runner) {
emit('click')
}
}
function handleToggleHold() {
if (props.holdInteractive) {
emit('toggleHold')
}
}
</script>
<style scoped>
@ -102,7 +125,15 @@ function handleClick() {
@apply ring-2 ring-red-500 bg-red-50 shadow-md;
}
.runner-pill.held {
@apply border-amber-400;
}
.runner-pill.empty {
@apply bg-gray-50 opacity-60;
}
.hold-icon:disabled {
@apply opacity-70;
}
</style>

View File

@ -13,7 +13,10 @@
:runner="runners[key]"
:is-selected="selectedRunner === key"
:team-color="'#ef4444'"
:is-held="holdRunners.includes(baseNameToNumber[key])"
:hold-interactive="holdInteractive"
@click="toggleRunner(key)"
@toggle-hold="emit('toggleHold', baseNameToNumber[key])"
/>
</div>
@ -112,6 +115,8 @@ interface Props {
fieldingTeamColor?: string
battingTeamAbbrev?: string
fieldingTeamAbbrev?: string
holdRunners?: number[]
holdInteractive?: boolean
}
const props = withDefaults(defineProps<Props>(), {
@ -120,8 +125,14 @@ const props = withDefaults(defineProps<Props>(), {
fieldingTeamColor: '#10b981',
battingTeamAbbrev: '',
fieldingTeamAbbrev: '',
holdRunners: () => [],
holdInteractive: false,
})
const emit = defineEmits<{
toggleHold: [base: number]
}>()
const gameStore = useGameStore()
const selectedRunner = ref<'first' | 'second' | 'third' | 'catcher' | null>(null)
@ -129,6 +140,7 @@ const selectedRunner = ref<'first' | 'second' | 'third' | 'catcher' | null>(null
const baseKeys = ['third', 'second', 'first'] as const
const baseLabels: ('1B' | '2B' | '3B')[] = ['3B', '2B', '1B']
const baseNameToLabel: Record<string, string> = { first: '1B', second: '2B', third: '3B' }
const baseNameToNumber: Record<string, number> = { first: 1, second: 2, third: 3 }
// Auto-select lead runner on mount
onMounted(() => {

View File

@ -0,0 +1,63 @@
import { ref, computed } from 'vue'
import type { DefensiveDecision } from '~/types/game'
// Module-level singleton state (shared across all consumers)
const holdRunners = ref<Set<number>>(new Set())
const infieldDepth = ref<'infield_in' | 'normal' | 'corners_in'>('normal')
const outfieldDepth = ref<'normal' | 'shallow'>('normal')
export function useDefensiveSetup() {
/** Reactive array of held base numbers (for prop passing) */
const holdRunnersArray = computed<number[]>(() => Array.from(holdRunners.value).sort())
/** Check if a specific base is held */
function isHeld(base: number): boolean {
return holdRunners.value.has(base)
}
/** Toggle hold on a base (1, 2, or 3) */
function toggleHold(base: number) {
const next = new Set(holdRunners.value)
if (next.has(base)) {
next.delete(base)
} else {
next.add(base)
}
holdRunners.value = next
}
/** Reset all defensive setup to defaults */
function reset() {
holdRunners.value = new Set()
infieldDepth.value = 'normal'
outfieldDepth.value = 'normal'
}
/** Sync state from an existing DefensiveDecision (e.g. from props/server) */
function syncFromDecision(decision: DefensiveDecision) {
holdRunners.value = new Set(decision.hold_runners)
infieldDepth.value = decision.infield_depth
outfieldDepth.value = decision.outfield_depth
}
/** Build a DefensiveDecision from current working state */
function getDecision(): DefensiveDecision {
return {
infield_depth: infieldDepth.value,
outfield_depth: outfieldDepth.value,
hold_runners: holdRunnersArray.value,
}
}
return {
holdRunners,
holdRunnersArray,
infieldDepth,
outfieldDepth,
isHeld,
toggleHold,
reset,
syncFromDecision,
getDecision,
}
}

View File

@ -1,335 +1,309 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import DefensiveSetup from '~/components/Decisions/DefensiveSetup.vue'
import type { DefensiveDecision } from '~/types/game'
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,
}
describe("DefensiveSetup", () => {
const defaultProps = {
gameId: "test-game-123",
isActive: true,
};
describe('Rendering', () => {
it('renders component with header', () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
})
beforeEach(() => {
// Reset the singleton composable state before each test
const { reset } = useDefensiveSetup();
reset();
});
expect(wrapper.text()).toContain('Defensive Setup')
})
describe("Rendering", () => {
it("renders component with header", () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
it('shows opponent turn indicator when not active', () => {
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
isActive: false,
},
})
expect(wrapper.text()).toContain("Defensive Setup");
});
expect(wrapper.text()).toContain("Opponent's Turn")
})
it("shows opponent turn indicator when not active", () => {
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
isActive: false,
},
});
it('renders all form sections', () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
})
expect(wrapper.text()).toContain("Opponent's Turn");
});
expect(wrapper.text()).toContain('Infield Depth')
expect(wrapper.text()).toContain('Outfield Depth')
expect(wrapper.text()).toContain('Hold Runners')
})
})
it("renders all form sections", () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
describe('Initial Values', () => {
it('uses default values when no currentSetup provided', () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
})
expect(wrapper.text()).toContain("Infield Depth");
expect(wrapper.text()).toContain("Outfield Depth");
expect(wrapper.text()).toContain("Hold Runners");
});
// Check preview shows defaults
expect(wrapper.text()).toContain('Normal')
})
it("shows hint text directing users to runner pills", () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
it('uses provided currentSetup values', () => {
const currentSetup: DefensiveDecision = {
infield_depth: 'back',
outfield_depth: 'normal',
hold_runners: [1, 3],
}
expect(wrapper.text()).toContain(
"Tap the H icons on the runner pills above",
);
});
});
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
currentSetup,
},
})
describe("Initial Values", () => {
it("uses default values when no currentSetup provided", () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
expect(wrapper.vm.localSetup.infield_depth).toBe('back')
expect(wrapper.vm.localSetup.outfield_depth).toBe('normal')
expect(wrapper.vm.localSetup.hold_runners).toEqual([1, 3])
})
})
// Check preview shows defaults
expect(wrapper.text()).toContain("Normal");
});
describe('Hold Runners', () => {
it('initializes hold runner toggles from currentSetup', () => {
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
currentSetup: {
infield_depth: 'normal',
outfield_depth: 'normal',
hold_runners: [1, 2],
},
},
})
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],
};
expect(wrapper.vm.holdFirst).toBe(true)
expect(wrapper.vm.holdSecond).toBe(true)
expect(wrapper.vm.holdThird).toBe(false)
})
mount(DefensiveSetup, {
props: {
...defaultProps,
currentSetup,
},
});
it('updates hold_runners array when toggles change', async () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
})
// 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");
});
});
wrapper.vm.holdFirst = true
wrapper.vm.holdThird = true
await wrapper.vm.$nextTick()
describe("Hold Runners Display", () => {
it('shows "None" when no runners held', () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
expect(wrapper.vm.localSetup.hold_runners).toContain(1)
expect(wrapper.vm.localSetup.hold_runners).toContain(3)
expect(wrapper.vm.localSetup.hold_runners).not.toContain(2)
})
})
expect(wrapper.text()).toContain("None");
});
describe('Preview Display', () => {
it('displays current infield depth in preview', () => {
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
gameState: {
on_third: 123, // Need runner on third for infield_in option
} as any,
currentSetup: {
infield_depth: 'infield_in',
outfield_depth: 'normal',
hold_runners: [],
},
},
})
it("shows held bases as amber badges when runners are held", () => {
/**
* When the composable has held runners, the DefensiveSetup should
* display them as read-only amber pill badges.
*/
const { syncFromDecision } = useDefensiveSetup();
syncFromDecision({
infield_depth: "normal",
outfield_depth: "normal",
hold_runners: [1, 3],
});
expect(wrapper.text()).toContain('Infield In')
})
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
it('displays holding status for multiple runners', () => {
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
currentSetup: {
infield_depth: 'normal',
outfield_depth: 'normal',
hold_runners: [1, 2, 3],
},
},
})
expect(wrapper.text()).toContain("1st");
expect(wrapper.text()).toContain("3rd");
// Verify amber badges exist
const badges = wrapper.findAll(".bg-amber-100");
expect(badges.length).toBe(2);
});
const holdingText = wrapper.vm.holdingDisplay
expect(holdingText).toContain('1st')
expect(holdingText).toContain('2nd')
expect(holdingText).toContain('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],
});
it('shows "None" when no runners held', () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
})
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
expect(wrapper.vm.holdingDisplay).toBe('None')
})
})
expect(wrapper.text()).toContain("1st");
expect(wrapper.text()).toContain("2nd");
expect(wrapper.text()).toContain("3rd");
});
});
describe('Form Submission', () => {
it('emits submit event with current setup', async () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
})
describe("Preview Display", () => {
it("displays current infield depth in preview", () => {
const { syncFromDecision } = useDefensiveSetup();
syncFromDecision({
infield_depth: "infield_in",
outfield_depth: "normal",
hold_runners: [],
});
wrapper.vm.localSetup = {
infield_depth: 'in',
outfield_depth: 'normal',
hold_runners: [2],
}
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
gameState: {
on_third: 123, // Need runner on third for infield_in option
} as any,
},
});
await wrapper.find('form').trigger('submit.prevent')
expect(wrapper.text()).toContain("Infield In");
});
});
expect(wrapper.emitted('submit')).toBeTruthy()
const emitted = wrapper.emitted('submit')![0][0] as DefensiveDecision
expect(emitted.infield_depth).toBe('in')
expect(emitted.outfield_depth).toBe('normal')
expect(emitted.hold_runners).toEqual([2])
})
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],
});
it('does not submit when not active', async () => {
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
isActive: false,
},
})
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
await wrapper.find('form').trigger('submit.prevent')
expect(wrapper.emitted('submit')).toBeFalsy()
})
await wrapper.find("form").trigger("submit.prevent");
it('allows submit with no changes (keep setup)', async () => {
const currentSetup: DefensiveDecision = {
infield_depth: 'normal',
outfield_depth: 'normal',
hold_runners: [],
}
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]);
});
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
currentSetup,
},
})
it("does not submit when not active", async () => {
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
isActive: false,
},
});
await wrapper.find('form').trigger('submit.prevent')
// Component allows submitting same setup to confirm player's choice
expect(wrapper.emitted('submit')).toBeTruthy()
const emitted = wrapper.emitted('submit')![0][0] as DefensiveDecision
expect(emitted).toEqual(currentSetup)
})
await wrapper.find("form").trigger("submit.prevent");
expect(wrapper.emitted("submit")).toBeFalsy();
});
it('shows loading state during submission', async () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
})
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,
});
// Trigger submission
wrapper.vm.submitting = true
await wrapper.vm.$nextTick()
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([]);
});
// Verify button is in loading state
expect(wrapper.vm.submitting).toBe(true)
})
})
it("shows loading state during submission", async () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
describe('Submit Button State', () => {
it('shows "Wait for Your Turn" when not active', () => {
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
isActive: false,
},
})
// Trigger submission
wrapper.vm.submitting = true;
await wrapper.vm.$nextTick();
expect(wrapper.vm.submitButtonText).toBe('Wait for Your Turn')
})
// Verify button is in loading state
expect(wrapper.vm.submitting).toBe(true);
});
});
it('shows "Submit (Keep Setup)" when setup unchanged', () => {
const currentSetup: DefensiveDecision = {
infield_depth: 'normal',
outfield_depth: 'normal',
hold_runners: [],
}
describe("Submit Button State", () => {
it('shows "Wait for Your Turn" when not active', () => {
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
isActive: false,
},
});
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
currentSetup,
},
})
expect(wrapper.vm.submitButtonText).toBe("Wait for Your Turn");
});
expect(wrapper.vm.submitButtonText).toBe('Submit (Keep Setup)')
})
it('shows "Submit Defensive Setup" when active', () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
});
it('shows "Submit Defensive Setup" when active with changes', () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
})
expect(wrapper.vm.submitButtonText).toBe("Submit Defensive Setup");
});
});
wrapper.vm.localSetup.infield_depth = 'back'
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,
});
describe('Change Detection', () => {
it('detects infield depth changes', () => {
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
currentSetup: {
infield_depth: 'normal',
outfield_depth: 'normal',
hold_runners: [],
},
},
})
const newSetup: DefensiveDecision = {
infield_depth: "infield_in",
outfield_depth: "normal",
hold_runners: [1, 2, 3],
};
expect(wrapper.vm.hasChanges).toBe(false)
wrapper.vm.localSetup.infield_depth = 'back'
expect(wrapper.vm.hasChanges).toBe(true)
})
await wrapper.setProps({ currentSetup: newSetup });
it('detects hold runners changes', () => {
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
currentSetup: {
infield_depth: 'normal',
outfield_depth: 'normal',
hold_runners: [],
},
},
})
const { infieldDepth, outfieldDepth, holdRunnersArray } =
useDefensiveSetup();
expect(infieldDepth.value).toBe("infield_in");
expect(outfieldDepth.value).toBe("normal");
expect(holdRunnersArray.value).toEqual([1, 2, 3]);
});
});
expect(wrapper.vm.hasChanges).toBe(false)
wrapper.vm.localSetup.hold_runners = [1]
expect(wrapper.vm.hasChanges).toBe(true)
})
})
describe("Disabled State", () => {
it("disables depth controls when not active", () => {
const wrapper = mount(DefensiveSetup, {
props: {
...defaultProps,
isActive: false,
},
});
describe('Prop Updates', () => {
it('updates local state when currentSetup prop changes', async () => {
const wrapper = mount(DefensiveSetup, {
props: defaultProps,
})
const newSetup: DefensiveDecision = {
infield_depth: 'double_play',
outfield_depth: 'normal',
hold_runners: [1, 2, 3],
}
await wrapper.setProps({ currentSetup: newSetup })
expect(wrapper.vm.localSetup.infield_depth).toBe('double_play')
expect(wrapper.vm.localSetup.outfield_depth).toBe('normal')
expect(wrapper.vm.localSetup.hold_runners).toEqual([1, 2, 3])
})
})
describe('Disabled State', () => {
it('disables all 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)
})
const toggles = wrapper.findAllComponents({ name: 'ToggleSwitch' })
toggles.forEach(toggle => {
expect(toggle.props('disabled')).toBe(true)
})
})
})
})
const buttonGroups = wrapper.findAllComponents({
name: "ButtonGroup",
});
buttonGroups.forEach((bg) => {
expect(bg.props("disabled")).toBe(true);
});
});
});
});

View File

@ -306,6 +306,192 @@ describe("RunnerCard", () => {
});
});
describe("hold runner icon", () => {
it("does not show hold icon by default", () => {
/**
* When neither isHeld nor holdInteractive is set, the hold icon
* should not appear keeps the pill clean for non-defensive contexts.
*/
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isSelected: false,
teamColor: "#3b82f6",
},
});
expect(wrapper.find(".hold-icon").exists()).toBe(false);
});
it("shows hold icon when holdInteractive is true", () => {
/**
* During the defensive decision phase, holdInteractive is true
* and the icon should appear even when the runner is not held.
*/
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isSelected: false,
teamColor: "#3b82f6",
holdInteractive: true,
isHeld: false,
},
});
expect(wrapper.find(".hold-icon").exists()).toBe(true);
});
it("shows hold icon when isHeld is true (read-only)", () => {
/**
* After submission, isHeld shows the current hold state as a
* non-interactive indicator even when holdInteractive is false.
*/
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isSelected: false,
teamColor: "#3b82f6",
isHeld: true,
holdInteractive: false,
},
});
const icon = wrapper.find(".hold-icon");
expect(icon.exists()).toBe(true);
expect(icon.attributes("disabled")).toBeDefined();
});
it("applies amber styling when held", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isSelected: false,
teamColor: "#3b82f6",
isHeld: true,
holdInteractive: true,
},
});
const icon = wrapper.find(".hold-icon");
expect(icon.classes()).toContain("bg-amber-500");
});
it("applies gray styling when not held", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isSelected: false,
teamColor: "#3b82f6",
isHeld: false,
holdInteractive: true,
},
});
const icon = wrapper.find(".hold-icon");
expect(icon.classes()).toContain("bg-gray-200");
});
it("emits toggleHold when clicked in interactive mode", async () => {
/**
* Clicking the hold icon should emit toggleHold so the parent
* can update the composable state.
*/
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isSelected: false,
teamColor: "#3b82f6",
isHeld: false,
holdInteractive: true,
},
});
await wrapper.find(".hold-icon").trigger("click");
expect(wrapper.emitted("toggleHold")).toHaveLength(1);
});
it("does not emit toggleHold when not interactive", async () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isSelected: false,
teamColor: "#3b82f6",
isHeld: true,
holdInteractive: false,
},
});
await wrapper.find(".hold-icon").trigger("click");
expect(wrapper.emitted("toggleHold")).toBeUndefined();
});
it("does not emit click (selection) when hold icon is clicked", async () => {
/**
* The hold icon uses @click.stop so tapping it should NOT trigger
* the pill's selection behavior only the hold toggle.
*/
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isSelected: false,
teamColor: "#3b82f6",
isHeld: false,
holdInteractive: true,
},
});
await wrapper.find(".hold-icon").trigger("click");
expect(wrapper.emitted("toggleHold")).toHaveLength(1);
expect(wrapper.emitted("click")).toBeUndefined();
});
it("applies held class to the pill when isHeld", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isSelected: false,
teamColor: "#3b82f6",
isHeld: true,
},
});
expect(wrapper.find(".runner-pill.held").exists()).toBe(true);
});
it("does not show hold icon on empty bases", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: null,
isSelected: false,
teamColor: "#3b82f6",
holdInteractive: true,
},
});
expect(wrapper.find(".hold-icon").exists()).toBe(false);
});
});
describe("base label variations", () => {
it("displays 1B correctly", () => {
const wrapper = mount(RunnerCard, {

View File

@ -0,0 +1,153 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { useDefensiveSetup } from '~/composables/useDefensiveSetup'
describe('useDefensiveSetup', () => {
beforeEach(() => {
const { reset } = useDefensiveSetup()
reset()
})
describe('singleton behavior', () => {
it('returns the same state across multiple calls', () => {
/**
* The composable is a module-level singleton multiple calls to
* useDefensiveSetup() should return refs pointing to the same state.
*/
const a = useDefensiveSetup()
const b = useDefensiveSetup()
a.toggleHold(1)
expect(b.isHeld(1)).toBe(true)
expect(b.holdRunnersArray.value).toEqual([1])
})
})
describe('toggleHold', () => {
it('adds a base when not held', () => {
const { toggleHold, isHeld } = useDefensiveSetup()
toggleHold(1)
expect(isHeld(1)).toBe(true)
})
it('removes a base when already held', () => {
const { toggleHold, isHeld } = useDefensiveSetup()
toggleHold(2)
expect(isHeld(2)).toBe(true)
toggleHold(2)
expect(isHeld(2)).toBe(false)
})
it('can hold multiple bases independently', () => {
const { toggleHold, isHeld, holdRunnersArray } = useDefensiveSetup()
toggleHold(1)
toggleHold(3)
expect(isHeld(1)).toBe(true)
expect(isHeld(2)).toBe(false)
expect(isHeld(3)).toBe(true)
expect(holdRunnersArray.value).toEqual([1, 3])
})
})
describe('holdRunnersArray', () => {
it('returns sorted array of held base numbers', () => {
/**
* holdRunnersArray should always be sorted so the output is
* deterministic regardless of toggle order.
*/
const { toggleHold, holdRunnersArray } = useDefensiveSetup()
toggleHold(3)
toggleHold(1)
expect(holdRunnersArray.value).toEqual([1, 3])
})
it('returns empty array when nothing is held', () => {
const { holdRunnersArray } = useDefensiveSetup()
expect(holdRunnersArray.value).toEqual([])
})
})
describe('reset', () => {
it('clears all hold state and resets depths to defaults', () => {
const { toggleHold, infieldDepth, outfieldDepth, holdRunnersArray, reset } = useDefensiveSetup()
toggleHold(1)
toggleHold(2)
infieldDepth.value = 'infield_in'
outfieldDepth.value = 'shallow'
reset()
expect(holdRunnersArray.value).toEqual([])
expect(infieldDepth.value).toBe('normal')
expect(outfieldDepth.value).toBe('normal')
})
})
describe('syncFromDecision', () => {
it('sets all state from a DefensiveDecision object', () => {
const { syncFromDecision, infieldDepth, outfieldDepth, holdRunnersArray } = useDefensiveSetup()
syncFromDecision({
infield_depth: 'corners_in',
outfield_depth: 'shallow',
hold_runners: [1, 3],
})
expect(infieldDepth.value).toBe('corners_in')
expect(outfieldDepth.value).toBe('shallow')
expect(holdRunnersArray.value).toEqual([1, 3])
})
it('clears previously held runners not in new decision', () => {
const { toggleHold, syncFromDecision, isHeld } = useDefensiveSetup()
toggleHold(1)
toggleHold(2)
toggleHold(3)
syncFromDecision({
infield_depth: 'normal',
outfield_depth: 'normal',
hold_runners: [2],
})
expect(isHeld(1)).toBe(false)
expect(isHeld(2)).toBe(true)
expect(isHeld(3)).toBe(false)
})
})
describe('getDecision', () => {
it('returns a valid DefensiveDecision from current state', () => {
const { toggleHold, infieldDepth, getDecision } = useDefensiveSetup()
infieldDepth.value = 'infield_in'
toggleHold(1)
toggleHold(3)
const decision = getDecision()
expect(decision).toEqual({
infield_depth: 'infield_in',
outfield_depth: 'normal',
hold_runners: [1, 3],
})
})
it('returns defaults when nothing has been set', () => {
const { getDecision } = useDefensiveSetup()
expect(getDecision()).toEqual({
infield_depth: 'normal',
outfield_depth: 'normal',
hold_runners: [],
})
})
})
})