strat-gameplay-webapp/frontend-sba/tests/unit/components/Substitutions/SubstitutionPanel.spec.ts
Cal Corum eab61ad966 CLAUDE: Phases 3.5, F1-F5 Complete - Statistics & Frontend Components
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>
2025-11-14 09:52:30 -06:00

529 lines
15 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import SubstitutionPanel from '~/components/Substitutions/SubstitutionPanel.vue'
import type { Lineup, SbaPlayer } from '~/types'
// Helper to create mock player
function createMockPlayer(id: number, name: string, positions: string[]): SbaPlayer {
const player: SbaPlayer = {
id,
name,
image: `player-${id}.jpg`,
pos_1: null,
pos_2: null,
pos_3: null,
pos_4: null,
pos_5: null,
pos_6: null,
pos_7: null,
pos_8: null,
wara: 2.0,
team_id: 1,
team_name: 'Test Team',
season: '2024',
strat_code: null,
bbref_id: null,
injury_rating: null,
}
positions.forEach((pos, index) => {
const key = `pos_${index + 1}` as keyof SbaPlayer
;(player as any)[key] = pos
})
return player
}
// Helper to create mock lineup entry
function createMockLineup(
id: number,
player: SbaPlayer,
isActive: boolean,
position = 'OF',
battingOrder: number | null = null
): Lineup {
return {
id,
game_id: 'test-game-123',
team_id: 1,
player_id: player.id,
position,
batting_order: battingOrder,
is_starter: true,
is_active: isActive,
entered_inning: 1,
replacing_id: null,
after_play: null,
is_fatigued: false,
player,
}
}
describe('SubstitutionPanel', () => {
const mockCurrentLineup = [
createMockLineup(1, createMockPlayer(101, 'John Pitcher', ['P']), true, 'P', null),
createMockLineup(2, createMockPlayer(102, 'Jane Catcher', ['C']), true, 'C', 1),
createMockLineup(3, createMockPlayer(103, 'Bob First', ['1B']), true, '1B', 2),
createMockLineup(4, createMockPlayer(104, 'Sally Second', ['2B']), true, '2B', 3),
]
const mockBenchPlayers = [
createMockLineup(5, createMockPlayer(201, 'Relief Pitcher', ['P']), false),
createMockLineup(6, createMockPlayer(202, 'Bench Hitter', ['OF']), false),
createMockLineup(7, createMockPlayer(203, 'Utility Player', ['2B', '3B', 'SS']), false),
]
const defaultProps = {
gameId: 'test-game-123',
teamId: 1,
currentLineup: mockCurrentLineup,
benchPlayers: mockBenchPlayers,
currentPitcher: mockCurrentLineup[0],
currentBatter: mockCurrentLineup[1],
}
describe('Rendering', () => {
it('renders component with header', () => {
const wrapper = mount(SubstitutionPanel, {
props: defaultProps,
})
expect(wrapper.text()).toContain('Substitutions')
})
it('renders all three tab buttons', () => {
const wrapper = mount(SubstitutionPanel, {
props: defaultProps,
})
expect(wrapper.text()).toContain('Pinch Hitter')
expect(wrapper.text()).toContain('Defensive Sub')
expect(wrapper.text()).toContain('Pitching Change')
})
it('shows idle state by default', () => {
const wrapper = mount(SubstitutionPanel, {
props: defaultProps,
})
expect(wrapper.text()).toContain('Select a substitution type above')
})
it('displays status indicator', () => {
const wrapper = mount(SubstitutionPanel, {
props: defaultProps,
})
const statusIndicator = wrapper.find('.status-indicator')
expect(statusIndicator.exists()).toBe(true)
})
})
describe('Tab Navigation', () => {
it('activates pinch hitter tab when clicked', async () => {
const wrapper = mount(SubstitutionPanel, {
props: defaultProps,
})
const tabs = wrapper.findAll('.tab-button')
const pinchHitterTab = tabs.find(tab => tab.text().includes('Pinch Hitter'))
await pinchHitterTab!.trigger('click')
expect(wrapper.vm.currentTab).toBe('pinch_hitter')
})
it('activates defensive replacement tab when clicked', async () => {
const wrapper = mount(SubstitutionPanel, {
props: defaultProps,
})
const tabs = wrapper.findAll('.tab-button')
const defensiveTab = tabs.find(tab => tab.text().includes('Defensive Sub'))
await defensiveTab!.trigger('click')
expect(wrapper.vm.currentTab).toBe('defensive_replacement')
})
it('activates pitching change tab when clicked', async () => {
const wrapper = mount(SubstitutionPanel, {
props: defaultProps,
})
const tabs = wrapper.findAll('.tab-button')
const pitchingTab = tabs.find(tab => tab.text().includes('Pitching Change'))
await pitchingTab!.trigger('click')
expect(wrapper.vm.currentTab).toBe('pitching_change')
})
it('highlights active tab', async () => {
const wrapper = mount(SubstitutionPanel, {
props: defaultProps,
})
const tabs = wrapper.findAll('.tab-button')
const pinchHitterTab = tabs.find(tab => tab.text().includes('Pinch Hitter'))
await pinchHitterTab!.trigger('click')
await wrapper.vm.$nextTick()
expect(pinchHitterTab!.classes()).toContain('tab-active')
})
it('allows switching between tabs', async () => {
const wrapper = mount(SubstitutionPanel, {
props: defaultProps,
})
const tabs = wrapper.findAll('.tab-button')
const pinchHitterTab = tabs.find(tab => tab.text().includes('Pinch Hitter'))
const pitchingTab = tabs.find(tab => tab.text().includes('Pitching Change'))
await pinchHitterTab!.trigger('click')
expect(wrapper.vm.currentTab).toBe('pinch_hitter')
await pitchingTab!.trigger('click')
expect(wrapper.vm.currentTab).toBe('pitching_change')
})
})
describe('Pinch Hitter Mode', () => {
it('renders PinchHitterSelector when tab selected', async () => {
const wrapper = mount(SubstitutionPanel, {
props: defaultProps,
})
const tabs = wrapper.findAll('.tab-button')
const pinchHitterTab = tabs.find(tab => tab.text().includes('Pinch Hitter'))
await pinchHitterTab!.trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Select a bench player to bat in place of the current batter')
})
it('shows instruction box for pinch hitter', async () => {
const wrapper = mount(SubstitutionPanel, {
props: defaultProps,
})
const tabs = wrapper.findAll('.tab-button')
const pinchHitterTab = tabs.find(tab => tab.text().includes('Pinch Hitter'))
await pinchHitterTab!.trigger('click')
await wrapper.vm.$nextTick()
const instructionBox = wrapper.find('.instruction-box')
expect(instructionBox.exists()).toBe(true)
})
})
describe('Defensive Replacement Mode', () => {
it('renders DefensiveReplacementSelector when tab selected', async () => {
const wrapper = mount(SubstitutionPanel, {
props: defaultProps,
})
const tabs = wrapper.findAll('.tab-button')
const defensiveTab = tabs.find(tab => tab.text().includes('Defensive Sub'))
await defensiveTab!.trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Replace a defensive player with a bench player')
})
it('shows active fielders for selection', async () => {
const wrapper = mount(SubstitutionPanel, {
props: defaultProps,
})
const tabs = wrapper.findAll('.tab-button')
const defensiveTab = tabs.find(tab => tab.text().includes('Defensive Sub'))
await defensiveTab!.trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Select player to replace:')
expect(wrapper.text()).toContain('Jane Catcher')
expect(wrapper.text()).toContain('Bob First')
})
it('excludes pitcher from defensive replacement selection', async () => {
const wrapper = mount(SubstitutionPanel, {
props: defaultProps,
})
const tabs = wrapper.findAll('.tab-button')
const defensiveTab = tabs.find(tab => tab.text().includes('Defensive Sub'))
await defensiveTab!.trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.text()).not.toContain('John Pitcher')
})
})
describe('Pitching Change Mode', () => {
it('renders PitchingChangeSelector when tab selected', async () => {
const wrapper = mount(SubstitutionPanel, {
props: defaultProps,
})
const tabs = wrapper.findAll('.tab-button')
const pitchingTab = tabs.find(tab => tab.text().includes('Pitching Change'))
await pitchingTab!.trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Bring in a relief pitcher from the bench')
})
})
describe('Event Emissions', () => {
it('emits pinchHitter event with correct payload', async () => {
const wrapper = mount(SubstitutionPanel, {
props: defaultProps,
})
const tabs = wrapper.findAll('.tab-button')
const pinchHitterTab = tabs.find(tab => tab.text().includes('Pinch Hitter'))
await pinchHitterTab!.trigger('click')
const testPayload = {
playerOutLineupId: 1,
playerInCardId: 201,
teamId: 1,
}
// Manually trigger the handler
wrapper.vm.handlePinchHitterSubmit(testPayload)
await wrapper.vm.$nextTick()
expect(wrapper.emitted()).toHaveProperty('pinchHitter')
expect(wrapper.emitted('pinchHitter')![0]).toEqual([testPayload])
})
it('emits defensiveReplacement event with correct payload', async () => {
const wrapper = mount(SubstitutionPanel, {
props: defaultProps,
})
const testPayload = {
playerOutLineupId: 2,
playerInCardId: 203,
newPosition: '2B',
teamId: 1,
}
wrapper.vm.handleDefensiveReplacementSubmit(testPayload)
await wrapper.vm.$nextTick()
expect(wrapper.emitted()).toHaveProperty('defensiveReplacement')
expect(wrapper.emitted('defensiveReplacement')![0]).toEqual([testPayload])
})
it('emits pitchingChange event with correct payload', async () => {
const wrapper = mount(SubstitutionPanel, {
props: defaultProps,
})
const testPayload = {
playerOutLineupId: 1,
playerInCardId: 201,
teamId: 1,
}
wrapper.vm.handlePitchingChangeSubmit(testPayload)
await wrapper.vm.$nextTick()
expect(wrapper.emitted()).toHaveProperty('pitchingChange')
expect(wrapper.emitted('pitchingChange')![0]).toEqual([testPayload])
})
it('emits cancel event when selector cancelled', async () => {
const wrapper = mount(SubstitutionPanel, {
props: defaultProps,
})
wrapper.vm.handleCancel()
await wrapper.vm.$nextTick()
expect(wrapper.emitted()).toHaveProperty('cancel')
})
})
describe('Status Management', () => {
it('shows active status when tab selected', async () => {
const wrapper = mount(SubstitutionPanel, {
props: defaultProps,
})
const tabs = wrapper.findAll('.tab-button')
const pinchHitterTab = tabs.find(tab => tab.text().includes('Pinch Hitter'))
await pinchHitterTab!.trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.vm.statusClass).toBe('status-active')
expect(wrapper.vm.statusText).toBe('Active')
})
it('shows success status after successful submission', async () => {
const wrapper = mount(SubstitutionPanel, {
props: defaultProps,
})
const testPayload = {
playerOutLineupId: 1,
playerInCardId: 201,
teamId: 1,
}
wrapper.vm.handlePinchHitterSubmit(testPayload)
await wrapper.vm.$nextTick()
expect(wrapper.vm.statusClass).toBe('status-success')
expect(wrapper.vm.statusText).toBe('Success')
})
it('shows idle status by default', () => {
const wrapper = mount(SubstitutionPanel, {
props: defaultProps,
})
expect(wrapper.vm.statusClass).toBe('status-idle')
expect(wrapper.vm.statusText).toBe('Idle')
})
})
describe('Success Messages', () => {
it('displays success message after pinch hitter submission', async () => {
const wrapper = mount(SubstitutionPanel, {
props: defaultProps,
})
const testPayload = {
playerOutLineupId: 1,
playerInCardId: 201,
teamId: 1,
}
wrapper.vm.handlePinchHitterSubmit(testPayload)
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Pinch hitter substitution submitted')
})
it('displays success message after defensive replacement', async () => {
const wrapper = mount(SubstitutionPanel, {
props: defaultProps,
})
const testPayload = {
playerOutLineupId: 2,
playerInCardId: 203,
newPosition: '2B',
teamId: 1,
}
wrapper.vm.handleDefensiveReplacementSubmit(testPayload)
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Defensive replacement submitted')
})
it('displays success message after pitching change', async () => {
const wrapper = mount(SubstitutionPanel, {
props: defaultProps,
})
const testPayload = {
playerOutLineupId: 1,
playerInCardId: 201,
teamId: 1,
}
wrapper.vm.handlePitchingChangeSubmit(testPayload)
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Pitching change submitted')
})
})
describe('State Reset', () => {
it('resets to idle after cancel', async () => {
const wrapper = mount(SubstitutionPanel, {
props: defaultProps,
})
const tabs = wrapper.findAll('.tab-button')
const pinchHitterTab = tabs.find(tab => tab.text().includes('Pinch Hitter'))
await pinchHitterTab!.trigger('click')
expect(wrapper.vm.currentTab).toBe('pinch_hitter')
wrapper.vm.handleCancel()
await wrapper.vm.$nextTick()
expect(wrapper.vm.currentTab).toBeNull()
})
it('clears error when changing tabs', async () => {
const wrapper = mount(SubstitutionPanel, {
props: defaultProps,
})
wrapper.vm.error = 'Test error'
await wrapper.vm.$nextTick()
const tabs = wrapper.findAll('.tab-button')
const pinchHitterTab = tabs.find(tab => tab.text().includes('Pinch Hitter'))
await pinchHitterTab!.trigger('click')
expect(wrapper.vm.error).toBeNull()
})
it('clears defensive player selection when switching away from defensive tab', async () => {
const wrapper = mount(SubstitutionPanel, {
props: defaultProps,
})
wrapper.vm.selectedDefensivePlayer = mockCurrentLineup[1]
await wrapper.vm.$nextTick()
const tabs = wrapper.findAll('.tab-button')
const pinchHitterTab = tabs.find(tab => tab.text().includes('Pinch Hitter'))
await pinchHitterTab!.trigger('click')
expect(wrapper.vm.selectedDefensivePlayer).toBeNull()
})
})
describe('Edge Cases', () => {
it('handles empty bench players gracefully', () => {
const wrapper = mount(SubstitutionPanel, {
props: {
...defaultProps,
benchPlayers: [],
},
})
expect(wrapper.exists()).toBe(true)
})
it('handles missing currentPitcher', () => {
const wrapper = mount(SubstitutionPanel, {
props: {
...defaultProps,
currentPitcher: null,
},
})
expect(wrapper.exists()).toBe(true)
})
it('handles missing currentBatter', () => {
const wrapper = mount(SubstitutionPanel, {
props: {
...defaultProps,
currentBatter: null,
},
})
expect(wrapper.exists()).toBe(true)
})
})
})