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

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

View File

@ -1,73 +1,37 @@
<template>
<div
:class="[
'runner-card',
'runner-pill',
runner ? 'occupied' : 'empty',
isExpanded ? 'expanded' : ''
isSelected ? 'selected' : ''
]"
@click="handleClick"
>
<!-- Summary (always visible) -->
<div class="runner-summary">
<template v-if="runner">
<!-- Occupied base -->
<div class="w-10 h-10 rounded-full flex-shrink-0 overflow-hidden border-2" :style="{ borderColor: teamColor }">
<img
v-if="runnerPlayer?.headshot || runnerPlayer?.image"
:src="runnerPlayer.headshot || runnerPlayer.image"
: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-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>
<template v-if="runner">
<!-- Occupied base -->
<div class="w-8 h-8 rounded-full flex-shrink-0 overflow-hidden border-2" :style="{ borderColor: teamColor }">
<img
v-if="runnerPlayer?.headshot || runnerPlayer?.image"
:src="runnerPlayer.headshot || runnerPlayer.image"
: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>
</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>
</template>
@ -79,7 +43,7 @@ import { useGameStore } from '~/store/game'
interface Props {
base: '1B' | '2B' | '3B'
runner: LineupPlayerState | null
isExpanded: boolean
isSelected: boolean
teamColor: string
}
@ -125,63 +89,20 @@ function handleClick() {
</script>
<style scoped>
.runner-card {
@apply bg-white border-l-4 rounded-lg shadow-sm transition-all duration-300;
.runner-pill {
@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 {
@apply bg-gray-50 border-gray-300;
.runner-pill.occupied {
@apply cursor-pointer hover:bg-blue-50;
}
.runner-card.occupied {
@apply cursor-pointer;
.runner-pill.selected {
@apply ring-2 ring-blue-500 bg-blue-50 shadow-md;
}
.runner-card.occupied:hover:not(.expanded) {
@apply transform translate-x-1;
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; }
.runner-pill.empty {
@apply bg-gray-50 opacity-60;
}
</style>

View File

@ -1,83 +1,69 @@
<template>
<!-- TODO: Spruce up the appearance of this component - improve styling, colors, animations, and visual polish -->
<div v-if="hasRunners" class="runners-on-base-container">
<!-- Split Layout: Runners List | Catcher Card -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-0 bg-white/20 rounded-lg shadow-md overflow-hidden">
<!-- LEFT: Runners List (with expandable cards) -->
<div class="border-r border-gray-200/50 p-4">
<h3 class="text-xs font-semibold text-gray-500 mb-3 uppercase tracking-wide">
Runners on Base
</h3>
<div class="space-y-2">
<!-- 1st Base -->
<RunnerCard
base="1B"
:runner="runners.first"
: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>
<!-- TOP ROW: Runner pills + Catcher summary -->
<div class="flex gap-2 items-stretch">
<!-- Runner pills (flex, equal width) -->
<div class="flex-1 flex gap-2">
<RunnerCard
v-for="(key, idx) in baseKeys"
:key="key"
class="flex-1"
:base="baseLabels[idx]"
:runner="runners[key]"
:is-selected="selectedRunner === key"
:team-color="battingTeamColor"
@click="toggleRunner(key)"
/>
</div>
<!-- RIGHT: Catcher Card -->
<div class="p-4 bg-gray-50/10">
<h3 class="text-xs font-semibold text-gray-500 mb-3 uppercase tracking-wide">
Catcher
</h3>
<!-- Collapsed state - minimal card -->
<div
v-if="!hasSelection"
class="bg-white border-l-4 border-gray-600 rounded-lg p-3 shadow-sm transition-all duration-300"
>
<div class="flex items-center gap-3">
<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>
<!-- Catcher summary pill (fixed width, full row height) -->
<div class="w-28 md:w-36 flex-shrink-0">
<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">
<div class="w-10 h-10 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="mt-3 text-xs text-gray-500 text-center">
Click a runner to see matchup
<div class="text-xs font-bold text-gray-900 mt-1 truncate w-full px-1">{{ catcherName }}</div>
<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>
<!-- Expanded state - full card (when runner selected) -->
<div
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 -->
<!-- Catcher full card -->
<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">
<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 }">
{{ fieldingTeamAbbrev }}
@ -85,7 +71,6 @@
<span class="text-white/70">C</span>
<span class="truncate flex-1 text-right font-bold">{{ catcherName }}</span>
</div>
<!-- Card Image -->
<div class="p-0">
<img
v-if="catcherPlayer?.image"
@ -99,12 +84,12 @@
</div>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import type { LineupPlayerState } from '~/types/game'
import type { Lineup } from '~/types/player'
import { useGameStore } from '~/store/game'
@ -134,6 +119,11 @@ const props = withDefaults(defineProps<Props>(), {
const gameStore = useGameStore()
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
const hasRunners = computed(() => {
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()
})
// 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') {
// Can't select empty base
if (!props.runners[base]) return
@ -176,6 +195,13 @@ function toggleRunner(base: 'first' | 'second' | 'third') {
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>
<style scoped>
@ -183,10 +209,23 @@ function toggleRunner(base: 'first' | 'second' | 'third') {
@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 {
animation: pulseGlowGreen 2s ease-in-out infinite;
}
.matchup-card-blue {
animation: pulseGlowBlue 2s ease-in-out infinite;
}
@keyframes pulseGlowGreen {
0%, 100% {
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 {
animation: fadeIn 0.3s ease-out;
@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 {

View File

@ -27,12 +27,12 @@ describe("RunnerCard", () => {
props: {
base: "1B",
runner: null,
isExpanded: false,
isSelected: false,
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");
});
@ -42,7 +42,7 @@ describe("RunnerCard", () => {
props: {
base: "2B",
runner: null,
isExpanded: false,
isSelected: false,
teamColor: "#3b82f6",
},
});
@ -56,7 +56,7 @@ describe("RunnerCard", () => {
props: {
base: "3B",
runner: null,
isExpanded: false,
isSelected: false,
teamColor: "#3b82f6",
},
});
@ -71,7 +71,7 @@ describe("RunnerCard", () => {
props: {
base: "1B",
runner: null,
isExpanded: false,
isSelected: false,
teamColor: "#3b82f6",
},
});
@ -131,13 +131,13 @@ describe("RunnerCard", () => {
props: {
base: "1B",
runner: mockRunner,
isExpanded: false,
isSelected: false,
teamColor: "#3b82f6",
},
});
expect(wrapper.find(".runner-card.occupied").exists()).toBe(true);
expect(wrapper.find(".runner-card.empty").exists()).toBe(false);
expect(wrapper.find(".runner-pill.occupied").exists()).toBe(true);
expect(wrapper.find(".runner-pill.empty").exists()).toBe(false);
});
it("displays runner name", () => {
@ -146,7 +146,7 @@ describe("RunnerCard", () => {
props: {
base: "1B",
runner: mockRunner,
isExpanded: false,
isSelected: false,
teamColor: "#3b82f6",
},
});
@ -160,7 +160,7 @@ describe("RunnerCard", () => {
props: {
base: "2B",
runner: mockRunner,
isExpanded: false,
isSelected: false,
teamColor: "#3b82f6",
},
});
@ -168,27 +168,13 @@ describe("RunnerCard", () => {
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", () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: false,
isSelected: false,
teamColor: "#3b82f6",
},
});
@ -206,7 +192,7 @@ describe("RunnerCard", () => {
props: {
base: "1B",
runner: mockRunner,
isExpanded: false,
isSelected: false,
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 () => {
const wrapper = mount(RunnerCard, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: false,
isSelected: false,
teamColor: "#3b82f6",
},
});
@ -248,7 +219,7 @@ describe("RunnerCard", () => {
});
});
describe("expanded state", () => {
describe("selected state", () => {
beforeEach(() => {
const gameStore = useGameStore();
gameStore.setGameState({
@ -259,19 +230,18 @@ describe("RunnerCard", () => {
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",
},
home_score: 0,
away_score: 0,
home_team_abbrev: "NYY",
away_team_abbrev: "BOS",
home_team_dice_color: "3b82f6",
current_batter: null,
current_pitcher: null,
on_first: null,
on_second: null,
on_third: null,
decision_phase: "idle",
play_count: 0,
});
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, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: false,
isSelected: false,
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, {
global: { plugins: [pinia] },
props: {
base: "1B",
runner: mockRunner,
isExpanded: true,
isSelected: true,
teamColor: "#3b82f6",
},
});
expect(wrapper.find(".runner-expanded").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");
expect(wrapper.find(".runner-pill.selected").exists()).toBe(true);
});
});
@ -444,120 +297,13 @@ describe("RunnerCard", () => {
props: {
base: "1B",
runner: mockRunner,
isExpanded: false,
isSelected: false,
teamColor: "#3b82f6",
},
});
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", () => {
@ -567,7 +313,7 @@ describe("RunnerCard", () => {
props: {
base: "1B",
runner: null,
isExpanded: false,
isSelected: false,
teamColor: "#3b82f6",
},
});
@ -581,7 +327,7 @@ describe("RunnerCard", () => {
props: {
base: "2B",
runner: null,
isExpanded: false,
isSelected: false,
teamColor: "#3b82f6",
},
});
@ -595,7 +341,7 @@ describe("RunnerCard", () => {
props: {
base: "3B",
runner: null,
isExpanded: false,
isSelected: false,
teamColor: "#3b82f6",
},
});

View File

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