# Client testing, lint and types

What is tested and how, plus the two gate tools and their traps.

> Last verified: 2026-07-30 against commit `d3ec723`.

---

## 1. What is tested

**Every shared component has a co-located test file.** A component is "shared" if it is imported from more
than one place — a page, a layout, or another component.

There are **125 test files** across `src/` (114 `*.test.tsx`, 11 `*.test.ts`). Location:
`src/components/<Name>/<Name>.test.tsx`, next to the component.

### Coverage baseline for a shared component

1. It renders without crashing.
2. Every documented prop produces the correct HTML attribute or CSS class.
3. User interactions (click, change) call the expected callbacks.

That is a floor, not a ceiling. Where a component encodes a rule, test the rule: `BookingDetailView`'s test
proves the customer **never fires the care-instructions query** (the two-stage disclosure gate), and
`matchActivePath`'s test proves a nested route lights up its parent tab and never a sibling. Those are the
tests worth writing.

### The two rules about the test itself

- **Wrap with `<ThemeProvider>`** if the component uses MUI theming.
- **Do NOT mock MUI components.** Test against the rendered DOM. A test that mocks `Button` proves nothing
  about what a user sees.

### Before removing or renaming a shared component

Check whether any `src/**/*.test.{ts,tsx}` imports it. If so, update or delete those tests in the same
change. A dangling test import fails the suite, and a test left behind for a deleted component is dead code.

### Deliberate coverage gaps

`NeshanMap` is **not** unit-tested — jsdom plus Leaflet is an integration problem, not a unit one — and it is
unreachable in CI anyway, because `NEXT_PUBLIC_NESHAN_KEY` is unset there so `AddressMapPicker` falls back to
the bounded-canvas stand-in. Both branches of that fork are tested; the map itself isn't.

---

## 2. Jest configuration

`jest.config.ts` **replaces** `next/jest`'s `transformIgnorePatterns` array outright rather than appending to
it. This is deliberate and easy to undo by accident:

`next/jest`'s default pattern already broadly matches all of `node_modules` (its negative-lookahead allowlist
carves out only a couple of Next-internal packages), and Jest's array semantics are **OR-based** — a file is
ignored if *any* pattern matches. So appending a more permissive pattern can never "un-ignore" a package an
earlier pattern already caught. The array has to be replaced, by post-processing the async config `next/jest`
returns.

What it lets through: `next-intl`, `use-intl`, `@formatjs`, `intl-messageformat` — all ESM-only builds.

That fixes real imports; it does not make the dependency free. See
[components.md](components.md) "Presentational purity" for why `ErrorBoundary`/`ErrorState` are caller-owned
and `Money` is the one sanctioned exception.

---

## 3. The gate

```
npm run check   →  npm run type  &&  npm run lint  &&  npm run lint:copy
```

Plus `npm run test:ci` when you touched a component with a co-located test.

Both gate tools are plain CLI tools. **There is no `next lint`** — it was removed in Next 16, and calling it
silently does nothing.

| Script | Runs |
| --- | --- |
| `type` | `tsc --noEmit`. `tsconfig.json`: `strict` on, `noEmit`, `@/*` → `src/*` |
| `lint` | `eslint .`, driven by **flat config** in `eslint.config.mjs` |
| `lint:copy` | `node scripts/check-copy.mjs` — see [i18n.md](i18n.md) §4 |

`eslint.config.mjs` spreads `eslint-config-next` (core-web-vitals + typescript + react + react-hooks +
jsx-a11y + import) and applies `eslint-config-prettier` **last**, so ESLint never fights Prettier on
formatting.

---

## 4. Lint rules for this project

- **Flat config only.** Do not add `.eslintrc*` files — put any rule change in `eslint.config.mjs`.
- **ESLint owns correctness, Prettier owns formatting.** Don't add stylistic ESLint rules.
- **No unused variables or imports.** `@typescript-eslint/no-unused-vars` is raised from
  eslint-config-next's default `warn` to **`error`**, so dead code fails `npm run check`. Delete it rather
  than disabling the rule; prefix a deliberately-unused binding with `_` (`_event`, `catch (_err)`) to opt
  out.
- **Prefer fixing code over silencing the linter.** When a disable is genuinely correct — the real example
  here is a deliberate browser-only read after mount that trips `react-hooks/set-state-in-effect` — use a
  scoped `// eslint-disable-next-line <rule>` with a one-line reason. **Never a file-wide disable.**

### Two pinned constraints

- **ESLint is pinned to 9.** ESLint 10 currently crashes with this Next 16 toolchain
  (`scopeManager.addGlobals is not a function`). Don't bump it as a housekeeping change.
- **`import/no-cycle` is disabled** — its TypeScript resolver has an interface mismatch here. The reason is
  noted in `eslint.config.mjs`; don't re-enable it without checking that note.

---

## 5. MUI v9 API only

The type gate catches most of this, but not all of it, and the failures are confusing when it doesn't.

- Use `sx={{ mb: 4 }}`, **not** `mb={4}` as a direct prop.
- **Do not pass `flexWrap` or `useFlexGap` as direct props to `Stack`.** Neither is a valid v9 `Stack` prop;
  both cause a TypeScript overload error. Use `sx={{ flexWrap: 'wrap' }}`. `useFlexGap` was a v5 opt-in and
  does not exist in v9.
- No other v5/v6-era props: `storageWindow`, `InitColorSchemeScript`. See [theme.md](theme.md) for the
  color-scheme ones specifically, which fail *silently* rather than at compile time.
- Avoid deprecated MUI APIs that throw at runtime.
