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>
462 lines
15 KiB
TypeScript
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')
|
|
})
|
|
})
|
|
})
|