--- name: dankvoice-behavior-scenario-authoring description: "Author or extend a dankvoice behavior.bats scenario: driver env knobs (DV_REWRITE/DV_FAIL/DV_CUT_MS/DV_TAG_LOG), the SPOKEN heard-words census, private-TMPDIR leak assertions, and the arm-the-precondition-before-absence trap." --- # Writing a dankvoice behavior test `tests/behavior.bats` drives the **built bundle** through a generated harness. Each `@test` is: set env → run a named *scenario* → assert on printed census lines. Never assert on internals; the observable vocabulary is `CALLS`, `PLAYS`, `PLAYORDER`, `SPOKEN`, `NOTIFY`, `CALL input=… auth=… at=…`, `PLAYER`, `KILLED`. The driver lives in a heredoc inside `setup()`; find and read it before adding anything: ```bash sed -n '/cat > "$DV_DRIVER" ":` in that switch that calls the extension's own handlers (`say.handler("on", ctx)`, `messageEnd?.(said("…"), ctx)`, `agentEnd?.(answer("…"), ctx)`). Prefer delivering narration through the harness (`agent_end`/`message_end`) over a typed `/say`: the humanizer, mood, heckle, framing and the frequency hold govern **unsolicited** narration only, so a typed line cannot exercise them. ## Fixture knobs (set per test, all read by the driver) | Knob | Effect | |---|---| | `DV_REWRITE=off\|echo\|prose\|reasoning\|stall` | what the chat (humanizer) route answers; unset ⇒ it 404s and the rewrite falls back to mechanical text | | `DV_STALL_MS` | delay the chat answer (timeout behaviour) | | `DV_FAIL=` | the first N `/audio/speech` requests answer HTTP 500 JSON — an endpoint outage a test can watch the queue weather | | `DV_HOLD=` | how long the player stub holds each clip; sets the playback windows | | `DV_CUT_MS=` | in `stagecut`, when another session cuts the line | | `DV_TAG_LOG`, `DV_TIMING_LOG` | per-clip tag census (`PLAYORDER`, `SPOKEN`) and player START/END ms | | `DV_CT` | content type of the answer (non-WAV routing) | | `DV_KEYS` | key replayer for the settings modal | | `TMPDIR="$WORK/scratch-tmp"` | scope synthesis temp dirs so a leak assertion is yours alone | Always pass `DANKVOICE_STAGE_DIR="$WORK/stage"` when the test touches the stage, and use the `say_run ` helper (it wraps `run env …` so `$output` is captured); a bare `scenario_name` invocation prints without capturing. ## Two traps that have each hidden a real bug **Assert absence only with the producer armed.** `DV_REWRITE` unset ⇒ the chat route 404s, so `[[ "$output" != *"/chat/completions"* ]]` and "payload equals the source text" pass even when rewriting is happening. Nine tests pinned the opposite of the repo's law this way. Pin with `DV_REWRITE=prose` (the fixture answers with its *own* words) and assert the payload you expect — then survival is evidence, not absence. **Count what was HEARD, not what was requested.** A segment whose clip was killed mid-play, or aborted mid-synthesis, is legitimately synthesized again on resume. Laws about framing ("one intro per narration") are about hearing, so count `SPOKEN` lines: the driver maps each played tag back to the `/audio/speech` request that produced that clip. `CALLS` counts requests and will over-report across a line-cut. Temp-dir leak assertions mirror this: `TMPDIR` private, then `find "$TMPDIR" -maxdepth 1 -type d -name 'dankvoice-*' | wc -l`. ## Pinning timing Derive windows from `DV_HOLD`: with 0.5 s clips, clips occupy ~[10,310], [310,610], [610,910]; choose `DV_CUT_MS` so the cut lands strictly inside the clip you mean to interrupt, leaving margin for the 250 ms stage poll and synthesis time. Default scenario timings are env-overridable (`DV_CUT_MS ?? 600`) so a new test never edits an existing one's schedule. ## Red-proof and gate (project invariants) - Prove a new assertion can fail: in a **private worktree**, `git show HEAD:src/ > src/`, `bun run build`, run the filter, restore the file and rebuild. Never while another suite runs (`setup()` rebuilds the bundle from `src/`). See `dankvoice-bats-serialization`, `dankvoice-bundle-swap-red-proof`. - `bun run build` before staging: `tests/packaging.bats` byte-compares committed `dist/` with a fresh build, so `dist/index.js` must be in the same commit as the `src/` change. - Gate with the whole suite (`bats tests/`), not the filter; ~5 min, so run it unbounded/async and never write to `src/` while it runs.