CLAUDE: Fix offensive action conditional rendering, remove emojis, always show hold pills
- OffensiveApproach: read game state from store (fix same prop-passing bug as DefensiveSetup), remove steal option (check_jump encompasses it), hide unavailable actions instead of disabling, fix conditions (sac/squeeze: <2 outs + runners, hit-and-run: R1/R3 not R2-only) - Remove all emoji icons from decision components (OffensiveApproach, DefensiveSetup, DecisionPanel) - RunnerCard: always show hold/not-held pills on occupied bases (status indicator in all phases) - DecisionPanel: remove dead hasRunnersOnBase computed and prop pass-through - Rewrite OffensiveApproach tests (32 new tests with Pinia store integration) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2d4dbe82eb
commit
187bd1ccae
@ -19,19 +19,8 @@
|
||||
:game-id="gameId"
|
||||
:is-active="isMyTurn"
|
||||
:current-decision="currentOffensiveDecision"
|
||||
:has-runners-on-base="hasRunnersOnBase"
|
||||
@submit="handleOffensiveSubmit"
|
||||
/>
|
||||
|
||||
<!-- Stolen Base Attempts (if runners on base) -->
|
||||
<StolenBaseInputs
|
||||
v-if="hasRunnersOnBase"
|
||||
:runners="runners"
|
||||
:is-active="isMyTurn"
|
||||
:current-attempts="currentStealAttempts"
|
||||
@submit="handleStealAttemptsSubmit"
|
||||
@cancel="handleStealAttemptsCancel"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Decision History (Collapsible) -->
|
||||
@ -84,7 +73,6 @@
|
||||
v-else
|
||||
class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-8 text-center"
|
||||
>
|
||||
<div class="text-6xl mb-4">⚾</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Waiting for Play
|
||||
</h3>
|
||||
@ -100,7 +88,6 @@ import { ref, computed } from 'vue'
|
||||
import type { DefensiveDecision, OffensiveDecision } from '~/types/game'
|
||||
import DefensiveSetup from './DefensiveSetup.vue'
|
||||
import OffensiveApproach from './OffensiveApproach.vue'
|
||||
import StolenBaseInputs from './StolenBaseInputs.vue'
|
||||
|
||||
interface DecisionHistoryItem {
|
||||
type: 'Defensive' | 'Offensive'
|
||||
@ -120,7 +107,6 @@ interface Props {
|
||||
}
|
||||
currentDefensiveSetup?: DefensiveDecision
|
||||
currentOffensiveDecision?: Omit<OffensiveDecision, 'steal_attempts'>
|
||||
currentStealAttempts?: number[]
|
||||
decisionHistory?: DecisionHistoryItem[]
|
||||
}
|
||||
|
||||
@ -131,26 +117,18 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
second: null,
|
||||
third: null,
|
||||
}),
|
||||
currentStealAttempts: () => [],
|
||||
decisionHistory: () => [],
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
defensiveSubmit: [decision: DefensiveDecision]
|
||||
offensiveSubmit: [decision: Omit<OffensiveDecision, 'steal_attempts'>]
|
||||
stealAttemptsSubmit: [attempts: number[]]
|
||||
}>()
|
||||
|
||||
// Local state
|
||||
const historyExpanded = ref(false)
|
||||
|
||||
// Computed
|
||||
const hasRunnersOnBase = computed(() => {
|
||||
return props.runners.first !== null ||
|
||||
props.runners.second !== null ||
|
||||
props.runners.third !== null
|
||||
})
|
||||
|
||||
const recentDecisions = computed(() => {
|
||||
return props.decisionHistory.slice(0, 3)
|
||||
})
|
||||
@ -164,11 +142,4 @@ const handleOffensiveSubmit = (decision: Omit<OffensiveDecision, 'steal_attempts
|
||||
emit('offensiveSubmit', decision)
|
||||
}
|
||||
|
||||
const handleStealAttemptsSubmit = (attempts: number[]) => {
|
||||
emit('stealAttemptsSubmit', attempts)
|
||||
}
|
||||
|
||||
const handleStealAttemptsCancel = () => {
|
||||
// Reset handled by parent component
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -6,8 +6,7 @@
|
||||
<form class="space-y-3" @submit.prevent="handleSubmit">
|
||||
<!-- Header row: icon + title -->
|
||||
<div class="flex items-center">
|
||||
<h3 class="text-base font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<span class="text-lg">🧤</span>
|
||||
<h3 class="text-base font-bold text-gray-900 dark:text-white">
|
||||
Defense
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@ -2,8 +2,7 @@
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<span class="text-2xl">⚔️</span>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white">
|
||||
Offensive Action
|
||||
</h3>
|
||||
<span
|
||||
@ -26,18 +25,16 @@
|
||||
v-for="option in availableActions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
:disabled="!isActive || option.disabled"
|
||||
:class="getActionButtonClasses(option.value, option.disabled)"
|
||||
:disabled="!isActive"
|
||||
:class="getActionButtonClasses(option.value)"
|
||||
class="touch-manipulation"
|
||||
:title="option.disabledReason"
|
||||
@click="selectAction(option.value)"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-2xl flex-shrink-0">{{ option.icon }}</span>
|
||||
<div class="flex-1 text-left">
|
||||
<div class="font-semibold text-base">{{ option.label }}</div>
|
||||
<div class="text-sm opacity-90 mt-0.5">
|
||||
{{ option.disabled ? option.disabledReason : option.description }}
|
||||
{{ option.description }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="localDecision.action === option.value" class="flex-shrink-0">
|
||||
@ -48,22 +45,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Strategy Summary -->
|
||||
<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">
|
||||
Current Strategy
|
||||
</h4>
|
||||
<div class="space-y-1 text-xs">
|
||||
<div>
|
||||
<span class="font-medium text-gray-600 dark:text-gray-400">Action:</span>
|
||||
<span class="ml-1 text-gray-900 dark:text-white">{{ currentActionLabel }}</span>
|
||||
</div>
|
||||
<div v-if="actionRequiresSpecialHandling" class="mt-2 p-2 bg-yellow-100 dark:bg-yellow-900/30 rounded">
|
||||
<span class="font-medium text-yellow-800 dark:text-yellow-200">{{ specialHandlingNote }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<ActionButton
|
||||
type="submit"
|
||||
@ -83,126 +64,99 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { OffensiveDecision } from '~/types/game'
|
||||
import ActionButton from '~/components/UI/ActionButton.vue'
|
||||
import { useGameStore } from '~/store/game'
|
||||
|
||||
interface ActionOption {
|
||||
value: OffensiveDecision['action']
|
||||
label: string
|
||||
icon: string
|
||||
description: string
|
||||
disabled: boolean
|
||||
disabledReason?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
gameId: string
|
||||
isActive: boolean
|
||||
currentDecision?: Omit<OffensiveDecision, 'steal_attempts'>
|
||||
hasRunnersOnBase?: boolean
|
||||
runnerOnFirst?: boolean
|
||||
runnerOnSecond?: boolean
|
||||
runnerOnThird?: boolean
|
||||
basesLoaded?: boolean
|
||||
outs?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isActive: false,
|
||||
hasRunnersOnBase: false,
|
||||
runnerOnFirst: false,
|
||||
runnerOnSecond: false,
|
||||
runnerOnThird: false,
|
||||
basesLoaded: false,
|
||||
outs: 0,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [decision: Omit<OffensiveDecision, 'steal_attempts'>]
|
||||
}>()
|
||||
|
||||
const gameStore = useGameStore()
|
||||
|
||||
// Local state
|
||||
const submitting = ref(false)
|
||||
const localDecision = ref<Omit<OffensiveDecision, 'steal_attempts'>>({
|
||||
action: props.currentDecision?.action || 'swing_away',
|
||||
})
|
||||
|
||||
// Action options with smart filtering
|
||||
const availableActions = computed<ActionOption[]>(() => {
|
||||
const twoOuts = props.outs >= 2
|
||||
// Read game state from store (fixes bug where props were never passed from DecisionPanel)
|
||||
const storeGameState = computed(() => gameStore.gameState)
|
||||
|
||||
return [
|
||||
const hasRunnersOnBase = computed(() => {
|
||||
const gs = storeGameState.value
|
||||
if (!gs) return false
|
||||
return !!(gs.on_first || gs.on_second || gs.on_third)
|
||||
})
|
||||
|
||||
const runnerOnFirst = computed(() => !!storeGameState.value?.on_first)
|
||||
const runnerOnThird = computed(() => !!storeGameState.value?.on_third)
|
||||
const outs = computed(() => storeGameState.value?.outs ?? 0)
|
||||
|
||||
// Action options: only include actions whose conditions are met (hidden, not disabled)
|
||||
const availableActions = computed<ActionOption[]>(() => {
|
||||
const actions: ActionOption[] = [
|
||||
{
|
||||
value: 'swing_away',
|
||||
label: 'Swing Away',
|
||||
icon: '⚾',
|
||||
description: 'Normal swing, no special tactics',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
value: 'steal',
|
||||
label: 'Steal',
|
||||
icon: '🏃',
|
||||
description: 'Attempt to steal base(s) - configure on steal inputs tab',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
value: 'check_jump',
|
||||
label: 'Check Jump',
|
||||
icon: '👀',
|
||||
description: 'Lead runner checks jump at start of delivery',
|
||||
disabled: !props.hasRunnersOnBase,
|
||||
disabledReason: 'Requires runner on base',
|
||||
},
|
||||
{
|
||||
value: 'hit_and_run',
|
||||
label: 'Hit and Run',
|
||||
icon: '💨',
|
||||
description: 'Runner(s) take off as pitcher delivers; batter must make contact',
|
||||
disabled: !props.hasRunnersOnBase,
|
||||
disabledReason: 'Requires runner on base',
|
||||
},
|
||||
{
|
||||
value: 'sac_bunt',
|
||||
label: 'Sacrifice Bunt',
|
||||
icon: '🎯',
|
||||
description: 'Bunt to advance runners, batter likely out',
|
||||
disabled: twoOuts,
|
||||
disabledReason: twoOuts ? 'Cannot bunt with 2 outs' : undefined,
|
||||
},
|
||||
{
|
||||
value: 'squeeze_bunt',
|
||||
label: 'Squeeze Bunt',
|
||||
icon: '🔥',
|
||||
description: 'Runner on 3rd breaks for home as pitcher delivers',
|
||||
disabled: !props.runnerOnThird || twoOuts,
|
||||
disabledReason: twoOuts
|
||||
? 'Cannot squeeze with 2 outs'
|
||||
: !props.runnerOnThird
|
||||
? 'Requires runner on third'
|
||||
: undefined,
|
||||
},
|
||||
]
|
||||
|
||||
// Check jump: requires any runner on base
|
||||
if (hasRunnersOnBase.value) {
|
||||
actions.push({
|
||||
value: 'check_jump',
|
||||
label: 'Check Jump',
|
||||
description: 'Lead runner checks jump at start of delivery',
|
||||
})
|
||||
}
|
||||
|
||||
// Hit and run: requires runner on first and/or third (NOT second only)
|
||||
if (runnerOnFirst.value || runnerOnThird.value) {
|
||||
actions.push({
|
||||
value: 'hit_and_run',
|
||||
label: 'Hit and Run',
|
||||
description: 'Runner(s) take off as pitcher delivers; batter must make contact',
|
||||
})
|
||||
}
|
||||
|
||||
// Sac bunt: requires < 2 outs AND runners on base
|
||||
if (outs.value < 2 && hasRunnersOnBase.value) {
|
||||
actions.push({
|
||||
value: 'sac_bunt',
|
||||
label: 'Sacrifice Bunt',
|
||||
description: 'Bunt to advance runners, batter likely out',
|
||||
})
|
||||
}
|
||||
|
||||
// Squeeze bunt: requires < 2 outs AND runner on third
|
||||
if (outs.value < 2 && runnerOnThird.value) {
|
||||
actions.push({
|
||||
value: 'squeeze_bunt',
|
||||
label: 'Squeeze Bunt',
|
||||
description: 'Runner on 3rd breaks for home as pitcher delivers',
|
||||
})
|
||||
}
|
||||
|
||||
return actions
|
||||
})
|
||||
|
||||
// Computed
|
||||
const currentActionLabel = computed(() => {
|
||||
const option = availableActions.value.find(opt => opt.value === localDecision.value.action)
|
||||
return option?.label || 'Swing Away'
|
||||
})
|
||||
|
||||
const actionRequiresSpecialHandling = computed(() => {
|
||||
return localDecision.value.action === 'steal' || localDecision.value.action === 'squeeze_bunt'
|
||||
})
|
||||
|
||||
const specialHandlingNote = computed(() => {
|
||||
if (localDecision.value.action === 'steal') {
|
||||
return 'Configure which bases to steal on the Stolen Base Inputs tab'
|
||||
}
|
||||
if (localDecision.value.action === 'squeeze_bunt') {
|
||||
return 'R3 will break for home as pitcher delivers - high risk, high reward!'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const hasChanges = computed(() => {
|
||||
if (!props.currentDecision) return true
|
||||
return localDecision.value.action !== props.currentDecision.action
|
||||
@ -217,22 +171,13 @@ const submitButtonText = computed(() => {
|
||||
// Methods
|
||||
const selectAction = (action: OffensiveDecision['action']) => {
|
||||
if (!props.isActive) return
|
||||
|
||||
// Check if action is disabled
|
||||
const option = availableActions.value.find(opt => opt.value === action)
|
||||
if (option?.disabled) return
|
||||
|
||||
localDecision.value.action = action
|
||||
}
|
||||
|
||||
const getActionButtonClasses = (action: OffensiveDecision['action'], disabled: boolean) => {
|
||||
const getActionButtonClasses = (action: OffensiveDecision['action']) => {
|
||||
const isSelected = localDecision.value.action === action
|
||||
const base = 'w-full p-4 rounded-lg border-2 transition-all duration-200 disabled:cursor-not-allowed'
|
||||
|
||||
if (disabled) {
|
||||
return `${base} bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500 border-gray-200 dark:border-gray-700 opacity-60`
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
return `${base} bg-gradient-to-r from-blue-600 to-blue-700 text-white border-blue-700 shadow-lg`
|
||||
} else {
|
||||
@ -258,12 +203,12 @@ watch(() => props.currentDecision, (newDecision) => {
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// Auto-reset to swing_away if current action becomes invalid
|
||||
// Auto-reset to swing_away if current action is no longer available
|
||||
watch(() => availableActions.value, (actions) => {
|
||||
const currentAction = localDecision.value.action
|
||||
const currentOption = actions.find(opt => opt.value === currentAction)
|
||||
const stillAvailable = actions.some(opt => opt.value === currentAction)
|
||||
|
||||
if (currentOption?.disabled) {
|
||||
if (!stillAvailable) {
|
||||
localDecision.value.action = 'swing_away'
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
@ -26,9 +26,8 @@
|
||||
<div class="text-[10px] text-gray-500">{{ base }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Hold runner icon -->
|
||||
<!-- Hold runner status pill (always visible for occupied bases, interactive only during defensive phase) -->
|
||||
<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',
|
||||
|
||||
@ -89,15 +89,13 @@ describe("DefensiveSetup", () => {
|
||||
}
|
||||
|
||||
describe("Rendering", () => {
|
||||
it("renders component with compact header and glove emoji", () => {
|
||||
it("renders component with compact header", () => {
|
||||
/**
|
||||
* The compact layout uses 🧤 emoji and "Defense" title (not the old
|
||||
* "Defensive Setup" with 🛡️).
|
||||
* The compact layout uses "Defense" title (not the old "Defensive Setup").
|
||||
* No emojis in the header.
|
||||
*/
|
||||
const wrapper = mountWithGameState();
|
||||
expect(wrapper.text()).toContain("Defense");
|
||||
expect(wrapper.text()).toContain("🧤");
|
||||
expect(wrapper.text()).not.toContain("🛡️");
|
||||
});
|
||||
|
||||
it("renders prominent full-width confirm button", () => {
|
||||
|
||||
@ -1,325 +1,381 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import OffensiveApproach from '~/components/Decisions/OffensiveApproach.vue'
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import OffensiveApproach from "~/components/Decisions/OffensiveApproach.vue";
|
||||
import { useGameStore } from "~/store/game";
|
||||
import type { GameState, OffensiveDecision } from "~/types/game";
|
||||
|
||||
// TODO: Fix form interaction and text rendering issues
|
||||
describe.skip('OffensiveApproach', () => {
|
||||
const defaultProps = {
|
||||
gameId: 'test-game-123',
|
||||
isActive: true,
|
||||
}
|
||||
/**
|
||||
* Creates a minimal GameState for testing, with sensible defaults.
|
||||
* Only override the fields you care about per test.
|
||||
*/
|
||||
function makeGameState(overrides: Partial<GameState> = {}): GameState {
|
||||
return {
|
||||
game_id: "test-game-123",
|
||||
league_id: "sba",
|
||||
home_team_id: 1,
|
||||
away_team_id: 2,
|
||||
home_team_is_ai: false,
|
||||
away_team_is_ai: false,
|
||||
creator_discord_id: null,
|
||||
auto_mode: false,
|
||||
status: "active",
|
||||
inning: 5,
|
||||
half: "top",
|
||||
outs: 0,
|
||||
balls: 0,
|
||||
strikes: 0,
|
||||
home_score: 3,
|
||||
away_score: 2,
|
||||
on_first: null,
|
||||
on_second: null,
|
||||
on_third: null,
|
||||
current_batter: { lineup_id: 10, card_id: 100, position: "LF", batting_order: 1, is_active: true },
|
||||
current_pitcher: { lineup_id: 20, card_id: 200, position: "P", batting_order: null, is_active: true },
|
||||
current_catcher: null,
|
||||
current_on_base_code: 0,
|
||||
away_team_batter_idx: 0,
|
||||
home_team_batter_idx: 0,
|
||||
pending_decision: null,
|
||||
decision_phase: "awaiting_offensive",
|
||||
decisions_this_play: {},
|
||||
pending_defensive_decision: null,
|
||||
pending_offensive_decision: null,
|
||||
pending_manual_roll: null,
|
||||
pending_x_check: null,
|
||||
pending_uncapped_hit: null,
|
||||
play_count: 0,
|
||||
last_play_result: null,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
started_at: "2025-01-01T00:00:00Z",
|
||||
completed_at: null,
|
||||
...overrides,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders all approach options', () => {
|
||||
const wrapper = mount(OffensiveApproach, {
|
||||
props: defaultProps,
|
||||
})
|
||||
/** Helper to create a mock runner LineupPlayerState */
|
||||
function makeRunner(lineupId: number, position: string = "LF") {
|
||||
return { lineup_id: lineupId, card_id: lineupId * 10, position, batting_order: 1, is_active: true };
|
||||
}
|
||||
|
||||
expect(wrapper.text()).toContain('Normal')
|
||||
expect(wrapper.text()).toContain('Contact')
|
||||
expect(wrapper.text()).toContain('Power')
|
||||
expect(wrapper.text()).toContain('Patient')
|
||||
})
|
||||
describe("OffensiveApproach", () => {
|
||||
let pinia: ReturnType<typeof createPinia>;
|
||||
|
||||
it('renders special tactics section', () => {
|
||||
const wrapper = mount(OffensiveApproach, {
|
||||
props: defaultProps,
|
||||
})
|
||||
const defaultProps = {
|
||||
gameId: "test-game-123",
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
expect(wrapper.text()).toContain('Hit and Run')
|
||||
expect(wrapper.text()).toContain('Bunt Attempt')
|
||||
})
|
||||
})
|
||||
beforeEach(() => {
|
||||
pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
});
|
||||
|
||||
describe('Approach Selection', () => {
|
||||
it('selects normal approach by default', () => {
|
||||
const wrapper = mount(OffensiveApproach, {
|
||||
props: defaultProps,
|
||||
})
|
||||
/**
|
||||
* Helper: mount with game state injected into the store.
|
||||
* Sets up Pinia and populates gameStore.gameState before mounting.
|
||||
*/
|
||||
function mountWithGameState(gameStateOverrides: Partial<GameState> = {}, propsOverrides: Record<string, any> = {}) {
|
||||
const gameStore = useGameStore();
|
||||
gameStore.setGameState(makeGameState(gameStateOverrides));
|
||||
return mount(OffensiveApproach, {
|
||||
props: { ...defaultProps, ...propsOverrides },
|
||||
global: { plugins: [pinia] },
|
||||
});
|
||||
}
|
||||
|
||||
expect(wrapper.vm.localDecision.approach).toBe('normal')
|
||||
})
|
||||
describe("Rendering", () => {
|
||||
it("always shows Swing Away option", () => {
|
||||
/**
|
||||
* Swing Away is the default action and should always be visible
|
||||
* regardless of game state.
|
||||
*/
|
||||
const wrapper = mountWithGameState();
|
||||
expect(wrapper.text()).toContain("Swing Away");
|
||||
});
|
||||
|
||||
it('uses provided currentDecision approach', () => {
|
||||
const wrapper = mount(OffensiveApproach, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
currentDecision: {
|
||||
approach: 'power',
|
||||
hit_and_run: false,
|
||||
bunt_attempt: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
it("renders offensive action header", () => {
|
||||
const wrapper = mountWithGameState();
|
||||
expect(wrapper.text()).toContain("Offensive Action");
|
||||
});
|
||||
|
||||
expect(wrapper.vm.localDecision.approach).toBe('power')
|
||||
})
|
||||
it("shows Opponent's Turn badge when not active", () => {
|
||||
const wrapper = mountWithGameState({}, { isActive: false });
|
||||
expect(wrapper.text()).toContain("Opponent's Turn");
|
||||
});
|
||||
|
||||
it('changes approach when button clicked', async () => {
|
||||
const wrapper = mount(OffensiveApproach, {
|
||||
props: defaultProps,
|
||||
})
|
||||
it("hides Opponent's Turn badge when active", () => {
|
||||
const wrapper = mountWithGameState();
|
||||
expect(wrapper.text()).not.toContain("Opponent's Turn");
|
||||
});
|
||||
});
|
||||
|
||||
wrapper.vm.selectApproach('contact')
|
||||
await wrapper.vm.$nextTick()
|
||||
describe("Check Jump (requires any runner on base)", () => {
|
||||
it("hides Check Jump when bases empty", () => {
|
||||
/**
|
||||
* Check Jump requires at least one runner on base.
|
||||
* With empty bases it should not appear at all.
|
||||
*/
|
||||
const wrapper = mountWithGameState({ on_first: null, on_second: null, on_third: null });
|
||||
expect(wrapper.text()).not.toContain("Check Jump");
|
||||
});
|
||||
|
||||
expect(wrapper.vm.localDecision.approach).toBe('contact')
|
||||
})
|
||||
it("shows Check Jump with runner on first", () => {
|
||||
const wrapper = mountWithGameState({ on_first: makeRunner(10) });
|
||||
expect(wrapper.text()).toContain("Check Jump");
|
||||
});
|
||||
|
||||
it('does not change approach when not active', () => {
|
||||
const wrapper = mount(OffensiveApproach, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
isActive: false,
|
||||
},
|
||||
})
|
||||
it("shows Check Jump with runner on second only", () => {
|
||||
/**
|
||||
* Even though hit-and-run is NOT available with runner on 2nd only,
|
||||
* check jump IS available with any runner.
|
||||
*/
|
||||
const wrapper = mountWithGameState({ on_second: makeRunner(20) });
|
||||
expect(wrapper.text()).toContain("Check Jump");
|
||||
});
|
||||
|
||||
const originalApproach = wrapper.vm.localDecision.approach
|
||||
wrapper.vm.selectApproach('power')
|
||||
it("shows Check Jump with runner on third", () => {
|
||||
const wrapper = mountWithGameState({ on_third: makeRunner(30) });
|
||||
expect(wrapper.text()).toContain("Check Jump");
|
||||
});
|
||||
});
|
||||
|
||||
expect(wrapper.vm.localDecision.approach).toBe(originalApproach)
|
||||
})
|
||||
})
|
||||
describe("Hit and Run (requires runner on 1st and/or 3rd)", () => {
|
||||
it("hides Hit and Run when bases empty", () => {
|
||||
const wrapper = mountWithGameState();
|
||||
expect(wrapper.text()).not.toContain("Hit and Run");
|
||||
});
|
||||
|
||||
describe('Hit and Run', () => {
|
||||
it('is disabled when no runners on base', () => {
|
||||
const wrapper = mount(OffensiveApproach, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
hasRunnersOnBase: false,
|
||||
},
|
||||
})
|
||||
it("hides Hit and Run with runner on second only", () => {
|
||||
/**
|
||||
* Hit and Run should NOT be available when only a runner on 2nd.
|
||||
* The user's rule: "runner on first and/or third".
|
||||
*/
|
||||
const wrapper = mountWithGameState({ on_second: makeRunner(20) });
|
||||
expect(wrapper.text()).not.toContain("Hit and Run");
|
||||
});
|
||||
|
||||
expect(wrapper.vm.canUseHitAndRun).toBe(false)
|
||||
})
|
||||
it("shows Hit and Run with runner on first", () => {
|
||||
const wrapper = mountWithGameState({ on_first: makeRunner(10) });
|
||||
expect(wrapper.text()).toContain("Hit and Run");
|
||||
});
|
||||
|
||||
it('is enabled when runners on base', () => {
|
||||
const wrapper = mount(OffensiveApproach, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
hasRunnersOnBase: true,
|
||||
},
|
||||
})
|
||||
it("shows Hit and Run with runner on third", () => {
|
||||
const wrapper = mountWithGameState({ on_third: makeRunner(30) });
|
||||
expect(wrapper.text()).toContain("Hit and Run");
|
||||
});
|
||||
|
||||
expect(wrapper.vm.canUseHitAndRun).toBe(true)
|
||||
})
|
||||
it("shows Hit and Run with runners on first and third", () => {
|
||||
const wrapper = mountWithGameState({
|
||||
on_first: makeRunner(10),
|
||||
on_third: makeRunner(30),
|
||||
});
|
||||
expect(wrapper.text()).toContain("Hit and Run");
|
||||
});
|
||||
|
||||
it('clears hit and run when runners removed', async () => {
|
||||
const wrapper = mount(OffensiveApproach, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
hasRunnersOnBase: true,
|
||||
},
|
||||
})
|
||||
it("shows Hit and Run with runner on first and second (first qualifies)", () => {
|
||||
/**
|
||||
* Runner on 1st satisfies the condition even if 2nd is also occupied.
|
||||
*/
|
||||
const wrapper = mountWithGameState({
|
||||
on_first: makeRunner(10),
|
||||
on_second: makeRunner(20),
|
||||
});
|
||||
expect(wrapper.text()).toContain("Hit and Run");
|
||||
});
|
||||
});
|
||||
|
||||
wrapper.vm.localDecision.hit_and_run = true
|
||||
await wrapper.vm.$nextTick()
|
||||
describe("Sacrifice Bunt (requires < 2 outs AND runners on base)", () => {
|
||||
it("hides Sac Bunt when bases empty", () => {
|
||||
/**
|
||||
* Sac bunt requires on_base_code > 0 (runners on base).
|
||||
*/
|
||||
const wrapper = mountWithGameState({ outs: 0 });
|
||||
expect(wrapper.text()).not.toContain("Sacrifice Bunt");
|
||||
});
|
||||
|
||||
await wrapper.setProps({ hasRunnersOnBase: false })
|
||||
it("hides Sac Bunt with 2 outs", () => {
|
||||
const wrapper = mountWithGameState({ outs: 2, on_first: makeRunner(10) });
|
||||
expect(wrapper.text()).not.toContain("Sacrifice Bunt");
|
||||
});
|
||||
|
||||
expect(wrapper.vm.localDecision.hit_and_run).toBe(false)
|
||||
})
|
||||
})
|
||||
it("shows Sac Bunt with 0 outs and runner on base", () => {
|
||||
const wrapper = mountWithGameState({ outs: 0, on_first: makeRunner(10) });
|
||||
expect(wrapper.text()).toContain("Sacrifice Bunt");
|
||||
});
|
||||
|
||||
describe('Change Detection', () => {
|
||||
it('detects approach changes', () => {
|
||||
const wrapper = mount(OffensiveApproach, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
currentDecision: {
|
||||
approach: 'normal',
|
||||
hit_and_run: false,
|
||||
bunt_attempt: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
it("shows Sac Bunt with 1 out and runner on base", () => {
|
||||
const wrapper = mountWithGameState({ outs: 1, on_second: makeRunner(20) });
|
||||
expect(wrapper.text()).toContain("Sacrifice Bunt");
|
||||
});
|
||||
});
|
||||
|
||||
expect(wrapper.vm.hasChanges).toBe(false)
|
||||
describe("Squeeze Bunt (requires < 2 outs AND runner on 3rd)", () => {
|
||||
it("hides Squeeze Bunt when no runner on third", () => {
|
||||
const wrapper = mountWithGameState({ outs: 0, on_first: makeRunner(10) });
|
||||
expect(wrapper.text()).not.toContain("Squeeze Bunt");
|
||||
});
|
||||
|
||||
wrapper.vm.localDecision.approach = 'power'
|
||||
expect(wrapper.vm.hasChanges).toBe(true)
|
||||
})
|
||||
it("hides Squeeze Bunt with 2 outs even if runner on third", () => {
|
||||
const wrapper = mountWithGameState({ outs: 2, on_third: makeRunner(30) });
|
||||
expect(wrapper.text()).not.toContain("Squeeze Bunt");
|
||||
});
|
||||
|
||||
it('detects hit and run changes', () => {
|
||||
const wrapper = mount(OffensiveApproach, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
currentDecision: {
|
||||
approach: 'normal',
|
||||
hit_and_run: false,
|
||||
bunt_attempt: false,
|
||||
},
|
||||
hasRunnersOnBase: true,
|
||||
},
|
||||
})
|
||||
it("shows Squeeze Bunt with 0 outs and runner on third", () => {
|
||||
const wrapper = mountWithGameState({ outs: 0, on_third: makeRunner(30) });
|
||||
expect(wrapper.text()).toContain("Squeeze Bunt");
|
||||
});
|
||||
|
||||
expect(wrapper.vm.hasChanges).toBe(false)
|
||||
it("shows Squeeze Bunt with 1 out and runner on third", () => {
|
||||
const wrapper = mountWithGameState({ outs: 1, on_third: makeRunner(30) });
|
||||
expect(wrapper.text()).toContain("Squeeze Bunt");
|
||||
});
|
||||
});
|
||||
|
||||
wrapper.vm.localDecision.hit_and_run = true
|
||||
expect(wrapper.vm.hasChanges).toBe(true)
|
||||
})
|
||||
describe("Steal option removed", () => {
|
||||
it("does not show Steal option even with runners on base", () => {
|
||||
/**
|
||||
* The steal option was removed — check jump encompasses steal behavior.
|
||||
*/
|
||||
const wrapper = mountWithGameState({ on_first: makeRunner(10) });
|
||||
const actionLabels = wrapper.findAll("button[type='button']").map(b => b.text());
|
||||
const hasStealLabel = actionLabels.some(label => label.includes("Steal") && !label.includes("Bunt"));
|
||||
expect(hasStealLabel).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('detects bunt attempt changes', () => {
|
||||
const wrapper = mount(OffensiveApproach, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
currentDecision: {
|
||||
approach: 'normal',
|
||||
hit_and_run: false,
|
||||
bunt_attempt: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
describe("Full scenario: all options visible", () => {
|
||||
it("shows all 5 options with 0 outs and runners on 1st and 3rd", () => {
|
||||
/**
|
||||
* With 0 outs, runner on 1st AND 3rd: all conditions are met.
|
||||
* Should show: Swing Away, Check Jump, Hit and Run, Sac Bunt, Squeeze Bunt.
|
||||
*/
|
||||
const wrapper = mountWithGameState({
|
||||
outs: 0,
|
||||
on_first: makeRunner(10),
|
||||
on_third: makeRunner(30),
|
||||
});
|
||||
expect(wrapper.text()).toContain("Swing Away");
|
||||
expect(wrapper.text()).toContain("Check Jump");
|
||||
expect(wrapper.text()).toContain("Hit and Run");
|
||||
expect(wrapper.text()).toContain("Sacrifice Bunt");
|
||||
expect(wrapper.text()).toContain("Squeeze Bunt");
|
||||
});
|
||||
});
|
||||
|
||||
expect(wrapper.vm.hasChanges).toBe(false)
|
||||
describe("Action Selection", () => {
|
||||
it("selects swing_away by default", () => {
|
||||
const wrapper = mountWithGameState({ on_first: makeRunner(10) });
|
||||
// The checkmark should be next to Swing Away
|
||||
const buttons = wrapper.findAll("button[type='button']");
|
||||
const swingBtn = buttons.find(b => b.text().includes("Swing Away"));
|
||||
expect(swingBtn?.text()).toContain("✓");
|
||||
});
|
||||
|
||||
wrapper.vm.localDecision.bunt_attempt = true
|
||||
expect(wrapper.vm.hasChanges).toBe(true)
|
||||
})
|
||||
})
|
||||
it("can select check_jump when available", async () => {
|
||||
const wrapper = mountWithGameState({ on_first: makeRunner(10) });
|
||||
const buttons = wrapper.findAll("button[type='button']");
|
||||
const checkJumpBtn = buttons.find(b => b.text().includes("Check Jump"));
|
||||
expect(checkJumpBtn).toBeTruthy();
|
||||
|
||||
describe('Form Submission', () => {
|
||||
it('emits submit with decision', async () => {
|
||||
const wrapper = mount(OffensiveApproach, {
|
||||
props: defaultProps,
|
||||
})
|
||||
await checkJumpBtn!.trigger("click");
|
||||
|
||||
wrapper.vm.localDecision = {
|
||||
approach: 'power',
|
||||
hit_and_run: false,
|
||||
bunt_attempt: true,
|
||||
}
|
||||
// Checkmark should move to Check Jump
|
||||
expect(checkJumpBtn!.text()).toContain("✓");
|
||||
});
|
||||
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
it("does not change action when not active", async () => {
|
||||
const wrapper = mountWithGameState({ on_first: makeRunner(10) }, { isActive: false });
|
||||
const buttons = wrapper.findAll("button[type='button']");
|
||||
const checkJumpBtn = buttons.find(b => b.text().includes("Check Jump"));
|
||||
|
||||
expect(wrapper.emitted('submit')).toBeTruthy()
|
||||
const emitted = wrapper.emitted('submit')![0][0]
|
||||
expect(emitted).toEqual({
|
||||
approach: 'power',
|
||||
hit_and_run: false,
|
||||
bunt_attempt: true,
|
||||
})
|
||||
})
|
||||
await checkJumpBtn!.trigger("click");
|
||||
|
||||
it('does not submit when not active', async () => {
|
||||
const wrapper = mount(OffensiveApproach, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
isActive: false,
|
||||
},
|
||||
})
|
||||
// Swing Away should still be selected (checkmark stays)
|
||||
const swingBtn = buttons.find(b => b.text().includes("Swing Away"));
|
||||
expect(swingBtn?.text()).toContain("✓");
|
||||
});
|
||||
});
|
||||
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
expect(wrapper.emitted('submit')).toBeFalsy()
|
||||
})
|
||||
describe("Auto-reset when action becomes unavailable", () => {
|
||||
it("resets to swing_away when selected action disappears", async () => {
|
||||
/**
|
||||
* If a user selects sac_bunt but then the game state changes
|
||||
* (e.g., outs increase to 2), the selection should reset to swing_away.
|
||||
*/
|
||||
const gameStore = useGameStore();
|
||||
gameStore.setGameState(makeGameState({ outs: 0, on_first: makeRunner(10) }));
|
||||
|
||||
it('does not submit when no changes', async () => {
|
||||
const wrapper = mount(OffensiveApproach, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
currentDecision: {
|
||||
approach: 'normal',
|
||||
hit_and_run: false,
|
||||
bunt_attempt: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
const wrapper = mount(OffensiveApproach, {
|
||||
props: defaultProps,
|
||||
global: { plugins: [pinia] },
|
||||
});
|
||||
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
expect(wrapper.emitted('submit')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
// Select sac bunt
|
||||
const sacBuntBtn = wrapper.findAll("button[type='button']").find(b => b.text().includes("Sacrifice Bunt"));
|
||||
await sacBuntBtn!.trigger("click");
|
||||
|
||||
describe('Display Text', () => {
|
||||
it('shows current approach label', () => {
|
||||
const wrapper = mount(OffensiveApproach, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
currentDecision: {
|
||||
approach: 'contact',
|
||||
hit_and_run: false,
|
||||
bunt_attempt: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
// Now change game state to 2 outs — sac bunt should disappear
|
||||
gameStore.setGameState(makeGameState({ outs: 2, on_first: makeRunner(10) }));
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.vm.currentApproachLabel).toBe('Contact')
|
||||
})
|
||||
// Action should auto-reset to swing_away
|
||||
const swingBtn = wrapper.findAll("button[type='button']").find(b => b.text().includes("Swing Away"));
|
||||
expect(swingBtn?.text()).toContain("✓");
|
||||
expect(wrapper.text()).not.toContain("Sacrifice Bunt");
|
||||
});
|
||||
});
|
||||
|
||||
it('shows active tactics', () => {
|
||||
const wrapper = mount(OffensiveApproach, {
|
||||
props: defaultProps,
|
||||
})
|
||||
describe("Form Submission", () => {
|
||||
it("emits submit with current action", async () => {
|
||||
const wrapper = mountWithGameState({ on_first: makeRunner(10) });
|
||||
|
||||
wrapper.vm.localDecision.hit_and_run = true
|
||||
wrapper.vm.localDecision.bunt_attempt = true
|
||||
// Select check_jump
|
||||
const checkJumpBtn = wrapper.findAll("button[type='button']").find(b => b.text().includes("Check Jump"));
|
||||
await checkJumpBtn!.trigger("click");
|
||||
|
||||
expect(wrapper.vm.activeTactics).toBe('Hit & Run, Bunt')
|
||||
})
|
||||
await wrapper.find("form").trigger("submit.prevent");
|
||||
|
||||
it('shows None when no tactics active', () => {
|
||||
const wrapper = mount(OffensiveApproach, {
|
||||
props: defaultProps,
|
||||
})
|
||||
expect(wrapper.emitted("submit")).toBeTruthy();
|
||||
const emitted = wrapper.emitted("submit")![0][0] as Omit<OffensiveDecision, "steal_attempts">;
|
||||
expect(emitted.action).toBe("check_jump");
|
||||
});
|
||||
|
||||
expect(wrapper.vm.activeTactics).toBe('None')
|
||||
})
|
||||
})
|
||||
it("does not submit when not active", async () => {
|
||||
const wrapper = mountWithGameState({}, { isActive: false });
|
||||
await wrapper.find("form").trigger("submit.prevent");
|
||||
expect(wrapper.emitted("submit")).toBeFalsy();
|
||||
});
|
||||
|
||||
describe('Submit Button Text', () => {
|
||||
it('shows wait message when not active', () => {
|
||||
const wrapper = mount(OffensiveApproach, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
isActive: false,
|
||||
},
|
||||
})
|
||||
it("submits default swing_away when no changes made", async () => {
|
||||
const wrapper = mountWithGameState();
|
||||
await wrapper.find("form").trigger("submit.prevent");
|
||||
expect(wrapper.emitted("submit")).toBeTruthy();
|
||||
const emitted = wrapper.emitted("submit")![0][0] as Omit<OffensiveDecision, "steal_attempts">;
|
||||
expect(emitted.action).toBe("swing_away");
|
||||
});
|
||||
});
|
||||
|
||||
expect(wrapper.vm.submitButtonText).toBe('Wait for Your Turn')
|
||||
})
|
||||
|
||||
it('shows no changes message', () => {
|
||||
const wrapper = mount(OffensiveApproach, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
currentDecision: {
|
||||
approach: 'normal',
|
||||
hit_and_run: false,
|
||||
bunt_attempt: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.vm.submitButtonText).toBe('No Changes')
|
||||
})
|
||||
|
||||
it('shows submit message when active with changes', () => {
|
||||
const wrapper = mount(OffensiveApproach, {
|
||||
props: defaultProps,
|
||||
})
|
||||
|
||||
wrapper.vm.localDecision.approach = 'power'
|
||||
expect(wrapper.vm.submitButtonText).toBe('Submit Offensive Strategy')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Prop Updates', () => {
|
||||
it('updates local state when currentDecision changes', async () => {
|
||||
const wrapper = mount(OffensiveApproach, {
|
||||
props: defaultProps,
|
||||
})
|
||||
|
||||
await wrapper.setProps({
|
||||
currentDecision: {
|
||||
approach: 'patient',
|
||||
hit_and_run: true,
|
||||
bunt_attempt: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.vm.localDecision.approach).toBe('patient')
|
||||
expect(wrapper.vm.localDecision.hit_and_run).toBe(true)
|
||||
expect(wrapper.vm.localDecision.bunt_attempt).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
describe("Game State from Store (bug fix verification)", () => {
|
||||
it("reads game state from store, not from props", () => {
|
||||
/**
|
||||
* The old OffensiveApproach used props like runnerOnFirst, runnerOnThird,
|
||||
* and outs that were never passed by DecisionPanel (all defaulted to false/0).
|
||||
* The new version reads from useGameStore() directly.
|
||||
*
|
||||
* This test verifies the fix by setting store state with runners and outs
|
||||
* WITHOUT passing any runner/outs props, and expecting correct filtering.
|
||||
*/
|
||||
const wrapper = mountWithGameState({
|
||||
outs: 0,
|
||||
on_first: makeRunner(10),
|
||||
on_third: makeRunner(30),
|
||||
});
|
||||
// No runner props passed — component reads from store
|
||||
expect(wrapper.text()).toContain("Check Jump");
|
||||
expect(wrapper.text()).toContain("Hit and Run");
|
||||
expect(wrapper.text()).toContain("Sacrifice Bunt");
|
||||
expect(wrapper.text()).toContain("Squeeze Bunt");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -307,10 +307,10 @@ describe("RunnerCard", () => {
|
||||
});
|
||||
|
||||
describe("hold runner icon", () => {
|
||||
it("does not show hold icon by default", () => {
|
||||
it("always shows hold pill for occupied bases", () => {
|
||||
/**
|
||||
* When neither isHeld nor holdInteractive is set, the hold icon
|
||||
* should not appear — keeps the pill clean for non-defensive contexts.
|
||||
* The hold pill is always visible for occupied bases as a status indicator.
|
||||
* It's non-interactive (disabled) outside the defensive phase.
|
||||
*/
|
||||
const wrapper = mount(RunnerCard, {
|
||||
global: { plugins: [pinia] },
|
||||
@ -322,7 +322,8 @@ describe("RunnerCard", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.find(".hold-icon").exists()).toBe(false);
|
||||
expect(wrapper.find(".hold-icon").exists()).toBe(true);
|
||||
expect(wrapper.find(".hold-icon").attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows hold icon when holdInteractive is true", () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user