feat: Uncapped hit decision tree, x-check workflow, baserunner UI #8
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user