Compare commits
10 Commits
b47da20b7f
...
69daedfa02
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69daedfa02 | ||
|
|
73f65df7b7 | ||
|
|
345ef7af9d | ||
|
|
63bcff8d9f | ||
|
|
c5aef933e2 | ||
|
|
8aab41485d | ||
|
|
d03dc1ddd2 | ||
|
|
56de143397 | ||
|
|
c45fae8c57 | ||
|
|
0d416028c0 |
144
CONTRIBUTING.md
Normal file
144
CONTRIBUTING.md
Normal file
@ -0,0 +1,144 @@
|
||||
# Contributing to Mantimon TCG
|
||||
|
||||
## Git Commit Guidelines
|
||||
|
||||
### Pre-Commit Hooks
|
||||
|
||||
**CRITICAL RULE: Never use `--no-verify` without explicit approval.**
|
||||
|
||||
This project has pre-commit hooks that run:
|
||||
- ESLint (catches undefined variables, unused imports, syntax errors)
|
||||
- TypeScript type checking (catches missing imports, type errors)
|
||||
- Tests (ensures nothing breaks)
|
||||
|
||||
These hooks are **required** to catch bugs before they enter the codebase.
|
||||
|
||||
### When Hooks Fail
|
||||
|
||||
If the pre-commit hook fails:
|
||||
|
||||
✅ **DO:**
|
||||
1. Read the error message carefully
|
||||
2. Fix the errors in your code
|
||||
3. Commit normally (hooks will pass)
|
||||
|
||||
❌ **DO NOT:**
|
||||
1. Use `--no-verify` to bypass the hooks
|
||||
2. Ignore lint/type errors
|
||||
3. Assume "it will be fine"
|
||||
|
||||
### Exception Process
|
||||
|
||||
If you believe you **must** bypass hooks (extremely rare):
|
||||
|
||||
1. **Ask for approval first:** "I need to use --no-verify because [reason]"
|
||||
2. **Wait for explicit approval**
|
||||
3. **Document why** in the commit message
|
||||
4. **Fix the issues** in a follow-up commit immediately
|
||||
|
||||
### Why This Matters
|
||||
|
||||
**Real Example:** In PR #X, commits were made with `--no-verify` to bypass pre-existing lint errors. This caused:
|
||||
- Missing imports that weren't caught until code audit
|
||||
- Runtime errors that would have been caught by TypeScript
|
||||
- Hours of debugging that could have been prevented
|
||||
|
||||
The pre-commit hook would have caught these issues in 5 seconds.
|
||||
|
||||
## Common Scenarios
|
||||
|
||||
### "But there are pre-existing lint errors!"
|
||||
|
||||
**Solution:** Fix them first in a separate commit:
|
||||
|
||||
```bash
|
||||
# Fix pre-existing errors
|
||||
npm run lint -- --fix
|
||||
git add .
|
||||
git commit -m "Fix pre-existing lint errors"
|
||||
|
||||
# Now do your work with hooks enabled
|
||||
git add my-feature.ts
|
||||
git commit -m "Add new feature" # Hooks will pass
|
||||
```
|
||||
|
||||
### "The hook is taking too long!"
|
||||
|
||||
The hooks are optimized to only check changed files. If they're slow:
|
||||
- Check if you have too many staged files
|
||||
- Consider committing in smaller chunks
|
||||
- Don't bypass - the time saved now will cost hours later
|
||||
|
||||
### "I'm just fixing a typo!"
|
||||
|
||||
Even typo fixes should pass hooks. If hooks fail on a typo fix, something is wrong with the surrounding code that needs addressing.
|
||||
|
||||
---
|
||||
|
||||
## Code Quality Standards
|
||||
|
||||
### TypeScript
|
||||
|
||||
- All code must pass `npm run typecheck`
|
||||
- No `any` types without justification
|
||||
- Explicit return types on functions
|
||||
- Proper imports (no missing or unused)
|
||||
|
||||
### ESLint
|
||||
|
||||
- All code must pass `npm run lint`
|
||||
- No unused variables or imports
|
||||
- Follow project style guide
|
||||
- Use `eslint-disable` sparingly with comments explaining why
|
||||
|
||||
### Testing
|
||||
|
||||
- All code must pass `npm run test`
|
||||
- New features require tests
|
||||
- Bug fixes require regression tests
|
||||
|
||||
---
|
||||
|
||||
## Pre-Commit Hook Details
|
||||
|
||||
Located at: `.git/hooks/pre-commit`
|
||||
|
||||
**Backend checks:**
|
||||
- Black formatting (`black --check app tests`)
|
||||
- Ruff linting (`ruff check app tests`)
|
||||
- Pytest (`pytest --tb=short -q`)
|
||||
|
||||
**Frontend checks:**
|
||||
- ESLint (`npm run lint`)
|
||||
- TypeScript (`npm run typecheck`)
|
||||
- Vitest (`npm run test`)
|
||||
|
||||
All checks must pass before commit is allowed.
|
||||
|
||||
---
|
||||
|
||||
## Emergency Bypass Procedure
|
||||
|
||||
**Only with explicit approval:**
|
||||
|
||||
```bash
|
||||
# 1. Get approval first
|
||||
# 2. Document in commit message:
|
||||
git commit --no-verify -m "Emergency fix: [issue]
|
||||
|
||||
This commit bypasses pre-commit hooks because [specific reason].
|
||||
Pre-commit errors will be fixed in next commit.
|
||||
|
||||
Approved by: [name]"
|
||||
|
||||
# 3. Fix immediately:
|
||||
git commit -m "Fix issues from previous emergency commit"
|
||||
```
|
||||
|
||||
**Never leave bypassed commits unfixed.**
|
||||
|
||||
---
|
||||
|
||||
## Questions?
|
||||
|
||||
If you're unsure whether to bypass hooks, **ask first**. It's better to ask than to introduce bugs into the codebase.
|
||||
307
TESTING.md
Normal file
307
TESTING.md
Normal file
@ -0,0 +1,307 @@
|
||||
# Testing Guide: Prize Zone Fix
|
||||
|
||||
Branch: `fix/defer-board-creation-until-state`
|
||||
|
||||
## What Was Fixed
|
||||
|
||||
**Bug:** Prize zone rectangles (2x3 grid) were appearing even when `use_prize_cards: false` (Mantimon TCG points mode).
|
||||
|
||||
**Root Cause:** Board was created during scene initialization before WebSocket state arrived, using default `usePrizeCards: true`.
|
||||
|
||||
**Fix:** Defer Board creation until StateRenderer receives first game state with correct `rules_config`.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Terminal 1: Start backend
|
||||
cd backend && uv run uvicorn app.main:app --reload
|
||||
|
||||
# Terminal 2: Start frontend
|
||||
cd frontend && npm run dev
|
||||
|
||||
# Terminal 3: Ensure Docker services running
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
Open: `http://localhost:5173`
|
||||
|
||||
---
|
||||
|
||||
## Test 1: Visual Verification (CRITICAL)
|
||||
|
||||
**Game:** `/game/f6f158c4-47b0-41b9-b3c2-8edc8275b70c` (or any Mantimon mode game)
|
||||
|
||||
### What to Look For:
|
||||
|
||||
**BEFORE FIX (Bug):**
|
||||
```
|
||||
+-----------------+
|
||||
| Opp Active |
|
||||
| Opp Bench |
|
||||
| [P][P] <---- Prize rectangles (WRONG!)
|
||||
| [P][P]
|
||||
| [P][P]
|
||||
+-----------------+
|
||||
| My Active |
|
||||
| My Bench |
|
||||
| [P][P] <---- Prize rectangles (WRONG!)
|
||||
| [P][P]
|
||||
| [P][P]
|
||||
+-----------------+
|
||||
```
|
||||
|
||||
**AFTER FIX (Correct):**
|
||||
```
|
||||
+-----------------+
|
||||
| Opp Active |
|
||||
| Opp Bench |
|
||||
| (no prizes) | <---- CORRECT!
|
||||
+-----------------+
|
||||
| My Active |
|
||||
| My Bench |
|
||||
| (no prizes) | <---- CORRECT!
|
||||
+-----------------+
|
||||
```
|
||||
|
||||
### Expected Board Layout (Mantimon Mode):
|
||||
|
||||
```
|
||||
Top (Opponent):
|
||||
- Active Zone (1 large rectangle, center-top)
|
||||
- Bench Slots (5 rectangles, horizontal row)
|
||||
- Deck (small rectangle, top-right)
|
||||
- Discard (small rectangle, next to deck)
|
||||
- Energy Deck (small rectangle, top-left)
|
||||
- Hand (row of card backs, very top)
|
||||
|
||||
Bottom (You):
|
||||
- Active Zone (1 large rectangle, center-bottom)
|
||||
- Bench Slots (5 rectangles, horizontal row)
|
||||
- Deck (small rectangle, bottom-right)
|
||||
- Discard (small rectangle, next to deck)
|
||||
- Energy Deck (small rectangle, bottom-left)
|
||||
- Hand (fanned cards, very bottom)
|
||||
```
|
||||
|
||||
**What should NOT be there:**
|
||||
- ❌ 2x3 grid of small rectangles (prize cards)
|
||||
- ❌ Any 6-rectangle groups
|
||||
- ❌ Empty bordered boxes on the left/right sides
|
||||
|
||||
---
|
||||
|
||||
## Test 2: Console Verification
|
||||
|
||||
Open DevTools Console (F12), look for these logs:
|
||||
|
||||
### ✅ CORRECT Logs:
|
||||
```
|
||||
[StateRenderer] Creating board with layout options: {
|
||||
usePrizeCards: false, ← Should be FALSE
|
||||
prizeCount: 4,
|
||||
benchSize: 5,
|
||||
energyDeckEnabled: true
|
||||
}
|
||||
[StateRenderer] ✓ Board created successfully
|
||||
[StateRenderer] Creating zones with rules: {
|
||||
usePrizeCards: false, ← Should be FALSE
|
||||
energyDeckEnabled: true,
|
||||
benchSize: 5
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ WRONG Logs (indicates bug):
|
||||
```
|
||||
[StateRenderer] Creating board with layout options: {
|
||||
usePrizeCards: true, ← WRONG! Should be false
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test 3: Loading Overlay
|
||||
|
||||
1. Refresh the game page
|
||||
2. Watch the loading sequence
|
||||
|
||||
**Expected:**
|
||||
1. "Connecting to game..." overlay appears instantly
|
||||
2. Overlay stays for 1-3 seconds
|
||||
3. Overlay disappears, board is fully rendered
|
||||
4. **No visual flash or flicker** of wrong state
|
||||
|
||||
**Bug would be:** Brief flash of prize rectangles before they disappear
|
||||
|
||||
---
|
||||
|
||||
## Test 4: Fatal Error Handling
|
||||
|
||||
### Setup (Temporary):
|
||||
Edit `frontend/src/game/sync/StateRenderer.ts`, line 154:
|
||||
|
||||
```typescript
|
||||
// Add this line temporarily:
|
||||
throw new Error('Test fatal error')
|
||||
this.board = createBoard(this.scene, this.layout)
|
||||
```
|
||||
|
||||
### Test Steps:
|
||||
1. Refresh game page
|
||||
2. Observe error handling
|
||||
|
||||
### Expected:
|
||||
- ✅ Toast appears (bottom-right): "Failed to initialize game board: Test fatal error"
|
||||
- ✅ Full-screen overlay appears:
|
||||
- Red error icon
|
||||
- "Failed to initialize game board"
|
||||
- "The game cannot continue. Please return to the menu."
|
||||
- Blue "Return to Menu" button
|
||||
- ✅ **NO auto-redirect** (wait 10+ seconds to verify)
|
||||
- ✅ Click button → redirects to `/play`
|
||||
|
||||
### Console Expected:
|
||||
```
|
||||
[StateRenderer] ✗ Failed to create board: Error: Test fatal error
|
||||
[GamePage] Fatal error from Phaser: {
|
||||
message: 'Failed to initialize game board',
|
||||
error: 'Test fatal error',
|
||||
context: 'StateRenderer.render()'
|
||||
}
|
||||
```
|
||||
|
||||
### Cleanup:
|
||||
**IMPORTANT:** Remove the `throw new Error()` line after testing!
|
||||
|
||||
---
|
||||
|
||||
## Test 5: Resign Failure Toast
|
||||
|
||||
### Setup:
|
||||
1. Start a game
|
||||
2. Open DevTools → Network tab → Set to "Offline"
|
||||
|
||||
### Test Steps:
|
||||
1. Click exit button (X, top-right)
|
||||
2. Click "Resign and Leave"
|
||||
|
||||
### Expected:
|
||||
- ✅ Toast appears: "Could not confirm resignation with server. Leaving game anyway."
|
||||
- ✅ Still redirects to `/play` (doesn't get stuck)
|
||||
- ✅ Console: `[GamePage] Failed to resign: ...`
|
||||
|
||||
### Cleanup:
|
||||
Turn network back to "Online"
|
||||
|
||||
---
|
||||
|
||||
## Test 6: TypeScript Compilation
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
### Expected Output:
|
||||
```
|
||||
> mantimon-tcg-frontend@0.1.0 typecheck
|
||||
> vue-tsc --noEmit
|
||||
|
||||
✓ No errors
|
||||
```
|
||||
|
||||
**Bug would be:**
|
||||
```
|
||||
error TS2304: Cannot find name 'gameBridge'
|
||||
error TS2304: Cannot find name 'Board'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test 7: Classic Mode (Prize Cards Enabled)
|
||||
|
||||
**Note:** This requires a game with `use_prize_cards: true` in backend rules.
|
||||
|
||||
### Expected:
|
||||
- ✅ 6 prize zone rectangles ARE visible (2x3 grid, left side)
|
||||
- ✅ Same for opponent (top-left)
|
||||
|
||||
### Console Should Show:
|
||||
```
|
||||
[StateRenderer] Creating board with layout options: {
|
||||
usePrizeCards: true, ← Should be TRUE for classic mode
|
||||
prizeCount: 6,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test 8: Multiple Games
|
||||
|
||||
1. Play game 1 (Mantimon mode)
|
||||
2. Exit
|
||||
3. Start game 2 (any mode)
|
||||
|
||||
### Expected:
|
||||
- ✅ Game 1: No prize rectangles
|
||||
- ✅ Exit works cleanly
|
||||
- ✅ Game 2: Board matches its rules
|
||||
- ✅ No leftover visual artifacts
|
||||
|
||||
### Console Should Show:
|
||||
```
|
||||
[StateRenderer] Board destroyed ← From game 1
|
||||
[StateRenderer] Creating board... ← For game 2
|
||||
[StateRenderer] ✓ Board created successfully
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pass/Fail Criteria
|
||||
|
||||
### ✅ ALL TESTS PASS IF:
|
||||
|
||||
1. **Visual:** No prize rectangles in Mantimon mode games
|
||||
2. **Console:** `usePrizeCards: false` in logs
|
||||
3. **Loading:** No visual flash of wrong state
|
||||
4. **Fatal Error:** Manual button, no auto-redirect
|
||||
5. **Resign:** Toast appears on failure
|
||||
6. **TypeScript:** No compilation errors
|
||||
7. **Classic Mode:** Prize rectangles appear when enabled
|
||||
8. **Multiple Games:** Clean transitions
|
||||
|
||||
### ❌ FIX FAILED IF:
|
||||
|
||||
- Prize rectangles visible when `use_prize_cards: false`
|
||||
- Console shows `usePrizeCards: true` for Mantimon games
|
||||
- Fatal error auto-redirects after 3 seconds
|
||||
- TypeScript compilation errors
|
||||
|
||||
---
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
If any test fails, report:
|
||||
|
||||
1. **Which test failed**
|
||||
2. **What you saw** (screenshot preferred)
|
||||
3. **Console logs** (copy full output)
|
||||
4. **Game ID** you tested with
|
||||
5. **Browser** and version
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria Summary
|
||||
|
||||
**The fix is working if:**
|
||||
|
||||
✓ No prize zone rectangles appear in your test game
|
||||
✓ Console shows `usePrizeCards: false`
|
||||
✓ Board renders correctly after loading overlay
|
||||
✓ Fatal error requires manual button click
|
||||
✓ TypeScript compiles without errors
|
||||
|
||||
**Ready for merge if all tests pass!**
|
||||
234
VISUAL-TEST-GUIDE.md
Normal file
234
VISUAL-TEST-GUIDE.md
Normal file
@ -0,0 +1,234 @@
|
||||
# Visual Test Guide - Prize Zone Fix
|
||||
|
||||
## 🔍 What to Look For
|
||||
|
||||
### ❌ BEFORE FIX (The Bug)
|
||||
|
||||
When you load the game, you would see **6 prize rectangles** even in Mantimon mode:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ OPPONENT SIDE (TOP) │
|
||||
│ │
|
||||
│ ┌─────┐ │
|
||||
│ │ ACT │ Active Zone │
|
||||
│ └─────┘ │
|
||||
│ │
|
||||
│ ┌───┐┌───┐┌───┐┌───┐┌───┐ │
|
||||
│ │ B ││ B ││ B ││ B ││ B │ Bench │
|
||||
│ └───┘└───┘└───┘└───┘└───┘ │
|
||||
│ │
|
||||
│ ┌──┐┌──┐ ◄── WRONG! │
|
||||
│ │P1││P2│ Prize cards │
|
||||
│ └──┘└──┘ should NOT be here! │
|
||||
│ ┌──┐┌──┐ │
|
||||
│ │P3││P4│ │
|
||||
│ └──┘└──┘ │
|
||||
│ ┌──┐┌──┐ │
|
||||
│ │P5││P6│ │
|
||||
│ └──┘└──┘ │
|
||||
│ │
|
||||
├──────────────────────────────────────────┤
|
||||
│ YOUR SIDE (BOTTOM) │
|
||||
│ │
|
||||
│ ┌──┐┌──┐ ◄── WRONG! │
|
||||
│ │P1││P2│ Prize cards │
|
||||
│ └──┘└──┘ should NOT be here! │
|
||||
│ ┌──┐┌──┐ │
|
||||
│ │P3││P4│ │
|
||||
│ └──┘└──┘ │
|
||||
│ ┌──┐┌──┐ │
|
||||
│ │P5││P6│ │
|
||||
│ └──┘└──┘ │
|
||||
│ │
|
||||
│ ┌───┐┌───┐┌───┐┌───┐┌───┐ │
|
||||
│ │ B ││ B ││ B ││ B ││ B │ Bench │
|
||||
│ └───┘└───┘└───┘└───┘└───┘ │
|
||||
│ │
|
||||
│ ┌─────┐ │
|
||||
│ │ ACT │ Active Zone │
|
||||
│ └─────┘ │
|
||||
│ │
|
||||
│ ┌───┐┌───┐┌───┐┌───┐┌───┐┌───┐┌───┐ │
|
||||
│ │ 🃏 │ 🃏 │ 🃏 │ 🃏 │ 🃏 │ 🃏 │ 🃏 │ Hand │
|
||||
│ └───┘└───┘└───┘└───┘└───┘└───┘└───┘ │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**The Bug:** Those `P1-P6` prize rectangles appear on the left side!
|
||||
|
||||
---
|
||||
|
||||
### ✅ AFTER FIX (Correct)
|
||||
|
||||
After the fix, **NO prize rectangles** should appear:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ OPPONENT SIDE (TOP) │
|
||||
│ │
|
||||
│ ┌─────┐ │
|
||||
│ │ ACT │ Active Zone │
|
||||
│ └─────┘ │
|
||||
│ │
|
||||
│ ┌───┐┌───┐┌───┐┌───┐┌───┐ │
|
||||
│ │ B ││ B ││ B ││ B ││ B │ Bench │
|
||||
│ └───┘└───┘└───┘└───┘└───┘ │
|
||||
│ │
|
||||
│ (no prize rectangles here) ✓ │
|
||||
│ │
|
||||
│ ┌──┐ ┌──┐ │
|
||||
│ │📚│ │🗑│ Deck & Discard │
|
||||
│ └──┘ └──┘ │
|
||||
│ │
|
||||
├──────────────────────────────────────────┤
|
||||
│ YOUR SIDE (BOTTOM) │
|
||||
│ │
|
||||
│ ┌──┐ ┌──┐ │
|
||||
│ │⚡│ │📚│ Energy Deck & Deck │
|
||||
│ └──┘ └──┘ │
|
||||
│ │
|
||||
│ (no prize rectangles here) ✓ │
|
||||
│ │
|
||||
│ ┌───┐┌───┐┌───┐┌───┐┌───┐ │
|
||||
│ │ B ││ B ││ B ││ B ││ B │ Bench │
|
||||
│ └───┘└───┘└───┘└───┘└───┘ │
|
||||
│ │
|
||||
│ ┌─────┐ │
|
||||
│ │ ACT │ Active Zone │
|
||||
│ └─────┘ │
|
||||
│ │
|
||||
│ ┌───┐┌───┐┌───┐┌───┐┌───┐┌───┐┌───┐ │
|
||||
│ │ 🃏 │ 🃏 │ 🃏 │ 🃏 │ 🃏 │ 🃏 │ 🃏 │ Hand │
|
||||
│ └───┘└───┘└───┘└───┘└───┘└───┘└───┘ │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**The Fix:** No `P1-P6` rectangles! Clean board layout!
|
||||
|
||||
---
|
||||
|
||||
## 📸 Screenshot Comparison
|
||||
|
||||
### Before Fix:
|
||||
- You'll see **12 total rectangles** (6 opponent + 6 yours) that are prize zones
|
||||
- They're usually on the left side of the board
|
||||
- Each is a small bordered rectangle in a 2x3 grid pattern
|
||||
|
||||
### After Fix:
|
||||
- Those 12 rectangles are **gone**
|
||||
- Board feels more spacious
|
||||
- Only essential zones visible
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Quick Visual Test (10 seconds)
|
||||
|
||||
1. Load game: http://localhost:5173/game/f6f158c4-47b0-41b9-b3c2-8edc8275b70c
|
||||
2. Wait for "Connecting..." overlay to disappear
|
||||
3. Count small rectangles on the board
|
||||
|
||||
**PASS:** You count approximately 12-15 rectangles total:
|
||||
- 1 Active (large, yours)
|
||||
- 1 Active (large, opponent)
|
||||
- 5 Bench (yours)
|
||||
- 5 Bench (opponent)
|
||||
- 2 Deck zones
|
||||
- 2 Discard zones
|
||||
- 2 Energy Deck zones (small)
|
||||
|
||||
**FAIL:** You count 24+ rectangles (because 12 extra prize rectangles appeared)
|
||||
|
||||
---
|
||||
|
||||
## 🐛 What the Bug Looked Like
|
||||
|
||||
The bug was **subtle but obvious once you knew to look for it**:
|
||||
|
||||
- 6 small bordered rectangles in a 2x3 grid pattern
|
||||
- Usually on the left side of each player's area
|
||||
- All empty (no cards in them)
|
||||
- Labeled or unlabeled as "prizes"
|
||||
|
||||
These appeared even when the game rules said `use_prize_cards: false` (Mantimon TCG uses points instead of prize cards).
|
||||
|
||||
---
|
||||
|
||||
## ✨ What Success Looks Like
|
||||
|
||||
**Clean board with only these zones:**
|
||||
|
||||
**Opponent (Top):**
|
||||
- 1 Active zone
|
||||
- 5 Bench slots
|
||||
- 1 Hand (row of card backs)
|
||||
- 1 Deck pile
|
||||
- 1 Discard pile
|
||||
- 1 Energy Deck pile (small)
|
||||
|
||||
**You (Bottom):**
|
||||
- 1 Active zone
|
||||
- 5 Bench slots
|
||||
- 1 Hand (fanned cards)
|
||||
- 1 Deck pile
|
||||
- 1 Discard pile
|
||||
- 1 Energy Deck pile (small)
|
||||
|
||||
**Total visible zones:** ~14 (no prizes)
|
||||
|
||||
**If you see more zones:** The bug is still present!
|
||||
|
||||
---
|
||||
|
||||
## 🎮 Browser Test
|
||||
|
||||
**Easiest way to verify:**
|
||||
|
||||
1. Open game in browser
|
||||
2. Take a screenshot
|
||||
3. Count the distinct bordered rectangles
|
||||
4. If you count 12 extra small rectangles → Bug still exists
|
||||
5. If board looks clean and minimal → Fix worked!
|
||||
|
||||
---
|
||||
|
||||
## 📊 Expected vs Actual
|
||||
|
||||
| Zone Type | Count (Yours) | Count (Opponent) | Total |
|
||||
|-----------|---------------|------------------|-------|
|
||||
| Active | 1 | 1 | 2 |
|
||||
| Bench | 5 | 5 | 10 |
|
||||
| Hand | 1 | 1 | 2 |
|
||||
| Deck | 1 | 1 | 2 |
|
||||
| Discard | 1 | 1 | 2 |
|
||||
| Energy Deck | 1 | 1 | 2 |
|
||||
| **Prizes** | **0** ✓ | **0** ✓ | **0** ✓ |
|
||||
| **TOTAL** | **10** | **10** | **20** |
|
||||
|
||||
**With bug:** Total would be 32 (20 + 12 prize zones)
|
||||
**After fix:** Total is 20 (no prize zones)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Still See Prize Rectangles?
|
||||
|
||||
If you still see the 2x3 grid of prize rectangles:
|
||||
|
||||
1. Check you're on the right branch: `fix/defer-board-creation-until-state`
|
||||
2. Verify the build is using the latest code: `git log --oneline -1`
|
||||
3. Hard refresh the browser: Ctrl+Shift+R (or Cmd+Shift+R on Mac)
|
||||
4. Check console for `usePrizeCards: false` in the logs
|
||||
5. If console shows `usePrizeCards: true`, the fix didn't apply correctly
|
||||
|
||||
---
|
||||
|
||||
## ✅ Success Confirmation
|
||||
|
||||
**You'll know the fix worked when:**
|
||||
|
||||
✓ Board looks cleaner (fewer rectangles)
|
||||
✓ No 2x3 grid pattern visible
|
||||
✓ Only essential game zones present
|
||||
✓ Console logs show `usePrizeCards: false`
|
||||
|
||||
**Ready to merge!** 🚀
|
||||
3
frontend/.gitignore
vendored
3
frontend/.gitignore
vendored
@ -23,3 +23,6 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Test coverage
|
||||
coverage/
|
||||
|
||||
864
frontend/PROJECT_PLAN_TEST_COVERAGE.json
Normal file
864
frontend/PROJECT_PLAN_TEST_COVERAGE.json
Normal file
@ -0,0 +1,864 @@
|
||||
{
|
||||
"meta": {
|
||||
"version": "1.0.3",
|
||||
"created": "2026-02-02",
|
||||
"lastUpdated": "2026-02-03",
|
||||
"started": "2026-02-02",
|
||||
"planType": "testing",
|
||||
"description": "Test coverage improvement plan - filling critical gaps in game engine, WebSocket, and gameplay code",
|
||||
"totalEstimatedHours": 120,
|
||||
"totalTasks": 35,
|
||||
"completedTasks": 6,
|
||||
"currentCoverage": "~67%",
|
||||
"targetCoverage": "85%",
|
||||
"progress": {
|
||||
"testsAdded": 337,
|
||||
"totalTests": 1337,
|
||||
"quickWinsCompleted": 3,
|
||||
"quickWinsRemaining": 0,
|
||||
"hoursSpent": 33,
|
||||
"coverageGain": "+5%",
|
||||
"branchStatus": "active",
|
||||
"branchName": "test/coverage-improvements"
|
||||
}
|
||||
},
|
||||
"categories": {
|
||||
"critical": "Zero or critically low coverage (<30%) on production code",
|
||||
"high": "Important gaps (30-60%) in core functionality",
|
||||
"medium": "Quality improvements (60-80%) for completeness",
|
||||
"low": "Polish and edge cases (80-90%)",
|
||||
"infrastructure": "Testing infrastructure and tooling"
|
||||
},
|
||||
"tasks": [
|
||||
{
|
||||
"id": "TEST-001",
|
||||
"name": "Create Phaser testing infrastructure",
|
||||
"description": "Set up minimal Phaser mocks and testing utilities to enable testing of game engine code. Create reusable mock classes for Phaser.Scene, Phaser.Game, Phaser.GameObjects, etc. This is the foundation for all subsequent game engine tests.",
|
||||
"category": "critical",
|
||||
"priority": 1,
|
||||
"completed": true,
|
||||
"tested": true,
|
||||
"dependencies": [],
|
||||
"files": [
|
||||
{
|
||||
"path": "src/test/mocks/phaser.ts",
|
||||
"lines": [1, 700],
|
||||
"issue": "COMPLETE - Comprehensive Phaser mocks created (MockScene, MockGame, MockContainer, MockSprite, MockText, MockGraphics, MockEventEmitter)"
|
||||
},
|
||||
{
|
||||
"path": "src/test/helpers/gameTestUtils.ts",
|
||||
"lines": [1, 400],
|
||||
"issue": "COMPLETE - Test utilities created (createMockGameState, createMockCard, setupMockScene, createGameScenario, etc.)"
|
||||
},
|
||||
{
|
||||
"path": "src/test/mocks/phaser.spec.ts",
|
||||
"lines": [1, 350],
|
||||
"issue": "COMPLETE - Mock tests added (33 tests verifying mock API)"
|
||||
},
|
||||
{
|
||||
"path": "src/test/helpers/gameTestUtils.spec.ts",
|
||||
"lines": [1, 300],
|
||||
"issue": "COMPLETE - Utility tests added (22 tests verifying helper functions)"
|
||||
},
|
||||
{
|
||||
"path": "src/test/README.md",
|
||||
"lines": [],
|
||||
"issue": "COMPLETE - Documentation created for testing infrastructure"
|
||||
}
|
||||
],
|
||||
"suggestedFix": "1. Create src/test/mocks/phaser.ts with MockScene, MockGame, MockSprite, MockGraphics classes\n2. Create src/test/helpers/gameTestUtils.ts with helper functions: createMockGameState(), createMockCard(), setupMockScene()\n3. Document mock API in test/README.md\n4. Add vitest setup file to auto-import mocks",
|
||||
"estimatedHours": 8,
|
||||
"notes": "This is a prerequisite for all Phaser-related tests. Use existing PhaserGame.spec.ts as reference for what mocks are needed. Keep mocks minimal - only mock what's necessary for tests to run."
|
||||
},
|
||||
{
|
||||
"id": "TEST-002",
|
||||
"name": "Test MatchScene initialization and lifecycle",
|
||||
"description": "Test MatchScene.ts core functionality: scene creation, initialization, state setup, and cleanup. Cover the scene lifecycle from preload to shutdown.",
|
||||
"category": "critical",
|
||||
"priority": 2,
|
||||
"completed": true,
|
||||
"tested": true,
|
||||
"dependencies": ["TEST-001"],
|
||||
"files": [
|
||||
{
|
||||
"path": "src/game/scenes/MatchScene.ts",
|
||||
"lines": [1, 511],
|
||||
"issue": "TESTED - Core lifecycle and event handling covered"
|
||||
},
|
||||
{
|
||||
"path": "src/game/scenes/MatchScene.spec.ts",
|
||||
"lines": [1, 600],
|
||||
"issue": "COMPLETE - 26 tests covering initialization, events, state updates, resize, shutdown"
|
||||
}
|
||||
],
|
||||
"suggestedFix": "1. Create MatchScene.spec.ts\n2. Test scene creation: verify scene key, config\n3. Test preload: asset loading calls\n4. Test create: board creation, event listeners setup\n5. Test shutdown: cleanup, event unsubscription\n6. Test update loop: state synchronization\n7. Mock gameBridge and verify event handling",
|
||||
"estimatedHours": 8,
|
||||
"notes": "Focus on initialization logic and event wiring. Don't test every animation frame - test that state changes trigger expected updates. Mock Board creation since Board tests are separate."
|
||||
},
|
||||
{
|
||||
"id": "TEST-003",
|
||||
"name": "Test Board layout and zone management",
|
||||
"description": "Test Board.ts: zone creation, card placement, layout calculations, coordinate transformations. Verify zones are created correctly for different game modes (prize vs no-prize).",
|
||||
"category": "critical",
|
||||
"priority": 3,
|
||||
"completed": true,
|
||||
"tested": true,
|
||||
"dependencies": ["TEST-001"],
|
||||
"files": [
|
||||
{
|
||||
"path": "src/game/objects/Board.ts",
|
||||
"lines": [1, 611],
|
||||
"issue": "COMPLETE - 611 lines now tested"
|
||||
},
|
||||
{
|
||||
"path": "src/game/objects/Board.spec.ts",
|
||||
"lines": [1, 730],
|
||||
"issue": "COMPLETE - 55 tests created (constructor, setLayout, highlighting, zone queries, coordinate detection, destroy, factory function, integration, edge cases)"
|
||||
},
|
||||
{
|
||||
"path": "src/test/mocks/phaser.ts",
|
||||
"lines": [413, 417],
|
||||
"issue": "ENHANCED - Added lineBetween() method to MockGraphics for center line rendering"
|
||||
}
|
||||
],
|
||||
"suggestedFix": "✅ COMPLETE\n1. ✅ Created Board.spec.ts with 55 tests\n2. ✅ Test constructor and initialization\n3. ✅ Test setLayout() with prize zones (Pokemon TCG) and without (Mantimon TCG)\n4. ✅ Test zone highlighting (single, bulk, clear)\n5. ✅ Test zone position queries\n6. ✅ Test coordinate hit detection (isPointInZone, getZoneAtPoint)\n7. ✅ Test destroy cleanup\n8. ✅ Test createBoard factory\n9. ✅ Integration and edge cases",
|
||||
"estimatedHours": 10,
|
||||
"actualHours": 10,
|
||||
"notes": "COMPLETE - Board is now fully tested. All 55 tests passing. Tests cover both game modes (with/without prizes), zone highlighting, coordinate queries, and lifecycle management."
|
||||
},
|
||||
{
|
||||
"id": "TEST-004",
|
||||
"name": "Test Card rendering and interactions",
|
||||
"description": "Test Card.ts: card display, state updates (tapped, selected, damaged), click handling, drag interactions, animations. This is the most complex game object.",
|
||||
"category": "critical",
|
||||
"priority": 4,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["TEST-001"],
|
||||
"files": [
|
||||
{
|
||||
"path": "src/game/objects/Card.ts",
|
||||
"lines": [1, 832],
|
||||
"issue": "0% coverage - 832 lines untested"
|
||||
},
|
||||
{
|
||||
"path": "src/game/objects/Card.spec.ts",
|
||||
"lines": [],
|
||||
"issue": "File needs to be created"
|
||||
}
|
||||
],
|
||||
"suggestedFix": "1. Create Card.spec.ts\n2. Test card creation: verify sprite, text, damage counter setup\n3. Test state updates: setTapped(), setSelected(), setDamaged()\n4. Test visual updates: tap rotation, selection highlight, damage display\n5. Test click handling: pointer events, interaction callbacks\n6. Test drag: drag start/end, position updates\n7. Test animations: flip, move, fade",
|
||||
"estimatedHours": 12,
|
||||
"notes": "Largest and most complex game object. Break into multiple test suites if needed: Card.rendering.spec.ts, Card.interactions.spec.ts, Card.animations.spec.ts. Mock Phaser graphics/tweens heavily."
|
||||
},
|
||||
{
|
||||
"id": "TEST-005",
|
||||
"name": "Test StateRenderer synchronization logic",
|
||||
"description": "Test StateRenderer.ts: game state diffing, incremental updates, animation sequencing, error recovery. This is the bridge between backend state and Phaser rendering.",
|
||||
"category": "critical",
|
||||
"priority": 5,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["TEST-001", "TEST-002", "TEST-003"],
|
||||
"files": [
|
||||
{
|
||||
"path": "src/game/sync/StateRenderer.ts",
|
||||
"lines": [1, 709],
|
||||
"issue": "0% coverage - 709 lines untested"
|
||||
},
|
||||
{
|
||||
"path": "src/game/sync/StateRenderer.spec.ts",
|
||||
"lines": [],
|
||||
"issue": "File needs to be created"
|
||||
}
|
||||
],
|
||||
"suggestedFix": "1. Create StateRenderer.spec.ts\n2. Test render(): initial state rendering, creates all zones/cards\n3. Test update(): incremental state changes, only updates diffs\n4. Test card movement: card moves from hand to active zone\n5. Test card removal: card removed from board when discarded\n6. Test damage updates: damage counters update without full re-render\n7. Test error handling: gracefully handles malformed state\n8. Test Board creation: defers creation until state available (recent fix)",
|
||||
"estimatedHours": 12,
|
||||
"notes": "This is critical for correctness - bugs here cause desyncs. Test with realistic game state transitions. Mock MatchScene and Board. Verify optimization: don't re-render unchanged cards."
|
||||
},
|
||||
{
|
||||
"id": "TEST-006",
|
||||
"name": "Test Zone base class and zone types",
|
||||
"description": "Test Zone.ts base class and subclasses (HandZone, BenchZone, ActiveZone, PrizeZone, PileZone): card capacity, layout, add/remove cards, sorting, hover effects.",
|
||||
"category": "critical",
|
||||
"priority": 6,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["TEST-001"],
|
||||
"files": [
|
||||
{
|
||||
"path": "src/game/objects/Zone.ts",
|
||||
"lines": [1, 458],
|
||||
"issue": "0% coverage - 458 lines untested"
|
||||
},
|
||||
{
|
||||
"path": "src/game/objects/HandZone.ts",
|
||||
"lines": [1, 321],
|
||||
"issue": "0% coverage - 321 lines untested"
|
||||
},
|
||||
{
|
||||
"path": "src/game/objects/BenchZone.ts",
|
||||
"lines": [1, 287],
|
||||
"issue": "0% coverage - 287 lines untested"
|
||||
},
|
||||
{
|
||||
"path": "src/game/objects/Zone.spec.ts",
|
||||
"lines": [],
|
||||
"issue": "File needs to be created"
|
||||
}
|
||||
],
|
||||
"suggestedFix": "1. Create Zone.spec.ts for base class\n2. Test capacity: maxCards enforcement, isFull()\n3. Test card management: addCard(), removeCard(), clear()\n4. Test layout: cards positioned correctly, spacing\n5. Test hover effects: zone highlights on drag-over\n6. Create subclass specs: HandZone.spec.ts, BenchZone.spec.ts, etc.\n7. Test zone-specific behavior: hand fanning, bench slots",
|
||||
"estimatedHours": 10,
|
||||
"notes": "Zone is the base class for all card containers. Test inheritance - ensure subclasses properly extend base behavior. Mock Card objects to focus on zone logic."
|
||||
},
|
||||
{
|
||||
"id": "TEST-007",
|
||||
"name": "Test WebSocket client connection lifecycle",
|
||||
"description": "Test socket/client.ts: connection establishment, disconnection, reconnection logic, authentication, error handling. Currently only 27% coverage - missing critical paths.",
|
||||
"category": "critical",
|
||||
"priority": 7,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": [],
|
||||
"files": [
|
||||
{
|
||||
"path": "src/socket/client.ts",
|
||||
"lines": [182, 300],
|
||||
"issue": "27% coverage - missing connection lifecycle tests"
|
||||
},
|
||||
{
|
||||
"path": "src/socket/client.spec.ts",
|
||||
"lines": [1, 300],
|
||||
"issue": "Incomplete coverage - add lifecycle tests"
|
||||
}
|
||||
],
|
||||
"suggestedFix": "1. Expand client.spec.ts\n2. Test connect(): successful connection, auth token passing\n3. Test disconnect(): clean disconnection, event cleanup\n4. Test reconnection: auto-reconnect on drop, exponential backoff\n5. Test connection errors: auth failure, network error, timeout\n6. Test event handlers: message routing, error propagation\n7. Test connection state: connected, disconnected, reconnecting states",
|
||||
"estimatedHours": 6,
|
||||
"notes": "WebSocket is critical for multiplayer. Test error scenarios thoroughly - connection drops are common. Mock socket.io-client and use fake timers for reconnection delays."
|
||||
},
|
||||
{
|
||||
"id": "TEST-008",
|
||||
"name": "Test useGameSocket action queueing and state sync",
|
||||
"description": "Test useGameSocket.ts: game action emission, response handling, state synchronization, error recovery, connection status. Currently 45% coverage - missing error paths and edge cases.",
|
||||
"category": "critical",
|
||||
"priority": 8,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["TEST-007"],
|
||||
"files": [
|
||||
{
|
||||
"path": "src/composables/useGameSocket.ts",
|
||||
"lines": [146, 464],
|
||||
"issue": "45% coverage - missing action queueing and error handling"
|
||||
},
|
||||
{
|
||||
"path": "src/composables/useGameSocket.spec.ts",
|
||||
"lines": [1, 500],
|
||||
"issue": "Incomplete coverage - add edge case tests"
|
||||
}
|
||||
],
|
||||
"suggestedFix": "1. Expand useGameSocket.spec.ts\n2. Test sendAction(): action emission, callback registration\n3. Test action response: success callback, error callback\n4. Test action timeout: callback with timeout error\n5. Test state updates: gameState reactive updates on server events\n6. Test connection recovery: queued actions resent after reconnect\n7. Test concurrent actions: multiple actions in flight\n8. Test invalid actions: server validation errors",
|
||||
"estimatedHours": 8,
|
||||
"notes": "This composable is the main game interaction interface. Test race conditions and concurrent actions. Mock socket client. Verify state updates trigger Vue reactivity."
|
||||
},
|
||||
{
|
||||
"id": "TEST-009",
|
||||
"name": "Test useGames game creation and management",
|
||||
"description": "Test useGames.ts: createGame(), fetchActiveGames(), joinGame(), game state management. Currently only 2% coverage - almost completely untested.",
|
||||
"category": "critical",
|
||||
"priority": 9,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": [],
|
||||
"files": [
|
||||
{
|
||||
"path": "src/composables/useGames.ts",
|
||||
"lines": [67, 257],
|
||||
"issue": "2% coverage - core game management untested"
|
||||
},
|
||||
{
|
||||
"path": "src/composables/useGames.spec.ts",
|
||||
"lines": [],
|
||||
"issue": "File needs to be created"
|
||||
}
|
||||
],
|
||||
"suggestedFix": "1. Create useGames.spec.ts\n2. Test createGame(): API call, loading states, success response\n3. Test createGame() error: validation error, server error\n4. Test fetchActiveGames(): retrieve user's active games\n5. Test joinGame(): join existing game, reconnection\n6. Test game list management: add/remove/update games\n7. Test loading states: isLoading, isCreating flags\n8. Test error states: error messages, clearError()",
|
||||
"estimatedHours": 6,
|
||||
"notes": "Critical for game flow. Mock apiClient. Test the full game lifecycle: create → join → play → end. Verify error handling - game creation can fail for many reasons (invalid deck, no opponent, etc.)."
|
||||
},
|
||||
{
|
||||
"id": "TEST-010",
|
||||
"name": "Test PreloadScene asset loading",
|
||||
"description": "Test PreloadScene.ts: asset manifest parsing, loading progress, error handling, transition to MatchScene. Currently 0% coverage.",
|
||||
"category": "high",
|
||||
"priority": 10,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["TEST-001"],
|
||||
"files": [
|
||||
{
|
||||
"path": "src/game/scenes/PreloadScene.ts",
|
||||
"lines": [1, 404],
|
||||
"issue": "0% coverage - 404 lines untested"
|
||||
},
|
||||
{
|
||||
"path": "src/game/scenes/PreloadScene.spec.ts",
|
||||
"lines": [],
|
||||
"issue": "File needs to be created"
|
||||
}
|
||||
],
|
||||
"suggestedFix": "1. Create PreloadScene.spec.ts\n2. Test preload(): loads all assets from manifest\n3. Test loading progress: progress events, progress bar updates\n4. Test loading errors: handle missing assets, network errors\n5. Test scene transition: starts MatchScene after load complete\n6. Test caching: verify assets cached correctly\n7. Mock Phaser.Loader and asset loading",
|
||||
"estimatedHours": 4,
|
||||
"notes": "Lower priority than MatchScene but still important for user experience. Test error handling - network failures during load should be graceful. Mock all asset loading."
|
||||
},
|
||||
{
|
||||
"id": "TEST-011",
|
||||
"name": "Test HandManager drag and drop logic",
|
||||
"description": "Test HandManager.ts: card dragging, drop validation, snap-back on invalid drop, touch events. Currently 39% coverage - missing edge cases.",
|
||||
"category": "high",
|
||||
"priority": 11,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["TEST-004"],
|
||||
"files": [
|
||||
{
|
||||
"path": "src/game/interactions/HandManager.ts",
|
||||
"lines": [1, 572],
|
||||
"issue": "39% coverage - missing drag edge cases"
|
||||
},
|
||||
{
|
||||
"path": "src/game/interactions/HandManager.spec.ts",
|
||||
"lines": [1, 300],
|
||||
"issue": "Incomplete coverage - add edge case tests"
|
||||
}
|
||||
],
|
||||
"suggestedFix": "1. Expand HandManager.spec.ts\n2. Test drag start: card pickup, visual feedback\n3. Test drag move: card follows pointer, over valid zone\n4. Test drag drop: valid drop, invalid drop (snap back)\n5. Test drag cancel: ESC key, right-click\n6. Test touch events: touch drag works on mobile\n7. Test multi-touch: handle multi-touch gracefully\n8. Test zone validation: can't drop on full zone",
|
||||
"estimatedHours": 6,
|
||||
"notes": "Important for UX - drag/drop is primary interaction. Test touch events separately with touch simulation. Verify snap-back animation on invalid drops."
|
||||
},
|
||||
{
|
||||
"id": "TEST-012",
|
||||
"name": "Test damage counter display and updates",
|
||||
"description": "Test DamageCounter.ts: damage display, number formatting, positioning, animations. Currently 0% coverage.",
|
||||
"category": "high",
|
||||
"priority": 12,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["TEST-001"],
|
||||
"files": [
|
||||
{
|
||||
"path": "src/game/objects/DamageCounter.ts",
|
||||
"lines": [1, 202],
|
||||
"issue": "0% coverage - 202 lines untested"
|
||||
},
|
||||
{
|
||||
"path": "src/game/objects/DamageCounter.spec.ts",
|
||||
"lines": [],
|
||||
"issue": "File needs to be created"
|
||||
}
|
||||
],
|
||||
"suggestedFix": "1. Create DamageCounter.spec.ts\n2. Test counter creation: initial damage value, position\n3. Test damage updates: setDamage(), increment, decrement\n4. Test display: zero damage hidden, non-zero visible\n5. Test positioning: counter positioned on card correctly\n6. Test animations: damage change animation, pulse effect\n7. Mock Phaser.GameObjects.Text and Graphics",
|
||||
"estimatedHours": 3,
|
||||
"notes": "Important visual feedback for game state. Test edge cases: 0 damage, very high damage (999+), negative damage (should never happen but handle gracefully)."
|
||||
},
|
||||
{
|
||||
"id": "TEST-013",
|
||||
"name": "Test asset loader and manifest",
|
||||
"description": "Test assets/loader.ts and assets/manifest.ts: asset path resolution, manifest parsing, preload hooks. Currently 0% coverage.",
|
||||
"category": "high",
|
||||
"priority": 13,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["TEST-001"],
|
||||
"files": [
|
||||
{
|
||||
"path": "src/game/assets/loader.ts",
|
||||
"lines": [1, 326],
|
||||
"issue": "0% coverage - 326 lines untested"
|
||||
},
|
||||
{
|
||||
"path": "src/game/assets/manifest.ts",
|
||||
"lines": [1, 190],
|
||||
"issue": "0% coverage - 190 lines untested"
|
||||
},
|
||||
{
|
||||
"path": "src/game/assets/loader.spec.ts",
|
||||
"lines": [],
|
||||
"issue": "File needs to be created"
|
||||
}
|
||||
],
|
||||
"suggestedFix": "1. Create loader.spec.ts and manifest.spec.ts\n2. Test manifest parsing: correct asset paths, keys\n3. Test loader: loads assets in correct order\n4. Test asset types: images, audio, JSON, atlas\n5. Test path resolution: base URL handling\n6. Test error handling: missing manifest, invalid paths\n7. Mock Phaser.Loader methods",
|
||||
"estimatedHours": 4,
|
||||
"notes": "Important for initialization. Test manifest structure validation - invalid manifests should fail fast with helpful errors. Mock all actual file loading."
|
||||
},
|
||||
{
|
||||
"id": "TEST-014",
|
||||
"name": "Test ResizeManager responsive layout",
|
||||
"description": "Test ResizeManager.ts: window resize handling, orientation changes, layout recalculation, mobile vs desktop. Currently 0% coverage.",
|
||||
"category": "high",
|
||||
"priority": 14,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["TEST-001"],
|
||||
"files": [
|
||||
{
|
||||
"path": "src/game/ResizeManager.ts",
|
||||
"lines": [1, 253],
|
||||
"issue": "0% coverage - 253 lines untested"
|
||||
},
|
||||
{
|
||||
"path": "src/game/ResizeManager.spec.ts",
|
||||
"lines": [],
|
||||
"issue": "File needs to be created"
|
||||
}
|
||||
],
|
||||
"suggestedFix": "1. Create ResizeManager.spec.ts\n2. Test resize handling: window resize triggers layout update\n3. Test orientation change: portrait ↔ landscape\n4. Test layout calculation: correct scaling for different sizes\n5. Test debouncing: resize events debounced\n6. Test mobile detection: detects mobile vs desktop\n7. Mock window.innerWidth/Height and ResizeObserver",
|
||||
"estimatedHours": 4,
|
||||
"notes": "Important for mobile support. Test both portrait and landscape orientations. Verify debouncing - don't recalculate layout on every pixel change."
|
||||
},
|
||||
{
|
||||
"id": "TEST-015",
|
||||
"name": "Test CardBack placeholder rendering",
|
||||
"description": "Test CardBack.ts: face-down card display, back texture, positioning. Currently 0% coverage but simple component.",
|
||||
"category": "medium",
|
||||
"priority": 15,
|
||||
"completed": true,
|
||||
"tested": true,
|
||||
"completedDate": "2026-02-02",
|
||||
"actualHours": 2,
|
||||
"testsAdded": 25,
|
||||
"coverageAfter": "~95%",
|
||||
"dependencies": ["TEST-001"],
|
||||
"files": [
|
||||
{
|
||||
"path": "src/game/objects/CardBack.ts",
|
||||
"lines": [1, 238],
|
||||
"issue": "0% coverage - 238 lines untested"
|
||||
},
|
||||
{
|
||||
"path": "src/game/objects/CardBack.spec.ts",
|
||||
"lines": [],
|
||||
"issue": "File needs to be created"
|
||||
}
|
||||
],
|
||||
"suggestedFix": "1. Create CardBack.spec.ts\n2. Test creation: card back sprite, texture key\n3. Test positioning: correct position and scale\n4. Test visibility: shown for face-down cards\n5. Test flip animation: flip to reveal front\n6. Mock Phaser.GameObjects.Sprite",
|
||||
"estimatedHours": 2,
|
||||
"notes": "Relatively simple component. Lower priority than Card.ts but still needs coverage. Test flip animation timing and easing."
|
||||
},
|
||||
{
|
||||
"id": "TEST-016",
|
||||
"name": "Test user store profile management",
|
||||
"description": "Test stores/user.ts: profile updates, linked account management, session handling. Currently 52% coverage - missing edge cases.",
|
||||
"category": "medium",
|
||||
"priority": 16,
|
||||
"completed": true,
|
||||
"tested": true,
|
||||
"completedDate": "2026-02-02",
|
||||
"actualHours": 3,
|
||||
"testsAdded": 20,
|
||||
"coverageAfter": "~90%",
|
||||
"dependencies": [],
|
||||
"files": [
|
||||
{
|
||||
"path": "src/stores/user.ts",
|
||||
"lines": [98, 139],
|
||||
"issue": "52% coverage - missing profile update edge cases"
|
||||
},
|
||||
{
|
||||
"path": "src/stores/user.spec.ts",
|
||||
"lines": [1, 200],
|
||||
"issue": "Incomplete coverage - add edge case tests"
|
||||
}
|
||||
],
|
||||
"suggestedFix": "1. Expand user.spec.ts\n2. Test profile update: updateProfile() success and error\n3. Test validation: invalid display name, invalid avatar URL\n4. Test linked accounts: add/remove linked accounts\n5. Test session management: active sessions, logout all\n6. Test concurrent updates: multiple profile updates in flight\n7. Mock API calls",
|
||||
"estimatedHours": 3,
|
||||
"notes": "Medium priority - basic functionality is tested, need edge cases. Test validation thoroughly - what happens with empty/null/very long display names?"
|
||||
},
|
||||
{
|
||||
"id": "TEST-017",
|
||||
"name": "Test useDragDrop edge cases",
|
||||
"description": "Test composables/useDragDrop.ts: edge cases in drag/drop, touch events, multi-touch handling. Currently 76% coverage - good but can improve.",
|
||||
"category": "medium",
|
||||
"priority": 17,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": [],
|
||||
"files": [
|
||||
{
|
||||
"path": "src/composables/useDragDrop.ts",
|
||||
"lines": [326, 424],
|
||||
"issue": "76% coverage - missing edge cases"
|
||||
},
|
||||
{
|
||||
"path": "src/composables/useDragDrop.spec.ts",
|
||||
"lines": [1, 450],
|
||||
"issue": "Good coverage but add edge cases"
|
||||
}
|
||||
],
|
||||
"suggestedFix": "1. Expand useDragDrop.spec.ts\n2. Test edge case: drag start outside draggable\n3. Test edge case: drop outside any drop zone\n4. Test edge case: multiple simultaneous drags\n5. Test touch: touch drag on mobile devices\n6. Test touch: pinch-to-zoom during drag\n7. Test accessibility: keyboard drag (space/enter)",
|
||||
"estimatedHours": 3,
|
||||
"notes": "Already has good coverage (76%), focus on edge cases. Test keyboard accessibility - drag/drop should work with keyboard for a11y."
|
||||
},
|
||||
{
|
||||
"id": "TEST-018",
|
||||
"name": "Test deck builder page edge cases",
|
||||
"description": "Test DeckBuilderPage.vue and related components: edge cases in deck validation, card limits, energy management. Currently 54-79% coverage.",
|
||||
"category": "medium",
|
||||
"priority": 18,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": [],
|
||||
"files": [
|
||||
{
|
||||
"path": "src/components/deck/DeckActionButtons.vue",
|
||||
"lines": [64, 86],
|
||||
"issue": "54% coverage - missing validation edge cases"
|
||||
},
|
||||
{
|
||||
"path": "src/components/deck/DeckHeader.vue",
|
||||
"lines": [32, 35],
|
||||
"issue": "71% coverage - missing error states"
|
||||
},
|
||||
{
|
||||
"path": "src/components/deck/DeckCardRow.vue",
|
||||
"lines": [50, 186],
|
||||
"issue": "79% coverage - missing interaction edge cases"
|
||||
},
|
||||
{
|
||||
"path": "src/pages/DeckBuilderPage.vue",
|
||||
"lines": [157, 290],
|
||||
"issue": "76% coverage - missing error handling"
|
||||
}
|
||||
],
|
||||
"suggestedFix": "1. Expand deck builder tests\n2. Test validation: deck too small, deck too large\n3. Test validation: too many copies of one card\n4. Test validation: invalid Pokemon (evolves without basic)\n5. Test energy: add/remove energy, energy limits\n6. Test save: save success, save error (network fail)\n7. Test delete: delete confirmation, delete error",
|
||||
"estimatedHours": 4,
|
||||
"notes": "Deck builder has good basic coverage, need edge cases. Test error states thoroughly - what happens when save fails mid-edit? Test undo/redo if implemented."
|
||||
},
|
||||
{
|
||||
"id": "TEST-019",
|
||||
"name": "Test HomePage, CampaignPage, MatchPage",
|
||||
"description": "Test main user-facing pages: navigation, state loading, error states. Currently 0% coverage on these pages.",
|
||||
"category": "medium",
|
||||
"priority": 19,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": [],
|
||||
"files": [
|
||||
{
|
||||
"path": "src/pages/HomePage.vue",
|
||||
"lines": [1, 200],
|
||||
"issue": "0% coverage - page untested"
|
||||
},
|
||||
{
|
||||
"path": "src/pages/CampaignPage.vue",
|
||||
"lines": [1, 300],
|
||||
"issue": "0% coverage - page untested"
|
||||
},
|
||||
{
|
||||
"path": "src/pages/MatchPage.vue",
|
||||
"lines": [1, 250],
|
||||
"issue": "0% coverage - page untested"
|
||||
}
|
||||
],
|
||||
"suggestedFix": "1. Create HomePage.spec.ts, CampaignPage.spec.ts, MatchPage.spec.ts\n2. Test HomePage: renders welcome message, navigation links\n3. Test CampaignPage: loads campaign data, displays clubs\n4. Test CampaignPage: click club navigates to matches\n5. Test MatchPage: loads match data, displays opponent\n6. Test error states: 404 game not found, loading errors\n7. Mock router and API calls",
|
||||
"estimatedHours": 6,
|
||||
"notes": "Page tests are integration tests - test user flows, not implementation details. Focus on happy path and error states. Mock all API calls and router."
|
||||
},
|
||||
{
|
||||
"id": "TEST-020",
|
||||
"name": "Test socket message factory functions",
|
||||
"description": "Test socket/types.ts: message factory functions (createJoinGameMessage, createActionMessage, etc). Lines 308-361 were factory functions, not type guards.",
|
||||
"category": "low",
|
||||
"priority": 20,
|
||||
"completed": true,
|
||||
"tested": true,
|
||||
"completedDate": "2026-02-02",
|
||||
"actualHours": 2,
|
||||
"testsAdded": 20,
|
||||
"coverageAfter": "100%",
|
||||
"dependencies": [],
|
||||
"files": [
|
||||
{
|
||||
"path": "src/socket/types.ts",
|
||||
"lines": [308, 361],
|
||||
"issue": "0% coverage - type guards untested"
|
||||
},
|
||||
{
|
||||
"path": "src/socket/types.spec.ts",
|
||||
"lines": [1, 400],
|
||||
"issue": "Has tests but missing type guards"
|
||||
}
|
||||
],
|
||||
"suggestedFix": "1. Check if lines 308-361 contain runtime type guards\n2. If so, expand types.spec.ts to test them\n3. Test type guards: isGameStateMessage(), isActionResultMessage()\n4. Test invalid input: null, undefined, wrong shape\n5. Test edge cases: missing optional fields, extra fields",
|
||||
"estimatedHours": 2,
|
||||
"notes": "Only test if there are runtime type guards. Type-only definitions don't need tests. Type guards are important for robustness - ensure they catch malformed messages."
|
||||
},
|
||||
{
|
||||
"id": "TEST-021",
|
||||
"name": "Test game animation sequences",
|
||||
"description": "Create integration tests for common game animation sequences: card played, attack, knockout, prize drawn. Test that animations play in correct order and complete.",
|
||||
"category": "medium",
|
||||
"priority": 21,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["TEST-002", "TEST-004", "TEST-005"],
|
||||
"files": [
|
||||
{
|
||||
"path": "src/game/animations/sequences.spec.ts",
|
||||
"lines": [],
|
||||
"issue": "File needs to be created - integration tests for animations"
|
||||
}
|
||||
],
|
||||
"suggestedFix": "1. Create sequences.spec.ts for integration tests\n2. Test card played: hand → active, animation completes\n3. Test attack: attacker animates toward defender, damage applied\n4. Test knockout: defender fades out, moved to discard\n5. Test prize drawn: prize flips, moves to hand\n6. Test game over: victory/defeat animation\n7. Use fake timers to control animation timing",
|
||||
"estimatedHours": 6,
|
||||
"notes": "Integration tests - test animation sequences end-to-end. Use vitest fake timers to advance animation frames. These tests ensure animations complete and don't get stuck."
|
||||
},
|
||||
{
|
||||
"id": "TEST-022",
|
||||
"name": "Set up visual regression testing infrastructure",
|
||||
"description": "Set up Playwright for visual regression testing of Phaser game. Take screenshots of game states and compare to baselines. This catches visual bugs that unit tests miss.",
|
||||
"category": "infrastructure",
|
||||
"priority": 22,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": [],
|
||||
"files": [
|
||||
{
|
||||
"path": "playwright.config.ts",
|
||||
"lines": [],
|
||||
"issue": "File needs to be created - Playwright config"
|
||||
},
|
||||
{
|
||||
"path": "tests/visual/game.spec.ts",
|
||||
"lines": [],
|
||||
"issue": "File needs to be created - visual regression tests"
|
||||
}
|
||||
],
|
||||
"suggestedFix": "1. Install Playwright: npm install -D @playwright/test\n2. Create playwright.config.ts with screenshot config\n3. Create tests/visual/ directory for visual tests\n4. Create baseline: npm run test:visual:update\n5. Test game board: screenshot of initial state\n6. Test game state: screenshot of mid-game state\n7. Test animations: screenshot at key animation frames\n8. Compare: fail if screenshots differ from baseline",
|
||||
"estimatedHours": 8,
|
||||
"notes": "Visual regression testing is essential for Phaser - catches layout bugs, rendering glitches, animation issues that unit tests can't detect. Keep baselines in Git. Run in CI to catch regressions."
|
||||
},
|
||||
{
|
||||
"id": "TEST-023",
|
||||
"name": "Add coverage reporting to CI",
|
||||
"description": "Configure CI to generate coverage reports, post to PRs, fail if coverage drops. Track coverage over time.",
|
||||
"category": "infrastructure",
|
||||
"priority": 23,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": [],
|
||||
"files": [
|
||||
{
|
||||
"path": ".github/workflows/test.yml",
|
||||
"lines": [],
|
||||
"issue": "Needs coverage reporting step"
|
||||
}
|
||||
],
|
||||
"suggestedFix": "1. Add coverage flag to CI test command: npm run test -- --coverage\n2. Upload coverage to Codecov or similar service\n3. Add coverage badge to README\n4. Configure PR comments with coverage diff\n5. Set minimum coverage threshold: fail if <80%\n6. Track coverage trends over time",
|
||||
"estimatedHours": 3,
|
||||
"notes": "Infrastructure improvement - makes coverage visible to team. Set threshold but don't be too strict initially (current 63%, target 80%). Use a service like Codecov, Coveralls, or GitHub Actions built-in."
|
||||
},
|
||||
{
|
||||
"id": "TEST-024",
|
||||
"name": "Document testing patterns and guidelines",
|
||||
"description": "Create comprehensive testing documentation: how to write Phaser tests, common patterns, mocking strategies, debugging tips.",
|
||||
"category": "infrastructure",
|
||||
"priority": 24,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["TEST-001"],
|
||||
"files": [
|
||||
{
|
||||
"path": "src/test/README.md",
|
||||
"lines": [],
|
||||
"issue": "File needs to be created - testing guide"
|
||||
}
|
||||
],
|
||||
"suggestedFix": "1. Create src/test/README.md\n2. Document Phaser testing patterns: how to mock scenes/objects\n3. Document common test utilities: gameTestUtils API\n4. Document integration testing: how to test sequences\n5. Add examples: good test vs bad test\n6. Add debugging tips: how to debug failing tests\n7. Link to Vitest and Phaser testing resources",
|
||||
"estimatedHours": 4,
|
||||
"notes": "Documentation is essential for team - makes it easy for others to write tests. Include real examples from the codebase. Keep updated as patterns evolve."
|
||||
},
|
||||
{
|
||||
"id": "TEST-025",
|
||||
"name": "Add mutation testing with Stryker",
|
||||
"description": "Set up Stryker mutation testing to verify test quality. Mutation testing detects weak tests by injecting bugs and checking if tests catch them.",
|
||||
"category": "infrastructure",
|
||||
"priority": 25,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": [],
|
||||
"files": [
|
||||
{
|
||||
"path": "stryker.conf.json",
|
||||
"lines": [],
|
||||
"issue": "File needs to be created - Stryker config"
|
||||
}
|
||||
],
|
||||
"suggestedFix": "1. Install Stryker: npm install -D @stryker-mutator/core @stryker-mutator/vitest-runner\n2. Create stryker.conf.json\n3. Configure mutators: arithmetic, conditionals, remove statements\n4. Run initial mutation test: npm run test:mutation\n5. Review results: identify weak tests\n6. Improve tests to catch mutations\n7. Add to CI (optional, slow)",
|
||||
"estimatedHours": 6,
|
||||
"notes": "Advanced testing technique - lower priority. Mutation testing is slow but valuable for critical code. Start with small modules (game/layout, game/config) before running on entire codebase."
|
||||
}
|
||||
],
|
||||
"quickWins": [
|
||||
{
|
||||
"taskId": "TEST-015",
|
||||
"estimatedMinutes": 120,
|
||||
"impact": "CardBack is simple and quick to test - gets game/ directory coverage started"
|
||||
},
|
||||
{
|
||||
"taskId": "TEST-020",
|
||||
"estimatedMinutes": 120,
|
||||
"impact": "Type guards are straightforward to test - improves socket reliability"
|
||||
},
|
||||
{
|
||||
"taskId": "TEST-016",
|
||||
"estimatedMinutes": 180,
|
||||
"impact": "User store edge cases - improves coverage from 52% to 90%+"
|
||||
}
|
||||
],
|
||||
"productionBlockers": [
|
||||
{
|
||||
"taskId": "TEST-005",
|
||||
"reason": "StateRenderer synchronization bugs cause game state desyncs between players"
|
||||
},
|
||||
{
|
||||
"taskId": "TEST-007",
|
||||
"reason": "WebSocket connection issues can make multiplayer games unplayable"
|
||||
},
|
||||
{
|
||||
"taskId": "TEST-008",
|
||||
"reason": "Action queueing bugs can cause lost actions or duplicate actions"
|
||||
}
|
||||
],
|
||||
"weeklyRoadmap": {
|
||||
"week1": {
|
||||
"theme": "Phaser Testing Infrastructure & Core Objects",
|
||||
"goals": "Establish Phaser testing foundation, test core game objects",
|
||||
"tasks": ["TEST-001", "TEST-003", "TEST-006", "TEST-015"],
|
||||
"estimatedHours": 28,
|
||||
"deliverables": "Phaser mocks, Board tests, Zone tests, CardBack tests"
|
||||
},
|
||||
"week2": {
|
||||
"theme": "Phaser Scenes & State Synchronization",
|
||||
"goals": "Test scene lifecycle and state rendering",
|
||||
"tasks": ["TEST-002", "TEST-004", "TEST-005"],
|
||||
"estimatedHours": 32,
|
||||
"deliverables": "MatchScene tests, Card tests, StateRenderer tests"
|
||||
},
|
||||
"week3": {
|
||||
"theme": "WebSocket & Network Layer",
|
||||
"goals": "Complete WebSocket testing, ensure reliable multiplayer",
|
||||
"tasks": ["TEST-007", "TEST-008", "TEST-009"],
|
||||
"estimatedHours": 20,
|
||||
"deliverables": "Socket client tests, useGameSocket tests, useGames tests"
|
||||
},
|
||||
"week4": {
|
||||
"theme": "Game Engine Completion",
|
||||
"goals": "Test remaining game engine components",
|
||||
"tasks": ["TEST-010", "TEST-011", "TEST-012", "TEST-013", "TEST-014"],
|
||||
"estimatedHours": 21,
|
||||
"deliverables": "PreloadScene, HandManager, DamageCounter, AssetLoader, ResizeManager tests"
|
||||
},
|
||||
"week5": {
|
||||
"theme": "UI & Integration Testing",
|
||||
"goals": "Test pages and integration scenarios",
|
||||
"tasks": ["TEST-016", "TEST-017", "TEST-018", "TEST-019", "TEST-021"],
|
||||
"estimatedHours": 22,
|
||||
"deliverables": "Page tests, edge case tests, animation sequence tests"
|
||||
},
|
||||
"week6": {
|
||||
"theme": "Infrastructure & Polish",
|
||||
"goals": "Set up visual regression, improve testing infrastructure",
|
||||
"tasks": ["TEST-020", "TEST-022", "TEST-023", "TEST-024"],
|
||||
"estimatedHours": 21,
|
||||
"deliverables": "Playwright setup, CI coverage, testing documentation"
|
||||
}
|
||||
},
|
||||
"milestones": [
|
||||
{
|
||||
"name": "Phaser Testing Enabled",
|
||||
"completionCriteria": "TEST-001 complete, can write Phaser tests easily",
|
||||
"targetDate": "End of Week 1",
|
||||
"blockedBy": []
|
||||
},
|
||||
{
|
||||
"name": "Core Game Engine Tested",
|
||||
"completionCriteria": "MatchScene, Board, Card, StateRenderer all >70% coverage",
|
||||
"targetDate": "End of Week 2",
|
||||
"blockedBy": ["Phaser Testing Enabled"]
|
||||
},
|
||||
{
|
||||
"name": "Network Layer Solid",
|
||||
"completionCriteria": "Socket and WebSocket code >80% coverage, multiplayer reliable",
|
||||
"targetDate": "End of Week 3",
|
||||
"blockedBy": []
|
||||
},
|
||||
{
|
||||
"name": "Game Engine Complete",
|
||||
"completionCriteria": "All game/ directory files >60% coverage",
|
||||
"targetDate": "End of Week 4",
|
||||
"blockedBy": ["Core Game Engine Tested"]
|
||||
},
|
||||
{
|
||||
"name": "Overall 75% Coverage",
|
||||
"completionCriteria": "Project-wide coverage reaches 75%",
|
||||
"targetDate": "End of Week 5",
|
||||
"blockedBy": ["Game Engine Complete", "Network Layer Solid"]
|
||||
},
|
||||
{
|
||||
"name": "Testing Infrastructure Mature",
|
||||
"completionCriteria": "Visual regression working, CI coverage tracking, good documentation",
|
||||
"targetDate": "End of Week 6",
|
||||
"blockedBy": []
|
||||
}
|
||||
],
|
||||
"coverageTargets": {
|
||||
"current": {
|
||||
"overall": "63%",
|
||||
"game": "0%",
|
||||
"composables": "84%",
|
||||
"components": "90%",
|
||||
"stores": "88%",
|
||||
"socket": "27%"
|
||||
},
|
||||
"week2": {
|
||||
"overall": "68%",
|
||||
"game": "40%",
|
||||
"composables": "84%",
|
||||
"components": "90%",
|
||||
"stores": "88%",
|
||||
"socket": "27%"
|
||||
},
|
||||
"week4": {
|
||||
"overall": "73%",
|
||||
"game": "60%",
|
||||
"composables": "86%",
|
||||
"components": "90%",
|
||||
"stores": "90%",
|
||||
"socket": "80%"
|
||||
},
|
||||
"week6": {
|
||||
"overall": "78%",
|
||||
"game": "65%",
|
||||
"composables": "90%",
|
||||
"components": "92%",
|
||||
"stores": "92%",
|
||||
"socket": "85%"
|
||||
},
|
||||
"target_3months": {
|
||||
"overall": "85%",
|
||||
"game": "80%",
|
||||
"composables": "95%",
|
||||
"components": "95%",
|
||||
"stores": "95%",
|
||||
"socket": "90%"
|
||||
}
|
||||
},
|
||||
"notes": {
|
||||
"phaserTesting": "Phaser testing is the biggest challenge. The infrastructure task (TEST-001) is critical - invest time here to make all subsequent tests easier. Consider extracting game logic from Phaser where possible (pure functions easier to test).",
|
||||
"visualRegression": "Visual regression testing (TEST-022) is optional but highly recommended for Phaser. Screenshots catch layout bugs, rendering glitches, and animation issues that unit tests miss.",
|
||||
"prioritization": "Focus on critical tasks first (TEST-001 through TEST-009). These are production-critical: game engine and network layer. Medium/low priority tasks can wait.",
|
||||
"incrementalApproach": "Don't try to achieve 100% coverage immediately. 85% is a realistic target. Some code is hard to test (animation frames, WebGL shaders) - focus on logic and state management.",
|
||||
"teamVelocity": "Estimates assume one developer. With 2-3 developers, can complete in 3-4 weeks instead of 6. Assign game engine to one person, network layer to another."
|
||||
}
|
||||
}
|
||||
620
frontend/TEST_COVERAGE_PLAN.md
Normal file
620
frontend/TEST_COVERAGE_PLAN.md
Normal file
@ -0,0 +1,620 @@
|
||||
# Test Coverage Improvement Plan
|
||||
|
||||
**Status:** In Progress
|
||||
**Created:** 2026-02-02
|
||||
**Started:** 2026-02-02
|
||||
**Target Completion:** 6 weeks
|
||||
**Current Coverage:** 63% → **~65%** (after quick wins 1-2)
|
||||
**Target Coverage:** 85%
|
||||
|
||||
See [`PROJECT_PLAN_TEST_COVERAGE.json`](./PROJECT_PLAN_TEST_COVERAGE.json) for full structured plan.
|
||||
|
||||
---
|
||||
|
||||
## 📝 Lessons Learned (Session 2026-02-02)
|
||||
|
||||
### Quick Wins Completed
|
||||
- ✅ **TEST-015:** CardBack tests (25 tests, ~2h)
|
||||
- ✅ **TEST-020:** Socket message factories (20 tests, ~2h)
|
||||
- **Total:** 45 new tests, 1000 → 1045 tests (+4.5%)
|
||||
|
||||
### Key Learnings for Future Testing
|
||||
|
||||
#### 1. **Phaser Mocking Pattern (from TEST-015)**
|
||||
|
||||
**Problem:** Phaser classes can't be imported directly in tests (WebGL/Canvas dependencies)
|
||||
|
||||
**Solution:** Mock Phaser classes inline within `vi.mock()` factory function
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Define mock classes inside vi.mock factory
|
||||
vi.mock('phaser', () => {
|
||||
class MockContainerFactory {
|
||||
x: number
|
||||
y: number
|
||||
// ... mock implementation
|
||||
}
|
||||
|
||||
return {
|
||||
default: {
|
||||
GameObjects: {
|
||||
Container: MockContainerFactory,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// ❌ WRONG - Referencing external classes causes hoisting issues
|
||||
class MockContainer { /* ... */ }
|
||||
vi.mock('phaser', () => ({
|
||||
default: {
|
||||
GameObjects: {
|
||||
Container: MockContainer, // ReferenceError: Cannot access before initialization
|
||||
},
|
||||
},
|
||||
}))
|
||||
```
|
||||
|
||||
**Why:** `vi.mock()` is hoisted to the top of the file, so it runs before any class definitions. Mock classes must be defined within the factory function.
|
||||
|
||||
#### 2. **Disabling ESLint for Test Mocks**
|
||||
|
||||
When mocking complex Phaser objects, `any` types are acceptable in tests:
|
||||
|
||||
```typescript
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// Disabling explicit-any for test mocks - Phaser types are complex and mocking requires any
|
||||
```
|
||||
|
||||
**Rationale:** Mocking Phaser's deep type hierarchies is impractical. Focus on testing behavior, not perfect type accuracy in mocks.
|
||||
|
||||
#### 3. **Test Docstrings Are Essential**
|
||||
|
||||
Every test must include a docstring explaining "what" and "why":
|
||||
|
||||
```typescript
|
||||
it('creates a card back with sprite when texture exists', () => {
|
||||
/**
|
||||
* Test that CardBack uses sprite when texture is available.
|
||||
*
|
||||
* When the card back texture is loaded, CardBack should create
|
||||
* a sprite instead of fallback graphics.
|
||||
*/
|
||||
// test implementation
|
||||
})
|
||||
```
|
||||
|
||||
**Why:** These docstrings make tests self-documenting and help future developers understand intent.
|
||||
|
||||
#### 4. **Test Actual Dimensions, Not Assumptions**
|
||||
|
||||
**Mistake Made:** Initially used wrong card dimensions (assumed 120x167 for large, actual was 150x210)
|
||||
|
||||
**Lesson:** Always check actual constants/values in codebase before writing assertions. Don't assume dimensions or magic numbers.
|
||||
|
||||
```typescript
|
||||
// ✅ Verified actual CARD_SIZES from types/phaser.ts
|
||||
expect(dimensions.width).toBe(150) // large: 150x210
|
||||
expect(dimensions.height).toBe(210)
|
||||
|
||||
// ❌ Assumed without checking
|
||||
expect(dimensions.width).toBe(120) // Wrong!
|
||||
```
|
||||
|
||||
#### 5. **Integration Tests Validate Full Lifecycle**
|
||||
|
||||
Include at least one integration test that exercises the full object lifecycle:
|
||||
|
||||
```typescript
|
||||
it('can create, resize, and destroy a card back', () => {
|
||||
/**
|
||||
* Test full lifecycle of a CardBack.
|
||||
*
|
||||
* This integration test verifies that a CardBack can be
|
||||
* created, resized multiple times, and destroyed without errors.
|
||||
*/
|
||||
const cardBack = new CardBack(mockScene, 100, 200, 'small')
|
||||
cardBack.setCardSize('medium')
|
||||
cardBack.setCardSize('large')
|
||||
expect(() => cardBack.destroy()).not.toThrow()
|
||||
})
|
||||
```
|
||||
|
||||
**Why:** Unit tests verify individual methods, integration tests verify they work together correctly.
|
||||
|
||||
#### 6. **Factory Function Testing Strategy**
|
||||
|
||||
When testing message/object factories (TEST-020):
|
||||
|
||||
1. **Test structure first** - Verify all required fields present
|
||||
2. **Test uniqueness** - IDs/tokens should be unique on each call
|
||||
3. **Test variations** - Test with/without optional parameters
|
||||
4. **Test integration** - Verify factories work together (same game, different messages)
|
||||
|
||||
```typescript
|
||||
// Structure
|
||||
expect(message.type).toBe('join_game')
|
||||
expect(message.message_id).toBeTruthy()
|
||||
|
||||
// Uniqueness
|
||||
const msg1 = createJoinGameMessage('game-1')
|
||||
const msg2 = createJoinGameMessage('game-1')
|
||||
expect(msg1.message_id).not.toBe(msg2.message_id)
|
||||
|
||||
// Variations
|
||||
const withLastEvent = createJoinGameMessage('game-1', 'event-123')
|
||||
expect(withLastEvent.last_event_id).toBe('event-123')
|
||||
|
||||
const withoutLastEvent = createJoinGameMessage('game-1')
|
||||
expect(withoutLastEvent.last_event_id).toBeUndefined()
|
||||
```
|
||||
|
||||
#### 7. **Avoid Testing Browser Internals**
|
||||
|
||||
**Mistake Made:** Attempted to mock `global.crypto` which is read-only
|
||||
|
||||
**Lesson:** Don't test browser APIs directly. Test your wrapper functions instead. If a function uses `crypto.randomUUID()`, test that it returns valid UUIDs, not that it calls the browser API.
|
||||
|
||||
```typescript
|
||||
// ✅ Test the wrapper's behavior
|
||||
it('generates a unique message ID', () => {
|
||||
const id = generateMessageId()
|
||||
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)
|
||||
})
|
||||
|
||||
// ❌ Don't test browser internals
|
||||
it('uses crypto.randomUUID when available', () => {
|
||||
global.crypto = { randomUUID: vi.fn() } // TypeError: Cannot set property
|
||||
})
|
||||
```
|
||||
|
||||
#### 8. **Pre-commit Hooks Catch Everything**
|
||||
|
||||
Since fixing pre-existing lint errors and enabling pre-commit hooks (PR #2):
|
||||
- All TypeScript errors caught immediately
|
||||
- All lint errors caught immediately
|
||||
- All test failures caught immediately
|
||||
- **No bypassing with --no-verify**
|
||||
|
||||
**Impact:** Zero bugs made it into commits during this session. Pre-commit hooks are working perfectly.
|
||||
|
||||
#### 9. **Quick Wins Build Momentum**
|
||||
|
||||
Starting with simple, high-value tests (CardBack, socket factories) built confidence and validated patterns:
|
||||
|
||||
- CardBack: Simple game object, validated Phaser mocking
|
||||
- Socket factories: Straightforward logic, 100% coverage quickly
|
||||
- Both: Provided immediate value (45 tests) in 4 hours
|
||||
|
||||
**Strategy:** When starting a new test area, pick the simplest component first to validate your approach.
|
||||
|
||||
#### 10. **Coverage Numbers Update Automatically**
|
||||
|
||||
After adding tests, re-run coverage to see actual improvement:
|
||||
|
||||
```bash
|
||||
npm run test -- --coverage
|
||||
```
|
||||
|
||||
**Observed Gains:**
|
||||
- CardBack: 0% → ~95% coverage (238 lines)
|
||||
- Socket factories: 0% → 100% coverage (54 lines)
|
||||
- Overall: 63% → ~65% (+2%)
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The Mantimon TCG frontend has **excellent test discipline** (1000 passing tests) but has **critical gaps** in the game engine layer:
|
||||
|
||||
- ✅ **Strong:** Composables (84%), Components (90%), Stores (88%)
|
||||
- ❌ **Critical Gap:** Game Engine (0% coverage, ~5,500 lines)
|
||||
- ⚠️ **Needs Work:** WebSocket (27-45% coverage)
|
||||
|
||||
**Biggest Risk:** Phaser game engine is completely untested - rendering bugs, state sync issues, and visual glitches won't be caught until manual testing or production.
|
||||
|
||||
---
|
||||
|
||||
## Priority Overview
|
||||
|
||||
### 🔴 Critical (Week 1-3)
|
||||
|
||||
| Task | Component | Impact |
|
||||
|------|-----------|--------|
|
||||
| TEST-001 | Phaser testing infrastructure | **Prerequisite for all game engine tests** |
|
||||
| TEST-002 | MatchScene lifecycle | Core game scene rendering |
|
||||
| TEST-003 | Board layout & zones | Prize zone bug, zone management |
|
||||
| TEST-004 | Card rendering | Card display and interactions |
|
||||
| TEST-005 | StateRenderer sync | Backend ↔ Phaser synchronization |
|
||||
| TEST-007 | Socket connection | WebSocket reliability |
|
||||
| TEST-008 | Game action handling | Action queueing and responses |
|
||||
| TEST-009 | Game management | Create/join game workflows |
|
||||
|
||||
**Total:** ~80 hours, 8 tasks
|
||||
|
||||
### 🟡 High (Week 4)
|
||||
|
||||
| Task | Component | Impact |
|
||||
|------|-----------|--------|
|
||||
| TEST-006 | Zone classes | Zone types and card management |
|
||||
| TEST-010 | PreloadScene | Asset loading |
|
||||
| TEST-011 | HandManager | Drag/drop edge cases |
|
||||
| TEST-012 | DamageCounter | Damage display |
|
||||
| TEST-013 | Asset loader | Asset management |
|
||||
| TEST-014 | ResizeManager | Responsive layout |
|
||||
|
||||
**Total:** ~31 hours, 6 tasks
|
||||
|
||||
### 🟢 Medium/Low (Week 5-6)
|
||||
|
||||
- Page tests (HomePage, CampaignPage, MatchPage)
|
||||
- Edge case tests (user store, drag/drop, deck builder)
|
||||
- Infrastructure (visual regression, CI coverage, documentation)
|
||||
|
||||
**Total:** ~45 hours, remaining tasks
|
||||
|
||||
---
|
||||
|
||||
## Week-by-Week Roadmap
|
||||
|
||||
### Week 1: Foundation (28 hours)
|
||||
**Theme:** Phaser Testing Infrastructure & Core Objects
|
||||
|
||||
**Tasks:**
|
||||
- [x] TEST-001: Create Phaser mocks and test utilities *(8h)* ✅ **COMPLETE** (55 tests: 33 mock tests + 22 utility tests)
|
||||
- [x] TEST-003: Test Board layout and zones *(10h)* ✅ **COMPLETE** (55 tests: constructor, setLayout, highlighting, zone queries, coordinate detection, destroy, factory, integration, edge cases)
|
||||
- [ ] TEST-006: Test Zone base class and subclasses *(10h)*
|
||||
|
||||
**Deliverables:**
|
||||
- ✅ Phaser testing infrastructure ready - MockScene, MockGame, MockContainer, MockSprite, MockText, MockGraphics
|
||||
- ✅ Test utilities created - createMockGameState, createMockCard, setupMockScene, createGameScenario
|
||||
- ✅ Documentation complete - src/test/README.md with usage examples
|
||||
- ✅ Board class tested - Zone rendering, highlighting, coordinate queries for both game modes (with/without prizes)
|
||||
- Zone classes tested
|
||||
- Game engine coverage: 0% → 20%
|
||||
|
||||
**Blockers:** None - can start immediately
|
||||
|
||||
---
|
||||
|
||||
### Week 2: Core Engine (32 hours)
|
||||
**Theme:** Scenes & State Synchronization
|
||||
|
||||
**Tasks:**
|
||||
- [x] TEST-002: Test MatchScene initialization *(8h)* ✅ **COMPLETE** (26 tests: init, create, update, shutdown, events, state updates, resize)
|
||||
- [ ] TEST-004: Test Card rendering and interactions *(12h)*
|
||||
- [ ] TEST-005: Test StateRenderer synchronization *(12h)*
|
||||
|
||||
**Deliverables:**
|
||||
- ✅ MatchScene lifecycle tested - All scene lifecycle methods covered
|
||||
- ✅ Event handling tested - Bridge communication and cleanup verified
|
||||
- Card display and interactions tested
|
||||
- State sync tested (prize zone fix validated!)
|
||||
- Game engine coverage: 20% → 50%
|
||||
|
||||
**Blockers:** Requires TEST-001 (Week 1)
|
||||
|
||||
---
|
||||
|
||||
### Week 3: Network Layer (20 hours)
|
||||
**Theme:** WebSocket & Multiplayer Reliability
|
||||
|
||||
**Tasks:**
|
||||
- [ ] TEST-007: Test socket connection lifecycle *(6h)*
|
||||
- [ ] TEST-008: Test game action handling *(8h)*
|
||||
- [ ] TEST-009: Test game creation/joining *(6h)*
|
||||
|
||||
**Deliverables:**
|
||||
- WebSocket reliability tests
|
||||
- Action queueing tested
|
||||
- Game management tested
|
||||
- Socket coverage: 27% → 80%
|
||||
|
||||
**Blockers:** None - independent of game engine
|
||||
|
||||
---
|
||||
|
||||
### Week 4: Engine Completion (21 hours)
|
||||
**Theme:** Remaining Game Components
|
||||
|
||||
**Tasks:**
|
||||
- [ ] TEST-010: Test PreloadScene *(4h)*
|
||||
- [ ] TEST-011: Test HandManager drag/drop *(6h)*
|
||||
- [ ] TEST-012: Test DamageCounter *(3h)*
|
||||
- [ ] TEST-013: Test asset loader *(4h)*
|
||||
- [ ] TEST-014: Test ResizeManager *(4h)*
|
||||
|
||||
**Deliverables:**
|
||||
- All major game components tested
|
||||
- Game engine coverage: 50% → 65%
|
||||
- **Overall coverage: 63% → 73%**
|
||||
|
||||
---
|
||||
|
||||
### Week 5: Integration & UI (22 hours)
|
||||
**Theme:** Pages and Integration Testing
|
||||
|
||||
**Tasks:**
|
||||
- [x] TEST-016: User store edge cases *(3h)* ✅ **COMPLETE** (20 tests)
|
||||
- [x] TEST-017: Drag/drop edge cases *(3h)* ✅ **COMPLETE** (17 edge case tests added)
|
||||
- [x] TEST-018: Deck builder edge cases *(4h)* ✅ **COMPLETE** (75 tests: DeckActionButtons, DeckHeader, DeckCardRow)
|
||||
- [x] TEST-019: Page tests (Home, Campaign, Match) *(6h)* ✅ **COMPLETE** (44 tests: HomePage, CampaignPage, MatchPage)
|
||||
- [ ] TEST-021: Animation sequences *(6h)*
|
||||
|
||||
**Deliverables:**
|
||||
- All pages tested
|
||||
- Integration scenarios covered
|
||||
- **Overall coverage: 73% → 78%**
|
||||
|
||||
---
|
||||
|
||||
### Week 6: Infrastructure (21 hours)
|
||||
**Theme:** Testing Tooling & Documentation
|
||||
|
||||
**Tasks:**
|
||||
- [ ] TEST-022: Visual regression with Playwright *(8h)*
|
||||
- [ ] TEST-023: CI coverage reporting *(3h)*
|
||||
- [ ] TEST-024: Testing documentation *(4h)*
|
||||
- [ ] TEST-020: Socket type guards *(2h)*
|
||||
- [ ] TEST-015: CardBack tests *(2h)* *(Quick win!)*
|
||||
- [ ] TEST-025: Mutation testing (optional) *(6h)*
|
||||
|
||||
**Deliverables:**
|
||||
- Visual regression testing enabled
|
||||
- CI coverage tracking
|
||||
- Testing guide for team
|
||||
- **Overall coverage: 78% → 82%+**
|
||||
|
||||
---
|
||||
|
||||
## Quick Wins (Do First!)
|
||||
|
||||
These tasks are simple and provide immediate value:
|
||||
|
||||
1. **TEST-015: CardBack** *(2h)* - Simple component, easy test, gets game/ coverage started
|
||||
2. **TEST-020: Type guards** *(2h)* - Straightforward logic tests, improves socket reliability
|
||||
3. **TEST-016: User store** *(3h)* - Boosts coverage from 52% to 90%+
|
||||
|
||||
**Total:** 7 hours for measurable coverage improvement
|
||||
|
||||
---
|
||||
|
||||
## Production Blockers
|
||||
|
||||
These must be tested before production release:
|
||||
|
||||
1. **TEST-005: StateRenderer** - State desync bugs cause multiplayer issues
|
||||
2. **TEST-007: Socket client** - Connection problems make game unplayable
|
||||
3. **TEST-008: useGameSocket** - Lost/duplicate actions break game flow
|
||||
|
||||
---
|
||||
|
||||
## Testing Challenges & Solutions
|
||||
|
||||
### Challenge 1: Phaser Requires WebGL/Canvas
|
||||
|
||||
**Problem:** Phaser games need WebGL/Canvas, jsdom doesn't support it
|
||||
|
||||
**Solutions:**
|
||||
1. **Mock Phaser classes** - Create minimal mocks (TEST-001)
|
||||
2. **Extract logic** - Pull game logic out of Phaser into pure functions
|
||||
3. **Visual regression** - Use Playwright for rendering tests (TEST-022)
|
||||
4. **Integration tests** - Test via PhaserGame.vue wrapper
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// ❌ Hard to test - logic inside Phaser class
|
||||
class Card extends Phaser.GameObjects.Sprite {
|
||||
update() {
|
||||
if (this.health <= 0) {
|
||||
this.destroy()
|
||||
this.scene.addScore(10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Easy to test - logic extracted
|
||||
function shouldDestroyCard(health: number): boolean {
|
||||
return health <= 0
|
||||
}
|
||||
|
||||
class Card extends Phaser.GameObjects.Sprite {
|
||||
update() {
|
||||
if (shouldDestroyCard(this.health)) {
|
||||
this.destroy()
|
||||
this.scene.addScore(10)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Challenge 2: WebSocket State Management
|
||||
|
||||
**Problem:** Real-time behavior, connection state, race conditions
|
||||
|
||||
**Solutions:**
|
||||
1. **Mock socket.io-client** - Already doing this well
|
||||
2. **Test state machines** - Focus on state transitions
|
||||
3. **Fake timers** - Control async timing
|
||||
4. **Error injection** - Simulate disconnect/reconnect
|
||||
|
||||
### Challenge 3: Animation Testing
|
||||
|
||||
**Problem:** Animations are async and timing-dependent
|
||||
|
||||
**Solutions:**
|
||||
1. **Fake timers** - Advance time manually with `vi.useFakeTimers()`
|
||||
2. **Test completion** - Verify animation completes, not individual frames
|
||||
3. **Integration tests** - Test animation sequences end-to-end (TEST-021)
|
||||
|
||||
---
|
||||
|
||||
## Coverage Targets
|
||||
|
||||
| Area | Current | Week 2 | Week 4 | Week 6 | 3 Months |
|
||||
|------|---------|--------|--------|--------|----------|
|
||||
| **Overall** | 63% | 68% | 73% | 78% | **85%** |
|
||||
| **Game Engine** | 0% | 40% | 60% | 65% | **80%** |
|
||||
| **Composables** | 84% | 84% | 86% | 90% | **95%** |
|
||||
| **Components** | 90% | 90% | 90% | 92% | **95%** |
|
||||
| **Stores** | 88% | 88% | 90% | 92% | **95%** |
|
||||
| **Socket** | 27% | 27% | 80% | 85% | **90%** |
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Option 1: Quick Wins First (Recommended)
|
||||
|
||||
Build momentum with easy tasks:
|
||||
|
||||
```bash
|
||||
# Week 1 - Quick Wins (7 hours)
|
||||
npm run test -- src/game/objects/CardBack.spec.ts # Create this
|
||||
npm run test -- src/socket/types.spec.ts # Expand this
|
||||
npm run test -- src/stores/user.spec.ts # Expand this
|
||||
|
||||
# Result: +5% coverage, confidence boost
|
||||
```
|
||||
|
||||
### Option 2: Critical Path (Production-Focused)
|
||||
|
||||
Tackle blockers immediately:
|
||||
|
||||
```bash
|
||||
# Week 1-3 - Critical Path (80 hours)
|
||||
# TEST-001: Phaser infrastructure (8h)
|
||||
# TEST-002: MatchScene (8h)
|
||||
# TEST-003: Board (10h)
|
||||
# TEST-004: Card (12h)
|
||||
# TEST-005: StateRenderer (12h)
|
||||
# TEST-007: Socket client (6h)
|
||||
# TEST-008: useGameSocket (8h)
|
||||
# TEST-009: useGames (6h)
|
||||
|
||||
# Result: Core game and network tested, ready for production
|
||||
```
|
||||
|
||||
### Option 3: Parallel Development (Team of 2-3)
|
||||
|
||||
Split work across developers:
|
||||
|
||||
```bash
|
||||
# Developer 1: Game Engine
|
||||
- Week 1: TEST-001, TEST-003, TEST-006
|
||||
- Week 2: TEST-002, TEST-004, TEST-005
|
||||
|
||||
# Developer 2: Network Layer
|
||||
- Week 1: TEST-007, TEST-008
|
||||
- Week 2: TEST-009, TEST-010, TEST-011
|
||||
|
||||
# Developer 3: UI & Infrastructure
|
||||
- Week 1: Quick wins (TEST-015, TEST-016, TEST-020)
|
||||
- Week 2: TEST-019, TEST-021
|
||||
- Week 3: TEST-022, TEST-023, TEST-024
|
||||
|
||||
# Result: Complete in 3 weeks instead of 6
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### Week 2 Checkpoint
|
||||
- [x] Phaser infrastructure works well ✅
|
||||
- [x] Board tests validate prize zone fix ✅
|
||||
- [ ] StateRenderer tests catch desync bugs
|
||||
- [ ] Coverage: 63% → 68%
|
||||
|
||||
### Week 4 Checkpoint
|
||||
- [ ] All major game components tested
|
||||
- [ ] WebSocket layer reliable
|
||||
- [ ] Coverage: 68% → 73%
|
||||
- [ ] Team confident in test suite
|
||||
|
||||
### Week 6 Completion
|
||||
- [ ] Visual regression enabled
|
||||
- [ ] CI coverage tracking
|
||||
- [ ] Testing documentation complete
|
||||
- [ ] Coverage: 73% → 78%
|
||||
- [ ] **Ready for production**
|
||||
|
||||
### 3 Month Goal
|
||||
- [ ] Coverage: 85%+
|
||||
- [ ] All critical code paths tested
|
||||
- [ ] Mutation testing shows strong tests
|
||||
- [ ] New features include tests by default
|
||||
|
||||
---
|
||||
|
||||
## Team Resources
|
||||
|
||||
### Documentation
|
||||
- [ ] TEST-024: Create `src/test/README.md` with testing patterns
|
||||
- [ ] Add Phaser testing examples
|
||||
- [ ] Document common pitfalls and solutions
|
||||
|
||||
### Infrastructure
|
||||
- [ ] TEST-022: Playwright visual regression
|
||||
- [ ] TEST-023: CI coverage reporting
|
||||
- [ ] TEST-025: Stryker mutation testing (optional)
|
||||
|
||||
### Training
|
||||
- Share knowledge as infrastructure is built
|
||||
- Code review testing PRs thoroughly
|
||||
- Pair program on first few Phaser tests
|
||||
|
||||
---
|
||||
|
||||
## Questions & Discussion
|
||||
|
||||
### Should we aim for 100% coverage?
|
||||
|
||||
**No.** 85% is realistic and valuable. Some code is hard to test:
|
||||
- Animation frames and tweens
|
||||
- WebGL rendering details
|
||||
- Entry points (main.ts, router config)
|
||||
|
||||
Focus on **logic and state management**, not rendering details.
|
||||
|
||||
### Should we stop feature work to fix coverage?
|
||||
|
||||
**Depends on priorities.** Options:
|
||||
|
||||
1. **Aggressive:** Pause features for 2-3 weeks, hit 80% coverage
|
||||
2. **Balanced:** Dedicate 1-2 days/week to testing, reach 80% in 8-10 weeks
|
||||
3. **Maintenance:** Add tests alongside features, reach 80% in 3-6 months
|
||||
|
||||
**Recommendation:** Balanced approach - TEST-001 through TEST-009 are critical for production confidence, do those first.
|
||||
|
||||
### What if tests are too slow?
|
||||
|
||||
**Strategies:**
|
||||
- Use `vi.mock()` liberally - mock Phaser, socket, API
|
||||
- Use `vi.useFakeTimers()` - control async timing
|
||||
- Run tests in parallel: `vitest --threads`
|
||||
- Use `it.skip()` for expensive tests during development
|
||||
- Run full suite in CI only
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Review this plan** - Discuss with team, adjust priorities
|
||||
2. **Start with TEST-001** - Build Phaser testing infrastructure
|
||||
3. **Quick win: TEST-015** - Test CardBack to validate infrastructure
|
||||
4. **Tackle critical path** - TEST-002 through TEST-009
|
||||
5. **Track progress** - Update `PROJECT_PLAN_TEST_COVERAGE.json` as tasks complete
|
||||
|
||||
---
|
||||
|
||||
## Related Documents
|
||||
|
||||
- [`PROJECT_PLAN_TEST_COVERAGE.json`](./PROJECT_PLAN_TEST_COVERAGE.json) - Full structured plan
|
||||
- [`TESTING.md`](../TESTING.md) - Current testing guide (recently added!)
|
||||
- [`CONTRIBUTING.md`](../CONTRIBUTING.md) - Never use --no-verify policy
|
||||
- [`VISUAL-TEST-GUIDE.md`](../VISUAL-TEST-GUIDE.md) - Visual testing reference
|
||||
|
||||
---
|
||||
|
||||
**Let's build confidence in our game engine! 🎮✅**
|
||||
228
frontend/package-lock.json
generated
228
frontend/package-lock.json
generated
@ -22,6 +22,7 @@
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
@ -57,6 +58,20 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@ampproject/remapping": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
||||
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz",
|
||||
@ -158,6 +173,16 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bcoe/v8-coverage": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
|
||||
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
||||
@ -977,6 +1002,16 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@istanbuljs/schema": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
|
||||
"integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@ -1980,6 +2015,40 @@
|
||||
"vue": "^3.2.25"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz",
|
||||
"integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.3.0",
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"ast-v8-to-istanbul": "^0.3.3",
|
||||
"debug": "^4.4.1",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
"istanbul-lib-source-maps": "^5.0.6",
|
||||
"istanbul-reports": "^3.1.7",
|
||||
"magic-string": "^0.30.17",
|
||||
"magicast": "^0.3.5",
|
||||
"std-env": "^3.9.0",
|
||||
"test-exclude": "^7.0.1",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "3.2.4",
|
||||
"vitest": "3.2.4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
||||
@ -2464,6 +2533,35 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-v8-to-istanbul": {
|
||||
"version": "0.3.11",
|
||||
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.11.tgz",
|
||||
"integrity": "sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.31",
|
||||
"estree-walker": "^3.0.3",
|
||||
"js-tokens": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-v8-to-istanbul/node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
||||
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.23",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
|
||||
@ -3528,6 +3626,13 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-escaper": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
@ -3659,6 +3764,60 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/istanbul-lib-coverage": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-report": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
|
||||
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"istanbul-lib-coverage": "^3.0.0",
|
||||
"make-dir": "^4.0.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-source-maps": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
|
||||
"integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.23",
|
||||
"debug": "^4.1.1",
|
||||
"istanbul-lib-coverage": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-reports": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
|
||||
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"html-escaper": "^2.0.0",
|
||||
"istanbul-lib-report": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/jackspeak": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||
@ -4126,6 +4285,34 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/magicast": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz",
|
||||
"integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.25.4",
|
||||
"@babel/types": "^7.25.4",
|
||||
"source-map-js": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.12.2",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz",
|
||||
@ -4930,6 +5117,47 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz",
|
||||
"integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@istanbuljs/schema": "^0.1.2",
|
||||
"glob": "^10.4.1",
|
||||
"minimatch": "^9.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
|
||||
@ -27,6 +27,7 @@
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
|
||||
434
frontend/src/components/deck/DeckActionButtons.spec.ts
Normal file
434
frontend/src/components/deck/DeckActionButtons.spec.ts
Normal file
@ -0,0 +1,434 @@
|
||||
/**
|
||||
* Tests for DeckActionButtons component.
|
||||
*
|
||||
* These tests verify the save/cancel buttons work correctly with
|
||||
* proper disabled states and loading indicators.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
import DeckActionButtons from './DeckActionButtons.vue'
|
||||
|
||||
describe('DeckActionButtons', () => {
|
||||
describe('initial state', () => {
|
||||
it('renders save and cancel buttons', () => {
|
||||
/**
|
||||
* Test that both buttons are rendered.
|
||||
*
|
||||
* Users need both save and cancel options when building decks.
|
||||
*/
|
||||
const wrapper = mount(DeckActionButtons, {
|
||||
props: {
|
||||
canSave: true,
|
||||
isSaving: false,
|
||||
isDirty: true,
|
||||
deckName: 'Test Deck',
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Save')
|
||||
expect(wrapper.text()).toContain('Cancel')
|
||||
})
|
||||
})
|
||||
|
||||
describe('save button states', () => {
|
||||
it('enables save button when deck has name and is dirty', () => {
|
||||
/**
|
||||
* Test save button is enabled when valid.
|
||||
*
|
||||
* Users should be able to save when the deck has a name
|
||||
* and has unsaved changes.
|
||||
*/
|
||||
const wrapper = mount(DeckActionButtons, {
|
||||
props: {
|
||||
canSave: true,
|
||||
isSaving: false,
|
||||
isDirty: true,
|
||||
deckName: 'Test Deck',
|
||||
},
|
||||
})
|
||||
|
||||
const saveButton = wrapper.findAll('button').find(b => b.text().includes('Save'))
|
||||
expect(saveButton?.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('disables save button when deck name is empty', () => {
|
||||
/**
|
||||
* Test validation edge case: empty deck name.
|
||||
*
|
||||
* Users cannot save a deck without a name - this prevents
|
||||
* creating unnamed decks that are hard to identify later.
|
||||
*/
|
||||
const wrapper = mount(DeckActionButtons, {
|
||||
props: {
|
||||
canSave: true,
|
||||
isSaving: false,
|
||||
isDirty: true,
|
||||
deckName: '',
|
||||
},
|
||||
})
|
||||
|
||||
const saveButton = wrapper.findAll('button').find(b => b.text().includes('Save'))
|
||||
expect(saveButton?.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('disables save button when deck name is only whitespace', () => {
|
||||
/**
|
||||
* Test validation edge case: whitespace-only name.
|
||||
*
|
||||
* Names that are just spaces are not valid - they should
|
||||
* be treated the same as empty names.
|
||||
*/
|
||||
const wrapper = mount(DeckActionButtons, {
|
||||
props: {
|
||||
canSave: true,
|
||||
isSaving: false,
|
||||
isDirty: true,
|
||||
deckName: ' ',
|
||||
},
|
||||
})
|
||||
|
||||
const saveButton = wrapper.findAll('button').find(b => b.text().includes('Save'))
|
||||
expect(saveButton?.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('disables save button when not dirty', () => {
|
||||
/**
|
||||
* Test save button disabled when no changes.
|
||||
*
|
||||
* Users shouldn't save if nothing has changed - prevents
|
||||
* unnecessary API calls and database writes.
|
||||
*/
|
||||
const wrapper = mount(DeckActionButtons, {
|
||||
props: {
|
||||
canSave: true,
|
||||
isSaving: false,
|
||||
isDirty: false,
|
||||
deckName: 'Test Deck',
|
||||
},
|
||||
})
|
||||
|
||||
const saveButton = wrapper.findAll('button').find(b => b.text().includes('Save'))
|
||||
expect(saveButton?.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('disables save button when saving is in progress', () => {
|
||||
/**
|
||||
* Test save button disabled during save.
|
||||
*
|
||||
* Prevents double-submits and race conditions by disabling
|
||||
* the button while a save is in progress.
|
||||
*/
|
||||
const wrapper = mount(DeckActionButtons, {
|
||||
props: {
|
||||
canSave: true,
|
||||
isSaving: true,
|
||||
isDirty: true,
|
||||
deckName: 'Test Deck',
|
||||
},
|
||||
})
|
||||
|
||||
const saveButton = wrapper.findAll('button').find(b => b.text().includes('Saving'))
|
||||
expect(saveButton?.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows "Saving..." text with spinner when saving', () => {
|
||||
/**
|
||||
* Test loading state visual feedback.
|
||||
*
|
||||
* Users need to see that their save is in progress so they
|
||||
* don't think the button is broken or click it again.
|
||||
*/
|
||||
const wrapper = mount(DeckActionButtons, {
|
||||
props: {
|
||||
canSave: true,
|
||||
isSaving: true,
|
||||
isDirty: true,
|
||||
deckName: 'Test Deck',
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Saving...')
|
||||
// Spinner SVG should be present
|
||||
const svg = wrapper.find('svg')
|
||||
expect(svg.exists()).toBe(true)
|
||||
expect(svg.classes()).toContain('animate-spin')
|
||||
})
|
||||
|
||||
it('shows "Save" text when not saving', () => {
|
||||
/**
|
||||
* Test default button text.
|
||||
*
|
||||
* Normal state should show simple "Save" text.
|
||||
*/
|
||||
const wrapper = mount(DeckActionButtons, {
|
||||
props: {
|
||||
canSave: true,
|
||||
isSaving: false,
|
||||
isDirty: true,
|
||||
deckName: 'Test Deck',
|
||||
},
|
||||
})
|
||||
|
||||
const saveButton = wrapper.findAll('button').find(b => b.text() === 'Save')
|
||||
expect(saveButton).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancel button states', () => {
|
||||
it('enables cancel button when not saving', () => {
|
||||
/**
|
||||
* Test cancel button is always enabled when idle.
|
||||
*
|
||||
* Users should be able to cancel at any time unless a save
|
||||
* is in progress.
|
||||
*/
|
||||
const wrapper = mount(DeckActionButtons, {
|
||||
props: {
|
||||
canSave: false,
|
||||
isSaving: false,
|
||||
isDirty: false,
|
||||
deckName: '',
|
||||
},
|
||||
})
|
||||
|
||||
const cancelButton = wrapper.findAll('button').find(b => b.text().includes('Cancel'))
|
||||
expect(cancelButton?.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('disables cancel button when saving', () => {
|
||||
/**
|
||||
* Test cancel disabled during save.
|
||||
*
|
||||
* Users shouldn't cancel mid-save as it could leave the deck
|
||||
* in an inconsistent state.
|
||||
*/
|
||||
const wrapper = mount(DeckActionButtons, {
|
||||
props: {
|
||||
canSave: true,
|
||||
isSaving: true,
|
||||
isDirty: true,
|
||||
deckName: 'Test Deck',
|
||||
},
|
||||
})
|
||||
|
||||
const cancelButton = wrapper.findAll('button').find(b => b.text().includes('Cancel'))
|
||||
expect(cancelButton?.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('event emission', () => {
|
||||
it('emits save event when save button clicked', async () => {
|
||||
/**
|
||||
* Test save event emission.
|
||||
*
|
||||
* Clicking the save button should notify the parent to
|
||||
* trigger the save operation.
|
||||
*/
|
||||
const wrapper = mount(DeckActionButtons, {
|
||||
props: {
|
||||
canSave: true,
|
||||
isSaving: false,
|
||||
isDirty: true,
|
||||
deckName: 'Test Deck',
|
||||
},
|
||||
})
|
||||
|
||||
const saveButton = wrapper.findAll('button').find(b => b.text().includes('Save'))
|
||||
await saveButton?.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('save')).toBeTruthy()
|
||||
expect(wrapper.emitted('save')?.length).toBe(1)
|
||||
})
|
||||
|
||||
it('emits cancel event when cancel button clicked', async () => {
|
||||
/**
|
||||
* Test cancel event emission.
|
||||
*
|
||||
* Clicking the cancel button should notify the parent to
|
||||
* navigate away or show confirmation dialog.
|
||||
*/
|
||||
const wrapper = mount(DeckActionButtons, {
|
||||
props: {
|
||||
canSave: false,
|
||||
isSaving: false,
|
||||
isDirty: false,
|
||||
deckName: '',
|
||||
},
|
||||
})
|
||||
|
||||
const cancelButton = wrapper.findAll('button').find(b => b.text().includes('Cancel'))
|
||||
await cancelButton?.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('cancel')).toBeTruthy()
|
||||
expect(wrapper.emitted('cancel')?.length).toBe(1)
|
||||
})
|
||||
|
||||
it('does not emit save when button is disabled', async () => {
|
||||
/**
|
||||
* Test disabled button doesn't emit.
|
||||
*
|
||||
* Even if a disabled button is somehow clicked (via code),
|
||||
* it shouldn't emit the event.
|
||||
*/
|
||||
const wrapper = mount(DeckActionButtons, {
|
||||
props: {
|
||||
canSave: false,
|
||||
isSaving: false,
|
||||
isDirty: false,
|
||||
deckName: '',
|
||||
},
|
||||
})
|
||||
|
||||
const saveButton = wrapper.findAll('button').find(b => b.text().includes('Save'))
|
||||
// Try to trigger even though disabled
|
||||
await saveButton?.trigger('click')
|
||||
|
||||
// Native disabled attribute prevents click, but if it somehow fires, no emit should occur
|
||||
// Since the button has disabled attribute, the click won't fire
|
||||
expect(saveButton?.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles rapid save clicks gracefully', async () => {
|
||||
/**
|
||||
* Test double-click protection.
|
||||
*
|
||||
* If a user clicks save multiple times quickly, only one
|
||||
* save event should be emitted before the button disables.
|
||||
*/
|
||||
const wrapper = mount(DeckActionButtons, {
|
||||
props: {
|
||||
canSave: true,
|
||||
isSaving: false,
|
||||
isDirty: true,
|
||||
deckName: 'Test Deck',
|
||||
},
|
||||
})
|
||||
|
||||
const saveButton = wrapper.findAll('button').find(b => b.text().includes('Save'))
|
||||
|
||||
// Click once
|
||||
await saveButton?.trigger('click')
|
||||
expect(wrapper.emitted('save')?.length).toBe(1)
|
||||
|
||||
// Try to click again (parent should have set isSaving=true by now)
|
||||
await wrapper.setProps({ isSaving: true })
|
||||
await saveButton?.trigger('click')
|
||||
|
||||
// Should still only have one emit (second click didn't fire because button is disabled)
|
||||
expect(wrapper.emitted('save')?.length).toBe(1)
|
||||
})
|
||||
|
||||
it('handles all disabled conditions at once', () => {
|
||||
/**
|
||||
* Test worst-case scenario: everything disabled.
|
||||
*
|
||||
* When saving, no name, and not dirty, the save button
|
||||
* should be disabled for multiple reasons.
|
||||
*/
|
||||
const wrapper = mount(DeckActionButtons, {
|
||||
props: {
|
||||
canSave: false,
|
||||
isSaving: true,
|
||||
isDirty: false,
|
||||
deckName: '',
|
||||
},
|
||||
})
|
||||
|
||||
const saveButton = wrapper.findAll('button').find(b => b.text().includes('Saving'))
|
||||
expect(saveButton?.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('re-enables save button after save completes', async () => {
|
||||
/**
|
||||
* Test state recovery after save.
|
||||
*
|
||||
* After a successful save, the button should become disabled
|
||||
* again (because isDirty becomes false), not remain in saving state.
|
||||
*/
|
||||
const wrapper = mount(DeckActionButtons, {
|
||||
props: {
|
||||
canSave: true,
|
||||
isSaving: true,
|
||||
isDirty: true,
|
||||
deckName: 'Test Deck',
|
||||
},
|
||||
})
|
||||
|
||||
// Save completes, deck is no longer dirty
|
||||
await wrapper.setProps({ isSaving: false, isDirty: false })
|
||||
|
||||
const saveButton = wrapper.findAll('button').find(b => b.text().includes('Save'))
|
||||
// Should be disabled because not dirty
|
||||
expect(saveButton?.attributes('disabled')).toBeDefined()
|
||||
// Should show "Save" not "Saving..."
|
||||
expect(wrapper.text()).not.toContain('Saving...')
|
||||
})
|
||||
|
||||
it('handles deck name with special characters', () => {
|
||||
/**
|
||||
* Test deck names with special characters.
|
||||
*
|
||||
* Names with emojis, unicode, or special characters should
|
||||
* not cause issues with the trim() validation.
|
||||
*/
|
||||
const wrapper = mount(DeckActionButtons, {
|
||||
props: {
|
||||
canSave: true,
|
||||
isSaving: false,
|
||||
isDirty: true,
|
||||
deckName: '🔥 Fire Deck! 🔥',
|
||||
},
|
||||
})
|
||||
|
||||
const saveButton = wrapper.findAll('button').find(b => b.text().includes('Save'))
|
||||
expect(saveButton?.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('save button has type="button"', () => {
|
||||
/**
|
||||
* Test button type attribute.
|
||||
*
|
||||
* Buttons should have type="button" to prevent accidental
|
||||
* form submission in parent contexts.
|
||||
*/
|
||||
const wrapper = mount(DeckActionButtons, {
|
||||
props: {
|
||||
canSave: true,
|
||||
isSaving: false,
|
||||
isDirty: true,
|
||||
deckName: 'Test Deck',
|
||||
},
|
||||
})
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
buttons.forEach(button => {
|
||||
expect(button.attributes('type')).toBe('button')
|
||||
})
|
||||
})
|
||||
|
||||
it('spinner has aria-hidden', () => {
|
||||
/**
|
||||
* Test spinner accessibility.
|
||||
*
|
||||
* Loading spinners should be hidden from screen readers
|
||||
* since the "Saving..." text provides the same information.
|
||||
*/
|
||||
const wrapper = mount(DeckActionButtons, {
|
||||
props: {
|
||||
canSave: true,
|
||||
isSaving: true,
|
||||
isDirty: true,
|
||||
deckName: 'Test Deck',
|
||||
},
|
||||
})
|
||||
|
||||
const svg = wrapper.find('svg')
|
||||
expect(svg.attributes('aria-hidden')).toBe('true')
|
||||
})
|
||||
})
|
||||
})
|
||||
812
frontend/src/components/deck/DeckCardRow.spec.ts
Normal file
812
frontend/src/components/deck/DeckCardRow.spec.ts
Normal file
@ -0,0 +1,812 @@
|
||||
/**
|
||||
* Tests for DeckCardRow component.
|
||||
*
|
||||
* These tests verify the card row displays correctly with quantity
|
||||
* stepper, drag-drop support, and interaction edge cases.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
import DeckCardRow from './DeckCardRow.vue'
|
||||
import type { CardDefinition, EnergyType } from '@/types'
|
||||
|
||||
/**
|
||||
* Helper to create a mock card definition.
|
||||
*/
|
||||
function createMockCard(overrides: Partial<CardDefinition> = {}): CardDefinition {
|
||||
return {
|
||||
id: 'test-card',
|
||||
name: 'Pikachu',
|
||||
category: 'pokemon',
|
||||
type: 'lightning',
|
||||
hp: 60,
|
||||
imageUrl: '/images/pikachu.png',
|
||||
rarity: 'common',
|
||||
setId: 'base',
|
||||
setNumber: 25,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('DeckCardRow', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders card name', () => {
|
||||
/**
|
||||
* Test card name display.
|
||||
*
|
||||
* Users need to see the card name to identify cards in
|
||||
* their deck.
|
||||
*/
|
||||
const card = createMockCard({ name: 'Pikachu' })
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 1,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Pikachu')
|
||||
})
|
||||
|
||||
it('renders card thumbnail when imageUrl exists', () => {
|
||||
/**
|
||||
* Test card image rendering.
|
||||
*
|
||||
* Cards with images should display a thumbnail.
|
||||
*/
|
||||
const card = createMockCard({ imageUrl: '/images/pikachu.png' })
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const img = wrapper.find('img')
|
||||
expect(img.exists()).toBe(true)
|
||||
expect(img.attributes('src')).toBe('/images/pikachu.png')
|
||||
expect(img.attributes('alt')).toBe('Pikachu')
|
||||
})
|
||||
|
||||
it('shows placeholder icon when no imageUrl', () => {
|
||||
/**
|
||||
* Test fallback for missing images.
|
||||
*
|
||||
* Cards without images should show a placeholder icon
|
||||
* instead of a broken image.
|
||||
*/
|
||||
const card = createMockCard({ imageUrl: undefined })
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 1,
|
||||
},
|
||||
})
|
||||
|
||||
// Should show SVG placeholder
|
||||
const svg = wrapper.find('svg')
|
||||
expect(svg.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays HP for Pokemon cards', () => {
|
||||
/**
|
||||
* Test HP display for Pokemon.
|
||||
*
|
||||
* Pokemon cards should show their HP value.
|
||||
*/
|
||||
const card = createMockCard({ category: 'pokemon', hp: 60 })
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 1,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('60 HP')
|
||||
})
|
||||
|
||||
it('does not display HP for non-Pokemon cards', () => {
|
||||
/**
|
||||
* Test HP hidden for trainer cards.
|
||||
*
|
||||
* Trainer and energy cards don't have HP and shouldn't
|
||||
* display it.
|
||||
*/
|
||||
const card = createMockCard({ category: 'trainer', hp: undefined })
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 1,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).not.toContain('HP')
|
||||
})
|
||||
|
||||
it('displays current quantity', () => {
|
||||
/**
|
||||
* Test quantity display.
|
||||
*
|
||||
* Users need to see how many copies are in the deck.
|
||||
*/
|
||||
const card = createMockCard()
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 3,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('3')
|
||||
})
|
||||
})
|
||||
|
||||
describe('quantity stepper', () => {
|
||||
it('enables add button when canAdd is true', () => {
|
||||
/**
|
||||
* Test add button enabled state.
|
||||
*
|
||||
* When users can add more copies (haven't hit 4-copy limit),
|
||||
* the add button should be enabled.
|
||||
*/
|
||||
const card = createMockCard()
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 1,
|
||||
canAdd: true,
|
||||
},
|
||||
})
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
const addButton = buttons.find(b => b.attributes('aria-label')?.includes('Add'))
|
||||
expect(addButton?.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('disables add button when canAdd is false', () => {
|
||||
/**
|
||||
* Test add button disabled at limit.
|
||||
*
|
||||
* When the 4-copy limit is reached, the add button should
|
||||
* be disabled to prevent violations.
|
||||
*/
|
||||
const card = createMockCard()
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 4,
|
||||
canAdd: false,
|
||||
},
|
||||
})
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
const addButton = buttons.find(b => b.attributes('aria-label')?.includes('Add'))
|
||||
expect(addButton?.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('enables remove button when quantity > 0', () => {
|
||||
/**
|
||||
* Test remove button enabled with cards.
|
||||
*
|
||||
* When there are cards in the deck, users should be able
|
||||
* to remove them.
|
||||
*/
|
||||
const card = createMockCard()
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
const removeButton = buttons.find(b => b.attributes('aria-label')?.includes('Remove'))
|
||||
expect(removeButton?.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('disables remove button when quantity is 0', () => {
|
||||
/**
|
||||
* Test remove button disabled at zero.
|
||||
*
|
||||
* When there are no cards, the remove button should be
|
||||
* disabled.
|
||||
*/
|
||||
const card = createMockCard()
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
const removeButton = buttons.find(b => b.attributes('aria-label')?.includes('Remove'))
|
||||
expect(removeButton?.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('emits add event when add button clicked', async () => {
|
||||
/**
|
||||
* Test add button functionality.
|
||||
*
|
||||
* Clicking the add button should emit an event with the
|
||||
* card ID so the parent can add a copy.
|
||||
*/
|
||||
const card = createMockCard({ id: 'pikachu-001' })
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 1,
|
||||
canAdd: true,
|
||||
},
|
||||
})
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
const addButton = buttons.find(b => b.attributes('aria-label')?.includes('Add'))
|
||||
await addButton?.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('add')).toBeTruthy()
|
||||
expect(wrapper.emitted('add')?.[0]).toEqual(['pikachu-001'])
|
||||
})
|
||||
|
||||
it('emits remove event when remove button clicked', async () => {
|
||||
/**
|
||||
* Test remove button functionality.
|
||||
*
|
||||
* Clicking the remove button should emit an event with the
|
||||
* card ID so the parent can remove a copy.
|
||||
*/
|
||||
const card = createMockCard({ id: 'pikachu-001' })
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 2,
|
||||
},
|
||||
})
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
const removeButton = buttons.find(b => b.attributes('aria-label')?.includes('Remove'))
|
||||
await removeButton?.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('remove')).toBeTruthy()
|
||||
expect(wrapper.emitted('remove')?.[0]).toEqual(['pikachu-001'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('card click handling', () => {
|
||||
it('emits click event when row is clicked', async () => {
|
||||
/**
|
||||
* Test row click for card details.
|
||||
*
|
||||
* Clicking the row should open card details view.
|
||||
*/
|
||||
const card = createMockCard({ id: 'pikachu-001' })
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 1,
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('click')).toBeTruthy()
|
||||
expect(wrapper.emitted('click')?.[0]).toEqual(['pikachu-001'])
|
||||
})
|
||||
|
||||
it('handles Enter key to click', async () => {
|
||||
/**
|
||||
* Test keyboard activation with Enter.
|
||||
*
|
||||
* Pressing Enter should trigger the same action as clicking
|
||||
* for keyboard accessibility.
|
||||
*/
|
||||
const card = createMockCard({ id: 'pikachu-001' })
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 1,
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.trigger('keydown', { key: 'Enter' })
|
||||
|
||||
expect(wrapper.emitted('click')).toBeTruthy()
|
||||
expect(wrapper.emitted('click')?.[0]).toEqual(['pikachu-001'])
|
||||
})
|
||||
|
||||
it('handles Space key to click', async () => {
|
||||
/**
|
||||
* Test keyboard activation with Space.
|
||||
*
|
||||
* Pressing Space should trigger the same action as clicking
|
||||
* for keyboard accessibility.
|
||||
*/
|
||||
const card = createMockCard({ id: 'pikachu-001' })
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 1,
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.trigger('keydown', { key: ' ' })
|
||||
|
||||
expect(wrapper.emitted('click')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not emit click when disabled', async () => {
|
||||
/**
|
||||
* Test disabled state blocks clicks.
|
||||
*
|
||||
* When disabled (e.g., during save), clicking should not
|
||||
* emit events.
|
||||
*/
|
||||
const card = createMockCard()
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 1,
|
||||
disabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('click')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('disabled state', () => {
|
||||
it('applies opacity when disabled', () => {
|
||||
/**
|
||||
* Test visual disabled state.
|
||||
*
|
||||
* Disabled rows should appear faded to indicate they're
|
||||
* not interactive.
|
||||
*/
|
||||
const card = createMockCard()
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 1,
|
||||
disabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
const row = wrapper.find('[role="button"]')
|
||||
expect(row.classes()).toContain('opacity-50')
|
||||
})
|
||||
|
||||
it('disables add button when row is disabled', () => {
|
||||
/**
|
||||
* Test buttons disabled when row disabled.
|
||||
*
|
||||
* All interactive elements should be disabled during save.
|
||||
*/
|
||||
const card = createMockCard()
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 1,
|
||||
canAdd: true,
|
||||
disabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
const addButton = buttons.find(b => b.attributes('aria-label')?.includes('Add'))
|
||||
expect(addButton?.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('disables remove button when row is disabled', () => {
|
||||
/**
|
||||
* Test remove button disabled state.
|
||||
*
|
||||
* Remove button should also be disabled during save.
|
||||
*/
|
||||
const card = createMockCard()
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 1,
|
||||
disabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
const removeButton = buttons.find(b => b.attributes('aria-label')?.includes('Remove'))
|
||||
expect(removeButton?.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not emit add when disabled', async () => {
|
||||
/**
|
||||
* Test add blocked when disabled.
|
||||
*
|
||||
* Even if the button is somehow clicked, the event handler
|
||||
* should check disabled state.
|
||||
*/
|
||||
const card = createMockCard()
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 1,
|
||||
canAdd: true,
|
||||
disabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
const addButton = buttons.find(b => b.attributes('aria-label')?.includes('Add'))
|
||||
await addButton?.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('add')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('does not emit remove when disabled', async () => {
|
||||
/**
|
||||
* Test remove blocked when disabled.
|
||||
*
|
||||
* Remove should also be blocked when disabled.
|
||||
*/
|
||||
const card = createMockCard()
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 2,
|
||||
disabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
const removeButton = buttons.find(b => b.attributes('aria-label')?.includes('Remove'))
|
||||
await removeButton?.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('remove')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('drag and drop', () => {
|
||||
it('applies drag handlers when provided', () => {
|
||||
/**
|
||||
* Test drag handlers binding.
|
||||
*
|
||||
* When drag handlers are provided, they should be bound to
|
||||
* the row element.
|
||||
*/
|
||||
const card = createMockCard()
|
||||
const mockDragHandlers = {
|
||||
draggable: true,
|
||||
onDragstart: () => {},
|
||||
onDragend: () => {},
|
||||
}
|
||||
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 1,
|
||||
dragHandlers: mockDragHandlers,
|
||||
},
|
||||
})
|
||||
|
||||
const row = wrapper.find('[role="button"]')
|
||||
expect(row.attributes('draggable')).toBe('true')
|
||||
})
|
||||
|
||||
it('shows grab cursor when draggable and not disabled', () => {
|
||||
/**
|
||||
* Test drag cursor style.
|
||||
*
|
||||
* Draggable rows should show a grab cursor to indicate they
|
||||
* can be dragged.
|
||||
*/
|
||||
const card = createMockCard()
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 1,
|
||||
dragHandlers: { draggable: true },
|
||||
},
|
||||
})
|
||||
|
||||
const row = wrapper.find('[role="button"]')
|
||||
expect(row.classes()).toContain('cursor-grab')
|
||||
})
|
||||
|
||||
it('shows grabbing cursor when dragging', () => {
|
||||
/**
|
||||
* Test active drag cursor.
|
||||
*
|
||||
* While being dragged, the cursor should change to grabbing.
|
||||
*/
|
||||
const card = createMockCard()
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 1,
|
||||
dragHandlers: { draggable: true },
|
||||
isDragging: true,
|
||||
},
|
||||
})
|
||||
|
||||
const row = wrapper.find('[role="button"]')
|
||||
expect(row.classes()).toContain('cursor-grabbing')
|
||||
})
|
||||
|
||||
it('applies opacity when dragging', () => {
|
||||
/**
|
||||
* Test drag visual feedback.
|
||||
*
|
||||
* The row being dragged should be semi-transparent.
|
||||
*/
|
||||
const card = createMockCard()
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 1,
|
||||
isDragging: true,
|
||||
},
|
||||
})
|
||||
|
||||
const row = wrapper.find('[role="button"]')
|
||||
expect(row.classes()).toContain('opacity-50')
|
||||
})
|
||||
|
||||
it('shows pointer cursor when not draggable', () => {
|
||||
/**
|
||||
* Test non-draggable cursor.
|
||||
*
|
||||
* Rows without drag handlers should show a pointer cursor
|
||||
* for clicking.
|
||||
*/
|
||||
const card = createMockCard()
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 1,
|
||||
dragHandlers: undefined,
|
||||
},
|
||||
})
|
||||
|
||||
const row = wrapper.find('[role="button"]')
|
||||
expect(row.classes()).toContain('cursor-pointer')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles quantity of 0', () => {
|
||||
/**
|
||||
* Test zero quantity display.
|
||||
*
|
||||
* Even with 0 quantity, the row should render correctly
|
||||
* (used in collection view).
|
||||
*/
|
||||
const card = createMockCard()
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 0,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('0')
|
||||
})
|
||||
|
||||
it('handles high quantities', () => {
|
||||
/**
|
||||
* Test high quantity display.
|
||||
*
|
||||
* The component should handle quantities beyond the normal
|
||||
* 4-copy limit (for testing or special cards).
|
||||
*/
|
||||
const card = createMockCard()
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 99,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('99')
|
||||
})
|
||||
|
||||
it('stops event propagation from stepper buttons', async () => {
|
||||
/**
|
||||
* Test click event isolation.
|
||||
*
|
||||
* Clicking add/remove buttons should not trigger the row
|
||||
* click event (which opens card details).
|
||||
*/
|
||||
const card = createMockCard()
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 1,
|
||||
canAdd: true,
|
||||
},
|
||||
})
|
||||
|
||||
// The stepper container has @click.stop
|
||||
const stepperContainer = wrapper.find('.flex.items-center.rounded-lg.border')
|
||||
await stepperContainer.trigger('click')
|
||||
|
||||
// Row click should not have been emitted
|
||||
expect(wrapper.emitted('click')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('handles long card names with truncation', () => {
|
||||
/**
|
||||
* Test long name display.
|
||||
*
|
||||
* Very long card names should be truncated with ellipsis
|
||||
* to prevent layout issues.
|
||||
*/
|
||||
const card = createMockCard({ name: 'A'.repeat(100) })
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const nameSpan = wrapper.find('.text-sm.font-medium')
|
||||
expect(nameSpan.classes()).toContain('truncate')
|
||||
})
|
||||
|
||||
it('handles cards with no type (colorless border)', () => {
|
||||
/**
|
||||
* Test default border for cards without type.
|
||||
*
|
||||
* Cards without a type should get a default border color.
|
||||
*/
|
||||
const card = createMockCard({ type: undefined })
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const thumbnail = wrapper.find('.w-10.h-14')
|
||||
expect(thumbnail.classes()).toContain('border-surface-light')
|
||||
})
|
||||
|
||||
it('applies correct border color for each type', () => {
|
||||
/**
|
||||
* Test type-based border colors.
|
||||
*
|
||||
* Each Pokemon type should have its own border color.
|
||||
*/
|
||||
const types = ['fire', 'water', 'lightning', 'grass']
|
||||
|
||||
types.forEach(type => {
|
||||
const card = createMockCard({ type: type as EnergyType })
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const thumbnail = wrapper.find('.w-10.h-14')
|
||||
expect(thumbnail.classes()).toContain(`border-type-${type}`)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('has role="button"', () => {
|
||||
/**
|
||||
* Test ARIA role.
|
||||
*
|
||||
* The row should have role="button" since it's clickable.
|
||||
*/
|
||||
const card = createMockCard()
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const row = wrapper.find('[role="button"]')
|
||||
expect(row.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('has tabindex for keyboard navigation', () => {
|
||||
/**
|
||||
* Test keyboard focusability.
|
||||
*
|
||||
* The row should be focusable via Tab key.
|
||||
*/
|
||||
const card = createMockCard()
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const row = wrapper.find('[role="button"]')
|
||||
expect(row.attributes('tabindex')).toBe('0')
|
||||
})
|
||||
|
||||
it('has descriptive aria-label', () => {
|
||||
/**
|
||||
* Test screen reader support.
|
||||
*
|
||||
* The row should have a label that describes the card and
|
||||
* quantity for screen reader users.
|
||||
*/
|
||||
const card = createMockCard({ name: 'Pikachu' })
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 3,
|
||||
},
|
||||
})
|
||||
|
||||
const row = wrapper.find('[role="button"]')
|
||||
const label = row.attributes('aria-label')
|
||||
expect(label).toContain('Pikachu')
|
||||
expect(label).toContain('3')
|
||||
})
|
||||
|
||||
it('add button has descriptive aria-label', () => {
|
||||
/**
|
||||
* Test button accessibility.
|
||||
*
|
||||
* Buttons should have clear labels for screen readers.
|
||||
*/
|
||||
const card = createMockCard()
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
const addButton = buttons.find(b => b.attributes('aria-label')?.includes('Add'))
|
||||
expect(addButton?.attributes('aria-label')).toBe('Add one copy')
|
||||
})
|
||||
|
||||
it('remove button has descriptive aria-label', () => {
|
||||
/**
|
||||
* Test button accessibility.
|
||||
*
|
||||
* Buttons should have clear labels for screen readers.
|
||||
*/
|
||||
const card = createMockCard()
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
const removeButton = buttons.find(b => b.attributes('aria-label')?.includes('Remove'))
|
||||
expect(removeButton?.attributes('aria-label')).toBe('Remove one copy')
|
||||
})
|
||||
|
||||
it('SVG icons have aria-hidden', () => {
|
||||
/**
|
||||
* Test icon accessibility.
|
||||
*
|
||||
* Decorative icons should be hidden from screen readers.
|
||||
*/
|
||||
const card = createMockCard()
|
||||
const wrapper = mount(DeckCardRow, {
|
||||
props: {
|
||||
card,
|
||||
quantity: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const svgs = wrapper.findAll('svg')
|
||||
svgs.forEach(svg => {
|
||||
expect(svg.attributes('aria-hidden')).toBe('true')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
398
frontend/src/components/deck/DeckHeader.spec.ts
Normal file
398
frontend/src/components/deck/DeckHeader.spec.ts
Normal file
@ -0,0 +1,398 @@
|
||||
/**
|
||||
* Tests for DeckHeader component.
|
||||
*
|
||||
* These tests verify the deck name input works correctly with
|
||||
* proper validation and error states.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
import DeckHeader from './DeckHeader.vue'
|
||||
|
||||
describe('DeckHeader', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders deck name input', () => {
|
||||
/**
|
||||
* Test that the input element is rendered.
|
||||
*
|
||||
* Users need an input field to name their deck.
|
||||
*/
|
||||
const wrapper = mount(DeckHeader, {
|
||||
props: {
|
||||
deckName: '',
|
||||
isValidating: false,
|
||||
},
|
||||
})
|
||||
|
||||
const input = wrapper.find('input[type="text"]')
|
||||
expect(input.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays current deck name', () => {
|
||||
/**
|
||||
* Test that the input shows the current deck name.
|
||||
*
|
||||
* When editing an existing deck, the name should be
|
||||
* pre-filled in the input.
|
||||
*/
|
||||
const wrapper = mount(DeckHeader, {
|
||||
props: {
|
||||
deckName: 'Fire Deck',
|
||||
isValidating: false,
|
||||
},
|
||||
})
|
||||
|
||||
const input = wrapper.find('input[type="text"]')
|
||||
expect((input.element as HTMLInputElement).value).toBe('Fire Deck')
|
||||
})
|
||||
|
||||
it('shows placeholder when deck name is empty', () => {
|
||||
/**
|
||||
* Test placeholder text.
|
||||
*
|
||||
* Empty inputs should show helpful placeholder text.
|
||||
*/
|
||||
const wrapper = mount(DeckHeader, {
|
||||
props: {
|
||||
deckName: '',
|
||||
isValidating: false,
|
||||
},
|
||||
})
|
||||
|
||||
const input = wrapper.find('input[type="text"]')
|
||||
expect(input.attributes('placeholder')).toBe('Deck Name')
|
||||
})
|
||||
})
|
||||
|
||||
describe('input handling', () => {
|
||||
it('emits update:deckName when user types', async () => {
|
||||
/**
|
||||
* Test input event emission.
|
||||
*
|
||||
* When the user types in the input, the parent component
|
||||
* should be notified via v-model binding.
|
||||
*/
|
||||
const wrapper = mount(DeckHeader, {
|
||||
props: {
|
||||
deckName: '',
|
||||
isValidating: false,
|
||||
},
|
||||
})
|
||||
|
||||
const input = wrapper.find('input[type="text"]')
|
||||
await input.setValue('New Deck Name')
|
||||
await input.trigger('input')
|
||||
|
||||
expect(wrapper.emitted('update:deckName')).toBeTruthy()
|
||||
expect(wrapper.emitted('update:deckName')?.[0]).toEqual(['New Deck Name'])
|
||||
})
|
||||
|
||||
it('emits update for each character typed', async () => {
|
||||
/**
|
||||
* Test real-time updates.
|
||||
*
|
||||
* The component should emit for each keystroke so the parent
|
||||
* can validate and show save button states in real-time.
|
||||
*/
|
||||
const wrapper = mount(DeckHeader, {
|
||||
props: {
|
||||
deckName: '',
|
||||
isValidating: false,
|
||||
},
|
||||
})
|
||||
|
||||
const input = wrapper.find('input[type="text"]')
|
||||
|
||||
// Type "F" (setValue automatically triggers input event)
|
||||
await input.setValue('F')
|
||||
expect(wrapper.emitted('update:deckName')?.length).toBe(1)
|
||||
expect(wrapper.emitted('update:deckName')?.[0]).toEqual(['F'])
|
||||
|
||||
// Type "i" (setValue automatically triggers input event)
|
||||
await input.setValue('Fi')
|
||||
expect(wrapper.emitted('update:deckName')?.length).toBe(2)
|
||||
expect(wrapper.emitted('update:deckName')?.[1]).toEqual(['Fi'])
|
||||
})
|
||||
|
||||
it('handles empty string input', async () => {
|
||||
/**
|
||||
* Test clearing the input.
|
||||
*
|
||||
* Users should be able to clear the deck name by deleting
|
||||
* all text.
|
||||
*/
|
||||
const wrapper = mount(DeckHeader, {
|
||||
props: {
|
||||
deckName: 'Old Name',
|
||||
isValidating: false,
|
||||
},
|
||||
})
|
||||
|
||||
const input = wrapper.find('input[type="text"]')
|
||||
await input.setValue('')
|
||||
await input.trigger('input')
|
||||
|
||||
expect(wrapper.emitted('update:deckName')?.[0]).toEqual([''])
|
||||
})
|
||||
|
||||
it('handles whitespace input', async () => {
|
||||
/**
|
||||
* Test whitespace-only input.
|
||||
*
|
||||
* The component should emit whitespace as-is - validation
|
||||
* happens in the parent component.
|
||||
*/
|
||||
const wrapper = mount(DeckHeader, {
|
||||
props: {
|
||||
deckName: '',
|
||||
isValidating: false,
|
||||
},
|
||||
})
|
||||
|
||||
const input = wrapper.find('input[type="text"]')
|
||||
await input.setValue(' ')
|
||||
await input.trigger('input')
|
||||
|
||||
expect(wrapper.emitted('update:deckName')?.[0]).toEqual([' '])
|
||||
})
|
||||
|
||||
it('handles special characters and emojis', async () => {
|
||||
/**
|
||||
* Test special character support.
|
||||
*
|
||||
* Users should be able to use unicode characters, emojis,
|
||||
* and special characters in deck names.
|
||||
*/
|
||||
const wrapper = mount(DeckHeader, {
|
||||
props: {
|
||||
deckName: '',
|
||||
isValidating: false,
|
||||
},
|
||||
})
|
||||
|
||||
const input = wrapper.find('input[type="text"]')
|
||||
const specialName = '🔥 Fire Deck! #1 (2026)'
|
||||
await input.setValue(specialName)
|
||||
await input.trigger('input')
|
||||
|
||||
expect(wrapper.emitted('update:deckName')?.[0]).toEqual([specialName])
|
||||
})
|
||||
|
||||
it('handles very long names', async () => {
|
||||
/**
|
||||
* Test long input handling.
|
||||
*
|
||||
* Very long names should be accepted (length validation
|
||||
* happens server-side).
|
||||
*/
|
||||
const wrapper = mount(DeckHeader, {
|
||||
props: {
|
||||
deckName: '',
|
||||
isValidating: false,
|
||||
},
|
||||
})
|
||||
|
||||
const input = wrapper.find('input[type="text"]')
|
||||
const longName = 'A'.repeat(200)
|
||||
await input.setValue(longName)
|
||||
await input.trigger('input')
|
||||
|
||||
expect(wrapper.emitted('update:deckName')?.[0]).toEqual([longName])
|
||||
})
|
||||
})
|
||||
|
||||
describe('validation states', () => {
|
||||
it('does not show validation indicator when not validating', () => {
|
||||
/**
|
||||
* Test normal state has no validation UI.
|
||||
*
|
||||
* When not validating, the input should appear normal
|
||||
* without any loading indicators.
|
||||
*/
|
||||
const wrapper = mount(DeckHeader, {
|
||||
props: {
|
||||
deckName: 'Test Deck',
|
||||
isValidating: false,
|
||||
},
|
||||
})
|
||||
|
||||
// Should not contain any validation-related text
|
||||
expect(wrapper.text()).not.toContain('Validating')
|
||||
expect(wrapper.text()).not.toContain('...')
|
||||
})
|
||||
|
||||
it('accepts isValidating prop for future validation state display', () => {
|
||||
/**
|
||||
* Test isValidating prop is accepted.
|
||||
*
|
||||
* The component accepts an isValidating prop for potential
|
||||
* future validation state display.
|
||||
*/
|
||||
const wrapper = mount(DeckHeader, {
|
||||
props: {
|
||||
deckName: 'Test Deck',
|
||||
isValidating: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Component should mount without errors with isValidating=true
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles rapid typing without issues', async () => {
|
||||
/**
|
||||
* Test rapid input changes.
|
||||
*
|
||||
* Rapid typing should not cause issues or dropped characters.
|
||||
*/
|
||||
const wrapper = mount(DeckHeader, {
|
||||
props: {
|
||||
deckName: '',
|
||||
isValidating: false,
|
||||
},
|
||||
})
|
||||
|
||||
const input = wrapper.find('input[type="text"]')
|
||||
|
||||
// Simulate rapid typing (setValue automatically triggers input event)
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await input.setValue(`Name ${i}`)
|
||||
}
|
||||
|
||||
expect(wrapper.emitted('update:deckName')?.length).toBe(10)
|
||||
})
|
||||
|
||||
it('handles prop updates correctly', async () => {
|
||||
/**
|
||||
* Test external prop changes.
|
||||
*
|
||||
* When the parent updates the deckName prop (e.g., after
|
||||
* loading a deck), the input should reflect the new value.
|
||||
*/
|
||||
const wrapper = mount(DeckHeader, {
|
||||
props: {
|
||||
deckName: 'Initial Name',
|
||||
isValidating: false,
|
||||
},
|
||||
})
|
||||
|
||||
// Parent updates the deck name
|
||||
await wrapper.setProps({ deckName: 'Updated Name' })
|
||||
|
||||
const input = wrapper.find('input[type="text"]')
|
||||
expect((input.element as HTMLInputElement).value).toBe('Updated Name')
|
||||
})
|
||||
|
||||
it('handles switching between empty and non-empty', async () => {
|
||||
/**
|
||||
* Test toggling between empty and populated states.
|
||||
*
|
||||
* Switching back and forth between empty and filled should
|
||||
* work correctly.
|
||||
*/
|
||||
const wrapper = mount(DeckHeader, {
|
||||
props: {
|
||||
deckName: 'Name',
|
||||
isValidating: false,
|
||||
},
|
||||
})
|
||||
|
||||
const input = wrapper.find('input[type="text"]')
|
||||
|
||||
// Clear it
|
||||
await wrapper.setProps({ deckName: '' })
|
||||
expect((input.element as HTMLInputElement).value).toBe('')
|
||||
|
||||
// Add name back
|
||||
await wrapper.setProps({ deckName: 'New Name' })
|
||||
expect((input.element as HTMLInputElement).value).toBe('New Name')
|
||||
})
|
||||
})
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('input has proper type attribute', () => {
|
||||
/**
|
||||
* Test input type.
|
||||
*
|
||||
* The input should be type="text" for proper mobile keyboard
|
||||
* and accessibility support.
|
||||
*/
|
||||
const wrapper = mount(DeckHeader, {
|
||||
props: {
|
||||
deckName: '',
|
||||
isValidating: false,
|
||||
},
|
||||
})
|
||||
|
||||
const input = wrapper.find('input')
|
||||
expect(input.attributes('type')).toBe('text')
|
||||
})
|
||||
|
||||
it('input is keyboard accessible', () => {
|
||||
/**
|
||||
* Test keyboard accessibility.
|
||||
*
|
||||
* The input should be focusable and usable via keyboard.
|
||||
*/
|
||||
const wrapper = mount(DeckHeader, {
|
||||
props: {
|
||||
deckName: '',
|
||||
isValidating: false,
|
||||
},
|
||||
})
|
||||
|
||||
const input = wrapper.find('input')
|
||||
// Input should not have tabindex=-1 or disabled
|
||||
expect(input.attributes('tabindex')).not.toBe('-1')
|
||||
expect(input.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('placeholder provides context for screen readers', () => {
|
||||
/**
|
||||
* Test placeholder accessibility.
|
||||
*
|
||||
* The placeholder text should be descriptive enough for
|
||||
* screen reader users to understand the input's purpose.
|
||||
*/
|
||||
const wrapper = mount(DeckHeader, {
|
||||
props: {
|
||||
deckName: '',
|
||||
isValidating: false,
|
||||
},
|
||||
})
|
||||
|
||||
const input = wrapper.find('input')
|
||||
const placeholder = input.attributes('placeholder')
|
||||
expect(placeholder).toBeTruthy()
|
||||
expect(placeholder).toContain('Deck')
|
||||
})
|
||||
})
|
||||
|
||||
describe('styling', () => {
|
||||
it('has proper input styling classes', () => {
|
||||
/**
|
||||
* Test input has expected styles.
|
||||
*
|
||||
* The input should have appropriate styling classes for
|
||||
* the deck builder design.
|
||||
*/
|
||||
const wrapper = mount(DeckHeader, {
|
||||
props: {
|
||||
deckName: '',
|
||||
isValidating: false,
|
||||
},
|
||||
})
|
||||
|
||||
const input = wrapper.find('input')
|
||||
const classes = input.classes().join(' ')
|
||||
|
||||
// Should have full width
|
||||
expect(classes).toContain('w-full')
|
||||
// Should have text styling
|
||||
expect(classes).toContain('text-xl')
|
||||
expect(classes).toContain('font-bold')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -5,7 +5,7 @@
|
||||
* and action dispatching functionality.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, VueWrapper } from '@vue/test-utils'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import AttackMenu from './AttackMenu.vue'
|
||||
import { useGameStore } from '@/stores/game'
|
||||
|
||||
@ -36,6 +36,8 @@ interface Props {
|
||||
show: boolean
|
||||
}
|
||||
|
||||
// Props are used in template via direct reference (show, not props.show)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@ -63,6 +63,7 @@ function createMockGame() {
|
||||
// Mock the createGame function from @/game
|
||||
vi.mock('@/game', () => ({
|
||||
createGame: vi.fn(() => createMockGame()),
|
||||
scenes: [], // Mock scenes array to satisfy the import
|
||||
}))
|
||||
|
||||
describe('PhaserGame', () => {
|
||||
@ -102,7 +103,7 @@ describe('PhaserGame', () => {
|
||||
wrapper = mount(PhaserGame)
|
||||
|
||||
expect(createGame).toHaveBeenCalledTimes(1)
|
||||
expect(createGame).toHaveBeenCalledWith(expect.any(HTMLElement))
|
||||
expect(createGame).toHaveBeenCalledWith(expect.any(HTMLElement), [])
|
||||
})
|
||||
|
||||
it('provides container element as ref', () => {
|
||||
|
||||
@ -592,6 +592,14 @@ describe('useAuth', () => {
|
||||
hasStarterDeck: true,
|
||||
})
|
||||
|
||||
// Mock the profile fetch that happens during initialization
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||
user_id: 'user-1',
|
||||
display_name: 'Test User',
|
||||
avatar_url: null,
|
||||
has_starter_deck: true,
|
||||
})
|
||||
|
||||
const { initialize } = useAuth()
|
||||
const result = await initialize()
|
||||
|
||||
@ -689,6 +697,14 @@ describe('useAuth', () => {
|
||||
hasStarterDeck: true,
|
||||
})
|
||||
|
||||
// Mock the profile fetch that happens during initialization
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||
user_id: 'user-1',
|
||||
display_name: 'Test User',
|
||||
avatar_url: null,
|
||||
has_starter_deck: true,
|
||||
})
|
||||
|
||||
const { initialize, isInitialized } = useAuth()
|
||||
|
||||
await initialize()
|
||||
|
||||
@ -522,4 +522,502 @@ describe('useDragDrop', () => {
|
||||
expect(dragSource.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
describe('drop outside drop zone', () => {
|
||||
it('should not call callback when dropped outside any drop zone', () => {
|
||||
/**
|
||||
* Test that dropping outside a drop zone doesn't trigger callbacks.
|
||||
*
|
||||
* When a card is dragged but dropped in empty space (not over any
|
||||
* drop target), no action should be taken. This prevents accidental
|
||||
* card removal or addition.
|
||||
*/
|
||||
const { createDragHandlers, createDropHandlers, isDragging } = useDragDrop()
|
||||
const card = createMockCard()
|
||||
const onDropCallback = vi.fn()
|
||||
|
||||
// Start a drag
|
||||
const dragHandlers = createDragHandlers(card, 'collection')
|
||||
dragHandlers.onDragstart(createMockDragEvent('dragstart'))
|
||||
expect(isDragging.value).toBe(true)
|
||||
|
||||
// Register a drop target but don't trigger its drop handler
|
||||
createDropHandlers('deck', () => true, onDropCallback)
|
||||
|
||||
// End drag without dropping on target (simulating drop in empty space)
|
||||
dragHandlers.onDragend(createMockDragEvent('dragend'))
|
||||
|
||||
// Callback should not have been called
|
||||
expect(onDropCallback).not.toHaveBeenCalled()
|
||||
expect(isDragging.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('data transfer fallback', () => {
|
||||
it('should recover card data from dataTransfer when draggedCard is null', () => {
|
||||
/**
|
||||
* Test data recovery from dataTransfer on drop.
|
||||
*
|
||||
* In some browser scenarios (cross-frame drag, external drag source),
|
||||
* draggedCard might be null but we can still recover the card data
|
||||
* from the event's dataTransfer object.
|
||||
*/
|
||||
const { createDropHandlers } = useDragDrop()
|
||||
const card = createMockCard()
|
||||
const onDropCallback = vi.fn()
|
||||
|
||||
const dropHandlers = createDropHandlers('deck', () => true, onDropCallback)
|
||||
|
||||
// Create drop event with card data in dataTransfer but no draggedCard state
|
||||
const cardJson = JSON.stringify(card)
|
||||
const dropEvent = createMockDragEvent('drop', {
|
||||
getData: vi.fn((type: string) => {
|
||||
if (type === 'application/x-mantimon-card') return cardJson
|
||||
return ''
|
||||
}),
|
||||
})
|
||||
|
||||
dropHandlers.onDrop(dropEvent)
|
||||
|
||||
expect(onDropCallback).toHaveBeenCalledWith(card)
|
||||
})
|
||||
|
||||
it('should handle invalid JSON in dataTransfer gracefully', () => {
|
||||
/**
|
||||
* Test that invalid JSON in dataTransfer doesn't crash.
|
||||
*
|
||||
* If the dataTransfer contains malformed JSON (corrupted data,
|
||||
* external drag source), the drop should fail gracefully without
|
||||
* throwing an error.
|
||||
*/
|
||||
const { createDropHandlers } = useDragDrop()
|
||||
const onDropCallback = vi.fn()
|
||||
|
||||
const dropHandlers = createDropHandlers('deck', () => true, onDropCallback)
|
||||
|
||||
// Create drop event with invalid JSON
|
||||
const dropEvent = createMockDragEvent('drop', {
|
||||
getData: vi.fn(() => '{invalid json'),
|
||||
})
|
||||
|
||||
// Should not throw
|
||||
expect(() => dropHandlers.onDrop(dropEvent)).not.toThrow()
|
||||
|
||||
// Callback should not have been called
|
||||
expect(onDropCallback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle empty dataTransfer gracefully', () => {
|
||||
/**
|
||||
* Test that empty dataTransfer is handled without errors.
|
||||
*
|
||||
* If neither draggedCard state nor dataTransfer have card data,
|
||||
* the drop should be silently ignored.
|
||||
*/
|
||||
const { createDropHandlers } = useDragDrop()
|
||||
const onDropCallback = vi.fn()
|
||||
|
||||
const dropHandlers = createDropHandlers('deck', () => true, onDropCallback)
|
||||
|
||||
// Create drop event with no data
|
||||
const dropEvent = createMockDragEvent('drop', {
|
||||
getData: vi.fn(() => ''),
|
||||
})
|
||||
|
||||
// Should not throw
|
||||
expect(() => dropHandlers.onDrop(dropEvent)).not.toThrow()
|
||||
|
||||
// Callback should not have been called
|
||||
expect(onDropCallback).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dragleave edge cases', () => {
|
||||
it('should not clear dropTarget when entering a child element', () => {
|
||||
/**
|
||||
* Test that dragleave doesn't clear state when entering children.
|
||||
*
|
||||
* When a drag moves from a parent drop zone to a child element
|
||||
* within it, dragleave fires on the parent. We should only clear
|
||||
* the drop state if actually leaving the drop zone entirely.
|
||||
*/
|
||||
const { createDragHandlers, createDropHandlers, dropTarget } = useDragDrop()
|
||||
const card = createMockCard()
|
||||
|
||||
// Start a drag and hover over deck
|
||||
const dragHandlers = createDragHandlers(card, 'collection')
|
||||
dragHandlers.onDragstart(createMockDragEvent('dragstart'))
|
||||
|
||||
const dropHandlers = createDropHandlers('deck', () => true, vi.fn())
|
||||
dropHandlers.onDragover(createMockDragEvent('dragover'))
|
||||
expect(dropTarget.value).toBe('deck')
|
||||
|
||||
// Create a dragleave event where related target is a child
|
||||
const parentDiv = document.createElement('div')
|
||||
const childDiv = document.createElement('div')
|
||||
parentDiv.appendChild(childDiv)
|
||||
|
||||
const leaveEvent = createMockDragEvent('dragleave')
|
||||
Object.defineProperty(leaveEvent, 'relatedTarget', { value: childDiv })
|
||||
Object.defineProperty(leaveEvent, 'currentTarget', { value: parentDiv })
|
||||
|
||||
dropHandlers.onDragleave(leaveEvent)
|
||||
|
||||
// Should NOT clear because we entered a child
|
||||
expect(dropTarget.value).toBe('deck')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dragover without active drag', () => {
|
||||
it('should handle dragover when no drag is active', () => {
|
||||
/**
|
||||
* Test that dragover without an active drag is handled gracefully.
|
||||
*
|
||||
* This can happen in edge cases like external drag sources or
|
||||
* race conditions. The handler should not crash or update state.
|
||||
*/
|
||||
const { createDropHandlers, dropTarget, isValidDrop } = useDragDrop()
|
||||
|
||||
const dropHandlers = createDropHandlers('deck', () => true, vi.fn())
|
||||
|
||||
// Trigger dragover without starting a drag first
|
||||
const event = createMockDragEvent('dragover')
|
||||
|
||||
// Should not throw
|
||||
expect(() => dropHandlers.onDragover(event)).not.toThrow()
|
||||
|
||||
// State should remain unchanged
|
||||
expect(dropTarget.value).toBeNull()
|
||||
expect(isValidDrop.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('touch edge cases', () => {
|
||||
it('should handle touchstart with no touches', () => {
|
||||
/**
|
||||
* Test that touchstart with empty touches array doesn't crash.
|
||||
*
|
||||
* This can occur in certain edge cases or on some devices.
|
||||
* The handler should return early without errors.
|
||||
*/
|
||||
const { createDragHandlers, isDragging } = useDragDrop()
|
||||
const card = createMockCard()
|
||||
const handlers = createDragHandlers(card, 'collection')
|
||||
|
||||
// Touch event with no touches
|
||||
const touchEvent = createMockTouchEvent('touchstart', [])
|
||||
|
||||
// Should not throw
|
||||
expect(() => handlers.onTouchstart(touchEvent)).not.toThrow()
|
||||
expect(isDragging.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle touchmove with no touches', () => {
|
||||
/**
|
||||
* Test that touchmove with empty touches array doesn't crash.
|
||||
*
|
||||
* Touch arrays can be empty during certain event sequences.
|
||||
* The handler should return early without errors.
|
||||
*/
|
||||
const { createDragHandlers } = useDragDrop()
|
||||
const card = createMockCard()
|
||||
const handlers = createDragHandlers(card, 'collection')
|
||||
|
||||
// Start a touch first
|
||||
handlers.onTouchstart(createMockTouchEvent('touchstart', [{ clientX: 100, clientY: 100 }]))
|
||||
|
||||
// Touchmove with no touches
|
||||
const touchEvent = createMockTouchEvent('touchmove', [])
|
||||
|
||||
// Should not throw
|
||||
expect(() => handlers.onTouchmove(touchEvent)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should clear long press timer if touchend occurs before timeout', () => {
|
||||
/**
|
||||
* Test that releasing touch before long-press completes cancels the drag.
|
||||
*
|
||||
* If the user taps quickly (less than 500ms), the drag should not
|
||||
* start. This ensures long-press is required for drag operations.
|
||||
*/
|
||||
const { createDragHandlers, isDragging } = useDragDrop()
|
||||
const card = createMockCard()
|
||||
const handlers = createDragHandlers(card, 'collection')
|
||||
|
||||
// Start touch
|
||||
handlers.onTouchstart(createMockTouchEvent('touchstart', [{ clientX: 100, clientY: 100 }]))
|
||||
|
||||
// End touch before timeout (at 200ms)
|
||||
vi.advanceTimersByTime(200)
|
||||
handlers.onTouchend(createMockTouchEvent('touchend'))
|
||||
|
||||
// Advance past when drag would have started
|
||||
vi.advanceTimersByTime(400)
|
||||
|
||||
// Should NOT be dragging
|
||||
expect(isDragging.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow small finger movement during long press', () => {
|
||||
/**
|
||||
* Test that tiny finger movements don't cancel long-press.
|
||||
*
|
||||
* Users' fingers naturally shift slightly while holding. Movement
|
||||
* under 10 pixels should not cancel the long-press detection.
|
||||
*/
|
||||
const { createDragHandlers, isDragging } = useDragDrop()
|
||||
const card = createMockCard()
|
||||
const handlers = createDragHandlers(card, 'collection')
|
||||
|
||||
// Start touch
|
||||
handlers.onTouchstart(createMockTouchEvent('touchstart', [{ clientX: 100, clientY: 100 }]))
|
||||
|
||||
// Small movement (within threshold)
|
||||
handlers.onTouchmove(createMockTouchEvent('touchmove', [{ clientX: 105, clientY: 103 }]))
|
||||
|
||||
// Wait for long press duration
|
||||
vi.advanceTimersByTime(500)
|
||||
|
||||
// Should still trigger drag because movement was within threshold
|
||||
expect(isDragging.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should call onDrop callback when touch released over valid drop zone', () => {
|
||||
/**
|
||||
* Test that touch drag and drop triggers the callback.
|
||||
*
|
||||
* When a user drags via long-press and releases over a valid drop
|
||||
* zone, the card should be added/removed as expected.
|
||||
*/
|
||||
const { createDragHandlers, createDropHandlers } = useDragDrop()
|
||||
const card = createMockCard()
|
||||
const onDropCallback = vi.fn()
|
||||
|
||||
// Register drop target
|
||||
createDropHandlers('deck', () => true, onDropCallback)
|
||||
|
||||
// Start long-press drag
|
||||
const handlers = createDragHandlers(card, 'collection')
|
||||
handlers.onTouchstart(createMockTouchEvent('touchstart', [{ clientX: 100, clientY: 100 }]))
|
||||
vi.advanceTimersByTime(500)
|
||||
|
||||
// Mock document.elementFromPoint to simulate being over drop zone
|
||||
const mockDropZone = document.createElement('div')
|
||||
mockDropZone.dataset.dropTarget = 'deck'
|
||||
const elementFromPointMock = vi.fn().mockReturnValue(mockDropZone)
|
||||
document.elementFromPoint = elementFromPointMock
|
||||
|
||||
// Move over drop zone
|
||||
handlers.onTouchmove(createMockTouchEvent('touchmove', [{ clientX: 200, clientY: 200 }]))
|
||||
|
||||
// Release
|
||||
handlers.onTouchend(createMockTouchEvent('touchend'))
|
||||
|
||||
expect(onDropCallback).toHaveBeenCalledWith(card)
|
||||
})
|
||||
|
||||
it('should not call onDrop callback when touch released outside drop zone', () => {
|
||||
/**
|
||||
* Test that touch release outside drop zones doesn't trigger callback.
|
||||
*
|
||||
* If the user drags via touch but releases in empty space, no
|
||||
* drop should occur.
|
||||
*/
|
||||
const { createDragHandlers, createDropHandlers } = useDragDrop()
|
||||
const card = createMockCard()
|
||||
const onDropCallback = vi.fn()
|
||||
|
||||
// Register drop target
|
||||
createDropHandlers('deck', () => true, onDropCallback)
|
||||
|
||||
// Start long-press drag
|
||||
const handlers = createDragHandlers(card, 'collection')
|
||||
handlers.onTouchstart(createMockTouchEvent('touchstart', [{ clientX: 100, clientY: 100 }]))
|
||||
vi.advanceTimersByTime(500)
|
||||
|
||||
// Mock document.elementFromPoint to simulate being over empty space
|
||||
const elementFromPointMock = vi.fn().mockReturnValue(document.createElement('div'))
|
||||
document.elementFromPoint = elementFromPointMock
|
||||
|
||||
// Move and release (not over a drop zone)
|
||||
handlers.onTouchmove(createMockTouchEvent('touchmove', [{ clientX: 200, clientY: 200 }]))
|
||||
handlers.onTouchend(createMockTouchEvent('touchend'))
|
||||
|
||||
expect(onDropCallback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call onDrop callback when touch released over invalid drop zone', () => {
|
||||
/**
|
||||
* Test that touch release over invalid drop zones doesn't trigger callback.
|
||||
*
|
||||
* If the drop zone's canDrop returns false (e.g., deck is full),
|
||||
* the touch release should not add the card.
|
||||
*/
|
||||
const { createDragHandlers, createDropHandlers } = useDragDrop()
|
||||
const card = createMockCard()
|
||||
const onDropCallback = vi.fn()
|
||||
|
||||
// Register drop target that rejects the card
|
||||
createDropHandlers('deck', () => false, onDropCallback)
|
||||
|
||||
// Start long-press drag
|
||||
const handlers = createDragHandlers(card, 'collection')
|
||||
handlers.onTouchstart(createMockTouchEvent('touchstart', [{ clientX: 100, clientY: 100 }]))
|
||||
vi.advanceTimersByTime(500)
|
||||
|
||||
// Mock document.elementFromPoint to simulate being over drop zone
|
||||
const mockDropZone = document.createElement('div')
|
||||
mockDropZone.dataset.dropTarget = 'deck'
|
||||
const elementFromPointMock = vi.fn().mockReturnValue(mockDropZone)
|
||||
document.elementFromPoint = elementFromPointMock
|
||||
|
||||
// Move over drop zone (will be marked invalid)
|
||||
handlers.onTouchmove(createMockTouchEvent('touchmove', [{ clientX: 200, clientY: 200 }]))
|
||||
|
||||
// Release
|
||||
handlers.onTouchend(createMockTouchEvent('touchend'))
|
||||
|
||||
// Should not call callback because drop was invalid
|
||||
expect(onDropCallback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should prevent scrolling during active touch drag', () => {
|
||||
/**
|
||||
* Test that touchmove prevents default during active drag.
|
||||
*
|
||||
* When dragging a card via touch, the page should not scroll.
|
||||
* This is essential for a good mobile drag experience.
|
||||
*/
|
||||
const { createDragHandlers } = useDragDrop()
|
||||
const card = createMockCard()
|
||||
const handlers = createDragHandlers(card, 'collection')
|
||||
|
||||
// Mock document.elementFromPoint (needed for touchmove during drag)
|
||||
const elementFromPointMock = vi.fn().mockReturnValue(document.createElement('div'))
|
||||
document.elementFromPoint = elementFromPointMock
|
||||
|
||||
// Start long-press drag
|
||||
handlers.onTouchstart(createMockTouchEvent('touchstart', [{ clientX: 100, clientY: 100 }]))
|
||||
vi.advanceTimersByTime(500)
|
||||
|
||||
// Touchmove during drag
|
||||
const moveEvent = createMockTouchEvent('touchmove', [{ clientX: 110, clientY: 110 }])
|
||||
const preventDefaultSpy = vi.spyOn(moveEvent, 'preventDefault')
|
||||
|
||||
handlers.onTouchmove(moveEvent)
|
||||
|
||||
expect(preventDefaultSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not prevent scrolling before long-press triggers', () => {
|
||||
/**
|
||||
* Test that touchmove allows default before drag starts.
|
||||
*
|
||||
* Before the long-press timeout completes, touchmove should not
|
||||
* prevent default so users can still scroll normally.
|
||||
*/
|
||||
const { createDragHandlers } = useDragDrop()
|
||||
const card = createMockCard()
|
||||
const handlers = createDragHandlers(card, 'collection')
|
||||
|
||||
// Start touch but don't wait for long-press
|
||||
handlers.onTouchstart(createMockTouchEvent('touchstart', [{ clientX: 100, clientY: 100 }]))
|
||||
|
||||
// Small movement (within threshold, so won't cancel)
|
||||
const moveEvent = createMockTouchEvent('touchmove', [{ clientX: 102, clientY: 102 }])
|
||||
const preventDefaultSpy = vi.spyOn(moveEvent, 'preventDefault')
|
||||
|
||||
handlers.onTouchmove(moveEvent)
|
||||
|
||||
// Should NOT prevent default because drag hasn't started yet
|
||||
expect(preventDefaultSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update drop target correctly during touch drag', () => {
|
||||
/**
|
||||
* Test that moving touch updates drop target state.
|
||||
*
|
||||
* As the user drags their finger across the screen, the current
|
||||
* drop target should update to provide real-time visual feedback.
|
||||
*/
|
||||
const { createDragHandlers, createDropHandlers, dropTarget, isValidDrop } = useDragDrop()
|
||||
const card = createMockCard()
|
||||
|
||||
// Register drop targets
|
||||
createDropHandlers('deck', () => true, vi.fn())
|
||||
createDropHandlers('collection', () => true, vi.fn())
|
||||
|
||||
// Start long-press drag
|
||||
const handlers = createDragHandlers(card, 'deck')
|
||||
handlers.onTouchstart(createMockTouchEvent('touchstart', [{ clientX: 100, clientY: 100 }]))
|
||||
vi.advanceTimersByTime(500)
|
||||
|
||||
// Move over deck drop zone
|
||||
const deckZone = document.createElement('div')
|
||||
deckZone.dataset.dropTarget = 'deck'
|
||||
const elementFromPointMock = vi.fn().mockReturnValue(deckZone)
|
||||
document.elementFromPoint = elementFromPointMock
|
||||
|
||||
handlers.onTouchmove(createMockTouchEvent('touchmove', [{ clientX: 200, clientY: 200 }]))
|
||||
expect(dropTarget.value).toBe('deck')
|
||||
expect(isValidDrop.value).toBe(true)
|
||||
|
||||
// Move over collection drop zone
|
||||
const collectionZone = document.createElement('div')
|
||||
collectionZone.dataset.dropTarget = 'collection'
|
||||
elementFromPointMock.mockReturnValue(collectionZone)
|
||||
|
||||
handlers.onTouchmove(createMockTouchEvent('touchmove', [{ clientX: 300, clientY: 300 }]))
|
||||
expect(dropTarget.value).toBe('collection')
|
||||
expect(isValidDrop.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('multiple drop targets', () => {
|
||||
it('should handle multiple registered drop targets correctly', () => {
|
||||
/**
|
||||
* Test that multiple drop zones can coexist.
|
||||
*
|
||||
* The deck builder has two drop zones (deck and collection).
|
||||
* Both should work independently without interfering with each other.
|
||||
*/
|
||||
const { createDragHandlers, createDropHandlers } = useDragDrop()
|
||||
const card = createMockCard()
|
||||
const deckCallback = vi.fn()
|
||||
const collectionCallback = vi.fn()
|
||||
|
||||
// Register both drop targets
|
||||
const deckHandlers = createDropHandlers('deck', () => true, deckCallback)
|
||||
const collectionHandlers = createDropHandlers('collection', () => true, collectionCallback)
|
||||
|
||||
// Start drag
|
||||
const dragHandlers = createDragHandlers(card, 'collection')
|
||||
dragHandlers.onDragstart(createMockDragEvent('dragstart'))
|
||||
|
||||
// Drop on deck
|
||||
deckHandlers.onDragover(createMockDragEvent('dragover'))
|
||||
deckHandlers.onDrop(createMockDragEvent('drop'))
|
||||
|
||||
// Only deck callback should be called
|
||||
expect(deckCallback).toHaveBeenCalledWith(card)
|
||||
expect(collectionCallback).not.toHaveBeenCalled()
|
||||
|
||||
// Reset
|
||||
deckCallback.mockClear()
|
||||
collectionCallback.mockClear()
|
||||
|
||||
// Start new drag
|
||||
dragHandlers.onDragstart(createMockDragEvent('dragstart'))
|
||||
|
||||
// Drop on collection
|
||||
collectionHandlers.onDragover(createMockDragEvent('dragover'))
|
||||
collectionHandlers.onDrop(createMockDragEvent('drop'))
|
||||
|
||||
// Only collection callback should be called
|
||||
expect(collectionCallback).toHaveBeenCalledWith(card)
|
||||
expect(deckCallback).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -5,9 +5,9 @@
|
||||
* with automatic cleanup and component-scoped listener tracking.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
import { defineComponent, h, nextTick } from 'vue'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
import { useGameBridge } from './useGameBridge'
|
||||
import { gameBridge } from '@/game/bridge'
|
||||
|
||||
@ -5,6 +5,7 @@ import { useGameSocket } from './useGameSocket'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useGameStore } from '@/stores/game'
|
||||
import { ConnectionStatus } from '@/types'
|
||||
import type { VisibleGameState } from '@/types/game'
|
||||
|
||||
// Mock socket.io-client - simplified mocking
|
||||
vi.mock('socket.io-client', () => ({
|
||||
@ -150,7 +151,7 @@ describe('useGameSocket', () => {
|
||||
|
||||
// Set up some state
|
||||
gameStore.currentGameId = 'game-123'
|
||||
gameStore.gameState = {} as any
|
||||
gameStore.gameState = {} as VisibleGameState
|
||||
|
||||
disconnect()
|
||||
|
||||
|
||||
@ -254,8 +254,8 @@ export function useGameSocket() {
|
||||
* Handle game state update.
|
||||
*/
|
||||
function handleGameState(message: unknown): void {
|
||||
const msg = message as { state: unknown; event_id: string }
|
||||
gameStore.setGameState(msg.state as any, msg.event_id)
|
||||
const msg = message as { state: VisibleGameState; event_id: string }
|
||||
gameStore.setGameState(msg.state, msg.event_id)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -312,8 +312,8 @@ export function useGameSocket() {
|
||||
* Handle game over notification.
|
||||
*/
|
||||
function handleGameOver(message: unknown): void {
|
||||
const msg = message as { final_state: unknown; event_id: string; winner_id: string | null }
|
||||
gameStore.setGameState(msg.final_state as any, msg.event_id)
|
||||
const msg = message as { final_state: VisibleGameState; event_id: string; winner_id: string | null }
|
||||
gameStore.setGameState(msg.final_state, msg.event_id)
|
||||
|
||||
const isWinner = msg.winner_id === gameStore.gameState?.viewer_id
|
||||
if (isWinner) {
|
||||
|
||||
@ -15,12 +15,9 @@ import {
|
||||
isZoneInBounds,
|
||||
getAllZones,
|
||||
getCardSize,
|
||||
CARD_WIDTH_MEDIUM,
|
||||
CARD_HEIGHT_MEDIUM,
|
||||
CARD_WIDTH_SMALL,
|
||||
BENCH_SIZE,
|
||||
PRIZE_SIZE,
|
||||
PORTRAIT_THRESHOLD,
|
||||
CARD_WIDTH_MEDIUM,
|
||||
} from './layout'
|
||||
import type { BoardLayout, ZonePosition } from '@/types/phaser'
|
||||
|
||||
@ -40,9 +37,6 @@ const LANDSCAPE_HEIGHT = 1080
|
||||
const PORTRAIT_WIDTH = 390
|
||||
const PORTRAIT_HEIGHT = 844
|
||||
|
||||
/** Square resolution (edge case) */
|
||||
const SQUARE_SIZE = 800
|
||||
|
||||
/** 4K resolution for scaling tests */
|
||||
const UHD_WIDTH = 3840
|
||||
const UHD_HEIGHT = 2160
|
||||
|
||||
1044
frontend/src/game/objects/Board.spec.ts
Normal file
1044
frontend/src/game/objects/Board.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -406,7 +406,7 @@ export class Board extends Phaser.GameObjects.Container {
|
||||
playerId: 'my' | 'opp' | 'both',
|
||||
enabled: boolean
|
||||
): void {
|
||||
for (const [key, zone] of this.zones) {
|
||||
for (const [, zone] of this.zones) {
|
||||
const matchesType = zone.zoneType === zoneType
|
||||
const matchesOwner =
|
||||
playerId === 'both' || zone.owner === playerId
|
||||
|
||||
618
frontend/src/game/objects/CardBack.spec.ts
Normal file
618
frontend/src/game/objects/CardBack.spec.ts
Normal file
@ -0,0 +1,618 @@
|
||||
/**
|
||||
* Tests for CardBack game object.
|
||||
*
|
||||
* Verifies card back rendering, sizing, and fallback behavior.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// Disabling explicit-any for test mocks - Phaser types are complex and mocking requires any
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
|
||||
import type { CardSize } from '@/types/phaser'
|
||||
|
||||
// =============================================================================
|
||||
// Mocks (must be defined before imports that use them)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Mock Phaser.GameObjects.Sprite
|
||||
*/
|
||||
class MockSprite {
|
||||
x: number
|
||||
y: number
|
||||
texture: string
|
||||
displayWidth: number = 0
|
||||
displayHeight: number = 0
|
||||
|
||||
constructor(x: number, y: number, texture: string) {
|
||||
this.x = x
|
||||
this.y = y
|
||||
this.texture = texture
|
||||
}
|
||||
|
||||
setDisplaySize(width: number, height: number): this {
|
||||
this.displayWidth = width
|
||||
this.displayHeight = height
|
||||
return this
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
// Mock destroy
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock Phaser.GameObjects.Graphics
|
||||
*/
|
||||
class MockGraphics {
|
||||
fillStyleCalls: Array<{ color: number; alpha: number }> = []
|
||||
fillRoundedRectCalls: Array<{ x: number; y: number; w: number; h: number; r: number }> = []
|
||||
lineStyleCalls: Array<{ width: number; color: number; alpha: number }> = []
|
||||
strokeRoundedRectCalls: Array<{ x: number; y: number; w: number; h: number; r: number }> = []
|
||||
lineBetweenCalls: Array<{ x1: number; y1: number; x2: number; y2: number }> = []
|
||||
fillCircleCalls: Array<{ x: number; y: number; r: number }> = []
|
||||
|
||||
clear(): this {
|
||||
this.fillStyleCalls = []
|
||||
this.fillRoundedRectCalls = []
|
||||
this.lineStyleCalls = []
|
||||
this.strokeRoundedRectCalls = []
|
||||
this.lineBetweenCalls = []
|
||||
this.fillCircleCalls = []
|
||||
return this
|
||||
}
|
||||
|
||||
fillStyle(color: number, alpha: number): this {
|
||||
this.fillStyleCalls.push({ color, alpha })
|
||||
return this
|
||||
}
|
||||
|
||||
fillRoundedRect(x: number, y: number, w: number, h: number, r: number): this {
|
||||
this.fillRoundedRectCalls.push({ x, y, w, h, r })
|
||||
return this
|
||||
}
|
||||
|
||||
lineStyle(width: number, color: number, alpha: number): this {
|
||||
this.lineStyleCalls.push({ width, color, alpha })
|
||||
return this
|
||||
}
|
||||
|
||||
strokeRoundedRect(x: number, y: number, w: number, h: number, r: number): this {
|
||||
this.strokeRoundedRectCalls.push({ x, y, w, h, r })
|
||||
return this
|
||||
}
|
||||
|
||||
lineBetween(x1: number, y1: number, x2: number, y2: number): this {
|
||||
this.lineBetweenCalls.push({ x1, y1, x2, y2 })
|
||||
return this
|
||||
}
|
||||
|
||||
fillCircle(x: number, y: number, r: number): this {
|
||||
this.fillCircleCalls.push({ x, y, r })
|
||||
return this
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
// Mock destroy
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock Phaser.Scene
|
||||
*/
|
||||
class MockScene {
|
||||
textures = {
|
||||
exists: vi.fn(),
|
||||
}
|
||||
|
||||
add = {
|
||||
existing: vi.fn(),
|
||||
sprite: vi.fn((x: number, y: number, texture: string) => new MockSprite(x, y, texture)),
|
||||
graphics: vi.fn(() => new MockGraphics()),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock the asset loader function
|
||||
*/
|
||||
vi.mock('../assets/loader', () => ({
|
||||
getCardBackTextureKey: vi.fn(() => 'card-back'),
|
||||
}))
|
||||
|
||||
/**
|
||||
* Mock Phaser module
|
||||
*
|
||||
* Note: We define the mock inline to avoid hoisting issues with class definitions
|
||||
*/
|
||||
vi.mock('phaser', () => {
|
||||
// Define MockContainer inline within the mock factory
|
||||
class MockContainerFactory {
|
||||
x: number
|
||||
y: number
|
||||
scene: any
|
||||
children: any[] = []
|
||||
|
||||
constructor(scene: any, x: number, y: number) {
|
||||
this.scene = scene
|
||||
this.x = x
|
||||
this.y = y
|
||||
}
|
||||
|
||||
add(child: any): this {
|
||||
this.children.push(child)
|
||||
return this
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
// Mock destroy
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
default: {
|
||||
GameObjects: {
|
||||
Container: MockContainerFactory,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// Import CardBack AFTER mocks are set up
|
||||
import { CardBack } from './CardBack'
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('CardBack', () => {
|
||||
let mockScene: MockScene
|
||||
|
||||
beforeEach(() => {
|
||||
mockScene = new MockScene()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('constructor', () => {
|
||||
it('creates a card back with sprite when texture exists', () => {
|
||||
/**
|
||||
* Test that CardBack uses sprite when texture is available.
|
||||
*
|
||||
* When the card back texture is loaded, CardBack should create
|
||||
* a sprite instead of fallback graphics.
|
||||
*/
|
||||
mockScene.textures.exists.mockReturnValue(true)
|
||||
|
||||
const cardBack = new CardBack(mockScene as any, 100, 200, 'medium')
|
||||
|
||||
expect(mockScene.textures.exists).toHaveBeenCalledWith('card-back')
|
||||
expect(mockScene.add.sprite).toHaveBeenCalledWith(0, 0, 'card-back')
|
||||
expect(mockScene.add.graphics).not.toHaveBeenCalled()
|
||||
expect(mockScene.add.existing).toHaveBeenCalledWith(cardBack)
|
||||
})
|
||||
|
||||
it('creates a card back with fallback graphics when texture missing', () => {
|
||||
/**
|
||||
* Test that CardBack falls back to graphics when texture unavailable.
|
||||
*
|
||||
* When the card back texture is not loaded, CardBack should create
|
||||
* fallback graphics to ensure card backs are always visible.
|
||||
*/
|
||||
mockScene.textures.exists.mockReturnValue(false)
|
||||
|
||||
const cardBack = new CardBack(mockScene as any, 100, 200, 'medium')
|
||||
|
||||
expect(mockScene.textures.exists).toHaveBeenCalledWith('card-back')
|
||||
expect(mockScene.add.sprite).not.toHaveBeenCalled()
|
||||
expect(mockScene.add.graphics).toHaveBeenCalled()
|
||||
expect(mockScene.add.existing).toHaveBeenCalledWith(cardBack)
|
||||
})
|
||||
|
||||
it('sets initial position correctly', () => {
|
||||
/**
|
||||
* Test that CardBack is positioned at constructor coordinates.
|
||||
*
|
||||
* The x,y coordinates passed to constructor should set the
|
||||
* container position correctly.
|
||||
*/
|
||||
mockScene.textures.exists.mockReturnValue(true)
|
||||
|
||||
const cardBack = new CardBack(mockScene as any, 150, 250, 'medium')
|
||||
|
||||
expect(cardBack.x).toBe(150)
|
||||
expect(cardBack.y).toBe(250)
|
||||
})
|
||||
|
||||
it('sets initial size correctly', () => {
|
||||
/**
|
||||
* Test that CardBack respects initial size parameter.
|
||||
*
|
||||
* The size parameter should be stored and applied to the
|
||||
* card back display.
|
||||
*/
|
||||
mockScene.textures.exists.mockReturnValue(true)
|
||||
|
||||
const cardBack = new CardBack(mockScene as any, 100, 200, 'large')
|
||||
|
||||
expect(cardBack.getCardSize()).toBe('large')
|
||||
})
|
||||
|
||||
it('defaults to medium size when not specified', () => {
|
||||
/**
|
||||
* Test that CardBack defaults to medium size.
|
||||
*
|
||||
* When no size is specified in constructor, medium should
|
||||
* be used as the default.
|
||||
*/
|
||||
mockScene.textures.exists.mockReturnValue(true)
|
||||
|
||||
const cardBack = new CardBack(mockScene as any, 100, 200)
|
||||
|
||||
expect(cardBack.getCardSize()).toBe('medium')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setCardSize', () => {
|
||||
it('updates sprite display size when using sprite', () => {
|
||||
/**
|
||||
* Test that setCardSize updates sprite dimensions.
|
||||
*
|
||||
* When using a sprite, changing the size should update
|
||||
* the sprite's display dimensions.
|
||||
*/
|
||||
mockScene.textures.exists.mockReturnValue(true)
|
||||
|
||||
const cardBack = new CardBack(mockScene as any, 100, 200, 'small')
|
||||
const sprite = (cardBack as any).sprite as MockSprite
|
||||
|
||||
cardBack.setCardSize('large')
|
||||
|
||||
// Large size is 150x210 from CARD_SIZES
|
||||
expect(sprite.displayWidth).toBe(150)
|
||||
expect(sprite.displayHeight).toBe(210)
|
||||
})
|
||||
|
||||
it('redraws fallback graphics when using graphics', () => {
|
||||
/**
|
||||
* Test that setCardSize redraws fallback graphics.
|
||||
*
|
||||
* When using fallback graphics, changing the size should
|
||||
* redraw the graphics at the new dimensions.
|
||||
*/
|
||||
mockScene.textures.exists.mockReturnValue(false)
|
||||
|
||||
const cardBack = new CardBack(mockScene as any, 100, 200, 'small')
|
||||
const graphics = (cardBack as any).fallbackGraphics as MockGraphics
|
||||
|
||||
// Clear the initial draw calls
|
||||
graphics.clear()
|
||||
graphics.fillStyleCalls = []
|
||||
graphics.fillRoundedRectCalls = []
|
||||
|
||||
cardBack.setCardSize('large')
|
||||
|
||||
// Should have redrawn with new size
|
||||
expect(graphics.fillStyleCalls.length).toBeGreaterThan(0)
|
||||
expect(graphics.fillRoundedRectCalls.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('updates current size', () => {
|
||||
/**
|
||||
* Test that setCardSize updates the stored size.
|
||||
*
|
||||
* The current size should be tracked and retrievable.
|
||||
*/
|
||||
mockScene.textures.exists.mockReturnValue(true)
|
||||
|
||||
const cardBack = new CardBack(mockScene as any, 100, 200, 'small')
|
||||
|
||||
cardBack.setCardSize('medium')
|
||||
|
||||
expect(cardBack.getCardSize()).toBe('medium')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setSize alias', () => {
|
||||
it('calls setCardSize internally', () => {
|
||||
/**
|
||||
* Test that setSize is an alias for setCardSize.
|
||||
*
|
||||
* The setSize method should delegate to setCardSize
|
||||
* for consistency with the example in the file comment.
|
||||
*/
|
||||
mockScene.textures.exists.mockReturnValue(true)
|
||||
|
||||
const cardBack = new CardBack(mockScene as any, 100, 200, 'small')
|
||||
|
||||
const result = cardBack.setSize('large')
|
||||
|
||||
expect(cardBack.getCardSize()).toBe('large')
|
||||
expect(result).toBe(cardBack) // Should return this for chaining
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDimensions', () => {
|
||||
it('returns correct dimensions for small size', () => {
|
||||
/**
|
||||
* Test that getDimensions returns correct small dimensions.
|
||||
*
|
||||
* Small cards should return 60x83 dimensions.
|
||||
*/
|
||||
mockScene.textures.exists.mockReturnValue(true)
|
||||
|
||||
const cardBack = new CardBack(mockScene as any, 100, 200, 'small')
|
||||
|
||||
const dimensions = cardBack.getDimensions()
|
||||
|
||||
expect(dimensions.width).toBe(60)
|
||||
expect(dimensions.height).toBe(84)
|
||||
})
|
||||
|
||||
it('returns correct dimensions for medium size', () => {
|
||||
/**
|
||||
* Test that getDimensions returns correct medium dimensions.
|
||||
*
|
||||
* Medium cards should return 100x140 dimensions.
|
||||
*/
|
||||
mockScene.textures.exists.mockReturnValue(true)
|
||||
|
||||
const cardBack = new CardBack(mockScene as any, 100, 200, 'medium')
|
||||
|
||||
const dimensions = cardBack.getDimensions()
|
||||
|
||||
expect(dimensions.width).toBe(100)
|
||||
expect(dimensions.height).toBe(140)
|
||||
})
|
||||
|
||||
it('returns correct dimensions for large size', () => {
|
||||
/**
|
||||
* Test that getDimensions returns correct large dimensions.
|
||||
*
|
||||
* Large cards should return 150x210 dimensions.
|
||||
*/
|
||||
mockScene.textures.exists.mockReturnValue(true)
|
||||
|
||||
const cardBack = new CardBack(mockScene as any, 100, 200, 'large')
|
||||
|
||||
const dimensions = cardBack.getDimensions()
|
||||
|
||||
expect(dimensions.width).toBe(150)
|
||||
expect(dimensions.height).toBe(210)
|
||||
})
|
||||
|
||||
it('returns updated dimensions after size change', () => {
|
||||
/**
|
||||
* Test that getDimensions reflects size changes.
|
||||
*
|
||||
* After changing size, getDimensions should return the
|
||||
* new dimensions.
|
||||
*/
|
||||
mockScene.textures.exists.mockReturnValue(true)
|
||||
|
||||
const cardBack = new CardBack(mockScene as any, 100, 200, 'small')
|
||||
|
||||
cardBack.setCardSize('large')
|
||||
const dimensions = cardBack.getDimensions()
|
||||
|
||||
expect(dimensions.width).toBe(150)
|
||||
expect(dimensions.height).toBe(210)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fallback graphics rendering', () => {
|
||||
it('draws card background with correct color', () => {
|
||||
/**
|
||||
* Test that fallback graphics use correct background color.
|
||||
*
|
||||
* The default background color should be used for the
|
||||
* card back rectangle.
|
||||
*/
|
||||
mockScene.textures.exists.mockReturnValue(false)
|
||||
|
||||
const cardBack = new CardBack(mockScene as any, 100, 200, 'medium')
|
||||
const graphics = (cardBack as any).fallbackGraphics as MockGraphics
|
||||
|
||||
const fillCalls = graphics.fillStyleCalls
|
||||
expect(fillCalls.length).toBeGreaterThan(0)
|
||||
// DEFAULT_BACK_COLOR = 0x2d3748
|
||||
expect(fillCalls[0].color).toBe(0x2d3748)
|
||||
})
|
||||
|
||||
it('draws rounded rectangle for card shape', () => {
|
||||
/**
|
||||
* Test that fallback graphics draw rounded rectangle.
|
||||
*
|
||||
* The card back should be a rounded rectangle with
|
||||
* appropriate corner radius.
|
||||
*/
|
||||
mockScene.textures.exists.mockReturnValue(false)
|
||||
|
||||
const cardBack = new CardBack(mockScene as any, 100, 200, 'medium')
|
||||
const graphics = (cardBack as any).fallbackGraphics as MockGraphics
|
||||
|
||||
expect(graphics.fillRoundedRectCalls.length).toBeGreaterThan(0)
|
||||
const rect = graphics.fillRoundedRectCalls[0]
|
||||
expect(rect.r).toBe(8) // CARD_CORNER_RADIUS
|
||||
})
|
||||
|
||||
it('draws border around card', () => {
|
||||
/**
|
||||
* Test that fallback graphics include a border.
|
||||
*
|
||||
* The card back should have a visible border stroke
|
||||
* around the rounded rectangle.
|
||||
*/
|
||||
mockScene.textures.exists.mockReturnValue(false)
|
||||
|
||||
const cardBack = new CardBack(mockScene as any, 100, 200, 'medium')
|
||||
const graphics = (cardBack as any).fallbackGraphics as MockGraphics
|
||||
|
||||
expect(graphics.strokeRoundedRectCalls.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('draws pattern lines on card back', () => {
|
||||
/**
|
||||
* Test that fallback graphics include pattern lines.
|
||||
*
|
||||
* The card back should have horizontal pattern lines
|
||||
* to make it visually distinct.
|
||||
*/
|
||||
mockScene.textures.exists.mockReturnValue(false)
|
||||
|
||||
const cardBack = new CardBack(mockScene as any, 100, 200, 'medium')
|
||||
const graphics = (cardBack as any).fallbackGraphics as MockGraphics
|
||||
|
||||
// Should have multiple pattern lines
|
||||
expect(graphics.lineBetweenCalls.length).toBeGreaterThan(5)
|
||||
})
|
||||
|
||||
it('draws center emblem circle', () => {
|
||||
/**
|
||||
* Test that fallback graphics include center emblem.
|
||||
*
|
||||
* The card back should have a center circle as a
|
||||
* placeholder emblem.
|
||||
*/
|
||||
mockScene.textures.exists.mockReturnValue(false)
|
||||
|
||||
const cardBack = new CardBack(mockScene as any, 100, 200, 'medium')
|
||||
const graphics = (cardBack as any).fallbackGraphics as MockGraphics
|
||||
|
||||
expect(graphics.fillCircleCalls.length).toBeGreaterThan(0)
|
||||
const circle = graphics.fillCircleCalls[0]
|
||||
expect(circle.x).toBe(0) // Centered
|
||||
expect(circle.y).toBe(0) // Centered
|
||||
})
|
||||
|
||||
it('centers graphics correctly for all sizes', () => {
|
||||
/**
|
||||
* Test that fallback graphics are centered regardless of size.
|
||||
*
|
||||
* The fallback graphics should be drawn centered at (0, 0)
|
||||
* relative to the container for all card sizes.
|
||||
*/
|
||||
mockScene.textures.exists.mockReturnValue(false)
|
||||
|
||||
const sizes: CardSize[] = ['small', 'medium', 'large']
|
||||
|
||||
sizes.forEach((size) => {
|
||||
const cardBack = new CardBack(mockScene as any, 100, 200, size)
|
||||
const graphics = (cardBack as any).fallbackGraphics as MockGraphics
|
||||
const rect = graphics.fillRoundedRectCalls[0]
|
||||
|
||||
// Should be centered (negative half width/height)
|
||||
expect(rect.x).toBeLessThan(0)
|
||||
expect(rect.y).toBeLessThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('destroy', () => {
|
||||
it('destroys sprite when using sprite', () => {
|
||||
/**
|
||||
* Test that destroy cleans up sprite.
|
||||
*
|
||||
* When CardBack is destroyed, the sprite should be
|
||||
* destroyed and the reference cleared.
|
||||
*/
|
||||
mockScene.textures.exists.mockReturnValue(true)
|
||||
|
||||
const cardBack = new CardBack(mockScene as any, 100, 200, 'medium')
|
||||
const sprite = (cardBack as any).sprite as MockSprite
|
||||
const destroySpy = vi.spyOn(sprite, 'destroy')
|
||||
|
||||
cardBack.destroy()
|
||||
|
||||
expect(destroySpy).toHaveBeenCalled()
|
||||
expect((cardBack as any).sprite).toBeNull()
|
||||
})
|
||||
|
||||
it('destroys graphics when using fallback', () => {
|
||||
/**
|
||||
* Test that destroy cleans up fallback graphics.
|
||||
*
|
||||
* When CardBack is destroyed, the fallback graphics should
|
||||
* be destroyed and the reference cleared.
|
||||
*/
|
||||
mockScene.textures.exists.mockReturnValue(false)
|
||||
|
||||
const cardBack = new CardBack(mockScene as any, 100, 200, 'medium')
|
||||
const graphics = (cardBack as any).fallbackGraphics as MockGraphics
|
||||
const destroySpy = vi.spyOn(graphics, 'destroy')
|
||||
|
||||
cardBack.destroy()
|
||||
|
||||
expect(destroySpy).toHaveBeenCalled()
|
||||
expect((cardBack as any).fallbackGraphics).toBeNull()
|
||||
})
|
||||
|
||||
it('handles destroy when sprite is already null', () => {
|
||||
/**
|
||||
* Test that destroy handles null sprite gracefully.
|
||||
*
|
||||
* Destroy should not error if sprite is already null.
|
||||
*/
|
||||
mockScene.textures.exists.mockReturnValue(true)
|
||||
|
||||
const cardBack = new CardBack(mockScene as any, 100, 200, 'medium')
|
||||
;(cardBack as any).sprite = null
|
||||
|
||||
expect(() => cardBack.destroy()).not.toThrow()
|
||||
})
|
||||
|
||||
it('handles destroy when graphics is already null', () => {
|
||||
/**
|
||||
* Test that destroy handles null graphics gracefully.
|
||||
*
|
||||
* Destroy should not error if fallback graphics is already null.
|
||||
*/
|
||||
mockScene.textures.exists.mockReturnValue(false)
|
||||
|
||||
const cardBack = new CardBack(mockScene as any, 100, 200, 'medium')
|
||||
;(cardBack as any).fallbackGraphics = null
|
||||
|
||||
expect(() => cardBack.destroy()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('integration', () => {
|
||||
it('can create, resize, and destroy a card back', () => {
|
||||
/**
|
||||
* Test full lifecycle of a CardBack.
|
||||
*
|
||||
* This integration test verifies that a CardBack can be
|
||||
* created, resized multiple times, and destroyed without errors.
|
||||
*/
|
||||
mockScene.textures.exists.mockReturnValue(true)
|
||||
|
||||
const cardBack = new CardBack(mockScene as any, 100, 200, 'small')
|
||||
|
||||
expect(cardBack.getCardSize()).toBe('small')
|
||||
|
||||
cardBack.setCardSize('medium')
|
||||
expect(cardBack.getCardSize()).toBe('medium')
|
||||
|
||||
cardBack.setCardSize('large')
|
||||
expect(cardBack.getCardSize()).toBe('large')
|
||||
|
||||
expect(() => cardBack.destroy()).not.toThrow()
|
||||
})
|
||||
|
||||
it('switches between sizes correctly with method chaining', () => {
|
||||
/**
|
||||
* Test method chaining with setSize.
|
||||
*
|
||||
* The setSize method should return this to allow
|
||||
* method chaining.
|
||||
*/
|
||||
mockScene.textures.exists.mockReturnValue(true)
|
||||
|
||||
const cardBack = new CardBack(mockScene as any, 100, 200, 'small')
|
||||
|
||||
const result = cardBack.setSize('medium').setSize('large')
|
||||
|
||||
expect(result).toBe(cardBack)
|
||||
expect(cardBack.getCardSize()).toBe('large')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -15,8 +15,7 @@ import Phaser from 'phaser'
|
||||
|
||||
import type { CardSize } from '@/types/phaser'
|
||||
import { CARD_SIZES } from '@/types/phaser'
|
||||
import { PLACEHOLDER_KEYS } from '../assets/manifest'
|
||||
import { getCardBackTextureKey, createPlaceholderTexture } from '../assets/loader'
|
||||
import { getCardBackTextureKey } from '../assets/loader'
|
||||
|
||||
// =============================================================================
|
||||
// Constants
|
||||
|
||||
@ -229,7 +229,7 @@ export class HandZone extends Zone {
|
||||
let angle = 0
|
||||
if (count > 1 && layout.totalAngle > 0) {
|
||||
const angleStep = layout.totalAngle / (count - 1)
|
||||
const angleRadians = Phaser.Math.DegToRad(layout.totalAngle / 2 - angleStep * index)
|
||||
const angleRadians = Phaser.Math.DegToRad(angleStep * index - layout.totalAngle / 2)
|
||||
angle = angleRadians
|
||||
}
|
||||
|
||||
|
||||
@ -19,7 +19,6 @@ import {
|
||||
DESIGN_WIDTH,
|
||||
DESIGN_HEIGHT,
|
||||
DESIGN_ASPECT_RATIO,
|
||||
type ScaleInfo,
|
||||
} from './scale'
|
||||
|
||||
describe('scale utilities', () => {
|
||||
|
||||
604
frontend/src/game/scenes/MatchScene.spec.ts
Normal file
604
frontend/src/game/scenes/MatchScene.spec.ts
Normal file
@ -0,0 +1,604 @@
|
||||
/**
|
||||
* Tests for MatchScene.
|
||||
*
|
||||
* These tests verify the main game scene handles initialization, state updates,
|
||||
* resizing, and cleanup correctly.
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
|
||||
import { createMockGameState } from '@/test/helpers/gameTestUtils'
|
||||
|
||||
// Mock Phaser before importing MatchScene
|
||||
vi.mock('phaser', () => ({
|
||||
default: {
|
||||
Scene: class {
|
||||
scene: any = { key: '' }
|
||||
add: any = {
|
||||
graphics: vi.fn().mockReturnValue({
|
||||
fillStyle: vi.fn().mockReturnThis(),
|
||||
fillRect: vi.fn().mockReturnThis(),
|
||||
clear: vi.fn().mockReturnThis(),
|
||||
}),
|
||||
container: vi.fn().mockReturnValue({
|
||||
add: vi.fn(),
|
||||
setPosition: vi.fn(),
|
||||
removeAll: vi.fn(),
|
||||
}),
|
||||
}
|
||||
cameras: any = {
|
||||
main: { width: 800, height: 600 },
|
||||
}
|
||||
scale: any = {
|
||||
resize: vi.fn(),
|
||||
}
|
||||
constructor(config: any) {
|
||||
this.scene = { key: config.key || '' }
|
||||
}
|
||||
init() {}
|
||||
create() {}
|
||||
update(_time: number, _delta: number) {}
|
||||
shutdown() {}
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock the gameBridge
|
||||
vi.mock('../bridge', () => ({
|
||||
gameBridge: {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock StateRenderer
|
||||
vi.mock('../sync/StateRenderer', () => ({
|
||||
StateRenderer: vi.fn().mockImplementation(() => ({
|
||||
render: vi.fn(),
|
||||
setHandManager: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
getPlayerZones: vi.fn().mockReturnValue(null),
|
||||
getBoard: vi.fn().mockReturnValue(null),
|
||||
})),
|
||||
}))
|
||||
|
||||
// Mock calculateLayout
|
||||
vi.mock('../layout', () => ({
|
||||
calculateLayout: vi.fn().mockReturnValue({
|
||||
boardWidth: 800,
|
||||
boardHeight: 600,
|
||||
scale: 1,
|
||||
zones: {},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock HandManager
|
||||
vi.mock('../interactions/HandManager', () => ({
|
||||
HandManager: vi.fn().mockImplementation(() => ({
|
||||
setLayout: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
// Import MatchScene after mocks are set up
|
||||
const { MatchScene, MATCH_SCENE_KEY } = await import('./MatchScene')
|
||||
const { gameBridge } = await import('../bridge')
|
||||
|
||||
describe('MatchScene', () => {
|
||||
let scene: MatchScene
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear all mocks before each test
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Create a new scene instance
|
||||
scene = new MatchScene()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up after each test
|
||||
if (scene && typeof scene.shutdown === 'function') {
|
||||
scene.shutdown()
|
||||
}
|
||||
})
|
||||
|
||||
describe('constructor', () => {
|
||||
it('initializes with correct scene key', () => {
|
||||
/**
|
||||
* Test scene key registration.
|
||||
*
|
||||
* The scene key is used by Phaser to identify and manage scenes.
|
||||
* It must match the expected constant.
|
||||
*/
|
||||
expect(scene.scene.key).toBe(MATCH_SCENE_KEY)
|
||||
})
|
||||
|
||||
it('has MatchScene as scene key constant', () => {
|
||||
/**
|
||||
* Test scene key constant value.
|
||||
*
|
||||
* Verify the exported constant matches expected value.
|
||||
*/
|
||||
expect(MATCH_SCENE_KEY).toBe('MatchScene')
|
||||
})
|
||||
})
|
||||
|
||||
describe('init', () => {
|
||||
it('resets state tracking', () => {
|
||||
/**
|
||||
* Test state initialization.
|
||||
*
|
||||
* The init() method should reset internal state to prepare
|
||||
* for a fresh scene start or restart.
|
||||
*/
|
||||
// Set some state
|
||||
;(scene as any).currentState = createMockGameState()
|
||||
;(scene as any).stateRenderer = { render: vi.fn() }
|
||||
|
||||
// Call init
|
||||
scene.init()
|
||||
|
||||
// Verify state was reset
|
||||
expect((scene as any).currentState).toBeNull()
|
||||
expect((scene as any).stateRenderer).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('create', () => {
|
||||
it('sets up board background', () => {
|
||||
/**
|
||||
* Test board background creation.
|
||||
*
|
||||
* The scene should create graphics for the board background
|
||||
* to provide a visual container for game elements.
|
||||
*/
|
||||
// Mock the add factory
|
||||
const addGraphicsSpy = vi.fn().mockReturnValue({
|
||||
fillStyle: vi.fn().mockReturnThis(),
|
||||
fillRect: vi.fn().mockReturnThis(),
|
||||
clear: vi.fn().mockReturnThis(),
|
||||
})
|
||||
scene.add.graphics = addGraphicsSpy
|
||||
|
||||
scene.create()
|
||||
|
||||
expect(addGraphicsSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates board container', () => {
|
||||
/**
|
||||
* Test board container creation.
|
||||
*
|
||||
* The board container holds all game objects and allows
|
||||
* them to be positioned and scaled together.
|
||||
*/
|
||||
const mockContainer = {
|
||||
add: vi.fn(),
|
||||
setPosition: vi.fn(),
|
||||
removeAll: vi.fn(),
|
||||
}
|
||||
const addContainerSpy = vi.fn().mockReturnValue(mockContainer)
|
||||
scene.add.container = addContainerSpy
|
||||
scene.add.graphics = vi.fn().mockReturnValue({
|
||||
fillStyle: vi.fn().mockReturnThis(),
|
||||
fillRect: vi.fn().mockReturnThis(),
|
||||
clear: vi.fn().mockReturnThis(),
|
||||
})
|
||||
|
||||
scene.create()
|
||||
|
||||
expect(addContainerSpy).toHaveBeenCalledWith(0, 0)
|
||||
})
|
||||
|
||||
it('creates StateRenderer instance', async () => {
|
||||
/**
|
||||
* Test StateRenderer initialization.
|
||||
*
|
||||
* StateRenderer synchronizes game state with Phaser rendering.
|
||||
* It should be created during scene setup.
|
||||
*/
|
||||
const { StateRenderer } = await vi.importMock('../sync/StateRenderer')
|
||||
|
||||
scene.create()
|
||||
|
||||
expect(StateRenderer).toHaveBeenCalledWith(scene)
|
||||
expect((scene as any).stateRenderer).toBeDefined()
|
||||
})
|
||||
|
||||
it('subscribes to bridge events', () => {
|
||||
/**
|
||||
* Test event subscription.
|
||||
*
|
||||
* The scene must subscribe to state updates and resize events
|
||||
* from the bridge to stay synchronized with Vue.
|
||||
*/
|
||||
scene.create()
|
||||
|
||||
expect(gameBridge.on).toHaveBeenCalledWith('state:updated', expect.any(Function))
|
||||
expect(gameBridge.on).toHaveBeenCalledWith('resize', expect.any(Function))
|
||||
})
|
||||
|
||||
it('emits ready event', () => {
|
||||
/**
|
||||
* Test ready event emission.
|
||||
*
|
||||
* After setup is complete, the scene should emit a ready event
|
||||
* to notify Vue that it can start sending game state.
|
||||
*/
|
||||
scene.create()
|
||||
|
||||
expect(gameBridge.emit).toHaveBeenCalledWith('ready', undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('update', () => {
|
||||
it('exists and can be called', () => {
|
||||
/**
|
||||
* Test update loop presence.
|
||||
*
|
||||
* The update method is called every frame by Phaser.
|
||||
* It should exist even if minimal.
|
||||
*/
|
||||
expect(typeof scene.update).toBe('function')
|
||||
expect(() => scene.update(0, 16)).not.toThrow()
|
||||
})
|
||||
|
||||
it('is intentionally minimal', () => {
|
||||
/**
|
||||
* Test update loop design.
|
||||
*
|
||||
* The scene uses event-driven updates rather than frame-based,
|
||||
* so update() should be minimal for performance.
|
||||
*/
|
||||
// This test documents the design decision
|
||||
// Most updates happen via events, not in update()
|
||||
scene.update(1000, 16)
|
||||
|
||||
// No side effects expected - update loop is intentionally empty
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shutdown', () => {
|
||||
it('unsubscribes from bridge events', () => {
|
||||
/**
|
||||
* Test event cleanup.
|
||||
*
|
||||
* When shutting down, the scene must remove all event listeners
|
||||
* to prevent memory leaks and errors.
|
||||
*/
|
||||
// Set up scene first
|
||||
scene.create()
|
||||
|
||||
// Clear previous calls
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Shutdown
|
||||
scene.shutdown()
|
||||
|
||||
expect(gameBridge.off).toHaveBeenCalledWith('state:updated', expect.any(Function))
|
||||
expect(gameBridge.off).toHaveBeenCalledWith('resize', expect.any(Function))
|
||||
})
|
||||
|
||||
it('clears bound handlers', () => {
|
||||
/**
|
||||
* Test handler cleanup.
|
||||
*
|
||||
* Bound handlers should be cleared to free memory.
|
||||
*/
|
||||
scene.create()
|
||||
expect((scene as any).boundHandlers).toHaveProperty('stateUpdated')
|
||||
|
||||
scene.shutdown()
|
||||
expect(Object.keys((scene as any).boundHandlers).length).toBe(0)
|
||||
})
|
||||
|
||||
it('can be called multiple times safely', () => {
|
||||
/**
|
||||
* Test idempotent shutdown.
|
||||
*
|
||||
* Multiple shutdown calls should not cause errors.
|
||||
* This can happen during rapid scene transitions.
|
||||
*/
|
||||
scene.create()
|
||||
|
||||
expect(() => {
|
||||
scene.shutdown()
|
||||
scene.shutdown()
|
||||
scene.shutdown()
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('state updates', () => {
|
||||
it('handles state update events', () => {
|
||||
/**
|
||||
* Test state update handling.
|
||||
*
|
||||
* When the bridge emits a state update, the scene should
|
||||
* store the new state and trigger rendering.
|
||||
*/
|
||||
scene.create()
|
||||
|
||||
// Get the state update handler
|
||||
const stateUpdateCall = (gameBridge.on as any).mock.calls.find(
|
||||
(call: any[]) => call[0] === 'state:updated'
|
||||
)
|
||||
expect(stateUpdateCall).toBeDefined()
|
||||
const stateUpdateHandler = stateUpdateCall[1]
|
||||
|
||||
const newState = createMockGameState()
|
||||
|
||||
// Call the handler
|
||||
stateUpdateHandler(newState)
|
||||
|
||||
// Verify state was stored
|
||||
expect((scene as any).currentState).toBe(newState)
|
||||
})
|
||||
|
||||
it('passes state to StateRenderer', async () => {
|
||||
/**
|
||||
* Test StateRenderer integration.
|
||||
*
|
||||
* State updates should be passed to StateRenderer for
|
||||
* synchronizing the visual representation.
|
||||
*/
|
||||
const mockRenderer = {
|
||||
render: vi.fn(),
|
||||
setHandManager: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
getPlayerZones: vi.fn().mockReturnValue(null),
|
||||
getBoard: vi.fn().mockReturnValue(null),
|
||||
}
|
||||
const { StateRenderer } = await vi.importMock('../sync/StateRenderer')
|
||||
;(StateRenderer as any).mockImplementationOnce(() => mockRenderer)
|
||||
|
||||
scene.create()
|
||||
|
||||
const stateUpdateCall = (gameBridge.on as any).mock.calls.find(
|
||||
(call: any[]) => call[0] === 'state:updated'
|
||||
)
|
||||
const stateUpdateHandler = stateUpdateCall[1]
|
||||
|
||||
const newState = createMockGameState()
|
||||
stateUpdateHandler(newState)
|
||||
|
||||
// Note: The actual rendering happens through private methods
|
||||
// This test verifies the state is stored and available
|
||||
expect((scene as any).currentState).toBe(newState)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resize handling', () => {
|
||||
it('handles resize events', () => {
|
||||
/**
|
||||
* Test resize event handling.
|
||||
*
|
||||
* When the viewport resizes, the scene should rescale
|
||||
* to fit the new dimensions.
|
||||
*/
|
||||
scene.create()
|
||||
|
||||
// Get the resize handler
|
||||
const resizeCall = (gameBridge.on as any).mock.calls.find(
|
||||
(call: any[]) => call[0] === 'resize'
|
||||
)
|
||||
expect(resizeCall).toBeDefined()
|
||||
const resizeHandler = resizeCall[1]
|
||||
|
||||
// Mock scale manager
|
||||
scene.scale.resize = vi.fn()
|
||||
|
||||
// Trigger resize
|
||||
resizeHandler({ width: 1920, height: 1080 })
|
||||
|
||||
expect(scene.scale.resize).toHaveBeenCalledWith(1920, 1080)
|
||||
})
|
||||
|
||||
it('recalculates layout on resize', async () => {
|
||||
/**
|
||||
* Test layout recalculation.
|
||||
*
|
||||
* Resizing should trigger layout recalculation to ensure
|
||||
* zones and cards are properly positioned.
|
||||
*/
|
||||
const { calculateLayout } = await vi.importMock('../layout')
|
||||
|
||||
scene.create()
|
||||
|
||||
const resizeCall = (gameBridge.on as any).mock.calls.find(
|
||||
(call: any[]) => call[0] === 'resize'
|
||||
)
|
||||
const resizeHandler = resizeCall[1]
|
||||
|
||||
vi.clearAllMocks()
|
||||
|
||||
resizeHandler({ width: 1024, height: 768 })
|
||||
|
||||
expect(calculateLayout).toHaveBeenCalledWith(1024, 768)
|
||||
})
|
||||
})
|
||||
|
||||
describe('event subscription lifecycle', () => {
|
||||
it('stores bound handlers for later removal', () => {
|
||||
/**
|
||||
* Test handler binding.
|
||||
*
|
||||
* Event handlers must be bound to the scene instance
|
||||
* so they can be properly removed later.
|
||||
*/
|
||||
scene.create()
|
||||
|
||||
expect((scene as any).boundHandlers.stateUpdated).toBeDefined()
|
||||
expect((scene as any).boundHandlers.resize).toBeDefined()
|
||||
expect(typeof (scene as any).boundHandlers.stateUpdated).toBe('function')
|
||||
expect(typeof (scene as any).boundHandlers.resize).toBe('function')
|
||||
})
|
||||
|
||||
it('removes correct handlers on unsubscribe', () => {
|
||||
/**
|
||||
* Test handler removal.
|
||||
*
|
||||
* Unsubscribing should remove the exact same function
|
||||
* references that were subscribed.
|
||||
*/
|
||||
scene.create()
|
||||
|
||||
const boundStateHandler = (scene as any).boundHandlers.stateUpdated
|
||||
const boundResizeHandler = (scene as any).boundHandlers.resize
|
||||
|
||||
scene.shutdown()
|
||||
|
||||
expect(gameBridge.off).toHaveBeenCalledWith('state:updated', boundStateHandler)
|
||||
expect(gameBridge.off).toHaveBeenCalledWith('resize', boundResizeHandler)
|
||||
})
|
||||
|
||||
it('handles shutdown when handlers are missing', () => {
|
||||
/**
|
||||
* Test defensive shutdown.
|
||||
*
|
||||
* Shutdown should not error if handlers were never created,
|
||||
* which can happen if create() was never called.
|
||||
*/
|
||||
// Don't call create()
|
||||
expect(() => scene.shutdown()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('integration', () => {
|
||||
it('completes full lifecycle without errors', () => {
|
||||
/**
|
||||
* Test complete scene lifecycle.
|
||||
*
|
||||
* A complete init -> create -> update -> shutdown cycle
|
||||
* should work without errors.
|
||||
*/
|
||||
expect(() => {
|
||||
scene.init()
|
||||
scene.create()
|
||||
scene.update(0, 16)
|
||||
scene.update(16, 16)
|
||||
scene.shutdown()
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('can handle state updates after creation', () => {
|
||||
/**
|
||||
* Test state updates after initialization.
|
||||
*
|
||||
* State updates should work correctly after the scene
|
||||
* has been fully initialized.
|
||||
*/
|
||||
scene.create()
|
||||
|
||||
const stateUpdateCall = (gameBridge.on as any).mock.calls.find(
|
||||
(call: any[]) => call[0] === 'state:updated'
|
||||
)
|
||||
const stateUpdateHandler = stateUpdateCall[1]
|
||||
|
||||
const state1 = createMockGameState({ turn_number: 1 })
|
||||
const state2 = createMockGameState({ turn_number: 2 })
|
||||
|
||||
expect(() => {
|
||||
stateUpdateHandler(state1)
|
||||
stateUpdateHandler(state2)
|
||||
}).not.toThrow()
|
||||
|
||||
expect((scene as any).currentState.turn_number).toBe(2)
|
||||
})
|
||||
|
||||
it('can handle resize events after creation', () => {
|
||||
/**
|
||||
* Test resize handling after initialization.
|
||||
*
|
||||
* Resize events should work correctly after the scene
|
||||
* has been fully initialized.
|
||||
*/
|
||||
scene.create()
|
||||
|
||||
const resizeCall = (gameBridge.on as any).mock.calls.find(
|
||||
(call: any[]) => call[0] === 'resize'
|
||||
)
|
||||
const resizeHandler = resizeCall[1]
|
||||
|
||||
expect(() => {
|
||||
resizeHandler({ width: 800, height: 600 })
|
||||
resizeHandler({ width: 1024, height: 768 })
|
||||
resizeHandler({ width: 1920, height: 1080 })
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles rapid init/shutdown cycles', () => {
|
||||
/**
|
||||
* Test rapid lifecycle changes.
|
||||
*
|
||||
* Rapid scene transitions should not cause errors or leaks.
|
||||
*/
|
||||
expect(() => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
scene.init()
|
||||
scene.create()
|
||||
scene.shutdown()
|
||||
}
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('handles state updates before create', () => {
|
||||
/**
|
||||
* Test early state updates.
|
||||
*
|
||||
* If somehow a state update arrives before create() is called,
|
||||
* it should not crash (though this shouldn't happen in practice).
|
||||
*/
|
||||
const stateUpdateCall = (gameBridge.on as any).mock.calls.find(
|
||||
(call: any[]) => call[0] === 'state:updated'
|
||||
)
|
||||
|
||||
// If no handler registered yet, this test is N/A
|
||||
if (!stateUpdateCall) {
|
||||
expect(true).toBe(true)
|
||||
return
|
||||
}
|
||||
|
||||
const stateUpdateHandler = stateUpdateCall[1]
|
||||
const state = createMockGameState()
|
||||
|
||||
// This shouldn't crash even if create() wasn't called
|
||||
expect(() => stateUpdateHandler(state)).not.toThrow()
|
||||
})
|
||||
|
||||
it('handles very large game states', () => {
|
||||
/**
|
||||
* Test large state handling.
|
||||
*
|
||||
* Scenes should handle game states with many cards without issues.
|
||||
*/
|
||||
scene.create()
|
||||
|
||||
const stateUpdateCall = (gameBridge.on as any).mock.calls.find(
|
||||
(call: any[]) => call[0] === 'state:updated'
|
||||
)
|
||||
const stateUpdateHandler = stateUpdateCall[1]
|
||||
|
||||
const largeState = createMockGameState({
|
||||
card_registry: {},
|
||||
})
|
||||
|
||||
// Add 100 cards to registry
|
||||
for (let i = 0; i < 100; i++) {
|
||||
largeState.card_registry[`card-${i}`] = {
|
||||
id: `card-${i}`,
|
||||
name: `Card ${i}`,
|
||||
card_type: 'pokemon',
|
||||
} as any
|
||||
}
|
||||
|
||||
expect(() => stateUpdateHandler(largeState)).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -19,7 +19,7 @@ import Phaser from 'phaser'
|
||||
import { gameBridge, type ResizeEvent } from '../bridge'
|
||||
import type { VisibleGameState } from '@/types/game'
|
||||
import { StateRenderer } from '../sync/StateRenderer'
|
||||
import { Board, createBoard } from '../objects/Board'
|
||||
import type { Board } from '../objects/Board'
|
||||
import { calculateLayout } from '../layout'
|
||||
import { HandManager } from '../interactions/HandManager'
|
||||
|
||||
@ -80,8 +80,9 @@ export class MatchScene extends Phaser.Scene {
|
||||
|
||||
/**
|
||||
* Board game object for zone background rendering.
|
||||
* Created by StateRenderer once we have the initial game state with rules_config.
|
||||
*/
|
||||
private board?: Board
|
||||
private board: Board | null = null
|
||||
|
||||
/**
|
||||
* Track bound event handlers for cleanup
|
||||
@ -171,8 +172,13 @@ export class MatchScene extends Phaser.Scene {
|
||||
/**
|
||||
* Set up the initial board layout.
|
||||
*
|
||||
* Creates the board container, background, Board object, and zone placeholders.
|
||||
* The actual card positions are updated when state is received.
|
||||
* Creates the board container and background graphics. The Board object
|
||||
* itself is created by StateRenderer once we have the initial game state
|
||||
* with rules_config, ensuring zone rectangles match the actual game rules
|
||||
* (prize cards vs points, bench size, energy deck enabled, etc.).
|
||||
*
|
||||
* This defers Board creation to avoid rendering incorrect zone layouts
|
||||
* when use_prize_cards is false (Mantimon TCG mode).
|
||||
*/
|
||||
private setupBoard(): void {
|
||||
const { width, height } = this.cameras.main
|
||||
@ -184,17 +190,37 @@ export class MatchScene extends Phaser.Scene {
|
||||
// Create main board container
|
||||
this.boardContainer = this.add.container(0, 0)
|
||||
|
||||
// Create Board object for zone background rendering
|
||||
const layout = calculateLayout(width, height)
|
||||
this.board = createBoard(this, layout)
|
||||
this.boardContainer.add(this.board)
|
||||
|
||||
// Note: Board will be created by StateRenderer when first state arrives
|
||||
// This ensures we have correct rules_config before rendering zone rectangles
|
||||
|
||||
// Draw debug zone outlines if enabled
|
||||
if (DEBUG_ZONES) {
|
||||
this.drawDebugZones()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the Board instance after it's created by StateRenderer.
|
||||
*
|
||||
* The board is created once we have the initial game state with rules_config,
|
||||
* ensuring zone rectangles match the actual game rules (prize cards vs points,
|
||||
* bench size, energy deck enabled, etc.).
|
||||
*
|
||||
* @param board - The Board instance to use
|
||||
*/
|
||||
setBoard(board: Board): void {
|
||||
if (this.board) {
|
||||
console.warn('[MatchScene] Board already exists, replacing')
|
||||
this.board.destroy()
|
||||
}
|
||||
|
||||
this.board = board
|
||||
|
||||
if (this.boardContainer) {
|
||||
this.boardContainer.add(board)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the board background.
|
||||
*
|
||||
@ -453,7 +479,7 @@ export class MatchScene extends Phaser.Scene {
|
||||
// Destroy board object
|
||||
if (this.board) {
|
||||
this.board.destroy()
|
||||
this.board = undefined
|
||||
this.board = null
|
||||
}
|
||||
|
||||
// Clear board container children (but keep container)
|
||||
|
||||
@ -13,13 +13,15 @@
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const renderer = new StateRenderer(scene)
|
||||
* const renderer = new StateRenderer(matchScene) // Now type-safe with MatchScene
|
||||
* renderer.render(gameState)
|
||||
* ```
|
||||
*/
|
||||
|
||||
import Phaser from 'phaser'
|
||||
|
||||
import type { MatchScene } from '../scenes/MatchScene'
|
||||
|
||||
import type {
|
||||
VisibleGameState,
|
||||
VisiblePlayerState,
|
||||
@ -37,6 +39,11 @@ import { HandZone } from '../objects/HandZone'
|
||||
import { PileZone } from '../objects/PileZone'
|
||||
import { PrizeZone } from '../objects/PrizeZone'
|
||||
import { Zone } from '../objects/Zone'
|
||||
import { Board, createBoard } from '../objects/Board'
|
||||
import { gameBridge } from '../bridge'
|
||||
|
||||
// Debug logging flag - only logs in development mode
|
||||
const DEBUG_RENDERER = import.meta.env.DEV
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
@ -78,8 +85,8 @@ export class StateRenderer {
|
||||
// Properties
|
||||
// ===========================================================================
|
||||
|
||||
/** The Phaser scene to render to */
|
||||
private scene: Phaser.Scene
|
||||
/** The MatchScene to render to */
|
||||
private scene: MatchScene
|
||||
|
||||
/** Map of card instance_id to Card game object */
|
||||
private cardMap: Map<string, Card> = new Map()
|
||||
@ -96,6 +103,9 @@ export class StateRenderer {
|
||||
/** Container for all game objects */
|
||||
private container: Phaser.GameObjects.Container | null = null
|
||||
|
||||
/** The Board instance for zone rectangle rendering */
|
||||
private board: Board | null = null
|
||||
|
||||
// ===========================================================================
|
||||
// Constructor
|
||||
// ===========================================================================
|
||||
@ -103,9 +113,9 @@ export class StateRenderer {
|
||||
/**
|
||||
* Create a new StateRenderer.
|
||||
*
|
||||
* @param scene - The Phaser scene to render to
|
||||
* @param scene - The MatchScene to render to (required for Board creation)
|
||||
*/
|
||||
constructor(scene: Phaser.Scene) {
|
||||
constructor(scene: MatchScene) {
|
||||
this.scene = scene
|
||||
}
|
||||
|
||||
@ -139,6 +149,31 @@ export class StateRenderer {
|
||||
// Calculate layout
|
||||
this.layout = calculateLayout(width, height, layoutOptions)
|
||||
|
||||
// Create Board on first render (now that we have correct rules_config)
|
||||
if (!this.board && this.layout) {
|
||||
try {
|
||||
if (DEBUG_RENDERER) {
|
||||
console.log('[StateRenderer] Creating board with layout options:', layoutOptions)
|
||||
}
|
||||
this.board = createBoard(this.scene, this.layout)
|
||||
this.scene.setBoard(this.board)
|
||||
if (DEBUG_RENDERER) {
|
||||
console.log('[StateRenderer] ✓ Board created successfully')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[StateRenderer] ✗ Failed to create board:', error)
|
||||
|
||||
// Emit fatal error to Vue layer
|
||||
gameBridge.emit('fatal-error', {
|
||||
message: 'Failed to initialize game board',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
context: 'StateRenderer.render()'
|
||||
})
|
||||
|
||||
return // Stop rendering
|
||||
}
|
||||
}
|
||||
|
||||
// Create zones if needed
|
||||
if (!this.zones) {
|
||||
this.createZones(state)
|
||||
@ -226,7 +261,7 @@ export class StateRenderer {
|
||||
*/
|
||||
clear(): void {
|
||||
// Destroy all cards
|
||||
for (const [_, card] of this.cardMap) {
|
||||
for (const [, card] of this.cardMap) {
|
||||
card.destroy()
|
||||
}
|
||||
this.cardMap.clear()
|
||||
@ -238,6 +273,15 @@ export class StateRenderer {
|
||||
this.zones = null
|
||||
}
|
||||
|
||||
// Destroy board
|
||||
if (this.board) {
|
||||
this.board.destroy()
|
||||
this.board = null
|
||||
if (DEBUG_RENDERER) {
|
||||
console.log('[StateRenderer] Board destroyed')
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy container
|
||||
if (this.container) {
|
||||
this.container.destroy()
|
||||
@ -269,6 +313,15 @@ export class StateRenderer {
|
||||
// Check if we should create optional zones
|
||||
const usePrizeCards = state.rules_config?.prizes.use_prize_cards ?? false
|
||||
const energyDeckEnabled = state.rules_config?.deck.energy_deck_enabled ?? true
|
||||
const benchSize = state.rules_config?.bench.max_size ?? 5
|
||||
|
||||
if (DEBUG_RENDERER) {
|
||||
console.log('[StateRenderer] Creating zones with rules:', {
|
||||
usePrizeCards,
|
||||
energyDeckEnabled,
|
||||
benchSize
|
||||
})
|
||||
}
|
||||
|
||||
// Create main container
|
||||
this.container = this.scene.add.container(0, 0)
|
||||
|
||||
117
frontend/src/pages/CampaignPage.spec.ts
Normal file
117
frontend/src/pages/CampaignPage.spec.ts
Normal file
@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Tests for CampaignPage.
|
||||
*
|
||||
* These tests verify the campaign page placeholder displays correctly
|
||||
* (Phase F2 feature).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
import CampaignPage from './CampaignPage.vue'
|
||||
|
||||
describe('CampaignPage', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders page title', () => {
|
||||
/**
|
||||
* Test that the campaign title is displayed.
|
||||
*
|
||||
* Users should see a clear page heading.
|
||||
*/
|
||||
const wrapper = mount(CampaignPage)
|
||||
|
||||
expect(wrapper.text()).toContain('Campaign')
|
||||
})
|
||||
|
||||
it('shows coming soon message', () => {
|
||||
/**
|
||||
* Test placeholder content.
|
||||
*
|
||||
* Users should know this feature is coming in a future phase.
|
||||
*/
|
||||
const wrapper = mount(CampaignPage)
|
||||
|
||||
expect(wrapper.text()).toContain('Campaign mode coming in Phase F2')
|
||||
})
|
||||
|
||||
it('shows feature description', () => {
|
||||
/**
|
||||
* Test that users can see what campaign mode will offer.
|
||||
*
|
||||
* Even though it's not implemented yet, the description helps
|
||||
* users understand what to expect.
|
||||
*/
|
||||
const wrapper = mount(CampaignPage)
|
||||
|
||||
expect(wrapper.text()).toContain('Challenge NPCs')
|
||||
expect(wrapper.text()).toContain('defeat Club Leaders')
|
||||
expect(wrapper.text()).toContain('become Champion')
|
||||
})
|
||||
})
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('has main heading as h1', () => {
|
||||
/**
|
||||
* Test heading hierarchy.
|
||||
*
|
||||
* The page should have a proper h1 for screen readers.
|
||||
*/
|
||||
const wrapper = mount(CampaignPage)
|
||||
|
||||
const h1 = wrapper.find('h1')
|
||||
expect(h1.exists()).toBe(true)
|
||||
expect(h1.text()).toContain('Campaign')
|
||||
})
|
||||
})
|
||||
|
||||
describe('layout', () => {
|
||||
it('has container for proper spacing', () => {
|
||||
/**
|
||||
* Test layout structure.
|
||||
*
|
||||
* The page should have a container for consistent margins.
|
||||
*/
|
||||
const wrapper = mount(CampaignPage)
|
||||
|
||||
const container = wrapper.find('.container')
|
||||
expect(container.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('has centered content card', () => {
|
||||
/**
|
||||
* Test content presentation.
|
||||
*
|
||||
* The placeholder message should be in a styled card.
|
||||
*/
|
||||
const wrapper = mount(CampaignPage)
|
||||
|
||||
const card = wrapper.find('.bg-surface')
|
||||
expect(card.exists()).toBe(true)
|
||||
expect(card.classes()).toContain('rounded-lg')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('mounts without errors', () => {
|
||||
/**
|
||||
* Test basic mounting.
|
||||
*
|
||||
* The component should mount successfully without throwing
|
||||
* errors.
|
||||
*/
|
||||
expect(() => mount(CampaignPage)).not.toThrow()
|
||||
})
|
||||
|
||||
it('renders even with no props or state', () => {
|
||||
/**
|
||||
* Test stateless rendering.
|
||||
*
|
||||
* Since this is a simple placeholder, it should render
|
||||
* without any external data.
|
||||
*/
|
||||
const wrapper = mount(CampaignPage)
|
||||
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
expect(wrapper.text()).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -20,6 +20,7 @@ import AttackMenu from '@/components/game/AttackMenu.vue'
|
||||
import { useGameStore } from '@/stores/game'
|
||||
import { useGameSocket } from '@/composables/useGameSocket'
|
||||
import { useGameBridge } from '@/composables/useGameBridge'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { ConnectionStatus } from '@/types'
|
||||
import type Phaser from 'phaser'
|
||||
|
||||
@ -32,6 +33,7 @@ const gameId = computed(() => route.params.id as string)
|
||||
const gameStore = useGameStore()
|
||||
const gameSocket = useGameSocket()
|
||||
const { emit: emitToBridge } = useGameBridge()
|
||||
const toast = useToast()
|
||||
|
||||
// Component refs
|
||||
const phaserGameRef = ref<InstanceType<typeof PhaserGame> | null>(null)
|
||||
@ -42,6 +44,7 @@ const isLoading = ref(true)
|
||||
const showExitConfirm = ref(false)
|
||||
const errorMessage = ref<string | null>(null)
|
||||
const showAttackMenu = ref(false)
|
||||
const fatalErrorMessage = ref<string | null>(null)
|
||||
|
||||
// Computed states from game store
|
||||
const isReconnecting = computed(() => gameStore.connectionStatus === ConnectionStatus.RECONNECTING)
|
||||
@ -140,6 +143,7 @@ async function resignGame(): Promise<void> {
|
||||
}, 500)
|
||||
} catch (error) {
|
||||
console.error('[GamePage] Failed to resign:', error)
|
||||
toast.warning('Could not confirm resignation with server. Leaving game anyway.')
|
||||
// Still exit even if resign fails
|
||||
showExitConfirm.value = false
|
||||
cleanup()
|
||||
@ -147,6 +151,15 @@ async function resignGame(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle fatal error - return to play menu.
|
||||
*/
|
||||
function handleFatalErrorReturn(): void {
|
||||
fatalErrorMessage.value = null
|
||||
cleanup()
|
||||
router.push({ name: 'PlayMenu' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the attack menu.
|
||||
*
|
||||
@ -253,6 +266,20 @@ onMounted(() => {
|
||||
on('attack:request', () => {
|
||||
openAttackMenu()
|
||||
})
|
||||
|
||||
// Handle fatal errors from Phaser
|
||||
on('fatal-error', (data: { message: string; error: string; context?: string }) => {
|
||||
console.error('[GamePage] Fatal error from Phaser:', data)
|
||||
|
||||
// Show error toast
|
||||
toast.error(`${data.message}: ${data.error}`, 0)
|
||||
|
||||
// Set error message for overlay
|
||||
fatalErrorMessage.value = data.message
|
||||
errorMessage.value = data.message
|
||||
|
||||
// No auto-redirect - user must manually click button
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@ -356,6 +383,63 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Fatal Error Overlay -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="fatalErrorMessage"
|
||||
class="
|
||||
absolute inset-0 z-50
|
||||
bg-background/95 backdrop-blur-sm
|
||||
flex flex-col items-center justify-center
|
||||
text-foreground
|
||||
"
|
||||
data-testid="fatal-error-overlay"
|
||||
>
|
||||
<div class="text-error mb-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-16 h-16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
/>
|
||||
<line
|
||||
x1="12"
|
||||
y1="8"
|
||||
x2="12"
|
||||
y2="12"
|
||||
/>
|
||||
<line
|
||||
x1="12"
|
||||
y1="16"
|
||||
x2="12.01"
|
||||
y2="16"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-xl font-semibold text-error mb-2">
|
||||
{{ fatalErrorMessage }}
|
||||
</p>
|
||||
<p class="text-sm text-muted mb-6">
|
||||
The game cannot continue. Please return to the menu.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="px-6 py-3 rounded bg-primary hover:bg-primary-dark text-white transition-colors"
|
||||
data-testid="fatal-error-return-button"
|
||||
@click="handleFatalErrorReturn"
|
||||
>
|
||||
Return to Menu
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Connection Status Overlay -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
|
||||
467
frontend/src/pages/HomePage.spec.ts
Normal file
467
frontend/src/pages/HomePage.spec.ts
Normal file
@ -0,0 +1,467 @@
|
||||
/**
|
||||
* Tests for HomePage.
|
||||
*
|
||||
* These tests verify the home page displays correctly for both
|
||||
* authenticated and unauthenticated users with proper navigation.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
import HomePage from './HomePage.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
// Stub RouterLink that preserves slot content
|
||||
const RouterLinkStub = {
|
||||
name: 'RouterLink',
|
||||
template: '<a><slot /></a>',
|
||||
props: ['to'],
|
||||
}
|
||||
|
||||
describe('HomePage', () => {
|
||||
beforeEach(() => {
|
||||
// Create fresh Pinia instance for each test
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders page title', () => {
|
||||
/**
|
||||
* Test that the main title is displayed.
|
||||
*
|
||||
* The home page should clearly show the game name.
|
||||
*/
|
||||
const wrapper = mount(HomePage, {
|
||||
global: {
|
||||
stubs: { RouterLink: RouterLinkStub },
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Mantimon TCG')
|
||||
})
|
||||
|
||||
it('renders tagline', () => {
|
||||
/**
|
||||
* Test that the description is displayed.
|
||||
*
|
||||
* The tagline helps users understand what the game is about.
|
||||
*/
|
||||
const wrapper = mount(HomePage, {
|
||||
global: {
|
||||
stubs: { RouterLink: RouterLinkStub },
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('single-player trading card game')
|
||||
})
|
||||
|
||||
it('renders feature cards', () => {
|
||||
/**
|
||||
* Test that feature highlights are displayed.
|
||||
*
|
||||
* The three feature cards (Campaign, Build Deck, PvP) help
|
||||
* users understand what they can do in the game.
|
||||
*/
|
||||
const wrapper = mount(HomePage, {
|
||||
global: {
|
||||
stubs: { RouterLink: RouterLinkStub },
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Campaign Mode')
|
||||
expect(wrapper.text()).toContain('Build Your Deck')
|
||||
expect(wrapper.text()).toContain('PvP Battles')
|
||||
})
|
||||
})
|
||||
|
||||
describe('unauthenticated state', () => {
|
||||
it('shows "Start Your Journey" button when not authenticated', () => {
|
||||
/**
|
||||
* Test unauthenticated user call-to-action.
|
||||
*
|
||||
* New users should see a button to start playing.
|
||||
*/
|
||||
// Get store and set state BEFORE mounting
|
||||
const authStore = useAuthStore()
|
||||
authStore.$patch({
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
user: null
|
||||
})
|
||||
|
||||
const wrapper = mount(HomePage, {
|
||||
global: {
|
||||
stubs: { RouterLink: RouterLinkStub },
|
||||
},
|
||||
})
|
||||
|
||||
// Verify store state
|
||||
expect(authStore.isAuthenticated).toBe(false)
|
||||
expect(wrapper.text()).toContain('Start Your Journey')
|
||||
})
|
||||
|
||||
it('shows "Continue Adventure" button when not authenticated', () => {
|
||||
/**
|
||||
* Test returning user login button.
|
||||
*
|
||||
* Users who already have an account should see a login option.
|
||||
*/
|
||||
const authStore = useAuthStore()
|
||||
authStore.$patch({
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
user: null
|
||||
})
|
||||
|
||||
const wrapper = mount(HomePage, {
|
||||
global: {
|
||||
stubs: { RouterLink: RouterLinkStub },
|
||||
},
|
||||
})
|
||||
|
||||
expect(authStore.isAuthenticated).toBe(false)
|
||||
expect(wrapper.text()).toContain('Continue Adventure')
|
||||
})
|
||||
|
||||
it('does not show authenticated navigation', () => {
|
||||
/**
|
||||
* Test that authenticated-only UI is hidden.
|
||||
*
|
||||
* Campaign and collection links should not appear for
|
||||
* unauthenticated users.
|
||||
*/
|
||||
const authStore = useAuthStore()
|
||||
authStore.$patch({
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
user: null
|
||||
})
|
||||
|
||||
const wrapper = mount(HomePage, {
|
||||
global: {
|
||||
stubs: { RouterLink: RouterLinkStub },
|
||||
},
|
||||
})
|
||||
|
||||
expect(authStore.isAuthenticated).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('Continue Campaign')
|
||||
expect(wrapper.text()).not.toContain('View Collection')
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticated state', () => {
|
||||
it('shows "Continue Campaign" button when authenticated', () => {
|
||||
/**
|
||||
* Test authenticated user primary action.
|
||||
*
|
||||
* Logged-in users should see a direct link to campaign mode.
|
||||
*/
|
||||
const authStore = useAuthStore()
|
||||
// Set accessToken to simulate authenticated state
|
||||
authStore.$patch({
|
||||
accessToken: 'mock-access-token',
|
||||
refreshToken: 'mock-refresh-token',
|
||||
expiresAt: Date.now() + 3600000
|
||||
})
|
||||
|
||||
const wrapper = mount(HomePage, {
|
||||
global: {
|
||||
stubs: { RouterLink: RouterLinkStub },
|
||||
},
|
||||
})
|
||||
|
||||
expect(authStore.isAuthenticated).toBe(true)
|
||||
expect(wrapper.text()).toContain('Continue Campaign')
|
||||
})
|
||||
|
||||
it('shows "View Collection" button when authenticated', () => {
|
||||
/**
|
||||
* Test authenticated user secondary action.
|
||||
*
|
||||
* Users should be able to view their card collection.
|
||||
*/
|
||||
const authStore = useAuthStore()
|
||||
authStore.$patch({
|
||||
accessToken: 'mock-access-token',
|
||||
refreshToken: 'mock-refresh-token',
|
||||
expiresAt: Date.now() + 3600000
|
||||
})
|
||||
|
||||
const wrapper = mount(HomePage, {
|
||||
global: {
|
||||
stubs: { RouterLink: RouterLinkStub },
|
||||
},
|
||||
})
|
||||
|
||||
expect(authStore.isAuthenticated).toBe(true)
|
||||
expect(wrapper.text()).toContain('View Collection')
|
||||
})
|
||||
|
||||
it('does not show unauthenticated navigation', () => {
|
||||
/**
|
||||
* Test that unauthenticated UI is hidden.
|
||||
*
|
||||
* Login/register buttons should not appear for authenticated
|
||||
* users.
|
||||
*/
|
||||
const authStore = useAuthStore()
|
||||
authStore.$patch({
|
||||
accessToken: 'mock-access-token',
|
||||
refreshToken: 'mock-refresh-token',
|
||||
expiresAt: Date.now() + 3600000
|
||||
})
|
||||
|
||||
const wrapper = mount(HomePage, {
|
||||
global: {
|
||||
stubs: { RouterLink: RouterLinkStub },
|
||||
},
|
||||
})
|
||||
|
||||
expect(authStore.isAuthenticated).toBe(true)
|
||||
expect(wrapper.text()).not.toContain('Start Your Journey')
|
||||
expect(wrapper.text()).not.toContain('Continue Adventure')
|
||||
})
|
||||
})
|
||||
|
||||
describe('navigation', () => {
|
||||
it('renders campaign and collection links when authenticated', () => {
|
||||
/**
|
||||
* Test authenticated navigation links exist.
|
||||
*
|
||||
* When authenticated, users should have navigation links
|
||||
* to campaign and collection pages.
|
||||
*/
|
||||
const authStore = useAuthStore()
|
||||
authStore.$patch({
|
||||
accessToken: 'mock-access-token',
|
||||
refreshToken: 'mock-refresh-token',
|
||||
expiresAt: Date.now() + 3600000
|
||||
})
|
||||
|
||||
const wrapper = mount(HomePage, {
|
||||
global: {
|
||||
stubs: { RouterLink: RouterLinkStub },
|
||||
},
|
||||
})
|
||||
|
||||
expect(authStore.isAuthenticated).toBe(true)
|
||||
// Check that RouterLink components exist with correct props
|
||||
const routerLinks = wrapper.findAllComponents({ name: 'RouterLink' })
|
||||
expect(routerLinks.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('renders login and register links when not authenticated', () => {
|
||||
/**
|
||||
* Test unauthenticated navigation links exist.
|
||||
*
|
||||
* When not authenticated, users should have navigation links
|
||||
* to login and register pages.
|
||||
*/
|
||||
const authStore = useAuthStore()
|
||||
authStore.$patch({
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
user: null
|
||||
})
|
||||
|
||||
const wrapper = mount(HomePage, {
|
||||
global: {
|
||||
stubs: { RouterLink: RouterLinkStub },
|
||||
},
|
||||
})
|
||||
|
||||
expect(authStore.isAuthenticated).toBe(false)
|
||||
// Check that RouterLink components exist
|
||||
const routerLinks = wrapper.findAllComponents({ name: 'RouterLink' })
|
||||
expect(routerLinks.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles auth state changes reactively', async () => {
|
||||
/**
|
||||
* Test reactive auth state updates.
|
||||
*
|
||||
* When auth state changes (login/logout), the UI should
|
||||
* update immediately without a page reload.
|
||||
*/
|
||||
const authStore = useAuthStore()
|
||||
authStore.$patch({
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
user: null
|
||||
})
|
||||
|
||||
const wrapper = mount(HomePage, {
|
||||
global: {
|
||||
stubs: { RouterLink: RouterLinkStub },
|
||||
},
|
||||
})
|
||||
|
||||
// Initially unauthenticated
|
||||
expect(authStore.isAuthenticated).toBe(false)
|
||||
expect(wrapper.text()).toContain('Start Your Journey')
|
||||
|
||||
// Login
|
||||
authStore.$patch({
|
||||
accessToken: 'mock-access-token',
|
||||
refreshToken: 'mock-refresh-token',
|
||||
expiresAt: Date.now() + 3600000
|
||||
})
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// Should now show authenticated UI
|
||||
expect(authStore.isAuthenticated).toBe(true)
|
||||
expect(wrapper.text()).toContain('Continue Campaign')
|
||||
expect(wrapper.text()).not.toContain('Start Your Journey')
|
||||
})
|
||||
|
||||
it('handles logout gracefully', async () => {
|
||||
/**
|
||||
* Test logout state change.
|
||||
*
|
||||
* After logout, the UI should revert to unauthenticated state.
|
||||
*/
|
||||
const authStore = useAuthStore()
|
||||
authStore.$patch({
|
||||
accessToken: 'mock-access-token',
|
||||
refreshToken: 'mock-refresh-token',
|
||||
expiresAt: Date.now() + 3600000
|
||||
})
|
||||
|
||||
const wrapper = mount(HomePage, {
|
||||
global: {
|
||||
stubs: { RouterLink: RouterLinkStub },
|
||||
},
|
||||
})
|
||||
|
||||
// Initially authenticated
|
||||
expect(authStore.isAuthenticated).toBe(true)
|
||||
expect(wrapper.text()).toContain('Continue Campaign')
|
||||
|
||||
// Logout
|
||||
authStore.$patch({
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
expiresAt: null
|
||||
})
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// Should now show unauthenticated UI
|
||||
expect(authStore.isAuthenticated).toBe(false)
|
||||
expect(wrapper.text()).toContain('Start Your Journey')
|
||||
expect(wrapper.text()).not.toContain('Continue Campaign')
|
||||
})
|
||||
})
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('has main heading as h1', () => {
|
||||
/**
|
||||
* Test heading hierarchy.
|
||||
*
|
||||
* The page should have a proper h1 for screen readers.
|
||||
*/
|
||||
const wrapper = mount(HomePage, {
|
||||
global: {
|
||||
stubs: { RouterLink: RouterLinkStub },
|
||||
},
|
||||
})
|
||||
|
||||
const h1 = wrapper.find('h1')
|
||||
expect(h1.exists()).toBe(true)
|
||||
expect(h1.text()).toContain('Mantimon TCG')
|
||||
})
|
||||
|
||||
it('feature cards have h3 headings', () => {
|
||||
/**
|
||||
* Test feature card accessibility.
|
||||
*
|
||||
* Each feature should have a proper heading for screen
|
||||
* readers to navigate.
|
||||
*/
|
||||
const wrapper = mount(HomePage, {
|
||||
global: {
|
||||
stubs: { RouterLink: RouterLinkStub },
|
||||
},
|
||||
})
|
||||
|
||||
const h3s = wrapper.findAll('h3')
|
||||
expect(h3s.length).toBe(3)
|
||||
expect(h3s[0].text()).toContain('Campaign Mode')
|
||||
expect(h3s[1].text()).toContain('Build Your Deck')
|
||||
expect(h3s[2].text()).toContain('PvP Battles')
|
||||
})
|
||||
|
||||
it('navigation buttons have clear labels', () => {
|
||||
/**
|
||||
* Test button text clarity.
|
||||
*
|
||||
* Buttons should have descriptive text that makes their
|
||||
* purpose clear to all users.
|
||||
*/
|
||||
const authStore = useAuthStore()
|
||||
authStore.$patch({
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
user: null
|
||||
})
|
||||
|
||||
const wrapper = mount(HomePage, {
|
||||
global: {
|
||||
stubs: { RouterLink: RouterLinkStub },
|
||||
},
|
||||
})
|
||||
|
||||
expect(authStore.isAuthenticated).toBe(false)
|
||||
// Button text should be clear and action-oriented
|
||||
expect(wrapper.text()).toContain('Start Your Journey')
|
||||
expect(wrapper.text()).toContain('Continue Adventure')
|
||||
})
|
||||
})
|
||||
|
||||
describe('responsive layout', () => {
|
||||
it('has responsive grid classes for feature cards', () => {
|
||||
/**
|
||||
* Test responsive design.
|
||||
*
|
||||
* Feature cards should stack on mobile and show in a row
|
||||
* on larger screens.
|
||||
*/
|
||||
const wrapper = mount(HomePage, {
|
||||
global: {
|
||||
stubs: { RouterLink: RouterLinkStub },
|
||||
},
|
||||
})
|
||||
|
||||
const grid = wrapper.find('.grid')
|
||||
expect(grid.exists()).toBe(true)
|
||||
expect(grid.classes()).toContain('grid-cols-1')
|
||||
expect(grid.classes()).toContain('md:grid-cols-3')
|
||||
})
|
||||
|
||||
it('has responsive text sizing', () => {
|
||||
/**
|
||||
* Test responsive typography.
|
||||
*
|
||||
* The main heading should scale appropriately on different
|
||||
* screen sizes.
|
||||
*/
|
||||
const wrapper = mount(HomePage, {
|
||||
global: {
|
||||
stubs: { RouterLink: RouterLinkStub },
|
||||
},
|
||||
})
|
||||
|
||||
const h1 = wrapper.find('h1')
|
||||
expect(h1.classes()).toContain('text-4xl')
|
||||
expect(h1.classes()).toContain('md:text-5xl')
|
||||
})
|
||||
})
|
||||
})
|
||||
368
frontend/src/pages/MatchPage.spec.ts
Normal file
368
frontend/src/pages/MatchPage.spec.ts
Normal file
@ -0,0 +1,368 @@
|
||||
/**
|
||||
* Tests for MatchPage.
|
||||
*
|
||||
* These tests verify the match page handles game connection states
|
||||
* correctly with proper error handling and cleanup.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MatchPage from './MatchPage.vue'
|
||||
import { useGameStore } from '@/stores/game'
|
||||
import type { VisibleGameState } from '@/types/game'
|
||||
|
||||
// Mock vue-router
|
||||
const mockPush = vi.fn()
|
||||
const mockRouteParams = ref<{ id?: string }>({})
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
useRoute: () => ({
|
||||
params: mockRouteParams.value,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('MatchPage', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockPush.mockReset()
|
||||
mockRouteParams.value = {}
|
||||
})
|
||||
|
||||
describe('mount with match ID', () => {
|
||||
it('renders loading state when game not connected', () => {
|
||||
/**
|
||||
* Test initial loading state.
|
||||
*
|
||||
* When first joining a match, users should see a loading
|
||||
* indicator while the connection is established.
|
||||
*/
|
||||
mockRouteParams.value = { id: 'match-123' }
|
||||
const gameStore = useGameStore()
|
||||
gameStore.gameState = null
|
||||
|
||||
const wrapper = mount(MatchPage)
|
||||
|
||||
expect(wrapper.text()).toContain('Connecting to match')
|
||||
})
|
||||
|
||||
it('shows loading spinner animation', () => {
|
||||
/**
|
||||
* Test loading visual feedback.
|
||||
*
|
||||
* The loading state should have an animated spinner for
|
||||
* better user experience.
|
||||
*/
|
||||
mockRouteParams.value = { id: 'match-123' }
|
||||
const gameStore = useGameStore()
|
||||
gameStore.gameState = null
|
||||
|
||||
const wrapper = mount(MatchPage)
|
||||
|
||||
const spinner = wrapper.find('.animate-pulse')
|
||||
expect(spinner.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows match UI when game is loaded', () => {
|
||||
/**
|
||||
* Test connected state.
|
||||
*
|
||||
* Once the game state is loaded, the match UI should appear.
|
||||
*/
|
||||
mockRouteParams.value = { id: 'match-123' }
|
||||
const gameStore = useGameStore()
|
||||
gameStore.gameState = {
|
||||
matchId: 'match-123',
|
||||
currentPlayer: 'player1',
|
||||
myHand: [],
|
||||
opponentHand: [],
|
||||
} as Partial<VisibleGameState>
|
||||
|
||||
const wrapper = mount(MatchPage)
|
||||
|
||||
// Should show match UI placeholder
|
||||
expect(wrapper.text()).toContain('Match UI coming in Phase F4')
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('mount without match ID', () => {
|
||||
it('redirects to campaign when no match ID', async () => {
|
||||
/**
|
||||
* Test error case: missing match ID.
|
||||
*
|
||||
* If the user navigates to /match without an ID, they should
|
||||
* be redirected to the campaign page.
|
||||
*/
|
||||
mockRouteParams.value = {}
|
||||
|
||||
mount(MatchPage)
|
||||
await flushPromises()
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/campaign')
|
||||
})
|
||||
|
||||
it('redirects when match ID is undefined', async () => {
|
||||
/**
|
||||
* Test error case: undefined match ID.
|
||||
*
|
||||
* Explicitly undefined match IDs should also redirect.
|
||||
*/
|
||||
mockRouteParams.value = { id: undefined }
|
||||
|
||||
mount(MatchPage)
|
||||
await flushPromises()
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/campaign')
|
||||
})
|
||||
|
||||
it('does not redirect when match ID is provided', () => {
|
||||
/**
|
||||
* Test normal case: valid match ID.
|
||||
*
|
||||
* With a valid match ID, no redirect should occur.
|
||||
*/
|
||||
mockRouteParams.value = { id: 'match-123' }
|
||||
|
||||
mount(MatchPage)
|
||||
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('unmount cleanup', () => {
|
||||
it('clears game state on unmount', () => {
|
||||
/**
|
||||
* Test cleanup on component unmount.
|
||||
*
|
||||
* When leaving the match page, the game state should be
|
||||
* cleared to prevent stale data.
|
||||
*/
|
||||
mockRouteParams.value = { id: 'match-123' }
|
||||
const gameStore = useGameStore()
|
||||
gameStore.gameState = {
|
||||
matchId: 'match-123',
|
||||
currentPlayer: 'player1',
|
||||
} as Partial<VisibleGameState>
|
||||
|
||||
const clearGameSpy = vi.spyOn(gameStore, 'clearGame')
|
||||
|
||||
const wrapper = mount(MatchPage)
|
||||
wrapper.unmount()
|
||||
|
||||
expect(clearGameSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears game state even if not connected', () => {
|
||||
/**
|
||||
* Test cleanup when game never loaded.
|
||||
*
|
||||
* Even if the game never connected, cleanup should still
|
||||
* run to ensure no partial state remains.
|
||||
*/
|
||||
mockRouteParams.value = { id: 'match-123' }
|
||||
const gameStore = useGameStore()
|
||||
gameStore.gameState = null
|
||||
|
||||
const clearGameSpy = vi.spyOn(gameStore, 'clearGame')
|
||||
|
||||
const wrapper = mount(MatchPage)
|
||||
wrapper.unmount()
|
||||
|
||||
expect(clearGameSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles match ID as string', () => {
|
||||
/**
|
||||
* Test match ID type handling.
|
||||
*
|
||||
* Route params are always strings, ensure this is handled
|
||||
* correctly.
|
||||
*/
|
||||
mockRouteParams.value = { id: 'match-abc-123' }
|
||||
|
||||
const wrapper = mount(MatchPage)
|
||||
|
||||
// Should not throw or redirect
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('handles very long match IDs', () => {
|
||||
/**
|
||||
* Test long ID handling.
|
||||
*
|
||||
* UUIDs and other long IDs should be handled without issues.
|
||||
*/
|
||||
const longId = 'a'.repeat(200)
|
||||
mockRouteParams.value = { id: longId }
|
||||
|
||||
const wrapper = mount(MatchPage)
|
||||
|
||||
// Should not throw or redirect
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('handles special characters in match ID', () => {
|
||||
/**
|
||||
* Test ID sanitization.
|
||||
*
|
||||
* Match IDs with special characters should be handled safely.
|
||||
*/
|
||||
mockRouteParams.value = { id: 'match-123!@#$%' }
|
||||
|
||||
const wrapper = mount(MatchPage)
|
||||
|
||||
// Should not throw or redirect
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('handles rapid mount/unmount cycles', () => {
|
||||
/**
|
||||
* Test cleanup resilience.
|
||||
*
|
||||
* Rapidly mounting and unmounting (e.g., during navigation)
|
||||
* should not cause errors.
|
||||
*/
|
||||
mockRouteParams.value = { id: 'match-123' }
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const wrapper = mount(MatchPage)
|
||||
wrapper.unmount()
|
||||
}
|
||||
|
||||
// Should not throw
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('game state transitions', () => {
|
||||
it('updates UI when game state changes from null to loaded', async () => {
|
||||
/**
|
||||
* Test reactive state updates.
|
||||
*
|
||||
* When the game state loads after mounting, the UI should
|
||||
* update to show the match interface.
|
||||
*/
|
||||
mockRouteParams.value = { id: 'match-123' }
|
||||
const gameStore = useGameStore()
|
||||
gameStore.gameState = null
|
||||
|
||||
const wrapper = mount(MatchPage)
|
||||
|
||||
// Initially loading
|
||||
expect(wrapper.text()).toContain('Connecting to match')
|
||||
|
||||
// Game loads
|
||||
gameStore.gameState = {
|
||||
matchId: 'match-123',
|
||||
currentPlayer: 'player1',
|
||||
} as Partial<VisibleGameState>
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// Should show match UI
|
||||
expect(wrapper.text()).toContain('Match UI coming in Phase F4')
|
||||
expect(wrapper.text()).not.toContain('Connecting to match')
|
||||
})
|
||||
|
||||
it('shows loading state when game state is cleared', async () => {
|
||||
/**
|
||||
* Test state clearing reactivity.
|
||||
*
|
||||
* If the game state is cleared (disconnect), the UI should
|
||||
* return to loading state.
|
||||
*/
|
||||
mockRouteParams.value = { id: 'match-123' }
|
||||
const gameStore = useGameStore()
|
||||
gameStore.gameState = {
|
||||
matchId: 'match-123',
|
||||
currentPlayer: 'player1',
|
||||
} as Partial<VisibleGameState>
|
||||
|
||||
const wrapper = mount(MatchPage)
|
||||
|
||||
// Initially showing match UI
|
||||
expect(wrapper.text()).toContain('Match UI coming in Phase F4')
|
||||
|
||||
// Game state cleared (disconnect)
|
||||
gameStore.gameState = null
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// Should show loading state again
|
||||
expect(wrapper.text()).toContain('Connecting to match')
|
||||
})
|
||||
})
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('loading state has descriptive text', () => {
|
||||
/**
|
||||
* Test loading accessibility.
|
||||
*
|
||||
* Screen reader users should know what's happening.
|
||||
*/
|
||||
mockRouteParams.value = { id: 'match-123' }
|
||||
const gameStore = useGameStore()
|
||||
gameStore.gameState = null
|
||||
|
||||
const wrapper = mount(MatchPage)
|
||||
|
||||
expect(wrapper.text()).toContain('Connecting to match')
|
||||
})
|
||||
|
||||
it('uses semantic HTML structure', () => {
|
||||
/**
|
||||
* Test HTML semantics.
|
||||
*
|
||||
* The page should use proper div structure.
|
||||
*/
|
||||
mockRouteParams.value = { id: 'match-123' }
|
||||
|
||||
const wrapper = mount(MatchPage)
|
||||
|
||||
// Should have proper container divs
|
||||
expect(wrapper.find('.h-screen').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('layout', () => {
|
||||
it('uses full screen height', () => {
|
||||
/**
|
||||
* Test full-screen layout.
|
||||
*
|
||||
* Match pages should use the full viewport height.
|
||||
*/
|
||||
mockRouteParams.value = { id: 'match-123' }
|
||||
|
||||
const wrapper = mount(MatchPage)
|
||||
|
||||
const container = wrapper.find('.h-screen')
|
||||
expect(container.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('centers loading indicator', () => {
|
||||
/**
|
||||
* Test loading centering.
|
||||
*
|
||||
* The loading spinner should be centered in the viewport.
|
||||
*/
|
||||
mockRouteParams.value = { id: 'match-123' }
|
||||
const gameStore = useGameStore()
|
||||
gameStore.gameState = null
|
||||
|
||||
const wrapper = mount(MatchPage)
|
||||
|
||||
const container = wrapper.find('.h-screen')
|
||||
expect(container.classes()).toContain('flex')
|
||||
expect(container.classes()).toContain('items-center')
|
||||
expect(container.classes()).toContain('justify-center')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -321,8 +321,8 @@ describe('PlayPage', () => {
|
||||
|
||||
expect(mockCreateGame).toHaveBeenCalledWith({
|
||||
deck_id: 'deck-1',
|
||||
opponent_id: 'placeholder-opponent',
|
||||
opponent_deck_id: 'placeholder-deck',
|
||||
opponent_id: '8d8b05a0-c231-4082-ba6f-56c2b908016c', // Bob's user ID
|
||||
opponent_deck_id: '0e8790dd-cf78-48f4-876b-65406f218f90', // Bob's Test Fire Deck
|
||||
game_type: 'freeplay',
|
||||
})
|
||||
})
|
||||
|
||||
367
frontend/src/socket/types.spec.ts
Normal file
367
frontend/src/socket/types.spec.ts
Normal file
@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Tests for WebSocket message types and factory functions.
|
||||
*
|
||||
* Verifies message creation, structure, and unique ID generation.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import {
|
||||
generateMessageId,
|
||||
createJoinGameMessage,
|
||||
createActionMessage,
|
||||
createResignMessage,
|
||||
createHeartbeatMessage,
|
||||
} from './types'
|
||||
import type { GameAction } from './types'
|
||||
|
||||
describe('Socket Message Types', () => {
|
||||
describe('generateMessageId', () => {
|
||||
it('generates a unique message ID', () => {
|
||||
/**
|
||||
* Test that generateMessageId returns a UUID.
|
||||
*
|
||||
* Message IDs must be unique for tracking and deduplication.
|
||||
*/
|
||||
const id = generateMessageId()
|
||||
|
||||
expect(id).toBeTruthy()
|
||||
expect(typeof id).toBe('string')
|
||||
// UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)
|
||||
})
|
||||
|
||||
it('generates different IDs on successive calls', () => {
|
||||
/**
|
||||
* Test that generateMessageId produces unique values.
|
||||
*
|
||||
* Each message must have a unique ID for proper tracking.
|
||||
*/
|
||||
const id1 = generateMessageId()
|
||||
const id2 = generateMessageId()
|
||||
const id3 = generateMessageId()
|
||||
|
||||
expect(id1).not.toBe(id2)
|
||||
expect(id2).not.toBe(id3)
|
||||
expect(id1).not.toBe(id3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createJoinGameMessage', () => {
|
||||
it('creates a join game message with game ID', () => {
|
||||
/**
|
||||
* Test createJoinGameMessage basic structure.
|
||||
*
|
||||
* Join messages must include type, message_id, and game_id.
|
||||
*/
|
||||
const gameId = 'game-123'
|
||||
|
||||
const message = createJoinGameMessage(gameId)
|
||||
|
||||
expect(message.type).toBe('join_game')
|
||||
expect(message.game_id).toBe(gameId)
|
||||
expect(message.message_id).toBeTruthy()
|
||||
expect(message.message_id).toMatch(/^[0-9a-f-]+$/i)
|
||||
})
|
||||
|
||||
it('includes last_event_id when provided', () => {
|
||||
/**
|
||||
* Test createJoinGameMessage with last_event_id for reconnection.
|
||||
*
|
||||
* When reconnecting, clients send the last event ID they received
|
||||
* to resume from that point.
|
||||
*/
|
||||
const gameId = 'game-123'
|
||||
const lastEventId = 'event-456'
|
||||
|
||||
const message = createJoinGameMessage(gameId, lastEventId)
|
||||
|
||||
expect(message.type).toBe('join_game')
|
||||
expect(message.game_id).toBe(gameId)
|
||||
expect(message.last_event_id).toBe(lastEventId)
|
||||
})
|
||||
|
||||
it('omits last_event_id when not provided', () => {
|
||||
/**
|
||||
* Test createJoinGameMessage without last_event_id for new connections.
|
||||
*
|
||||
* Initial joins don't have a last_event_id.
|
||||
*/
|
||||
const gameId = 'game-123'
|
||||
|
||||
const message = createJoinGameMessage(gameId)
|
||||
|
||||
expect(message.last_event_id).toBeUndefined()
|
||||
})
|
||||
|
||||
it('generates unique message IDs', () => {
|
||||
/**
|
||||
* Test that each join message has a unique ID.
|
||||
*
|
||||
* Message IDs must be unique even for the same game.
|
||||
*/
|
||||
const gameId = 'game-123'
|
||||
|
||||
const message1 = createJoinGameMessage(gameId)
|
||||
const message2 = createJoinGameMessage(gameId)
|
||||
|
||||
expect(message1.message_id).not.toBe(message2.message_id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createActionMessage', () => {
|
||||
it('creates an action message with game ID and action', () => {
|
||||
/**
|
||||
* Test createActionMessage basic structure.
|
||||
*
|
||||
* Action messages must include type, message_id, game_id, and action.
|
||||
*/
|
||||
const gameId = 'game-123'
|
||||
const action: GameAction = {
|
||||
action_type: 'play_card',
|
||||
card_id: 'card-456',
|
||||
}
|
||||
|
||||
const message = createActionMessage(gameId, action)
|
||||
|
||||
expect(message.type).toBe('action')
|
||||
expect(message.game_id).toBe(gameId)
|
||||
expect(message.action).toEqual(action)
|
||||
expect(message.message_id).toBeTruthy()
|
||||
})
|
||||
|
||||
it('preserves action data structure', () => {
|
||||
/**
|
||||
* Test that action data is preserved without modification.
|
||||
*
|
||||
* The action object should be included as-is in the message.
|
||||
*/
|
||||
const gameId = 'game-123'
|
||||
const action: GameAction = {
|
||||
action_type: 'attack',
|
||||
attacker_id: 'attacker-1',
|
||||
defender_id: 'defender-2',
|
||||
attack_index: 0,
|
||||
}
|
||||
|
||||
const message = createActionMessage(gameId, action)
|
||||
|
||||
expect(message.action).toEqual(action)
|
||||
expect(message.action.action_type).toBe('attack')
|
||||
expect(message.action.attacker_id).toBe('attacker-1')
|
||||
expect(message.action.defender_id).toBe('defender-2')
|
||||
expect(message.action.attack_index).toBe(0)
|
||||
})
|
||||
|
||||
it('handles different action types', () => {
|
||||
/**
|
||||
* Test createActionMessage with various action types.
|
||||
*
|
||||
* Different game actions should all be properly wrapped.
|
||||
*/
|
||||
const gameId = 'game-123'
|
||||
|
||||
const playCardMessage = createActionMessage(gameId, {
|
||||
action_type: 'play_card',
|
||||
card_id: 'card-1',
|
||||
})
|
||||
|
||||
const attachEnergyMessage = createActionMessage(gameId, {
|
||||
action_type: 'attach_energy',
|
||||
card_id: 'energy-1',
|
||||
target_id: 'pokemon-1',
|
||||
})
|
||||
|
||||
const endTurnMessage = createActionMessage(gameId, {
|
||||
action_type: 'end_turn',
|
||||
})
|
||||
|
||||
expect(playCardMessage.action.action_type).toBe('play_card')
|
||||
expect(attachEnergyMessage.action.action_type).toBe('attach_energy')
|
||||
expect(endTurnMessage.action.action_type).toBe('end_turn')
|
||||
})
|
||||
|
||||
it('generates unique message IDs for different actions', () => {
|
||||
/**
|
||||
* Test that each action message has a unique ID.
|
||||
*
|
||||
* Even identical actions should have different message IDs.
|
||||
*/
|
||||
const gameId = 'game-123'
|
||||
const action: GameAction = { action_type: 'end_turn' }
|
||||
|
||||
const message1 = createActionMessage(gameId, action)
|
||||
const message2 = createActionMessage(gameId, action)
|
||||
|
||||
expect(message1.message_id).not.toBe(message2.message_id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createResignMessage', () => {
|
||||
it('creates a resign message with game ID', () => {
|
||||
/**
|
||||
* Test createResignMessage basic structure.
|
||||
*
|
||||
* Resign messages must include type, message_id, and game_id.
|
||||
*/
|
||||
const gameId = 'game-123'
|
||||
|
||||
const message = createResignMessage(gameId)
|
||||
|
||||
expect(message.type).toBe('resign')
|
||||
expect(message.game_id).toBe(gameId)
|
||||
expect(message.message_id).toBeTruthy()
|
||||
})
|
||||
|
||||
it('generates unique message IDs', () => {
|
||||
/**
|
||||
* Test that each resign message has a unique ID.
|
||||
*
|
||||
* Message IDs must be unique even for the same game.
|
||||
*/
|
||||
const gameId = 'game-123'
|
||||
|
||||
const message1 = createResignMessage(gameId)
|
||||
const message2 = createResignMessage(gameId)
|
||||
|
||||
expect(message1.message_id).not.toBe(message2.message_id)
|
||||
})
|
||||
|
||||
it('includes only required fields', () => {
|
||||
/**
|
||||
* Test that resign messages don't include extra fields.
|
||||
*
|
||||
* Resign is simple - just type, message_id, and game_id.
|
||||
*/
|
||||
const gameId = 'game-123'
|
||||
|
||||
const message = createResignMessage(gameId)
|
||||
|
||||
const keys = Object.keys(message)
|
||||
expect(keys).toContain('type')
|
||||
expect(keys).toContain('message_id')
|
||||
expect(keys).toContain('game_id')
|
||||
expect(keys.length).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createHeartbeatMessage', () => {
|
||||
it('creates a heartbeat message', () => {
|
||||
/**
|
||||
* Test createHeartbeatMessage basic structure.
|
||||
*
|
||||
* Heartbeat messages must include type and message_id.
|
||||
*/
|
||||
const message = createHeartbeatMessage()
|
||||
|
||||
expect(message.type).toBe('heartbeat')
|
||||
expect(message.message_id).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not include game_id', () => {
|
||||
/**
|
||||
* Test that heartbeat messages are game-independent.
|
||||
*
|
||||
* Heartbeats maintain the connection regardless of active game.
|
||||
*/
|
||||
const message = createHeartbeatMessage()
|
||||
|
||||
expect('game_id' in message).toBe(false)
|
||||
})
|
||||
|
||||
it('generates unique message IDs', () => {
|
||||
/**
|
||||
* Test that each heartbeat has a unique ID.
|
||||
*
|
||||
* Message IDs must be unique for tracking.
|
||||
*/
|
||||
const message1 = createHeartbeatMessage()
|
||||
const message2 = createHeartbeatMessage()
|
||||
|
||||
expect(message1.message_id).not.toBe(message2.message_id)
|
||||
})
|
||||
|
||||
it('includes only required fields', () => {
|
||||
/**
|
||||
* Test that heartbeat messages don't include extra fields.
|
||||
*
|
||||
* Heartbeat is minimal - just type and message_id.
|
||||
*/
|
||||
const message = createHeartbeatMessage()
|
||||
|
||||
const keys = Object.keys(message)
|
||||
expect(keys).toContain('type')
|
||||
expect(keys).toContain('message_id')
|
||||
expect(keys.length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('message factories integration', () => {
|
||||
it('all factories generate valid UUID message IDs', () => {
|
||||
/**
|
||||
* Test that all message factories use proper UUID format.
|
||||
*
|
||||
* Consistent ID format ensures reliable message tracking.
|
||||
*/
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
const joinMessage = createJoinGameMessage('game-1')
|
||||
const actionMessage = createActionMessage('game-1', { action_type: 'end_turn' })
|
||||
const resignMessage = createResignMessage('game-1')
|
||||
const heartbeatMessage = createHeartbeatMessage()
|
||||
|
||||
expect(joinMessage.message_id).toMatch(uuidRegex)
|
||||
expect(actionMessage.message_id).toMatch(uuidRegex)
|
||||
expect(resignMessage.message_id).toMatch(uuidRegex)
|
||||
expect(heartbeatMessage.message_id).toMatch(uuidRegex)
|
||||
})
|
||||
|
||||
it('messages for the same game have different IDs', () => {
|
||||
/**
|
||||
* Test that multiple messages for one game have unique IDs.
|
||||
*
|
||||
* This ensures proper message tracking within a game session.
|
||||
*/
|
||||
const gameId = 'game-123'
|
||||
|
||||
const message1 = createJoinGameMessage(gameId)
|
||||
const message2 = createActionMessage(gameId, { action_type: 'end_turn' })
|
||||
const message3 = createResignMessage(gameId)
|
||||
|
||||
const ids = [message1.message_id, message2.message_id, message3.message_id]
|
||||
const uniqueIds = new Set(ids)
|
||||
|
||||
expect(uniqueIds.size).toBe(3) // All IDs should be unique
|
||||
})
|
||||
|
||||
it('message structure matches expected types', () => {
|
||||
/**
|
||||
* Test that factory output matches TypeScript type definitions.
|
||||
*
|
||||
* This validates that the factories produce correctly-typed messages.
|
||||
*/
|
||||
const gameId = 'game-123'
|
||||
|
||||
const joinMessage = createJoinGameMessage(gameId)
|
||||
expect(joinMessage).toHaveProperty('type')
|
||||
expect(joinMessage).toHaveProperty('message_id')
|
||||
expect(joinMessage).toHaveProperty('game_id')
|
||||
|
||||
const actionMessage = createActionMessage(gameId, { action_type: 'end_turn' })
|
||||
expect(actionMessage).toHaveProperty('type')
|
||||
expect(actionMessage).toHaveProperty('message_id')
|
||||
expect(actionMessage).toHaveProperty('game_id')
|
||||
expect(actionMessage).toHaveProperty('action')
|
||||
|
||||
const resignMessage = createResignMessage(gameId)
|
||||
expect(resignMessage).toHaveProperty('type')
|
||||
expect(resignMessage).toHaveProperty('message_id')
|
||||
expect(resignMessage).toHaveProperty('game_id')
|
||||
|
||||
const heartbeatMessage = createHeartbeatMessage()
|
||||
expect(heartbeatMessage).toHaveProperty('type')
|
||||
expect(heartbeatMessage).toHaveProperty('message_id')
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
@ -5,7 +5,7 @@
|
||||
* All messages follow a discriminated union pattern with a 'type' field.
|
||||
*/
|
||||
|
||||
import type { GameEndReason, TurnPhase, VisibleGameState } from '@/types'
|
||||
import type { GameEndReason, VisibleGameState } from '@/types'
|
||||
|
||||
// =============================================================================
|
||||
// Enums
|
||||
|
||||
@ -1,7 +1,18 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
import { useUserStore } from './user'
|
||||
import { useAuthStore } from './auth'
|
||||
import { apiClient } from '@/api/client'
|
||||
import { ApiError } from '@/api/types'
|
||||
|
||||
// Mock the API client
|
||||
vi.mock('@/api/client', () => ({
|
||||
apiClient: {
|
||||
get: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('useUserStore', () => {
|
||||
beforeEach(() => {
|
||||
@ -112,4 +123,637 @@ describe('useUserStore', () => {
|
||||
expect(store.linkedAccounts).toHaveLength(1)
|
||||
expect(store.linkedAccounts[0].provider).toBe('discord')
|
||||
})
|
||||
|
||||
describe('fetchProfile', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('successfully fetches and transforms profile data', async () => {
|
||||
/**
|
||||
* Test that fetchProfile retrieves and transforms API data correctly.
|
||||
*
|
||||
* The store should fetch profile from API, transform snake_case to
|
||||
* camelCase, and update both user store and auth store.
|
||||
*/
|
||||
const store = useUserStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Set auth as authenticated
|
||||
authStore.setTokens({
|
||||
accessToken: 'token',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
})
|
||||
|
||||
const apiResponse = {
|
||||
id: 'user-123',
|
||||
display_name: 'Test User',
|
||||
avatar_url: 'https://example.com/avatar.png',
|
||||
has_starter_deck: true,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
linked_accounts: [
|
||||
{
|
||||
provider: 'google',
|
||||
provider_user_id: 'google-123',
|
||||
email: 'test@gmail.com',
|
||||
linked_at: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce(apiResponse)
|
||||
|
||||
const result = await store.fetchProfile()
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(store.profile).not.toBeNull()
|
||||
expect(store.profile?.id).toBe('user-123')
|
||||
expect(store.profile?.displayName).toBe('Test User')
|
||||
expect(store.profile?.linkedAccounts).toHaveLength(1)
|
||||
expect(store.error).toBeNull()
|
||||
})
|
||||
|
||||
it('returns false when not authenticated', async () => {
|
||||
/**
|
||||
* Test that fetchProfile fails gracefully when not authenticated.
|
||||
*
|
||||
* If user is not logged in, fetchProfile should return false
|
||||
* and set an error without making API call.
|
||||
*/
|
||||
const store = useUserStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Ensure not authenticated
|
||||
await authStore.logout(false) // Don't revoke on server in test
|
||||
|
||||
const result = await store.fetchProfile()
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(store.error).toBe('Not authenticated')
|
||||
expect(apiClient.get).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles API error responses', async () => {
|
||||
/**
|
||||
* Test that fetchProfile handles API errors gracefully.
|
||||
*
|
||||
* When API returns an error, the store should capture the
|
||||
* error message and return false.
|
||||
*/
|
||||
const store = useUserStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
authStore.setTokens({
|
||||
accessToken: 'token',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
})
|
||||
|
||||
const apiError = new ApiError(
|
||||
404,
|
||||
'Not Found',
|
||||
'User profile does not exist'
|
||||
)
|
||||
vi.mocked(apiClient.get).mockRejectedValueOnce(apiError)
|
||||
|
||||
const result = await store.fetchProfile()
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(store.error).toBe('User profile does not exist')
|
||||
expect(store.profile).toBeNull()
|
||||
})
|
||||
|
||||
it('handles generic errors', async () => {
|
||||
/**
|
||||
* Test that fetchProfile handles non-API errors.
|
||||
*
|
||||
* Network errors or unexpected failures should be captured
|
||||
* with a generic error message.
|
||||
*/
|
||||
const store = useUserStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
authStore.setTokens({
|
||||
accessToken: 'token',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
})
|
||||
|
||||
vi.mocked(apiClient.get).mockRejectedValueOnce(new Error('Network failure'))
|
||||
|
||||
const result = await store.fetchProfile()
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(store.error).toBe('Network failure')
|
||||
})
|
||||
|
||||
it('handles non-Error exceptions', async () => {
|
||||
/**
|
||||
* Test that fetchProfile handles thrown strings or objects.
|
||||
*
|
||||
* Some errors might be thrown as strings or plain objects.
|
||||
* The store should handle these gracefully.
|
||||
*/
|
||||
const store = useUserStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
authStore.setTokens({
|
||||
accessToken: 'token',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
})
|
||||
|
||||
vi.mocked(apiClient.get).mockRejectedValueOnce('Something went wrong')
|
||||
|
||||
const result = await store.fetchProfile()
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(store.error).toBe('Failed to fetch profile')
|
||||
})
|
||||
|
||||
it('handles profile with no linked accounts', async () => {
|
||||
/**
|
||||
* Test that fetchProfile handles missing linked_accounts field.
|
||||
*
|
||||
* Not all users have linked accounts. The store should default
|
||||
* to an empty array if the field is missing.
|
||||
*/
|
||||
const store = useUserStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
authStore.setTokens({
|
||||
accessToken: 'token',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
})
|
||||
|
||||
const apiResponse = {
|
||||
id: 'user-456',
|
||||
display_name: 'New User',
|
||||
avatar_url: null,
|
||||
has_starter_deck: false,
|
||||
created_at: '2026-01-02T00:00:00Z',
|
||||
// linked_accounts field missing
|
||||
}
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce(apiResponse)
|
||||
|
||||
const result = await store.fetchProfile()
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(store.profile?.linkedAccounts).toEqual([])
|
||||
})
|
||||
|
||||
it('updates auth store with profile data', async () => {
|
||||
/**
|
||||
* Test that fetchProfile syncs data to auth store.
|
||||
*
|
||||
* The auth store needs basic profile info for guards and UI.
|
||||
* fetchProfile should update it after successful fetch.
|
||||
*/
|
||||
const store = useUserStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
authStore.setTokens({
|
||||
accessToken: 'token',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
})
|
||||
|
||||
const setUserSpy = vi.spyOn(authStore, 'setUser')
|
||||
|
||||
const apiResponse = {
|
||||
id: 'user-789',
|
||||
display_name: 'Sync User',
|
||||
avatar_url: 'https://example.com/sync.png',
|
||||
has_starter_deck: true,
|
||||
created_at: '2026-01-03T00:00:00Z',
|
||||
}
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce(apiResponse)
|
||||
|
||||
await store.fetchProfile()
|
||||
|
||||
expect(setUserSpy).toHaveBeenCalledWith({
|
||||
id: 'user-789',
|
||||
displayName: 'Sync User',
|
||||
avatarUrl: 'https://example.com/sync.png',
|
||||
hasStarterDeck: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('sets loading state during fetch', async () => {
|
||||
/**
|
||||
* Test that isLoading is true during API call.
|
||||
*
|
||||
* Components need to show loading spinners while profile
|
||||
* is being fetched.
|
||||
*/
|
||||
const store = useUserStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
authStore.setTokens({
|
||||
accessToken: 'token',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
})
|
||||
|
||||
let loadingDuringFetch = false
|
||||
vi.mocked(apiClient.get).mockImplementation(async () => {
|
||||
loadingDuringFetch = store.isLoading
|
||||
return {
|
||||
id: 'user-123',
|
||||
display_name: 'Test',
|
||||
avatar_url: null,
|
||||
has_starter_deck: false,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
}
|
||||
})
|
||||
|
||||
await store.fetchProfile()
|
||||
|
||||
expect(loadingDuringFetch).toBe(true)
|
||||
expect(store.isLoading).toBe(false) // Should be false after completion
|
||||
})
|
||||
|
||||
it('clears error on successful fetch', async () => {
|
||||
/**
|
||||
* Test that successful fetch clears previous errors.
|
||||
*
|
||||
* If a previous fetch failed, a successful fetch should
|
||||
* clear the error state.
|
||||
*/
|
||||
const store = useUserStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
authStore.setTokens({
|
||||
accessToken: 'token',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
})
|
||||
|
||||
// Set an existing error
|
||||
store.error = 'Previous error'
|
||||
|
||||
const apiResponse = {
|
||||
id: 'user-123',
|
||||
display_name: 'Test',
|
||||
avatar_url: null,
|
||||
has_starter_deck: false,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce(apiResponse)
|
||||
|
||||
await store.fetchProfile()
|
||||
|
||||
expect(store.error).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateDisplayName', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('successfully updates display name', async () => {
|
||||
/**
|
||||
* Test that updateDisplayName updates the name via API.
|
||||
*
|
||||
* The store should send the new name to API, then refetch
|
||||
* the profile to get the updated data.
|
||||
*/
|
||||
const store = useUserStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
authStore.setTokens({
|
||||
accessToken: 'token',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
})
|
||||
|
||||
const updatedProfile = {
|
||||
id: 'user-123',
|
||||
display_name: 'New Name',
|
||||
avatar_url: null,
|
||||
has_starter_deck: true,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
vi.mocked(apiClient.patch).mockResolvedValueOnce(undefined)
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce(updatedProfile)
|
||||
|
||||
const result = await store.updateDisplayName('New Name')
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/api/users/me', {
|
||||
display_name: 'New Name',
|
||||
})
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/api/users/me')
|
||||
expect(store.displayName).toBe('New Name')
|
||||
})
|
||||
|
||||
it('returns false when not authenticated', async () => {
|
||||
/**
|
||||
* Test that updateDisplayName fails when not authenticated.
|
||||
*
|
||||
* Users must be logged in to update their profile.
|
||||
*/
|
||||
const store = useUserStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
await authStore.logout(false)
|
||||
|
||||
const result = await store.updateDisplayName('New Name')
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(store.error).toBe('Not authenticated')
|
||||
expect(apiClient.patch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles API validation errors', async () => {
|
||||
/**
|
||||
* Test that updateDisplayName handles validation errors.
|
||||
*
|
||||
* API may reject names that are too short, too long, or
|
||||
* contain profanity. These errors should be captured.
|
||||
*/
|
||||
const store = useUserStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
authStore.setTokens({
|
||||
accessToken: 'token',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
})
|
||||
|
||||
const validationError = new ApiError(
|
||||
422,
|
||||
'Unprocessable Entity',
|
||||
'Display name must be between 3 and 20 characters'
|
||||
)
|
||||
vi.mocked(apiClient.patch).mockRejectedValueOnce(validationError)
|
||||
|
||||
const result = await store.updateDisplayName('AB')
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(store.error).toBe('Display name must be between 3 and 20 characters')
|
||||
})
|
||||
|
||||
it('handles profanity filter errors', async () => {
|
||||
/**
|
||||
* Test that updateDisplayName handles profanity errors.
|
||||
*
|
||||
* API blocks profane or inappropriate names. These should
|
||||
* be communicated clearly to the user.
|
||||
*/
|
||||
const store = useUserStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
authStore.setTokens({
|
||||
accessToken: 'token',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
})
|
||||
|
||||
const profanityError = new ApiError(
|
||||
422,
|
||||
'Unprocessable Entity',
|
||||
'Display name contains inappropriate content'
|
||||
)
|
||||
vi.mocked(apiClient.patch).mockRejectedValueOnce(profanityError)
|
||||
|
||||
const result = await store.updateDisplayName('BadName')
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(store.error).toBe('Display name contains inappropriate content')
|
||||
})
|
||||
|
||||
it('handles network errors during update', async () => {
|
||||
/**
|
||||
* Test that updateDisplayName handles network failures.
|
||||
*
|
||||
* Network errors during update should not crash the app.
|
||||
*/
|
||||
const store = useUserStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
authStore.setTokens({
|
||||
accessToken: 'token',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
})
|
||||
|
||||
vi.mocked(apiClient.patch).mockRejectedValueOnce(new Error('Network timeout'))
|
||||
|
||||
const result = await store.updateDisplayName('New Name')
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(store.error).toBe('Network timeout')
|
||||
})
|
||||
|
||||
it('handles generic errors during update', async () => {
|
||||
/**
|
||||
* Test that updateDisplayName handles unexpected errors.
|
||||
*
|
||||
* Any unexpected error should result in a generic message.
|
||||
*/
|
||||
const store = useUserStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
authStore.setTokens({
|
||||
accessToken: 'token',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
})
|
||||
|
||||
vi.mocked(apiClient.patch).mockRejectedValueOnce({ unexpected: 'error' })
|
||||
|
||||
const result = await store.updateDisplayName('New Name')
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(store.error).toBe('Failed to update profile')
|
||||
})
|
||||
|
||||
it('sets loading state during update', async () => {
|
||||
/**
|
||||
* Test that isLoading is true during update.
|
||||
*
|
||||
* Components should show loading state while update is
|
||||
* in progress.
|
||||
*/
|
||||
const store = useUserStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
authStore.setTokens({
|
||||
accessToken: 'token',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
})
|
||||
|
||||
let loadingDuringUpdate = false
|
||||
vi.mocked(apiClient.patch).mockImplementation(async () => {
|
||||
loadingDuringUpdate = store.isLoading
|
||||
return undefined
|
||||
})
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||
id: 'user-123',
|
||||
display_name: 'New Name',
|
||||
avatar_url: null,
|
||||
has_starter_deck: false,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
})
|
||||
|
||||
await store.updateDisplayName('New Name')
|
||||
|
||||
expect(loadingDuringUpdate).toBe(true)
|
||||
expect(store.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('clears error on successful update', async () => {
|
||||
/**
|
||||
* Test that successful update clears previous errors.
|
||||
*
|
||||
* If a previous update failed, a successful one should
|
||||
* clear the error state.
|
||||
*/
|
||||
const store = useUserStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
authStore.setTokens({
|
||||
accessToken: 'token',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
})
|
||||
|
||||
store.error = 'Previous error'
|
||||
|
||||
vi.mocked(apiClient.patch).mockResolvedValueOnce(undefined)
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||
id: 'user-123',
|
||||
display_name: 'New Name',
|
||||
avatar_url: null,
|
||||
has_starter_deck: false,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
})
|
||||
|
||||
await store.updateDisplayName('New Name')
|
||||
|
||||
expect(store.error).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('handles concurrent fetchProfile calls', async () => {
|
||||
/**
|
||||
* Test that concurrent fetchProfile calls don't cause issues.
|
||||
*
|
||||
* If multiple components call fetchProfile simultaneously,
|
||||
* the store should handle it gracefully.
|
||||
*/
|
||||
const store = useUserStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
authStore.setTokens({
|
||||
accessToken: 'token',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
})
|
||||
|
||||
const apiResponse = {
|
||||
id: 'user-123',
|
||||
display_name: 'Test',
|
||||
avatar_url: null,
|
||||
has_starter_deck: false,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue(apiResponse)
|
||||
|
||||
// Call fetchProfile three times concurrently
|
||||
const results = await Promise.all([
|
||||
store.fetchProfile(),
|
||||
store.fetchProfile(),
|
||||
store.fetchProfile(),
|
||||
])
|
||||
|
||||
// All should succeed
|
||||
expect(results).toEqual([true, true, true])
|
||||
// API should have been called three times
|
||||
expect(apiClient.get).toHaveBeenCalledTimes(3)
|
||||
// Store should have the profile
|
||||
expect(store.profile).not.toBeNull()
|
||||
})
|
||||
|
||||
it('handles profile with null avatar_url', async () => {
|
||||
/**
|
||||
* Test that null avatar_url is handled correctly.
|
||||
*
|
||||
* Users without avatars should have null avatar_url,
|
||||
* not undefined or empty string.
|
||||
*/
|
||||
const store = useUserStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
authStore.setTokens({
|
||||
accessToken: 'token',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
})
|
||||
|
||||
const apiResponse = {
|
||||
id: 'user-123',
|
||||
display_name: 'Test',
|
||||
avatar_url: null,
|
||||
has_starter_deck: false,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce(apiResponse)
|
||||
|
||||
await store.fetchProfile()
|
||||
|
||||
expect(store.avatarUrl).toBeNull()
|
||||
})
|
||||
|
||||
it('handles empty display name in API response', async () => {
|
||||
/**
|
||||
* Test that empty display name falls back to "Unknown".
|
||||
*
|
||||
* If API returns empty display name (shouldn't happen but
|
||||
* defensive), the computed property should provide fallback.
|
||||
*/
|
||||
const store = useUserStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
authStore.setTokens({
|
||||
accessToken: 'token',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
})
|
||||
|
||||
const apiResponse = {
|
||||
id: 'user-123',
|
||||
display_name: '',
|
||||
avatar_url: null,
|
||||
has_starter_deck: false,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce(apiResponse)
|
||||
|
||||
await store.fetchProfile()
|
||||
|
||||
// Store has empty string, computed should still use it
|
||||
expect(store.profile?.displayName).toBe('')
|
||||
// But if profile was null, computed would return "Unknown"
|
||||
store.profile = null
|
||||
expect(store.displayName).toBe('Unknown')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
401
frontend/src/test/README.md
Normal file
401
frontend/src/test/README.md
Normal file
@ -0,0 +1,401 @@
|
||||
|
||||
|
||||
# Game Engine Testing Infrastructure
|
||||
|
||||
This directory contains mocks and utilities for testing the Phaser-based game engine code.
|
||||
|
||||
## Overview
|
||||
|
||||
Testing Phaser game code is challenging because Phaser requires WebGL/Canvas support, which is not available in jsdom (the DOM implementation used by Vitest). This infrastructure provides:
|
||||
|
||||
1. **Phaser Mocks** (`mocks/phaser.ts`) - Minimal mock implementations of Phaser classes
|
||||
2. **Test Utilities** (`helpers/gameTestUtils.ts`) - Helper functions to create mock game states and cards
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { MockScene, MockContainer } from '@/test/mocks/phaser'
|
||||
import { createMockGameState, createMockCard, setupMockScene } from '@/test/helpers/gameTestUtils'
|
||||
|
||||
// Test a Phaser game object
|
||||
describe('MyGameObject', () => {
|
||||
it('renders correctly', () => {
|
||||
const { scene } = setupMockScene()
|
||||
const myObject = new MyGameObject(scene, 100, 200)
|
||||
|
||||
expect(myObject.x).toBe(100)
|
||||
expect(myObject.y).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
// Test game logic with mock state
|
||||
describe('gameLogic', () => {
|
||||
it('handles player actions', () => {
|
||||
const gameState = createMockGameState({
|
||||
current_player_id: 'player1',
|
||||
turn_number: 3
|
||||
})
|
||||
|
||||
const result = processPlayerAction(gameState, 'draw-card')
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Phaser Mocks
|
||||
|
||||
### Available Mock Classes
|
||||
|
||||
| Mock Class | Real Phaser Class | Purpose |
|
||||
|------------|------------------|---------|
|
||||
| `MockEventEmitter` | `Phaser.Events.EventEmitter` | Event system |
|
||||
| `MockScene` | `Phaser.Scene` | Game scenes |
|
||||
| `MockGame` | `Phaser.Game` | Game instance |
|
||||
| `MockContainer` | `Phaser.GameObjects.Container` | Object containers |
|
||||
| `MockSprite` | `Phaser.GameObjects.Sprite` | Image sprites |
|
||||
| `MockText` | `Phaser.GameObjects.Text` | Text display |
|
||||
| `MockGraphics` | `Phaser.GameObjects.Graphics` | Shape drawing |
|
||||
|
||||
### Using Phaser Mocks
|
||||
|
||||
```typescript
|
||||
import { MockScene, MockContainer, MockSprite } from '@/test/mocks/phaser'
|
||||
|
||||
describe('Card', () => {
|
||||
it('displays card image', () => {
|
||||
const scene = new MockScene('test')
|
||||
const container = new MockContainer(scene, 0, 0)
|
||||
const sprite = new MockSprite(scene, 50, 50, 'pikachu-card')
|
||||
|
||||
container.add(sprite)
|
||||
|
||||
expect(container.list).toContain(sprite)
|
||||
expect(sprite.texture.key).toBe('pikachu-card')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Mock Limitations
|
||||
|
||||
The mocks provide the minimal API surface needed for testing. They do **NOT** implement:
|
||||
|
||||
- Actual rendering (no canvas output)
|
||||
- Physics simulation
|
||||
- Collision detection
|
||||
- Particle systems
|
||||
- Complex animations
|
||||
- Asset loading (mocked with immediate completion)
|
||||
|
||||
If your test needs these features, consider:
|
||||
1. Extracting the logic into pure functions that can be tested without Phaser
|
||||
2. Using integration tests with a real Phaser instance (slower but more accurate)
|
||||
3. Using visual regression tests with Playwright (for rendering verification)
|
||||
|
||||
## Test Utilities
|
||||
|
||||
### Game State Helpers
|
||||
|
||||
Create complete game states for testing game logic:
|
||||
|
||||
```typescript
|
||||
import { createMockGameState, createGameScenario } from '@/test/helpers/gameTestUtils'
|
||||
|
||||
// Basic game state
|
||||
const game = createMockGameState({
|
||||
current_player_id: 'player1',
|
||||
turn_number: 1,
|
||||
phase: 'main'
|
||||
})
|
||||
|
||||
// Complete scenario with cards
|
||||
const scenario = createGameScenario({
|
||||
player1HandSize: 7,
|
||||
player2HandSize: 7,
|
||||
player1Active: createMockPokemonCard('fire', { name: 'Charmander', hp: 50 }),
|
||||
player2Active: createMockPokemonCard('water', { name: 'Squirtle', hp: 50 }),
|
||||
player1BenchSize: 3,
|
||||
player2BenchSize: 2,
|
||||
turnNumber: 5,
|
||||
currentPlayer: 'player1'
|
||||
})
|
||||
```
|
||||
|
||||
### Card Helpers
|
||||
|
||||
Create various types of cards:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createMockCardDefinition,
|
||||
createMockPokemonCard,
|
||||
createMockEnergyCard,
|
||||
createMockTrainerCard
|
||||
} from '@/test/helpers/gameTestUtils'
|
||||
|
||||
// Generic card
|
||||
const card = createMockCardDefinition({
|
||||
id: 'my-card',
|
||||
name: 'My Card',
|
||||
hp: 100
|
||||
})
|
||||
|
||||
// Type-specific helpers
|
||||
const pikachu = createMockPokemonCard('lightning', {
|
||||
name: 'Pikachu',
|
||||
hp: 60
|
||||
})
|
||||
|
||||
const fireEnergy = createMockEnergyCard('fire')
|
||||
|
||||
const potion = createMockTrainerCard('item', {
|
||||
name: 'Potion'
|
||||
})
|
||||
```
|
||||
|
||||
### Scene Setup
|
||||
|
||||
Quickly set up a mock scene for testing:
|
||||
|
||||
```typescript
|
||||
import { setupMockScene } from '@/test/helpers/gameTestUtils'
|
||||
|
||||
describe('MyScene', () => {
|
||||
it('initializes correctly', () => {
|
||||
const { scene, game } = setupMockScene('my-scene')
|
||||
|
||||
// Use scene and game in tests
|
||||
expect(scene.key).toBe('my-scene')
|
||||
expect(scene.add.container).toBeDefined()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### Pattern 1: Test Game Object Initialization
|
||||
|
||||
```typescript
|
||||
import { MockScene } from '@/test/mocks/phaser'
|
||||
import { Card } from '@/game/objects/Card'
|
||||
import { createMockCardDefinition } from '@/test/helpers/gameTestUtils'
|
||||
|
||||
describe('Card', () => {
|
||||
it('initializes with card data', () => {
|
||||
const scene = new MockScene('test')
|
||||
const cardData = createMockCardDefinition({
|
||||
name: 'Pikachu',
|
||||
hp: 60
|
||||
})
|
||||
|
||||
const card = new Card(scene, 0, 0, cardData)
|
||||
|
||||
expect(card.cardData.name).toBe('Pikachu')
|
||||
expect(card.cardData.hp).toBe(60)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern 2: Test Event Handling
|
||||
|
||||
```typescript
|
||||
import { MockScene } from '@/test/mocks/phaser'
|
||||
import { Board } from '@/game/objects/Board'
|
||||
|
||||
describe('Board', () => {
|
||||
it('emits zone click events', () => {
|
||||
const scene = new MockScene('test')
|
||||
const board = new Board(scene, gameState)
|
||||
|
||||
let clickedZone: string | null = null
|
||||
scene.events.on('zone:click', (zone: string) => {
|
||||
clickedZone = zone
|
||||
})
|
||||
|
||||
board.handleZoneClick('active')
|
||||
|
||||
expect(clickedZone).toBe('active')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern 3: Test State Synchronization
|
||||
|
||||
```typescript
|
||||
import { createMockGameState } from '@/test/helpers/gameTestUtils'
|
||||
import { StateRenderer } from '@/game/StateRenderer'
|
||||
|
||||
describe('StateRenderer', () => {
|
||||
it('updates board when state changes', () => {
|
||||
const initialState = createMockGameState()
|
||||
const renderer = new StateRenderer(scene)
|
||||
|
||||
renderer.render(initialState)
|
||||
|
||||
// Modify state
|
||||
const newState = {
|
||||
...initialState,
|
||||
turn_number: 2
|
||||
}
|
||||
|
||||
renderer.render(newState)
|
||||
|
||||
// Verify board updated
|
||||
expect(renderer.currentTurn).toBe(2)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern 4: Test Complex Scenarios
|
||||
|
||||
```typescript
|
||||
import { createGameScenario, addCardToRegistry } from '@/test/helpers/gameTestUtils'
|
||||
|
||||
describe('attack resolution', () => {
|
||||
it('applies damage to defending pokemon', () => {
|
||||
const pikachu = createMockPokemonCard('lightning', {
|
||||
id: 'pikachu',
|
||||
name: 'Pikachu',
|
||||
hp: 60,
|
||||
attacks: [{
|
||||
name: 'Thunder Shock',
|
||||
damage: 20,
|
||||
cost: ['lightning']
|
||||
}]
|
||||
})
|
||||
|
||||
const charmander = createMockPokemonCard('fire', {
|
||||
id: 'charmander',
|
||||
name: 'Charmander',
|
||||
hp: 50
|
||||
})
|
||||
|
||||
const scenario = createGameScenario({
|
||||
player1Active: pikachu,
|
||||
player2Active: charmander,
|
||||
currentPlayer: 'player1'
|
||||
})
|
||||
|
||||
const result = executeAttack(scenario, 'pikachu', 0)
|
||||
|
||||
expect(result.defendingCard.damage).toBe(20)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Keep Mocks Minimal
|
||||
|
||||
Only mock what you need. If a Phaser class has 50 methods but your code only uses 3, only mock those 3.
|
||||
|
||||
```typescript
|
||||
// ✅ Good - minimal mock
|
||||
class SimpleMock {
|
||||
x: number = 0
|
||||
y: number = 0
|
||||
setPosition(x: number, y: number) {
|
||||
this.x = x
|
||||
this.y = y
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ Bad - over-mocking
|
||||
class OverMock {
|
||||
// 47 unused methods...
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Test Logic, Not Rendering
|
||||
|
||||
Focus on testing game logic and state management, not visual rendering:
|
||||
|
||||
```typescript
|
||||
// ✅ Good - tests logic
|
||||
it('card takes damage correctly', () => {
|
||||
const card = createMockCardInstance({ hp: 60, damage: 0 })
|
||||
applyDamage(card, 20)
|
||||
expect(card.damage).toBe(20)
|
||||
})
|
||||
|
||||
// ❌ Bad - tests rendering (use Playwright for this)
|
||||
it('damage counter displays red when hp is low', () => {
|
||||
// Can't reliably test visual appearance in unit tests
|
||||
})
|
||||
```
|
||||
|
||||
### 3. Use Helpers for Consistency
|
||||
|
||||
Always use the provided helpers instead of manually creating objects:
|
||||
|
||||
```typescript
|
||||
// ✅ Good - uses helper
|
||||
const card = createMockCardDefinition({ name: 'Pikachu' })
|
||||
|
||||
// ❌ Bad - manual creation
|
||||
const card = {
|
||||
id: 'test',
|
||||
name: 'Pikachu',
|
||||
// Oops, forgot 15 other required fields!
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Test Edge Cases
|
||||
|
||||
Use the helpers to easily create edge case scenarios:
|
||||
|
||||
```typescript
|
||||
describe('game over detection', () => {
|
||||
it('detects win when opponent has no prizes', () => {
|
||||
const scenario = createGameScenario({
|
||||
player1Active: createMockPokemonCard('fire'),
|
||||
player2Active: createMockPokemonCard('water')
|
||||
})
|
||||
|
||||
// Set opponent prizes to 0
|
||||
scenario.players.player2.prizes_remaining = 0
|
||||
|
||||
const isGameOver = checkGameOver(scenario)
|
||||
expect(isGameOver).toBe(true)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: "TypeError: Cannot read property 'x' of undefined"
|
||||
|
||||
**Cause:** Trying to access a Phaser API that isn't mocked.
|
||||
|
||||
**Solution:** Add the missing property/method to the appropriate mock class in `mocks/phaser.ts`.
|
||||
|
||||
### Issue: "Expected X to be called but it wasn't"
|
||||
|
||||
**Cause:** Mocks don't automatically track method calls.
|
||||
|
||||
**Solution:** Use `vi.fn()` for methods you need to assert were called:
|
||||
|
||||
```typescript
|
||||
const scene = new MockScene('test')
|
||||
scene.add.sprite = vi.fn()
|
||||
|
||||
myCode.createSprite(scene)
|
||||
|
||||
expect(scene.add.sprite).toHaveBeenCalledWith(0, 0, 'texture')
|
||||
```
|
||||
|
||||
### Issue: "Tests pass but game doesn't work"
|
||||
|
||||
**Cause:** Mocks don't perfectly match real Phaser behavior.
|
||||
|
||||
**Solution:**
|
||||
1. Run manual testing in browser
|
||||
2. Add integration tests with real Phaser
|
||||
3. Add visual regression tests with Playwright
|
||||
|
||||
## See Also
|
||||
|
||||
- [Phaser 3 API Documentation](https://photonstorm.github.io/phaser3-docs/)
|
||||
- [Vitest Documentation](https://vitest.dev/)
|
||||
- [Vue Test Utils](https://test-utils.vuejs.org/)
|
||||
- `../game/README.md` - Game engine architecture docs
|
||||
- `../../TEST_COVERAGE_PLAN.md` - Overall testing strategy
|
||||
363
frontend/src/test/helpers/gameTestUtils.spec.ts
Normal file
363
frontend/src/test/helpers/gameTestUtils.spec.ts
Normal file
@ -0,0 +1,363 @@
|
||||
/**
|
||||
* Tests for game testing utilities.
|
||||
*
|
||||
* These tests verify that our helper functions create valid mock data
|
||||
* structures that match the game's type definitions.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import {
|
||||
createMockCardDefinition,
|
||||
createMockCardInstance,
|
||||
createMockZone,
|
||||
createMockPlayerState,
|
||||
createMockGameState,
|
||||
addCardToRegistry,
|
||||
createGameScenario,
|
||||
setupMockScene,
|
||||
createMockPokemonCard,
|
||||
createMockEnergyCard,
|
||||
createMockTrainerCard,
|
||||
} from './gameTestUtils'
|
||||
|
||||
describe('createMockCardDefinition', () => {
|
||||
it('creates card with default values', () => {
|
||||
/**
|
||||
* Test default card creation.
|
||||
*
|
||||
* Mock cards should have sensible defaults for all required fields.
|
||||
*/
|
||||
const card = createMockCardDefinition()
|
||||
|
||||
expect(card.id).toBeDefined()
|
||||
expect(card.name).toBeDefined()
|
||||
expect(card.card_type).toBe('pokemon')
|
||||
})
|
||||
|
||||
it('overrides default values', () => {
|
||||
/**
|
||||
* Test card customization.
|
||||
*
|
||||
* Overrides should replace default values.
|
||||
*/
|
||||
const card = createMockCardDefinition({
|
||||
name: 'Pikachu',
|
||||
hp: 60,
|
||||
pokemon_type: 'lightning',
|
||||
})
|
||||
|
||||
expect(card.name).toBe('Pikachu')
|
||||
expect(card.hp).toBe(60)
|
||||
expect(card.pokemon_type).toBe('lightning')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createMockCardInstance', () => {
|
||||
it('creates card instance with default values', () => {
|
||||
/**
|
||||
* Test default instance creation.
|
||||
*
|
||||
* Mock instances should track damage and status.
|
||||
*/
|
||||
const instance = createMockCardInstance()
|
||||
|
||||
expect(instance.instance_id).toBeDefined()
|
||||
expect(instance.damage).toBe(0)
|
||||
expect(instance.status_conditions).toEqual([])
|
||||
})
|
||||
|
||||
it('overrides default values', () => {
|
||||
/**
|
||||
* Test instance customization.
|
||||
*
|
||||
* Instances should support custom damage and status.
|
||||
*/
|
||||
const instance = createMockCardInstance({
|
||||
damage: 30,
|
||||
status_conditions: ['paralyzed'],
|
||||
})
|
||||
|
||||
expect(instance.damage).toBe(30)
|
||||
expect(instance.status_conditions).toContain('paralyzed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createMockZone', () => {
|
||||
it('creates empty zone by default', () => {
|
||||
/**
|
||||
* Test default zone creation.
|
||||
*
|
||||
* Zones should start empty.
|
||||
*/
|
||||
const zone = createMockZone()
|
||||
|
||||
expect(zone.card_ids).toEqual([])
|
||||
expect(zone.size).toBe(0)
|
||||
})
|
||||
|
||||
it('creates zone with cards', () => {
|
||||
/**
|
||||
* Test zone with content.
|
||||
*
|
||||
* Zones should support pre-populated card lists.
|
||||
*/
|
||||
const zone = createMockZone({
|
||||
zone_type: 'hand',
|
||||
card_ids: ['card-1', 'card-2', 'card-3'],
|
||||
size: 3,
|
||||
})
|
||||
|
||||
expect(zone.zone_type).toBe('hand')
|
||||
expect(zone.card_ids.length).toBe(3)
|
||||
expect(zone.size).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createMockPlayerState', () => {
|
||||
it('creates player with all zones', () => {
|
||||
/**
|
||||
* Test complete player state.
|
||||
*
|
||||
* Players should have all required zones initialized.
|
||||
*/
|
||||
const player = createMockPlayerState()
|
||||
|
||||
expect(player.player_id).toBeDefined()
|
||||
expect(player.hand).toBeDefined()
|
||||
expect(player.deck).toBeDefined()
|
||||
expect(player.discard).toBeDefined()
|
||||
expect(player.active).toBeDefined()
|
||||
expect(player.bench).toBeDefined()
|
||||
expect(player.prizes).toBeDefined()
|
||||
})
|
||||
|
||||
it('tracks game state flags', () => {
|
||||
/**
|
||||
* Test player state tracking.
|
||||
*
|
||||
* Players should track turn actions.
|
||||
*/
|
||||
const player = createMockPlayerState()
|
||||
|
||||
expect(player.has_drawn_for_turn).toBe(false)
|
||||
expect(player.has_attacked_this_turn).toBe(false)
|
||||
expect(player.has_played_energy_this_turn).toBe(false)
|
||||
})
|
||||
|
||||
it('overrides player zones', () => {
|
||||
/**
|
||||
* Test custom zone setup.
|
||||
*
|
||||
* Players should support custom zone configurations.
|
||||
*/
|
||||
const player = createMockPlayerState({
|
||||
hand: createMockZone({
|
||||
zone_type: 'hand',
|
||||
card_ids: ['card-1', 'card-2'],
|
||||
size: 2,
|
||||
}),
|
||||
})
|
||||
|
||||
expect(player.hand.card_ids.length).toBe(2)
|
||||
expect(player.hand.size).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createMockGameState', () => {
|
||||
it('creates game with two players', () => {
|
||||
/**
|
||||
* Test complete game state.
|
||||
*
|
||||
* Games should have both players initialized.
|
||||
*/
|
||||
const game = createMockGameState()
|
||||
|
||||
expect(game.game_id).toBeDefined()
|
||||
expect(game.players.player1).toBeDefined()
|
||||
expect(game.players.player2).toBeDefined()
|
||||
})
|
||||
|
||||
it('tracks turn state', () => {
|
||||
/**
|
||||
* Test turn tracking.
|
||||
*
|
||||
* Games should track current player and turn number.
|
||||
*/
|
||||
const game = createMockGameState()
|
||||
|
||||
expect(game.current_player_id).toBeDefined()
|
||||
expect(game.turn_number).toBe(1)
|
||||
expect(game.phase).toBe('main')
|
||||
})
|
||||
|
||||
it('has card registry', () => {
|
||||
/**
|
||||
* Test card registry.
|
||||
*
|
||||
* Games should provide a card definition lookup.
|
||||
*/
|
||||
const game = createMockGameState()
|
||||
|
||||
expect(game.card_registry).toBeDefined()
|
||||
expect(typeof game.card_registry).toBe('object')
|
||||
})
|
||||
})
|
||||
|
||||
describe('addCardToRegistry', () => {
|
||||
it('adds card to game registry', () => {
|
||||
/**
|
||||
* Test registry population.
|
||||
*
|
||||
* Cards should be added to the registry by ID.
|
||||
*/
|
||||
const game = createMockGameState()
|
||||
const card = createMockCardDefinition({ id: 'pikachu-001', name: 'Pikachu' })
|
||||
|
||||
addCardToRegistry(game, card)
|
||||
|
||||
expect(game.card_registry['pikachu-001']).toBe(card)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createGameScenario', () => {
|
||||
it('creates game with default setup', () => {
|
||||
/**
|
||||
* Test default scenario.
|
||||
*
|
||||
* Scenarios should create playable game states.
|
||||
*/
|
||||
const scenario = createGameScenario()
|
||||
|
||||
expect(scenario.players.player1.hand.size).toBe(7)
|
||||
expect(scenario.players.player2.hand.size).toBe(7)
|
||||
})
|
||||
|
||||
it('creates game with active pokemon', () => {
|
||||
/**
|
||||
* Test active pokemon setup.
|
||||
*
|
||||
* Scenarios should support setting active pokemon.
|
||||
*/
|
||||
const pikachu = createMockCardDefinition({ id: 'pikachu', name: 'Pikachu' })
|
||||
const charmander = createMockCardDefinition({ id: 'charmander', name: 'Charmander' })
|
||||
|
||||
const scenario = createGameScenario({
|
||||
player1Active: pikachu,
|
||||
player2Active: charmander,
|
||||
})
|
||||
|
||||
expect(scenario.players.player1.active.card_ids).toContain('pikachu')
|
||||
expect(scenario.players.player2.active.card_ids).toContain('charmander')
|
||||
expect(scenario.card_registry['pikachu']).toBe(pikachu)
|
||||
expect(scenario.card_registry['charmander']).toBe(charmander)
|
||||
})
|
||||
|
||||
it('creates game with bench pokemon', () => {
|
||||
/**
|
||||
* Test bench setup.
|
||||
*
|
||||
* Scenarios should support setting bench sizes.
|
||||
*/
|
||||
const scenario = createGameScenario({
|
||||
player1BenchSize: 3,
|
||||
player2BenchSize: 2,
|
||||
})
|
||||
|
||||
expect(scenario.players.player1.bench.size).toBe(3)
|
||||
expect(scenario.players.player2.bench.size).toBe(2)
|
||||
expect(scenario.players.player1.bench.card_ids.length).toBe(3)
|
||||
expect(scenario.players.player2.bench.card_ids.length).toBe(2)
|
||||
})
|
||||
|
||||
it('sets turn state', () => {
|
||||
/**
|
||||
* Test turn configuration.
|
||||
*
|
||||
* Scenarios should support custom turn states.
|
||||
*/
|
||||
const scenario = createGameScenario({
|
||||
turnNumber: 5,
|
||||
currentPlayer: 'player2',
|
||||
})
|
||||
|
||||
expect(scenario.turn_number).toBe(5)
|
||||
expect(scenario.current_player_id).toBe('player2')
|
||||
})
|
||||
|
||||
it('registers all created cards', () => {
|
||||
/**
|
||||
* Test registry completeness.
|
||||
*
|
||||
* All scenario cards should be in the registry.
|
||||
*/
|
||||
const scenario = createGameScenario({
|
||||
player1HandSize: 3,
|
||||
player2HandSize: 2,
|
||||
})
|
||||
|
||||
// All hand cards should be in registry
|
||||
scenario.players.player1.hand.card_ids.forEach((cardId) => {
|
||||
expect(scenario.card_registry[cardId]).toBeDefined()
|
||||
})
|
||||
|
||||
scenario.players.player2.hand.card_ids.forEach((cardId) => {
|
||||
expect(scenario.card_registry[cardId]).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('setupMockScene', () => {
|
||||
it('creates scene with game', () => {
|
||||
/**
|
||||
* Test scene setup.
|
||||
*
|
||||
* Scenes should be created with a game instance.
|
||||
*/
|
||||
const { scene, game } = setupMockScene('test-scene')
|
||||
|
||||
expect(scene.key).toBe('test-scene')
|
||||
expect(game).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('card type helpers', () => {
|
||||
it('createMockPokemonCard creates pokemon', () => {
|
||||
/**
|
||||
* Test pokemon card helper.
|
||||
*
|
||||
* Should create cards with correct type.
|
||||
*/
|
||||
const fireCard = createMockPokemonCard('fire', { name: 'Charmander', hp: 50 })
|
||||
|
||||
expect(fireCard.card_type).toBe('pokemon')
|
||||
expect(fireCard.pokemon_type).toBe('fire')
|
||||
expect(fireCard.name).toBe('Charmander')
|
||||
expect(fireCard.hp).toBe(50)
|
||||
})
|
||||
|
||||
it('createMockEnergyCard creates energy', () => {
|
||||
/**
|
||||
* Test energy card helper.
|
||||
*
|
||||
* Should create cards with correct type and name.
|
||||
*/
|
||||
const waterEnergy = createMockEnergyCard('water')
|
||||
|
||||
expect(waterEnergy.card_type).toBe('energy')
|
||||
expect(waterEnergy.pokemon_type).toBe('water')
|
||||
expect(waterEnergy.name).toContain('Water')
|
||||
})
|
||||
|
||||
it('createMockTrainerCard creates trainer', () => {
|
||||
/**
|
||||
* Test trainer card helper.
|
||||
*
|
||||
* Should create cards with correct type and trainer subtype.
|
||||
*/
|
||||
const potion = createMockTrainerCard('item', { name: 'Potion' })
|
||||
|
||||
expect(potion.card_type).toBe('trainer')
|
||||
expect(potion.trainer_type).toBe('item')
|
||||
expect(potion.name).toBe('Potion')
|
||||
})
|
||||
})
|
||||
402
frontend/src/test/helpers/gameTestUtils.ts
Normal file
402
frontend/src/test/helpers/gameTestUtils.ts
Normal file
@ -0,0 +1,402 @@
|
||||
/**
|
||||
* Game Testing Utilities
|
||||
*
|
||||
* Helper functions for creating mock game states, cards, and test scenarios.
|
||||
* These utilities simplify writing tests for game engine code by providing
|
||||
* pre-configured mock data that matches the game's data structures.
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* import { createMockGameState, createMockCard, setupMockScene } from '@/test/helpers/gameTestUtils'
|
||||
*
|
||||
* const gameState = createMockGameState({ currentPlayer: 'player1' })
|
||||
* const card = createMockCard({ name: 'Pikachu', hp: 60 })
|
||||
* const scene = setupMockScene()
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type {
|
||||
VisibleGameState,
|
||||
CardDefinition,
|
||||
CardInstance,
|
||||
VisiblePlayerState,
|
||||
VisibleZone,
|
||||
EnergyType,
|
||||
} from '@/types'
|
||||
import { MockScene, MockGame } from '@/test/mocks/phaser'
|
||||
|
||||
// =============================================================================
|
||||
// Card Mocks
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create a mock card definition for testing.
|
||||
*
|
||||
* Provides sensible defaults for all required fields, allowing you to
|
||||
* override only the fields relevant to your test.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const pikachu = createMockCardDefinition({
|
||||
* name: 'Pikachu',
|
||||
* hp: 60,
|
||||
* pokemon_type: 'lightning'
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function createMockCardDefinition(
|
||||
overrides: Partial<CardDefinition> = {}
|
||||
): CardDefinition {
|
||||
return {
|
||||
id: 'test-card-001',
|
||||
name: 'Test Card',
|
||||
card_type: 'pokemon',
|
||||
image_path: 'test/card.webp',
|
||||
image_url: 'https://example.com/test-card.webp',
|
||||
set_id: 'test-set',
|
||||
set_number: 1,
|
||||
rarity: 'common',
|
||||
stage: 'basic',
|
||||
hp: 60,
|
||||
pokemon_type: 'colorless',
|
||||
retreat_cost: [],
|
||||
attacks: [],
|
||||
weakness: undefined,
|
||||
resistance: undefined,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock card instance for testing.
|
||||
*
|
||||
* Card instances represent cards in play with damage counters and status conditions.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const activeCard = createMockCardInstance({
|
||||
* card_id: 'pikachu-001',
|
||||
* damage: 20,
|
||||
* status_conditions: ['paralyzed']
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function createMockCardInstance(
|
||||
overrides: Partial<CardInstance> = {}
|
||||
): CardInstance {
|
||||
return {
|
||||
instance_id: 'instance-001',
|
||||
card_id: 'test-card-001',
|
||||
owner_id: 'player1',
|
||||
zone: 'hand',
|
||||
zone_position: 0,
|
||||
damage: 0,
|
||||
status_conditions: [],
|
||||
attached_energy: [],
|
||||
modifiers: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Zone Mocks
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create a mock visible zone for testing.
|
||||
*
|
||||
* Zones contain cards and have size limits.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const hand = createMockZone({
|
||||
* zone_type: 'hand',
|
||||
* card_ids: ['card-1', 'card-2', 'card-3']
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function createMockZone(overrides: Partial<VisibleZone> = {}): VisibleZone {
|
||||
return {
|
||||
zone_type: 'hand',
|
||||
card_ids: [],
|
||||
size: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Player State Mocks
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create a mock player state for testing.
|
||||
*
|
||||
* Player states track zones, prize cards, and game status for one player.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const player = createMockPlayerState({
|
||||
* player_id: 'player1',
|
||||
* hand: createMockZone({ card_ids: ['card-1', 'card-2'] })
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function createMockPlayerState(
|
||||
overrides: Partial<VisiblePlayerState> = {}
|
||||
): VisiblePlayerState {
|
||||
return {
|
||||
player_id: 'player1',
|
||||
hand: createMockZone({ zone_type: 'hand' }),
|
||||
deck: createMockZone({ zone_type: 'deck', size: 60 }),
|
||||
discard: createMockZone({ zone_type: 'discard' }),
|
||||
active: createMockZone({ zone_type: 'active' }),
|
||||
bench: createMockZone({ zone_type: 'bench' }),
|
||||
prizes: createMockZone({ zone_type: 'prizes' }),
|
||||
prizes_remaining: 6,
|
||||
is_ready: true,
|
||||
has_drawn_for_turn: false,
|
||||
has_attacked_this_turn: false,
|
||||
has_played_energy_this_turn: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Game State Mocks
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create a mock game state for testing.
|
||||
*
|
||||
* Game states represent the complete visible game state for one player.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const gameState = createMockGameState({
|
||||
* current_player_id: 'player1',
|
||||
* turn_number: 3,
|
||||
* phase: 'main'
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function createMockGameState(
|
||||
overrides: Partial<VisibleGameState> = {}
|
||||
): VisibleGameState {
|
||||
const player1 = createMockPlayerState({ player_id: 'player1' })
|
||||
const player2 = createMockPlayerState({ player_id: 'player2' })
|
||||
|
||||
return {
|
||||
game_id: 'game-001',
|
||||
viewer_id: 'player1',
|
||||
current_player_id: 'player1',
|
||||
turn_number: 1,
|
||||
phase: 'main',
|
||||
players: {
|
||||
player1,
|
||||
player2,
|
||||
},
|
||||
card_registry: {},
|
||||
pending_selection: null,
|
||||
game_over: false,
|
||||
winner_id: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a card definition to the game state's card registry.
|
||||
*
|
||||
* This is necessary for the game to look up card data by ID.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const gameState = createMockGameState()
|
||||
* const pikachu = createMockCardDefinition({ id: 'pikachu-001', name: 'Pikachu' })
|
||||
* addCardToRegistry(gameState, pikachu)
|
||||
* ```
|
||||
*/
|
||||
export function addCardToRegistry(
|
||||
gameState: VisibleGameState,
|
||||
card: CardDefinition
|
||||
): void {
|
||||
gameState.card_registry[card.id] = card
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a complete game scenario with players holding cards.
|
||||
*
|
||||
* Useful for integration tests that need a fully-set-up game.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const scenario = createGameScenario({
|
||||
* player1HandSize: 7,
|
||||
* player2HandSize: 7,
|
||||
* player1Active: createMockCardDefinition({ name: 'Pikachu' }),
|
||||
* player2Active: createMockCardDefinition({ name: 'Charmander' })
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export interface GameScenarioOptions {
|
||||
player1HandSize?: number
|
||||
player2HandSize?: number
|
||||
player1Active?: CardDefinition
|
||||
player2Active?: CardDefinition
|
||||
player1BenchSize?: number
|
||||
player2BenchSize?: number
|
||||
turnNumber?: number
|
||||
currentPlayer?: 'player1' | 'player2'
|
||||
}
|
||||
|
||||
export function createGameScenario(
|
||||
options: GameScenarioOptions = {}
|
||||
): VisibleGameState {
|
||||
const {
|
||||
player1HandSize = 7,
|
||||
player2HandSize = 7,
|
||||
player1Active,
|
||||
player2Active,
|
||||
player1BenchSize = 0,
|
||||
player2BenchSize = 0,
|
||||
turnNumber = 1,
|
||||
currentPlayer = 'player1',
|
||||
} = options
|
||||
|
||||
const gameState = createMockGameState({
|
||||
turn_number: turnNumber,
|
||||
current_player_id: currentPlayer,
|
||||
})
|
||||
|
||||
// Create hand cards for player 1
|
||||
for (let i = 0; i < player1HandSize; i++) {
|
||||
const card = createMockCardDefinition({ id: `p1-hand-${i}`, name: `P1 Card ${i}` })
|
||||
addCardToRegistry(gameState, card)
|
||||
gameState.players.player1.hand.card_ids.push(card.id)
|
||||
}
|
||||
gameState.players.player1.hand.size = player1HandSize
|
||||
|
||||
// Create hand cards for player 2
|
||||
for (let i = 0; i < player2HandSize; i++) {
|
||||
const card = createMockCardDefinition({ id: `p2-hand-${i}`, name: `P2 Card ${i}` })
|
||||
addCardToRegistry(gameState, card)
|
||||
gameState.players.player2.hand.card_ids.push(card.id)
|
||||
}
|
||||
gameState.players.player2.hand.size = player2HandSize
|
||||
|
||||
// Set active cards
|
||||
if (player1Active) {
|
||||
addCardToRegistry(gameState, player1Active)
|
||||
gameState.players.player1.active.card_ids.push(player1Active.id)
|
||||
gameState.players.player1.active.size = 1
|
||||
}
|
||||
|
||||
if (player2Active) {
|
||||
addCardToRegistry(gameState, player2Active)
|
||||
gameState.players.player2.active.card_ids.push(player2Active.id)
|
||||
gameState.players.player2.active.size = 1
|
||||
}
|
||||
|
||||
// Create bench cards for player 1
|
||||
for (let i = 0; i < player1BenchSize; i++) {
|
||||
const card = createMockCardDefinition({ id: `p1-bench-${i}`, name: `P1 Bench ${i}` })
|
||||
addCardToRegistry(gameState, card)
|
||||
gameState.players.player1.bench.card_ids.push(card.id)
|
||||
}
|
||||
gameState.players.player1.bench.size = player1BenchSize
|
||||
|
||||
// Create bench cards for player 2
|
||||
for (let i = 0; i < player2BenchSize; i++) {
|
||||
const card = createMockCardDefinition({ id: `p2-bench-${i}`, name: `P2 Bench ${i}` })
|
||||
addCardToRegistry(gameState, card)
|
||||
gameState.players.player2.bench.card_ids.push(card.id)
|
||||
}
|
||||
gameState.players.player2.bench.size = player2BenchSize
|
||||
|
||||
return gameState
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Phaser Scene Setup
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create a configured mock scene for testing.
|
||||
*
|
||||
* Returns a MockScene with commonly-needed spies already set up.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const { scene, spies } = setupMockScene()
|
||||
* // Use scene in tests
|
||||
* expect(spies.add.container).toHaveBeenCalled()
|
||||
* ```
|
||||
*/
|
||||
export function setupMockScene(key: string = 'test-scene') {
|
||||
const game = new MockGame()
|
||||
const scene = new MockScene(key, game)
|
||||
|
||||
return {
|
||||
scene,
|
||||
game,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock card definition with specific energy type.
|
||||
*
|
||||
* Shorthand for creating pokemon cards of different types.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const fireCard = createMockPokemonCard('fire', { name: 'Charmander', hp: 50 })
|
||||
* const waterCard = createMockPokemonCard('water', { name: 'Squirtle', hp: 50 })
|
||||
* ```
|
||||
*/
|
||||
export function createMockPokemonCard(
|
||||
type: EnergyType,
|
||||
overrides: Partial<CardDefinition> = {}
|
||||
): CardDefinition {
|
||||
return createMockCardDefinition({
|
||||
card_type: 'pokemon',
|
||||
pokemon_type: type,
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock energy card definition.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const fireEnergy = createMockEnergyCard('fire')
|
||||
* const waterEnergy = createMockEnergyCard('water')
|
||||
* ```
|
||||
*/
|
||||
export function createMockEnergyCard(type: EnergyType): CardDefinition {
|
||||
return createMockCardDefinition({
|
||||
id: `energy-${type}`,
|
||||
name: `${type.charAt(0).toUpperCase() + type.slice(1)} Energy`,
|
||||
card_type: 'energy',
|
||||
pokemon_type: type,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock trainer card definition.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const potion = createMockTrainerCard('item', { name: 'Potion' })
|
||||
* const profOak = createMockTrainerCard('supporter', { name: "Professor Oak" })
|
||||
* ```
|
||||
*/
|
||||
export function createMockTrainerCard(
|
||||
trainerType: 'item' | 'supporter' | 'stadium',
|
||||
overrides: Partial<CardDefinition> = {}
|
||||
): CardDefinition {
|
||||
return createMockCardDefinition({
|
||||
card_type: 'trainer',
|
||||
trainer_type: trainerType,
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
468
frontend/src/test/mocks/phaser.spec.ts
Normal file
468
frontend/src/test/mocks/phaser.spec.ts
Normal file
@ -0,0 +1,468 @@
|
||||
/**
|
||||
* Tests for Phaser mocks.
|
||||
*
|
||||
* These tests verify that our Phaser mocks provide the correct API
|
||||
* surface for testing game engine code.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
|
||||
import {
|
||||
MockEventEmitter,
|
||||
MockContainer,
|
||||
MockGameObject,
|
||||
MockSprite,
|
||||
MockText,
|
||||
MockGraphics,
|
||||
MockScene,
|
||||
MockGame,
|
||||
createMockGame,
|
||||
} from './phaser'
|
||||
|
||||
describe('MockEventEmitter', () => {
|
||||
let emitter: MockEventEmitter
|
||||
|
||||
beforeEach(() => {
|
||||
emitter = new MockEventEmitter()
|
||||
})
|
||||
|
||||
it('registers event listeners with on()', () => {
|
||||
/**
|
||||
* Test basic event registration.
|
||||
*
|
||||
* Event emitters must support adding listeners for events.
|
||||
*/
|
||||
const callback = () => {}
|
||||
emitter.on('test', callback)
|
||||
|
||||
expect(emitter.listenerCount('test')).toBe(1)
|
||||
})
|
||||
|
||||
it('emits events to registered listeners', () => {
|
||||
/**
|
||||
* Test event emission.
|
||||
*
|
||||
* When events are emitted, all registered listeners should be called.
|
||||
*/
|
||||
let callCount = 0
|
||||
emitter.on('test', () => {
|
||||
callCount++
|
||||
})
|
||||
|
||||
emitter.emit('test')
|
||||
expect(callCount).toBe(1)
|
||||
})
|
||||
|
||||
it('passes arguments to event listeners', () => {
|
||||
/**
|
||||
* Test event argument passing.
|
||||
*
|
||||
* Event data must be passed through to listeners correctly.
|
||||
*/
|
||||
let receivedArg: string | null = null
|
||||
emitter.on('test', (arg: string) => {
|
||||
receivedArg = arg
|
||||
})
|
||||
|
||||
emitter.emit('test', 'hello')
|
||||
expect(receivedArg).toBe('hello')
|
||||
})
|
||||
|
||||
it('removes listeners with off()', () => {
|
||||
/**
|
||||
* Test listener removal.
|
||||
*
|
||||
* Listeners must be removable to prevent memory leaks.
|
||||
*/
|
||||
const callback = () => {}
|
||||
emitter.on('test', callback)
|
||||
emitter.off('test', callback)
|
||||
|
||||
expect(emitter.listenerCount('test')).toBe(0)
|
||||
})
|
||||
|
||||
it('supports once() for one-time listeners', () => {
|
||||
/**
|
||||
* Test one-time event listeners.
|
||||
*
|
||||
* once() listeners should fire once then auto-remove.
|
||||
*/
|
||||
let callCount = 0
|
||||
emitter.once('test', () => {
|
||||
callCount++
|
||||
})
|
||||
|
||||
emitter.emit('test')
|
||||
emitter.emit('test')
|
||||
|
||||
expect(callCount).toBe(1)
|
||||
})
|
||||
|
||||
it('removes all listeners for an event', () => {
|
||||
/**
|
||||
* Test bulk listener removal.
|
||||
*
|
||||
* off() without a callback should remove all listeners for an event.
|
||||
*/
|
||||
emitter.on('test', () => {})
|
||||
emitter.on('test', () => {})
|
||||
emitter.off('test')
|
||||
|
||||
expect(emitter.listenerCount('test')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MockContainer', () => {
|
||||
let scene: MockScene
|
||||
let container: MockContainer
|
||||
|
||||
beforeEach(() => {
|
||||
scene = new MockScene('test')
|
||||
container = new MockContainer(scene, 100, 200)
|
||||
})
|
||||
|
||||
it('initializes with position', () => {
|
||||
/**
|
||||
* Test container positioning.
|
||||
*
|
||||
* Containers must track their x/y position.
|
||||
*/
|
||||
expect(container.x).toBe(100)
|
||||
expect(container.y).toBe(200)
|
||||
})
|
||||
|
||||
it('adds children to list', () => {
|
||||
/**
|
||||
* Test child management.
|
||||
*
|
||||
* Containers must maintain a list of child objects.
|
||||
*/
|
||||
const child = new MockGameObject(scene)
|
||||
container.add(child)
|
||||
|
||||
expect(container.list).toContain(child)
|
||||
expect(container.list.length).toBe(1)
|
||||
})
|
||||
|
||||
it('removes children from list', () => {
|
||||
/**
|
||||
* Test child removal.
|
||||
*
|
||||
* Containers must support removing specific children.
|
||||
*/
|
||||
const child = new MockGameObject(scene)
|
||||
container.add(child)
|
||||
container.remove(child)
|
||||
|
||||
expect(container.list).not.toContain(child)
|
||||
expect(container.list.length).toBe(0)
|
||||
})
|
||||
|
||||
it('destroys children when destroyChild flag is true', () => {
|
||||
/**
|
||||
* Test cascading destruction.
|
||||
*
|
||||
* When removing children with destroyChild=true, the child
|
||||
* should be destroyed (active set to false).
|
||||
*/
|
||||
const child = new MockGameObject(scene)
|
||||
container.add(child)
|
||||
container.remove(child, true)
|
||||
|
||||
expect(child.active).toBe(false)
|
||||
})
|
||||
|
||||
it('sets position with setPosition()', () => {
|
||||
/**
|
||||
* Test position setter.
|
||||
*
|
||||
* Containers must support fluent position setting.
|
||||
*/
|
||||
container.setPosition(300, 400)
|
||||
|
||||
expect(container.x).toBe(300)
|
||||
expect(container.y).toBe(400)
|
||||
})
|
||||
|
||||
it('sets visibility with setVisible()', () => {
|
||||
/**
|
||||
* Test visibility control.
|
||||
*
|
||||
* Containers must support showing/hiding.
|
||||
*/
|
||||
container.setVisible(false)
|
||||
expect(container.visible).toBe(false)
|
||||
|
||||
container.setVisible(true)
|
||||
expect(container.visible).toBe(true)
|
||||
})
|
||||
|
||||
it('chains method calls', () => {
|
||||
/**
|
||||
* Test method chaining.
|
||||
*
|
||||
* Phaser uses fluent interfaces - methods should return 'this'.
|
||||
*/
|
||||
const result = container.setPosition(0, 0).setVisible(true).setAlpha(0.5)
|
||||
|
||||
expect(result).toBe(container)
|
||||
expect(container.alpha).toBe(0.5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MockSprite', () => {
|
||||
let scene: MockScene
|
||||
let sprite: MockSprite
|
||||
|
||||
beforeEach(() => {
|
||||
scene = new MockScene('test')
|
||||
sprite = new MockSprite(scene, 100, 200, 'card-texture')
|
||||
})
|
||||
|
||||
it('initializes with texture', () => {
|
||||
/**
|
||||
* Test sprite texture tracking.
|
||||
*
|
||||
* Sprites must track their texture key.
|
||||
*/
|
||||
expect(sprite.texture.key).toBe('card-texture')
|
||||
})
|
||||
|
||||
it('changes texture with setTexture()', () => {
|
||||
/**
|
||||
* Test texture swapping.
|
||||
*
|
||||
* Sprites must support changing textures dynamically.
|
||||
*/
|
||||
sprite.setTexture('new-texture')
|
||||
expect(sprite.texture.key).toBe('new-texture')
|
||||
})
|
||||
|
||||
it('supports frame selection', () => {
|
||||
/**
|
||||
* Test sprite frame selection.
|
||||
*
|
||||
* Sprites using spritesheets must support frame selection.
|
||||
*/
|
||||
sprite.setFrame(5)
|
||||
expect(sprite.frame).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MockText', () => {
|
||||
let scene: MockScene
|
||||
let text: MockText
|
||||
|
||||
beforeEach(() => {
|
||||
scene = new MockScene('test')
|
||||
text = new MockText(scene, 100, 200, 'Hello', { fontSize: '16px' })
|
||||
})
|
||||
|
||||
it('initializes with text and style', () => {
|
||||
/**
|
||||
* Test text object initialization.
|
||||
*
|
||||
* Text objects must store both content and styling.
|
||||
*/
|
||||
expect(text.text).toBe('Hello')
|
||||
expect(text.style.fontSize).toBe('16px')
|
||||
})
|
||||
|
||||
it('updates text with setText()', () => {
|
||||
/**
|
||||
* Test text content updates.
|
||||
*
|
||||
* Text objects must support changing displayed text.
|
||||
*/
|
||||
text.setText('World')
|
||||
expect(text.text).toBe('World')
|
||||
})
|
||||
|
||||
it('updates style with setStyle()', () => {
|
||||
/**
|
||||
* Test style updates.
|
||||
*
|
||||
* Text objects must support style changes without recreating.
|
||||
*/
|
||||
text.setStyle({ color: '#ff0000' })
|
||||
expect(text.style.color).toBe('#ff0000')
|
||||
expect(text.style.fontSize).toBe('16px') // Preserves existing style
|
||||
})
|
||||
})
|
||||
|
||||
describe('MockGraphics', () => {
|
||||
let scene: MockScene
|
||||
let graphics: MockGraphics
|
||||
|
||||
beforeEach(() => {
|
||||
scene = new MockScene('test')
|
||||
graphics = new MockGraphics(scene)
|
||||
})
|
||||
|
||||
it('supports fill operations', () => {
|
||||
/**
|
||||
* Test graphics fill API.
|
||||
*
|
||||
* Graphics must support setting fill styles and drawing shapes.
|
||||
*/
|
||||
expect(() => {
|
||||
graphics.fillStyle(0xff0000, 0.5)
|
||||
graphics.fillRect(0, 0, 100, 100)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('supports stroke operations', () => {
|
||||
/**
|
||||
* Test graphics stroke API.
|
||||
*
|
||||
* Graphics must support setting line styles and drawing outlines.
|
||||
*/
|
||||
expect(() => {
|
||||
graphics.lineStyle(2, 0x00ff00, 1)
|
||||
graphics.strokeRect(0, 0, 100, 100)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('chains drawing methods', () => {
|
||||
/**
|
||||
* Test method chaining for graphics.
|
||||
*
|
||||
* Graphics operations should be chainable for cleaner code.
|
||||
*/
|
||||
const result = graphics.fillStyle(0xff0000).fillCircle(50, 50, 25).lineStyle(2).strokeCircle(50, 50, 25)
|
||||
|
||||
expect(result).toBe(graphics)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MockScene', () => {
|
||||
let scene: MockScene
|
||||
|
||||
beforeEach(() => {
|
||||
scene = new MockScene('test-scene')
|
||||
})
|
||||
|
||||
it('initializes with scene key', () => {
|
||||
/**
|
||||
* Test scene key tracking.
|
||||
*
|
||||
* Scenes must have unique identifiers.
|
||||
*/
|
||||
expect(scene.key).toBe('test-scene')
|
||||
})
|
||||
|
||||
it('has event emitter', () => {
|
||||
/**
|
||||
* Test scene event system.
|
||||
*
|
||||
* Scenes must provide event emitters for lifecycle events.
|
||||
*/
|
||||
expect(scene.events).toBeInstanceOf(MockEventEmitter)
|
||||
})
|
||||
|
||||
it('has loader', () => {
|
||||
/**
|
||||
* Test scene loader presence.
|
||||
*
|
||||
* Scenes must provide asset loading capabilities.
|
||||
*/
|
||||
expect(scene.load).toBeDefined()
|
||||
})
|
||||
|
||||
it('has add factory', () => {
|
||||
/**
|
||||
* Test game object factory.
|
||||
*
|
||||
* Scenes must provide factory methods to create game objects.
|
||||
*/
|
||||
expect(scene.add).toBeDefined()
|
||||
})
|
||||
|
||||
it('creates containers via add.container()', () => {
|
||||
/**
|
||||
* Test container creation.
|
||||
*
|
||||
* The add factory must support creating containers.
|
||||
*/
|
||||
const container = scene.add.container(100, 200)
|
||||
|
||||
expect(container).toBeInstanceOf(MockContainer)
|
||||
expect(container.x).toBe(100)
|
||||
expect(container.y).toBe(200)
|
||||
})
|
||||
|
||||
it('creates sprites via add.sprite()', () => {
|
||||
/**
|
||||
* Test sprite creation.
|
||||
*
|
||||
* The add factory must support creating sprites.
|
||||
*/
|
||||
const sprite = scene.add.sprite(50, 50, 'texture')
|
||||
|
||||
expect(sprite).toBeInstanceOf(MockSprite)
|
||||
expect(sprite.texture.key).toBe('texture')
|
||||
})
|
||||
|
||||
it('emits shutdown event', () => {
|
||||
/**
|
||||
* Test scene shutdown lifecycle.
|
||||
*
|
||||
* Scenes must emit shutdown events for cleanup.
|
||||
*/
|
||||
let shutdownCalled = false
|
||||
scene.events.on('shutdown', () => {
|
||||
shutdownCalled = true
|
||||
})
|
||||
|
||||
scene.shutdown()
|
||||
|
||||
expect(shutdownCalled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MockGame', () => {
|
||||
let game: MockGame
|
||||
|
||||
beforeEach(() => {
|
||||
game = createMockGame()
|
||||
})
|
||||
|
||||
it('has event emitter', () => {
|
||||
/**
|
||||
* Test game event system.
|
||||
*
|
||||
* Games must provide global event emitters.
|
||||
*/
|
||||
expect(game.events).toBeInstanceOf(MockEventEmitter)
|
||||
})
|
||||
|
||||
it('has scale manager', () => {
|
||||
/**
|
||||
* Test scale manager presence.
|
||||
*
|
||||
* Games must track canvas dimensions and support resizing.
|
||||
*/
|
||||
expect(game.scale.width).toBeDefined()
|
||||
expect(game.scale.height).toBeDefined()
|
||||
expect(game.scale.resize).toBeDefined()
|
||||
})
|
||||
|
||||
it('has scene manager', () => {
|
||||
/**
|
||||
* Test scene manager presence.
|
||||
*
|
||||
* Games must support adding/removing/starting scenes.
|
||||
*/
|
||||
expect(game.scene.add).toBeDefined()
|
||||
expect(game.scene.remove).toBeDefined()
|
||||
expect(game.scene.start).toBeDefined()
|
||||
})
|
||||
|
||||
it('has destroy method', () => {
|
||||
/**
|
||||
* Test game cleanup.
|
||||
*
|
||||
* Games must support full destruction for cleanup.
|
||||
*/
|
||||
expect(game.destroy).toBeDefined()
|
||||
})
|
||||
})
|
||||
628
frontend/src/test/mocks/phaser.ts
Normal file
628
frontend/src/test/mocks/phaser.ts
Normal file
@ -0,0 +1,628 @@
|
||||
/**
|
||||
* Phaser Testing Mocks
|
||||
*
|
||||
* Comprehensive mock implementations of Phaser classes for testing game engine code.
|
||||
* These mocks provide the minimal API surface needed to test game logic without
|
||||
* requiring WebGL/Canvas support in jsdom.
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* import { MockScene, MockContainer, createMockGame } from '@/test/mocks/phaser'
|
||||
*
|
||||
* const scene = new MockScene('test-scene')
|
||||
* const container = new MockContainer(scene, 100, 200)
|
||||
* ```
|
||||
*
|
||||
* @see https://photonstorm.github.io/phaser3-docs/ - Real Phaser API reference
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest'
|
||||
|
||||
// =============================================================================
|
||||
// Event System
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Mock event emitter that simulates Phaser.Events.EventEmitter.
|
||||
*
|
||||
* Provides on/once/off/emit methods for event-driven code.
|
||||
*/
|
||||
export class MockEventEmitter {
|
||||
private listeners: Map<string, Set<(...args: unknown[]) => void>> = new Map()
|
||||
|
||||
on(event: string, callback: (...args: unknown[]) => void, _context?: unknown): this {
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, new Set())
|
||||
}
|
||||
this.listeners.get(event)!.add(callback)
|
||||
return this
|
||||
}
|
||||
|
||||
once(event: string, callback: (...args: unknown[]) => void, _context?: unknown): this {
|
||||
const wrapper = (...args: unknown[]) => {
|
||||
this.off(event, wrapper)
|
||||
callback(...args)
|
||||
}
|
||||
return this.on(event, wrapper)
|
||||
}
|
||||
|
||||
off(event: string, callback?: (...args: unknown[]) => void, _context?: unknown): this {
|
||||
if (!callback) {
|
||||
this.listeners.delete(event)
|
||||
} else {
|
||||
this.listeners.get(event)?.delete(callback)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
emit(event: string, ...args: unknown[]): boolean {
|
||||
const callbacks = this.listeners.get(event)
|
||||
if (callbacks) {
|
||||
callbacks.forEach((cb) => cb(...args))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
removeAllListeners(event?: string): this {
|
||||
if (event) {
|
||||
this.listeners.delete(event)
|
||||
} else {
|
||||
this.listeners.clear()
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
listenerCount(event: string): number {
|
||||
return this.listeners.get(event)?.size ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GameObject Mocks
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Mock container that simulates Phaser.GameObjects.Container.
|
||||
*
|
||||
* Containers group multiple game objects together and manage their
|
||||
* position, visibility, and lifecycle.
|
||||
*/
|
||||
export class MockContainer {
|
||||
x: number
|
||||
y: number
|
||||
width: number = 0
|
||||
height: number = 0
|
||||
visible: boolean = true
|
||||
alpha: number = 1
|
||||
rotation: number = 0
|
||||
scale: number = 1
|
||||
scaleX: number = 1
|
||||
scaleY: number = 1
|
||||
depth: number = 0
|
||||
name: string = ''
|
||||
active: boolean = true
|
||||
|
||||
scene: MockScene
|
||||
list: MockGameObject[] = []
|
||||
|
||||
constructor(scene: MockScene, x: number = 0, y: number = 0) {
|
||||
this.scene = scene
|
||||
this.x = x
|
||||
this.y = y
|
||||
}
|
||||
|
||||
add(child: MockGameObject | MockGameObject[]): this {
|
||||
const children = Array.isArray(child) ? child : [child]
|
||||
this.list.push(...children)
|
||||
return this
|
||||
}
|
||||
|
||||
remove(child: MockGameObject, destroyChild?: boolean): this {
|
||||
const index = this.list.indexOf(child)
|
||||
if (index !== -1) {
|
||||
this.list.splice(index, 1)
|
||||
if (destroyChild) {
|
||||
child.destroy()
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
removeAll(destroyChild?: boolean): this {
|
||||
if (destroyChild) {
|
||||
this.list.forEach((child) => child.destroy())
|
||||
}
|
||||
this.list = []
|
||||
return this
|
||||
}
|
||||
|
||||
getAt(index: number): MockGameObject | null {
|
||||
return this.list[index] ?? null
|
||||
}
|
||||
|
||||
getIndex(child: MockGameObject): number {
|
||||
return this.list.indexOf(child)
|
||||
}
|
||||
|
||||
setPosition(x: number, y?: number): this {
|
||||
this.x = x
|
||||
this.y = y ?? x
|
||||
return this
|
||||
}
|
||||
|
||||
setVisible(value: boolean): this {
|
||||
this.visible = value
|
||||
return this
|
||||
}
|
||||
|
||||
setAlpha(value: number): this {
|
||||
this.alpha = value
|
||||
return this
|
||||
}
|
||||
|
||||
setDepth(value: number): this {
|
||||
this.depth = value
|
||||
return this
|
||||
}
|
||||
|
||||
setInteractive(_config?: unknown): this {
|
||||
// Mock interactive setup
|
||||
return this
|
||||
}
|
||||
|
||||
disableInteractive(): this {
|
||||
// Mock interactive teardown
|
||||
return this
|
||||
}
|
||||
|
||||
setSize(width: number, height: number): this {
|
||||
this.width = width
|
||||
this.height = height
|
||||
return this
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.list.forEach((child) => child.destroy())
|
||||
this.list = []
|
||||
this.active = false
|
||||
}
|
||||
|
||||
update(): void {
|
||||
// Override in subclasses
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Base mock game object.
|
||||
*
|
||||
* Provides common properties shared by all Phaser game objects.
|
||||
*/
|
||||
export class MockGameObject {
|
||||
x: number = 0
|
||||
y: number = 0
|
||||
width: number = 0
|
||||
height: number = 0
|
||||
visible: boolean = true
|
||||
alpha: number = 1
|
||||
rotation: number = 0
|
||||
scale: number = 1
|
||||
scaleX: number = 1
|
||||
scaleY: number = 1
|
||||
depth: number = 0
|
||||
name: string = ''
|
||||
active: boolean = true
|
||||
|
||||
scene: MockScene
|
||||
|
||||
constructor(scene: MockScene, x: number = 0, y: number = 0) {
|
||||
this.scene = scene
|
||||
this.x = x
|
||||
this.y = y
|
||||
}
|
||||
|
||||
setPosition(x: number, y?: number): this {
|
||||
this.x = x
|
||||
this.y = y ?? x
|
||||
return this
|
||||
}
|
||||
|
||||
setVisible(value: boolean): this {
|
||||
this.visible = value
|
||||
return this
|
||||
}
|
||||
|
||||
setAlpha(value: number): this {
|
||||
this.alpha = value
|
||||
return this
|
||||
}
|
||||
|
||||
setDepth(value: number): this {
|
||||
this.depth = value
|
||||
return this
|
||||
}
|
||||
|
||||
setScale(x: number, y?: number): this {
|
||||
this.scaleX = x
|
||||
this.scaleY = y ?? x
|
||||
this.scale = x
|
||||
return this
|
||||
}
|
||||
|
||||
setRotation(radians: number): this {
|
||||
this.rotation = radians
|
||||
return this
|
||||
}
|
||||
|
||||
setInteractive(_config?: unknown): this {
|
||||
return this
|
||||
}
|
||||
|
||||
disableInteractive(): this {
|
||||
return this
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.active = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock sprite that simulates Phaser.GameObjects.Sprite.
|
||||
*
|
||||
* Sprites display textures/images in the game world.
|
||||
*/
|
||||
export class MockSprite extends MockGameObject {
|
||||
texture: { key: string }
|
||||
frame: string | number
|
||||
|
||||
constructor(
|
||||
scene: MockScene,
|
||||
x: number = 0,
|
||||
y: number = 0,
|
||||
texture: string = '',
|
||||
frame?: string | number
|
||||
) {
|
||||
super(scene, x, y)
|
||||
this.texture = { key: texture }
|
||||
this.frame = frame ?? 0
|
||||
}
|
||||
|
||||
setTexture(key: string, frame?: string | number): this {
|
||||
this.texture = { key }
|
||||
if (frame !== undefined) {
|
||||
this.frame = frame
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
setFrame(frame: string | number): this {
|
||||
this.frame = frame
|
||||
return this
|
||||
}
|
||||
|
||||
play(_key: string): this {
|
||||
// Mock animation playback
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock text that simulates Phaser.GameObjects.Text.
|
||||
*
|
||||
* Text objects display styled text strings.
|
||||
*/
|
||||
export class MockText extends MockGameObject {
|
||||
text: string
|
||||
style: Record<string, unknown>
|
||||
|
||||
constructor(
|
||||
scene: MockScene,
|
||||
x: number = 0,
|
||||
y: number = 0,
|
||||
text: string = '',
|
||||
style: Record<string, unknown> = {}
|
||||
) {
|
||||
super(scene, x, y)
|
||||
this.text = text
|
||||
this.style = style
|
||||
}
|
||||
|
||||
setText(text: string): this {
|
||||
this.text = text
|
||||
return this
|
||||
}
|
||||
|
||||
setStyle(style: Record<string, unknown>): this {
|
||||
this.style = { ...this.style, ...style }
|
||||
return this
|
||||
}
|
||||
|
||||
setFontSize(size: number): this {
|
||||
this.style.fontSize = `${size}px`
|
||||
return this
|
||||
}
|
||||
|
||||
setColor(color: string): this {
|
||||
this.style.color = color
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock graphics that simulates Phaser.GameObjects.Graphics.
|
||||
*
|
||||
* Graphics objects draw shapes, lines, and fills.
|
||||
*/
|
||||
export class MockGraphics extends MockGameObject {
|
||||
private fillColorValue: number = 0xffffff
|
||||
private fillAlphaValue: number = 1
|
||||
private lineColorValue: number = 0xffffff
|
||||
private lineWidthValue: number = 1
|
||||
private lineAlphaValue: number = 1
|
||||
|
||||
fillStyle(color: number, alpha: number = 1): this {
|
||||
this.fillColorValue = color
|
||||
this.fillAlphaValue = alpha
|
||||
return this
|
||||
}
|
||||
|
||||
lineStyle(width: number, color: number = 0xffffff, alpha: number = 1): this {
|
||||
this.lineWidthValue = width
|
||||
this.lineColorValue = color
|
||||
this.lineAlphaValue = alpha
|
||||
return this
|
||||
}
|
||||
|
||||
fillRect(_x: number, _y: number, _width: number, _height: number): this {
|
||||
return this
|
||||
}
|
||||
|
||||
fillRoundedRect(_x: number, _y: number, _width: number, _height: number, _radius: number): this {
|
||||
return this
|
||||
}
|
||||
|
||||
strokeRect(_x: number, _y: number, _width: number, _height: number): this {
|
||||
return this
|
||||
}
|
||||
|
||||
strokeRoundedRect(_x: number, _y: number, _width: number, _height: number, _radius: number): this {
|
||||
return this
|
||||
}
|
||||
|
||||
fillCircle(_x: number, _y: number, _radius: number): this {
|
||||
return this
|
||||
}
|
||||
|
||||
strokeCircle(_x: number, _y: number, _radius: number): this {
|
||||
return this
|
||||
}
|
||||
|
||||
beginPath(): this {
|
||||
return this
|
||||
}
|
||||
|
||||
closePath(): this {
|
||||
return this
|
||||
}
|
||||
|
||||
moveTo(_x: number, _y: number): this {
|
||||
return this
|
||||
}
|
||||
|
||||
lineTo(_x: number, _y: number): this {
|
||||
return this
|
||||
}
|
||||
|
||||
lineBetween(_x1: number, _y1: number, _x2: number, _y2: number): this {
|
||||
return this
|
||||
}
|
||||
|
||||
stroke(): this {
|
||||
return this
|
||||
}
|
||||
|
||||
fill(): this {
|
||||
return this
|
||||
}
|
||||
|
||||
clear(): this {
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Scene Mock
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Mock loader that simulates Phaser.Loader.LoaderPlugin.
|
||||
*
|
||||
* Handles asset loading in Phaser scenes.
|
||||
*/
|
||||
export class MockLoader extends MockEventEmitter {
|
||||
scene: MockScene
|
||||
|
||||
constructor(scene: MockScene) {
|
||||
super()
|
||||
this.scene = scene
|
||||
}
|
||||
|
||||
image(_key: string, _url: string): this {
|
||||
return this
|
||||
}
|
||||
|
||||
spritesheet(_key: string, _url: string, _config?: unknown): this {
|
||||
return this
|
||||
}
|
||||
|
||||
audio(_key: string, _url: string | string[]): this {
|
||||
return this
|
||||
}
|
||||
|
||||
json(_key: string, _url: string): this {
|
||||
return this
|
||||
}
|
||||
|
||||
start(): void {
|
||||
// Simulate immediate load completion
|
||||
setTimeout(() => this.emit('complete'), 0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock add factory that simulates Phaser.GameObjects.GameObjectFactory.
|
||||
*
|
||||
* Provides factory methods to create game objects.
|
||||
*/
|
||||
export class MockAdd {
|
||||
scene: MockScene
|
||||
|
||||
constructor(scene: MockScene) {
|
||||
this.scene = scene
|
||||
}
|
||||
|
||||
container(x?: number, y?: number, children?: MockGameObject[]): MockContainer {
|
||||
const container = new MockContainer(this.scene, x, y)
|
||||
if (children) {
|
||||
container.add(children)
|
||||
}
|
||||
return container
|
||||
}
|
||||
|
||||
sprite(x: number, y: number, texture: string, frame?: string | number): MockSprite {
|
||||
return new MockSprite(this.scene, x, y, texture, frame)
|
||||
}
|
||||
|
||||
text(x: number, y: number, text: string, style?: Record<string, unknown>): MockText {
|
||||
return new MockText(this.scene, x, y, text, style)
|
||||
}
|
||||
|
||||
graphics(_config?: unknown): MockGraphics {
|
||||
return new MockGraphics(this.scene)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock scene that simulates Phaser.Scene.
|
||||
*
|
||||
* Scenes are the fundamental building blocks of Phaser games.
|
||||
*/
|
||||
export class MockScene {
|
||||
key: string
|
||||
sys: {
|
||||
game: MockGame
|
||||
events: MockEventEmitter
|
||||
}
|
||||
events: MockEventEmitter
|
||||
load: MockLoader
|
||||
add: MockAdd
|
||||
scale: {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
textures: {
|
||||
exists: (key: string) => boolean
|
||||
get: (key: string) => { key: string }
|
||||
}
|
||||
cache: {
|
||||
json: {
|
||||
exists: (key: string) => boolean
|
||||
get: (key: string) => unknown
|
||||
}
|
||||
}
|
||||
|
||||
constructor(key: string = 'default', game?: MockGame) {
|
||||
this.key = key
|
||||
this.events = new MockEventEmitter()
|
||||
this.load = new MockLoader(this)
|
||||
this.add = new MockAdd(this)
|
||||
this.sys = {
|
||||
game: game || createMockGame(),
|
||||
events: new MockEventEmitter(),
|
||||
}
|
||||
this.scale = {
|
||||
width: 800,
|
||||
height: 600,
|
||||
}
|
||||
this.textures = {
|
||||
exists: vi.fn().mockReturnValue(true),
|
||||
get: vi.fn((key: string) => ({ key })),
|
||||
}
|
||||
this.cache = {
|
||||
json: {
|
||||
exists: vi.fn().mockReturnValue(true),
|
||||
get: vi.fn().mockReturnValue({}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
preload(): void {
|
||||
// Override in subclasses
|
||||
}
|
||||
|
||||
create(): void {
|
||||
// Override in subclasses
|
||||
}
|
||||
|
||||
update(_time: number, _delta: number): void {
|
||||
// Override in subclasses
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
this.events.emit('shutdown')
|
||||
this.events.removeAllListeners()
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Game Mock
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Mock game that simulates Phaser.Game.
|
||||
*
|
||||
* The Game instance is the top-level container for the entire game.
|
||||
*/
|
||||
export class MockGame {
|
||||
events: MockEventEmitter
|
||||
scale: {
|
||||
width: number
|
||||
height: number
|
||||
resize: ReturnType<typeof vi.fn>
|
||||
}
|
||||
scene: {
|
||||
add: ReturnType<typeof vi.fn>
|
||||
remove: ReturnType<typeof vi.fn>
|
||||
start: ReturnType<typeof vi.fn>
|
||||
stop: ReturnType<typeof vi.fn>
|
||||
getScene: ReturnType<typeof vi.fn>
|
||||
}
|
||||
destroy: ReturnType<typeof vi.fn>
|
||||
|
||||
constructor() {
|
||||
this.events = new MockEventEmitter()
|
||||
this.scale = {
|
||||
width: 800,
|
||||
height: 600,
|
||||
resize: vi.fn(),
|
||||
}
|
||||
this.scene = {
|
||||
add: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
getScene: vi.fn(),
|
||||
}
|
||||
this.destroy = vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a mock game instance.
|
||||
*
|
||||
* Use this instead of `new MockGame()` for consistency with real Phaser API.
|
||||
*/
|
||||
export function createMockGame(): MockGame {
|
||||
return new MockGame()
|
||||
}
|
||||
@ -143,6 +143,9 @@ export interface GameBridgeEvents {
|
||||
|
||||
/** Error occurred in Phaser */
|
||||
'error': (error: Error) => void
|
||||
|
||||
/** Fatal error in Phaser - game cannot continue */
|
||||
'fatal-error': (data: { message: string; error: string; context?: string }) => void
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
46
test-prize-fix.md
Normal file
46
test-prize-fix.md
Normal file
@ -0,0 +1,46 @@
|
||||
# Quick Test: Prize Zone Fix Verification
|
||||
|
||||
## Visual Test (30 seconds)
|
||||
|
||||
1. Start game: `/game/f6f158c4-47b0-41b9-b3c2-8edc8275b70c`
|
||||
2. Wait for loading overlay to disappear
|
||||
3. Count the rectangular zones on the board
|
||||
|
||||
**PASS:** You see exactly these zones (no prize rectangles):
|
||||
- Active (1 large center zone)
|
||||
- Bench (5 horizontal slots)
|
||||
- Hand (bottom, cards fanned)
|
||||
- Deck (small square, bottom-right area)
|
||||
- Discard (small square, next to deck)
|
||||
- Energy Deck (small square, bottom-left area)
|
||||
|
||||
**FAIL:** You see a 2x3 grid of prize rectangles on the left side
|
||||
|
||||
## Console Test (10 seconds)
|
||||
|
||||
Open DevTools Console and look for:
|
||||
|
||||
```
|
||||
[StateRenderer] Creating board with layout options: { usePrizeCards: false, ... }
|
||||
```
|
||||
|
||||
**PASS:** `usePrizeCards: false`
|
||||
**FAIL:** `usePrizeCards: true` or missing logs
|
||||
|
||||
## Fatal Error Test (1 minute)
|
||||
|
||||
1. Open `frontend/src/game/sync/StateRenderer.ts`
|
||||
2. Line 154, add: `throw new Error('Test')`
|
||||
3. Refresh game
|
||||
4. Should see: Full-screen overlay with "Return to Menu" button
|
||||
5. Should NOT auto-redirect
|
||||
6. Remove the `throw` line
|
||||
|
||||
**PASS:** Error overlay stays until you click button
|
||||
**FAIL:** Auto-redirects after 3 seconds
|
||||
|
||||
## Result
|
||||
|
||||
If all 3 tests pass: ✅ **Fix is working correctly!**
|
||||
|
||||
If any fail: ❌ Report which test failed and what you saw
|
||||
88
verify-fix.sh
Executable file
88
verify-fix.sh
Executable file
@ -0,0 +1,88 @@
|
||||
#!/bin/bash
|
||||
# Quick verification script for prize zone fix
|
||||
|
||||
echo "🔍 Verifying Prize Zone Fix..."
|
||||
echo ""
|
||||
|
||||
# Check we're on the right branch
|
||||
BRANCH=$(git branch --show-current)
|
||||
if [ "$BRANCH" != "fix/defer-board-creation-until-state" ]; then
|
||||
echo "❌ Wrong branch: $BRANCH"
|
||||
echo " Expected: fix/defer-board-creation-until-state"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ On correct branch: $BRANCH"
|
||||
|
||||
# Check TypeScript compilation
|
||||
echo ""
|
||||
echo "🔧 Checking TypeScript compilation..."
|
||||
cd frontend
|
||||
npm run typecheck > /dev/null 2>&1
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✓ TypeScript compilation passed"
|
||||
else
|
||||
echo "❌ TypeScript compilation failed"
|
||||
echo " Run 'cd frontend && npm run typecheck' for details"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for required imports
|
||||
echo ""
|
||||
echo "📦 Checking required imports..."
|
||||
|
||||
if grep -q "import { gameBridge } from '../bridge'" frontend/src/game/sync/StateRenderer.ts; then
|
||||
echo "✓ gameBridge import found in StateRenderer"
|
||||
else
|
||||
echo "❌ Missing gameBridge import in StateRenderer"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -q "import type { Board } from '../objects/Board'" frontend/src/game/scenes/MatchScene.ts; then
|
||||
echo "✓ Board type import found in MatchScene"
|
||||
else
|
||||
echo "❌ Missing Board type import in MatchScene"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for fatal error handling
|
||||
echo ""
|
||||
echo "⚠️ Checking fatal error handling..."
|
||||
|
||||
if grep -q "handleFatalErrorReturn" frontend/src/pages/GamePage.vue; then
|
||||
echo "✓ Fatal error handler function exists"
|
||||
else
|
||||
echo "❌ Missing fatal error handler function"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -q "Return to Menu" frontend/src/pages/GamePage.vue; then
|
||||
echo "✓ Manual return button exists"
|
||||
else
|
||||
echo "❌ Missing manual return button"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for resign toast
|
||||
echo ""
|
||||
echo "📢 Checking resign failure toast..."
|
||||
|
||||
if grep -q "Could not confirm resignation" frontend/src/pages/GamePage.vue; then
|
||||
echo "✓ Resign failure toast exists"
|
||||
else
|
||||
echo "❌ Missing resign failure toast"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Summary
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "✅ All automated checks passed!"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Start dev servers (backend + frontend)"
|
||||
echo "2. Navigate to: http://localhost:5173/game/f6f158c4-47b0-41b9-b3c2-8edc8275b70c"
|
||||
echo "3. Verify NO prize rectangles appear on board"
|
||||
echo "4. Check console logs show: usePrizeCards: false"
|
||||
echo ""
|
||||
echo "See TESTING.md for full test suite"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
Loading…
Reference in New Issue
Block a user