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
|
Infield Depth
|
||||||
</label>
|
</label>
|
||||||
<ButtonGroup
|
<ButtonGroup
|
||||||
v-model="localSetup.infield_depth"
|
v-model="infieldDepth"
|
||||||
:options="infieldDepthOptions"
|
:options="infieldDepthOptions"
|
||||||
:disabled="!isActive"
|
:disabled="!isActive"
|
||||||
size="md"
|
size="md"
|
||||||
@ -37,7 +37,7 @@
|
|||||||
Outfield Depth
|
Outfield Depth
|
||||||
</label>
|
</label>
|
||||||
<ButtonGroup
|
<ButtonGroup
|
||||||
v-model="localSetup.outfield_depth"
|
v-model="outfieldDepth"
|
||||||
:options="outfieldDepthOptions"
|
:options="outfieldDepthOptions"
|
||||||
:disabled="!isActive"
|
:disabled="!isActive"
|
||||||
size="md"
|
size="md"
|
||||||
@ -45,33 +45,6 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 -->
|
<!-- 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">
|
<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">
|
<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 type { DefensiveDecision, GameState } from '~/types/game'
|
||||||
import ButtonGroup from '~/components/UI/ButtonGroup.vue'
|
import ButtonGroup from '~/components/UI/ButtonGroup.vue'
|
||||||
import type { ButtonGroupOption } 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 ActionButton from '~/components/UI/ActionButton.vue'
|
||||||
|
import { useDefensiveSetup } from '~/composables/useDefensiveSetup'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
gameId: string
|
gameId: string
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
currentSetup?: DefensiveDecision
|
currentSetup?: DefensiveDecision
|
||||||
gameState?: GameState // Added for smart filtering
|
gameState?: GameState
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@ -131,27 +104,10 @@ const emit = defineEmits<{
|
|||||||
submit: [setup: DefensiveDecision]
|
submit: [setup: DefensiveDecision]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const { infieldDepth, outfieldDepth, holdRunnersArray, getDecision, syncFromDecision } = useDefensiveSetup()
|
||||||
|
|
||||||
// Local state
|
// Local state
|
||||||
const submitting = ref(false)
|
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
|
// Dynamic options based on game state
|
||||||
const infieldDepthOptions = computed<ButtonGroupOption[]>(() => {
|
const infieldDepthOptions = computed<ButtonGroupOption[]>(() => {
|
||||||
@ -194,18 +150,19 @@ const outfieldDepthOptions = computed<ButtonGroupOption[]>(() => {
|
|||||||
|
|
||||||
// Display helpers
|
// Display helpers
|
||||||
const infieldDisplay = computed(() => {
|
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'
|
return option?.label || 'Normal'
|
||||||
})
|
})
|
||||||
|
|
||||||
const outfieldDisplay = computed(() => {
|
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'
|
return option?.label || 'Normal'
|
||||||
})
|
})
|
||||||
|
|
||||||
const holdingDisplay = computed(() => {
|
const holdingDisplay = computed(() => {
|
||||||
if (localSetup.value.hold_runners.length === 0) return 'None'
|
const arr = holdRunnersArray.value
|
||||||
return localSetup.value.hold_runners.map(base => {
|
if (arr.length === 0) return 'None'
|
||||||
|
return arr.map(base => {
|
||||||
if (base === 1) return '1st'
|
if (base === 1) return '1st'
|
||||||
if (base === 2) return '2nd'
|
if (base === 2) return '2nd'
|
||||||
if (base === 3) return '3rd'
|
if (base === 3) return '3rd'
|
||||||
@ -213,19 +170,8 @@ const holdingDisplay = computed(() => {
|
|||||||
}).join(', ')
|
}).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(() => {
|
const submitButtonText = computed(() => {
|
||||||
if (!props.isActive) return 'Wait for Your Turn'
|
if (!props.isActive) return 'Wait for Your Turn'
|
||||||
if (!hasChanges.value) return 'Submit (Keep Setup)'
|
|
||||||
return 'Submit Defensive Setup'
|
return 'Submit Defensive Setup'
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -235,19 +181,16 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
emit('submit', { ...localSetup.value })
|
emit('submit', getDecision())
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
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) => {
|
watch(() => props.currentSetup, (newSetup) => {
|
||||||
if (newSetup) {
|
if (newSetup) {
|
||||||
localSetup.value = { ...newSetup }
|
syncFromDecision(newSetup)
|
||||||
holdFirst.value = newSetup.hold_runners.includes(1)
|
|
||||||
holdSecond.value = newSetup.hold_runners.includes(2)
|
|
||||||
holdThird.value = newSetup.hold_runners.includes(3)
|
|
||||||
}
|
}
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -79,6 +79,9 @@
|
|||||||
:fielding-team-color="fieldingTeamColor"
|
:fielding-team-color="fieldingTeamColor"
|
||||||
:batting-team-abbrev="batterTeamAbbrev"
|
:batting-team-abbrev="batterTeamAbbrev"
|
||||||
:fielding-team-abbrev="pitcherTeamAbbrev"
|
:fielding-team-abbrev="pitcherTeamAbbrev"
|
||||||
|
:hold-runners="defensiveSetup.holdRunnersArray.value"
|
||||||
|
:hold-interactive="holdInteractive"
|
||||||
|
@toggle-hold="handleToggleHold"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Decision Panel (Phase F3) -->
|
<!-- Decision Panel (Phase F3) -->
|
||||||
@ -146,6 +149,9 @@
|
|||||||
:fielding-team-color="fieldingTeamColor"
|
:fielding-team-color="fieldingTeamColor"
|
||||||
:batting-team-abbrev="batterTeamAbbrev"
|
:batting-team-abbrev="batterTeamAbbrev"
|
||||||
:fielding-team-abbrev="pitcherTeamAbbrev"
|
:fielding-team-abbrev="pitcherTeamAbbrev"
|
||||||
|
:hold-runners="defensiveSetup.holdRunnersArray.value"
|
||||||
|
:hold-interactive="holdInteractive"
|
||||||
|
@toggle-hold="handleToggleHold"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Decision Panel (Phase F3) -->
|
<!-- Decision Panel (Phase F3) -->
|
||||||
@ -328,6 +334,7 @@ import { useAuthStore } from '~/store/auth'
|
|||||||
import { useUiStore } from '~/store/ui'
|
import { useUiStore } from '~/store/ui'
|
||||||
import { useWebSocket } from '~/composables/useWebSocket'
|
import { useWebSocket } from '~/composables/useWebSocket'
|
||||||
import { useGameActions } from '~/composables/useGameActions'
|
import { useGameActions } from '~/composables/useGameActions'
|
||||||
|
import { useDefensiveSetup } from '~/composables/useDefensiveSetup'
|
||||||
import CurrentSituation from '~/components/Game/CurrentSituation.vue'
|
import CurrentSituation from '~/components/Game/CurrentSituation.vue'
|
||||||
import RunnersOnBase from '~/components/Game/RunnersOnBase.vue'
|
import RunnersOnBase from '~/components/Game/RunnersOnBase.vue'
|
||||||
import PlayByPlay from '~/components/Game/PlayByPlay.vue'
|
import PlayByPlay from '~/components/Game/PlayByPlay.vue'
|
||||||
@ -363,6 +370,9 @@ const actions = useGameActions(props.gameId)
|
|||||||
// Destructure undoLastPlay for the undo button
|
// Destructure undoLastPlay for the undo button
|
||||||
const { undoLastPlay } = actions
|
const { undoLastPlay } = actions
|
||||||
|
|
||||||
|
// Defensive setup composable (shared with DefensiveSetup.vue and RunnersOnBase)
|
||||||
|
const defensiveSetup = useDefensiveSetup()
|
||||||
|
|
||||||
// Game state from store
|
// Game state from store
|
||||||
const gameState = computed(() => {
|
const gameState = computed(() => {
|
||||||
const state = gameStore.gameState
|
const state = gameStore.gameState
|
||||||
@ -531,6 +541,9 @@ const decisionPhase = computed(() => {
|
|||||||
return 'idle'
|
return 'idle'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Hold runner toggles are interactive only during defensive decision phase
|
||||||
|
const holdInteractive = computed(() => needsDefensiveDecision.value && isMyTurn.value)
|
||||||
|
|
||||||
// Phase F6: Conditional panel rendering
|
// Phase F6: Conditional panel rendering
|
||||||
const showDecisions = computed(() => {
|
const showDecisions = computed(() => {
|
||||||
// Don't show decision panels if there's a result pending dismissal
|
// Don't show decision panels if there's a result pending dismissal
|
||||||
@ -643,6 +656,10 @@ const handleStealAttemptsSubmit = (attempts: number[]) => {
|
|||||||
gameStore.setPendingStealAttempts(attempts)
|
gameStore.setPendingStealAttempts(attempts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleToggleHold = (base: number) => {
|
||||||
|
defensiveSetup.toggleHold(base)
|
||||||
|
}
|
||||||
|
|
||||||
// Undo handler
|
// Undo handler
|
||||||
const handleUndoLastPlay = () => {
|
const handleUndoLastPlay = () => {
|
||||||
console.log('[GamePlay] Undoing last play')
|
console.log('[GamePlay] Undoing last play')
|
||||||
@ -715,6 +732,18 @@ watch(gameState, (state, oldState) => {
|
|||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { 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
|
// Quality of Life: Auto-submit default decisions when bases are empty
|
||||||
watch([needsDefensiveDecision, needsOffensiveDecision, basesEmpty], ([defensive, offensive, empty]) => {
|
watch([needsDefensiveDecision, needsOffensiveDecision, basesEmpty], ([defensive, offensive, empty]) => {
|
||||||
// Only auto-submit if it's the player's turn and bases are empty
|
// Only auto-submit if it's the player's turn and bases are empty
|
||||||
|
|||||||
@ -3,7 +3,8 @@
|
|||||||
:class="[
|
:class="[
|
||||||
'runner-pill',
|
'runner-pill',
|
||||||
runner ? 'occupied' : 'empty',
|
runner ? 'occupied' : 'empty',
|
||||||
isSelected ? 'selected' : ''
|
isSelected ? 'selected' : '',
|
||||||
|
isHeld ? 'held' : ''
|
||||||
]"
|
]"
|
||||||
@click="handleClick"
|
@click="handleClick"
|
||||||
>
|
>
|
||||||
@ -24,6 +25,30 @@
|
|||||||
<div class="text-xs font-bold text-gray-900 truncate">{{ runnerName }}</div>
|
<div class="text-xs font-bold text-gray-900 truncate">{{ runnerName }}</div>
|
||||||
<div class="text-[10px] text-gray-500">{{ base }}</div>
|
<div class="text-[10px] text-gray-500">{{ base }}</div>
|
||||||
</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>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Empty base -->
|
<!-- Empty base -->
|
||||||
@ -45,11 +70,18 @@ interface Props {
|
|||||||
runner: LineupPlayerState | null
|
runner: LineupPlayerState | null
|
||||||
isSelected: boolean
|
isSelected: boolean
|
||||||
teamColor: string
|
teamColor: string
|
||||||
|
isHeld?: boolean
|
||||||
|
holdInteractive?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
isHeld: false,
|
||||||
|
holdInteractive: false,
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
click: []
|
click: []
|
||||||
|
toggleHold: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const gameStore = useGameStore()
|
const gameStore = useGameStore()
|
||||||
@ -66,26 +98,17 @@ const runnerName = computed(() => {
|
|||||||
return runnerPlayer.value.name
|
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() {
|
function handleClick() {
|
||||||
if (props.runner) {
|
if (props.runner) {
|
||||||
emit('click')
|
emit('click')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleToggleHold() {
|
||||||
|
if (props.holdInteractive) {
|
||||||
|
emit('toggleHold')
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -102,7 +125,15 @@ function handleClick() {
|
|||||||
@apply ring-2 ring-red-500 bg-red-50 shadow-md;
|
@apply ring-2 ring-red-500 bg-red-50 shadow-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.runner-pill.held {
|
||||||
|
@apply border-amber-400;
|
||||||
|
}
|
||||||
|
|
||||||
.runner-pill.empty {
|
.runner-pill.empty {
|
||||||
@apply bg-gray-50 opacity-60;
|
@apply bg-gray-50 opacity-60;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hold-icon:disabled {
|
||||||
|
@apply opacity-70;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -13,7 +13,10 @@
|
|||||||
:runner="runners[key]"
|
:runner="runners[key]"
|
||||||
:is-selected="selectedRunner === key"
|
:is-selected="selectedRunner === key"
|
||||||
:team-color="'#ef4444'"
|
:team-color="'#ef4444'"
|
||||||
|
:is-held="holdRunners.includes(baseNameToNumber[key])"
|
||||||
|
:hold-interactive="holdInteractive"
|
||||||
@click="toggleRunner(key)"
|
@click="toggleRunner(key)"
|
||||||
|
@toggle-hold="emit('toggleHold', baseNameToNumber[key])"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -112,6 +115,8 @@ interface Props {
|
|||||||
fieldingTeamColor?: string
|
fieldingTeamColor?: string
|
||||||
battingTeamAbbrev?: string
|
battingTeamAbbrev?: string
|
||||||
fieldingTeamAbbrev?: string
|
fieldingTeamAbbrev?: string
|
||||||
|
holdRunners?: number[]
|
||||||
|
holdInteractive?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@ -120,8 +125,14 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
fieldingTeamColor: '#10b981',
|
fieldingTeamColor: '#10b981',
|
||||||
battingTeamAbbrev: '',
|
battingTeamAbbrev: '',
|
||||||
fieldingTeamAbbrev: '',
|
fieldingTeamAbbrev: '',
|
||||||
|
holdRunners: () => [],
|
||||||
|
holdInteractive: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
toggleHold: [base: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
const gameStore = useGameStore()
|
const gameStore = useGameStore()
|
||||||
const selectedRunner = ref<'first' | 'second' | 'third' | 'catcher' | null>(null)
|
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 baseKeys = ['third', 'second', 'first'] as const
|
||||||
const baseLabels: ('1B' | '2B' | '3B')[] = ['3B', '2B', '1B']
|
const baseLabels: ('1B' | '2B' | '3B')[] = ['3B', '2B', '1B']
|
||||||
const baseNameToLabel: Record<string, string> = { first: '1B', second: '2B', third: '3B' }
|
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
|
// Auto-select lead runner on mount
|
||||||
onMounted(() => {
|
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 { describe, it, expect, beforeEach } from "vitest";
|
||||||
import { mount } from '@vue/test-utils'
|
import { mount } from "@vue/test-utils";
|
||||||
import DefensiveSetup from '~/components/Decisions/DefensiveSetup.vue'
|
import DefensiveSetup from "~/components/Decisions/DefensiveSetup.vue";
|
||||||
import type { DefensiveDecision } from '~/types/game'
|
import { useDefensiveSetup } from "~/composables/useDefensiveSetup";
|
||||||
|
import type { DefensiveDecision } from "~/types/game";
|
||||||
|
|
||||||
describe('DefensiveSetup', () => {
|
describe("DefensiveSetup", () => {
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
gameId: 'test-game-123',
|
gameId: "test-game-123",
|
||||||
isActive: true,
|
isActive: true,
|
||||||
}
|
};
|
||||||
|
|
||||||
describe('Rendering', () => {
|
beforeEach(() => {
|
||||||
it('renders component with header', () => {
|
// Reset the singleton composable state before each test
|
||||||
|
const { reset } = useDefensiveSetup();
|
||||||
|
reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Rendering", () => {
|
||||||
|
it("renders component with header", () => {
|
||||||
const wrapper = mount(DefensiveSetup, {
|
const wrapper = mount(DefensiveSetup, {
|
||||||
props: defaultProps,
|
props: defaultProps,
|
||||||
})
|
});
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('Defensive Setup')
|
expect(wrapper.text()).toContain("Defensive Setup");
|
||||||
})
|
});
|
||||||
|
|
||||||
it('shows opponent turn indicator when not active', () => {
|
it("shows opponent turn indicator when not active", () => {
|
||||||
const wrapper = mount(DefensiveSetup, {
|
const wrapper = mount(DefensiveSetup, {
|
||||||
props: {
|
props: {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
isActive: false,
|
isActive: false,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
expect(wrapper.text()).toContain("Opponent's Turn")
|
expect(wrapper.text()).toContain("Opponent's Turn");
|
||||||
})
|
});
|
||||||
|
|
||||||
it('renders all form sections', () => {
|
it("renders all form sections", () => {
|
||||||
const wrapper = mount(DefensiveSetup, {
|
const wrapper = mount(DefensiveSetup, {
|
||||||
props: defaultProps,
|
props: defaultProps,
|
||||||
})
|
});
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('Infield Depth')
|
expect(wrapper.text()).toContain("Infield Depth");
|
||||||
expect(wrapper.text()).toContain('Outfield Depth')
|
expect(wrapper.text()).toContain("Outfield Depth");
|
||||||
expect(wrapper.text()).toContain('Hold Runners')
|
expect(wrapper.text()).toContain("Hold Runners");
|
||||||
})
|
});
|
||||||
})
|
|
||||||
|
|
||||||
describe('Initial Values', () => {
|
it("shows hint text directing users to runner pills", () => {
|
||||||
it('uses default values when no currentSetup provided', () => {
|
|
||||||
const wrapper = mount(DefensiveSetup, {
|
const wrapper = mount(DefensiveSetup, {
|
||||||
props: defaultProps,
|
props: defaultProps,
|
||||||
})
|
});
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain(
|
||||||
|
"Tap the H icons on the runner pills above",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Initial Values", () => {
|
||||||
|
it("uses default values when no currentSetup provided", () => {
|
||||||
|
const wrapper = mount(DefensiveSetup, {
|
||||||
|
props: defaultProps,
|
||||||
|
});
|
||||||
|
|
||||||
// Check preview shows defaults
|
// Check preview shows defaults
|
||||||
expect(wrapper.text()).toContain('Normal')
|
expect(wrapper.text()).toContain("Normal");
|
||||||
})
|
});
|
||||||
|
|
||||||
it('uses provided currentSetup values', () => {
|
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 = {
|
const currentSetup: DefensiveDecision = {
|
||||||
infield_depth: 'back',
|
infield_depth: "normal",
|
||||||
outfield_depth: 'normal',
|
outfield_depth: "normal",
|
||||||
hold_runners: [1, 3],
|
hold_runners: [1, 3],
|
||||||
}
|
};
|
||||||
|
|
||||||
const wrapper = mount(DefensiveSetup, {
|
mount(DefensiveSetup, {
|
||||||
props: {
|
props: {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
currentSetup,
|
currentSetup,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
expect(wrapper.vm.localSetup.infield_depth).toBe('back')
|
// The composable should be synced from the prop via the watcher
|
||||||
expect(wrapper.vm.localSetup.outfield_depth).toBe('normal')
|
const { holdRunnersArray, infieldDepth, outfieldDepth } =
|
||||||
expect(wrapper.vm.localSetup.hold_runners).toEqual([1, 3])
|
useDefensiveSetup();
|
||||||
})
|
// Watcher fires on prop change, check initial sync happens
|
||||||
})
|
expect(infieldDepth.value).toBe("normal");
|
||||||
|
expect(outfieldDepth.value).toBe("normal");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Hold Runners', () => {
|
describe("Hold Runners Display", () => {
|
||||||
it('initializes hold runner toggles from currentSetup', () => {
|
it('shows "None" when no runners held', () => {
|
||||||
const wrapper = mount(DefensiveSetup, {
|
|
||||||
props: {
|
|
||||||
...defaultProps,
|
|
||||||
currentSetup: {
|
|
||||||
infield_depth: 'normal',
|
|
||||||
outfield_depth: 'normal',
|
|
||||||
hold_runners: [1, 2],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(wrapper.vm.holdFirst).toBe(true)
|
|
||||||
expect(wrapper.vm.holdSecond).toBe(true)
|
|
||||||
expect(wrapper.vm.holdThird).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('updates hold_runners array when toggles change', async () => {
|
|
||||||
const wrapper = mount(DefensiveSetup, {
|
const wrapper = mount(DefensiveSetup, {
|
||||||
props: defaultProps,
|
props: defaultProps,
|
||||||
})
|
});
|
||||||
|
|
||||||
wrapper.vm.holdFirst = true
|
expect(wrapper.text()).toContain("None");
|
||||||
wrapper.vm.holdThird = true
|
});
|
||||||
await wrapper.vm.$nextTick()
|
|
||||||
|
|
||||||
expect(wrapper.vm.localSetup.hold_runners).toContain(1)
|
it("shows held bases as amber badges when runners are held", () => {
|
||||||
expect(wrapper.vm.localSetup.hold_runners).toContain(3)
|
/**
|
||||||
expect(wrapper.vm.localSetup.hold_runners).not.toContain(2)
|
* 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],
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapper = mount(DefensiveSetup, {
|
||||||
|
props: defaultProps,
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
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("1st");
|
||||||
|
expect(wrapper.text()).toContain("2nd");
|
||||||
|
expect(wrapper.text()).toContain("3rd");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Preview Display", () => {
|
||||||
|
it("displays current infield depth in preview", () => {
|
||||||
|
const { syncFromDecision } = useDefensiveSetup();
|
||||||
|
syncFromDecision({
|
||||||
|
infield_depth: "infield_in",
|
||||||
|
outfield_depth: "normal",
|
||||||
|
hold_runners: [],
|
||||||
|
});
|
||||||
|
|
||||||
describe('Preview Display', () => {
|
|
||||||
it('displays current infield depth in preview', () => {
|
|
||||||
const wrapper = mount(DefensiveSetup, {
|
const wrapper = mount(DefensiveSetup, {
|
||||||
props: {
|
props: {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
gameState: {
|
gameState: {
|
||||||
on_third: 123, // Need runner on third for infield_in option
|
on_third: 123, // Need runner on third for infield_in option
|
||||||
} as any,
|
} as any,
|
||||||
currentSetup: {
|
|
||||||
infield_depth: 'infield_in',
|
|
||||||
outfield_depth: 'normal',
|
|
||||||
hold_runners: [],
|
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
})
|
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('Infield In')
|
expect(wrapper.text()).toContain("Infield In");
|
||||||
})
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('displays holding status for multiple runners', () => {
|
describe("Form Submission", () => {
|
||||||
const wrapper = mount(DefensiveSetup, {
|
it("emits submit event with composable state", async () => {
|
||||||
props: {
|
/**
|
||||||
...defaultProps,
|
* On submit, the component should call getDecision() from the composable
|
||||||
currentSetup: {
|
* and emit the full DefensiveDecision.
|
||||||
infield_depth: 'normal',
|
*/
|
||||||
outfield_depth: 'normal',
|
const { syncFromDecision } = useDefensiveSetup();
|
||||||
hold_runners: [1, 2, 3],
|
syncFromDecision({
|
||||||
},
|
infield_depth: "normal",
|
||||||
},
|
outfield_depth: "normal",
|
||||||
})
|
|
||||||
|
|
||||||
const holdingText = wrapper.vm.holdingDisplay
|
|
||||||
expect(holdingText).toContain('1st')
|
|
||||||
expect(holdingText).toContain('2nd')
|
|
||||||
expect(holdingText).toContain('3rd')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows "None" when no runners held', () => {
|
|
||||||
const wrapper = mount(DefensiveSetup, {
|
|
||||||
props: defaultProps,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(wrapper.vm.holdingDisplay).toBe('None')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Form Submission', () => {
|
|
||||||
it('emits submit event with current setup', async () => {
|
|
||||||
const wrapper = mount(DefensiveSetup, {
|
|
||||||
props: defaultProps,
|
|
||||||
})
|
|
||||||
|
|
||||||
wrapper.vm.localSetup = {
|
|
||||||
infield_depth: 'in',
|
|
||||||
outfield_depth: 'normal',
|
|
||||||
hold_runners: [2],
|
hold_runners: [2],
|
||||||
}
|
});
|
||||||
|
|
||||||
await wrapper.find('form').trigger('submit.prevent')
|
const wrapper = mount(DefensiveSetup, {
|
||||||
|
props: defaultProps,
|
||||||
|
});
|
||||||
|
|
||||||
expect(wrapper.emitted('submit')).toBeTruthy()
|
await wrapper.find("form").trigger("submit.prevent");
|
||||||
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])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not submit when not active', async () => {
|
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, {
|
const wrapper = mount(DefensiveSetup, {
|
||||||
props: {
|
props: {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
isActive: false,
|
isActive: false,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
await wrapper.find('form').trigger('submit.prevent')
|
await wrapper.find("form").trigger("submit.prevent");
|
||||||
expect(wrapper.emitted('submit')).toBeFalsy()
|
expect(wrapper.emitted("submit")).toBeFalsy();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('allows submit with no changes (keep setup)', async () => {
|
it("allows submit with default setup", async () => {
|
||||||
const currentSetup: DefensiveDecision = {
|
/**
|
||||||
infield_depth: 'normal',
|
* Submitting with defaults should emit a valid DefensiveDecision
|
||||||
outfield_depth: 'normal',
|
* with normal depth and no held runners.
|
||||||
hold_runners: [],
|
*/
|
||||||
}
|
|
||||||
|
|
||||||
const wrapper = mount(DefensiveSetup, {
|
|
||||||
props: {
|
|
||||||
...defaultProps,
|
|
||||||
currentSetup,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows loading state during submission', async () => {
|
|
||||||
const wrapper = mount(DefensiveSetup, {
|
const wrapper = mount(DefensiveSetup, {
|
||||||
props: defaultProps,
|
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
|
// Trigger submission
|
||||||
wrapper.vm.submitting = true
|
wrapper.vm.submitting = true;
|
||||||
await wrapper.vm.$nextTick()
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
// Verify button is in loading state
|
// Verify button is in loading state
|
||||||
expect(wrapper.vm.submitting).toBe(true)
|
expect(wrapper.vm.submitting).toBe(true);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('Submit Button State', () => {
|
describe("Submit Button State", () => {
|
||||||
it('shows "Wait for Your Turn" when not active', () => {
|
it('shows "Wait for Your Turn" when not active', () => {
|
||||||
const wrapper = mount(DefensiveSetup, {
|
const wrapper = mount(DefensiveSetup, {
|
||||||
props: {
|
props: {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
isActive: false,
|
isActive: false,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
expect(wrapper.vm.submitButtonText).toBe('Wait for Your Turn')
|
expect(wrapper.vm.submitButtonText).toBe("Wait for Your Turn");
|
||||||
})
|
});
|
||||||
|
|
||||||
it('shows "Submit (Keep Setup)" when setup unchanged', () => {
|
it('shows "Submit Defensive Setup" when active', () => {
|
||||||
const currentSetup: DefensiveDecision = {
|
|
||||||
infield_depth: 'normal',
|
|
||||||
outfield_depth: 'normal',
|
|
||||||
hold_runners: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
const wrapper = mount(DefensiveSetup, {
|
|
||||||
props: {
|
|
||||||
...defaultProps,
|
|
||||||
currentSetup,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(wrapper.vm.submitButtonText).toBe('Submit (Keep Setup)')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows "Submit Defensive Setup" when active with changes', () => {
|
|
||||||
const wrapper = mount(DefensiveSetup, {
|
const wrapper = mount(DefensiveSetup, {
|
||||||
props: defaultProps,
|
props: defaultProps,
|
||||||
})
|
});
|
||||||
|
|
||||||
wrapper.vm.localSetup.infield_depth = 'back'
|
expect(wrapper.vm.submitButtonText).toBe("Submit Defensive Setup");
|
||||||
expect(wrapper.vm.submitButtonText).toBe('Submit Defensive Setup')
|
});
|
||||||
})
|
});
|
||||||
})
|
|
||||||
|
|
||||||
describe('Change Detection', () => {
|
describe("Prop Updates", () => {
|
||||||
it('detects infield depth changes', () => {
|
it("syncs composable state when currentSetup prop changes", async () => {
|
||||||
const wrapper = mount(DefensiveSetup, {
|
/**
|
||||||
props: {
|
* When the parent updates the currentSetup prop (e.g. from server state),
|
||||||
...defaultProps,
|
* the composable should be synced to match.
|
||||||
currentSetup: {
|
*/
|
||||||
infield_depth: 'normal',
|
|
||||||
outfield_depth: 'normal',
|
|
||||||
hold_runners: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(wrapper.vm.hasChanges).toBe(false)
|
|
||||||
wrapper.vm.localSetup.infield_depth = 'back'
|
|
||||||
expect(wrapper.vm.hasChanges).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('detects hold runners changes', () => {
|
|
||||||
const wrapper = mount(DefensiveSetup, {
|
|
||||||
props: {
|
|
||||||
...defaultProps,
|
|
||||||
currentSetup: {
|
|
||||||
infield_depth: 'normal',
|
|
||||||
outfield_depth: 'normal',
|
|
||||||
hold_runners: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(wrapper.vm.hasChanges).toBe(false)
|
|
||||||
wrapper.vm.localSetup.hold_runners = [1]
|
|
||||||
expect(wrapper.vm.hasChanges).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Prop Updates', () => {
|
|
||||||
it('updates local state when currentSetup prop changes', async () => {
|
|
||||||
const wrapper = mount(DefensiveSetup, {
|
const wrapper = mount(DefensiveSetup, {
|
||||||
props: defaultProps,
|
props: defaultProps,
|
||||||
})
|
});
|
||||||
|
|
||||||
const newSetup: DefensiveDecision = {
|
const newSetup: DefensiveDecision = {
|
||||||
infield_depth: 'double_play',
|
infield_depth: "infield_in",
|
||||||
outfield_depth: 'normal',
|
outfield_depth: "normal",
|
||||||
hold_runners: [1, 2, 3],
|
hold_runners: [1, 2, 3],
|
||||||
}
|
};
|
||||||
|
|
||||||
await wrapper.setProps({ currentSetup: newSetup })
|
await wrapper.setProps({ currentSetup: newSetup });
|
||||||
|
|
||||||
expect(wrapper.vm.localSetup.infield_depth).toBe('double_play')
|
const { infieldDepth, outfieldDepth, holdRunnersArray } =
|
||||||
expect(wrapper.vm.localSetup.outfield_depth).toBe('normal')
|
useDefensiveSetup();
|
||||||
expect(wrapper.vm.localSetup.hold_runners).toEqual([1, 2, 3])
|
expect(infieldDepth.value).toBe("infield_in");
|
||||||
})
|
expect(outfieldDepth.value).toBe("normal");
|
||||||
})
|
expect(holdRunnersArray.value).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Disabled State', () => {
|
describe("Disabled State", () => {
|
||||||
it('disables all controls when not active', () => {
|
it("disables depth controls when not active", () => {
|
||||||
const wrapper = mount(DefensiveSetup, {
|
const wrapper = mount(DefensiveSetup, {
|
||||||
props: {
|
props: {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
isActive: false,
|
isActive: false,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const buttonGroups = wrapper.findAllComponents({ name: 'ButtonGroup' })
|
const buttonGroups = wrapper.findAllComponents({
|
||||||
buttonGroups.forEach(bg => {
|
name: "ButtonGroup",
|
||||||
expect(bg.props('disabled')).toBe(true)
|
});
|
||||||
})
|
buttonGroups.forEach((bg) => {
|
||||||
|
expect(bg.props("disabled")).toBe(true);
|
||||||
const toggles = wrapper.findAllComponents({ name: 'ToggleSwitch' })
|
});
|
||||||
toggles.forEach(toggle => {
|
});
|
||||||
expect(toggle.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", () => {
|
describe("base label variations", () => {
|
||||||
it("displays 1B correctly", () => {
|
it("displays 1B correctly", () => {
|
||||||
const wrapper = mount(RunnerCard, {
|
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