strat-gameplay-webapp/frontend-sba/tests/unit/components/Gameplay/DiceShapes.spec.ts
Cal Corum 2b8fea36a8 CLAUDE: Redesign dice display with team colors and consolidate player cards
Backend:
- Add home_team_dice_color and away_team_dice_color to GameState model
- Extract dice_color from game metadata in StateManager (default: cc0000)
- Add runners_on_base param to roll_ab for chaos check skipping

Frontend - Dice Display:
- Create DiceShapes.vue with SVG d6 (square) and d20 (hexagon) shapes
- Apply home team's dice_color to d6 dice, white for resolution d20
- Show chaos d20 in amber only when WP/PB check triggered
- Add automatic text contrast based on color luminance
- Reduce blank space and remove info bubble from dice results

Frontend - Player Cards:
- Consolidate pitcher/batter cards to single location below diamond
- Add active card highlighting based on dice roll (d6_one: 1-3=batter, 4-6=pitcher)
- New card header format: [Team] Position [Name] with full card image
- Remove redundant card displays from GameBoard and GameplayPanel
- Enlarge PlayerCardModal on desktop (max-w-3xl at 1024px+)

Tests:
- Add DiceShapes.spec.ts with 34 tests for color calculations and rendering
- Update DiceRoller.spec.ts for new DiceShapes integration
- Fix test_roll_dice_success for new runners_on_base parameter

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 00:16:32 -06:00

462 lines
15 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import DiceShapes from '~/components/Gameplay/DiceShapes.vue'
describe('DiceShapes', () => {
const defaultD6Props = {
type: 'd6' as const,
value: 5,
}
const defaultD20Props = {
type: 'd20' as const,
value: 15,
}
// ============================================================================
// D6 Rendering Tests
// ============================================================================
describe('D6 Shape Rendering', () => {
it('renders d6 die container when type is d6', () => {
/**
* The d6 die should render a square-shaped die with rounded corners,
* decorative corner dots suggesting pip positions, and the value centered.
*/
const wrapper = mount(DiceShapes, {
props: defaultD6Props,
})
expect(wrapper.find('.die-container').exists()).toBe(true)
expect(wrapper.find('svg.die-shape').exists()).toBe(true)
})
it('renders rect element for d6 die body', () => {
/**
* D6 dice use a rounded rectangle (rect with rx/ry) to create the
* classic square die shape with softened corners.
*/
const wrapper = mount(DiceShapes, {
props: defaultD6Props,
})
const rect = wrapper.find('rect')
expect(rect.exists()).toBe(true)
expect(rect.attributes('rx')).toBe('10')
expect(rect.attributes('ry')).toBe('10')
})
it('renders four corner dots for d6 decoration', () => {
/**
* Four decorative dots in the corners suggest the pip positions
* of a physical die, adding visual authenticity.
*/
const wrapper = mount(DiceShapes, {
props: defaultD6Props,
})
const circles = wrapper.findAll('circle')
expect(circles).toHaveLength(4)
})
it('displays the value in the center', () => {
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, value: 3 },
})
expect(wrapper.find('.die-value').text()).toBe('3')
})
it('displays label when provided', () => {
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, label: 'd6 (One)' },
})
expect(wrapper.find('.die-label').exists()).toBe(true)
expect(wrapper.find('.die-label').text()).toBe('d6 (One)')
})
it('displays sublabel when provided', () => {
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, sublabel: '(3 + 2)' },
})
expect(wrapper.find('.die-sublabel').exists()).toBe(true)
expect(wrapper.find('.die-sublabel').text()).toBe('(3 + 2)')
})
it('hides label when not provided', () => {
const wrapper = mount(DiceShapes, {
props: defaultD6Props,
})
expect(wrapper.find('.die-label').exists()).toBe(false)
})
})
// ============================================================================
// D20 Rendering Tests
// ============================================================================
describe('D20 Shape Rendering', () => {
it('renders d20 die container when type is d20', () => {
/**
* The d20 die renders a hexagonal shape inspired by the icosahedron
* geometry of a real d20, with facet lines for 3D effect.
*/
const wrapper = mount(DiceShapes, {
props: defaultD20Props,
})
expect(wrapper.find('.die-container').exists()).toBe(true)
expect(wrapper.find('svg.die-shape').exists()).toBe(true)
})
it('renders polygon element for d20 hexagonal shape', () => {
/**
* D20 uses a 6-sided polygon (hexagon) rotated with flat top
* to suggest the multi-faceted nature of an icosahedron.
*/
const wrapper = mount(DiceShapes, {
props: defaultD20Props,
})
const polygon = wrapper.find('polygon')
expect(polygon.exists()).toBe(true)
// Verify points attribute contains 6 coordinate pairs
const points = polygon.attributes('points')
expect(points).toBeDefined()
const pointPairs = points!.split(' ')
expect(pointPairs).toHaveLength(6)
})
it('renders facet lines for 3D effect', () => {
/**
* Two vertical lines inside the hexagon create the illusion
* of depth and the faceted surface of a d20.
*/
const wrapper = mount(DiceShapes, {
props: defaultD20Props,
})
const lines = wrapper.findAll('line')
expect(lines).toHaveLength(2)
})
it('displays the value with large styling', () => {
const wrapper = mount(DiceShapes, {
props: defaultD20Props,
})
const valueEl = wrapper.find('.die-value')
expect(valueEl.text()).toBe('15')
expect(valueEl.classes()).toContain('die-value-large')
})
it('displays label when provided', () => {
const wrapper = mount(DiceShapes, {
props: { ...defaultD20Props, label: 'Resolution' },
})
expect(wrapper.find('.die-label').text()).toBe('Resolution')
})
})
// ============================================================================
// Color Calculation Tests
// ============================================================================
describe('Color Calculations', () => {
it('applies fill color from color prop', () => {
/**
* The color prop (hex without #) should be converted to a proper
* CSS color value and applied to the die fill.
*/
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, color: '0066ff' },
})
const rect = wrapper.find('rect')
expect(rect.attributes('fill')).toBe('#0066ff')
})
it('uses default red color when no color prop provided', () => {
/**
* Default dice color is cc0000 (red) matching the traditional
* Strat-O-Matic dice color.
*/
const wrapper = mount(DiceShapes, {
props: defaultD6Props,
})
const rect = wrapper.find('rect')
expect(rect.attributes('fill')).toBe('#cc0000')
})
it('calculates darker stroke color from fill', () => {
/**
* The stroke color should be a darkened version of the fill
* to create depth and definition around the die edge.
*/
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, color: 'ffffff' },
})
const rect = wrapper.find('rect')
const stroke = rect.attributes('stroke')
// White (ffffff) darkened by 20% should be #cccccc
expect(stroke).toBe('#cccccc')
})
it('uses white text on dark backgrounds', () => {
/**
* When the die color is dark (low luminance), the value text
* should be white for readability.
*/
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, color: '000000' },
})
const valueEl = wrapper.find('.die-value')
expect(valueEl.attributes('style')).toContain('color: #ffffff')
})
it('uses dark text on light backgrounds', () => {
/**
* When the die color is light (high luminance), the value text
* should be dark (#1a1a1a) for readability.
*/
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, color: 'ffffff' },
})
const valueEl = wrapper.find('.die-value')
expect(valueEl.attributes('style')).toContain('color: #1a1a1a')
})
it('uses white corner dots on dark d6 backgrounds', () => {
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, color: '000066' },
})
const circles = wrapper.findAll('circle')
expect(circles[0].attributes('fill')).toBe('#ffffff')
})
it('uses dark corner dots on light d6 backgrounds', () => {
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, color: 'ffff00' },
})
const circles = wrapper.findAll('circle')
expect(circles[0].attributes('fill')).toBe('#333333')
})
})
// ============================================================================
// Size Prop Tests
// ============================================================================
describe('Size Configuration', () => {
it('applies default size of 100px', () => {
const wrapper = mount(DiceShapes, {
props: defaultD6Props,
})
const container = wrapper.find('.die-container')
expect(container.attributes('style')).toContain('width: 100px')
expect(container.attributes('style')).toContain('height: 100px')
})
it('applies custom size from prop', () => {
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, size: 80 },
})
const container = wrapper.find('.die-container')
expect(container.attributes('style')).toContain('width: 80px')
expect(container.attributes('style')).toContain('height: 80px')
})
it('scales SVG viewBox to match size', () => {
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, size: 120 },
})
const svg = wrapper.find('svg')
expect(svg.attributes('viewBox')).toBe('0 0 120 120')
})
it('scales d6 rect dimensions proportionally', () => {
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, size: 80 },
})
const rect = wrapper.find('rect')
// width and height should be size - 8
expect(rect.attributes('width')).toBe('72')
expect(rect.attributes('height')).toBe('72')
})
})
// ============================================================================
// Hexagon Point Calculation Tests
// ============================================================================
describe('Hexagon Point Calculation', () => {
it('generates valid hexagon points for d20', () => {
/**
* The hexagon points should form a valid 6-sided polygon
* with coordinates that create a symmetrical shape.
*/
const wrapper = mount(DiceShapes, {
props: { ...defaultD20Props, size: 100 },
})
const polygon = wrapper.find('polygon')
const points = polygon.attributes('points')!
const pointPairs = points.split(' ')
// Each pair should be valid x,y coordinates
pointPairs.forEach(pair => {
const [x, y] = pair.split(',').map(Number)
expect(x).toBeGreaterThanOrEqual(0)
expect(x).toBeLessThanOrEqual(100)
expect(y).toBeGreaterThanOrEqual(0)
expect(y).toBeLessThanOrEqual(100)
})
})
it('scales hexagon points with size prop', () => {
const smallWrapper = mount(DiceShapes, {
props: { ...defaultD20Props, size: 50 },
})
const largeWrapper = mount(DiceShapes, {
props: { ...defaultD20Props, size: 100 },
})
const smallPoints = smallWrapper.find('polygon').attributes('points')!
const largePoints = largeWrapper.find('polygon').attributes('points')!
// First point of small should be roughly half the large
const smallFirst = smallPoints.split(' ')[0].split(',').map(Number)
const largeFirst = largePoints.split(' ')[0].split(',').map(Number)
expect(smallFirst[0]).toBeCloseTo(largeFirst[0] / 2, 0)
})
})
// ============================================================================
// Edge Cases
// ============================================================================
describe('Edge Cases', () => {
it('handles string value prop', () => {
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, value: '20' },
})
expect(wrapper.find('.die-value').text()).toBe('20')
})
it('handles very dark colors (near black)', () => {
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, color: '0a0a0a' },
})
const valueEl = wrapper.find('.die-value')
expect(valueEl.attributes('style')).toContain('color: #ffffff')
})
it('handles very light colors (near white)', () => {
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, color: 'f5f5f5' },
})
const valueEl = wrapper.find('.die-value')
expect(valueEl.attributes('style')).toContain('color: #1a1a1a')
})
it('handles mid-luminance colors correctly', () => {
/**
* Colors near the 50% luminance threshold should still pick
* appropriate contrast. 808080 (gray) has exactly 50% luminance.
*/
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, color: '808080' },
})
// Gray is exactly at threshold, should use white text
const valueEl = wrapper.find('.die-value')
expect(valueEl.attributes('style')).toBeDefined()
})
it('handles team colors correctly - red team', () => {
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, color: 'cc0000' },
})
const rect = wrapper.find('rect')
expect(rect.attributes('fill')).toBe('#cc0000')
// Text should be white on red
const valueEl = wrapper.find('.die-value')
expect(valueEl.attributes('style')).toContain('color: #ffffff')
})
it('handles team colors correctly - blue team', () => {
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, color: '0066cc' },
})
const rect = wrapper.find('rect')
expect(rect.attributes('fill')).toBe('#0066cc')
// Text should be white on blue
const valueEl = wrapper.find('.die-value')
expect(valueEl.attributes('style')).toContain('color: #ffffff')
})
it('handles team colors correctly - yellow team', () => {
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, color: 'ffcc00' },
})
const rect = wrapper.find('rect')
expect(rect.attributes('fill')).toBe('#ffcc00')
// Text should be dark on yellow
const valueEl = wrapper.find('.die-value')
expect(valueEl.attributes('style')).toContain('color: #1a1a1a')
})
})
// ============================================================================
// Slot Content Tests
// ============================================================================
describe('Slot Content', () => {
it('renders slot content instead of value when provided', () => {
/**
* The die-value slot allows custom content to be rendered
* instead of the raw number value.
*/
const wrapper = mount(DiceShapes, {
props: defaultD6Props,
slots: {
default: '<span class="custom-value">★</span>',
},
})
expect(wrapper.find('.custom-value').exists()).toBe(true)
expect(wrapper.find('.custom-value').text()).toBe('★')
})
it('falls back to value prop when no slot content', () => {
const wrapper = mount(DiceShapes, {
props: { ...defaultD6Props, value: 6 },
})
expect(wrapper.find('.die-value').text()).toBe('6')
})
})
})