mantimon-tcg/frontend/src/game/layout.spec.ts
Cal Corum 0d416028c0
Fix prize zone rendering in Mantimon TCG mode (#2)
* Fix hand card rotation direction

Cards now fan outward correctly instead of curling inward

* Update StateRenderer to require MatchScene type for type safety

- Change constructor parameter from Phaser.Scene to MatchScene
- Update scene property type to MatchScene
- Add import for MatchScene type
- Update JSDoc example to reflect type-safe constructor

* Defer Board creation to StateRenderer for correct rules config

- Make board property nullable (Board | null instead of Board?)
- Remove Board and createBoard imports (now handled by StateRenderer)
- Update setupBoard() to skip Board creation
- Add setBoard() method for StateRenderer to call
- Update clearBoard() to use null instead of undefined
- Add JSDoc explaining why Board creation is deferred

* Create Board in StateRenderer with correct layout options

- Add Board and createBoard imports
- Add board property to StateRenderer
- Create Board in render() on first call with correct rules_config
- Add debug logging for Board creation and zone creation
- Update clear() to destroy Board when clearing
- Board now created after we have rules_config from first state

* Add fatal error handling with toast notification and auto-redirect

- Add 'fatal-error' event to GameBridgeEvents type
- Import and initialize useToast in GamePage
- Listen for 'fatal-error' event from Phaser
- Show error toast that persists until redirect
- Show full-screen fatal error overlay with countdown
- Auto-redirect to /play after 3 seconds
- Update StateRenderer to emit 'fatal-error' when Board creation fails

* Gate debug logging with DEV flag

- Add DEBUG_RENDERER constant gated by import.meta.env.DEV
- Update all console.log statements in StateRenderer to only log in development
- Keep console.error and console.warn as they are (always show errors)
- Debug logs now only appear during development, not in production

* Fix code audit issues - add missing imports and improve error UX

Critical fixes:
- Add missing gameBridge import to StateRenderer (fixes runtime error in fatal error handler)
- Add missing Board type import to MatchScene (fixes TypeScript compilation error)

UX improvements:
- Replace fatal error auto-redirect with manual 'Return to Menu' button
- Add toast notification when resignation fails
- Give users unlimited time to read fatal errors before returning

Addresses issues found in frontend code audit:
- errors.missing-import (StateRenderer.ts:166)
- errors.missing-type-import (MatchScene.ts:84)
- errors.catch-only-console (GamePage.vue:145)
- architecture.missing-fatal-error-handling (GamePage.vue:261)

* Add CONTRIBUTING policy and fix pre-existing lint/test errors

- Add CONTRIBUTING.md with strict policy: never use --no-verify without approval
- Add comprehensive testing documentation (TESTING.md, VISUAL-TEST-GUIDE.md)
- Add test-prize-fix.md quick test checklist and verify-fix.sh script

Lint fixes (enables pre-commit hooks):
- Remove unused imports in 9 files
- Fix unused variables (underscore convention)
- Replace 'as any' type assertions with proper VisibleGameState types
- Add missing CARD_WIDTH_MEDIUM import in layout.spec.ts
- All ESLint errors now resolved (only acceptable warnings remain)

Test fixes (all 1000 tests now passing):
- Fix layout.spec.ts: Add missing CARD_WIDTH_MEDIUM import
- Fix PlayPage.spec.ts: Update test to use actual hardcoded UUIDs
- Fix useAuth.spec.ts: Mock API profile fetch in initialization tests
- Fix PhaserGame.spec.ts: Add scenes export to mock and update createGame call expectations

This ensures pre-commit hooks work properly going forward and prevents
bypassing TypeScript/lint checks that catch errors early.

* Add comprehensive test coverage improvement plan

- Create PROJECT_PLAN_TEST_COVERAGE.json with 25 structured tasks
- Create TEST_COVERAGE_PLAN.md with executive summary and roadmap
- Plan addresses critical gaps: game engine (0%), WebSocket (27%)
- 6-week roadmap to reach 85% coverage from current 63%
- Target: Phase 1 (weeks 1-3) - critical game engine and network tests
- Includes quick wins, production blockers, and success metrics

Based on coverage analysis showing:
- Strong: Composables (84%), Components (90%), Stores (88%)
- Critical gaps: Phaser game engine (~5,500 untested lines)
- High priority: WebSocket/multiplayer reliability

See TEST_COVERAGE_PLAN.md for overview and week-by-week breakdown.

* Add coverage tooling and ignore coverage directory

- Add @vitest/coverage-v8 package for coverage analysis
- Add coverage/ directory to .gitignore
- Used during test coverage analysis for PROJECT_PLAN_TEST_COVERAGE.json
2026-02-02 15:30:27 -06:00

953 lines
32 KiB
TypeScript

/**
* Tests for Board Layout System.
*
* Verifies that layout calculations produce valid, non-overlapping zone
* positions for both landscape and portrait orientations.
*/
import { describe, it, expect } from 'vitest'
import {
calculateLayout,
isPortrait,
getScaleFactor,
zonesOverlap,
isZoneInBounds,
getAllZones,
getCardSize,
BENCH_SIZE,
PRIZE_SIZE,
CARD_WIDTH_MEDIUM,
} from './layout'
import type { BoardLayout, ZonePosition } from '@/types/phaser'
// Design resolution constants (duplicated here to avoid Phaser import)
const DESIGN_WIDTH = 1920
const DESIGN_HEIGHT = 1080
// =============================================================================
// Test Constants
// =============================================================================
/** Standard landscape resolution (1080p) */
const LANDSCAPE_WIDTH = 1920
const LANDSCAPE_HEIGHT = 1080
/** Standard portrait resolution (mobile) */
const PORTRAIT_WIDTH = 390
const PORTRAIT_HEIGHT = 844
/** 4K resolution for scaling tests */
const UHD_WIDTH = 3840
const UHD_HEIGHT = 2160
// =============================================================================
// Helper Functions
// =============================================================================
/**
* Check that a zone has valid positive dimensions.
*/
function isValidZone(zone: ZonePosition): boolean {
return (
typeof zone.x === 'number' &&
typeof zone.y === 'number' &&
typeof zone.width === 'number' &&
typeof zone.height === 'number' &&
zone.width > 0 &&
zone.height > 0 &&
!isNaN(zone.x) &&
!isNaN(zone.y)
)
}
/**
* Check that critical gameplay zones don't overlap.
*
* We check active zones, bench slots, and deck/discard.
* Hand zones are allowed to be wide and may overlap with other areas.
* Prize zones may overlap with each other (stacked display).
*/
function getCriticalZones(layout: BoardLayout): ZonePosition[] {
return [
layout.myActive,
...layout.myBench,
layout.myDeck,
layout.myDiscard,
layout.oppActive,
...layout.oppBench,
layout.oppDeck,
layout.oppDiscard,
]
}
// =============================================================================
// Orientation Detection Tests
// =============================================================================
describe('isPortrait', () => {
it('returns false for landscape aspect ratios', () => {
/**
* Test that standard desktop/landscape resolutions are detected as landscape.
*
* Landscape detection is critical for applying the correct layout algorithm
* that places opponent zones at top and player zones at bottom.
*/
expect(isPortrait(1920, 1080)).toBe(false)
expect(isPortrait(1280, 720)).toBe(false)
expect(isPortrait(2560, 1440)).toBe(false)
})
it('returns true for portrait aspect ratios', () => {
/**
* Test that mobile/portrait resolutions are detected as portrait.
*
* Portrait detection enables the compressed horizontal layout needed
* for narrow screens where cards must fit side-by-side.
*/
expect(isPortrait(390, 844)).toBe(true)
expect(isPortrait(375, 812)).toBe(true)
expect(isPortrait(414, 896)).toBe(true)
})
it('handles edge case near threshold', () => {
/**
* Test behavior around the portrait/landscape threshold.
*
* The threshold should provide a clean switch point without
* ambiguous behavior for borderline aspect ratios.
*/
// Just below threshold = portrait
expect(isPortrait(89, 100)).toBe(true)
// At or above threshold = landscape
expect(isPortrait(90, 100)).toBe(false)
expect(isPortrait(100, 100)).toBe(false)
})
})
describe('getScaleFactor', () => {
it('returns 1.0 for design resolution', () => {
/**
* Test that the design resolution produces a scale factor of 1.0.
*
* This verifies our base calculations are correct and layout positions
* will match expected values at the reference resolution.
*/
const scale = getScaleFactor(DESIGN_WIDTH, DESIGN_HEIGHT)
expect(scale).toBe(1)
})
it('scales down for smaller screens', () => {
/**
* Test that smaller screens get a scale factor less than 1.0.
*
* Proper scaling ensures cards and zones fit on smaller displays
* while maintaining playable proportions.
*/
const scale = getScaleFactor(960, 540)
expect(scale).toBe(0.5)
})
it('scales up for larger screens', () => {
/**
* Test that larger screens get a scale factor greater than 1.0.
*
* Scaling up for 4K and larger displays ensures the game
* doesn't appear tiny on high-resolution screens.
*/
const scale = getScaleFactor(UHD_WIDTH, UHD_HEIGHT)
expect(scale).toBe(2)
})
it('uses limiting dimension for non-matching aspect ratios', () => {
/**
* Test that scaling is constrained by the smaller dimension ratio.
*
* For example, an ultra-wide screen should scale based on height
* to prevent vertical overflow.
*/
// Ultra-wide: limited by height
const ultraWide = getScaleFactor(3440, 1080)
expect(ultraWide).toBe(1) // Limited by 1080/1080
// Tall and narrow: limited by width
const tallNarrow = getScaleFactor(960, 2160)
expect(tallNarrow).toBe(0.5) // Limited by 960/1920
})
})
// =============================================================================
// Landscape Layout Tests
// =============================================================================
describe('calculateLayout - landscape', () => {
it('produces valid positions for all zones', () => {
/**
* Test that all zones in a landscape layout have valid positions.
*
* Every zone must have numeric coordinates and positive dimensions
* for Phaser to render them correctly.
*/
const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT)
const allZones = getAllZones(layout)
for (const { label, zone } of allZones) {
expect(isValidZone(zone), `Zone ${label} should be valid`).toBe(true)
}
})
it('creates correct number of bench slots', () => {
/**
* Test that bench arrays have exactly 5 slots.
*
* The Pokemon TCG bench has 5 positions, and both players
* need all slots available.
*/
const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT)
expect(layout.myBench).toHaveLength(BENCH_SIZE)
expect(layout.oppBench).toHaveLength(BENCH_SIZE)
})
it('creates correct number of prize slots', () => {
/**
* Test that prize arrays have exactly 6 slots.
*
* Each player starts with 6 prize cards that must all have
* defined positions.
*/
const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT)
expect(layout.myPrizes).toHaveLength(PRIZE_SIZE)
expect(layout.oppPrizes).toHaveLength(PRIZE_SIZE)
})
it('places player zones in bottom half of screen', () => {
/**
* Test that player's active zone is below the center line.
*
* The player's perspective should have their cards at the bottom
* for easy access and viewing.
*/
const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT)
const centerY = LANDSCAPE_HEIGHT / 2
expect(layout.myActive.y).toBeGreaterThan(centerY)
expect(layout.myDeck.y).toBeGreaterThan(centerY)
})
it('places opponent zones in top half of screen', () => {
/**
* Test that opponent's active zone is above the center line.
*
* The opponent's cards should be at the top, creating the
* traditional face-to-face card game layout.
*/
const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT)
const centerY = LANDSCAPE_HEIGHT / 2
expect(layout.oppActive.y).toBeLessThan(centerY)
expect(layout.oppDeck.y).toBeLessThan(centerY)
})
it('rotates opponent zones by 180 degrees', () => {
/**
* Test that opponent cards are displayed upside down.
*
* This creates the impression of facing an opponent across
* a table, making their cards readable from their perspective.
*/
const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT)
expect(layout.oppActive.rotation).toBe(Math.PI)
expect(layout.oppBench[0].rotation).toBe(Math.PI)
})
it('does not rotate player zones', () => {
/**
* Test that player's own cards are right-side up.
*
* The player's cards should face them for easy reading.
*/
const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT)
expect(layout.myActive.rotation).toBe(0)
expect(layout.myBench[0].rotation).toBe(0)
})
it('places hand zone at bottom edge', () => {
/**
* Test that the hand zone is near the bottom of the screen.
*
* The hand should be easily accessible at the bottom where
* the player can drag cards from it.
*/
const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT)
// Hand should be in the bottom 15% of the screen
expect(layout.myHand.y).toBeGreaterThan(LANDSCAPE_HEIGHT * 0.85)
})
it('critical zones do not overlap', () => {
/**
* Test that active zones, bench slots, and deck/discard don't overlap.
*
* Overlapping zones would make it impossible to distinguish
* between different game areas during play.
*/
const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT)
const criticalZones = getCriticalZones(layout)
for (let i = 0; i < criticalZones.length; i++) {
for (let j = i + 1; j < criticalZones.length; j++) {
const overlaps = zonesOverlap(criticalZones[i], criticalZones[j])
expect(overlaps, `Zones ${i} and ${j} should not overlap`).toBe(false)
}
}
})
it('active zones are horizontally centered', () => {
/**
* Test that active zones are centered on the screen.
*
* The active Pokemon should be prominently displayed in
* the center of each player's area.
*/
const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT)
const centerX = LANDSCAPE_WIDTH / 2
expect(layout.myActive.x).toBe(centerX)
expect(layout.oppActive.x).toBe(centerX)
})
})
// =============================================================================
// Portrait Layout Tests
// =============================================================================
describe('calculateLayout - portrait', () => {
it('produces valid positions for all zones', () => {
/**
* Test that portrait layout produces valid zone positions.
*
* Portrait mode must work on mobile devices with limited
* horizontal space.
*/
const layout = calculateLayout(PORTRAIT_WIDTH, PORTRAIT_HEIGHT)
const allZones = getAllZones(layout)
for (const { label, zone } of allZones) {
expect(isValidZone(zone), `Zone ${label} should be valid`).toBe(true)
}
})
it('uses smaller card sizes than landscape', () => {
/**
* Test that portrait mode uses smaller card sizes.
*
* Mobile screens need smaller cards to fit all zones
* without excessive overlap.
*/
const portrait = calculateLayout(PORTRAIT_WIDTH, PORTRAIT_HEIGHT)
const landscape = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT)
expect(portrait.myActive.width).toBeLessThan(landscape.myActive.width)
expect(portrait.myActive.height).toBeLessThan(landscape.myActive.height)
})
it('maintains player/opponent vertical separation', () => {
/**
* Test that player and opponent zones are still vertically separated.
*
* Even in portrait mode, the fundamental layout of opponent-top
* and player-bottom must be maintained.
*/
const layout = calculateLayout(PORTRAIT_WIDTH, PORTRAIT_HEIGHT)
expect(layout.myActive.y).toBeGreaterThan(layout.oppActive.y)
})
it('fits all bench slots within screen width', () => {
/**
* Test that bench slots don't extend beyond screen edges.
*
* Portrait mode must compress bench horizontally while keeping
* all 5 slots visible and accessible.
*/
const layout = calculateLayout(PORTRAIT_WIDTH, PORTRAIT_HEIGHT)
for (const benchSlot of layout.myBench) {
const left = benchSlot.x - benchSlot.width / 2
const right = benchSlot.x + benchSlot.width / 2
expect(left).toBeGreaterThanOrEqual(0)
expect(right).toBeLessThanOrEqual(PORTRAIT_WIDTH)
}
})
it('critical zones do not overlap', () => {
/**
* Test that critical zones don't overlap in portrait mode.
*
* The compressed layout must still keep gameplay zones distinct.
*/
const layout = calculateLayout(PORTRAIT_WIDTH, PORTRAIT_HEIGHT)
const criticalZones = getCriticalZones(layout)
for (let i = 0; i < criticalZones.length; i++) {
for (let j = i + 1; j < criticalZones.length; j++) {
const overlaps = zonesOverlap(criticalZones[i], criticalZones[j])
expect(overlaps, `Zones ${i} and ${j} should not overlap`).toBe(false)
}
}
})
})
// =============================================================================
// Scaling Tests
// =============================================================================
describe('layout scaling', () => {
it('scales proportionally for larger screens', () => {
/**
* Test that 4K resolution produces proportionally larger zones.
*
* The layout should scale up while maintaining the same
* relative proportions.
*/
const hd = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT)
const uhd = calculateLayout(UHD_WIDTH, UHD_HEIGHT)
// 4K is exactly 2x 1080p, so zones should be 2x larger
expect(uhd.myActive.width).toBeCloseTo(hd.myActive.width * 2, 0)
expect(uhd.myActive.height).toBeCloseTo(hd.myActive.height * 2, 0)
})
it('scales proportionally for smaller screens', () => {
/**
* Test that half-size resolution produces proportionally smaller zones.
*
* The layout should scale down while maintaining relative proportions.
*/
const hd = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT)
const half = calculateLayout(LANDSCAPE_WIDTH / 2, LANDSCAPE_HEIGHT / 2)
expect(half.myActive.width).toBeCloseTo(hd.myActive.width / 2, 0)
expect(half.myActive.height).toBeCloseTo(hd.myActive.height / 2, 0)
})
it('maintains relative zone positions across scales', () => {
/**
* Test that zone positions scale proportionally.
*
* If active zone is at 50% width on 1080p, it should still
* be at 50% width on 4K.
*/
const hd = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT)
const uhd = calculateLayout(UHD_WIDTH, UHD_HEIGHT)
// Active zone should be centered at same relative position
const hdRelativeX = hd.myActive.x / LANDSCAPE_WIDTH
const uhdRelativeX = uhd.myActive.x / UHD_WIDTH
expect(uhdRelativeX).toBeCloseTo(hdRelativeX, 1)
})
})
// =============================================================================
// Utility Function Tests
// =============================================================================
describe('zonesOverlap', () => {
it('detects overlapping zones', () => {
/**
* Test that overlapping rectangles are correctly identified.
*
* This utility is critical for validating layout correctness.
*/
const zone1: ZonePosition = { x: 100, y: 100, width: 50, height: 50 }
const zone2: ZonePosition = { x: 120, y: 120, width: 50, height: 50 }
expect(zonesOverlap(zone1, zone2)).toBe(true)
})
it('returns false for non-overlapping zones', () => {
/**
* Test that separated zones are correctly identified as non-overlapping.
*/
const zone1: ZonePosition = { x: 100, y: 100, width: 50, height: 50 }
const zone2: ZonePosition = { x: 200, y: 200, width: 50, height: 50 }
expect(zonesOverlap(zone1, zone2)).toBe(false)
})
it('handles adjacent zones (edge touching)', () => {
/**
* Test that zones with touching edges are not considered overlapping.
*
* Two zones can be directly adjacent without overlapping.
*/
const zone1: ZonePosition = { x: 100, y: 100, width: 50, height: 50 }
const zone2: ZonePosition = { x: 150, y: 100, width: 50, height: 50 } // Adjacent on right
expect(zonesOverlap(zone1, zone2)).toBe(false)
})
})
describe('isZoneInBounds', () => {
it('returns true for zones within canvas', () => {
/**
* Test that zones fully within canvas bounds are detected.
*/
const zone: ZonePosition = { x: 500, y: 500, width: 100, height: 100 }
expect(isZoneInBounds(zone, 1000, 1000)).toBe(true)
})
it('returns false for zones extending past canvas', () => {
/**
* Test that zones extending beyond canvas edges are detected.
*/
const zone: ZonePosition = { x: 980, y: 500, width: 100, height: 100 }
expect(isZoneInBounds(zone, 1000, 1000)).toBe(false)
})
})
describe('getCardSize', () => {
it('returns medium card size for landscape', () => {
/**
* Test that landscape mode uses medium-sized cards.
*
* Desktop screens have enough space for comfortably-sized cards.
*/
const size = getCardSize(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT)
// Should be close to medium card size at scale 1.0
expect(size.width).toBeCloseTo(CARD_WIDTH_MEDIUM, 0)
})
it('returns smaller card size for portrait', () => {
/**
* Test that portrait mode uses smaller cards.
*
* Mobile screens need smaller cards to fit the compressed layout.
*/
const portrait = getCardSize(PORTRAIT_WIDTH, PORTRAIT_HEIGHT)
const landscape = getCardSize(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT)
expect(portrait.width).toBeLessThan(landscape.width)
})
})
describe('getAllZones', () => {
it('returns all zones with labels', () => {
/**
* Test that getAllZones returns all defined zones.
*
* This utility should provide complete access to all layout zones
* for iteration and validation.
*/
const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT)
const allZones = getAllZones(layout)
// Count expected zones:
// 2 active + 10 bench + 2 hand + 2 deck + 2 discard + 12 prizes + 2 energy = 32
const expectedCount = 2 + (BENCH_SIZE * 2) + 2 + 2 + 2 + (PRIZE_SIZE * 2) + 2
expect(allZones).toHaveLength(expectedCount)
})
it('includes labeled bench slots', () => {
/**
* Test that bench slots have indexed labels.
*
* Labels should clearly identify which slot each zone represents.
*/
const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT)
const allZones = getAllZones(layout)
const benchLabels = allZones.filter(z => z.label.includes('Bench'))
expect(benchLabels).toHaveLength(BENCH_SIZE * 2)
// Check specific labels exist
expect(allZones.find(z => z.label === 'myBench[0]')).toBeDefined()
expect(allZones.find(z => z.label === 'myBench[4]')).toBeDefined()
expect(allZones.find(z => z.label === 'oppBench[0]')).toBeDefined()
})
})
// =============================================================================
// Conditional Prize Zone Tests (RulesConfig support)
// =============================================================================
describe('calculateLayout - prize zone options', () => {
it('returns empty prize arrays when usePrizeCards is false', () => {
/**
* Test that prize zones are not generated in points-based mode.
*
* When using the Mantimon TCG points system instead of classic prize cards,
* the prize zones should be omitted from the layout to save screen space.
*/
const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, {
usePrizeCards: false,
})
expect(layout.myPrizes).toHaveLength(0)
expect(layout.oppPrizes).toHaveLength(0)
})
it('returns correct prize count when usePrizeCards is true with custom count', () => {
/**
* Test that prize zones can have a custom count.
*
* Different rule variants may use 4, 6, or other prize counts.
* The layout should generate exactly the requested number of slots.
*/
const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, {
usePrizeCards: true,
prizeCount: 4,
})
expect(layout.myPrizes).toHaveLength(4)
expect(layout.oppPrizes).toHaveLength(4)
})
it('defaults to 6 prizes when usePrizeCards is true without count', () => {
/**
* Test that classic mode defaults to 6 prize cards.
*
* When using prize cards without specifying a count, the standard
* Pokemon TCG count of 6 should be used.
*/
const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, {
usePrizeCards: true,
})
expect(layout.myPrizes).toHaveLength(PRIZE_SIZE)
expect(layout.oppPrizes).toHaveLength(PRIZE_SIZE)
})
it('returns 6 prizes when no options provided (backwards compatibility)', () => {
/**
* Test backwards compatibility with existing code.
*
* When called without options, the layout should behave as before,
* generating 6 prize slots for each player.
*/
const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT)
// Default behavior assumes classic prize card mode with 6 prizes
expect(layout.myPrizes).toHaveLength(PRIZE_SIZE)
expect(layout.oppPrizes).toHaveLength(PRIZE_SIZE)
})
it('works correctly in portrait mode with usePrizeCards false', () => {
/**
* Test that portrait mode also respects prize card settings.
*
* The portrait layout should also omit prize zones when using
* the points system.
*/
const layout = calculateLayout(PORTRAIT_WIDTH, PORTRAIT_HEIGHT, {
usePrizeCards: false,
})
expect(layout.myPrizes).toHaveLength(0)
expect(layout.oppPrizes).toHaveLength(0)
})
it('works correctly in portrait mode with custom prize count', () => {
/**
* Test that portrait mode generates correct prize count.
*
* The portrait layout should create the correct number of
* prize positions when using prize cards with a custom count.
*/
const layout = calculateLayout(PORTRAIT_WIDTH, PORTRAIT_HEIGHT, {
usePrizeCards: true,
prizeCount: 4,
})
expect(layout.myPrizes).toHaveLength(4)
expect(layout.oppPrizes).toHaveLength(4)
})
it('prize positions are valid when generated', () => {
/**
* Test that generated prize positions are all valid.
*
* Each prize slot should have positive dimensions and valid coordinates.
*/
const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, {
usePrizeCards: true,
prizeCount: 4,
})
for (const prize of layout.myPrizes) {
expect(isValidZone(prize), 'Player prize zone should be valid').toBe(true)
}
for (const prize of layout.oppPrizes) {
expect(isValidZone(prize), 'Opponent prize zone should be valid').toBe(true)
}
})
})
// =============================================================================
// Conditional Bench Size Tests (RulesConfig support)
// =============================================================================
describe('calculateLayout - bench size options', () => {
it('returns default 5 bench slots when no options provided', () => {
/**
* Test backwards compatibility with existing code.
*
* When called without options, the layout should generate 5 bench slots
* for each player (standard Pokemon TCG bench size).
*/
const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT)
expect(layout.myBench).toHaveLength(BENCH_SIZE)
expect(layout.oppBench).toHaveLength(BENCH_SIZE)
})
it('returns correct bench count with custom benchSize option', () => {
/**
* Test that bench zones can have a custom count.
*
* Some rule variants may use fewer bench slots (e.g., 3 for a faster game).
* The layout should generate exactly the requested number of slots.
*/
const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, {
benchSize: 3,
})
expect(layout.myBench).toHaveLength(3)
expect(layout.oppBench).toHaveLength(3)
})
it('works with larger bench sizes', () => {
/**
* Test that larger bench sizes are supported.
*
* Some custom variants might use more than 5 bench slots.
*/
const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, {
benchSize: 8,
})
expect(layout.myBench).toHaveLength(8)
expect(layout.oppBench).toHaveLength(8)
})
it('works correctly in portrait mode with custom bench size', () => {
/**
* Test that portrait mode also respects bench size settings.
*
* The portrait layout should also generate the correct number
* of bench positions based on the benchSize option.
*/
const layout = calculateLayout(PORTRAIT_WIDTH, PORTRAIT_HEIGHT, {
benchSize: 3,
})
expect(layout.myBench).toHaveLength(3)
expect(layout.oppBench).toHaveLength(3)
})
it('bench positions are valid when using custom size', () => {
/**
* Test that generated bench positions are all valid.
*
* Each bench slot should have positive dimensions and valid coordinates.
*/
const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, {
benchSize: 4,
})
for (const bench of layout.myBench) {
expect(isValidZone(bench), 'Player bench zone should be valid').toBe(true)
}
for (const bench of layout.oppBench) {
expect(isValidZone(bench), 'Opponent bench zone should be valid').toBe(true)
}
})
it('bench slots are centered properly with different sizes', () => {
/**
* Test that bench zones remain centered regardless of count.
*
* Whether using 3, 5, or 8 bench slots, they should all be
* horizontally centered on the screen.
*/
const layout3 = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, { benchSize: 3 })
const layout5 = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, { benchSize: 5 })
const layout7 = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, { benchSize: 7 })
const centerX = LANDSCAPE_WIDTH / 2
// Calculate average X position of each player's bench
const avg3 = layout3.myBench.reduce((sum, b) => sum + b.x, 0) / 3
const avg5 = layout5.myBench.reduce((sum, b) => sum + b.x, 0) / 5
const avg7 = layout7.myBench.reduce((sum, b) => sum + b.x, 0) / 7
// All should be centered
expect(avg3).toBeCloseTo(centerX, 0)
expect(avg5).toBeCloseTo(centerX, 0)
expect(avg7).toBeCloseTo(centerX, 0)
})
it('can combine benchSize with prize options', () => {
/**
* Test that bench and prize options work together.
*
* Custom rules might have both custom bench size and prize count,
* and both should be applied correctly.
*/
const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, {
benchSize: 3,
usePrizeCards: true,
prizeCount: 4,
})
expect(layout.myBench).toHaveLength(3)
expect(layout.oppBench).toHaveLength(3)
expect(layout.myPrizes).toHaveLength(4)
expect(layout.oppPrizes).toHaveLength(4)
})
})
// =============================================================================
// Conditional Energy Deck Zone Tests (RulesConfig support)
// =============================================================================
describe('calculateLayout - energy deck options', () => {
it('returns energy zones when energyDeckEnabled is true (default)', () => {
/**
* Test that energy deck zones are generated by default.
*
* The Pokemon Pocket-style energy deck zone should be included
* when the energy deck feature is enabled (the default behavior).
*/
const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT)
expect(layout.myEnergyZone).not.toBeNull()
expect(layout.oppEnergyZone).not.toBeNull()
})
it('returns null energy zones when energyDeckEnabled is false', () => {
/**
* Test that energy zones are omitted in classic mode.
*
* When using classic Pokemon TCG rules without an energy deck,
* the energy zones should be null to save screen space.
*/
const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, {
energyDeckEnabled: false,
})
expect(layout.myEnergyZone).toBeNull()
expect(layout.oppEnergyZone).toBeNull()
})
it('energy zones have valid positions when enabled', () => {
/**
* Test that generated energy zones have valid positions.
*
* Each energy zone should have positive dimensions and valid coordinates.
*/
const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, {
energyDeckEnabled: true,
})
expect(layout.myEnergyZone).not.toBeNull()
expect(layout.oppEnergyZone).not.toBeNull()
if (layout.myEnergyZone) {
expect(isValidZone(layout.myEnergyZone), 'Player energy zone should be valid').toBe(true)
}
if (layout.oppEnergyZone) {
expect(isValidZone(layout.oppEnergyZone), 'Opponent energy zone should be valid').toBe(true)
}
})
it('works correctly in portrait mode with energyDeckEnabled false', () => {
/**
* Test that portrait mode also respects energy deck settings.
*
* The portrait layout should also omit energy zones when using
* classic mode without an energy deck.
*/
const layout = calculateLayout(PORTRAIT_WIDTH, PORTRAIT_HEIGHT, {
energyDeckEnabled: false,
})
expect(layout.myEnergyZone).toBeNull()
expect(layout.oppEnergyZone).toBeNull()
})
it('works correctly in portrait mode with energyDeckEnabled true', () => {
/**
* Test that portrait mode generates energy zones when enabled.
*
* The portrait layout should include energy zones when the
* energy deck feature is enabled.
*/
const layout = calculateLayout(PORTRAIT_WIDTH, PORTRAIT_HEIGHT, {
energyDeckEnabled: true,
})
expect(layout.myEnergyZone).not.toBeNull()
expect(layout.oppEnergyZone).not.toBeNull()
})
it('can combine energyDeckEnabled with other options', () => {
/**
* Test that energy deck option works with other layout options.
*
* Custom rules might combine energy deck settings with bench size
* and prize options, and all should be applied correctly.
*/
const layout = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, {
energyDeckEnabled: false,
benchSize: 3,
usePrizeCards: true,
prizeCount: 4,
})
expect(layout.myEnergyZone).toBeNull()
expect(layout.oppEnergyZone).toBeNull()
expect(layout.myBench).toHaveLength(3)
expect(layout.oppBench).toHaveLength(3)
expect(layout.myPrizes).toHaveLength(4)
expect(layout.oppPrizes).toHaveLength(4)
})
it('getAllZones excludes null energy zones', () => {
/**
* Test that getAllZones handles null energy zones correctly.
*
* When energy deck is disabled, getAllZones should not include
* energy zone entries in its output.
*/
const layoutWithEnergy = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, {
energyDeckEnabled: true,
})
const layoutWithoutEnergy = calculateLayout(LANDSCAPE_WIDTH, LANDSCAPE_HEIGHT, {
energyDeckEnabled: false,
})
const zonesWithEnergy = getAllZones(layoutWithEnergy)
const zonesWithoutEnergy = getAllZones(layoutWithoutEnergy)
// Should have 2 fewer zones when energy deck is disabled
expect(zonesWithoutEnergy.length).toBe(zonesWithEnergy.length - 2)
// Verify energy zone labels exist/don't exist
expect(zonesWithEnergy.find(z => z.label === 'myEnergyZone')).toBeDefined()
expect(zonesWithEnergy.find(z => z.label === 'oppEnergyZone')).toBeDefined()
expect(zonesWithoutEnergy.find(z => z.label === 'myEnergyZone')).toBeUndefined()
expect(zonesWithoutEnergy.find(z => z.label === 'oppEnergyZone')).toBeUndefined()
})
})