CLAUDE: Improve hold runner button - fixed width, two-column NOT|HELD display, remove redundant section
This commit is contained in:
parent
46caf9cd81
commit
7c54bfd26b
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
63
frontend-sba/composables/useDefensiveSetup.ts
Normal file
63
frontend-sba/composables/useDefensiveSetup.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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, {
|
||||
|
||||
153
frontend-sba/tests/unit/composables/useDefensiveSetup.spec.ts
Normal file
153
frontend-sba/tests/unit/composables/useDefensiveSetup.spec.ts
Normal 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: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user