first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,66 @@
# `local-backend` — Default in-process `FingerprintBackend`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`LocalBackend` is the default implementation of the `FingerprintBackend`
contract that hooks and components depend on. It composes `AudioFingerprinter`
and `FingerprintMatcher` so callers get a single facade with the same shape
the optional `PythonBackend` exposes from its gated subpath. The class owns
no state apart from its two collaborator instances.
## Features
- Single facade for `fingerprint(doc, opts?)` and `match(query, corpus)`.
- `AbortSignal` and progress passthrough to the underlying fingerprinter.
- Pure in-process: no I/O, no fetch, no server dependency.
- Matches the `FingerprintBackend` interface byte-for-byte so it can be
swapped at runtime via a hook prop.
- Constructor takes no arguments — defaults rule.
## Usage: Basic Example
```ts
import { LocalBackend } from "@suaveplan/react-audio-fingerprint/backends/local-backend";
const backend = new LocalBackend();
const fp = await backend.fingerprint(doc);
```
## Usage: Advanced Example
Inject the backend into the hook explicitly to keep the default exporting tree
shaken when a custom backend is in use:
```ts
import { LocalBackend } from "@suaveplan/react-audio-fingerprint/backends/local-backend";
const backend = new LocalBackend();
const controller = new AbortController();
const fp = await backend.fingerprint(doc, {
signal: controller.signal,
onProgress: ({ fraction }) => console.log(fraction),
});
const results = await backend.match(fp, corpus);
```
## API Reference
- `class LocalBackend implements FingerprintBackend`.
- `fingerprint(doc, opts?): Promise<Fingerprint>`.
- `match(query, corpus): Promise<MatchResult[]>`.
## Implementation Notes
The class deliberately avoids exposing the underlying `AudioFingerprinter` and
`FingerprintMatcher` instances so consumers cannot mutate them after
construction — this preserves determinism and keeps the public surface stable
across versions. There is no caching layer here; consumers wishing to cache
fingerprint results between renders should do so at the hook layer, where the
relationship to the React commit phase is explicit. Errors from the
underlying components propagate unchanged; the backend deliberately does NOT
re-wrap them so the original `ReactAudioFingerprintError` code surfaces to the
caller.

View File

@@ -0,0 +1,67 @@
# `FingerprintMatchDisplay` — Ranked results list
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<FingerprintMatchDisplay>` renders the output of `FingerprintMatcher.match`
as an accessible ranked list. Each row shows the result identifier, a
confidence bar whose width is proportional to the top score, and the recovered
time offset in seconds. The component is intentionally lightweight — it has
no fetching logic, no internal state, and no animation — so consumers can
compose it inside any layout without conflicts.
## Features
- Empty-state message via `<p role="status">`.
- Confidence bar with proper `role="progressbar"` and `aria-valuemin/max/now`.
- Optional `onSelect` callback renders the row as a `<button>` for keyboard
activation.
- Optional `renderTitle` to override the default `id` label.
- Accessible `aria-label` on the wrapping list (`"Match results"` default).
- Zero side-effects; pure rendering function.
## Usage: Basic Example
```tsx
import { FingerprintMatchDisplay } from "@suaveplan/react-audio-fingerprint/components/fingerprint-match-display";
<FingerprintMatchDisplay results={ranked} />;
```
## Usage: Advanced Example
Map IDs to friendlier titles and listen for selection:
```tsx
import { FingerprintMatchDisplay } from "@suaveplan/react-audio-fingerprint/components/fingerprint-match-display";
<FingerprintMatchDisplay
results={ranked}
label="Top matches in your library"
onSelect={(r) => navigateToTrack(r.id)}
renderTitle={(r) => library.titles[r.id] ?? r.id}
/>;
```
## API Reference
- `FingerprintMatchDisplayProps = { results, label?, onSelect?, renderTitle? }`.
- `results: ReadonlyArray<MatchResult>`.
- `label?: string` (default `"Match results"`).
- `onSelect?: (result: MatchResult) => void`.
- `renderTitle?: (result: MatchResult) => string`.
## Implementation Notes
The component never mutates `props.results`. The confidence bar's width
percentage is computed against the top score (`results[0].score`) so the best
match always reads 100% and the rest scale proportionally — this provides an
intuitive visual ranking regardless of the absolute hash counts. When
`onSelect` is supplied each row renders as a `<button type="button">` so the
default keyboard behaviour (Enter / Space) just works; without `onSelect` the
rows are plain `<li>` to avoid spurious focus stops. The empty state uses
`role="status"` (rather than rendering nothing) so assistive tech announces
"no matches" rather than silently leaving the user in the dark.

View File

@@ -0,0 +1,72 @@
# `memory-corpus` — In-memory `Map`-backed fingerprint corpus
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MemoryCorpus` is the default `Corpus` implementation: a `Map<string,
Fingerprint>` wrapped with a `Promise`-returning surface and a subscription
hook. It satisfies the matcher's `CorpusLike` contract and the React hook's
`subscribe` requirement, making it the simplest store you can plug into the
fingerprint pipeline. The asynchronous API matches the `IndexedDbCorpus`
follow-up so caller code does not need to branch on backing store.
## Features
- `add`, `remove`, `get`, `size`, `values`, `subscribe`.
- Sync work behind an async surface — no I/O cost in tests.
- Notifies subscribers on every successful mutation.
- Throws `REACT_AUDIO_FINGERPRINT.CORPUS_FAILED` on invalid IDs.
- `subscribe(listener)` returns an unsubscribe function (React-friendly).
- Iteration order matches insertion order.
## Usage: Basic Example
```ts
import { MemoryCorpus } from "@suaveplan/react-audio-fingerprint/corpus/memory-corpus";
const corpus = new MemoryCorpus();
await corpus.add("song-1", fingerprint);
console.log(corpus.size()); // 1
```
## Usage: Advanced Example
Subscribe to mutations from a `useSyncExternalStore` consumer and clean up on
unmount:
```ts
import { useSyncExternalStore } from "react";
import { MemoryCorpus } from "@suaveplan/react-audio-fingerprint/corpus/memory-corpus";
const corpus = new MemoryCorpus();
function useCorpusSize(): number {
return useSyncExternalStore(
(cb) => corpus.subscribe(cb),
() => corpus.size(),
() => 0
);
}
```
## API Reference
- `class MemoryCorpus implements Corpus`.
- `add(id, fingerprint): Promise<void>`.
- `remove(id): Promise<void>`.
- `get(id): Promise<FingerprintCorpusEntry | undefined>`.
- `size(): number`.
- `values(): IterableIterator<FingerprintCorpusEntry>`.
- `subscribe(listener): () => void`.
## Implementation Notes
The listener set is a `Set` so subscribe/unsubscribe is O(1) and duplicate
registrations are de-duped. `remove` only notifies when the key actually
existed; this matches the React convention that no-op mutations should not
trigger a render. Empty-string IDs are rejected so consumers cannot create a
ghost entry that `get("")` would later return — the matcher relies on `id`
being a stable label. Iteration uses a generator so callers can `break` early
without paying for a full copy of the store.

View File

@@ -0,0 +1,89 @@
# `error-codes` — Stable error registry for `@suaveplan/react-audio-fingerprint`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module declares every error code that `@suaveplan/react-audio-fingerprint`
can throw. Each code is registered through `defineCodes` from
`@suaveplan/error`, producing namespaced strings (e.g.
`REACT_AUDIO_FINGERPRINT.DECODE_FAILED`) that downstream code, telemetry, and
i18n catalogues can branch on without depending on free-form messages. The
companion `ReactAudioFingerprintError` class extends `SuaveplanError`, carries
the code as a structural field, and round-trips through the error wire format
defined by the error package.
## Features
- Six explicit codes covering the full failure surface: decode, fingerprint,
match, corpus, abort, and option validation.
- Frozen metadata table keyed by code with `description` and `severity`.
- `ReactAudioFingerprintError` class with stable `category = "react-audio-fingerprint"`.
- Idempotent `registerReactAudioFingerprintErrorCodes()` hook for parity with
sibling packages.
- Strict TypeScript typing — codes are inferred from the literal tuple.
## Usage: Basic Example
```ts
import {
REACT_AUDIO_FINGERPRINT_CODES,
ReactAudioFingerprintError,
} from "@suaveplan/react-audio-fingerprint/error-codes";
throw new ReactAudioFingerprintError(
REACT_AUDIO_FINGERPRINT_CODES.DECODE_FAILED,
"react-audio-fingerprint.decode.failed",
{ context: { mime: "audio/mp3" } }
);
```
## Usage: Advanced Example
Discriminate caught errors by code and surface a structured toast:
```ts
import {
REACT_AUDIO_FINGERPRINT_CODES,
ReactAudioFingerprintError,
REACT_AUDIO_FINGERPRINT_ERROR_METADATA,
} from "@suaveplan/react-audio-fingerprint/error-codes";
try {
await fingerprint(source);
} catch (e) {
if (e instanceof ReactAudioFingerprintError) {
const meta = REACT_AUDIO_FINGERPRINT_ERROR_METADATA[e.code];
if (e.code === REACT_AUDIO_FINGERPRINT_CODES.ABORTED) {
return; // user-driven cancel, no toast
}
toast.error(`${meta.severity}: ${meta.description}`);
}
throw e;
}
```
## API Reference
- `REACT_AUDIO_FINGERPRINT_CODES` — frozen object whose keys/values are the
six dotted code strings.
- `ReactAudioFingerprintErrorCode` — union of valid code strings.
- `REACT_AUDIO_FINGERPRINT_ERROR_METADATA` — frozen lookup keyed by code.
- `ReactAudioFingerprintError extends SuaveplanError` — `code`, `messageKey`,
optional `cause` / `context`, `category: "react-audio-fingerprint"`.
- `registerReactAudioFingerprintErrorCodes()` — no-op registration hook for
symmetry with packages that defer metadata registration to runtime.
## Implementation Notes
The metadata table is `Object.freeze`d so consumers cannot mutate it. Every
code in `REACT_AUDIO_FINGERPRINT_CODES` MUST have a metadata entry; the test
suite asserts the two sets line up via `Object.keys`. The class sets
`this.name` to include the severity for friendlier debugger output without
mutating the underlying code, leaving structural equality intact. Registration
runs at module load via the side-effectless hook so importing the barrel is
sufficient to make codes discoverable from the central registry. No biome
suppression comments are present; the file passes biome's strict ruleset
unaided.

View File

@@ -0,0 +1,73 @@
# `audio-fingerprinter` — Spectrogram → peaks → constellation orchestrator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`AudioFingerprinter` is the entry point that turns an `AudioDocument` into a
deterministic constellation fingerprint. It composes three pieces: the
spectrogram from `@suaveplan/audio-analysis-spectrum` (the ONLY FFT used by
this package), the local-peak detector defined alongside this file, and the
constellation pair-hasher. The class owns no DSP — every numerical step is
delegated, which keeps the fingerprint algorithm in one auditable file.
## Features
- Zod-validated options (`frameSize`, `hopSize`, `window`, `noiseFloor`,
`peaksPerFrame`, `neighbourhood`, `fanout`, `targetZoneSec`).
- Cooperative cancellation via `AbortSignal` checked at every pipeline stage.
- Monotonic progress callback (`{ fraction }`) reported at 0, 0.5, 0.8, 1.
- Wraps spectrogram failures in `REACT_AUDIO_FINGERPRINT.FINGERPRINT_FAILED`
while preserving the underlying `cause`.
- Stable output: identical input bytes produce a byte-equal fingerprint.
- Returns the sample rate, hop, and frame size alongside the fingerprint so
the matcher / visualizer can reconstruct frame ↔ time arithmetic.
## Usage: Basic Example
```ts
import { AudioFingerprinter } from "@suaveplan/react-audio-fingerprint/fingerprint/audio-fingerprinter";
const fp = await new AudioFingerprinter().fingerprint(doc);
console.log(fp.fingerprint.length, "pairs");
```
## Usage: Advanced Example
Pass an `AbortSignal` and a progress callback while running a non-default
window:
```ts
import { AudioFingerprinter } from "@suaveplan/react-audio-fingerprint/fingerprint/audio-fingerprinter";
const controller = new AbortController();
const fp = await new AudioFingerprinter().fingerprint(doc, {
window: "blackman",
frameSize: 2048,
hopSize: 512,
signal: controller.signal,
onProgress: ({ fraction }) => console.log(fraction),
});
```
## API Reference
- `class AudioFingerprinter`.
- `fingerprint(doc, opts?): Promise<FingerprintResult>`.
- `FingerprintResult = { fingerprint, sampleRate, hopSize, frameSize }`.
- `FingerprintOptionsSchema` — Zod schema; defaults `frameSize=4096`,
`hopSize=1024`, `window="hann"`, `targetZoneSec=5`.
- `signal?: AbortSignal`, `onProgress?: ({ fraction }) => void`.
## Implementation Notes
`targetZoneFrames` is derived from `targetZoneSec` using the document's actual
`sampleRate` and the configured `hopSize`. This keeps the target zone musically
consistent across rates (e.g. 16 kHz speech vs 48 kHz music). The class only
re-throws a `ReactAudioFingerprintError` when it can identify the original
failure as an abort; everything else becomes `FINGERPRINT_FAILED` with the
original error as `cause`. There is no caching — every call re-runs the
spectrogram. Callers wanting to fingerprint the same document twice should
memoise the result themselves.

View File

@@ -0,0 +1,69 @@
# `constellation` — Pair-hash construction over detected peaks
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The constellation module converts a list of spectrogram peaks into a stable,
hashable fingerprint by pairing each anchor peak with several subsequent peaks
inside a fixed target zone. Each pair `(binA, binB, Δframes)` is packed into a
32-bit integer; the resulting `(hash, timeOffsetSec)` records form the
fingerprint that the matcher consumes. This is the classic Shazam construction
described by Wang (2003) without any modifications.
## Features
- 32-bit packed hash with explicit `binA[12] | binB[12] | dt[8]` layout.
- `packHash` / `unpackHash` round-trip helpers for diagnostics and tests.
- Configurable `fanout`, `targetZoneFrames`, `minDeltaFrames` via Zod schema.
- Deterministic output sorted by anchor `timeOffsetSec`.
- Empty-input safe (no peaks → empty fingerprint, no throw).
- Validates `hopSamples` / `sampleRate` and rejects non-positive grids with
`ReactAudioFingerprintError(INVALID_OPTIONS)`.
## Usage: Basic Example
```ts
import { buildConstellation } from "@suaveplan/react-audio-fingerprint/fingerprint/constellation";
const fp = buildConstellation(peaks, 512, 48_000);
console.log(fp.length, fp[0]?.hash);
```
## Usage: Advanced Example
Tighten the target zone for short percussive clips:
```ts
import { buildConstellation } from "@suaveplan/react-audio-fingerprint/fingerprint/constellation";
const fp = buildConstellation(peaks, 256, 48_000, {
fanout: 8,
targetZoneFrames: 80,
minDeltaFrames: 2,
});
```
## API Reference
- `buildConstellation(peaks, hopSamples, sampleRate, opts?): Fingerprint`.
- `Fingerprint = ReadonlyArray<{ hash: number; timeOffsetSec: number }>`.
- `packHash(binA, binB, dt): number` — 32-bit pack.
- `unpackHash(h): { binA; binB; dt }` — inverse.
- `ConstellationOptionsSchema` — Zod schema with defaults `fanout=5`,
`targetZoneFrames=200`, `minDeltaFrames=1`.
## Implementation Notes
The Δt field is clamped to 8 bits, which limits the effective target zone to
255 hop-frames regardless of the schema value. At the default 512-sample hop
and 48 kHz sample rate this is ~2.72 seconds — well inside the classic
constellation target-zone width. Hashes are unsigned 32-bit integers; the
`>>> 0` at the end of `packHash` reinterprets the bitwise result, which is
otherwise a signed 32-bit integer in JavaScript's number type. Output sorting
makes equal-fingerprint comparison trivial (`JSON.stringify` round-trip is
enough for byte equality). Pair construction is O(`peaks × fanout`) because
each anchor stops emitting once the fanout cap is hit or the target zone is
exhausted.

View File

@@ -0,0 +1,67 @@
# `fingerprint-matcher` — Time-offset histogram match scoring
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`FingerprintMatcher` scores a query fingerprint against a corpus of candidate
fingerprints using the classic Shazam time-offset histogramming technique. For
every hash that appears in both query and candidate, the matcher accumulates
`(candidateOffset − queryOffset)` into a 10 ms-wide histogram bucket; the
heaviest bucket count is the match score and its midpoint is the alignment
offset. Results sort by descending score, ready for ranked display.
## Features
- `match(query, corpus)` returns ranked `MatchResult[]`.
- `matchOne(query, candidate)` returns the best bin only — useful for unit
tests and direct pairwise comparison.
- Configurable `binWidthSec` (default 0.01 s).
- Hash-index pre-step keeps the cost at `O(|query| + |candidate|)` per pair.
- Wraps iteration-time corpus failures in
`REACT_AUDIO_FINGERPRINT.MATCH_FAILED`.
- Empty inputs return zero-scored results without throwing.
## Usage: Basic Example
```ts
import { FingerprintMatcher } from "@suaveplan/react-audio-fingerprint/fingerprint/fingerprint-matcher";
const ranked = new FingerprintMatcher().match(queryFp, corpus);
console.log(ranked[0]?.id, ranked[0]?.score);
```
## Usage: Advanced Example
Drive a tighter alignment window for short clips:
```ts
import { FingerprintMatcher } from "@suaveplan/react-audio-fingerprint/fingerprint/fingerprint-matcher";
const m = new FingerprintMatcher();
const one = m.matchOne(queryFp, candidateFp, { binWidthSec: 0.005 });
console.log(one.score, one.offsetSec, one.matchedHashCount);
```
## API Reference
- `class FingerprintMatcher`.
- `match(query, corpus, opts?): MatchResult[]`.
- `matchOne(query, candidate, opts?): MatchOneResult`.
- `MatchResult = { id, score, offsetSec, matchedHashCount }`.
- `CorpusLike = { values(): Iterable<{ id; fingerprint }> }`.
- `MatcherOptions = { binWidthSec?: number }` (default `0.01`).
## Implementation Notes
A candidate's hash table is built lazily on each call — caching is left to
callers because corpora are typically streamed. Hash indices use `Map<number,
number[]>` since duplicate hashes inside one fingerprint are common (the same
constellation pair can recur at different times). The histogram is also a
`Map<number, number>` because the bucket range is unbounded in principle.
Bucket midpoints (rather than left edges) keep the reported offset symmetric
around the true alignment. The matcher is pure — given the same inputs it
produces the same outputs — so callers can safely memoise results keyed by
`(queryFp, corpusVersion)`.

View File

@@ -0,0 +1,69 @@
# `peak-detector` — 2D spectrogram local-peak detection
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The peak detector consumes a spectrogram (a sequence of FFT magnitude frames
produced by `@suaveplan/audio-analysis-spectrum`) and returns the time-frequency
coordinates of cells that dominate their local neighbourhood. The output drives
constellation hashing, which is the core of every Shazam-style fingerprint
algorithm. The detector is deterministic — identical input produces a
byte-equal peak list — and is the only place in the package where raw
spectrogram cells are inspected.
## Features
- Configurable square `neighbourhood` radius (default 3).
- `noiseFloor` threshold (linear magnitude, default 1e-3 ≈ −60 dBFS).
- `peaksPerFrame` cap (default 5) — bounds memory and downstream hash count.
- Deterministic ordering: ascending frame, then descending magnitude.
- Zod-validated options with a `ReactAudioFingerprintError(INVALID_OPTIONS)` on
invalid input.
## Usage: Basic Example
```ts
import { detectPeaks } from "@suaveplan/react-audio-fingerprint/fingerprint/peak-detector";
const peaks = detectPeaks(spectrogram);
console.log(peaks.length, peaks[0]);
```
## Usage: Advanced Example
Tighten the neighbourhood and lower the floor to catch quiet harmonics in a
chirp:
```ts
import { detectPeaks } from "@suaveplan/react-audio-fingerprint/fingerprint/peak-detector";
const peaks = detectPeaks(spectrogram, {
neighbourhood: 2,
noiseFloor: 1e-5,
peaksPerFrame: 8,
});
```
## API Reference
- `detectPeaks(spectrogram, opts?): Peak[]` — runs the 2D local-max sweep.
- `Peak = { frame: number; bin: number; magnitude: number }`.
- `PeakDetectorOptionsSchema` — Zod schema; defaults shown above.
- `PeakDetectorOptionsInput` — pre-parse input shape (everything optional).
## Implementation Notes
The implementation uses a straightforward `O(F · B · N²)` sweep where `F` is
the frame count, `B` is the bin count, and `N` is the neighbourhood radius. The
constants in the spec (default `N=3`, `peaksPerFrame=5`, `bins≈1025` for a
2048-point FFT) keep the cost firmly in `Float32Array` index arithmetic — no
heap allocations inside the inner loop other than the per-frame `frameHits`
array, which is bounded by the bin count. Sorting by descending magnitude and
truncating to `peaksPerFrame` ensures the loudest local maxima win when more
candidates qualify than the per-frame cap allows; ties resolve to the lower
bin via the stable native sort. Empty spectrograms produce an empty array
without throwing; downstream constellation hashing handles that case by
emitting no pairs.

View File

@@ -0,0 +1,71 @@
# `useAudioFingerprint` — React hook for the fingerprint pipeline
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useAudioFingerprint(source, options?)` drives a `FingerprintBackend` off a
React-managed `AudioDocument` and returns the live state, the resulting
fingerprint when done, a `0..1` progress fraction, and any error encountered.
The hook centralises the abort-on-unmount and progress wiring so component
authors do not have to repeat that work across uploaders, visualisers, and
match displays.
## Features
- `source` can be `undefined`/`null` to mean "do nothing" — useful while
waiting for an upload to decode.
- `state` cycles through `'idle'` → `'fingerprinting'` → `'done'` (or
`'error'`); a fresh source resets the cycle.
- Abort-on-unmount and abort-on-source-change via internal `AbortController`.
- Backend swap via `options.backend`; defaults to `LocalBackend`.
- Wraps non-`ReactAudioFingerprintError` rejections in `FINGERPRINT_FAILED`.
- Suppresses `ABORTED` errors — they settle back to `'idle'`.
## Usage: Basic Example
```tsx
import { useAudioFingerprint } from "@suaveplan/react-audio-fingerprint/hooks/use-audio-fingerprint";
function FingerprintPreview({ doc }: { doc: AudioDocument }) {
const { state, fingerprint, progress } = useAudioFingerprint(doc);
if (state === "fingerprinting") return <progress value={progress} />;
if (state === "done") return <span>{fingerprint?.length} pairs</span>;
return null;
}
```
## Usage: Advanced Example
Inject a custom backend (e.g. the optional Python backend) and surface errors:
```tsx
import { useAudioFingerprint } from "@suaveplan/react-audio-fingerprint/hooks/use-audio-fingerprint";
function Surface({ doc, backend }: { doc: AudioDocument; backend: FingerprintBackend }) {
const { state, error, fingerprint } = useAudioFingerprint(doc, { backend });
if (state === "error") return <Banner code={error?.code}>{error?.message}</Banner>;
if (state === "done") return <Result fp={fingerprint!} />;
return <Spinner />;
}
```
## API Reference
- `useAudioFingerprint(source, options?): UseAudioFingerprintResult`.
- `source: AudioDocument | undefined | null`.
- `options?: { backend?: FingerprintBackend }`.
- `UseAudioFingerprintResult = { state, fingerprint, progress, error }`.
- `state: 'idle' | 'fingerprinting' | 'done' | 'error'`.
## Implementation Notes
The hook uses `useMemo` to stabilise the default `LocalBackend` so changing
unrelated props does not retrigger the effect. The `alive` flag guarding all
post-await `setState` calls is the canonical React pattern for protecting
against the lifecycle race between an async fetch completing and a fast
unmount. Aborts settle to `'idle'` (not `'error'`) so user-driven cancels do
not produce a banner. Non-Suaveplan errors get wrapped so consumers can always
discriminate via `error.code` without an `instanceof` chain.

View File

@@ -0,0 +1,58 @@
# `test-setup` — Vitest global setup
## Purpose
This file is loaded by `vitest.config.ts` via `setupFiles` so every test in
the package runs against a consistent jest-dom matcher set and tears down its
React tree after each case. It is intentionally short — heavy work happens in
the runner-agnostic polyfills shipped by `@suaveplan/testing/polyfills`, which
this file complements with DOM-specific affordances.
## Features
- Imports `@testing-library/jest-dom/vitest` to register matchers such as
`.toBeInTheDocument()` and `.toHaveAccessibleName()`.
- Registers an `afterEach` that calls `cleanup()` from
`@testing-library/react`, unmounting trees between tests and avoiding
cross-test DOM pollution.
- No mocks, no spies, no global stubs — keeps the suite faithful to the
CLAUDE.md "no mocks" rule.
## Usage: Basic Example
`vitest.config.ts` references this file:
```ts
setupFiles: [
"@suaveplan/testing/polyfills",
"./src/test-setup.ts",
],
```
## Usage: Advanced Example
If a future component needs an additional matcher (for example to assert
custom accessibility properties), add the relevant import here so every test
in the package benefits without per-file boilerplate:
```ts
import "@testing-library/jest-dom/vitest";
import { afterEach } from "@suaveplan/testing/runner";
import { cleanup } from "@testing-library/react";
// (Add additional matchers or global afterEach hooks here.)
afterEach(() => cleanup());
```
## API Reference
- No exports; runs purely for side-effects at vitest setup time.
- Depends on `@suaveplan/testing/runner` for `afterEach` (rule 11 compliance).
## Implementation Notes
The setup file runs once per worker, so the `cleanup` afterEach registration
is shared across every test file in that worker. This is the canonical
pattern documented by Testing Library and matches every sibling Suaveplan
React package's setup. The package owns no provider configuration so there is
no provider-tree wrapper to install — tests render components directly via
`@testing-library/react`'s `render` / `renderHook`.