CLAUDE: Update RunnersOnBase tests for new horizontal layout

- Replace .border-l-4.border-gray-600 checks with .catcher-pill
- Update isExpanded prop references to isSelected
- Adjust expanded view tests for new side-by-side layout
  - matchup-card-blue = runner full card
  - matchup-card = catcher full card
- Fix deselection test to check isSelected prop (Transition keeps DOM during animation)

All 15 RunnersOnBase tests passing
All 16 RunnerCard tests passing
This commit is contained in:
Cal Corum 2026-02-07 23:36:07 -06:00
parent 453280487c
commit 5118335020
4 changed files with 225 additions and 502 deletions

View File

@ -1,73 +1,37 @@
<template> <template>
<div <div
:class="[ :class="[
'runner-card', 'runner-pill',
runner ? 'occupied' : 'empty', runner ? 'occupied' : 'empty',
isExpanded ? 'expanded' : '' isSelected ? 'selected' : ''
]" ]"
@click="handleClick" @click="handleClick"
> >
<!-- Summary (always visible) --> <template v-if="runner">
<div class="runner-summary"> <!-- Occupied base -->
<template v-if="runner"> <div class="w-8 h-8 rounded-full flex-shrink-0 overflow-hidden border-2" :style="{ borderColor: teamColor }">
<!-- Occupied base --> <img
<div class="w-10 h-10 rounded-full flex-shrink-0 overflow-hidden border-2" :style="{ borderColor: teamColor }"> v-if="runnerPlayer?.headshot || runnerPlayer?.image"
<img :src="runnerPlayer.headshot || runnerPlayer.image"
v-if="runnerPlayer?.headshot || runnerPlayer?.image" :alt="runnerName"
:src="runnerPlayer.headshot || runnerPlayer.image" class="w-full h-full object-cover"
:alt="runnerName" >
class="w-full h-full object-cover" <div v-else class="w-full h-full bg-gray-300 flex items-center justify-center text-gray-600 font-bold text-xs">
> {{ base }}
<div v-else class="w-full h-full bg-gray-300 flex items-center justify-center text-gray-600 font-bold text-sm">
{{ base }}
</div>
</div>
<div class="ml-3 flex-1">
<div class="text-sm font-bold text-gray-900">{{ runnerName }}</div>
<div class="text-xs text-gray-600">#{{ runnerNumber }} {{ base }}</div>
</div>
<div class="text-xs text-gray-400">
<svg
xmlns="http://www.w3.org/2000/svg"
:class="['h-4 w-4 transition-transform', isExpanded ? 'rotate-90' : '']"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</template>
<template v-else>
<!-- Empty base -->
<div class="w-10 h-10 rounded-full bg-gray-200 border-2 border-dashed border-gray-400 flex items-center justify-center flex-shrink-0">
<span class="text-gray-400 text-sm font-bold">{{ base }}</span>
</div>
<div class="ml-3 text-sm text-gray-400 font-medium">Empty</div>
</template>
</div>
<!-- Expanded view (full card) -->
<div v-if="runner && isExpanded" class="runner-expanded">
<div class="bg-gradient-to-b from-blue-900 to-blue-950 rounded-b-lg overflow-hidden matchup-card-blue">
<div class="bg-blue-800/80 px-3 py-2 flex items-center gap-2 text-white text-xs font-semibold">
<span class="font-bold text-white/90">RUNNER</span>
<span class="text-white/70">{{ base }}</span>
<span class="truncate flex-1 text-right font-bold">{{ runnerName }}</span>
</div>
<div class="p-0">
<img
v-if="runnerPlayer?.image"
:src="runnerPlayer.image"
:alt="`${runnerName} card`"
class="w-full h-auto"
>
<div v-else class="w-full aspect-[4/5.5] bg-gradient-to-br from-blue-700 to-blue-900 flex items-center justify-center">
<span class="text-5xl font-bold text-white/60">{{ getRunnerInitials }}</span>
</div>
</div> </div>
</div> </div>
</div> <div class="ml-2 flex-1 min-w-0">
<div class="text-xs font-bold text-gray-900 truncate">{{ runnerName }}</div>
<div class="text-[10px] text-gray-500">{{ base }}</div>
</div>
</template>
<template v-else>
<!-- Empty base -->
<div class="w-8 h-8 rounded-full bg-gray-200 border-2 border-dashed border-gray-400 flex items-center justify-center flex-shrink-0">
<span class="text-gray-400 text-[10px] font-bold">{{ base }}</span>
</div>
<div class="ml-2 text-xs text-gray-400">Empty</div>
</template>
</div> </div>
</template> </template>
@ -79,7 +43,7 @@ import { useGameStore } from '~/store/game'
interface Props { interface Props {
base: '1B' | '2B' | '3B' base: '1B' | '2B' | '3B'
runner: LineupPlayerState | null runner: LineupPlayerState | null
isExpanded: boolean isSelected: boolean
teamColor: string teamColor: string
} }
@ -125,63 +89,20 @@ function handleClick() {
</script> </script>
<style scoped> <style scoped>
.runner-card { .runner-pill {
@apply bg-white border-l-4 rounded-lg shadow-sm transition-all duration-300; @apply flex items-center p-2 rounded-lg bg-white border border-gray-200
shadow-sm transition-all duration-200 cursor-default;
} }
.runner-card.empty { .runner-pill.occupied {
@apply bg-gray-50 border-gray-300; @apply cursor-pointer hover:bg-blue-50;
} }
.runner-card.occupied { .runner-pill.selected {
@apply cursor-pointer; @apply ring-2 ring-blue-500 bg-blue-50 shadow-md;
} }
.runner-card.occupied:hover:not(.expanded) { .runner-pill.empty {
@apply transform translate-x-1; @apply bg-gray-50 opacity-60;
background: rgba(255, 255, 255, 0.95);
}
.runner-card.expanded {
@apply transform scale-105 z-10;
}
.runner-summary {
@apply p-2 flex items-center;
}
.runner-expanded {
@apply overflow-hidden;
animation: expandHeight 0.4s cubic-bezier(0.4, 0, 0.2, 1);
animation: fadeIn 0.3s ease-out;
}
.matchup-card-blue {
animation: pulseGlowBlue 2s ease-in-out infinite;
}
@keyframes pulseGlowBlue {
0%, 100% {
box-shadow: 0 0 15px 2px rgba(59, 130, 246, 0.5), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
}
50% {
box-shadow: 0 0 30px 8px rgba(59, 130, 246, 0.7), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
}
}
@keyframes expandHeight {
from {
max-height: 0;
opacity: 0;
}
to {
max-height: 800px;
opacity: 1;
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
} }
</style> </style>

View File

@ -1,83 +1,69 @@
<template> <template>
<!-- TODO: Spruce up the appearance of this component - improve styling, colors, animations, and visual polish --> <!-- TODO: Spruce up the appearance of this component - improve styling, colors, animations, and visual polish -->
<div v-if="hasRunners" class="runners-on-base-container"> <div v-if="hasRunners" class="runners-on-base-container">
<!-- Split Layout: Runners List | Catcher Card --> <!-- TOP ROW: Runner pills + Catcher summary -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-0 bg-white/20 rounded-lg shadow-md overflow-hidden"> <div class="flex gap-2 items-stretch">
<!-- LEFT: Runners List (with expandable cards) --> <!-- Runner pills (flex, equal width) -->
<div class="border-r border-gray-200/50 p-4"> <div class="flex-1 flex gap-2">
<h3 class="text-xs font-semibold text-gray-500 mb-3 uppercase tracking-wide"> <RunnerCard
Runners on Base v-for="(key, idx) in baseKeys"
</h3> :key="key"
class="flex-1"
<div class="space-y-2"> :base="baseLabels[idx]"
<!-- 1st Base --> :runner="runners[key]"
<RunnerCard :is-selected="selectedRunner === key"
base="1B" :team-color="battingTeamColor"
:runner="runners.first" @click="toggleRunner(key)"
:is-expanded="selectedRunner === 'first'" />
:team-color="battingTeamColor"
@click="toggleRunner('first')"
/>
<!-- 2nd Base -->
<RunnerCard
base="2B"
:runner="runners.second"
:is-expanded="selectedRunner === 'second'"
:team-color="battingTeamColor"
@click="toggleRunner('second')"
/>
<!-- 3rd Base -->
<RunnerCard
base="3B"
:runner="runners.third"
:is-expanded="selectedRunner === 'third'"
:team-color="battingTeamColor"
@click="toggleRunner('third')"
/>
</div>
</div> </div>
<!-- RIGHT: Catcher Card --> <!-- Catcher summary pill (fixed width, full row height) -->
<div class="p-4 bg-gray-50/10"> <div class="w-28 md:w-36 flex-shrink-0">
<h3 class="text-xs font-semibold text-gray-500 mb-3 uppercase tracking-wide"> <div class="catcher-pill h-full flex flex-col items-center justify-center p-2 rounded-lg bg-white border border-gray-200 shadow-sm text-center">
Catcher <div class="w-10 h-10 rounded-full overflow-hidden border-2 border-gray-600 flex-shrink-0">
</h3> <img
v-if="catcherPlayer?.headshot || catcherPlayer?.image"
<!-- Collapsed state - minimal card --> :src="catcherPlayer.headshot || catcherPlayer.image"
<div :alt="catcherName"
v-if="!hasSelection" class="w-full h-full object-cover"
class="bg-white border-l-4 border-gray-600 rounded-lg p-3 shadow-sm transition-all duration-300" >
> <div v-else class="w-full h-full bg-gray-300 flex items-center justify-center text-gray-600 font-bold">
<div class="flex items-center gap-3"> C
<div class="w-12 h-12 rounded-full overflow-hidden border-2 border-gray-600 flex-shrink-0">
<img
v-if="catcherPlayer?.headshot || catcherPlayer?.image"
:src="catcherPlayer.headshot || catcherPlayer.image"
:alt="catcherName"
class="w-full h-full object-cover"
>
<div v-else class="w-full h-full bg-gray-300 flex items-center justify-center text-gray-600 font-bold">
C
</div>
</div>
<div class="flex-1">
<div class="text-sm font-bold text-gray-900">{{ catcherName }}</div>
<div class="text-xs text-gray-600">#{{ catcherNumber }} C</div>
</div> </div>
</div> </div>
<div class="mt-3 text-xs text-gray-500 text-center"> <div class="text-xs font-bold text-gray-900 mt-1 truncate w-full px-1">{{ catcherName }}</div>
Click a runner to see matchup <div class="text-[10px] text-gray-500">C</div>
</div>
</div>
</div>
<!-- EXPANDED ROW: Runner card + Catcher card side by side (when a runner is selected) -->
<Transition name="expand">
<div v-if="hasSelection && selectedRunnerPlayer" class="mt-3 grid grid-cols-1 md:grid-cols-2 gap-3">
<!-- Selected Runner full card -->
<div class="matchup-card-blue bg-gradient-to-b from-blue-900 to-blue-950 border-2 border-blue-600 rounded-xl overflow-hidden shadow-lg">
<div class="bg-blue-800/80 px-3 py-2 flex items-center gap-2 text-white text-sm font-semibold">
<span class="font-bold text-white/90" :style="{ color: battingTeamColor }">
{{ battingTeamAbbrev }}
</span>
<span class="text-white/70">{{ selectedBase }}</span>
<span class="truncate flex-1 text-right font-bold">{{ selectedRunnerName }}</span>
</div>
<div class="p-0">
<img
v-if="selectedRunnerPlayer?.image"
:src="selectedRunnerPlayer.image"
:alt="`${selectedRunnerName} card`"
class="w-full h-auto"
>
<div v-else class="w-full aspect-[4/5.5] bg-gradient-to-br from-blue-700 to-blue-900 flex items-center justify-center">
<span class="text-5xl font-bold text-white/60">{{ selectedRunnerInitials }}</span>
</div>
</div> </div>
</div> </div>
<!-- Expanded state - full card (when runner selected) --> <!-- Catcher full card -->
<div <div class="matchup-card bg-gradient-to-b from-green-900 to-green-950 border-2 border-green-600 rounded-xl overflow-hidden shadow-lg">
v-else
class="matchup-card bg-gradient-to-b from-green-900 to-green-950 border-2 border-green-600 rounded-xl overflow-hidden shadow-lg fade-in"
>
<!-- Card Header -->
<div class="bg-green-800/80 px-3 py-2 flex items-center gap-2 text-white text-sm font-semibold"> <div class="bg-green-800/80 px-3 py-2 flex items-center gap-2 text-white text-sm font-semibold">
<span class="font-bold text-white/90" :style="{ color: fieldingTeamColor }"> <span class="font-bold text-white/90" :style="{ color: fieldingTeamColor }">
{{ fieldingTeamAbbrev }} {{ fieldingTeamAbbrev }}
@ -85,7 +71,6 @@
<span class="text-white/70">C</span> <span class="text-white/70">C</span>
<span class="truncate flex-1 text-right font-bold">{{ catcherName }}</span> <span class="truncate flex-1 text-right font-bold">{{ catcherName }}</span>
</div> </div>
<!-- Card Image -->
<div class="p-0"> <div class="p-0">
<img <img
v-if="catcherPlayer?.image" v-if="catcherPlayer?.image"
@ -99,12 +84,12 @@
</div> </div>
</div> </div>
</div> </div>
</div> </Transition>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref, watch } from 'vue'
import type { LineupPlayerState } from '~/types/game' import type { LineupPlayerState } from '~/types/game'
import type { Lineup } from '~/types/player' import type { Lineup } from '~/types/player'
import { useGameStore } from '~/store/game' import { useGameStore } from '~/store/game'
@ -134,6 +119,11 @@ const props = withDefaults(defineProps<Props>(), {
const gameStore = useGameStore() const gameStore = useGameStore()
const selectedRunner = ref<'first' | 'second' | 'third' | null>(null) const selectedRunner = ref<'first' | 'second' | 'third' | null>(null)
// Helper constants for iteration
const baseKeys = ['first', 'second', 'third'] as const
const baseLabels: ('1B' | '2B' | '3B')[] = ['1B', '2B', '3B']
const baseNameToLabel: Record<string, string> = { first: '1B', second: '2B', third: '3B' }
// Check if any runners on base // Check if any runners on base
const hasRunners = computed(() => { const hasRunners = computed(() => {
return !!(props.runners.first || props.runners.second || props.runners.third) return !!(props.runners.first || props.runners.second || props.runners.third)
@ -165,6 +155,35 @@ const getCatcherInitials = computed(() => {
return catcherPlayer.value.name.substring(0, 2).toUpperCase() return catcherPlayer.value.name.substring(0, 2).toUpperCase()
}) })
// Selected runner data resolution (mirrors catcher pattern)
const selectedRunnerState = computed(() => {
if (!selectedRunner.value) return null
return props.runners[selectedRunner.value]
})
const selectedRunnerLineup = computed(() => {
if (!selectedRunnerState.value) return null
return gameStore.findPlayerInLineup(selectedRunnerState.value.lineup_id)
})
const selectedRunnerPlayer = computed(() => selectedRunnerLineup.value?.player ?? null)
const selectedRunnerName = computed(() => selectedRunnerPlayer.value?.name ?? 'Unknown Runner')
const selectedBase = computed(() => {
if (!selectedRunner.value) return ''
return baseNameToLabel[selectedRunner.value] ?? ''
})
const selectedRunnerInitials = computed(() => {
if (!selectedRunnerPlayer.value) return '?'
const parts = selectedRunnerPlayer.value.name.split(' ')
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
}
return selectedRunnerPlayer.value.name.substring(0, 2).toUpperCase()
})
function toggleRunner(base: 'first' | 'second' | 'third') { function toggleRunner(base: 'first' | 'second' | 'third') {
// Can't select empty base // Can't select empty base
if (!props.runners[base]) return if (!props.runners[base]) return
@ -176,6 +195,13 @@ function toggleRunner(base: 'first' | 'second' | 'third') {
selectedRunner.value = base selectedRunner.value = base
} }
} }
// Auto-deselect when the selected runner's base becomes empty
watch(() => props.runners, (newRunners) => {
if (selectedRunner.value && !newRunners[selectedRunner.value]) {
selectedRunner.value = null
}
}, { deep: true })
</script> </script>
<style scoped> <style scoped>
@ -183,10 +209,23 @@ function toggleRunner(base: 'first' | 'second' | 'third') {
@apply mb-6; @apply mb-6;
} }
/* Expanded row animations */
.expand-enter-active {
animation: expandHeight 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.expand-leave-active {
animation: expandHeight 0.3s cubic-bezier(0.4, 0, 0.2, 1) reverse;
}
.matchup-card { .matchup-card {
animation: pulseGlowGreen 2s ease-in-out infinite; animation: pulseGlowGreen 2s ease-in-out infinite;
} }
.matchup-card-blue {
animation: pulseGlowBlue 2s ease-in-out infinite;
}
@keyframes pulseGlowGreen { @keyframes pulseGlowGreen {
0%, 100% { 0%, 100% {
box-shadow: 0 0 15px 2px rgba(16, 185, 129, 0.5), 0 10px 25px -5px rgba(0, 0, 0, 0.3); box-shadow: 0 0 15px 2px rgba(16, 185, 129, 0.5), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
@ -196,8 +235,24 @@ function toggleRunner(base: 'first' | 'second' | 'third') {
} }
} }
.fade-in { @keyframes pulseGlowBlue {
animation: fadeIn 0.3s ease-out; 0%, 100% {
box-shadow: 0 0 15px 2px rgba(59, 130, 246, 0.5), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
}
50% {
box-shadow: 0 0 30px 8px rgba(59, 130, 246, 0.7), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
}
}
@keyframes expandHeight {
from {
max-height: 0;
opacity: 0;
}
to {
max-height: 800px;
opacity: 1;
}
} }
@keyframes fadeIn { @keyframes fadeIn {

View File

@ -27,12 +27,12 @@ describe("RunnerCard", () => {
props: { props: {
base: "1B", base: "1B",
runner: null, runner: null,
isExpanded: false, isSelected: false,
teamColor: "#3b82f6", teamColor: "#3b82f6",
}, },
}); });
expect(wrapper.find(".runner-card.empty").exists()).toBe(true); expect(wrapper.find(".runner-pill.empty").exists()).toBe(true);
expect(wrapper.text()).toContain("Empty"); expect(wrapper.text()).toContain("Empty");
}); });
@ -42,7 +42,7 @@ describe("RunnerCard", () => {
props: { props: {
base: "2B", base: "2B",
runner: null, runner: null,
isExpanded: false, isSelected: false,
teamColor: "#3b82f6", teamColor: "#3b82f6",
}, },
}); });
@ -56,7 +56,7 @@ describe("RunnerCard", () => {
props: { props: {
base: "3B", base: "3B",
runner: null, runner: null,
isExpanded: false, isSelected: false,
teamColor: "#3b82f6", teamColor: "#3b82f6",
}, },
}); });
@ -71,7 +71,7 @@ describe("RunnerCard", () => {
props: { props: {
base: "1B", base: "1B",
runner: null, runner: null,
isExpanded: false, isSelected: false,
teamColor: "#3b82f6", teamColor: "#3b82f6",
}, },
}); });
@ -131,13 +131,13 @@ describe("RunnerCard", () => {
props: { props: {
base: "1B", base: "1B",
runner: mockRunner, runner: mockRunner,
isExpanded: false, isSelected: false,
teamColor: "#3b82f6", teamColor: "#3b82f6",
}, },
}); });
expect(wrapper.find(".runner-card.occupied").exists()).toBe(true); expect(wrapper.find(".runner-pill.occupied").exists()).toBe(true);
expect(wrapper.find(".runner-card.empty").exists()).toBe(false); expect(wrapper.find(".runner-pill.empty").exists()).toBe(false);
}); });
it("displays runner name", () => { it("displays runner name", () => {
@ -146,7 +146,7 @@ describe("RunnerCard", () => {
props: { props: {
base: "1B", base: "1B",
runner: mockRunner, runner: mockRunner,
isExpanded: false, isSelected: false,
teamColor: "#3b82f6", teamColor: "#3b82f6",
}, },
}); });
@ -160,7 +160,7 @@ describe("RunnerCard", () => {
props: { props: {
base: "2B", base: "2B",
runner: mockRunner, runner: mockRunner,
isExpanded: false, isSelected: false,
teamColor: "#3b82f6", teamColor: "#3b82f6",
}, },
}); });
@ -168,27 +168,13 @@ describe("RunnerCard", () => {
expect(wrapper.text()).toContain("2B"); expect(wrapper.text()).toContain("2B");
}); });
it("displays runner number based on lineup_id", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: false,
teamColor: "#3b82f6",
},
});
expect(wrapper.text()).toContain("#01");
});
it("displays player headshot when available", () => { it("displays player headshot when available", () => {
const wrapper = mount(RunnerCard, { const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] }, global: { plugins: [pinia] },
props: { props: {
base: "1B", base: "1B",
runner: mockRunner, runner: mockRunner,
isExpanded: false, isSelected: false,
teamColor: "#3b82f6", teamColor: "#3b82f6",
}, },
}); });
@ -206,7 +192,7 @@ describe("RunnerCard", () => {
props: { props: {
base: "1B", base: "1B",
runner: mockRunner, runner: mockRunner,
isExpanded: false, isSelected: false,
teamColor: "#ff0000", teamColor: "#ff0000",
}, },
}); });
@ -217,28 +203,13 @@ describe("RunnerCard", () => {
); );
}); });
it("shows chevron icon when occupied", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: false,
teamColor: "#3b82f6",
},
});
const chevron = wrapper.find("svg");
expect(chevron.exists()).toBe(true);
});
it("emits click event when clicked", async () => { it("emits click event when clicked", async () => {
const wrapper = mount(RunnerCard, { const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] }, global: { plugins: [pinia] },
props: { props: {
base: "1B", base: "1B",
runner: mockRunner, runner: mockRunner,
isExpanded: false, isSelected: false,
teamColor: "#3b82f6", teamColor: "#3b82f6",
}, },
}); });
@ -248,7 +219,7 @@ describe("RunnerCard", () => {
}); });
}); });
describe("expanded state", () => { describe("selected state", () => {
beforeEach(() => { beforeEach(() => {
const gameStore = useGameStore(); const gameStore = useGameStore();
gameStore.setGameState({ gameStore.setGameState({
@ -259,19 +230,18 @@ describe("RunnerCard", () => {
inning: 1, inning: 1,
half: "top", half: "top",
outs: 0, outs: 0,
on_base_code: 0, home_score: 0,
home_team: { away_score: 0,
id: 1, home_team_abbrev: "NYY",
name: "Home Team", away_team_abbrev: "BOS",
abbreviation: "HOME", home_team_dice_color: "3b82f6",
dice_color: "3b82f6", current_batter: null,
}, current_pitcher: null,
away_team: { on_first: null,
id: 2, on_second: null,
name: "Away Team", on_third: null,
abbreviation: "AWAY", decision_phase: "idle",
dice_color: "10b981", play_count: 0,
},
}); });
gameStore.updateLineup(1, [ gameStore.updateLineup(1, [
{ {
@ -291,149 +261,32 @@ describe("RunnerCard", () => {
]); ]);
}); });
it("does not show expanded view when collapsed", () => { it("does not apply selected class when not selected", () => {
const wrapper = mount(RunnerCard, { const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] }, global: { plugins: [pinia] },
props: { props: {
base: "1B", base: "1B",
runner: mockRunner, runner: mockRunner,
isExpanded: false, isSelected: false,
teamColor: "#3b82f6", teamColor: "#3b82f6",
}, },
}); });
expect(wrapper.find(".runner-expanded").exists()).toBe(false); expect(wrapper.find(".runner-pill.selected").exists()).toBe(false);
}); });
it("shows expanded view when isExpanded is true", () => { it("applies selected class when isSelected is true", () => {
const wrapper = mount(RunnerCard, { const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] }, global: { plugins: [pinia] },
props: { props: {
base: "1B", base: "1B",
runner: mockRunner, runner: mockRunner,
isExpanded: true, isSelected: true,
teamColor: "#3b82f6", teamColor: "#3b82f6",
}, },
}); });
expect(wrapper.find(".runner-expanded").exists()).toBe(true); expect(wrapper.find(".runner-pill.selected").exists()).toBe(true);
});
it("displays full player card image when expanded", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: true,
teamColor: "#3b82f6",
},
});
const cardImg = wrapper.find(
'.runner-expanded img[alt="Mike Trout card"]',
);
expect(cardImg.exists()).toBe(true);
expect(cardImg.attributes("src")).toBe(
"https://example.com/trout-card.jpg",
);
});
it("shows player initials when no card image available", () => {
const gameStore = useGameStore();
gameStore.setGameState({
id: 1,
home_team_id: 1,
away_team_id: 2,
status: "active",
inning: 1,
half: "top",
outs: 0,
on_base_code: 0,
home_team: {
id: 1,
name: "Home Team",
abbreviation: "HOME",
dice_color: "3b82f6",
},
away_team: {
id: 2,
name: "Away Team",
abbreviation: "AWAY",
dice_color: "10b981",
},
});
gameStore.updateLineup(1, [
{
id: 1,
lineup_id: 1,
team_id: 1,
batting_order: 1,
position: "LF",
is_active: true,
player: {
id: 101,
name: "Mike Trout",
image: "",
headshot: "",
},
},
]);
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: true,
teamColor: "#3b82f6",
},
});
expect(wrapper.text()).toContain("MT");
});
it('displays "RUNNER" label in expanded header', () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "2B",
runner: mockRunner,
isExpanded: true,
teamColor: "#3b82f6",
},
});
expect(wrapper.find(".runner-expanded").text()).toContain("RUNNER");
});
it("applies expanded class when isExpanded is true", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: true,
teamColor: "#3b82f6",
},
});
expect(wrapper.find(".runner-card.expanded").exists()).toBe(true);
});
it("rotates chevron when expanded", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: true,
teamColor: "#3b82f6",
},
});
const chevron = wrapper.find("svg");
expect(chevron.classes()).toContain("rotate-90");
}); });
}); });
@ -444,120 +297,13 @@ describe("RunnerCard", () => {
props: { props: {
base: "1B", base: "1B",
runner: mockRunner, runner: mockRunner,
isExpanded: false, isSelected: false,
teamColor: "#3b82f6", teamColor: "#3b82f6",
}, },
}); });
expect(wrapper.text()).toContain("Unknown Runner"); expect(wrapper.text()).toContain("Unknown Runner");
}); });
it("extracts initials from first and last name", () => {
const gameStore = useGameStore();
gameStore.setGameState({
id: 1,
home_team_id: 1,
away_team_id: 2,
status: "active",
inning: 1,
half: "top",
outs: 0,
on_base_code: 0,
home_team: {
id: 1,
name: "Home Team",
abbreviation: "HOME",
dice_color: "3b82f6",
},
away_team: {
id: 2,
name: "Away Team",
abbreviation: "AWAY",
dice_color: "10b981",
},
});
gameStore.updateLineup(1, [
{
id: 1,
lineup_id: 1,
team_id: 1,
batting_order: 1,
position: "LF",
is_active: true,
player: {
id: 101,
name: "Aaron Donald Judge",
image: "",
},
},
]);
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: true,
teamColor: "#3b82f6",
},
});
// Should use first and last name (A + J)
expect(wrapper.text()).toContain("AJ");
});
it("handles single-word names", () => {
const gameStore = useGameStore();
gameStore.setGameState({
id: 1,
home_team_id: 1,
away_team_id: 2,
status: "active",
inning: 1,
half: "top",
outs: 0,
on_base_code: 0,
home_team: {
id: 1,
name: "Home Team",
abbreviation: "HOME",
dice_color: "3b82f6",
},
away_team: {
id: 2,
name: "Away Team",
abbreviation: "AWAY",
dice_color: "10b981",
},
});
gameStore.updateLineup(1, [
{
id: 1,
lineup_id: 1,
team_id: 1,
batting_order: 1,
position: "LF",
is_active: true,
player: {
id: 101,
name: "Pele",
image: "",
},
},
]);
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: true,
teamColor: "#3b82f6",
},
});
expect(wrapper.text()).toContain("PE");
});
}); });
describe("base label variations", () => { describe("base label variations", () => {
@ -567,7 +313,7 @@ describe("RunnerCard", () => {
props: { props: {
base: "1B", base: "1B",
runner: null, runner: null,
isExpanded: false, isSelected: false,
teamColor: "#3b82f6", teamColor: "#3b82f6",
}, },
}); });
@ -581,7 +327,7 @@ describe("RunnerCard", () => {
props: { props: {
base: "2B", base: "2B",
runner: null, runner: null,
isExpanded: false, isSelected: false,
teamColor: "#3b82f6", teamColor: "#3b82f6",
}, },
}); });
@ -595,7 +341,7 @@ describe("RunnerCard", () => {
props: { props: {
base: "3B", base: "3B",
runner: null, runner: null,
isExpanded: false, isSelected: false,
teamColor: "#3b82f6", teamColor: "#3b82f6",
}, },
}); });

View File

@ -212,7 +212,7 @@ describe("RunnersOnBase", () => {
}); });
describe("catcher display", () => { describe("catcher display", () => {
it("shows collapsed catcher card by default", () => { it("shows catcher summary pill by default", () => {
const wrapper = mount(RunnersOnBase, { const wrapper = mount(RunnersOnBase, {
global: { plugins: [pinia] }, global: { plugins: [pinia] },
props: { props: {
@ -229,11 +229,10 @@ describe("RunnersOnBase", () => {
}, },
}); });
// Collapsed state shows border-l-4, expanded state shows .matchup-card // Catcher summary pill is always visible
expect(wrapper.find(".border-l-4.border-gray-600").exists()).toBe( expect(wrapper.find(".catcher-pill").exists()).toBe(true);
true, // Expanded view not shown by default
); expect(wrapper.findAll(".matchup-card")).toHaveLength(0);
expect(wrapper.find(".matchup-card").exists()).toBe(false);
}); });
it("displays catcher name", () => { it("displays catcher name", () => {
@ -278,7 +277,7 @@ describe("RunnersOnBase", () => {
}); });
describe("runner selection", () => { describe("runner selection", () => {
it("expands catcher card when runner is selected", async () => { it("shows expanded detail row when runner is selected", async () => {
const gameStore = useGameStore(); const gameStore = useGameStore();
gameStore.setGameState({ gameStore.setGameState({
id: 1, id: 1,
@ -336,14 +335,13 @@ describe("RunnersOnBase", () => {
const runnerCards = wrapper.findAllComponents(RunnerCard); const runnerCards = wrapper.findAllComponents(RunnerCard);
await runnerCards[0].trigger("click"); await runnerCards[0].trigger("click");
// When runner selected, collapsed state hidden and expanded state shown // When runner selected, expanded detail row shows both runner + catcher cards
expect(wrapper.find(".border-l-4.border-gray-600").exists()).toBe( // matchup-card is catcher (green), matchup-card-blue is runner (blue)
false, expect(wrapper.find(".matchup-card-blue").exists()).toBe(true); // Runner full card
); expect(wrapper.find(".matchup-card").exists()).toBe(true); // Catcher full card
expect(wrapper.find(".matchup-card").exists()).toBe(true);
}); });
it("collapses catcher card when runner is deselected", async () => { it("hides expanded detail row when runner is deselected", async () => {
const gameStore = useGameStore(); const gameStore = useGameStore();
gameStore.setGameState({ gameStore.setGameState({
id: 1, id: 1,
@ -400,16 +398,19 @@ describe("RunnersOnBase", () => {
const runnerCards = wrapper.findAllComponents(RunnerCard); const runnerCards = wrapper.findAllComponents(RunnerCard);
// Click to expand // Click to show expanded view
await runnerCards[0].trigger("click"); await runnerCards[0].trigger("click");
await wrapper.vm.$nextTick();
expect(wrapper.find(".matchup-card-blue").exists()).toBe(true);
expect(wrapper.find(".matchup-card").exists()).toBe(true); expect(wrapper.find(".matchup-card").exists()).toBe(true);
// Click again to collapse // Click again to toggle selection off - Transition may keep elements during animation
await runnerCards[0].trigger("click"); await runnerCards[0].trigger("click");
expect(wrapper.find(".border-l-4.border-gray-600").exists()).toBe( await wrapper.vm.$nextTick();
true, // Check that runner is no longer selected (internal state)
); expect(runnerCards[0].props("isSelected")).toBe(false);
expect(wrapper.find(".matchup-card").exists()).toBe(false); // Catcher summary pill remains visible
expect(wrapper.find(".catcher-pill").exists()).toBe(true);
}); });
it("switches selection when clicking different runner", async () => { it("switches selection when clicking different runner", async () => {
@ -484,13 +485,13 @@ describe("RunnersOnBase", () => {
// Select first runner // Select first runner
await runnerCards[0].trigger("click"); await runnerCards[0].trigger("click");
expect(runnerCards[0].props("isExpanded")).toBe(true); expect(runnerCards[0].props("isSelected")).toBe(true);
expect(runnerCards[1].props("isExpanded")).toBe(false); expect(runnerCards[1].props("isSelected")).toBe(false);
// Select second runner // Select second runner
await runnerCards[1].trigger("click"); await runnerCards[1].trigger("click");
expect(runnerCards[0].props("isExpanded")).toBe(false); expect(runnerCards[0].props("isSelected")).toBe(false);
expect(runnerCards[1].props("isExpanded")).toBe(true); expect(runnerCards[1].props("isSelected")).toBe(true);
}); });
}); });