diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index 75018d4..bef01ce 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,5 +1,5 @@ # Circular Dependencies -Generated: 2026-05-25T16:26:37.693Z +Generated: 2026-05-25T18:18:45.184Z Found: 6 circular chain(s) — these MUST be fixed before modifying involved files. 1. Processed 135 files (1.6s) (2 warnings) diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index f65cc2e..ba6af3b 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_meta": { - "generated": "2026-05-25T16:26:35.898Z", + "generated": "2026-05-25T18:18:43.369Z", "description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.", "usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry." }, @@ -143,7 +143,6 @@ "types.ts" ], "crafting-equipment.ts": [ - "crafting-utils.ts", "data/crafting-recipes.ts", "data/equipment/index.ts", "types.ts", @@ -168,9 +167,7 @@ "data/attunements.ts": [ "types.ts" ], - "data/crafting-recipes.ts": [ - "data/equipment/types.ts" - ], + "data/crafting-recipes.ts": [], "data/disciplines/base.ts": [ "types/disciplines.ts" ], @@ -226,8 +223,7 @@ "data/equipment/index.ts" ], "data/enchantments/defense-effects.ts": [ - "data/enchantment-types.ts", - "data/equipment/index.ts" + "data/enchantment-types.ts" ], "data/enchantments/elemental-effects.ts": [ "data/enchantment-types.ts", @@ -439,8 +435,7 @@ "effects/discipline-effects.ts", "stores/combat-state.types.ts", "types.ts", - "utils/index.ts", - "utils/result.ts" + "utils/index.ts" ], "stores/combat-state.types.ts": [ "types.ts" @@ -448,7 +443,6 @@ "stores/combatStore.ts": [ "stores/combat-actions.ts", "stores/combat-state.types.ts", - "stores/prestigeStore.ts", "types.ts", "utils/activity-log.ts", "utils/index.ts", @@ -471,7 +465,6 @@ "stores/craftingStore.types.ts", "stores/manaStore.ts", "stores/uiStore.ts", - "types.ts", "types/equipmentSlot.ts", "utils/result.ts", "utils/safe-persist.ts" @@ -499,7 +492,6 @@ "stores/gameActions.ts": [ "effects/discipline-effects.ts", "stores/combatStore.ts", - "stores/discipline-slice.ts", "stores/gameStore.ts", "stores/manaStore.ts", "stores/prestigeStore.ts", @@ -510,20 +502,16 @@ "constants.ts", "effects.ts", "effects/discipline-effects.ts", - "stores/combatStore.ts", "stores/craftingStore.ts", - "stores/discipline-slice.ts", "stores/gameStore.ts", "stores/manaStore.ts", "stores/prestigeStore.ts", - "stores/uiStore.ts", "utils/index.ts" ], "stores/gameLoopActions.ts": [ "constants.ts", "effects/discipline-effects.ts", "stores/combatStore.ts", - "stores/discipline-slice.ts", "stores/gameStore.ts", "stores/manaStore.ts", "stores/prestigeStore.ts", diff --git a/docs/project-structure.txt b/docs/project-structure.txt index 2c4660a..a10a047 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -181,6 +181,7 @@ Mana-Loop/ │ │ │ ├── toggle.tsx │ │ │ ├── tooltip-info.tsx │ │ │ ├── tooltip.tsx +│ │ │ ├── ui-components.test.tsx │ │ │ └── value-display.tsx │ │ └── ErrorBoundary.tsx │ ├── hooks/ @@ -193,6 +194,7 @@ Mana-Loop/ │ │ │ ├── achievements.test.ts │ │ │ ├── activity-log.test.ts │ │ │ ├── bug-fixes.test.ts +│ │ │ ├── combat-actions.test.ts │ │ │ ├── combat-utils.test.ts │ │ │ ├── computed-stats.test.ts │ │ │ ├── crafting-utils-basic.test.ts diff --git a/scorecard.png b/scorecard.png index 60ca011..afa39e9 100644 Binary files a/scorecard.png and b/scorecard.png differ diff --git a/src/components/ui/ui-components.test.tsx b/src/components/ui/ui-components.test.tsx new file mode 100644 index 0000000..81d9412 --- /dev/null +++ b/src/components/ui/ui-components.test.tsx @@ -0,0 +1,186 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + CardFooter, +} from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; + +// ═══════════════════════════════════════════════════════════════════════════════ +// UI COMPONENT TESTS — Card +// ═══════════════════════════════════════════════════════════════════════════════ + +describe('Card', () => { + it('should render with children', () => { + render(Hello World); + expect(screen.getByTestId('card')).toHaveTextContent('Hello World'); + }); + + it('should accept custom className', () => { + render(Test); + expect(screen.getByTestId('card')).toHaveClass('custom-class'); + }); + + it('should have data-slot attribute', () => { + render(Test); + expect(screen.getByTestId('card')).toHaveAttribute('data-slot', 'card'); + }); + + it('should render CardHeader with children', () => { + render( + + Header Text + , + ); + expect(screen.getByTestId('header')).toHaveTextContent('Header Text'); + }); + + it('should render CardTitle', () => { + render( + + + My Title + + , + ); + expect(screen.getByTestId('title')).toHaveTextContent('My Title'); + }); + + it('should render CardDescription', () => { + render( + + + Description text + + , + ); + expect(screen.getByTestId('desc')).toHaveTextContent('Description text'); + }); + + it('should render CardContent', () => { + render( + + Content here + , + ); + expect(screen.getByTestId('content')).toHaveTextContent('Content here'); + }); + + it('should render CardFooter', () => { + render( + + Footer text + , + ); + expect(screen.getByTestId('footer')).toHaveTextContent('Footer text'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// UI COMPONENT TESTS — Button +// ═══════════════════════════════════════════════════════════════════════════════ + +describe('Button', () => { + it('should render with children text', () => { + render(); + expect(screen.getByTestId('btn')).toHaveTextContent('Click Me'); + }); + + it('should have button role', () => { + render(); + expect(screen.getByTestId('btn').tagName).toBe('BUTTON'); + }); + + it('should apply variant classes', () => { + render(); + expect(screen.getByTestId('btn')).toHaveClass('bg-destructive'); + }); + + it('should apply size classes', () => { + render(); + expect(screen.getByTestId('btn')).toHaveClass('h-8'); + }); + + it('should apply custom className', () => { + render(); + expect(screen.getByTestId('btn')).toHaveClass('custom'); + }); + + it('should render as child component when asChild is true', () => { + render( + , + ); + const el = screen.getByTestId('btn'); + expect(el.tagName).toBe('A'); + expect(el).toHaveAttribute('href', '/test'); + }); + + it('should have data-slot attribute', () => { + render(); + expect(screen.getByTestId('btn')).toHaveAttribute('data-slot', 'button'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// UI COMPONENT TESTS — Badge +// ═══════════════════════════════════════════════════════════════════════════════ + +describe('Badge', () => { + it('should render with children text', () => { + render(New); + expect(screen.getByTestId('badge')).toHaveTextContent('New'); + }); + + it('should render as span by default', () => { + render(Test); + expect(screen.getByTestId('badge').tagName).toBe('SPAN'); + }); + + it('should apply default variant classes', () => { + render(Default); + expect(screen.getByTestId('badge')).toHaveClass('bg-primary'); + }); + + it('should apply destructive variant', () => { + render(Error); + expect(screen.getByTestId('badge')).toHaveClass('bg-destructive'); + }); + + it('should apply outline variant', () => { + render(Outline); + expect(screen.getByTestId('badge')).toHaveClass('text-foreground'); + }); + + it('should apply secondary variant', () => { + render(Secondary); + expect(screen.getByTestId('badge')).toHaveClass('bg-secondary'); + }); + + it('should apply custom className', () => { + render(Custom); + expect(screen.getByTestId('badge')).toHaveClass('custom-badge'); + }); + + it('should accept asChild prop', () => { + render( + + Link Badge + , + ); + const el = screen.getByTestId('badge'); + expect(el.tagName).toBe('A'); + }); + + it('should have data-slot attribute', () => { + render(Test); + expect(screen.getByTestId('badge')).toHaveAttribute('data-slot', 'badge'); + }); +}); diff --git a/src/lib/game/__tests__/combat-actions.test.ts b/src/lib/game/__tests__/combat-actions.test.ts new file mode 100644 index 0000000..aa6a931 --- /dev/null +++ b/src/lib/game/__tests__/combat-actions.test.ts @@ -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): 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); + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 1d26efe..cdccbe6 100755 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ environment: 'jsdom', globals: true, setupFiles: ['./src/test/setup.ts'], - include: ['src/**/*.{test,spec}.ts'], + include: ['src/**/*.{test,spec}.{ts,tsx}'], coverage: { reporter: ['text', 'json'], exclude: [