* 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
953 lines
32 KiB
TypeScript
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()
|
|
})
|
|
})
|