fix: SpireTab store props, mana regen display, skill cost deduction, grimoire cost format, unequip store, add test suite
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m34s

This commit is contained in:
2026-05-08 11:45:31 +02:00
parent 0fadbfef4a
commit 71fbc7c964
12 changed files with 354 additions and 22 deletions
@@ -0,0 +1,97 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useCraftingStore } from '@/lib/game/stores';
import { initialCraftingState } from '@/lib/game/stores/craftingStore';
describe('useCraftingStore - Equipment Actions', () => {
beforeEach(() => {
useCraftingStore.setState(initialCraftingState);
});
it('equipItem sets equippedInstances[slot] to instanceId', () => {
const instanceId = 'test-instance-1';
const slot = 'mainHand';
// First, add the instance to equipmentInstances
useCraftingStore.setState((state) => ({
equipmentInstances: {
...state.equipmentInstances,
[instanceId]: {
id: instanceId,
equipmentId: 'test-equip',
name: 'Test Sword',
rarity: 'common',
level: 1,
upgrades: [],
createdAt: Date.now(),
} as any,
},
}));
// Equip the item
useCraftingStore.getState().equipItem(slot, instanceId);
expect(useCraftingStore.getState().equippedInstances[slot]).toBe(instanceId);
});
it('unequipItem sets equippedInstances[slot] to null', () => {
const instanceId = 'test-instance-1';
const slot = 'mainHand';
// First equip the item
useCraftingStore.setState((state) => ({
equipmentInstances: {
...state.equipmentInstances,
[instanceId]: {
id: instanceId,
equipmentId: 'test-equip',
name: 'Test Sword',
rarity: 'common',
level: 1,
upgrades: [],
createdAt: Date.now(),
} as any,
},
equippedInstances: {
...state.equippedInstances,
[slot]: instanceId,
},
}));
// Unequip the item
useCraftingStore.getState().unequipItem(slot);
expect(useCraftingStore.getState().equippedInstances[slot]).toBeNull();
});
it('deleteEquipmentInstance removes from both equippedInstances and equipmentInstances', () => {
const instanceId = 'test-instance-1';
const slot = 'mainHand';
// Add and equip the item
useCraftingStore.setState((state) => ({
equipmentInstances: {
...state.equipmentInstances,
[instanceId]: {
id: instanceId,
equipmentId: 'test-equip',
name: 'Test Sword',
rarity: 'common',
level: 1,
upgrades: [],
createdAt: Date.now(),
} as any,
},
equippedInstances: {
...state.equippedInstances,
[slot]: instanceId,
},
}));
// Delete the item
useCraftingStore.getState().deleteEquipmentInstance(instanceId);
const state = useCraftingStore.getState();
expect(state.equipmentInstances[instanceId]).toBeUndefined();
expect(state.equippedInstances[slot]).toBeNull();
});
});
@@ -0,0 +1,30 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useManaStore } from '@/lib/game/stores';
import { initialManaState } from '@/lib/game/stores/manaStore';
describe('useManaStore', () => {
beforeEach(() => {
useManaStore.setState(initialManaState);
});
it('spendRawMana reduces rawMana correctly', () => {
useManaStore.setState({ rawMana: 100 });
const result = useManaStore.getState().spendRawMana(30);
expect(result).toBe(true);
expect(useManaStore.getState().rawMana).toBe(70);
});
it('spendRawMana returns false if insufficient mana', () => {
useManaStore.setState({ rawMana: 20 });
const result = useManaStore.getState().spendRawMana(50);
expect(result).toBe(false);
expect(useManaStore.getState().rawMana).toBe(20); // unchanged
});
it('rawMana never goes below 0', () => {
useManaStore.setState({ rawMana: 10 });
const result = useManaStore.getState().spendRawMana(20);
expect(result).toBe(false);
expect(useManaStore.getState().rawMana).toBeGreaterThanOrEqual(0);
});
});
@@ -0,0 +1,50 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { computeRegen } from '@/lib/game/store-modules/computed-stats';
import { getIncursionStrength } from '@/lib/game/store-modules/computed-stats';
import { useSkillStore } from '@/lib/game/stores';
import { initialSkillState } from '@/lib/game/stores/skillStore';
describe('computeRegen', () => {
beforeEach(() => {
useSkillStore.setState(initialSkillState);
});
it('Returns 0 when no skills', () => {
const result = computeRegen({
skills: {},
skillTiers: {},
skillUpgrades: {},
});
expect(result).toBe(0);
});
it('Increases with manaWell skill level', () => {
const base = computeRegen({
skills: { manaWell: 0 },
skillTiers: {},
skillUpgrades: {},
});
const withSkill = computeRegen({
skills: { manaWell: 5 },
skillTiers: {},
skillUpgrades: {},
});
expect(withSkill).toBeGreaterThan(base);
});
});
describe('effectiveRegen', () => {
it('effectiveRegen = baseRegen * (1 - incursionStrength)', () => {
const baseRegen = 10;
const day = 20; // After incursion start
const hour = 12;
const incursionStrength = getIncursionStrength(day, hour);
const effectiveRegen = baseRegen * (1 - incursionStrength);
expect(effectiveRegen).toBeLessThan(baseRegen);
expect(effectiveRegen).toBeGreaterThanOrEqual(0);
});
});
@@ -0,0 +1,55 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useSkillStore } from '@/lib/game/stores';
import { useManaStore } from '@/lib/game/stores';
import { initialSkillState } from '@/lib/game/stores/skillStore';
import { initialManaState } from '@/lib/game/stores/manaStore';
describe('useSkillStore', () => {
beforeEach(() => {
useSkillStore.setState(initialSkillState);
useManaStore.setState(initialManaState);
});
it('startStudyingSkill returns { started: false } if rawMana < cost', () => {
// Set rawMana to 0
useManaStore.setState({ rawMana: 0 });
const result = useSkillStore.getState().startStudyingSkill('manaWell', false);
expect(result.started).toBe(false);
});
it('startStudyingSkill deducts mana via manaStore.spendRawMana when started', () => {
// Set rawMana to 100, skill cost is maybe 50? We need to know the cost.
// Let's mock spendRawMana to track calls
const spendRawManaSpy = vi.spyOn(useManaStore.getState(), 'spendRawMana');
useManaStore.setState({ rawMana: 100 });
const result = useSkillStore.getState().startStudyingSkill('manaWell', false);
if (result.started) {
expect(spendRawManaSpy).toHaveBeenCalledWith(result.cost);
}
});
it('startStudyingSkill does NOT deduct if isAlreadyPaid', () => {
const spendRawManaSpy = vi.spyOn(useManaStore.getState(), 'spendRawMana');
useManaStore.setState({ rawMana: 100 });
const result = useSkillStore.getState().startStudyingSkill('manaWell', true);
if (result.started) {
expect(spendRawManaSpy).not.toHaveBeenCalled();
expect(result.cost).toBe(0); // cost should be 0 if already paid
}
});
it('cancelStudy clears currentStudyTarget', () => {
// First start studying
useManaStore.setState({ rawMana: 100 });
useSkillStore.getState().startStudyingSkill('manaWell', false);
expect(useSkillStore.getState().currentStudyTarget).not.toBeNull();
// Cancel study
useSkillStore.getState().cancelStudy();
expect(useSkillStore.getState().currentStudyTarget).toBeNull();
});
});
@@ -0,0 +1,64 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { canAffordSpellCost } from '@/lib/game/stores';
import { formatSpellCost } from '@/lib/game/formatting';
import { useManaStore } from '@/lib/game/stores';
import { initialManaState } from '@/lib/game/stores/manaStore';
import type { SpellCost } from '@/lib/game/types';
describe('canAffordSpellCost', () => {
beforeEach(() => {
useManaStore.setState(initialManaState);
});
it('returns true when rawMana >= cost.amount (type: raw)', () => {
useManaStore.setState({ rawMana: 100 });
const cost: SpellCost = { type: 'raw', amount: 50 };
expect(canAffordSpellCost(cost)).toBe(true);
});
it('returns false when insufficient rawMana', () => {
useManaStore.setState({ rawMana: 20 });
const cost: SpellCost = { type: 'raw', amount: 50 };
expect(canAffordSpellCost(cost)).toBe(false);
});
it('returns true when has enough element mana', () => {
// Set initial state first
useManaStore.setState(initialManaState);
// Then update the Fire element
useManaStore.setState((state) => ({
elements: {
...state.elements,
Fire: { amount: 100, max: 200, rate: 0 },
},
}));
const cost: SpellCost = { type: 'element', element: 'Fire', amount: 50 };
expect(canAffordSpellCost(cost)).toBe(true);
});
});
describe('formatSpellCost', () => {
it('returns a non-empty string for raw cost', () => {
const cost: SpellCost = { type: 'raw', amount: 50 };
const result = formatSpellCost(cost);
expect(result).toBeTruthy();
expect(typeof result).toBe('string');
});
it('returns a non-empty string for element cost', () => {
const cost: SpellCost = { type: 'element', element: 'Fire', amount: 30 };
const result = formatSpellCost(cost);
expect(result).toBeTruthy();
expect(typeof result).toBe('string');
});
it('does NOT throw when cost.type is element', () => {
const cost: SpellCost = { type: 'element', element: 'Water', amount: 25 };
expect(() => formatSpellCost(cost)).not.toThrow();
});
it('handles edge case: zero amount', () => {
const cost: SpellCost = { type: 'raw', amount: 0 };
expect(() => formatSpellCost(cost)).not.toThrow();
});
});