Spaces:
Running
Testing
Scope. Backend tests live under
inspector/tests/(pytest) andqua_shared/tests/(pytest). Frontend tests live co-located with their modules underinspector/frontend/src/**/__tests__/*.test.ts(vitest + happy-dom + @testing-library/svelte). Shared JSON fixtures live underinspector/tests/fixtures/segments/and are aliased to@fixturesin 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).
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 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.
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 directrepo_state.update_statead-hoc β that's the durability boundary the state-machine doc enforces. - For audit assertions, prefer
repo_transitions.for_slug(slug)over patchingaudit.append. If you must record-and-assert, call through to the real implementation (see Mocking boundaries). - See
state-machine.mdfor 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.
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; neverin (200, 4xx)β it masks which branch ran. - For 4xx responses, assert
body["code"]too. The error envelope is part of the contract β seeauth-permissions.md. - Anonymous requests use
flask_client(no cookie). Don't reach forflask_clientwhensigned_in_clientis 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.
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 staleskipIfgates as soon as the module exists. See Phase-gates. - Type command literals against the appropriate
SegmentCommandmember; avoidas any. - See
segments-editor.mdfor 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.
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 useevents: {...}(that's the Svelte 4createEventDispatcherpattern). See the Svelte conventions inCLAUDE.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
getByRoleoverquerySelector.
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:
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:
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.pypins 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 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:
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/.
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.
Commit + attribution
- Format.
prefix(scope): imperative descriptionplus 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 β 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β Migration #5 records the writer/reader drift that motivates the schema round-trip policy.auth-permissions.mdβ error envelope shape ({error, code, context?}), CSRF/Origin contract, predicate gates.