Testing¶
./dev test # everything
./dev test backend # ruff, mypy, pytest with coverage
./dev test frontend # eslint, tsc, vitest, production build
./dev test scripts # bash helper tests
Backend¶
pytest with coverage, threshold 80 percent. tests/conftest.py builds the app with a temporary
data directory and fixture mode on, so no test ever reaches the Model API. Tests are named after
the behaviour they pin down (test_missing_key_fails_fast_in_live_mode).
Model API behaviour is tested against httpx.MockTransport: the handler plays the server, a
recording sleep captures the backoff delays, and the ledger is inspected after each call. Async
tests use the anyio marker on the asyncio backend.
Feature tests come in three layers. The pure parts (image preparation, normalisers) are tested directly. The service is tested twice: against the recorded fixture, and against a mock server that captures the request body, so the prompt, the schema and the image parts are pinned. The route is tested through the app with multipart uploads, including every error status.
The inventory is tested at the service level against a temporary SQLite file (provenance, review, versions, components, soft delete, schema version) and through the routes, including a reading saved from the recorded extraction answer.
The demo gate is tested at the service level (the sliding allowance and when to come back, the
daily count, which calls spend, sessions signed, bound to the code and ending) and through the
app (nothing gated without a code; every page 401 with one and the open paths open; the right
code as a cookie and as a bearer token, a wrong one refused and limited; leaving; the hourly and
daily allowances on calls that spend; the operator's switch refusing only what spends; the
per-address limit with the open paths exempt; the forwarded address trusted only when told to;
the settings checked). Playwright runs behind the gate: one session from a global setup for
every test, and access.spec.ts starts without one.
Build and reuse is tested with a recorded two-step tool loop (propose-1, propose-2): the
templates load and are checked (a repeated key, no blocking requirement, a file named
otherwise refused); the tools show only what a build may use; the proposal built on an item
nobody has is dropped and an allocation of the wrong kind is dropped and said; nothing usable
refused before any call; selection commits and reserves, a second build wanting the same item
is a conflict until it takes it, abandoning releases, advancing in order and not before every blocking requirement is filled;
a requirement filled by hand with an item of the right kind, and the wrong kind, a gone item,
a filled requirement and a project that is over refused; a missing requirement becomes a search
under the budget left and the item bought from it marks the search found; the build pack
written from what stands (the items' accepted values in, a serial number, a location and a note
out, the search with its top price, the budget line, the tools, the steps with progress, the
limits), refused before selection, stale once an item on it or the budget changed and the next
writing the next version; progress on a selected project and an unknown step refused; the
outcome recorded only while building, a failed test keeping it building and a working one
marking it done with the items still reserved, the dates real and past, blank text refused; the
share text redacted and refused until shared; the routes with the Markdown file and its
headers; the loop replayed statelessly through a
mock server (the brief, the output items and one result per call, two ledger entries) and a
loop that never answers refused; the routes and their refusals.
Missing items are tested with a recorded listing reading (listing-text): the handoff link,
the ladder onto the states, the seller message; a search from an item or from values with its
refusals; a text candidate read, assessed and dated, with a rule key kept as read and an unknown
name dropped; typed values as the owner's word and a conflict rejected; a screenshot through the
extractor; a recheck after the need changed and a withdrawal; a candidate without its source
refused; the routes with multipart requests and every refusal.
The search plan is tested with a recorded plan (search-plan): the pattern checks (the example
accepted; lookarounds, backreferences, \C, comments, inline flags and counted repeats that
expand too far refused with their reasons; a pattern over 300 characters and one that matches
empty text refused), every adversarial pattern from the review (open repeats side by side,
optional separators, [\s\S]+, nested and long counted repeats, .{0,40} gaps) refused or
answered within 200 ms on 4,000 characters in a child process, so a backtracking engine fails
the test instead of hanging it; code-point offsets with an emoji, exclusions in capitals and
not inside a longer word, the 4,000-character cut-off, a price at the limit and a limit of 0,
the price readings (k, free, CA$, the first price, $1 200, thousands separators) and several prices either side of the limit leaving a maybe, the
links encoded one per phrasing, the plan, screenshot and Check fit routes counted as spending
and one allowance hit per screenshot, a written pattern that fails the checks not kept, the
owner's pattern scoring every listing again, listings trimmed and capped at 50, a pattern
saved while listings come in losing neither, listing ids never given again, screenshots kept as they are read, two Check fits at once reading once, Plan again keeping the owner's pattern, refused screenshot requests counting nothing, and the routes.
Price guidance is tested against a recorded search-grounded answer (price): one search, four
results, five proposed comparables of which the unbacked one is dropped and said; the four
figures and their counts; a reported sale counted apart and one in another currency not; whole
against parts from the parts' own guidance; a live call through a mock server carrying the
web_search tool, the location and the include, with two searches billed; the routes and their
refusals. Search calls, results and citations are read off a response in isolation.
Deciding is tested against a recorded answer (uses): the templates a laptop fills and a
charger does not, the brief without a serial number, a location or the notes, the model's text
trimmed and capped, the uses kept per item and made stale by an edit, the view with its
guidance, searches, committed projects and last decision, the same choice again as an event
without a new version, a choice from an older version refused with 409, and the demo visitor
refused on every route.
Listing drafts are tested against the recorded Muse Spark answers (draft-sell, draft-give):
facts resting on unconfirmed fields dropped, the disclosures appended, the free title, the
data steps, a stale draft after the item changes, and a working claim without a recorded test
flagged through a mock server. Voice notes: the WAV header read, a recorded transcript kept as
the owner's words, the live multipart request and its pricing by the seconds heard, the
limits before any call, and the route.
Assessment is tested at three levels: each rule kind in isolation (missing values, normalised text, uncertainty that straddles the tolerance), the ladder from a list of checks, and the service against inventory items, including a value that came from a stored measurement with its uncertainty; the route covers both sides as items and as values, the profile's rules endpoint and every error status.
Measurement is tested on synthetic photos with known geometry (tests/measure_images.py): a
ruler bar of 400 px for 100 mm and an opening of 140 px, sharp and blurred; the geometry
module against a known projective transform; every refusal reason; the routes including a
capture stored on an item and the proposed value it leaves.
Two conftest fixtures matter here: recorded_client serves the fixtures committed under
tests/fixtures/api/, and client serves an empty fixture directory, which is how the "no
recorded answer" path is tested.
Frontend¶
vitest for the pure modules under src/lib/: the API client against a stubbed fetch, the
formatting helpers. The components are covered end to end in the browser.
End to end¶
./dev e2e # the whole flow in a real browser
E2E_KEEP=1 ./dev e2e # leave the stack up afterwards
E2E_VIDEO=on ./dev e2e # record every test, not only failures
Playwright runs in its official image against the dev stack with the backend in fixture mode,
so the browser sees exactly what a user sees and no credit is spent; PICTURE_ENABLED is on for
the run, so the item dialog draws its picture from the recorded stub. The runner shares the
frontend container's network and runs as the host user; results, traces and videos land under
e2e/test-results/. Specs live in e2e/tests/, one per surface (extract, inventory,
measure, assess, value, listing, decide, find, search-plan, build, inbox, and
access, door and samples for the two doors and the visitor's gallery), and address the page by role and label, the way a user
does. CI runs the same command in its e2e job. Expectations wait ten seconds rather
than the default five: six workers share one backend process, and while the segmentation test
decodes masks in pure Python every other read waits a second or two.
The command starts the backend in fixture mode, recreating it if it was running with a key, and
stops the whole stack afterwards unless E2E_KEEP=1; the next ./dev up brings it back in the
mode set by .env. The Playwright image tag in docker-compose.yml and the @playwright/test
version in e2e/package.json must match, or the browsers in the image are the wrong build.
Scripts¶
scripts/tests/run.sh loads every test_*.sh, runs each test_* function and counts
assert_eq, assert_true and assert_false. One file reads the backend Dockerfile and pins
the production command to one worker without reload; others run the release scripts against
throwaway repositories: the tag check (branch names, VERSION, manifests, changelog sections,
an existing tag), the release notes (the section, the pull requests since the last release, the
compare link, the images), the release itself (every refusal, a dry run, a tag created and
pushed to a bare origin), and the deploy (the one remote command, with a stand-in for ssh
that records it; what is not set up skipped or refused).
The live check¶
./dev live-check # one real call per operation, at most $0.25
./dev live-check --ops segment,extract # a few of them
./dev live-check --ops price --max-usd 0.44 # the price call on its own
./dev live-check --ops picture # Muse Image, only when named
OPL_FAKE_API=1 ./dev live-check # the dry run on the recordings: the default set, $0
./dev live-check --against local # over HTTP against the stack on this machine
./dev live-check --against hosted # over HTTP against API_URL
Everything above runs on recorded answers. The live check is the one command that calls the
Model API for real, on purpose: each operation the product makes (segment, transcribe,
extract, listing-text, draft, uses, propose, price, and picture when named) once,
through the service its route calls, on one item made from the demo set, with a line per
operation: the model that answered, PASS or FAIL, milliseconds, input, output and reasoning
tokens, the cost, and the exception's one-line message on failure. A live run spends real
credit: price, with its web search, is the dearest at four to seven cents (PRICE_MAX_SEARCHES searches), propose and its
tool loop a cent or more, segment a quarter of a cent. Each operation has a ceiling (its
output budget and reasoning headroom, an input allowance, its searches, times the client's
attempts); before the first call the run names every operation whose ceiling alone is above
--max-usd (at the default 0.25, propose and price) and skips it, then stops before one
whose ceiling would take the run past --max-usd, and refuses to start when the spend ledger
has less headroom than that. It never records fixtures. With --against the proof runs over
HTTP against a running backend: behind a gate, a sample photo read without the code is answered from
its recording, a photo sent without it and a write are refused, then a session with LIVECHECK_ACCESS_CODE reads the live
models and signs out. The access code is used in that mode only; it and the key come from the
environment (the shell or .env), never an argument, and ./dev passes them on by name
without reading either value.
The check lives in livecheck/ at the repository root. It runs in a development backend
container, where docker-compose.yml mounts the folder read-only, or on this machine in the
backend's virtualenv with NATIVE=1; it is in no image and never deployed.
backend/tests/test_livecheck.py pins that: the Modal app does not name it and adds the three
known directories only, both images build from their own directories and copy nothing from
outside them, the production overlay mounts nothing but the data volume, nothing in the app
imports it. It also runs the dry run as a subprocess with a placeholder key and asserts the key
never appears in the output, refuses record mode, checks the redaction, the ceilings and the
live ledger rule, and drives the HTTP proof against a gated app on recordings.
scripts/tests/test_livecheck.sh scans every file under livecheck/ and the live-check
lines of ./dev for anything that reads like a credential, runs gitleaks over the folder when
it is installed, fails unless the folder holds only tracked .py and .md files, and runs
./dev live-check against a stand-in docker to show that it reads OPL_FAKE_API strictly,
refuses to record, and traces no secret under -d. The livecheck lines in both
.dockerignore files are belt and braces beside the build contexts. livecheck/README.md has
the price table.
Fixture mode and recorded fixtures¶
The Android app has unit tests on the JVM (./dev android test): the client's requests as
they go on the wire (the access call and the bearer token it keeps, the photo and the
recording as multipart, the item with the whole reading, the error text read off a 422), the
multipart framing, the WAV header and sizes, and the settings' URL. The smoke run
(./dev android smoke) installs the debug APK on the connected phone or a headless emulator
(ANDROID_AVD), launches the app, confirms its activity is on screen and keeps a screenshot;
scripts/tests/test_android.sh checks that script with stand-ins for gradlew and adb.
Enhancement is tested on synthetic images: a stretched, sharpened copy keeps the size and
stays a JPEG; printed text scores far above its blurred copy and a flat patch; a reading of a
flat photo carries the blur warning and the enhanced flag, and none of either with
EXTRACT_ENHANCE=0 and the threshold at zero.
Pairing is tested in isolation (a session only when gated, verified by the gate; the origin
kept as given and anything but a scheme, a host and a port refused; the SVG and the terminal
block) and through the routes (open without a gate, 401 behind one until a session, the
phone's token working as a bearer header); the command line's answers. The Android setup
script is tested with stand-ins for java, curl, sdkmanager and the package manager: what
--check reports and that it runs nothing, an old JDK sent to the package manager, the
packages installed after the licences, a download refused on its checksum.
Captures are tested on bundles made in the test (a synthetic photo, a WAV, the head of an MP4): a bundle kept as sent with the photos re-encoded on arrival and read into an item with the transcript and the line, processed once only, deleted with its files while the item stays; a capture without photos still an item; every refusal (an empty bundle, five photos, a bad photo, a clip of the wrong kind, an audio file that is not a WAV, a clip beyond the limit); a failed reading recorded with the media kept; the label found first through a mock server; the multipart route, the media routes and their 404s; and the paths in the OpenAPI document.
The demo set (demo/set.yaml, python -m app.demo) is tested on a set written into a temporary
directory: the set checked (repeated keys, a search needing an item that is not there, a search
without an item or a profile, five photos refused, an empty file allowed); the scrub (metadata
gone, the long side shrunk, a PNG as a JPEG, a WAV copied, other files left out with a note, the
raw directory untouched); a load in fixture mode (items, the reading's values accepted, an
owner value beside them, a voice note kept, drafts, guidance, searches with candidates from a
file and from text, the proposals, the counts and the skipped line); record mode through a mock
server writing one fixture keyed by the request digest; the reset removing only the inventory
file; the command line's answers; and the committed set naming only files under demo/ with
every written listing labelled as demo data.
OPL_FAKE_API=1 makes the Model API client serve recorded responses from
backend/tests/fixtures/api/<endpoint>/. A file named after the request digest matches one exact
request; a file named after the operation (extract.json) answers every request for that
operation. Recording is OPL_RECORD_FIXTURES=1 with a real key: each response is written next to
its request summary, with inline images replaced by a digest. Details in
Model API client.
The committed responses/extract.json is the answer for a laptop power adapter label, and
responses/segment-<concept>.json the answers for the item, the label and the connector of one
photo (the mask payloads are small synthetic shapes in the real wire encoding); together they
make the tests, CI and the offline demo work with no key and no network.