strat-gameplay-webapp/frontend-sba/tests/unit/components/Gameplay/DiceRoller.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

472 lines
13 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import DiceRoller from '~/components/Gameplay/DiceRoller.vue'
import type { RollData } from '~/types'
describe('DiceRoller', () => {
const createRollData = (overrides = {}): RollData => ({
roll_id: 'test-roll-123',
d6_one: 3,
d6_two_a: 2,
d6_two_b: 4,
d6_two_total: 6,
chaos_d20: 15,
resolution_d20: 8,
check_wild_pitch: false,
check_passed_ball: false,
timestamp: '2025-01-13T12:00:00Z',
...overrides,
})
beforeEach(() => {
vi.clearAllTimers()
vi.useFakeTimers()
})
// ============================================================================
// Rendering Tests
// ============================================================================
describe('Rendering', () => {
it('renders roll button when no pending roll', () => {
const wrapper = mount(DiceRoller, {
props: {
canRoll: true,
pendingRoll: null,
},
})
expect(wrapper.find('.roll-button').exists()).toBe(true)
expect(wrapper.text()).toContain('Roll Dice')
expect(wrapper.find('.dice-results').exists()).toBe(false)
})
it('renders dice results when pending roll exists', () => {
const rollData = createRollData()
const wrapper = mount(DiceRoller, {
props: {
canRoll: false,
pendingRoll: rollData,
},
})
expect(wrapper.find('.roll-button').exists()).toBe(false)
expect(wrapper.find('.dice-results').exists()).toBe(true)
expect(wrapper.text()).toContain('Dice Results')
})
it('displays all four dice values correctly', () => {
const rollData = createRollData({
d6_one: 5,
d6_two_total: 8,
chaos_d20: 17,
resolution_d20: 3,
})
const wrapper = mount(DiceRoller, {
props: {
canRoll: false,
pendingRoll: rollData,
},
})
const diceValues = wrapper.findAll('.dice-value')
expect(diceValues).toHaveLength(4)
expect(diceValues[0].text()).toBe('5')
expect(diceValues[1].text()).toBe('8')
expect(diceValues[2].text()).toBe('17')
expect(diceValues[3].text()).toBe('3')
})
it('displays d6_two component dice values', () => {
const rollData = createRollData({
d6_two_a: 3,
d6_two_b: 5,
d6_two_total: 8,
})
const wrapper = mount(DiceRoller, {
props: {
canRoll: false,
pendingRoll: rollData,
},
})
expect(wrapper.text()).toContain('(3 + 5)')
})
})
// ============================================================================
// Button State Tests
// ============================================================================
describe('Button States', () => {
it('enables button when canRoll is true', () => {
const wrapper = mount(DiceRoller, {
props: {
canRoll: true,
pendingRoll: null,
},
})
const button = wrapper.find('.roll-button')
expect(button.classes()).toContain('roll-button-enabled')
expect(button.classes()).not.toContain('roll-button-disabled')
expect(button.attributes('disabled')).toBeUndefined()
})
it('disables button when canRoll is false', () => {
const wrapper = mount(DiceRoller, {
props: {
canRoll: false,
pendingRoll: null,
},
})
const button = wrapper.find('.roll-button')
expect(button.classes()).toContain('roll-button-disabled')
expect(button.classes()).not.toContain('roll-button-enabled')
expect(button.attributes('disabled')).toBeDefined()
})
it('shows loading state when rolling', async () => {
const wrapper = mount(DiceRoller, {
props: {
canRoll: true,
pendingRoll: null,
},
})
await wrapper.find('.roll-button').trigger('click')
expect(wrapper.text()).toContain('Rolling...')
expect(wrapper.find('.animate-spin').exists()).toBe(true)
})
it('disables button during rolling animation', async () => {
const wrapper = mount(DiceRoller, {
props: {
canRoll: true,
pendingRoll: null,
},
})
await wrapper.find('.roll-button').trigger('click')
const button = wrapper.find('.roll-button')
expect(button.attributes('disabled')).toBeDefined()
})
it('resets rolling state after timeout', async () => {
const wrapper = mount(DiceRoller, {
props: {
canRoll: true,
pendingRoll: null,
},
})
await wrapper.find('.roll-button').trigger('click')
expect(wrapper.text()).toContain('Rolling...')
vi.advanceTimersByTime(2000)
await wrapper.vm.$nextTick()
expect(wrapper.text()).not.toContain('Rolling...')
})
})
// ============================================================================
// Event Emission Tests
// ============================================================================
describe('Event Emission', () => {
it('emits roll event when button clicked', async () => {
const wrapper = mount(DiceRoller, {
props: {
canRoll: true,
pendingRoll: null,
},
})
await wrapper.find('.roll-button').trigger('click')
expect(wrapper.emitted('roll')).toBeTruthy()
expect(wrapper.emitted('roll')).toHaveLength(1)
})
it('does not emit roll when canRoll is false', async () => {
const wrapper = mount(DiceRoller, {
props: {
canRoll: false,
pendingRoll: null,
},
})
await wrapper.find('.roll-button').trigger('click')
expect(wrapper.emitted('roll')).toBeFalsy()
})
it('does not emit roll during rolling animation', async () => {
const wrapper = mount(DiceRoller, {
props: {
canRoll: true,
pendingRoll: null,
},
})
await wrapper.find('.roll-button').trigger('click')
await wrapper.find('.roll-button').trigger('click')
expect(wrapper.emitted('roll')).toHaveLength(1)
})
})
// ============================================================================
// Special Events Tests
// ============================================================================
describe('Special Events', () => {
it('shows wild pitch indicator when check_wild_pitch is true', () => {
const rollData = createRollData({ check_wild_pitch: true })
const wrapper = mount(DiceRoller, {
props: {
canRoll: false,
pendingRoll: rollData,
},
})
expect(wrapper.find('.wild-pitch').exists()).toBe(true)
expect(wrapper.text()).toContain('Wild Pitch Check')
})
it('shows passed ball indicator when check_passed_ball is true', () => {
const rollData = createRollData({ check_passed_ball: true })
const wrapper = mount(DiceRoller, {
props: {
canRoll: false,
pendingRoll: rollData,
},
})
expect(wrapper.find('.passed-ball').exists()).toBe(true)
expect(wrapper.text()).toContain('Passed Ball Check')
})
it('shows both indicators when both checks are true', () => {
const rollData = createRollData({
check_wild_pitch: true,
check_passed_ball: true,
})
const wrapper = mount(DiceRoller, {
props: {
canRoll: false,
pendingRoll: rollData,
},
})
expect(wrapper.find('.wild-pitch').exists()).toBe(true)
expect(wrapper.find('.passed-ball').exists()).toBe(true)
})
it('hides special events section when no checks are true', () => {
const rollData = createRollData({
check_wild_pitch: false,
check_passed_ball: false,
})
const wrapper = mount(DiceRoller, {
props: {
canRoll: false,
pendingRoll: rollData,
},
})
expect(wrapper.find('.special-events').exists()).toBe(false)
})
})
// ============================================================================
// Card Instructions Tests
// ============================================================================
describe('Card Instructions', () => {
it('shows card reading instructions when roll exists', () => {
const rollData = createRollData()
const wrapper = mount(DiceRoller, {
props: {
canRoll: false,
pendingRoll: rollData,
},
})
expect(wrapper.find('.card-instructions').exists()).toBe(true)
expect(wrapper.text()).toContain('Use these dice results')
})
it('does not show instructions when no roll', () => {
const wrapper = mount(DiceRoller, {
props: {
canRoll: true,
pendingRoll: null,
},
})
expect(wrapper.find('.card-instructions').exists()).toBe(false)
})
})
// ============================================================================
// Timestamp Formatting Tests
// ============================================================================
describe('Timestamp Formatting', () => {
it('formats timestamp correctly', () => {
const rollData = createRollData({
timestamp: '2025-01-13T14:30:45Z',
})
const wrapper = mount(DiceRoller, {
props: {
canRoll: false,
pendingRoll: rollData,
},
})
const timestampText = wrapper.find('.dice-header .text-sm').text()
expect(timestampText).toMatch(/\d{1,2}:\d{2}:\d{2}/)
})
})
// ============================================================================
// Dice Type Styling Tests
// ============================================================================
describe('Dice Type Styling', () => {
it('applies d6 styling to d6 dice', () => {
const rollData = createRollData()
const wrapper = mount(DiceRoller, {
props: {
canRoll: false,
pendingRoll: rollData,
},
})
const diceItems = wrapper.findAll('.dice-item')
expect(diceItems[0].classes()).toContain('dice-d6') // d6 one
expect(diceItems[1].classes()).toContain('dice-d6') // d6 two
})
it('applies d20 styling to d20 dice', () => {
const rollData = createRollData()
const wrapper = mount(DiceRoller, {
props: {
canRoll: false,
pendingRoll: rollData,
},
})
const diceItems = wrapper.findAll('.dice-item')
expect(diceItems[2].classes()).toContain('dice-d20') // chaos d20
expect(diceItems[3].classes()).toContain('dice-d20') // resolution d20
})
it('applies large value class to d20 dice', () => {
const rollData = createRollData()
const wrapper = mount(DiceRoller, {
props: {
canRoll: false,
pendingRoll: rollData,
},
})
const diceValues = wrapper.findAll('.dice-value')
expect(diceValues[2].classes()).toContain('dice-value-large')
expect(diceValues[3].classes()).toContain('dice-value-large')
})
})
// ============================================================================
// Edge Cases
// ============================================================================
describe('Edge Cases', () => {
it('handles maximum dice values', () => {
const rollData = createRollData({
d6_one: 6,
d6_two_a: 6,
d6_two_b: 6,
d6_two_total: 12,
chaos_d20: 20,
resolution_d20: 20,
})
const wrapper = mount(DiceRoller, {
props: {
canRoll: false,
pendingRoll: rollData,
},
})
expect(wrapper.text()).toContain('6')
expect(wrapper.text()).toContain('12')
expect(wrapper.text()).toContain('20')
})
it('handles minimum dice values', () => {
const rollData = createRollData({
d6_one: 1,
d6_two_a: 1,
d6_two_b: 1,
d6_two_total: 2,
chaos_d20: 1,
resolution_d20: 1,
})
const wrapper = mount(DiceRoller, {
props: {
canRoll: false,
pendingRoll: rollData,
},
})
expect(wrapper.text()).toContain('1')
expect(wrapper.text()).toContain('2')
})
it('transitions from no roll to roll result', async () => {
const wrapper = mount(DiceRoller, {
props: {
canRoll: true,
pendingRoll: null,
},
})
expect(wrapper.find('.roll-button').exists()).toBe(true)
expect(wrapper.find('.dice-results').exists()).toBe(false)
await wrapper.setProps({ pendingRoll: createRollData(), canRoll: false })
expect(wrapper.find('.roll-button').exists()).toBe(false)
expect(wrapper.find('.dice-results').exists()).toBe(true)
})
it('clears roll result when pendingRoll set to null', async () => {
const wrapper = mount(DiceRoller, {
props: {
canRoll: false,
pendingRoll: createRollData(),
},
})
expect(wrapper.find('.dice-results').exists()).toBe(true)
await wrapper.setProps({ pendingRoll: null, canRoll: true })
expect(wrapper.find('.dice-results').exists()).toBe(false)
expect(wrapper.find('.roll-button').exists()).toBe(true)
})
})
})