6 changed files with 5 additions and 1776 deletions
@ -1,162 +0,0 @@
@@ -1,162 +0,0 @@
|
||||
# Birthday Spire Test Suite |
||||
|
||||
This directory contains comprehensive tests for all card mechanics in Birthday Spire. |
||||
|
||||
## Files |
||||
|
||||
- **`card-tests.js`** - Complete test suite with all card tests |
||||
- **`run-tests.js`** - Node.js command-line test runner |
||||
- **`test-runner.html`** - Browser-based test runner with UI |
||||
- **`README.md`** - This documentation |
||||
|
||||
## Running Tests |
||||
|
||||
### Command Line (Node.js) |
||||
|
||||
```bash |
||||
# Run all tests |
||||
npm test |
||||
|
||||
# Run tests with verbose output |
||||
npm run test:verbose |
||||
|
||||
# Check test coverage |
||||
npm run test:coverage |
||||
|
||||
# Test a specific card |
||||
npm run test:card strike |
||||
|
||||
# Or directly with node |
||||
node tests/run-tests.js |
||||
node tests/run-tests.js --verbose |
||||
node tests/run-tests.js --coverage |
||||
node tests/run-tests.js --card=strike |
||||
``` |
||||
|
||||
### Browser |
||||
|
||||
1. Start the development server: |
||||
```bash |
||||
npm run serve |
||||
``` |
||||
|
||||
2. Open `http://localhost:8002/tests/test-runner.html` in your browser |
||||
|
||||
3. Use the interactive test runner: |
||||
- **Run All Tests** - Executes sample test suite |
||||
- **Check Coverage** - Shows test coverage statistics |
||||
- **Test Specific Card** - Test any card by ID |
||||
- **Clear Output** - Clears the test output |
||||
|
||||
## Test Structure |
||||
|
||||
Each test follows this pattern: |
||||
|
||||
```javascript |
||||
testCard('cardId', setupFn, assertionFn) |
||||
``` |
||||
|
||||
- **`cardId`** - The card to test |
||||
- **`setupFn(ctx)`** - Optional setup function to modify test context |
||||
- **`assertionFn(result, ctx)`** - Function to verify the test results |
||||
|
||||
### Example Test |
||||
|
||||
```javascript |
||||
strike: () => testCard('strike', null, (result) => { |
||||
if (result.finalState.enemyHp !== 94) { |
||||
throw new Error(`Expected enemy HP 94, got ${result.finalState.enemyHp}`); |
||||
} |
||||
}) |
||||
``` |
||||
|
||||
## Test Context |
||||
|
||||
The test context provides a mock battle environment: |
||||
|
||||
```javascript |
||||
{ |
||||
player: { hp, maxHp, energy, block, hand, deck, draw, discard, ... }, |
||||
enemy: { hp, maxHp, block, weak, vuln, intent, ... }, |
||||
flags: { skipThisTurn, nextCardFree, doubleNextCard, ... }, |
||||
logs: [], // Captured log messages |
||||
|
||||
// Available functions: |
||||
deal(target, amount), |
||||
draw(n), |
||||
applyWeak(target, amount), |
||||
applyVulnerable(target, amount), |
||||
intentIsAttack(), |
||||
scalarFromWeak(base), |
||||
forceEndTurn(), |
||||
promptExhaust(count), |
||||
moveFromDiscardToHand(cardId), |
||||
countCardType(type), |
||||
replayCard(card) |
||||
} |
||||
``` |
||||
|
||||
## Adding New Tests |
||||
|
||||
To add a test for a new card: |
||||
|
||||
1. Add the test to the `tests` object in `run-tests.js`: |
||||
|
||||
```javascript |
||||
my_new_card: () => testCard('my_new_card', |
||||
(ctx) => { |
||||
// Setup test conditions |
||||
ctx.player.hp = 30; |
||||
ctx.enemy.block = 5; |
||||
}, |
||||
(result, ctx) => { |
||||
// Assert expected results |
||||
if (result.finalState.enemyHp !== expectedHp) { |
||||
throw new Error(`Expected enemy HP ${expectedHp}, got ${result.finalState.enemyHp}`); |
||||
} |
||||
} |
||||
) |
||||
``` |
||||
|
||||
## Test Coverage |
||||
|
||||
Current test coverage includes: |
||||
|
||||
- ✅ Basic attack/skill cards (strike, defend, etc.) |
||||
- ✅ Energy manipulation cards (coffee_rush, etc.) |
||||
- ✅ Complex effect cards (macro, segfault, etc.) |
||||
- ✅ Flag-based cards (just_one_game, pair_programming, etc.) |
||||
- ✅ Advanced mechanics (ctrl_z, npm_audit, infinite_loop, etc.) |
||||
- ✅ Healing cards (stack_trace, refactor, etc.) |
||||
- ✅ Multi-hit cards (merge_conflict, etc.) |
||||
- ✅ Self-damage cards (production_deploy, etc.) |
||||
|
||||
Run `npm run test:coverage` to see detailed coverage statistics. |
||||
|
||||
## Debugging Tests |
||||
|
||||
For debugging failed tests: |
||||
|
||||
1. Use `--verbose` flag to see detailed logs |
||||
2. Test specific cards with `--card=cardId` |
||||
3. Check the browser test runner for interactive debugging |
||||
4. Examine the test context setup and assertions |
||||
|
||||
## Integration with CI/CD |
||||
|
||||
The test suite returns appropriate exit codes: |
||||
- `0` - All tests passed |
||||
- `1` - One or more tests failed |
||||
|
||||
This makes it suitable for continuous integration systems. |
||||
|
||||
## Extending Tests |
||||
|
||||
To test more complex scenarios: |
||||
|
||||
- **Multi-card combos**: Set up multiple cards in hand/play sequence |
||||
- **Relic interactions**: Add relic states to context |
||||
- **Status effect combinations**: Test weak/vulnerable interactions |
||||
- **Edge cases**: Test with 0 energy, full hand, empty deck, etc. |
||||
|
||||
The test framework is designed to be flexible and extensible for any card mechanics you add to the game. |
||||
@ -1,501 +0,0 @@
@@ -1,501 +0,0 @@
|
||||
<!DOCTYPE html> |
||||
<html lang="en"> |
||||
<head> |
||||
<meta charset="UTF-8"> |
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||
<title>Birthday Spire Card Tests</title> |
||||
<style> |
||||
body { |
||||
font-family: 'Courier New', monospace; |
||||
max-width: 1200px; |
||||
margin: 0 auto; |
||||
padding: 20px; |
||||
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%); |
||||
color: white; |
||||
min-height: 100vh; |
||||
} |
||||
|
||||
.container { |
||||
background: rgba(0, 0, 0, 0.7); |
||||
padding: 30px; |
||||
border-radius: 10px; |
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); |
||||
} |
||||
|
||||
h1 { |
||||
text-align: center; |
||||
color: #ffd700; |
||||
margin-bottom: 30px; |
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5); |
||||
} |
||||
|
||||
.controls { |
||||
text-align: center; |
||||
margin-bottom: 30px; |
||||
} |
||||
|
||||
button { |
||||
background: linear-gradient(135deg, #ff6b6b, #ee5a52); |
||||
border: none; |
||||
color: white; |
||||
padding: 12px 24px; |
||||
margin: 0 10px; |
||||
border-radius: 5px; |
||||
cursor: pointer; |
||||
font-size: 16px; |
||||
font-weight: bold; |
||||
transition: transform 0.2s; |
||||
} |
||||
|
||||
button:hover { |
||||
transform: translateY(-2px); |
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3); |
||||
} |
||||
|
||||
button:disabled { |
||||
background: #666; |
||||
cursor: not-allowed; |
||||
transform: none; |
||||
} |
||||
|
||||
.results { |
||||
background: rgba(0, 0, 0, 0.5); |
||||
padding: 20px; |
||||
border-radius: 5px; |
||||
margin-top: 20px; |
||||
border-left: 4px solid #ffd700; |
||||
} |
||||
|
||||
.test-output { |
||||
background: #000; |
||||
color: #00ff00; |
||||
padding: 20px; |
||||
border-radius: 5px; |
||||
font-family: 'Courier New', monospace; |
||||
font-size: 14px; |
||||
line-height: 1.4; |
||||
max-height: 600px; |
||||
overflow-y: auto; |
||||
white-space: pre-wrap; |
||||
margin-top: 20px; |
||||
} |
||||
|
||||
.stats { |
||||
display: flex; |
||||
justify-content: space-around; |
||||
margin: 20px 0; |
||||
text-align: center; |
||||
} |
||||
|
||||
.stat { |
||||
background: rgba(255, 255, 255, 0.1); |
||||
padding: 15px; |
||||
border-radius: 5px; |
||||
flex: 1; |
||||
margin: 0 10px; |
||||
} |
||||
|
||||
.stat-value { |
||||
font-size: 24px; |
||||
font-weight: bold; |
||||
color: #ffd700; |
||||
} |
||||
|
||||
.stat-label { |
||||
font-size: 14px; |
||||
opacity: 0.8; |
||||
} |
||||
|
||||
.error-details { |
||||
background: rgba(255, 0, 0, 0.1); |
||||
border: 1px solid #ff4444; |
||||
padding: 15px; |
||||
border-radius: 5px; |
||||
margin-top: 10px; |
||||
} |
||||
|
||||
.success { color: #00ff00; } |
||||
.error { color: #ff4444; } |
||||
.warning { color: #ffaa00; } |
||||
|
||||
.loading { |
||||
text-align: center; |
||||
color: #ffd700; |
||||
font-style: italic; |
||||
} |
||||
</style> |
||||
</head> |
||||
<body> |
||||
<div class="container"> |
||||
<h1>🎮 Birthday Spire Card Tests</h1> |
||||
|
||||
<div class="controls"> |
||||
<button onclick="runTests()" id="runBtn">Run All Tests</button> |
||||
<button onclick="checkCoverage()" id="coverageBtn">Check Coverage</button> |
||||
<button onclick="runSpecificTest()" id="specificBtn">Test Specific Card</button> |
||||
<button onclick="clearOutput()" id="clearBtn">Clear Output</button> |
||||
</div> |
||||
|
||||
<div class="stats" id="stats" style="display: none;"> |
||||
<div class="stat"> |
||||
<div class="stat-value" id="passedCount">0</div> |
||||
<div class="stat-label">Passed</div> |
||||
</div> |
||||
<div class="stat"> |
||||
<div class="stat-value" id="failedCount">0</div> |
||||
<div class="stat-label">Failed</div> |
||||
</div> |
||||
<div class="stat"> |
||||
<div class="stat-value" id="coveragePercent">0%</div> |
||||
<div class="stat-label">Coverage</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="test-output" id="output">Ready to run tests...</div> |
||||
|
||||
<div id="errorDetails" class="error-details" style="display: none;"> |
||||
<h3>Failed Tests:</h3> |
||||
<div id="errorList"></div> |
||||
</div> |
||||
</div> |
||||
|
||||
<!-- Import the game modules --> |
||||
<script type="module"> |
||||
import { CARDS, STARTER_DECK, CARD_POOL } from '../src/data/cards.js'; |
||||
import { ENEMIES } from '../src/data/enemies.js'; |
||||
import { makePlayer, cloneCard } from '../src/engine/core.js'; |
||||
|
||||
// Make available globally for the test runner |
||||
window.CARDS = CARDS; |
||||
window.ENEMIES = ENEMIES; |
||||
window.makePlayer = makePlayer; |
||||
window.cloneCard = cloneCard; |
||||
|
||||
// Simple console override to capture output |
||||
const originalConsole = { ...console }; |
||||
let capturedOutput = []; |
||||
|
||||
function captureConsole() { |
||||
capturedOutput = []; |
||||
console.log = (...args) => { |
||||
capturedOutput.push(args.join(' ')); |
||||
originalConsole.log(...args); |
||||
}; |
||||
} |
||||
|
||||
function restoreConsole() { |
||||
Object.assign(console, originalConsole); |
||||
} |
||||
|
||||
function output(text, className = '') { |
||||
const outputEl = document.getElementById('output'); |
||||
const span = document.createElement('span'); |
||||
span.className = className; |
||||
span.textContent = text + '\n'; |
||||
outputEl.appendChild(span); |
||||
outputEl.scrollTop = outputEl.scrollHeight; |
||||
} |
||||
|
||||
function clearOutput() { |
||||
document.getElementById('output').innerHTML = 'Output cleared...\n'; |
||||
document.getElementById('stats').style.display = 'none'; |
||||
document.getElementById('errorDetails').style.display = 'none'; |
||||
} |
||||
|
||||
function updateStats(results) { |
||||
document.getElementById('passedCount').textContent = results.passed; |
||||
document.getElementById('failedCount').textContent = results.failed; |
||||
document.getElementById('stats').style.display = 'flex'; |
||||
|
||||
if (results.errors && results.errors.length > 0) { |
||||
const errorDetails = document.getElementById('errorDetails'); |
||||
const errorList = document.getElementById('errorList'); |
||||
errorList.innerHTML = ''; |
||||
|
||||
results.errors.forEach(({ cardId, error }) => { |
||||
const errorDiv = document.createElement('div'); |
||||
errorDiv.innerHTML = `<strong>${cardId}:</strong> ${error}`; |
||||
errorList.appendChild(errorDiv); |
||||
}); |
||||
|
||||
errorDetails.style.display = 'block'; |
||||
} |
||||
} |
||||
|
||||
// Test utilities (simplified version for browser) |
||||
function createTestBattleContext() { |
||||
const player = makePlayer(); |
||||
player.hp = 50; |
||||
player.maxHp = 50; |
||||
player.energy = 3; |
||||
player.maxEnergy = 3; |
||||
player.block = 0; |
||||
player.weak = 0; |
||||
player.vuln = 0; |
||||
player.hand = []; |
||||
player.deck = []; |
||||
player.draw = []; |
||||
player.discard = []; |
||||
|
||||
const enemy = { |
||||
id: "test_dummy", |
||||
name: "Test Dummy", |
||||
hp: 100, |
||||
maxHp: 100, |
||||
block: 0, |
||||
weak: 0, |
||||
vuln: 0, |
||||
intent: { type: "attack", value: 5 } |
||||
}; |
||||
|
||||
const logs = []; |
||||
const ctx = { |
||||
player, |
||||
enemy, |
||||
flags: {}, |
||||
lastCard: null, |
||||
relicStates: [], |
||||
log: (msg) => logs.push(msg), |
||||
logs, |
||||
render: () => {}, |
||||
deal: (target, amount) => { |
||||
const blocked = Math.min(amount, target.block); |
||||
const damage = Math.max(0, amount - blocked); |
||||
target.block -= blocked; |
||||
target.hp -= damage; |
||||
logs.push(`Deal ${amount} damage (${damage} after ${blocked} block)`); |
||||
}, |
||||
draw: (n) => { |
||||
for (let i = 0; i < n && player.draw.length > 0; i++) { |
||||
const cardId = player.draw.pop(); |
||||
const card = cloneCard(CARDS[cardId]); |
||||
if (card) player.hand.push(card); |
||||
} |
||||
}, |
||||
applyWeak: (target, amount) => { |
||||
target.weak = (target.weak || 0) + amount; |
||||
logs.push(`Applied ${amount} weak`); |
||||
}, |
||||
applyVulnerable: (target, amount) => { |
||||
target.vuln = (target.vuln || 0) + amount; |
||||
logs.push(`Applied ${amount} vulnerable`); |
||||
}, |
||||
intentIsAttack: () => enemy.intent.type === "attack", |
||||
scalarFromWeak: (base) => player.weak > 0 ? Math.floor(base * 0.75) : base, |
||||
forceEndTurn: () => logs.push("Turn ended"), |
||||
promptExhaust: (count) => { |
||||
while (count-- > 0 && player.hand.length > 0) { |
||||
const card = player.hand.shift(); |
||||
logs.push(`Exhausted ${card.name}`); |
||||
} |
||||
}, |
||||
moveFromDiscardToHand: (cardId) => { |
||||
const idx = player.discard.findIndex(id => id === cardId); |
||||
if (idx >= 0) { |
||||
const [id] = player.discard.splice(idx, 1); |
||||
const card = cloneCard(CARDS[id]); |
||||
if (card) { |
||||
player.hand.push(card); |
||||
return true; |
||||
} |
||||
} |
||||
return false; |
||||
}, |
||||
countCardType: (type) => { |
||||
const allCards = [...player.deck, ...player.hand.map(c => c.id), ...player.draw, ...player.discard]; |
||||
return allCards.filter(id => CARDS[id]?.type === type).length; |
||||
}, |
||||
replayCard: (card) => { |
||||
if (typeof card.effect === 'function') { |
||||
card.effect(ctx); |
||||
logs.push(`Replayed ${card.name}`); |
||||
} |
||||
} |
||||
}; |
||||
|
||||
return ctx; |
||||
} |
||||
|
||||
function testCard(cardId, setupFn = null, assertionFn = null) { |
||||
const card = CARDS[cardId]; |
||||
if (!card) { |
||||
throw new Error(`Card ${cardId} not found`); |
||||
} |
||||
|
||||
const ctx = createTestBattleContext(); |
||||
|
||||
if (setupFn) { |
||||
setupFn(ctx); |
||||
} |
||||
|
||||
const initialState = { |
||||
playerHp: ctx.player.hp, |
||||
enemyHp: ctx.enemy.hp, |
||||
playerBlock: ctx.player.block, |
||||
playerEnergy: ctx.player.energy, |
||||
handSize: ctx.player.hand.length |
||||
}; |
||||
|
||||
ctx.logs.length = 0; |
||||
|
||||
try { |
||||
card.effect(ctx); |
||||
|
||||
const finalState = { |
||||
playerHp: ctx.player.hp, |
||||
enemyHp: ctx.enemy.hp, |
||||
playerBlock: ctx.player.block, |
||||
playerEnergy: ctx.player.energy, |
||||
handSize: ctx.player.hand.length |
||||
}; |
||||
|
||||
const result = { |
||||
card, |
||||
initialState, |
||||
finalState, |
||||
logs: [...ctx.logs], |
||||
flags: { ...ctx.flags }, |
||||
success: true, |
||||
error: null |
||||
}; |
||||
|
||||
if (assertionFn) { |
||||
assertionFn(result, ctx); |
||||
} |
||||
|
||||
return result; |
||||
} catch (error) { |
||||
return { |
||||
card, |
||||
initialState, |
||||
finalState: initialState, |
||||
logs: [...ctx.logs], |
||||
flags: { ...ctx.flags }, |
||||
success: false, |
||||
error: error.message |
||||
}; |
||||
} |
||||
} |
||||
|
||||
// Sample tests |
||||
const sampleTests = { |
||||
strike: () => testCard('strike', null, (result) => { |
||||
if (result.finalState.enemyHp !== 94) { |
||||
throw new Error(`Expected enemy to have 94 HP, got ${result.finalState.enemyHp}`); |
||||
} |
||||
}), |
||||
|
||||
defend: () => testCard('defend', null, (result) => { |
||||
if (result.finalState.playerBlock !== 5) { |
||||
throw new Error(`Expected player to have 5 block, got ${result.finalState.playerBlock}`); |
||||
} |
||||
}), |
||||
|
||||
coffee_rush: () => testCard('coffee_rush', null, (result) => { |
||||
if (result.finalState.playerEnergy !== 5) { |
||||
throw new Error(`Expected player to have 5 energy, got ${result.finalState.playerEnergy}`); |
||||
} |
||||
}), |
||||
|
||||
segfault: () => testCard('segfault', |
||||
(ctx) => { |
||||
ctx.player.draw = ['strike']; |
||||
}, |
||||
(result) => { |
||||
if (result.finalState.enemyHp !== 93) { |
||||
throw new Error(`Expected enemy to have 93 HP, got ${result.finalState.enemyHp}`); |
||||
} |
||||
if (result.finalState.handSize !== 1) { |
||||
throw new Error(`Expected 1 card drawn, got ${result.finalState.handSize}`); |
||||
} |
||||
} |
||||
), |
||||
|
||||
pair_programming: () => testCard('pair_programming', null, (result) => { |
||||
if (!result.flags.doubleNextCard) { |
||||
throw new Error(`Expected doubleNextCard flag to be set`); |
||||
} |
||||
}) |
||||
}; |
||||
|
||||
window.runTests = async function() { |
||||
clearOutput(); |
||||
output('🎮 Running Birthday Spire Card Tests...', 'success'); |
||||
output(''); |
||||
|
||||
const results = { |
||||
passed: 0, |
||||
failed: 0, |
||||
errors: [] |
||||
}; |
||||
|
||||
for (const [cardId, testFn] of Object.entries(sampleTests)) { |
||||
try { |
||||
output(`Testing ${cardId}...`); |
||||
const result = testFn(); |
||||
output(`✅ ${cardId} passed`, 'success'); |
||||
results.passed++; |
||||
} catch (error) { |
||||
output(`❌ ${cardId} failed: ${error.message}`, 'error'); |
||||
results.failed++; |
||||
results.errors.push({ cardId, error: error.message }); |
||||
} |
||||
} |
||||
|
||||
output(''); |
||||
output(`📊 Test Results:`, 'warning'); |
||||
output(`✅ Passed: ${results.passed}`, 'success'); |
||||
output(`❌ Failed: ${results.failed}`, 'error'); |
||||
output(`📈 Total: ${results.passed + results.failed}`); |
||||
|
||||
updateStats(results); |
||||
}; |
||||
|
||||
window.checkCoverage = function() { |
||||
const allCardIds = Object.keys(CARDS); |
||||
const testedCardIds = Object.keys(sampleTests); |
||||
const coverage = ((testedCardIds.length / allCardIds.length) * 100).toFixed(1); |
||||
|
||||
clearOutput(); |
||||
output('📋 Test Coverage Analysis:', 'warning'); |
||||
output(`Total cards: ${allCardIds.length}`); |
||||
output(`Sample tested cards: ${testedCardIds.length}`); |
||||
output(`Sample coverage: ${coverage}%`); |
||||
output(''); |
||||
output('Note: This is a sample test suite. Full test suite is in card-tests.js', 'warning'); |
||||
|
||||
document.getElementById('coveragePercent').textContent = coverage + '%'; |
||||
document.getElementById('stats').style.display = 'flex'; |
||||
}; |
||||
|
||||
window.runSpecificTest = function() { |
||||
const cardId = prompt('Enter card ID to test:'); |
||||
if (!cardId) return; |
||||
|
||||
if (!CARDS[cardId]) { |
||||
output(`❌ Card '${cardId}' not found`, 'error'); |
||||
return; |
||||
} |
||||
|
||||
clearOutput(); |
||||
output(`Testing specific card: ${cardId}`, 'warning'); |
||||
|
||||
try { |
||||
const result = testCard(cardId); |
||||
output(`✅ ${cardId} executed successfully`, 'success'); |
||||
output(`Initial state: ${JSON.stringify(result.initialState, null, 2)}`); |
||||
output(`Final state: ${JSON.stringify(result.finalState, null, 2)}`); |
||||
output(`Logs: ${result.logs.join(', ')}`); |
||||
if (Object.keys(result.flags).length > 0) { |
||||
output(`Flags: ${JSON.stringify(result.flags, null, 2)}`); |
||||
} |
||||
} catch (error) { |
||||
output(`❌ ${cardId} failed: ${error.message}`, 'error'); |
||||
} |
||||
}; |
||||
|
||||
// Make functions global |
||||
window.clearOutput = clearOutput; |
||||
</script> |
||||
</body> |
||||
</html> |
||||
Loading…
Reference in new issue