test: add combat-actions and UI component tests — 40 new tests covering processCombatTick, Card, Button, Badge
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m24s

This commit is contained in:
2026-05-25 20:44:09 +02:00
parent 25ba565467
commit fdc636faaa
7 changed files with 430 additions and 18 deletions
@@ -0,0 +1,236 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { processCombatTick, makeInitialSpells } from '../stores/combat-actions';
import { useCombatStore, makeInitialSpells as makeStoreInitialSpells } from '../stores/combatStore';
import { useManaStore, makeInitialElements } from '../stores/manaStore';
import { useGameStore } from '../stores/gameStore';
import { usePrestigeStore } from '../stores/prestigeStore';
import { useUIStore } from '../stores/uiStore';
import { useDisciplineStore } from '../stores/discipline-slice';
import { getFloorMaxHP } from '../utils';
import type { CombatTickResult } from '../stores/combat-actions';
// ─── Helpers ──────────────────────────────────────────────────────────────────
function resetStores() {
useUIStore.setState({ paused: false, gameOver: false, victory: false, logs: [] });
useGameStore.setState({ day: 1, hour: 0, incursionStrength: 0, containmentWards: 0, initialized: true });
useManaStore.setState({ rawMana: 1000, meditateTicks: 0, totalManaGathered: 0, elements: makeInitialElements(500, {}) });
useCombatStore.setState({
currentFloor: 1,
floorHP: getFloorMaxHP(1),
floorMaxHP: getFloorMaxHP(1),
maxFloorReached: 1,
activeSpell: 'manaBolt',
currentAction: 'climb',
castProgress: 0,
spireMode: false,
currentRoom: { roomType: 'combat', enemies: [] },
clearedFloors: {},
climbDirection: null,
isDescending: false,
golemancy: { enabledGolems: [], summonedGolems: [], lastSummonFloor: 0 },
equipmentSpellStates: [],
comboHitCount: 0,
floorHitCount: 0,
spells: makeStoreInitialSpells(),
activityLog: [],
achievements: { unlocked: [], progress: {} },
totalSpellsCast: 0,
totalDamageDealt: 0,
totalCraftsCompleted: 0,
});
usePrestigeStore.setState({
loopCount: 0, insight: 0, totalInsight: 0, loopInsight: 0,
prestigeUpgrades: {}, pactSlots: 1, defeatedGuardians: [],
signedPacts: [], signedPactDetails: {},
pactRitualFloor: null, pactRitualProgress: 0,
});
useDisciplineStore.setState({ disciplines: {}, activeIds: [], concurrentLimit: 1, totalXP: 0, processedPerks: [] });
}
function runCombatTick(rawMana: number, elements: Record<string, { current: number; max: number; unlocked: boolean }>): CombatTickResult {
return processCombatTick(
() => useCombatStore.getState(),
(partial) => useCombatStore.setState(partial),
rawMana,
elements,
1000, // maxMana
1, // attackSpeedMult
vi.fn(), // onFloorCleared
(dmg) => ({ rawMana, elements, modifiedDamage: dmg }), // onDamageDealt (no modifiers)
[], // signedPacts
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// COMBAT ACTIONS — processCombatTick
// ═══════════════════════════════════════════════════════════════════════════════
describe('processCombatTick', () => {
beforeEach(resetStores);
describe('basic mechanics', () => {
it('should return default result when currentAction is not climb', () => {
useCombatStore.setState({ currentAction: 'meditate' });
const elements = makeInitialElements(500, {});
const result = runCombatTick(1000, elements);
expect(result.currentFloor).toBe(1);
expect(result.floorHP).toBe(getFloorMaxHP(1));
});
it('should return default result when spell is not found', () => {
useCombatStore.setState({ activeSpell: 'nonexistentSpell' });
const elements = makeInitialElements(500, {});
const result = runCombatTick(1000, elements);
expect(result.currentFloor).toBe(1);
});
it('should progress cast bar on each tick', () => {
const elements = makeInitialElements(500, {});
const result = runCombatTick(1000, elements);
expect(result.castProgress).toBeGreaterThan(0);
});
it('should reduce floor HP when cast completes', () => {
// Set cast progress close to 1 so next cast completes
useCombatStore.setState({ castProgress: 0.99 });
const elements = makeInitialElements(500, {});
const initialHP = useCombatStore.getState().floorHP;
const result = runCombatTick(1000, elements);
// Either HP decreased or floor advanced (if HP reached 0)
expect(result.floorHP).toBeLessThanOrEqual(initialHP);
});
});
describe('floor advancement', () => {
it('should advance floor when HP reaches 0', () => {
// Set floor HP very low so it's cleared in one cast
useCombatStore.setState({ floorHP: 1, castProgress: 0.99 });
const elements = makeInitialElements(500, {});
const result = runCombatTick(1000, elements);
expect(result.currentFloor).toBe(2);
expect(result.floorHP).toBe(getFloorMaxHP(2));
});
it('should update maxFloorReached when advancing', () => {
useCombatStore.setState({ floorHP: 1, castProgress: 0.99, maxFloorReached: 5 });
const elements = makeInitialElements(500, {});
const result = runCombatTick(1000, elements);
expect(result.maxFloorReached).toBeGreaterThanOrEqual(5);
});
it('should not advance beyond floor 100', () => {
useCombatStore.setState({ currentFloor: 100, floorHP: 1, castProgress: 0.99 });
const elements = makeInitialElements(500, {});
const result = runCombatTick(1000, elements);
expect(result.currentFloor).toBeLessThanOrEqual(100);
});
});
describe('mana cost', () => {
it('should deduct raw mana when spell costs raw mana', () => {
const elements = makeInitialElements(500, {});
const startMana = 1000;
const result = runCombatTick(startMana, elements);
// manaBolt costs raw mana, so after casting, rawMana should decrease
expect(result.rawMana).toBeLessThanOrEqual(startMana);
});
it('should not cast when insufficient mana', () => {
const elements = makeInitialElements(500, {});
const state = useCombatStore.getState();
// With 0 mana and 0 progress, no cast should complete
const result = processCombatTick(
() => state,
() => {},
0, // no mana
elements,
1000,
1,
vi.fn(),
(dmg) => ({ rawMana: 0, elements, modifiedDamage: dmg }),
[],
);
expect(result.rawMana).toBe(0);
});
});
describe('equipment spell states', () => {
it('should initialize empty equipmentSpellStates', () => {
const elements = makeInitialElements(500, {});
const result = runCombatTick(1000, elements);
expect(result.equipmentSpellStates).toBeDefined();
});
it('should not error with empty equipment spell states', () => {
useCombatStore.setState({ equipmentSpellStates: [] });
const elements = makeInitialElements(500, {});
expect(() => runCombatTick(1000, elements)).not.toThrow();
});
});
describe('error handling', () => {
it('should return safe defaults when spell def is missing (early return path)', () => {
// When the spell definition is not found, processCombatTick returns
// a default result without crashing — this exercises the early return
// path that acts as a safety net.
useCombatStore.setState({ activeSpell: 'totallyInvalidSpell' });
const elements = makeInitialElements(500, {});
const result = processCombatTick(
() => useCombatStore.getState(),
() => {},
1000,
elements,
1000,
1,
vi.fn(),
(dmg) => ({ rawMana: 1000, elements, modifiedDamage: dmg }),
[],
);
expect(result).toBeDefined();
expect(result.rawMana).toBe(1000);
expect(result.logMessages).toBeDefined();
});
it('should not throw when onDamageDealt callback throws', () => {
// The try/catch in processCombatTick should catch errors from callbacks
// and return safe defaults instead of crashing
const elements = makeInitialElements(500, {});
useCombatStore.setState({ castProgress: 0.99 });
expect(() => {
processCombatTick(
() => useCombatStore.getState(),
() => {},
1000,
elements,
1000,
1,
vi.fn(),
(_dmg) => { throw new Error('damage callback error'); },
[],
);
}).not.toThrow();
});
});
describe('helper: makeInitialSpells', () => {
it('should create manaBolt as default spell', () => {
const spells = makeInitialSpells();
expect(spells.manaBolt).toBeDefined();
expect(spells.manaBolt.learned).toBe(true);
expect(spells.manaBolt.level).toBe(1);
});
it('should keep additional spells when specified', () => {
const spells = makeInitialSpells(['fireball']);
expect(spells.manaBolt).toBeDefined();
expect(spells.fireball).toBeDefined();
expect(spells.fireball.learned).toBe(true);
});
it('should not duplicate manaBolt when included in keep list', () => {
const spells = makeInitialSpells(['manaBolt']);
expect(Object.keys(spells).length).toBe(1);
});
});
});