app
Adom TTS: Text to Speech
Public Made by Adomby adom
Adom's shared voice service. Reads your AI's answers back to you while you drive, and renders perfectly-timed voiceover tracks for your demo videos, so you never record your own voice.
master
John Lauer
v1.9.54: one surface open per drain (10s debounce), re-offers logged with their reason, T13 asserts a single hbw open, fake page no longer orphans (issue #7)
8dd1194
1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
#!/bin/bash
# ralph-test.sh — the adom-tts regression harness ("ralph test everything").
#
# Runs the FULL field-failure regression matrix against a real drainer in an
# ISOLATED $HOME (never touches the live player, queue, or the human's tab).
# Instead of a flaky browser, a FAKE PAGE speaks the player's real HTTP
# protocol (/poll, /audio, /histplay) — deterministic, headless, fast.
#
# Every test here is a real incident from the field week of 2026-07/08:
# T1 basic play → verdict ok (protocol sanity)
# T2 pause-is-sacred: paused=1 → pause_hold, queue holds, resume completes
# T3 restart REFUSES while a clip plays/paused; --force overrides
# T4 history replay while IDLE (the dead ↻ button)
# T5 enqueue dedup: identical bytes refused (exit 7)
# T6 stale-audio guard: old mp3 mtime → skipped, never spoken
# T7 metadata recovery by content hash (no more garbage headers)
# T8 claim orphan → History as "interrupted" (never silently destroyed)
# T9 port stability: killed drainer's port is reused by the next one
# T10 second clip survives the swap: stale telemetry must not kill it
# T11 script markers: cue one marker, no auto-advance, re-cue allowed, status
# T12 queue management: clear needs scope, --mine, drop one, control skip
#
# Usage: tools/ralph-test.sh (builds nothing; uses target/release)
# Exit: 0 all pass, 1 any fail. Prints PASS/FAIL per test.
set -u
cd "$(dirname "$0")/.."
BIN=$(pwd)/target/release/adom-tts
[ -x "$BIN" ] || { echo "build first: cargo build --release"; exit 1; }
T=$(mktemp -d /tmp/adom-tts-ralph.XXXXXX)
export RIGHOME=$T
mkdir -p $T/.adom/tts/queue
PASS=0; FAIL=0
say() { echo "== $*"; }
ok() { PASS=$((PASS+1)); echo " PASS: $*"; }
bad() { FAIL=$((FAIL+1)); echo " FAIL: $*"; }
rig() { env -u ADOM_BRIDGE_TARGET -u ADOM_DESKTOP_TARGET HOME=$T PATH=/usr/bin:/bin ADOM_TTS_NO_HINTS=1 "$BIN" "$@"; }
port() { python3 -c "import json;print(json.load(open('$T/.adom/tts/player.json'))['port'])" 2>/dev/null; }
ev() { grep -h "\"$1\"" $T/.adom/tts/events.jsonl 2>/dev/null; }
# make a tiny valid-enough mp3 (the drainer never decodes; bytes just flow)
mkmp3() { python3 -c "
import sys,random
random.seed('$2')
sys.stdout.buffer.write(b'ID3' + bytes([4,0,0,0,0,0,0]) + bytes(random.randrange(256) for _ in range($3)))" > "$1"; }
# ---- fake page: polls, downloads, reports progress/pause/done ---------------
# fakepage <port> <mode> modes: play (progress→done) | pause-mid | just-poll
# exec: the background job's pid must BE the python page, or `kill $FP` only
# kills the wrapper subshell and an orphan page keeps polling a port the next
# drainer reuses (which hid issue #7 from T13 for two runs).
fakepage() {
exec python3 - "$1" "$2" <<'PY'
import sys,time,json,urllib.request
port,mode=sys.argv[1],sys.argv[2]
base=f"http://127.0.0.1:{port}"
def get(p):
with urllib.request.urlopen(base+p,timeout=3) as r: return r.read()
cur=0; t=0.0; dur=12.0; clipno=0; started=time.time()
while time.time()-started < 150:
q=f"/poll?id={cur}&loaded=1&t={t:.1f}&dur={dur}&done=0&paused=0"
if mode=="pause-mid" and t>=3: q=f"/poll?id={cur}&loaded=1&t={t:.1f}&dur={dur}&done=0&paused=1"
try: j=json.loads(get(q))
except Exception: time.sleep(.4); continue
if j.get("id") and j["id"]!=cur:
prev_t=t
cur=j["id"]; t=0.0
clipno+=1
# duration derived from the clip id, so CONSECUTIVE clips always differ:
# then 'B reports A's reached' can only mean inheritance, never coincidence
dur = 12.0 + (cur % 5) * 2.0
if mode=="stale-done":
# THE REGRESSION: a real page sets curClipId synchronously but the
# <audio> element still holds the previous clip while the new blob
# downloads. Replay that window: new id, previous clip's ended+pos.
# A correct drainer must ignore this (page sends loaded=0).
for _ in range(3):
try: get(f"/poll?id={cur}&loaded=0&t={prev_t:.1f}&dur={dur}&done=1&paused=0")
except Exception: pass
time.sleep(.4)
try: get(f"/audio?id={cur}")
except Exception: pass
if cur and mode in ("play","pause-mid","stale-done"):
if mode=="pause-mid" and t>=3:
time.sleep(.5); continue # hold paused forever (pause is sacred)
t+=1.5
if t>=dur:
get(f"/poll?id={cur}&loaded=1&t={dur}&dur={dur}&done=1&paused=0")
print(f"page: clip {cur} done"); cur=0; t=0.0
time.sleep(.5)
PY
}
say "T1 basic play → verdict ok"
mkmp3 $T/t1.mp3 seed-t1 4000
rig play $T/t1.mp3 >/dev/null 2>&1
sleep 1; fakepage $(port) play >/dev/null 2>&1 &
FP=$!
for i in $(seq 1 30); do ev clip_end | grep -q '"t1.mp3"' && break; sleep 1; done
kill $FP 2>/dev/null
ev clip_end | grep '"t1.mp3"' | grep -q '"ok"' && ok "t1 played, verdict ok" || bad "t1 verdict: $(ev clip_end | grep t1 | tail -1)"
say "T2 pause-is-sacred (paused page → pause_hold, queue held)"
mkmp3 $T/t2.mp3 seed-t2 4000
mkmp3 $T/t2b.mp3 seed-t2b 4000
rig play $T/t2.mp3 >/dev/null 2>&1; rig play $T/t2b.mp3 >/dev/null 2>&1
sleep 1; fakepage $(port) pause-mid >/dev/null 2>&1 &
FP=$!
for i in $(seq 1 20); do ev pause_hold | grep -q . && break; sleep 1; done
if ev pause_hold | grep -q .; then
sleep 3
# while paused, t2b must NOT have started (queue held behind the pause)
ev clip_start | grep -q '"t2b.mp3"' && bad "queue advanced past a paused clip" || ok "pause_hold logged, queue held"
else bad "no pause_hold logged"; fi
say "T3 restart refuses mid-clip (needs --force)"
OUT=$(rig restart 2>&1); RC=$?
if [ $RC -eq 5 ] && echo "$OUT" | grep -q REFUSED; then ok "restart refused while paused (exit 5)"
else bad "restart rc=$RC out=$(echo "$OUT"|head -1)"; fi
kill $FP 2>/dev/null
OUT=$(rig restart --force 2>&1) && ok "restart --force succeeded" || bad "force restart failed: $OUT"
sleep 2
say "T4 history replay while IDLE (the dead ↻)"
# t1 is in history; drainer (fresh from --force) should be idle soon
for i in $(seq 1 10); do curl -s -m 1 "http://127.0.0.1:$(port)/state" >/dev/null 2>&1 && break; sleep 1; done
fakepage $(port) play >/dev/null 2>&1 &
FP=$!
sleep 3
SEQ=$(python3 -c "
import json
rows=[json.loads(l) for l in open('$T/.adom/tts/play_history.jsonl')]
m=[r for r in rows if r.get('name')=='t1.mp3']
print(m[-1]['seq'] if m else '')")
if [ -n "$SEQ" ]; then
curl -s -m 2 "http://127.0.0.1:$(port)/histplay?seq=$SEQ" >/dev/null
for i in $(seq 1 20); do ev clip_start | grep -q '"replay":true' && break; sleep 1; done
ev clip_start | grep -q '"replay":true' && ok "idle ↻ replay played (clip_start replay=true)" || bad "idle replay never started"
else bad "t1 not in history"; fi
kill $FP 2>/dev/null
say "T5 enqueue dedup (identical bytes refused, exit 7)"
mkmp3 $T/t5.mp3 seed-t5 4000
rig play $T/t5.mp3 >/dev/null 2>&1
rig play $T/t5.mp3 >/dev/null 2>&1; RC=$?
[ $RC -eq 7 ] && ok "duplicate refused (exit 7)" || bad "duplicate rc=$RC (want 7)"
say "T6 stale-audio guard (weeks-old mp3 never spoken)"
mkmp3 $T/t6.mp3 seed-t6 4000
touch -d "40 days ago" $T/t6.mp3
rig play $T/t6.mp3 >/dev/null 2>&1
fakepage $(port) play >/dev/null 2>&1 & FP=$!
# wait for t6's actual OUTCOME (it may sit behind an earlier clip): the stale
# event, a start, or its queue entry disappearing — not a fixed timer.
for i in $(seq 1 45); do
ev clip_stale_audio | grep -q '"t6.mp3"' && break
ev clip_start | grep -q '"t6.mp3"' && break
sleep 1
done
kill $FP 2>/dev/null
if ev clip_stale_audio | grep -q '"t6.mp3"'; then
ev clip_start | grep -q '"t6.mp3"' && bad "stale clip STARTED anyway" || ok "stale audio skipped, never spoken"
else bad "no clip_stale_audio event (start=$(ev clip_start | grep -c t6))"; fi
say "T7 metadata recovery by content hash"
# t1 played into history with name t1.mp3; replay its bytes under a snapshot-ish
# name. --allow-repeat: identical bytes are otherwise dedup-refused (exit 7)
# before recovery can run — a deliberate replay is exactly the recovery case.
cp $T/t1.mp3 "$T/00000009999999999999-1234567.mp3"
rig play "$T/00000009999999999999-1234567.mp3" --allow-repeat >/dev/null 2>&1
ev meta_recovered | grep -q '"t1.mp3"' && ok "name/label recovered from history by hash" || bad "no meta_recovered event"
# clean ONLY T7's own entry — a blanket rm here once deleted T6's still-queued
# clip and produced a phantom T6 failure (the harness's own field lesson)
python3 -c "
import json,os,glob
for f in glob.glob('$T/.adom/tts/queue/*.json'):
try:
if json.load(open(f)).get('name','').startswith('00000009999999999999'): os.remove(f)
except Exception: pass
"
say "T8 claim orphan → History as interrupted"
LIVEPORT=$(port)
mkmp3 $T/t8.mp3 seed-t8 4000
rig play $T/t8.mp3 >/dev/null 2>&1
fakepage $(port) play >/dev/null 2>&1 & FP=$!
for i in $(seq 1 15); do ev clip_start | grep -q '"t8.mp3"' && break; sleep 1; done
kill $FP 2>/dev/null
DP=$(cut -d' ' -f1 < $T/.adom/tts/drainer.lock 2>/dev/null || head -1 $T/.adom/tts/drainer.lock)
DP=$(python3 -c "print(open('$T/.adom/tts/drainer.lock').read().split()[0])" 2>/dev/null)
[ -n "$DP" ] && kill -9 $DP 2>/dev/null
ls $T/.adom/tts/queue/*.playing >/dev/null 2>&1 || echo " (note: no .playing at kill instant — timing)"
rm -f $T/.adom/tts/drainer.lock
mkmp3 $T/t8spawn.mp3 seed-t8spawn 4000
rig play $T/t8spawn.mp3 >/dev/null 2>&1 # UNIQUE bytes: dedup would refuse a repeat and never spawn the sweeping drainer
sleep 3
if ls $T/.adom/tts/queue/*.playing >/dev/null 2>&1; then bad "orphan .playing still present"; else
python3 -c "
import json,sys
rows=[json.loads(l) for l in open('$T/.adom/tts/play_history.jsonl')]
sys.exit(0 if any(r.get('name')=='t8.mp3' and r.get('verdict')=='interrupted' for r in rows) else 1)" \
&& ok "orphan recorded in History as interrupted" || bad "orphan not in History as interrupted"
fi
say "T9 port stability across drainer death"
NEWPORT=$(port)
[ "$NEWPORT" = "$LIVEPORT" ] && ok "port $LIVEPORT reused after kill" || bad "port changed $LIVEPORT → $NEWPORT"
say "T10 second clip must NOT be killed by the previous clip's telemetry"
# THE FIELD REPORT: two AI threads send clips back to back; the moment clip two
# arrives, clip one's stale ended/position gets attributed to it, so it is
# marked done on arrival and cut off seconds in (falsely reported "ok").
mkmp3 $T/t10a.mp3 seed-t10a 4000
mkmp3 $T/t10b.mp3 seed-t10b 4000
rig play $T/t10a.mp3 >/dev/null 2>&1
rig play $T/t10b.mp3 >/dev/null 2>&1
fakepage $(port) stale-done >/dev/null 2>&1 & FP=$!
for i in $(seq 1 60); do ev clip_end | grep -q '"t10b.mp3"' && break; sleep 1; done
kill $FP 2>/dev/null
# assert clip B lived longer than a swap-window blip (>4s wall) and did not
# inherit clip A's reached value
python3 -c "
import json,sys
ev=[json.loads(l) for l in open('$T/.adom/tts/events.jsonl')]
st={ }; res={}
for v in ev:
if v.get('kind')=='clip_start': st[v.get('clip')]=v['ts_ms']
if v.get('kind')=='clip_end' and v.get('clip') in st:
res[v['clip']]=((v['ts_ms']-st[v['clip']])/1000.0, v.get('reached_s',0), v.get('verdict'))
b=res.get('t10b.mp3')
a=res.get('t10a.mp3')
if not b: print('NOSTART'); sys.exit(1)
wall,reached,verdict=b
if wall < 4: print('KILLED wall=%.1fs reached=%s verdict=%s' % (wall,reached,verdict)); sys.exit(1)
if a and abs(reached-a[1])<0.01 and reached>0: print('INHERITED reached=%s from clip A' % reached); sys.exit(1)
print('OK wall=%.1fs reached=%s verdict=%s' % (wall,reached,verdict))
" > $T/t10.out 2>&1
if grep -q '^OK' $T/t10.out; then ok "second clip played through ($(cat $T/t10.out))"
else bad "second clip cut off by stale telemetry: $(cat $T/t10.out)"; fi
say "T11 script markers: cue plays ONE marker, re-cue is not refused, status is honest"
# Issue #4: a demo thread pre-caches narration and cues one marker at a time.
# Hand-built manifest (no synthesis service needed in the harness).
mkdir -p $T/.adom/tts/scripts/t11
mkmp3 $T/.adom/tts/scripts/t11/seg-1.mp3 seed-t11a 4000
mkmp3 $T/.adom/tts/scripts/t11/seg-2.mp3 seed-t11b 4000
python3 -c "
import json,time
m={'id':'t11','thread':'ralph','created_ms':int(time.time()*1000),'segments':[
{'n':1,'mp3':'$T/.adom/tts/scripts/t11/seg-1.mp3','text':'marker one','hash':'h1'},
{'n':2,'mp3':'$T/.adom/tts/scripts/t11/seg-2.mp3','text':'marker two','hash':'h2'}]}
json.dump(m,open('$T/.adom/tts/scripts/t11.json','w'))"
for i in $(seq 1 10); do curl -s -m 1 "http://127.0.0.1:$(port)/state" >/dev/null 2>&1 && break; sleep 1; done
fakepage $(port) play >/dev/null 2>&1 & FP=$!
rig cue t11 1 >/dev/null 2>&1
for i in $(seq 1 30); do ev clip_end | grep -q '"t11#1"' && break; sleep 1; done
if ev clip_end | grep '"t11#1"' | grep -q '"ok"'; then ok "cue 1 played as its own clip (t11#1 ok)"; else bad "cue 1 no ok verdict: $(ev clip_end | grep t11 | tail -1)"; fi
# nothing auto-advances: marker 2 must NOT have started on its own
ev clip_start | grep -q '"t11#2"' && bad "marker 2 auto-advanced without a cue" || ok "marker 2 did not auto-advance"
# re-cue the SAME marker: must be accepted (explicit), not dedup-refused (exit 7)
rig cue t11 1 >/dev/null 2>&1; RC=$?
[ $RC -eq 0 ] && ok "re-cue of marker 1 accepted (rc=0, not refused)" || bad "re-cue rc=$RC"
for i in $(seq 1 30); do [ "$(ev clip_end | grep -c '"t11#1"')" -ge 2 ] && break; sleep 1; done
kill $FP 2>/dev/null
# prefetch advertised + /seg serves the marker
curl -s -m 2 "http://127.0.0.1:$(port)/poll?id=0&loaded=1&t=0&dur=0&done=0&paused=0" | grep -q '"script":"t11"' \
&& ok "poll advertises t11 markers for prefetch" || bad "no prefetch advertised"
CODE=$(curl -s -m 2 -o /dev/null -w '%{http_code}' "http://127.0.0.1:$(port)/seg?script=t11&n=2")
[ "$CODE" = "200" ] && ok "/seg serves marker bytes (200)" || bad "/seg -> $CODE"
rig cue-status t11 2>&1 | grep -qE '^\s+1\s+PLAYED' && ok "cue-status reports marker 1 PLAYED" || bad "cue-status: $(rig cue-status t11 2>&1 | tr '\n' '|')"
say "T12 queue management: clear needs scope, --mine scopes by thread, drop one, skip the playing clip"
# Issue #4 §2: 17 stale clips behind a pause with no API to retract them.
# No page is polling now, so these stay WAITING (.json), never claimed.
mkmp3 $T/t12a.mp3 seed-t12a 4000; echo '{"label":"ralph","desc":"mine"}' > $T/t12a.json
mkmp3 $T/t12b.mp3 seed-t12b 4000; echo '{"label":"other","desc":"theirs"}' > $T/t12b.json
mkmp3 $T/t12c.mp3 seed-t12c 4000
rig play $T/t12a.mp3 >/dev/null 2>&1; rig play $T/t12b.mp3 >/dev/null 2>&1; rig play $T/t12c.mp3 >/dev/null 2>&1
sleep 1
rig queue clear >/dev/null 2>&1; RC=$?
[ $RC -eq 5 ] && ok "queue clear without scope is refused (exit 5)" || bad "unscoped clear rc=$RC"
rig queue clear --mine ralph >/dev/null 2>&1
W=$(ls $T/.adom/tts/queue/*.json 2>/dev/null | wc -l)
if [ "$W" -eq 2 ] && ! grep -l '"t12a.mp3"' $T/.adom/tts/queue/*.json >/dev/null 2>&1; then ok "clear --mine dropped only ralph's clip (2 left)"; else bad "after --mine: $W waiting"; fi
rig queue drop t12b.mp3 >/dev/null 2>&1 && ok "queue drop t12b accepted" || bad "queue drop failed"
rig queue clear --all >/dev/null 2>&1
[ "$(ls $T/.adom/tts/queue/*.json 2>/dev/null | wc -l)" -eq 0 ] && ok "clear --all emptied the waiting queue" || bad "clear --all left entries"
python3 -c "
import json,sys
rows=[json.loads(l) for l in open('$T/.adom/tts/play_history.jsonl')]
d=[r['name'] for r in rows if r.get('verdict')=='dropped']
sys.exit(0 if all(n in d for n in ('t12a.mp3','t12b.mp3','t12c.mp3')) else 1)" && ok "all three dropped clips recorded in History as 'dropped'" || bad "dropped clips missing from History"
# control skip: end the PLAYING clip, verdict=skipped, no retry
mkmp3 $T/t12d.mp3 seed-t12d 4000
rig play $T/t12d.mp3 >/dev/null 2>&1
fakepage $(port) play >/dev/null 2>&1 & FP=$!
for i in $(seq 1 20); do ev clip_start | grep -q '"t12d.mp3"' && break; sleep 1; done
sleep 2
rig control skip >/dev/null 2>&1
for i in $(seq 1 20); do ev clip_end | grep -q '"t12d.mp3"' && break; sleep 1; done
kill $FP 2>/dev/null
if ev clip_end | grep '"t12d.mp3"' | grep -q '"skipped"'; then ok "control skip ended the playing clip with verdict=skipped"; else bad "skip verdict: $(ev clip_end | grep t12d | tail -1)"; fi
ev clip_retry | grep -q '"t12d.mp3"' && bad "skipped clip was retried" || ok "skipped clip was not retried"
say "T13 hydrogen -> desktop fall-through (issue #5: no editor session)"
# Fake the two CLIs the surfaces talk to: adom-cli says there is no browser
# session (workspace get fails, probe reports browser_connected:false) and
# adom-bridge records the hd_api call it received instead of opening anything.
FAKE=$T/fakebin; mkdir -p $FAKE
cat > $FAKE/adom-cli <<'EOS'
#!/bin/sh
case "$*" in
*"hydrogen probe"*) echo '{"browser_connected":false,"sse_connections":0}'; exit 0;;
*"hydrogen workspace get"*) echo 'No browser session is connected' >&2; exit 1;;
*) exit 0;;
esac
EOS
cat > $FAKE/adom-bridge <<'EOS'
#!/bin/sh
case "$*" in
*hd_api*) echo "$*" >> "$FAKE_LOG"; echo '{"success":true,"ok":true,"status":200,"body":{"ok":true}}'; exit 0;;
*targets*) echo '{"targets":[{"name":"fakebox"}]}'; exit 0;;
*) exit 0;;
esac
EOS
chmod +x $FAKE/adom-cli $FAKE/adom-bridge
export FAKE_LOG=$T/hbw-open.log
env -u ADOM_DESKTOP_TARGET HOME=$T PATH=$FAKE:/usr/bin:/bin ADOM_BRIDGE_TARGET=fakebox VSCODE_PROXY_URI='http://127.0.0.1:{{port}}/' ADOM_TTS_NO_HINTS=1 "$BIN" restart --force >/dev/null 2>&1
for i in $(seq 1 20); do ev surface_fallthrough | grep -q '"to":"desktop"' && break; sleep 1; done
ev surface_fallthrough | grep -q '"to":"desktop"' && ok "hydrogen open failed with no session -> fell through to desktop" || bad "no surface_fallthrough event: $(ev open_surface_failed | tail -1)"
grep -q '/hbw/open' $FAKE_LOG 2>/dev/null && grep -q -- '--target fakebox' $FAKE_LOG && ok "hd_api POST /hbw/open sent to the pinned target" || bad "hd_api call not seen: $(cat $FAKE_LOG 2>/dev/null | head -c 200)"
python3 -c "import json,sys; sys.exit(0 if json.load(open('$T/.adom/tts/player.json')).get('surface')=='desktop' else 1)" && ok "portfile records the surface that actually opened (desktop)" || bad "portfile surface: $(cat $T/.adom/tts/player.json)"
# a clip's verdict names the surface that took it
mkmp3 $T/t13.mp3 seed-t13 4000
rig play $T/t13.mp3 >/dev/null 2>&1
# A real Hydrogen Desktop window takes seconds to load and poll; a page that
# answers instantly hides issue #7 (the audience wait never re-offers). Delay it.
(sleep 6; fakepage $(port) play >/dev/null 2>&1) & FP=$!
for i in $(seq 1 30); do ev clip_end | grep -q '"t13.mp3"' && break; sleep 1; done
kill $FP 2>/dev/null
ev clip_end | grep '"t13.mp3"' | grep -q '"surface":"desktop"' && ok "clip_end carries surface=desktop" || bad "clip_end surface: $(ev clip_end | grep t13 | tail -1 | head -c 200)"
# issue #7: the startup open and the audience wait's first offer must not both
# open a Hydrogen Desktop window. Exactly ONE hd_api /hbw/open per drain.
N=$(grep -c '/hbw/open' $FAKE_LOG 2>/dev/null)
[ "$N" -eq 1 ] && ok "desktop window opened exactly once per drain (hd_api /hbw/open x$N)" || bad "hd_api /hbw/open called $N times in one drain (issue #7)"
ev surface_open_skipped | grep -q 'issue #7' && ok "the repeat offer was logged as skipped (not a second open)" || bad "no surface_open_skipped event"
# teardown
DP=$(python3 -c "print(open('$T/.adom/tts/drainer.lock').read().split()[0])" 2>/dev/null)
[ -n "${DP:-}" ] && kill -9 $DP 2>/dev/null
echo
echo "RESULT: $PASS pass, $FAIL fail (rig: $T)"
[ $FAIL -eq 0 ]