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>
472 lines
13 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|