I can't believe I made this either...
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

678 lines
22 KiB

#!/usr/bin/env node
/**
* Node.js Test Runner for Birthday Spire Card Tests
* Usage: node tests/run-tests.js [--card=cardId] [--coverage] [--verbose]
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Mock browser environment for Node.js
global.window = {};
// Import game modules (adjust paths as needed)
let CARDS, ENEMIES, makePlayer, cloneCard;
try {
const cardsModule = await import('../src/data/cards.js');
const enemiesModule = await import('../src/data/enemies.js');
const coreModule = await import('../src/engine/core.js');
CARDS = cardsModule.CARDS;
ENEMIES = enemiesModule.ENEMIES;
makePlayer = coreModule.makePlayer;
cloneCard = coreModule.cloneCard;
} catch (error) {
console.error('❌ Failed to import game modules:', error.message);
console.error('Make sure you\'re running this from the project root directory');
process.exit(1);
}
// Test utilities
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,
enemyBlock: ctx.enemy.block,
playerEnergy: ctx.player.energy,
handSize: ctx.player.hand.length,
discardSize: ctx.player.discard.length
};
ctx.logs.length = 0;
try {
card.effect(ctx);
const finalState = {
playerHp: ctx.player.hp,
enemyHp: ctx.enemy.hp,
playerBlock: ctx.player.block,
enemyBlock: ctx.enemy.block,
playerEnergy: ctx.player.energy,
handSize: ctx.player.hand.length,
discardSize: ctx.player.discard.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
};
}
}
// Comprehensive test definitions
const tests = {
// Basic cards
strike: () => testCard('strike', null, (result) => {
if (result.finalState.enemyHp !== 94) {
throw new Error(`Expected enemy HP 94, got ${result.finalState.enemyHp}`);
}
}),
'strike+': () => testCard('strike+', null, (result) => {
if (result.finalState.enemyHp !== 91) {
throw new Error(`Expected enemy HP 91, got ${result.finalState.enemyHp}`);
}
}),
defend: () => testCard('defend', null, (result) => {
if (result.finalState.playerBlock !== 5) {
throw new Error(`Expected player block 5, got ${result.finalState.playerBlock}`);
}
}),
'defend+': () => testCard('defend+', null, (result) => {
if (result.finalState.playerBlock !== 8) {
throw new Error(`Expected player block 8, got ${result.finalState.playerBlock}`);
}
}),
// Energy cards
coffee_rush: () => testCard('coffee_rush', null, (result) => {
if (result.finalState.playerEnergy !== 5) {
throw new Error(`Expected energy 5, got ${result.finalState.playerEnergy}`);
}
}),
'coffee_rush+': () => testCard('coffee_rush+', null, (result) => {
if (result.finalState.playerEnergy !== 6) {
throw new Error(`Expected energy 6, got ${result.finalState.playerEnergy}`);
}
}),
// Complex cards
macro: () => testCard('macro',
(ctx) => {
ctx.lastCard = 'strike';
ctx.player.hand.push(cloneCard(CARDS['strike']));
},
(result) => {
if (result.finalState.enemyHp !== 94) {
throw new Error(`Macro should replay strike, expected enemy HP 94, got ${result.finalState.enemyHp}`);
}
}
),
segfault: () => testCard('segfault',
(ctx) => {
ctx.player.draw = ['strike'];
},
(result) => {
if (result.finalState.enemyHp !== 93) {
throw new Error(`Expected enemy HP 93, got ${result.finalState.enemyHp}`);
}
if (result.finalState.handSize !== 1) {
throw new Error(`Expected hand size 1, got ${result.finalState.handSize}`);
}
}
),
skill_issue: () => testCard('skill_issue',
(ctx) => {
ctx.enemy.intent = { type: "attack", value: 5 };
},
(result, ctx) => {
if (result.finalState.playerBlock !== 6) {
throw new Error(`Expected block 6, got ${result.finalState.playerBlock}`);
}
if (ctx.enemy.weak !== 1) {
throw new Error(`Expected enemy to be weakened`);
}
}
),
dark_mode: () => testCard('dark_mode', null, (result) => {
if (result.finalState.enemyHp !== 80) {
throw new Error(`Expected enemy HP 80, got ${result.finalState.enemyHp}`);
}
if (!result.logs.includes("Turn ended")) {
throw new Error(`Expected turn to end`);
}
}),
// Flag-based cards
just_one_game: () => testCard('just_one_game', null, (result) => {
if (!result.flags.skipThisTurn) {
throw new Error(`Expected skipThisTurn flag`);
}
if (result.flags.nextTurnEnergyBonus !== 2) {
throw new Error(`Expected nextTurnEnergyBonus 2, got ${result.flags.nextTurnEnergyBonus}`);
}
}),
vibe_code: () => testCard('vibe_code', null, (result) => {
if (!result.flags.nextCardFree) {
throw new Error(`Expected nextCardFree flag`);
}
}),
pair_programming: () => testCard('pair_programming', null, (result) => {
if (!result.flags.doubleNextCard) {
throw new Error(`Expected doubleNextCard flag`);
}
}),
// Advanced mechanics
ctrl_z: () => testCard('ctrl_z',
(ctx) => {
ctx.player.discard = ['strike'];
},
(result) => {
if (result.finalState.handSize !== 1) {
throw new Error(`Expected hand size 1, got ${result.finalState.handSize}`);
}
if (result.finalState.discardSize !== 0) {
throw new Error(`Expected discard size 0, got ${result.finalState.discardSize}`);
}
}
),
npm_audit: () => testCard('npm_audit',
(ctx) => {
ctx.player.deck = ['sugar_crash', 'sugar_crash'];
ctx.player.discard = ['sugar_crash'];
},
(result) => {
if (result.finalState.playerBlock !== 9) {
throw new Error(`Expected block 9 (3 curses * 3), got ${result.finalState.playerBlock}`);
}
}
),
infinite_loop: () => testCard('infinite_loop',
(ctx) => {
ctx.lastCard = 'strike';
ctx.player.hand.push(cloneCard(CARDS['strike']));
},
(result) => {
if (!result.logs.some(log => log.includes('Replayed'))) {
throw new Error(`Expected card to be replayed`);
}
}
),
// Test all basic damage cards work
merge_conflict: () => testCard('merge_conflict', null, (result) => {
if (result.finalState.enemyHp !== 88) { // 6 + 6 = 12 damage
throw new Error(`Expected enemy HP 88, got ${result.finalState.enemyHp}`);
}
}),
production_deploy: () => testCard('production_deploy', null, (result) => {
if (result.finalState.enemyHp !== 75) { // 25 damage
throw new Error(`Expected enemy HP 75, got ${result.finalState.enemyHp}`);
}
if (result.finalState.playerHp !== 45) { // Lost 5 HP
throw new Error(`Expected player HP 45, got ${result.finalState.playerHp}`);
}
}),
// Test healing cards
stack_trace: () => testCard('stack_trace',
(ctx) => {
ctx.player.hp = 40; // Start damaged
},
(result) => {
if (result.finalState.playerHp !== 45) { // Healed 5
throw new Error(`Expected player HP 45, got ${result.finalState.playerHp}`);
}
}
),
refactor: () => testCard('refactor',
(ctx) => {
ctx.player.hp = 40;
ctx.player.draw = ['strike'];
},
(result) => {
if (result.finalState.playerHp !== 48) { // Healed 8
throw new Error(`Expected player HP 48, got ${result.finalState.playerHp}`);
}
if (result.finalState.handSize !== 1) { // Drew 1 card
throw new Error(`Expected hand size 1, got ${result.finalState.handSize}`);
}
}
),
// Previously untested cards
'404': () => testCard('404', null, (result, ctx) => {
if (ctx.enemy.weak !== 2) {
throw new Error(`Expected enemy to have 2 weak, got ${ctx.enemy.weak}`);
}
}),
object_object: () => testCard('object_object',
(ctx) => {
ctx.player.hand = [cloneCard(CARDS['strike']), cloneCard(CARDS['defend'])];
ctx.player.draw = ['coffee_rush'];
},
(result) => {
if (result.finalState.handSize !== 2) { // Exhausted 1, drew 2, net +1
throw new Error(`Expected hand size 2, got ${result.finalState.handSize}`);
}
}
),
colon_q: () => testCard('colon_q',
(ctx) => {
ctx.player.discard = ['strike', 'defend', 'coffee_rush']; // 3 cards
},
(result) => {
if (result.finalState.enemyHp !== 97) { // 100 - 3 = 97
throw new Error(`Expected enemy HP 97, got ${result.finalState.enemyHp}`);
}
}
),
raw_dog: () => testCard('raw_dog',
(ctx) => {
ctx.player.draw = ['strike', 'defend'];
},
(result) => {
if (result.finalState.handSize !== 2) {
throw new Error(`Expected hand size 2, got ${result.finalState.handSize}`);
}
// Note: Card should be exhausted after play
}
),
task_failed_successfully: () => testCard('task_failed_successfully', null, (result) => {
if (result.finalState.enemyHp !== 88) { // 8 + 4 = 12 damage (no block)
throw new Error(`Expected enemy HP 88, got ${result.finalState.enemyHp}`);
}
}),
recursion: () => testCard('recursion', null, (result) => {
if (result.finalState.enemyHp !== 90) { // 5 + 5 = 10 damage (triggers twice)
throw new Error(`Expected enemy HP 90, got ${result.finalState.enemyHp}`);
}
}),
git_commit: () => testCard('git_commit',
(ctx) => {
ctx.player.draw = ['strike']; // Need cards to draw
},
(result) => {
if (result.finalState.playerBlock !== 4) { // Actually gives 4 block, not 8
throw new Error(`Expected player block 4, got ${result.finalState.playerBlock}`);
}
if (result.finalState.handSize !== 1) { // Should draw 1 card
throw new Error(`Expected hand size 1, got ${result.finalState.handSize}`);
}
}
),
memory_leak: () => testCard('memory_leak', null, (result) => {
if (result.finalState.playerEnergy !== 4) { // +1 energy
throw new Error(`Expected player energy 4, got ${result.finalState.playerEnergy}`);
}
if (result.flags.nextTurnEnergyPenalty !== 1) {
throw new Error(`Expected nextTurnEnergyPenalty 1, got ${result.flags.nextTurnEnergyPenalty}`);
}
}),
code_review: () => testCard('code_review',
(ctx) => {
ctx.player.draw = ['strike']; // Need cards to draw
},
(result) => {
if (result.finalState.handSize !== 1) { // Should draw 1 card
throw new Error(`Expected hand size 1, got ${result.finalState.handSize}`);
}
if (!result.logs.includes('Code review reveals useful insights. You draw a card.')) {
throw new Error(`Expected code review log message`);
}
}
),
hotfix: () => testCard('hotfix',
(ctx) => {
ctx.player.hp = 20; // Below 50%
},
(result) => {
if (result.finalState.enemyHp !== 90) {
throw new Error(`Expected enemy HP 90, got ${result.finalState.enemyHp}`);
}
}
),
ligma: () => testCard('ligma',
(ctx) => {
ctx.player.draw = ['strike']; // Need cards to draw
},
(result) => {
if (result.finalState.playerHp !== 0) { // Takes exactly 69 damage, 50-69=0 (clamped to 0)
throw new Error(`Expected player HP 0 after 69 damage, got ${result.finalState.playerHp}`);
}
if (result.finalState.handSize !== 1) { // Should draw 1 card
throw new Error(`Expected hand size 1, got ${result.finalState.handSize}`);
}
}
),
virgin: () => testCard('virgin',
(ctx) => {
ctx.enemy.intent = { type: "attack", value: 5 };
},
(result) => {
if (result.finalState.playerBlock !== 8) {
throw new Error(`Expected player block 8, got ${result.finalState.playerBlock}`);
}
}
),
sugar_crash: () => {
// Test that curse cards cannot be played
const ctx = createTestBattleContext();
const card = CARDS['sugar_crash'];
try {
card.effect(ctx);
// If we get here, the card was played successfully
// But curse cards should be blocked at the game level, not in the effect
// So this test just verifies the effect exists and doesn't crash
return { success: true, message: 'Curse effect executed' };
} catch (error) {
throw new Error(`Unexpected error in curse effect: ${error.message}`);
}
},
stack_overflow: () => testCard('stack_overflow',
(ctx) => {
ctx.player.hand = [cloneCard(CARDS['strike']), cloneCard(CARDS['defend'])]; // 2 cards in hand
},
(result) => {
if (result.finalState.enemyHp !== 98) { // 2 damage
throw new Error(`Expected enemy HP 98, got ${result.finalState.enemyHp}`);
}
}
),
rubber_duck: () => testCard('rubber_duck',
(ctx) => {
ctx.player.draw = ['strike', 'defend'];
},
(result) => {
if (result.finalState.handSize !== 3) { // Drew 3 cards
throw new Error(`Expected hand size 3, got ${result.finalState.handSize}`);
}
}
),
git_push_force: () => testCard('git_push_force',
(ctx) => {
ctx.player.hand = [cloneCard(CARDS['strike']), cloneCard(CARDS['defend'])];
},
(result, ctx) => {
if (result.finalState.enemyHp !== 85) { // 15 damage
throw new Error(`Expected enemy HP 85, got ${result.finalState.enemyHp}`);
}
// Should have moved a card back to draw pile
if (result.finalState.handSize !== 1) { // Started with 2, removed 1
throw new Error(`Expected hand size 1 after force push, got ${result.finalState.handSize}`);
}
}
)
};
// Test runner
function runAllTests(verbose = false) {
console.log('🎮 Running Birthday Spire Card Tests...\n');
const results = {
passed: 0,
failed: 0,
errors: []
};
for (const [cardId, testFn] of Object.entries(tests)) {
try {
if (verbose) console.log(`Testing ${cardId}...`);
const result = testFn();
console.log(`${cardId} passed`);
results.passed++;
if (verbose && result.logs.length > 0) {
console.log(` Logs: ${result.logs.join(', ')}`);
}
} catch (error) {
console.log(`${cardId} failed: ${error.message}`);
results.failed++;
results.errors.push({ cardId, error: error.message });
}
}
console.log(`\n📊 Test Results:`);
console.log(`✅ Passed: ${results.passed}`);
console.log(`❌ Failed: ${results.failed}`);
console.log(`📈 Total: ${results.passed + results.failed}`);
if (results.errors.length > 0) {
console.log(`\n🔍 Failed Tests:`);
results.errors.forEach(({ cardId, error }) => {
console.log(` ${cardId}: ${error}`);
});
}
return results;
}
function checkTestCoverage() {
const allCardIds = Object.keys(CARDS);
const testedCardIds = Object.keys(tests);
const untestedCards = allCardIds.filter(id => !testedCardIds.includes(id));
console.log(`\n📋 Test Coverage:`);
console.log(`Total cards: ${allCardIds.length}`);
console.log(`Tested cards: ${testedCardIds.length}`);
console.log(`Coverage: ${((testedCardIds.length / allCardIds.length) * 100).toFixed(1)}%`);
if (untestedCards.length > 0) {
console.log(`\n Untested cards:`);
untestedCards.forEach(cardId => console.log(` - ${cardId}`));
}
return {
total: allCardIds.length,
tested: testedCardIds.length,
untested: untestedCards
};
}
function runSpecificCardTest(cardId, verbose = false) {
if (!CARDS[cardId]) {
console.log(`❌ Card '${cardId}' not found`);
return;
}
console.log(`🎯 Testing specific card: ${cardId}`);
try {
const result = testCard(cardId);
console.log(`${cardId} executed successfully`);
if (verbose) {
console.log(`Initial state:`, result.initialState);
console.log(`Final state:`, result.finalState);
console.log(`Logs:`, result.logs);
if (Object.keys(result.flags).length > 0) {
console.log(`Flags:`, result.flags);
}
}
} catch (error) {
console.log(`${cardId} failed: ${error.message}`);
}
}
// CLI handling
const args = process.argv.slice(2);
const cardArg = args.find(arg => arg.startsWith('--card='));
const coverageArg = args.includes('--coverage');
const verboseArg = args.includes('--verbose');
if (cardArg) {
const cardId = cardArg.split('=')[1];
runSpecificCardTest(cardId, verboseArg);
} else if (coverageArg) {
checkTestCoverage();
} else {
const results = runAllTests(verboseArg);
checkTestCoverage();
// Exit with error code if tests failed
if (results.failed > 0) {
process.exit(1);
}
}