---
name: "vitest-test-writer"
description: "Generate complete Vitest + Testing Library test files for Next.js components and hooks following SkillSlap's exact patterns: vi.mock for next/navigation and Supabase, getAllByText for dual-rendered elements, proper beforeEach cleanup."
metadata:
  version: "1.0.0"
---

# Vitest Test Writer

> **Purpose:** Generate a complete, passing Vitest + Testing Library test file for a Next.js component or hook. The output must follow SkillSlap's exact test patterns — no guessing at mock shapes, no test utils the project doesn't have.

---

## Invocation

```
/test <component-path> [description]
```

**Examples:**
- `/test components/skills/skill-card.tsx`
- `/test components/skills/skill-search.tsx Tests all sort/filter/tag interactions`
- `/test hooks/use-hover-intent.ts`

---

## SkillSlap Test Conventions

### Test file location
```
tests/unit/components/[component-name].test.tsx
tests/unit/hooks/[hook-name].test.ts
```

### Required imports
```tsx
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
```

### next/navigation mock — always needed for components using router/params
```tsx
const mockPush = vi.fn()
const mockSearchParams = new URLSearchParams()

vi.mock('next/navigation', () => ({
  useRouter: () => ({ push: mockPush }),
  useSearchParams: () => mockSearchParams,
  usePathname: () => '/',
}))
```

**Critical:** `useSearchParams()` must return a **stable reference** across renders. Create it once outside `vi.mock()` and use that reference. Otherwise `useEffect([searchParams])` will loop infinitely in tests.

```tsx
// ✅ Stable reference
const stableSearchParams = new URLSearchParams()
vi.mock('next/navigation', () => ({
  useSearchParams: () => stableSearchParams,
  ...
}))

// ❌ New object every render — causes infinite loop
vi.mock('next/navigation', () => ({
  useSearchParams: () => new URLSearchParams(),
  ...
}))
```

### next/link mock
```tsx
vi.mock('next/link', () => ({
  default: ({ children, href, ...props }: any) => <a href={href} {...props}>{children}</a>,
}))
```

### Supabase client mock
```tsx
const mockFrom = vi.fn()
const mockSupabase = { from: mockFrom }

vi.mock('@/lib/supabase/client', () => ({
  createClient: () => mockSupabase,
}))

// In test: chain the query mock
beforeEach(() => {
  mockFrom.mockReturnValue({
    select: vi.fn().mockReturnThis(),
    eq: vi.fn().mockReturnThis(),
    order: vi.fn().mockReturnThis(),
    limit: vi.fn().mockResolvedValue({ data: [], error: null }),
    insert: vi.fn().mockResolvedValue({ data: null, error: null }),
  })
})
```

### Dual-rendered elements (mobile + desktop)
Many SkillSlap components render the same content in both a mobile overlay and a desktop section. Use `getAllByText` not `getByText`:

```tsx
// ❌ Fails if element appears twice
expect(screen.getByText('testuser')).toBeInTheDocument()

// ✅ Works for both mobile and desktop renders
expect(screen.getAllByText('testuser').length).toBeGreaterThan(0)
// Or target a specific instance:
const badge = screen.getAllByText('85%')[0].closest('[class]')
```

### Timer mocking for debounce
```tsx
beforeEach(() => {
  vi.useFakeTimers()
})
afterEach(() => {
  vi.useRealTimers()
})

// Advance timers in test
fireEvent.change(input, { target: { value: 'hello' } })
vi.advanceTimersByTime(400)  // match debounce delay
expect(mockPush).toHaveBeenCalledWith('/?search=hello')
```

### Hooks testing
```tsx
import { renderHook, act } from '@testing-library/react'

it('sets isHovering true after delay', async () => {
  vi.useFakeTimers()
  const { result } = renderHook(() => useHoverIntent({ delay: 100 }))
  act(() => { result.current.onMouseEnter() })
  expect(result.current.isHovering).toBe(false)  // not yet
  act(() => { vi.advanceTimersByTime(100) })
  expect(result.current.isHovering).toBe(true)
})
```

### beforeEach cleanup pattern
```tsx
beforeEach(() => {
  mockPush.mockClear()
  stableSearchParams.delete('search')
  stableSearchParams.delete('tag')
  stableSearchParams.delete('sort')
})
```

### Async assertions
```tsx
// Wait for async state updates
await waitFor(() => {
  expect(screen.getByText('Success')).toBeInTheDocument()
})
```

---

## What to Test

### Components — cover these cases
1. Renders without crashing (smoke test)
2. Renders key content (title, description, labels)
3. Link hrefs are correct
4. Conditional rendering (show X when Y is true, hide when false)
5. User interactions: click, type, submit
6. Loading state while async operation is pending
7. Error state when operation fails
8. Empty state when data is absent

### Hooks — cover these cases
1. Initial return value
2. State transitions after trigger
3. Cleanup on unmount (no lingering timers/listeners)
4. Edge cases (rapid toggling, zero delay)

---

## Output Format

Complete test file, ready to save. Include:
1. All necessary mocks at the top (before component import)
2. A `makeX()` factory function for complex test data
3. `describe` block with `it` tests grouped logically
4. No skipped or placeholder tests — every test must be asserting something real
