Spaces:
Running
Running
| # Testing | |
| > **Scope.** Backend tests live under `inspector/tests/` (pytest) and `qua_shared/tests/` (pytest). Frontend tests live co-located with their modules under `inspector/frontend/src/**/__tests__/*.test.ts` (vitest + happy-dom + @testing-library/svelte). Shared JSON fixtures live under `inspector/tests/fixtures/segments/` and are aliased to `@fixtures` in vitest. CI runs all three suites; coverage is measured but not gated. | |
| Where it lives: | |
| | Concern | Location | | |
| |---|---| | |
| | BE pytest tree | `inspector/tests/` (subdirs mirror the prod tree: `services/`, `routes/`, `db/`, `persistence/`, `command/`, `undo/`, `classifier/`, `registry/`, `identity/`, `admin/`, `parity/`) | | |
| | BE shared fixtures + autouse substrate | `inspector/tests/conftest.py` | | |
| | qua_shared pytest tree | `qua_shared/tests/` (standalone β uses its own sys.path bootstrap) | | |
| | FE vitest tree | colocated under `inspector/frontend/src/**/__tests__/` | | |
| | FE setup + global fetch stub | `inspector/frontend/vitest.setup.ts` | | |
| | FE vitest config + coverage exclude list | `inspector/frontend/vitest.config.ts` | | |
| | BE coverage config | `inspector/pyproject.toml` `[tool.coverage]` | | |
| | CI workflow | `.github/workflows/inspector-checks.yml` | | |
| | JSON fixtures (BE + FE) | `inspector/tests/fixtures/segments/` (`@fixtures` alias from FE) | | |
| | Test-only segment factories | `inspector/frontend/src/tabs/segments/__tests__/helpers/make-segment.ts` | | |
| A new subsystem maps to a new pytest subdir + a colocated FE `__tests__/` folder. Don't add tests at the repo root or under `inspector/tests/` directly β pick the subdir matching the subsystem reference doc (see [docs/reference/README.md](README.md)). | |
| ## Runners and tools | |
| **Backend (pytest).** Python 3.11, pytest β₯7. The autouse `_substrate_db` fixture in `inspector/tests/conftest.py` provisions a fresh, migrated SQLite DB at `tmp_path/inspector-test.db` for every test and disables bucket sync. Teardown resets the global db_seq counter and invalidates the db_seq-keyed caches (`public_reciters`, `catalog_snapshot`, `admin_users`, `admin_requests`, `capability_matrix`). No test sees another's DB rows. | |
| **Frontend (vitest).** vitest with the v8 coverage provider, happy-dom for the DOM, `@testing-library/svelte` for component rendering, `@testing-library/user-event` for interactions. `vitest.setup.ts` installs a global fetch stub that returns `200 {}` for every URL (with a special case for `/surah-info/*`). | |
| **Shared mock infrastructure.** | |
| | Need | BE | FE | | |
| |---|---|---| | |
| | Tmp filesystem | pytest `tmp_path` + `FilesystemBackend` | happy-dom | | |
| | HTTP client | `flask_client` / `signed_in_client` | global `fetch` stub in `vitest.setup.ts` | | |
| | Auth identity | `signed_in_client(role=...)` mints a signed cookie | `currentUser.set(...)` against the real store | | |
| | Per-reciter bucket files | `tmp_reciter_dir.install(slug, fixture)` | n/a | | |
| End-to-end Playwright lives under `inspector/frontend/tests/e2e/` (`npm run test:e2e`) β out of scope for the unit-test contract. | |
| ## Running tests | |
| | Task | Command | | |
| |---|---| | |
| | BE all | `cd inspector && python -m pytest tests/ -v` | | |
| | BE single test | `python -m pytest tests/db/test_repo_state.py::test_name -v` | | |
| | BE single file | `python -m pytest tests/routes/test_route_save.py -v` | | |
| | BE keyword | `python -m pytest tests/ -k "transition and reject" -v` | | |
| | BE with coverage | `python -m pytest --cov=services --cov=routes --cov=domain --cov=adapters --cov-report=term-missing tests/` | | |
| | qua_shared | `python -m pytest qua_shared/tests -v` | | |
| | FE all | `cd inspector/frontend && npm run test` | | |
| | FE single | `npx vitest run src/lib/playback/__tests__/audio-graph.test.ts` | | |
| | FE watch | `npx vitest src/lib/playback/__tests__/audio-graph.test.ts` | | |
| | FE with coverage | `npm run test:coverage` | | |
| | FE typecheck | `npm run check` | | |
| | FE lint | `npm run lint` | | |
| CI does not gate on coverage thresholds (`thresholds: undefined` in `vitest.config.ts`; no `fail_under` in `pyproject.toml`). Coverage is informational. See [Coverage](#coverage) below. | |
| ## How to add a test for X | |
| Four walkthroughs covering the common cases. Pick the one closest to your work and copy the shape. | |
| ### 1. State-machine transition rule (BE) | |
| Tests for `transition()` lifecycle moves live under `inspector/tests/services/test_state_*.py` (event-centric) or `inspector/tests/db/test_repo_transitions.py` (storage-centric). Seed the FROM state via `seed_state`, call `transition()`, assert the post-state row + the audit row. | |
| ```python | |
| from datetime import datetime, timezone | |
| from qua_shared.schemas import Actor, ReciterState, Role | |
| from services.db import repo_state, repo_transitions | |
| from services.state import transition | |
| def _actor(role: str = "contributor") -> Actor: | |
| return Actor(hf_user_id="u-1", login_at_time="alice", role=Role(role)) | |
| def test_request_moves_catalogued_to_awaiting_alignment(seed_state): | |
| seed_state("d1", state="catalogued") | |
| transition( | |
| "d1", | |
| event="reciter.requested", | |
| actor=_actor(), | |
| now=datetime.now(timezone.utc), | |
| payload={"request_id": "req-1"}, | |
| ) | |
| row = repo_state.get_row("d1") | |
| assert row.state == ReciterState.AWAITING_ALIGNMENT | |
| transitions = repo_transitions.for_slug("d1") | |
| assert transitions[-1].event == "reciter.requested" | |
| assert transitions[-1].actor.hf_user_id == "u-1" | |
| ``` | |
| Rules: | |
| - Drive state through `transition()`, never via direct `repo_state.update_state` ad-hoc β that's the durability boundary the state-machine doc enforces. | |
| - For audit assertions, prefer `repo_transitions.for_slug(slug)` over patching `audit.append`. If you must record-and-assert, **call through** to the real implementation (see [Mocking boundaries](#mocking-boundaries)). | |
| - See [`state-machine.md`](state-machine.md) for the full event matrix. | |
| ### 2. HTTP route handler (BE) | |
| Route tests live under `inspector/tests/routes/test_route_<name>.py`. Use `signed_in_client` to mint a cookie. If the route reads or writes per-reciter bucket content, also request `tmp_reciter_dir` and call `.install(slug, fixture)` or `.seed_under_review(slug, hf_user_id)`. Mutating routes go through `require_same_origin` β include the `Origin` header. | |
| ```python | |
| import json | |
| _HEADERS = {"Content-Type": "application/json", "Origin": "http://localhost"} | |
| def test_save_assignee_succeeds(signed_in_client, tmp_reciter_dir): | |
| reciter = "fixture_reciter" | |
| tmp_reciter_dir.install(reciter, "112-ikhlas", under_review_for="test-user-1") | |
| client, user = signed_in_client(hf_user_id="test-user-1", login="alice") | |
| res = client.post( | |
| f"/api/seg/save/{reciter}/112", | |
| data=json.dumps({"segments": [], "operations": []}), | |
| headers=_HEADERS, | |
| ) | |
| assert res.status_code == 200 | |
| body = res.get_json() | |
| assert body["ok"] is True | |
| def test_save_cross_origin_rejected(signed_in_client, tmp_reciter_dir): | |
| reciter = "fixture_reciter" | |
| tmp_reciter_dir.install(reciter, "112-ikhlas", under_review_for="test-user-1") | |
| client, _ = signed_in_client(hf_user_id="test-user-1") | |
| res = client.post( | |
| f"/api/seg/save/{reciter}/112", | |
| data=json.dumps({"segments": [], "operations": []}), | |
| headers={"Content-Type": "application/json", "Origin": "http://evil.example"}, | |
| ) | |
| assert res.status_code == 403 | |
| assert res.get_json()["code"] == "SAME_ORIGIN_REQUIRED" | |
| ``` | |
| Rules: | |
| - Assert exact `status_code == N`; never `in (200, 4xx)` β it masks which branch ran. | |
| - For 4xx responses, assert `body["code"]` too. The error envelope is part of the contract β see [`auth-permissions.md`](auth-permissions.md). | |
| - Anonymous requests use `flask_client` (no cookie). Don't reach for `flask_client` when `signed_in_client` is what you need. | |
| ### 3. Segments editor command (FE) | |
| Command tests live under `inspector/frontend/src/tabs/segments/__tests__/command/<command>.test.ts`. Build state via `makeApplyCommandState` + `makeSegment` from the helpers module, call `applyCommand(state, command)` directly, and assert on `result.nextState` + `result.operation`. | |
| ```ts | |
| import { describe, expect, it } from 'vitest'; | |
| import { applyCommand } from '../../domain/apply-command'; | |
| import type { TrimCommand } from '../../domain/command'; | |
| import { makeApplyCommandState, makeSegment } from '../helpers/make-segment'; | |
| const baseState = () => makeApplyCommandState([ | |
| makeSegment(0, 0, 2000, { segment_uid: 'uid-trim' }), | |
| ]); | |
| const baseCmd: TrimCommand = { | |
| type: 'trim', | |
| segmentUid: 'uid-trim', | |
| delta: { time_start: 250 }, | |
| }; | |
| describe('command/trim', () => { | |
| it('mutates time_start to the delta value', () => { | |
| const r = applyCommand(baseState(), baseCmd); | |
| const updated = r.nextState.byId?.['uid-trim']; | |
| expect(updated.time_start).toBe(250); | |
| }); | |
| it('records before + after snapshots on the operation', () => { | |
| const r = applyCommand(baseState(), baseCmd); | |
| expect(r.operation.snapshots?.before).toBeTruthy(); | |
| expect(r.operation.snapshots?.after).toBeTruthy(); | |
| }); | |
| }); | |
| ``` | |
| For the BE save-flow side of the same command (JSONL round-trip, undo), pair the FE test with a `inspector/tests/persistence/` or `inspector/tests/command/` test that drives the same command through `/api/seg/save/<slug>/<ch>` and reads the persisted batch back via `parse_edit_history_line`. | |
| Rules: | |
| - Don't gate new tests behind `loadOptional` + `describe.skipIf`. That convention is for tests landing ahead of their implementation on the same branch series β flag stale `skipIf` gates as soon as the module exists. See [Phase-gates](#phase-gates). | |
| - Type command literals against the appropriate `SegmentCommand` member; avoid `as any`. | |
| - See [`segments-editor.md`](segments-editor.md) for the command grammar. | |
| ### 4. Frontend component (@testing-library/svelte) | |
| Component tests live colocated with the component under `__tests__/<Name>.test.ts`. Render via `render(Component, { props })`, query with `screen.getByRole`/`getByText`, and assert against the DOM. For components reading shared stores, import the real store and `set()` it before rendering. | |
| ```ts | |
| import { render } from '@testing-library/svelte'; | |
| import { describe, expect, it } from 'vitest'; | |
| import type { ReciterTask } from '../../api/reciter-task'; | |
| import { currentUser } from '../../stores/current-user'; | |
| import ClaimButton from '../ClaimButton.svelte'; | |
| function makeTask(canClaim: boolean): ReciterTask { | |
| return { | |
| row: { | |
| slug: 'this-reciter', name: 'X', state: 'awaiting_review', | |
| state_since: '2026-01-01T00:00:00Z', | |
| assignee_hf_id: null, assignee_login: null, assignee_since: null, | |
| marked_ready: false, visibility: 'public', | |
| }, | |
| predicates: { can_claim: canClaim, can_edit: false, /* ... */ }, | |
| } as ReciterTask; | |
| } | |
| describe('ClaimButton', () => { | |
| it('renders nothing for an anonymous user', () => { | |
| currentUser.set(null); | |
| const { container } = render(ClaimButton, { | |
| props: { slug: 'this-reciter', task: makeTask(true), onClaimed: null }, | |
| }); | |
| expect(container.querySelector('button')).toBeNull(); | |
| }); | |
| }); | |
| ``` | |
| Rules: | |
| - Svelte 5 (runes) components: pass callback props directly (`onClaimed: () => {}`); don't use `events: {...}` (that's the Svelte 4 `createEventDispatcher` pattern). See the Svelte conventions in `CLAUDE.md`. | |
| - Svelte 4 legacy components keep working β the canvas/audio-imperative components (`TimestampsWaveform.svelte`, `WaveformCanvas.svelte`, etc.) are deliberately exempt from migration. | |
| - For accessibility assertions, prefer `getByRole` over `querySelector`. | |
| ## Fixture cheatsheet | |
| The autouse `_substrate_db` already gives every test a fresh migrated SQLite DB. Don't reach for `tmp_reciter_dir` if SQLite state is all you need. | |
| | You need | Use | | |
| |---|---| | |
| | Just a SQLite DB | nothing β autouse `_substrate_db` runs for every test | | |
| | Seed a `delivery_states` row + FK chain | `seed_state(slug, state=, assignee_hf_id=, marked_ready=, ...)` | | |
| | Seed a member's role | `seed_role(hf_user_id, login=, role=)` | | |
| | Mint a signed-in test client | `signed_in_client(role=...)` β `(client, user_dict)` | | |
| | Per-reciter bucket content from a fixture | `tmp_reciter_dir.install(slug, fixture_name, under_review_for=...)` | | |
| | Per-reciter content without a fixture (hand-author files) | `state_persistence` β installs `FilesystemBackend`, returns it | | |
| | Just the lock decorator to pass (no fixture) | `tmp_reciter_dir.seed_under_review(slug, hf_user_id)` | | |
| | Load a JSON fixture by name | `load_fixture(name)` β reads `<name>.detailed.json` | | |
| | Load an expected/baseline output | `load_expected(name, kind)` β reads `expected/<name>.<kind>.json` | | |
| | Flask client without auth | `flask_client` | | |
| | Inspect the FilesystemBackend root | `tmp_reciter_dir.backend` / `tmp_reciter_dir.data_dir` | | |
| `tmp_reciter_dir.install(reciter, fixture)` does four things: seeds a state row (default `AWAITING_REVIEW`, or `UNDER_REVIEW` if `under_review_for` is given), writes `detailed.json`, writes `edit_history.jsonl` if a sidecar fixture exists, and writes an empty `pipeline_meta.json` (the basmala_amin rule reads it). It also rebuilds `segments.json` to match. | |
| `signed_in_client` seeds the role into the SQLite substrate then mints a signed cookie via `auth_service.encode_session`. Returns `(client, {"hf_user_id", "login", "role"})`. The same fixture works for owner / maintainer / contributor by passing `role=`. | |
| ## Mocking boundaries | |
| Pin every test against the real boundary it owns. Mocks that reach behind the boundary lose the regression signal. | |
| | Boundary | Convention | Don't | | |
| |---|---|---| | |
| | BE bucket / data dir | `tmp_reciter_dir` or `state_persistence` (installs `FilesystemBackend`, sets `INSPECTOR_BACKEND=filesystem`) | patch `Path.read_text`, mock `hf_bucket` internals | | |
| | BE env vars | `monkeypatch.setenv` | mutate `os.environ` directly at module load | | |
| | BE HF OAuth / identity | `signed_in_client(role=...)` | mock `auth_service.encode_session` | | |
| | BE external HTTP (QF, HF API) | monkeypatch the specific service function | mock `requests.get` | | |
| | BE caches | rely on autouse teardown; add new caches to `_SEG_CACHE_NAMES` in `conftest.py` | mock cache primitives | | |
| | BE audit | record-and-call-through (capture kwargs to a list, then invoke real `audit.append`) + assert against `repo_transitions.for_slug(slug)` | replace with `lambda **kw: None` (loses the durability check) | | |
| | FE modules | real imports + props-driven state | `vi.mock(...)` for non-ambient modules | | |
| | FE `fetch` | `vi.spyOn(window, 'fetch')` per-test | global mock that returns 200 `{}` for every URL | | |
| | FE stores | real store + `.set()` in the test | mock the store | | |
| The BE audit boundary is the most common foot-gun: stubbing `services.audit.append` to a no-op is tempting, but the test then cannot catch a regression that drops the call. Record-and-call-through: | |
| ```python | |
| def _record_and_call_through(monkeypatch) -> list[dict]: | |
| from services import audit as audit_service | |
| calls: list[dict] = [] | |
| real = audit_service.append | |
| def _spy(*args, **kwargs): | |
| calls.append(kwargs) | |
| return real(*args, **kwargs) | |
| monkeypatch.setattr(audit_service, "append", _spy) | |
| return calls | |
| ``` | |
| ## Schema parity | |
| When you edit anything under `qua_shared/schemas/`, regenerate the FE types and commit them in the same change: | |
| ```bash | |
| python scripts/codegen/regen_fe_types.py | |
| git add inspector/frontend/src/lib/types/generated/schemas.ts | |
| ``` | |
| CI's `schema-codegen-check` job runs the same script and fails the build via `git diff --exit-code` if `schemas.ts` is out of sync. Codegen sources `qua_shared.schemas.fe_types`, not `qua_shared.schemas` directly β a new FE-facing model has to be re-exported there too. | |
| Persistence schemas (`DetailedSegment`, `EditHistoryBatch`, `EditOperation`, `PeaksRecord`, `AudioManifestSidecar`) MUST round-trip both directions: | |
| - The offline extraction pipeline and Inspector save flow construct via Pydantic, not dict literals. | |
| - The reader (`history_query.parse_edit_history_line`, `peaks_history`, `audio_meta`) parses via Pydantic. | |
| - A round-trip test under `inspector/tests/persistence/test_<schema>_schema.py` pins the contract. | |
| These bucket persistence models are pure `extra='forbid'` (no strip layer): an unknown/legacy field raises `ValidationError` rather than being silently dropped, so writer drift fails loudly. Don't loosen them to `extra='allow'` or add a `strip_and_warn` tolerance β the prod-data migration already rewrote on-disk artefacts to the canonical shape, so forbid is safe. `strip_and_warn` survives only for the `ts_shard` `_meta` forward-compat exception. | |
| See [`data-migrations.md`](data-migrations.md) for the writer/reader-drift rationale. | |
| ## Coverage | |
| Coverage is measured on every CI run and emitted to the GitHub Step Summary, but no threshold gates the build. | |
| **Backend.** `pyproject.toml` `[tool.coverage.run]` is `source=['.']`, `branch=false`, `skip_empty=true`. The pytest CI invocations add `--cov=services --cov=routes --cov=domain --cov=adapters --cov-report=term-missing --cov-report=xml:.coverage.xml`. `qua_shared` runs separately with `--cov=qua_shared --cov-report=xml:.coverage-qua-shared.xml`. | |
| Local coverage: | |
| ```bash | |
| cd inspector && python -m pytest --cov=services --cov=routes --cov=domain --cov=adapters \ | |
| --cov-report=term-missing --cov-report=html tests/ | |
| open htmlcov/index.html | |
| ``` | |
| **Frontend.** `vitest.config.ts` uses the v8 provider with `coverage.all: true`, reporters `text` + `json` + `html`, and an explicit exclude list (`lib/types/generated/**`, `**/*.test.ts`, `**/*.spec.ts`, `**/__tests__/**`, `**/*.d.ts`, setup, config files). HTML lands under `inspector/frontend/coverage/`. | |
| ```bash | |
| cd inspector/frontend && npm run test:coverage | |
| open coverage/index.html | |
| ``` | |
| **Asymmetry to know about.** BE coverage is line-only (`branch=false`) for the baseline run; FE v8 reports branches by default. Headline percentages between the two are not directly comparable. Don't game coverage by writing assertion-free smoke tests β the audit will flag them as `dead-test`. | |
| ## Phase-gates | |
| A `describe.skipIf(loadOptional(...))` block is acceptable **only** when introducing a test for a module that does not yet exist on `main` and will land in a follow-up commit on the same branch series. Pair with `it.todo('phase-N: <module> not yet present')`. | |
| Remove the gate the moment the module lands. A phase-gate older than the PR that introduced it is a smell. When you touch `__tests__/command/` or `__tests__/normalized-state/`, audit for stale gates and delete them. | |
| ## Conventions | |
| - **Test naming.** BE: `test_<unit>_<scenario>_<expectation>`. FE: `it('does X when Y', ...)` / `describe('<module>', ...)`. | |
| - **Test location.** BE: mirror the prod tree (`services/state/state.py` β `tests/services/test_state_*.py`). FE: co-locate under `__tests__/` next to the module. | |
| - **One conftest per major tree.** Promote duplicated seeders up the tree, don't fork them inline. | |
| - **Subsystem map.** When you add a new subsystem, add a new pytest subdir + a row to [`docs/reference/README.md`](README.md). | |
| ## Commit + attribution | |
| - **Format.** `prefix(scope): imperative description` plus 1β3 bullets. See `.claude/rules/commit.md`. | |
| - **Scopes.** Tests roll up under the area they cover: `test(segs-be)`, `test(ts-fe)`, `test(global-fe)`, etc. β same areas as the production code they exercise. | |
| - **No co-author attribution.** Never commit as Claude or add `Co-Authored-By: Claude`. The user is the author. | |
| ## See also | |
| - [Subsystem index](README.md) β the per-subsystem reference docs (state machine, segments editor, validation, frontend, etc.) own the *what-is* for the code under test. Read the matching one before writing tests for a subsystem. | |
| - [`data-migrations.md`](data-migrations.md) β Migration #5 records the writer/reader drift that motivates the schema round-trip policy. | |
| - [`auth-permissions.md`](auth-permissions.md) β error envelope shape (`{error, code, context?}`), CSRF/Origin contract, predicate gates. | |