This commit captures work from multiple sessions building the statistics system and frontend component library. Backend - Phase 3.5: Statistics System - Box score statistics with materialized views - Play stat calculator for real-time updates - Stat view refresher service - Alembic migration for materialized views - Test coverage: 41 new tests (all passing) Frontend - Phase F1: Foundation - Composables: useGameState, useGameActions, useWebSocket - Type definitions and interfaces - Store setup with Pinia Frontend - Phase F2: Game Display - ScoreBoard, GameBoard, CurrentSituation, PlayByPlay components - Demo page at /demo Frontend - Phase F3: Decision Inputs - DefensiveSetup, OffensiveApproach, StolenBaseInputs components - DecisionPanel orchestration - Demo page at /demo-decisions - Test coverage: 213 tests passing Frontend - Phase F4: Dice & Manual Outcome - DiceRoller component - ManualOutcomeEntry with validation - PlayResult display - GameplayPanel orchestration - Demo page at /demo-gameplay - Test coverage: 119 tests passing Frontend - Phase F5: Substitutions - PinchHitterSelector, DefensiveReplacementSelector, PitchingChangeSelector - SubstitutionPanel with tab navigation - Demo page at /demo-substitutions - Test coverage: 114 tests passing Documentation: - PHASE_3_5_HANDOFF.md - Statistics system handoff - PHASE_F2_COMPLETE.md - Game display completion - Frontend phase planning docs - NEXT_SESSION.md updated for Phase F6 Configuration: - Package updates (Nuxt 4 fixes) - Tailwind config enhancements - Game store updates Test Status: - Backend: 731/731 passing (100%) - Frontend: 446/446 passing (100%) - Total: 1,177 tests passing Next Phase: F6 - Integration (wire all components into game page) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
469 lines
13 KiB
TypeScript
469 lines
13 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
import { mount } from '@vue/test-utils'
|
|
import PlayResult from '~/components/Gameplay/PlayResult.vue'
|
|
import type { PlayResult as PlayResultType } from '~/types'
|
|
|
|
describe('PlayResult', () => {
|
|
const createPlayResult = (overrides = {}): PlayResultType => ({
|
|
play_number: 1,
|
|
outcome: 'STRIKEOUT',
|
|
description: 'Mike Trout strikes out swinging',
|
|
outs_recorded: 1,
|
|
runs_scored: 0,
|
|
runners_advanced: [],
|
|
batter_result: null,
|
|
new_state: {},
|
|
is_hit: false,
|
|
is_out: true,
|
|
is_walk: false,
|
|
is_strikeout: true,
|
|
...overrides,
|
|
})
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllTimers()
|
|
vi.useFakeTimers()
|
|
})
|
|
|
|
// ============================================================================
|
|
// Rendering Tests
|
|
// ============================================================================
|
|
|
|
describe('Rendering', () => {
|
|
it('renders when result is provided', () => {
|
|
const result = createPlayResult()
|
|
const wrapper = mount(PlayResult, {
|
|
props: { result },
|
|
})
|
|
|
|
expect(wrapper.find('.play-result').exists()).toBe(true)
|
|
expect(wrapper.text()).toContain('Play #1')
|
|
})
|
|
|
|
it('does not render when result is null', () => {
|
|
const wrapper = mount(PlayResult, {
|
|
props: { result: null },
|
|
})
|
|
|
|
expect(wrapper.find('.play-result').exists()).toBe(false)
|
|
})
|
|
|
|
it('displays play description', () => {
|
|
const result = createPlayResult({
|
|
description: 'Custom play description',
|
|
})
|
|
|
|
const wrapper = mount(PlayResult, {
|
|
props: { result },
|
|
})
|
|
|
|
expect(wrapper.text()).toContain('Custom play description')
|
|
})
|
|
|
|
it('displays play number correctly', () => {
|
|
const result = createPlayResult({ play_number: 42 })
|
|
const wrapper = mount(PlayResult, {
|
|
props: { result },
|
|
})
|
|
|
|
expect(wrapper.text()).toContain('Play #42')
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// Result Type Styling Tests
|
|
// ============================================================================
|
|
|
|
describe('Result Type Styling', () => {
|
|
it('applies runs styling when runs scored', () => {
|
|
const result = createPlayResult({
|
|
runs_scored: 2,
|
|
is_out: false,
|
|
})
|
|
|
|
const wrapper = mount(PlayResult, {
|
|
props: { result },
|
|
})
|
|
|
|
expect(wrapper.find('.result-runs').exists()).toBe(true)
|
|
})
|
|
|
|
it('applies hit styling when is_hit is true', () => {
|
|
const result = createPlayResult({
|
|
is_hit: true,
|
|
is_out: false,
|
|
runs_scored: 0,
|
|
})
|
|
|
|
const wrapper = mount(PlayResult, {
|
|
props: { result },
|
|
})
|
|
|
|
expect(wrapper.find('.result-hit').exists()).toBe(true)
|
|
})
|
|
|
|
it('applies out styling when is_out is true', () => {
|
|
const result = createPlayResult({
|
|
is_out: true,
|
|
is_hit: false,
|
|
runs_scored: 0,
|
|
})
|
|
|
|
const wrapper = mount(PlayResult, {
|
|
props: { result },
|
|
})
|
|
|
|
expect(wrapper.find('.result-out').exists()).toBe(true)
|
|
})
|
|
|
|
it('applies default styling when no special type', () => {
|
|
const result = createPlayResult({
|
|
is_out: false,
|
|
is_hit: false,
|
|
runs_scored: 0,
|
|
})
|
|
|
|
const wrapper = mount(PlayResult, {
|
|
props: { result },
|
|
})
|
|
|
|
expect(wrapper.find('.result-default').exists()).toBe(true)
|
|
})
|
|
|
|
it('prioritizes runs over hit styling', () => {
|
|
const result = createPlayResult({
|
|
runs_scored: 1,
|
|
is_hit: true,
|
|
is_out: false,
|
|
})
|
|
|
|
const wrapper = mount(PlayResult, {
|
|
props: { result },
|
|
})
|
|
|
|
expect(wrapper.find('.result-runs').exists()).toBe(true)
|
|
expect(wrapper.find('.result-hit').exists()).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// Stats Display Tests
|
|
// ============================================================================
|
|
|
|
describe('Stats Display', () => {
|
|
it('displays outs recorded', () => {
|
|
const result = createPlayResult({ outs_recorded: 2 })
|
|
const wrapper = mount(PlayResult, {
|
|
props: { result },
|
|
})
|
|
|
|
expect(wrapper.find('.stat-outs').exists()).toBe(true)
|
|
expect(wrapper.find('.stat-outs').text()).toContain('2')
|
|
expect(wrapper.find('.stat-outs').text()).toContain('Outs')
|
|
})
|
|
|
|
it('uses singular "Out" for single out', () => {
|
|
const result = createPlayResult({ outs_recorded: 1 })
|
|
const wrapper = mount(PlayResult, {
|
|
props: { result },
|
|
})
|
|
|
|
expect(wrapper.find('.stat-outs').text()).toContain('Out')
|
|
expect(wrapper.find('.stat-outs').text()).not.toContain('Outs')
|
|
})
|
|
|
|
it('displays runs scored', () => {
|
|
const result = createPlayResult({
|
|
runs_scored: 3,
|
|
is_out: false,
|
|
})
|
|
|
|
const wrapper = mount(PlayResult, {
|
|
props: { result },
|
|
})
|
|
|
|
expect(wrapper.find('.stat-runs').exists()).toBe(true)
|
|
expect(wrapper.find('.stat-runs').text()).toContain('3')
|
|
expect(wrapper.find('.stat-runs').text()).toContain('Runs')
|
|
})
|
|
|
|
it('uses singular "Run" for single run', () => {
|
|
const result = createPlayResult({
|
|
runs_scored: 1,
|
|
is_out: false,
|
|
})
|
|
|
|
const wrapper = mount(PlayResult, {
|
|
props: { result },
|
|
})
|
|
|
|
expect(wrapper.find('.stat-runs').text()).toContain('Run')
|
|
expect(wrapper.find('.stat-runs').text()).not.toContain('Runs')
|
|
})
|
|
|
|
it('displays hit indicator when is_hit true', () => {
|
|
const result = createPlayResult({
|
|
is_hit: true,
|
|
is_out: false,
|
|
})
|
|
|
|
const wrapper = mount(PlayResult, {
|
|
props: { result },
|
|
})
|
|
|
|
expect(wrapper.find('.stat-hit').exists()).toBe(true)
|
|
expect(wrapper.find('.stat-hit').text()).toContain('Hit')
|
|
})
|
|
|
|
it('hides stats when values are zero', () => {
|
|
const result = createPlayResult({
|
|
outs_recorded: 0,
|
|
runs_scored: 0,
|
|
is_hit: false,
|
|
})
|
|
|
|
const wrapper = mount(PlayResult, {
|
|
props: { result },
|
|
})
|
|
|
|
expect(wrapper.find('.stat-outs').exists()).toBe(false)
|
|
expect(wrapper.find('.stat-runs').exists()).toBe(false)
|
|
expect(wrapper.find('.stat-hit').exists()).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// Runner Advancement Tests
|
|
// ============================================================================
|
|
|
|
describe('Runner Advancement', () => {
|
|
it('displays runner advancement section when runners advanced', () => {
|
|
const result = createPlayResult({
|
|
runners_advanced: [
|
|
{ from: 1, to: 3, out: false },
|
|
],
|
|
})
|
|
|
|
const wrapper = mount(PlayResult, {
|
|
props: { result },
|
|
})
|
|
|
|
expect(wrapper.find('.runner-advancement').exists()).toBe(true)
|
|
expect(wrapper.text()).toContain('Runner Movement:')
|
|
})
|
|
|
|
it('hides runner advancement when no runners advanced', () => {
|
|
const result = createPlayResult({
|
|
runners_advanced: [],
|
|
})
|
|
|
|
const wrapper = mount(PlayResult, {
|
|
props: { result },
|
|
})
|
|
|
|
expect(wrapper.find('.runner-advancement').exists()).toBe(false)
|
|
})
|
|
|
|
it('formats base labels correctly', () => {
|
|
const result = createPlayResult({
|
|
runners_advanced: [
|
|
{ from: 0, to: 1 }, // Batter to 1st
|
|
{ from: 1, to: 2 }, // 1st to 2nd
|
|
{ from: 2, to: 3 }, // 2nd to 3rd
|
|
{ from: 3, to: 4 }, // 3rd to Home
|
|
],
|
|
})
|
|
|
|
const wrapper = mount(PlayResult, {
|
|
props: { result },
|
|
})
|
|
|
|
expect(wrapper.text()).toContain('Batter')
|
|
expect(wrapper.text()).toContain('1st')
|
|
expect(wrapper.text()).toContain('2nd')
|
|
expect(wrapper.text()).toContain('3rd')
|
|
expect(wrapper.text()).toContain('Home')
|
|
})
|
|
|
|
it('shows "Out" for runners who are out', () => {
|
|
const result = createPlayResult({
|
|
runners_advanced: [
|
|
{ from: 1, to: 2, out: true },
|
|
],
|
|
})
|
|
|
|
const wrapper = mount(PlayResult, {
|
|
props: { result },
|
|
})
|
|
|
|
expect(wrapper.find('.advancement-out').exists()).toBe(true)
|
|
expect(wrapper.text()).toContain('Out')
|
|
})
|
|
|
|
it('displays multiple runner advancements', () => {
|
|
const result = createPlayResult({
|
|
runners_advanced: [
|
|
{ from: 1, to: 3 },
|
|
{ from: 0, to: 1 },
|
|
],
|
|
})
|
|
|
|
const wrapper = mount(PlayResult, {
|
|
props: { result },
|
|
})
|
|
|
|
const items = wrapper.findAll('.advancement-item')
|
|
expect(items).toHaveLength(2)
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// Dismiss Button Tests
|
|
// ============================================================================
|
|
|
|
describe('Dismiss Button', () => {
|
|
it('shows dismiss button when autoHide is false', () => {
|
|
const result = createPlayResult()
|
|
const wrapper = mount(PlayResult, {
|
|
props: {
|
|
result,
|
|
autoHide: false,
|
|
},
|
|
})
|
|
|
|
expect(wrapper.find('.dismiss-button').exists()).toBe(true)
|
|
})
|
|
|
|
it('hides dismiss button when autoHide is true', () => {
|
|
const result = createPlayResult()
|
|
const wrapper = mount(PlayResult, {
|
|
props: {
|
|
result,
|
|
autoHide: true,
|
|
},
|
|
})
|
|
|
|
expect(wrapper.find('.dismiss-button').exists()).toBe(false)
|
|
})
|
|
|
|
it('emits dismiss event when button clicked', async () => {
|
|
const result = createPlayResult()
|
|
const wrapper = mount(PlayResult, {
|
|
props: {
|
|
result,
|
|
autoHide: false,
|
|
},
|
|
})
|
|
|
|
await wrapper.find('.dismiss-button').trigger('click')
|
|
|
|
expect(wrapper.emitted('dismiss')).toBeTruthy()
|
|
expect(wrapper.emitted('dismiss')).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// Auto-Hide Tests
|
|
// ============================================================================
|
|
|
|
describe('Auto-Hide', () => {
|
|
it('emits dismiss after 5 seconds when autoHide is true', async () => {
|
|
const result = createPlayResult()
|
|
const wrapper = mount(PlayResult, {
|
|
props: {
|
|
result,
|
|
autoHide: true,
|
|
},
|
|
})
|
|
|
|
expect(wrapper.emitted('dismiss')).toBeFalsy()
|
|
|
|
vi.advanceTimersByTime(5000)
|
|
await wrapper.vm.$nextTick()
|
|
|
|
expect(wrapper.emitted('dismiss')).toBeTruthy()
|
|
expect(wrapper.emitted('dismiss')).toHaveLength(1)
|
|
})
|
|
|
|
it('does not auto-dismiss when autoHide is false', async () => {
|
|
const result = createPlayResult()
|
|
const wrapper = mount(PlayResult, {
|
|
props: {
|
|
result,
|
|
autoHide: false,
|
|
},
|
|
})
|
|
|
|
vi.advanceTimersByTime(10000)
|
|
await wrapper.vm.$nextTick()
|
|
|
|
expect(wrapper.emitted('dismiss')).toBeFalsy()
|
|
})
|
|
|
|
it('does not auto-dismiss when result is null', async () => {
|
|
const wrapper = mount(PlayResult, {
|
|
props: {
|
|
result: null,
|
|
autoHide: true,
|
|
},
|
|
})
|
|
|
|
vi.advanceTimersByTime(5000)
|
|
await wrapper.vm.$nextTick()
|
|
|
|
expect(wrapper.emitted('dismiss')).toBeFalsy()
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// Edge Cases
|
|
// ============================================================================
|
|
|
|
describe('Edge Cases', () => {
|
|
it('handles very long play descriptions', () => {
|
|
const result = createPlayResult({
|
|
description: 'A'.repeat(200),
|
|
})
|
|
|
|
const wrapper = mount(PlayResult, {
|
|
props: { result },
|
|
})
|
|
|
|
expect(wrapper.find('.result-description').text()).toHaveLength(200)
|
|
})
|
|
|
|
it('handles high play numbers', () => {
|
|
const result = createPlayResult({ play_number: 999 })
|
|
const wrapper = mount(PlayResult, {
|
|
props: { result },
|
|
})
|
|
|
|
expect(wrapper.text()).toContain('Play #999')
|
|
})
|
|
|
|
it('handles multiple outs recorded', () => {
|
|
const result = createPlayResult({ outs_recorded: 3 })
|
|
const wrapper = mount(PlayResult, {
|
|
props: { result },
|
|
})
|
|
|
|
expect(wrapper.find('.stat-outs').text()).toContain('3')
|
|
})
|
|
|
|
it('handles grand slam scenario (4 runs)', () => {
|
|
const result = createPlayResult({
|
|
runs_scored: 4,
|
|
is_hit: true,
|
|
outs_recorded: 0,
|
|
})
|
|
|
|
const wrapper = mount(PlayResult, {
|
|
props: { result },
|
|
})
|
|
|
|
expect(wrapper.find('.stat-runs').text()).toContain('4')
|
|
expect(wrapper.find('.result-runs').exists()).toBe(true)
|
|
})
|
|
})
|
|
})
|