master
John Lauer harness: T5 derives the expected thread slug from ADOM_AI_THREAD (a hardcoded name produced a false 'stamp path broken'… 781d1b2 2mo ago
#!/usr/bin/env bash
# mode-ralph.sh — the taskbar-state switching ralph (John, 2026-08-16: "loop forever until you have
# it fixed. figure out how to take screenshots to fix yourself and analyze").
#
# Drives taskbarMode/overlayBadges through the SAME /dashboard/action endpoint the Settings dialog
# uses (via pup_eval on the cn-dash window), waits for the apply-progress tracker to report done,
# then verifies the GLASS with UIA button names and captures the tray. Includes RAPID double-click
# rounds that reproduce the in-flight-stamp race (the 2.0.216 generation-token fix's regression
# test). Exit code = number of failed rounds.
#
# Usage: tools/mode-ralph.sh [TARGET] [FINAL_MODE] [FINAL_OV]
set -u
TARGET="${1:-AdomLapper}"
FINAL_MODE="${2:-plain}"
FINAL_OV="${3:-on}"
export ADOM_AI_THREAD="${ADOM_AI_THREAD:-pup bridge maintenance}"
export ADOM_CONTAINER_NAME="${ADOM_CONTAINER_NAME:-galliaApril}"
CLI="adom-bridge --target $TARGET"
PASS=0; FAIL=0
SHOTDIR="${SHOTDIR:-/tmp/mode-ralph-shots}"; mkdir -p "$SHOTDIR"
# v2: the expected thread-tile names come from the LIVE sessions, never a hardcoded list. A stale
# list made every tiles round fail with "only 1 thread-name buttons" while the bar was perfect.
OWNERS=$(timeout 40 $CLI pup_status '{}' 2>&1 | python3 -c "
import sys,json
raw=sys.stdin.read();i=raw.find('{')
d=json.loads(raw[i:]) if i>=0 else {}
seen=[]
for s in d.get('sessions',[]):
    o=(s.get('owner') or '').strip()
    if o and o not in seen: seen.append(o)
print('|'.join(seen))" 2>/dev/null)
[ -z "$OWNERS" ] && OWNERS="pup bridge maintenance"
echo "live threads expected as tiles: $OWNERS"
export OWNERS

evaljs() { # evaljs <expression> -> prints result
  timeout 60 $CLI pup_eval "{\"sessionId\":\"cn-dash\",\"expression\":$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1]))' "$1")}" 2>&1 \
    | python3 -c "import sys,json;raw=sys.stdin.read();i=raw.find('{');print(json.loads(raw[i:]).get('result',''))" 2>/dev/null
}

switch_state() { # switch_state <mode> <ov on|off>
  evaljs "(fetch('/dashboard/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'set-setting',key:'taskbarMode',value:'$1'})}).then(()=>fetch('/dashboard/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'set-setting',key:'overlayBadges',value:'$2'})})), 'sent')" >/dev/null
}

wait_done() { # poll apply-progress until done (max ~40s)
  for i in $(seq 1 40); do
    D=$(evaljs "fetch('/dashboard/apply-progress').then(r=>r.json()).then(j=>String(j&&j.apply&&j.apply.done))")
    [ "$D" = "true" ] && return 0
    sleep 1
  done
  return 1
}

uia_buttons() { # $1 = rect file basename (default rects.txt); prints name lines; writes x|y|w|h|name rows
  local PS='Add-Type -AssemblyName UIAutomationClient; $tray=[System.Windows.Automation.AutomationElement]::RootElement.FindFirst([System.Windows.Automation.TreeScope]::Children,(New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ClassNameProperty,"Shell_TrayWnd"))); $btns=$tray.FindAll([System.Windows.Automation.TreeScope]::Descendants,(New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ControlTypeProperty,[System.Windows.Automation.ControlType]::Button))); foreach($b in $btns){$r=$b.Current.BoundingRectangle; Write-Output ("{0}|{1}|{2}|{3}|{4}" -f [int]$r.X,[int]$r.Y,[int]$r.Width,[int]$r.Height,$b.Current.Name)}'
  local B64; B64=$(python3 -c "import base64,sys;print(base64.b64encode(sys.stdin.read().encode('utf-16-le')).decode())" <<< "$PS")
  timeout 90 $CLI shell_execute "{\"command\":\"powershell -NoProfile -EncodedCommand $B64\",\"reason\":\"mode-ralph: UIA taskbar button names + rects\"}" 2>&1 \
    | python3 -c "
import sys,json
raw=sys.stdin.read();i=raw.find('{');d=json.loads(raw[i:]) if i>=0 else {}
out=d.get('stdout') or ''
import os
open(os.path.join('$SHOTDIR', '${1:-rects.txt}'),'w').write(out)
for l in out.splitlines():
    p=l.split('|',4)
    print(p[4] if len(p)==5 else l)"
}

pixel_gate() { # pixel_gate <mode> <label> — crop the button under test by its UIA rect, red-box the
  # tray shot, and CLASSIFY the art: plain must show NO pup teal on the CfT group button; tiles and
  # aumid must show pup teal on a pup button. Names are not pixels (John's red-box rule).
  local mode="$1" label="$2"
  python3 - "$mode" "$label" <<'PYEOF'
import sys, re
from PIL import Image, ImageDraw
mode, label = sys.argv[1], sys.argv[2]
import os
SHOTDIR = os.environ.get("SHOTDIR","/tmp/mode-ralph-shots")
def load(f):
    return [l.split('|',4) for l in open(f).read().splitlines() if l.count('|')>=4]
def pick(rows):
    if mode=='plain': return [r for r in rows if 'Chrome for Testing' in r[4]]
    owners=[o for o in (os.environ.get('OWNERS') or '').split('|') if o]
    return [r for r in rows if r[4].startswith('Adom Pup') or any(r[4].startswith(o) for o in owners)]
rows=load(f'{SHOTDIR}/rects.txt')
tgt=pick(rows)
if not tgt: print('PIXELGATE FAIL: no target button found'); sys.exit(1)
# the taskbar REFLOWS for seconds after a mode switch (buttons materialize as stamps land) — a rect
# captured before the screenshot can point at a NEIGHBOR button by capture time (the RustDesk-swirl
# incident). rects2.txt is read AFTER the shot; if the target moved, the bar is still settling.
try:
    tgt2=pick(load(f'{SHOTDIR}/rects2.txt'))
    if not tgt2 or tgt2[0][4]!=tgt[0][4] or abs(int(tgt2[0][0])-int(tgt[0][0]))>4:
        print('PIXELGATE UNSTABLE: taskbar reflowed between rect read and screenshot'); sys.exit(2)
    tgt=tgt2
except FileNotFoundError:
    pass
# judge the MODE, not one unlucky button: a single straggler (the #53 hwnd-flaky class) can lag
# for tens of seconds while every sibling wears correct art — sample up to 4 targets, keep the best.
cands = tgt[:4] if mode!='plain' else tgt[:1]
import os.path
if not os.path.exists(f'{SHOTDIR}/{label}.png'):
    print('PIXELGATE FAIL: no fresh tray capture'); sys.exit(1)
im=Image.open(f'{SHOTDIR}/{label}.png')
import json as _json
def _map(x,w):
    try:
        # imageToScreen describes the SAFE image, not the full.png we open — map via screenRect
        # against THIS image's width (the 3-button-drift bug caught in the six-state evidence walk)
        cm=_json.load(open(f'{SHOTDIR}/{label}.json')).get('coordMap') or dict()
        sr=cm.get('screenRect') or dict()
        srw=float(sr.get('w') or 0); srx=float(sr.get('x') or 0)
        if not srw: raise ValueError('no screenRect')
        sc=im.width/srw
        a,b=int((x-srx)*sc),int((x+w-srx)*sc)
    except Exception:
        sc=im.width/2560.0; a,b=int(x*sc),int((x+w)*sc)
    a=max(0,min(im.width-2,a)); b=max(a+2,min(im.width,b))
    return a,b
best=None
for t in cands:
    x,y,w,h=(int(t[i]) for i in range(4))
    cx0,cx1=_map(x,w)
    crop=im.crop((cx0,0,cx1,im.height)).convert('RGB')
    W,H=crop.size
    px=crop.load(); teal=0; total=0
    for yy in range(H):
        for xx in range(W):
            if xx > W*0.45 and yy < H*0.62: continue
            r,g,b=px[xx,yy][:3]; total+=1
            # pup teal is #00b8b1: near-zero red, green~blue. CfT's own BLUE logo has cyan pixels
            # that a loose rule counted as teal (the 0.056-forever plain fail) — keep it tight.
            if g>120 and b>110 and r<70 and abs(int(g)-int(b))<45: teal+=1
    frac=teal/max(1,total)
    take = (best is None) or (frac > best[0] if mode != 'plain' else frac < best[0])
    if take:
        best=(frac,t,cx0,cx1)
frac,t,cx0,cx1=best
ann=im.convert('RGB').copy(); d=ImageDraw.Draw(ann)
d.rectangle([cx0-2,0,cx1+2,im.height-1],outline=(255,0,0),width=2)
ann.resize((ann.width*2,ann.height*2),Image.LANCZOS).save(f'{SHOTDIR}/{label}-redbox.png')
im.crop((cx0,0,cx1,im.height)).resize(((cx1-cx0)*10,im.height*10),Image.LANCZOS).save(f'{SHOTDIR}/{label}-btn10x.png')
ok = (frac < 0.02) if mode=='plain' else (frac > 0.04)
print(f"PIXELGATE {'PASS' if ok else 'FAIL'}: teal fraction {frac:.3f} on '{t[4][:50]}' ({'must be ~0 in plain' if mode=='plain' else 'best of ' + str(len(cands)) + ' pup buttons'})")
sys.exit(0 if ok else 1)
PYEOF
}

shot_tray() { # shot_tray <label> — MUST produce a FRESH capture; a stale file poisons every gate
  local HW; HW=$(cat /tmp/tray_hwnd 2>/dev/null || echo 0)
  if [ "$HW" = "0" ] || [ -z "$HW" ]; then HW=$(resolve_tray_hwnd); fi
  timeout 60 $CLI desktop_screenshot_window "{\"hwnd\":$HW}" >/dev/null 2>&1
  local P; P=$(find ~/project/screenshots/adom-bridge/window -name "*-${HW}.full.png" -newermt "$(date -u -d '-45 seconds' '+%Y-%m-%dT%H:%M:%S')" 2>/dev/null | head -1)
  if [ -z "$P" ]; then
    # capture failed or hwnd stale (Explorer restart) — re-resolve once and retry
    HW=$(resolve_tray_hwnd); echo "$HW" > /tmp/tray_hwnd
    timeout 60 $CLI desktop_screenshot_window "{\"hwnd\":$HW}" >/dev/null 2>&1
    P=$(find ~/project/screenshots/adom-bridge/window -name "*-${HW}.full.png" -newermt "$(date -u -d '-45 seconds' '+%Y-%m-%dT%H:%M:%S')" 2>/dev/null | head -1)
  fi
  if [ -z "$P" ]; then echo "  SHOT_TRAY FAILED: no fresh capture (hwnd $HW) — pixel gate will be skipped as FAIL" >&2; rm -f "$SHOTDIR/$1.png"; return 1; fi
  cp "$P" "$SHOTDIR/$1.png"
}

resolve_tray_hwnd() {
  local PS='Add-Type -AssemblyName UIAutomationClient; $tray=[System.Windows.Automation.AutomationElement]::RootElement.FindFirst([System.Windows.Automation.TreeScope]::Children,(New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ClassNameProperty,"Shell_TrayWnd"))); $tray.Current.NativeWindowHandle'
  local B64; B64=$(python3 -c "import base64,sys;print(base64.b64encode(sys.stdin.read().encode('utf-16-le')).decode())" <<< "$PS")
  timeout 60 $CLI shell_execute "{\"command\":\"powershell -NoProfile -EncodedCommand $B64\",\"reason\":\"mode-ralph: resolve tray hwnd\"}" 2>&1 \
    | python3 -c "import sys,json;raw=sys.stdin.read();i=raw.find('{');d=json.loads(raw[i:]) if i>=0 else {};print((d.get('stdout') or '0').strip())"
}

verify_state() { # verify_state <mode> <label> — retries while the bar is still reflowing
  local mode="$1" label="$2"
  local B PG PGOK attempt
  local N_PUP N_THREAD N_CFT
  for attempt in 1 2 3 4 5; do
    B=$(uia_buttons rects.txt)
    N_PUP=$(echo "$B" | grep -c "Adom Pup" || true)
    N_THREAD=$(echo "$B" | python3 -c "
import sys,os
owners=[o for o in (os.environ.get('OWNERS') or '').split('|') if o]
n=sum(1 for l in sys.stdin if any(l.startswith(o) for o in owners))
print(n)")
    N_CFT=$(echo "$B" | grep -c "Chrome for Testing" || true)
    shot_tray "$label"
    uia_buttons rects2.txt >/dev/null
    PG=$(SHOTDIR="$SHOTDIR" pixel_gate "$mode" "$label"); PGOK=$?
    # names must agree with the mode too — a switch still landing shows the PREVIOUS mode's buttons
    NAMEOK=1
    case "$mode" in
      plain) { [ "$N_PUP" -eq 0 ] && [ "$N_THREAD" -eq 0 ] && [ "$N_CFT" -eq 1 ]; } && NAMEOK=0 ;;
      aumid) WANTA=$(python3 -c "import os;o=[x for x in (os.environ.get('OWNERS') or '').split('|') if x];print(max(1,len(o)))")
             [ "$N_PUP" -ge "$WANTA" ] && NAMEOK=0 ;;
      tiles) WANT=$(python3 -c "import os;o=[x for x in (os.environ.get('OWNERS') or '').split('|') if x];print(max(1,len(o)-1))")
             [ "$N_THREAD" -ge "$WANT" ] && NAMEOK=0 ;;
    esac
    [ $PGOK -eq 0 ] && [ $NAMEOK -eq 0 ] && break
    echo "  (attempt $attempt: names $( [ $NAMEOK -eq 0 ] && echo ok || echo "still showing the old mode" ), $PG — settling 8s)"
    sleep 8
  done
  echo "  $PG"
  case "$mode" in
    plain)
      if [ "$N_PUP" -eq 0 ] && [ "$N_THREAD" -eq 0 ] && [ "$N_CFT" -eq 1 ] && [ $PGOK -eq 0 ]; then PASS=$((PASS+1)); echo "  PASS [$label] plain: 0 branded buttons, exactly ONE CfT group + pixel gate"; return 0
      else FAIL=$((FAIL+1)); echo "  FAIL [$label] plain: $N_PUP 'Adom Pup' + $N_THREAD thread buttons, $N_CFT CfT groups (must be exactly 1)"; echo "$B" | grep -E "Adom Pup|^(ti-crawl|mouser-run|digikey-run|wiki-in|wiki-out|dash-app|reclaimed|pup bridge)" | sed 's/^/    /'; return 1; fi ;;
    aumid)
      WANTA=$(python3 -c "import os;o=[x for x in (os.environ.get('OWNERS') or '').split('|') if x];print(max(1,len(o)))")
      if [ "$N_PUP" -ge "$WANTA" ] && [ $PGOK -eq 0 ]; then PASS=$((PASS+1)); echo "  PASS [$label] aumid: $N_PUP 'Adom Pup' per-window buttons + pixel gate"; return 0
      else FAIL=$((FAIL+1)); echo "  FAIL [$label] aumid: $N_PUP 'Adom Pup' button(s), wanted >= $WANTA"; return 1; fi ;;
    tiles)
      WANT=$(python3 -c "import os;o=[x for x in (os.environ.get('OWNERS') or '').split('|') if x];print(max(1,len(o)-1))")
      if [ "$N_THREAD" -ge "$WANT" ] && [ $PGOK -eq 0 ]; then PASS=$((PASS+1)); echo "  PASS [$label] tiles: $N_THREAD thread-name buttons + pixel gate"; return 0
      else FAIL=$((FAIL+1)); echo "  FAIL [$label] tiles: $N_THREAD thread-name button(s), wanted >= $WANT of [$OWNERS]"; return 1; fi ;;
  esac
}

round() { # round <mode> <ov> <label> — clean switch, wait, verify
  echo "== ROUND $3: switch to $1 + overlay $2 =="
  switch_state "$1" "$2"
  wait_done || echo "  (apply-progress never reported done — verifying anyway)"
  sleep 3
  verify_state "$1" "$3"
}

rapid_round() { # rapid_round <modeA> <modeB> <label> — the race: click A, then B ~1.2s later; B must win.
  # B lands with overlay OFF: the group badge favicon is racey (most-recent window wins), so a teal
  # badge would poison the base-art teal measurement nondeterministically.
  echo "== RAPID $3: $1 then $2 after 1.2s (the in-flight-stamp race) =="
  switch_state "$1" "on"
  sleep 1.2
  switch_state "$2" "off"
  wait_done || echo "  (apply-progress never reported done — verifying anyway)"
  sleep 5
  verify_state "$2" "$3"
}

round tiles on  r1-tiles
round aumid on  r2-aumid
round plain off r3-plain
rapid_round aumid plain r4-rapid-aumid-then-plain
round tiles on  r5-tiles
rapid_round tiles plain r6-rapid-tiles-then-plain
round aumid on  r7-aumid
round "$FINAL_MODE" off r8-final-$FINAL_MODE
# restore the user's chosen overlay setting (measurement used overlay-off for determinism)
switch_state "$FINAL_MODE" "$FINAL_OV"
echo "== restored final state: $FINAL_MODE + overlay $FINAL_OV =="

echo
echo "== MODE-RALPH RESULT: $PASS passed, $FAIL failed (shots in $SHOTDIR) =="
[ "$FAIL" -eq 0 ] && echo "GREEN" || echo "RED"
exit $FAIL