tools: submenu sweep gets a cursor-region rule and a two-sided self-test
Sweep 1 failed because ring_row scans the MAIN MENU's gutter and these screens put their cursors elsewhere; sweep 2 failed because a whole-frame identity test cannot match once a crash dialog covers the centre. This rework fixes both: the decision uses the region that CHANGED when the cursor moved, so no per-screen geometry is assumed, and the back-on-the-menu test is the narrow ring row, which is what kept reading correctly under the dialog. The self-test is sylpheed-port's sharpened rule -- a control must construct the failure it is named after. Mine was one-sided: I checked only that the rule reports RESETS on a known-RESETS triple, so a rule biased entirely to RESETS would have passed. It now constructs both verdicts from the same frames and exits 3 if either is wrong. Committed BEFORE running, which is the process fix: the previous run aborted because I was editing and deliberately breaking this file while its own run was in flight, and the sweep read the broken version. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
This commit is contained in:
@@ -105,6 +105,46 @@ def wait_until(pred, what, limit=60):
|
||||
|
||||
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
# ── SELF-TEST: the decision rule must CONSTRUCT both of its verdicts ──────────
|
||||
# sylpheed-port's rule, after a control of theirs carried the right NAME over the
|
||||
# wrong filter: a control must construct the failure it is named after. Mine was
|
||||
# one-sided -- I checked only that the rule reports RESETS on a known-RESETS
|
||||
# triple, so a rule biased entirely to RESETS would have passed. Both directions
|
||||
# are constructed here from the SAME frames, and the EXIT CODE is the assertion:
|
||||
# printing a verdict is not asserting it.
|
||||
def _verdict(a, b, c):
|
||||
cur = np.abs(a - b).max(axis=2) > 24
|
||||
if cur.sum() == 0:
|
||||
return "NO-CURSOR"
|
||||
d1 = float(np.abs(c - a).max(axis=2)[cur].mean())
|
||||
d2 = float(np.abs(c - b).max(axis=2)[cur].mean())
|
||||
return "RESETS" if d1 < d2 * 0.5 else "PERSISTS" if d2 < d1 * 0.5 else "UNDECIDED"
|
||||
|
||||
|
||||
def _self_test():
|
||||
ref = os.environ.get("SWEEP_SELFTEST_DIR", "/sylph-home/re/extrasfocus")
|
||||
try:
|
||||
A, B, C = [np.asarray(Image.open(f"{ref}/{f}.png").convert("RGB"), dtype=int)
|
||||
for f in ("E1", "E2", "E3")]
|
||||
except Exception as e:
|
||||
print(f"🔴 SELF-TEST UNAVAILABLE ({e}) — refusing to run", flush=True)
|
||||
sys.exit(3)
|
||||
bad = 0
|
||||
for nm, args, want in (("known RESETS (real EXTRAS triple)", (A, B, C), "RESETS"),
|
||||
("constructed PERSISTS", (A, B, B), "PERSISTS"),
|
||||
("constructed RESETS", (A, B, A), "RESETS")):
|
||||
got = _verdict(*args); ok = got == want; bad += not ok
|
||||
print(f" {'✅' if ok else '🔴'} {nm:36} -> {got:9} (want {want})", flush=True)
|
||||
if bad:
|
||||
print("🔴 SELF-TEST FAILED — the rule cannot produce both verdicts.", flush=True)
|
||||
sys.exit(3)
|
||||
print(" ✅ self-test passed: the rule constructs both verdicts", flush=True)
|
||||
|
||||
|
||||
print("── decision-rule self-test ──", flush=True)
|
||||
_self_test()
|
||||
|
||||
MAIN = wait_until(lambda a: main_menu_item(ring_row(img(a))) is not None
|
||||
and 250 <= glyph(a) <= 420, "the main menu", 150)
|
||||
if MAIN is None:
|
||||
@@ -117,9 +157,13 @@ results = {}
|
||||
for tgt in TARGETS:
|
||||
name = NAMES[tgt]
|
||||
print(f"\n=========== {name} ===========", flush=True)
|
||||
a = wait_until(lambda a: differs(a, MAIN) < 0.15, "the main menu", 60)
|
||||
# 🔴 NARROW test, not whole-frame. A crash dialog covering the screen centre
|
||||
# made a whole-frame identity test unable to match ever again, while the ring
|
||||
# column the dialog did not cover read correctly throughout.
|
||||
a = wait_until(lambda a: main_menu_item(ring_row(img(a))) is not None,
|
||||
"the main menu (by ring row)", 60)
|
||||
if a is None:
|
||||
print(f" SKIP {name}: not on the main menu"); continue
|
||||
print(f" SKIP {name}: no main-menu ring row"); continue
|
||||
cur = main_menu_item(ring_row(img(a)))
|
||||
if cur is None:
|
||||
print(f" SKIP {name}: no main-menu ring row"); continue
|
||||
@@ -164,7 +208,8 @@ for tgt in TARGETS:
|
||||
|
||||
if not press("B", "5801"):
|
||||
results[name] = "skipped (B not delivered)"; continue
|
||||
if wait_until(lambda x: differs(x, MAIN) < 0.15, "the main menu", 60) is None:
|
||||
if wait_until(lambda x: main_menu_item(ring_row(img(x))) is not None,
|
||||
"the main menu (by ring row)", 60) is None:
|
||||
results[name] = "VOID: B did not return to the main menu"; continue
|
||||
if not press("A", "5800"):
|
||||
results[name] = "skipped (A not delivered on re-entry)"; continue
|
||||
@@ -172,17 +217,23 @@ for tgt in TARGETS:
|
||||
results[name] = "VOID: re-entry did not change the screen"; continue
|
||||
time.sleep(3.0)
|
||||
S3 = fresh(); img(S3).save(f"{OUT}/{name.replace(' ','_')}-S3.png")
|
||||
d1, d2 = differs(S3, S1), differs(S3, S2)
|
||||
print(f" S3 re-entered: {100*d1:.2f}% from S1 (opened on), "
|
||||
f"{100*d2:.2f}% from S2 (left on)", flush=True)
|
||||
if min(d1, d2) > 0.15:
|
||||
results[name] = f"VOID: re-entry matches neither ({100*d1:.1f}% / {100*d2:.1f}%)"
|
||||
# The pixels that changed when the cursor moved ARE the cursor's region -- no
|
||||
# per-screen geometry, which is what defeated sweep 1 (ring_row scans the MAIN
|
||||
# MENU's gutter; these screens put cursors at x 97..231, 338..1099, 153..479).
|
||||
cur = np.abs(S1 - S2).max(axis=2) > 24
|
||||
same_p95 = float(np.percentile(np.abs(S3 - S1).max(axis=2)[~cur], 95))
|
||||
d1 = float(np.abs(S3 - S1).max(axis=2)[cur].mean())
|
||||
d2 = float(np.abs(S3 - S2).max(axis=2)[cur].mean())
|
||||
print(f" S3 re-entered: off-cursor p95 {same_p95:.1f}; in-cursor "
|
||||
f"|S3-S1| {d1:.1f}, |S3-S2| {d2:.1f}", flush=True)
|
||||
if same_p95 > 40:
|
||||
results[name] = f"VOID: re-entry is not the same screen (off-cursor p95 {same_p95:.0f})"
|
||||
elif d2 < d1 * 0.5:
|
||||
results[name] = f"PERSISTS (S3 {100*d2:.2f}% from where left, {100*d1:.2f}% from opened)"
|
||||
results[name] = f"PERSISTS (in-cursor {d2:.1f} from where left vs {d1:.1f} from opened)"
|
||||
elif d1 < d2 * 0.5:
|
||||
results[name] = f"RESETS (S3 {100*d1:.2f}% from opened, {100*d2:.2f}% from where left)"
|
||||
results[name] = f"RESETS (in-cursor {d1:.1f} from opened vs {d2:.1f} from where left)"
|
||||
else:
|
||||
results[name] = f"UNDECIDED ({100*d1:.2f}% / {100*d2:.2f}%)"
|
||||
results[name] = f"UNDECIDED (in-cursor {d1:.1f} / {d2:.1f})"
|
||||
print(f" => {results[name]}", flush=True)
|
||||
press("B", "5801"); time.sleep(3)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user