---
trigger: glob
globs: 'app/**/*.test.ts,app/**/*.test.tsx'
---
# Testing Standards

## Core Principle

Test WHAT, not HOW. Tests survive refactoring.

## Structure by Type

**Unit (Pure Functions):** AAA pattern

```typescript
it("resolves when image loads successfully", async () => {
  const img = createMockImage({ loadBehavior: "async" });
  const result = preloadImage({ url: "example.jpg" });
  await expect(result).resolves.toBeUndefined();
});
```

**Integration (Hooks):** Given-When-Then

```typescript
it("updates shop when data arrives", async () => {
  const { result } = renderHook(() => useHook());

  mockFetcher.data = { shop: mockShopB };

  await waitFor(() => expect(result.current.shop).toEqual(mockShopB));
});
```

**Model (State Machines):** State + Event → Next State

```typescript
it("transitions idle to discovering on discover event", async () => {
  const actor = createActor(machine);
  await advanceTo(actor, "idle");
  actor.send({ type: "discover" });
  expect(actor.getSnapshot().value).toBe("discovering");
});
```

**Component:** Test behavior via user interactions and DOM assertions

```typescript
it("shows recurring badge when task has recurrence", () => {
  const task = createMockTask({ recurrence: { frequency: "daily" } });

  render(<TaskBadge task={task} />);

  expect(screen.getByText("Recurring")).toBeInTheDocument();
});
```

## Naming

Natural language: `[action/state] [condition] [outcome]`

✅ `resolves when image cached` | `handles permission denied`
❌ `documents bug fix` | `test case 1`

## Test vs Don't Test

**✅ Test:** Public API (inputs/outputs, callbacks, state transitions), edge cases
**❌ Skip:** Internal state, private functions, effect deps, framework code

## Categories

**Unit:** Pure functions, mock all deps, < 1ms
**Model:** State machines, mock actors, < 100ms
**Integration:** Hooks/components, mock externals, < 500ms

## Organization

**Colocated (simple components):**

```
ComponentName/
├── ComponentName.tsx
├── ComponentName.test.tsx
└── index.ts
```

**Separate folder (complex features):**

```
feature/
  __tests__/
    helpers.ts    # Reusable utilities
    fixtures.ts   # Mock factories
  feature.test.ts
```

**Fixtures:** Use factories with overrides, not static mocks

```typescript
function createMockTask(overrides: Partial<Task> = {}): Task {
  return {
    id: "task-1",
    title: "Test Task",
    state: "active",
    ...overrides,
  };
}
```

## Anti-Patterns

**❌ Test internal flags:** `context.isShopLoaded`
**✅ Test observable:** `result.current.isDiscovering`

**❌ Name for bugs:** `"documents race condition fix"`
**✅ Name for behavior:** `"resolves when cached"`

**❌ Multiple behaviors per test**
**✅ One behavior per test**

**❌ Comments explaining what code does**
**✅ No comments unless absolutely necessary**

## When to Test

**✅ Test:** Public APIs, business logic, state machines, errors, user flows
**❌ Skip:** Getters/setters, framework code, third-party libs, types

## Required Edge Cases

Empty/null, permission denied, network fails, duplicates, boundaries (0, negative, max)

## Regression Tests

**When bugs are found, add regression tests:**

See @useAudioPlayer.test.tsx:267-295 for example:

```typescript
it("maintains playback state during volume adjustments", async () => {
  // Given: Audio is playing
  act(() => result.current.togglePlayPause());

  // When: Volume changes multiple times
  act(() => result.current.handleVolumeChange([50]));
  act(() => result.current.handleVolumeChange([70]));

  // Then: Playback state unchanged
  expect(result.current.isPlaying).toBe(wasPlaying);
  expect(howlInstances).toHaveLength(1);
});
```

**Rules:**

- Name for behavior, not bug: ✅ "maintains..." not ❌ "fixes race condition"
- Test observable outcomes, not internals
- Place in relevant describe block, not "regression" section

## Test Assertions

### Observable State > Method Calls

See @useAudioPlayer.test.tsx:550-582 for correct approach:

```typescript
// ❌ Testing implementation (fragile)
expect(play).toHaveBeenCalledTimes(1);

// ✅ Testing behavior (resilient)
expect(result.current.isPlaying).toBe(true);
expect(instances.filter((h) => h.playing()).length).toBe(1);
```

### When Call Counts Are OK

**Acceptable:** Cleanup verification, duplicate prevention
**Not acceptable:** Business logic, state transitions

```typescript
// ✅ OK: Verifying cleanup
expect(unload).toHaveBeenCalled();

// ✅ OK: Preventing duplicates (see @useAudioPlayer.test.tsx:571-573)
const playingCount = instances.filter((h) => h.playing()).length;
expect(playingCount).toBeLessThanOrEqual(2);

// ❌ NOT OK: Testing state change internals
expect(setState).toHaveBeenCalledTimes(3);
```

## Mock Limitations

Mocks hide real-world issues:

- Performance problems (repeated calls)
- Race conditions (timing)
- Resource conflicts (audio layering)

**Example from session:** Volume slider duplication bug passed all tests but was audible to users.

**Complement with:**

- Manual testing for UX
- Performance profiling
- Integration tests

## Templates

```typescript
// Unit
describe("functionName", () => {
  it("does thing when condition", () => {
    const input = createInput();
    const result = functionName(input);
    expect(result).toEqual(expected);
  });
});

// Integration
describe("useHookName", () => {
  it("performs action on interaction", async () => {
    const { result } = renderHook(() => useHookName());
    act(() => result.current.doAction());
    await waitFor(() => expect(result.current.state).toEqual(expected));
  });
});
```

## Test Commands

```bash
npm run test              # Run all tests
npm run test -- --watch   # Watch mode
npm run test -- --coverage # Coverage report
```
