Skip to content

e2e: on-screen tests you can actually judge — ids, captions, and a review TUI - #133

Merged
joschaschmiedt merged 14 commits into
mainfrom
feat/e2e-onscreen-review
Sep 2, 2026
Merged

e2e: on-screen tests you can actually judge — ids, captions, and a review TUI#133
joschaschmiedt merged 14 commits into
mainfrom
feat/e2e-onscreen-review

Conversation

@joschaschmiedt

@joschaschmiedt joschaschmiedt commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

The on-screen e2e suite renders stimuli for a human to look at, but there was no
way to tell what any given moment was supposed to look like, and no way to
report a fault precisely. This branch makes the suite legible, then gives it a
front end — and, chasing a flake it exposed, fixes a real scene bug.

What changed

Every test says what it should show, and shows it long enough to judge.
Each test carries @pytest.mark.onscreen("GRAT-09", "…"); an autouse fixture
captions the display with [GRAT-09] <what you should be seeing> while it runs.
Ids are unique and stable across the 147 tests (RECT CIRC ELLI POLY SHAPE GRAT TEXT ANIM SHARED QUERY SYS CFG VTL DEMO PSY), so a fault is reported as
"GRAT-09 looked wrong" and found again with grep -rn GRAT-09 tests/e2e.
Descriptions are whole sentences — "a full square patch of stripes that becomes
a circular disc when the CIRCLE mask is applied — the corners are cut away"

rather than the old "mask".

Tests that drew a stimulus and deleted it in the same breath now hold it up
first. The demo tests, which built a whole scene, diffed it and tore it down
without ever showing it, now hold each demo on screen for a few seconds. Short
animations are lengthened where nothing asserts their frame count, and
Stage.cue() says what to watch for before the animation is armed.

Scenes are cleaned up per test. A scene_reset fixture clears stimuli,
animations, VTL names, background and deferred mode after every test — a failed
assertion, a disabled stimulus or one an animation hid used to accumulate
invisibly over a run.

A terminal UI for reviewing it by eyemake test-e2e-interactive. It
lists every test with what it should put on screen; you pick one, run it, watch
the display, and flag it with a note if it looks wrong. Notes land in
e2e-review.md with a ready-made command to re-run just the flagged tests.
pytest still does collection, fixtures and reporting, but the app decides what
runs and when — so a test can be replayed or gone back to, which a plain pytest
run cannot do. It starts its own windowed server so the terminal stays visible.

Reusable widgets in the client package (vstimd.tui, behind a tui extra):
StimulusList (the live scene), TriggerLines (VTL lines, with t to toggle
and p to pulse — a trigger-driven scene exercised with no DAQ attached) and
ServerStatus. The review app mounts all three, which is what proves they
compose; they are the beginnings of a terminal console for a rig you are on over
SSH.

Targets, renamed and pruned: test-e2e-visible, test-e2e-interactive,
test-e2e-null. test-integration is gone — it printed "not implemented yet:
integration tests with MockServer", no MockServer exists anywhere, and the null
renderer covers that ground as a real server without a display.

The server bug this turned up

About one full run in ten failed a single "mutate a property, query it straight
back" test — never the same test twice, never reproducible in isolation. The
cause is end_deferred scheduling a flip unconditionally. The flip promotes
every copy slot over its live one, and outside deferred mode the copies are
stale by construction: ordinary writes go to live alone. So a client that
switches deferred mode off defensively, having never switched it on, armed a
flip that undid every write made in the ~16 ms before the next frame. The
animation path's END_DEFERRED did the same.

Both now do nothing when nothing is staged, with a test pinning it, and the e2e
scene reset asks for cancel — drop what is staged, schedule no flip — which is
the right request regardless of the server fix. Reproduced in three lines
against a live server before the fix, five consecutive full null runs clean
after it.

Fixes found along the way

  • Text stimuli render at zero size without letter_height_px and
    box_size_px; SHARED-09 created one that way and was invisible.
  • The caption was pinned at y=420 — off-screen entirely on the 720-line window
    the review app opens. Position, box and letter height are fractions of the
    real display now.
  • The caption was created before everything the test drew, so it could be
    buried; it is rebuilt on each step, on top, with a dim backing box.
  • The PsychoPy Window is deferred: a queued autoDraw = False was replaying
    against a stimulus the next test's reset had deleted.
  • The e2e server address was documented as VSTIM_SERVER_ADDR in three places.
    Nothing reads that; it is VSTIMD_SERVER.

Verification

cargo test all green (49 in commands, including the new one), make test-e2e-null 141 passed / 6 xfailed — five consecutive runs after the deferred
fix, all clean — make test 79 passed, make typecheck clean. The full
on-screen suite was run for real on a display: 141 passed / 6 xfailed in 4:31.
The review app was driven headless through Textual's pilot harness — moving,
running, run-and-advance, flagging with a note, panel switching, report output.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Y8Epm1dhLes5amyezTRyTD

joschaschmiedt and others added 14 commits August 18, 2026 14:27
…well

Watching the real-renderer suite, it was hard to tell what any given moment
was supposed to look like, and impossible to report a fault precisely: the
caption said "phase_cycles", many tests drew their stimulus and deleted it in
the same breath, and the demo tests never showed the scene they had just built
at all.

Every test now carries an `onscreen(id, description)` marker. An autouse
`stage` fixture puts `[GRAT-09] <description>` on screen for the whole test and
`Stage.step()/hold()` keeps each state up for a multiple of --step-delay, so
what a test claims is visible actually is. Ids are stable and unique
(RECT, CIRC, ELLI, POLY, SHAPE, GRAT, TEXT, ANIM, SHARED, QUERY, SYS, CFG, VTL,
DEMO, PSY), so a fault can be written down as "GRAT-09" and grepped back to the
test. Tests that compare the whole scene declare the caption deferred and raise
it once they are past that point — which is also what finally makes each demo
visible, held for a few seconds before its teardown.

Scenes are also cleaned up per test now: a `scene_reset` fixture clears
stimuli, animations, VTL names, background and deferred mode after every test,
so a failed assertion or a merely-disabled stimulus can no longer leave
invisible clutter behind for the rest of the run. The PsychoPy suite gets a
`flush_window` fixture with it, because its Window is deferred and would
otherwise replay a staged command against a stimulus the reset has deleted.

Null suites pin --step-delay to 0, so none of this costs a headless run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y8Epm1dhLes5amyezTRyTD
…ausing

Watching the suite run turned up three things. Text stimuli were sometimes
invisible: `letter_height_px` and `box_size_px` both default to 0, so the
opacity check in SHARED-09 created text that rendered at zero size — it now
sets both. The caption could also be buried, because draw order is creation
order and the caption was created before everything the test drew; it is now
rebuilt on every step (new one first, so no frame goes without), sits higher up
the frame clear of the demos' own titles, and carries a dim backing box so it
stays readable over mid-grey backgrounds and over stimuli.

Several animations were over before the caption could be read. The short
flashes and flickers are lengthened where nothing asserts their frame count
(ANIM-01/03/08/17/18/20/21/22/25/27/28), and `Stage.cue()` says what to watch
for *before* the animation is armed rather than after it has started.

Finally the suite can be paused: `--pause` waits for a keypress once per test,
`--pause=step` at every caption change; Enter continues, `c` carries on without
pausing, `q` stops the run. The prompt suspends pytest's capture so it is both
visible and answerable, and a run with nothing on stdin turns pausing off
instead of hanging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y8Epm1dhLes5amyezTRyTD
… e2e suite

The suite's own server is fullscreen and covers the terminal the --pause prompt
waits in. Pre-starting a windowed server is already enough — the fixture
attaches to any reachable server rather than starting one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y8Epm1dhLes5amyezTRyTD
…ging

Watching the suite is only half the job; the other half is writing down what
looked wrong, and ids on screen still left that to paper. The pause prompt is
now a small review console: it names the test, what it is for, and what is on
screen at that instant, and `f` records the test as problematic with a note
before the run carries on. Flagged tests are listed in the terminal summary and
written to e2e-review.md — each note, the caption it belongs to, and a
ready-made command to re-run just those tests.

Two make targets drive it, since the option combination is easy to forget:
test-e2e-review pauses once per test, test-e2e-step at every caption change.
Both take PYTEST_ARGS, and test-e2e/test-e2e-null now do too.

The confirmation after flagging goes straight to the terminal rather than
through print, which pytest's capture would otherwise swallow until (and unless)
the test failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y8Epm1dhLes5amyezTRyTD
… readout

Watching the suite in order is not how a review actually goes: the thing that
looked wrong was two tests ago, and there was no way back to it. The pause
prompt now numbers every test, shows where in the suite it is, how the test came
out, and takes vim's motions — j/k, 5j/5k, 42G, gg/G, r to replay, /text to
search, l to list.

Forward motions are handled inside the session by skipping ahead. Backwards
cannot be: a pytest session runs its tests once, in order. So tests/e2e/browse.py
owns the loop — pytest records the target and stops, the browser restarts it
there, and the notes travel in the same file. It also starts a windowed server
for the whole browse session, which keeps this terminal (and its prompt)
visible, and reuses one that is already up.

Stopping now goes through session.shouldstop rather than pytest.exit: the prompt
runs inside a fixture teardown, and exiting from there abandoned the scene reset
that follows it, leaving stimuli, a background colour or deferred mode behind
for the next session to trip over.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y8Epm1dhLes5amyezTRyTD
…creen

Four things a real session on a real display turned up.

The caption was pinned at y=420, which is near the top of a 1080-line screen
and off the bottom of the 720-line window the review target opens — invisible,
exactly as reported. Position, box and letter height are now fractions of what
the server says the display is.

The prompt came out as one long smear: readline treats an input() prompt as a
single line and eats newlines in it. Everything multi-line is printed now and
only the cursor line is the prompt, and both the caption and the list are
folded to the terminal width instead of running off it.

A test that lasts half a second was over before its description arrived, since
the prompt only comes afterwards. Each test is now announced before it runs, so
the description is on screen before the thing it describes happens. The
animation captions that still quoted their old frame counts are corrected too.

Taking the caption down before the scene reset clears it stops the server
logging a handle-not-found warning per test.

Also, four e2e targets were three too many: test-e2e runs start to finish,
test-e2e-review is the one you drive by hand (now the browser, so it has the
vim motions), test-e2e-null needs no display. While in the README, the e2e
server address was documented as VSTIM_SERVER_ADDR in three places, which
nothing reads — it is VSTIMD_SERVER.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y8Epm1dhLes5amyezTRyTD
…le widgets

Driving pytest's own terminal was the wrong shape for this. Single keypresses
fought readline, the console had to be rebuilt out of print statements, and
going backwards meant restarting pytest and handing state through a file. What
the job actually wants is a UI: a list of every test with what it should put on
screen, and keys to run, re-run, revisit and annotate.

So the review target is now a Textual app with pytest underneath it. The plugin
takes over pytest_runtestloop and runs whichever item the app asks for, in any
order, as often as asked — passing a *next* item to the protocol keeps
session-scoped fixtures (the server connection) alive between runs. Two settings
keep the terminal ours: `-p no:terminal` silences pytest's reporting and
`--capture=sys` captures at the Python level, leaving the real stdout to draw
on. All the terminal-prompt machinery in conftest, and browse.py with it, is
gone; what remains there is the caption, the scene reset and --step-delay.

The panels are `vstimd.tui` widgets rather than app-local code, since a control
program for the rig wants exactly these: StimulusList (what the scene holds),
TriggerLines (VTL lines, with t to toggle and p to pulse — a trigger-driven
scene exercised with no DAQ attached) and ServerStatus. They take a Connection
and poll it; textual is an optional `tui` extra, so nothing about talking to a
server depends on it.

Verified headless with Textual's own pilot: moving, running, run-and-advance,
flagging with a note, panel switching, and the report that comes out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y8Epm1dhLes5amyezTRyTD
test-e2e said nothing about the display it needs, and test-e2e-review said
nothing about being interactive. They are now test-e2e-visible and
test-e2e-interactive, next to test-e2e-null.

test-integration is deleted: it printed "not implemented yet: integration tests
with MockServer", there is no MockServer anywhere in the tree, and the null
renderer covers the ground a mock was meant to — it is a real server without a
display, which is a more faithful thing to test against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y8Epm1dhLes5amyezTRyTD
…he scene

Found chasing an intermittent e2e failure: a mutation would take, and then a
frame later read back as the value it had before. Roughly one full run in ten,
never the same test twice, never reproducible in isolation.

`end_deferred` scheduled a flip unconditionally. The flip promotes every copy
slot over its live one, and outside deferred mode the copies are stale by
construction — ordinary writes go to `live` alone. So a client that switches
deferred mode off defensively, without ever having switched it on, armed a flip
that undid every write made in the ~16 ms before the next frame. The animation
path's END_DEFERRED did the same thing.

Both now do nothing when there is nothing staged, and a test pins it.

The e2e scene reset was the client that did this — it turned deferred mode off
after every test, so the next test's first mutations were the ones reverted. It
now asks for `cancel`, which drops staged state without scheduling a flip: the
right request for "whatever the last test left, I do not want it", and correct
against a server without this fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y8Epm1dhLes5amyezTRyTD
The call returned a bare ack, so a client could not tell "flip queued" from
"there was nothing to end" — the distinction that made the reverted-scene bug
above so hard to see — and had no idea when the staged frame would be on screen.

The reply now carries `deferred` (the mode after), `was_deferred` (before, so a
no-op is visible), `flip_scheduled`, and `flip_frame`: the frame the staged
state is first drawn from. Together with the envelope's `frame_count` — the
frame the call was handled on — a client can say exactly where a batch started
and where it lands, and wait for that frame instead of sleeping on a guessed
vsync:

    begun = conn.system.set_deferred_mode(True)    # staging from begun.frame_count
    ...
    ended = conn.system.set_deferred_mode(False)
    conn.system.wait_for_frame(ended.flip_frame)   # now it is drawn

The Python client returns a DeferredModeStatus with those fields, plus
`was_a_no_op` and `frames_staged`, and gains `wait_for_frame(n)` — the protocol
wait is relative, the interesting number is absolute, so the remaining distance
is recomputed until covered. The e2e deferred test now checks the reported
status rather than sleeping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y8Epm1dhLes5amyezTRyTD
Two sessions against one server wreck each other: every test resets the scene,
so one session's teardown deletes the other's stimuli and animations, and what
comes out is a handle-not-found in an unrelated test. That is what happened
running the null suite next to an interactive review session.

The null suites now start their own server on a free port, with --no-web so the
web surface does not fight for 8080 either. Passing --server (or VSTIMD_SERVER)
still opts into an existing one, which is what CI and a rig do; the default is
simply no longer "whatever is on 5555".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y8Epm1dhLes5amyezTRyTD
A REQ socket that times out waiting for a reply is stuck: it still owes one, and
the next send fails with EFSM. `wait_until_ready` knew that and rebuilt the
socket by hand; every other caller did not, so a single slow reply left the
connection useless for the rest of the session.

`_send` now does it once, centrally, and raises TimeoutError naming the address
and the command rather than leaking zmq.Again. That is what makes a polling UI
safe: the vstimd.tui widgets read from a connection on the UI thread, and a
server that stops answering must not take the interface down with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y8Epm1dhLes5amyezTRyTD
@joschaschmiedt
joschaschmiedt merged commit 6b72e9d into main Sep 2, 2026
11 of 12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant