mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-18 14:32:36 +00:00
659 lines
31 KiB
Python
Executable File
659 lines
31 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""The click-edit contract: a value field that opens must accept what is TYPED into it.
|
|
|
|
WHY THIS EXISTS SEPARATELY FROM check-gui-sketching.py. That ladder draws geometry and grades the
|
|
result, and to make its values land it calls focus_field() — one synthetic click INTO the field
|
|
before typing. Its own docstring says why:
|
|
|
|
WITHOUT THIS THE TYPED VALUE IS SILENTLY DISCARDED. The field is shown and raised but the
|
|
window manager does not give it the keyboard, so xdotool's digits go to the canvas and Return
|
|
commits the value the field opened with — the pre-filled as-drawn number.
|
|
|
|
That click is a workaround for a defect, and a suite that performs it can never see the defect
|
|
again. A user cannot be told to click the field first; when they do not, they get the as-drawn
|
|
number and report "the label value is not editable". So this ladder types IMMEDIATELY after the
|
|
field opens, exactly as a person does, and fails if the prefill is what gets committed.
|
|
|
|
WHAT IT GRADES. The app emits one line per event under ORCA_CAD_UXTRACE=1:
|
|
|
|
[UX] open title=Length prefill=154.76
|
|
[UX] commit title=Length typed=80 value=80.0000
|
|
[UX] refused title=Length typed=8O
|
|
[UX] cancel title=Length
|
|
|
|
For every field the driver opens it asserts: a commit arrived, what the field received is what we
|
|
typed, the parsed value equals it, and it differs from the prefill. The last clause is the one
|
|
that matters — a field that is on screen but deaf commits its prefill, and every other signal
|
|
(the field is visible, a constraint is created, the solve succeeds) looks perfectly healthy.
|
|
|
|
scripts/CAD/check-gui-click-edit.py --display :10 --bin build/src/Release/orca-slicer
|
|
|
|
With --attach it drives an already-running app instead of launching one; the app must have been
|
|
started with ORCA_CAD_UXTRACE=1 and its stderr redirected to --trace.
|
|
Exit 0 = every field took what was typed.
|
|
"""
|
|
import argparse, json, os, re, shutil, signal, socket, subprocess, sys, tempfile, time
|
|
|
|
AP = argparse.ArgumentParser()
|
|
AP.add_argument("--display", default=os.environ.get("DISPLAY", ":10"))
|
|
AP.add_argument("--bin", default="build/src/Release/orca-slicer")
|
|
AP.add_argument("--datadir", default="")
|
|
AP.add_argument("--trace", default="")
|
|
AP.add_argument("--sock", default="/tmp/mcp-uxcheck.sock",
|
|
help="the app's MCP socket: the oracle for whether a sketch is really open")
|
|
AP.add_argument("--attach", action="store_true", help="drive a running app; do not launch one")
|
|
AP.add_argument("--keep", action="store_true", help="leave the app running afterwards")
|
|
AP.add_argument("--no-defocus", action="store_true",
|
|
help="do NOT take focus off the field before typing (weakens the gate; see below)")
|
|
AP.add_argument("--seed-from", default=os.path.expanduser("~/.config/OrcaCAD/OrcaSlicer.conf"),
|
|
help="an existing OrcaSlicer.conf to copy presets/settings from")
|
|
A = AP.parse_args()
|
|
|
|
DISP = A.display
|
|
TRACE = A.trace or os.path.join(tempfile.gettempdir(), "ux-click-edit.log")
|
|
_fail = 0
|
|
_checks = 0
|
|
|
|
|
|
_n = 0
|
|
|
|
|
|
def call(method, **params):
|
|
"""One MCP request over the app's unix socket. The socket is the only witness that cannot
|
|
lie about sketch state: the keytrace says a key ARRIVED, a screenshot says something is on
|
|
screen, and neither distinguishes an open sketch from sketch mode with the plane offer up."""
|
|
global _n
|
|
_n += 1
|
|
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
s.settimeout(30)
|
|
s.connect(A.sock)
|
|
s.sendall((json.dumps({"jsonrpc": "2.0", "id": _n, "method": method,
|
|
"params": params}) + "\n").encode())
|
|
buf = b""
|
|
while b"\n" not in buf:
|
|
d = s.recv(65536)
|
|
if not d:
|
|
break
|
|
buf += d
|
|
r = json.loads(buf.decode().strip())
|
|
if "error" in r:
|
|
raise RuntimeError(f"{method}: {r['error']['message']}")
|
|
return r["result"]
|
|
|
|
|
|
def try_call(method, **params):
|
|
try:
|
|
return call(method, **params)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def sh(cmd):
|
|
# bash -c, NOT -lc: a login shell sources the profile on every xdotool call, and this driver
|
|
# makes hundreds. On a GNOME box that meant im-config running per call, thousands of journal
|
|
# lines, and a window poll slow enough to time out before the app had finished starting.
|
|
return subprocess.run(["bash", "-c", cmd], capture_output=True, text=True).stdout
|
|
|
|
|
|
def xdo(args):
|
|
sh(f"DISPLAY={DISP} xdotool {args}")
|
|
|
|
|
|
def key(k, pause=0.35, window=None):
|
|
xdo(f"key {'--window ' + str(window) + ' ' if window else ''}{k}")
|
|
time.sleep(pause)
|
|
|
|
|
|
def typ(s, pause=0.35, window=None):
|
|
# --clearmodifiers so a modifier left down by an earlier synthetic key cannot turn digits
|
|
# into something else; --delay 60 because ImGui reads one character per frame.
|
|
#
|
|
# `window` targets a specific window with XSendEvent instead of following the input focus.
|
|
# That is the whole gate: see type_into_open_field.
|
|
tgt = f"--window {window} " if window else ""
|
|
xdo(f"type {tgt}--clearmodifiers --delay 60 -- '{s}'")
|
|
time.sleep(pause)
|
|
|
|
|
|
def die(msg):
|
|
print(f"FATAL {msg}", file=sys.stderr)
|
|
sys.exit(2)
|
|
|
|
|
|
# ---------------------------------------------------------------- the app
|
|
|
|
_proc = None
|
|
|
|
|
|
def seed_datadir(datadir):
|
|
"""The Design tab does not exist unless enable_cad_feature is on, and it needs a RESTART.
|
|
|
|
A fresh datadir has it off, so a driver that just points the app at an empty directory gets
|
|
Prepare/Preview/Device/Project, no Design tab, and every rung fails for a reason that has
|
|
nothing to do with what is being tested. Seed the flag before the first launch.
|
|
"""
|
|
os.makedirs(datadir, exist_ok=True)
|
|
conf = os.path.join(datadir, "OrcaSlicer.conf")
|
|
data = {}
|
|
if os.path.exists(A.seed_from):
|
|
try:
|
|
with open(A.seed_from) as f:
|
|
data = json.load(f)
|
|
except Exception:
|
|
data = {}
|
|
app = data.setdefault("app", {})
|
|
app["enable_cad_feature"] = True
|
|
# Deterministic starting state for the rungs that follow: the bed drawn, loops welded as the
|
|
# ~90% case expects. A ladder whose result depends on the developer's own preferences is not
|
|
# a gate.
|
|
app["auto_close_sketch_loops"] = True
|
|
# SILENCE THE NETWORK PLUGIN PROMPT. Without this, GUI_App::post_init() re-raises "Bambu
|
|
# Network Plug-in Required" from an IDLE event — after any modal sweep this driver does at
|
|
# startup — and ShowModal() then runs a nested event loop. The app is alive, its window is
|
|
# there, and the MCP socket answers nothing: indistinguishable from a hang, and it was
|
|
# investigated as one, with gdb, twice. `installed_networking` false stops the whole
|
|
# networking-plugin path, so m_networking_need_update is never set and the dialog never
|
|
# exists to be swept.
|
|
app["installed_networking"] = False
|
|
with open(conf, "w") as f:
|
|
json.dump(data, f, indent=1)
|
|
for sub in ("user", "system", "presets", "vendor"):
|
|
src = os.path.join(os.path.dirname(A.seed_from), sub)
|
|
dst = os.path.join(datadir, sub)
|
|
if os.path.isdir(src) and not os.path.exists(dst):
|
|
shutil.copytree(src, dst)
|
|
|
|
|
|
def launch():
|
|
global _proc
|
|
datadir = A.datadir or os.path.join(tempfile.gettempdir(), "orcacad-uxcheck")
|
|
seed_datadir(datadir)
|
|
env = dict(os.environ)
|
|
# WAYLAND_DISPLAY MUST GO, and GDK_BACKEND must say x11. GTK prefers Wayland whenever
|
|
# WAYLAND_DISPLAY is set and ignores DISPLAY entirely, so a driver launched from a systemd
|
|
# user unit (which inherits it) started the app on the DESKTOP session instead of the rig:
|
|
# the process was alive, `xdotool search` on the rig display found nothing, and the window
|
|
# was sitting on the user's own screen. Silent, and it drives a stray app at someone's face.
|
|
env.pop("WAYLAND_DISPLAY", None)
|
|
env.update(DISPLAY=DISP, GDK_BACKEND="x11", ORCA_CAD_UXTRACE="1",
|
|
LIBGL_ALWAYS_SOFTWARE="1", GALLIUM_DRIVER="llvmpipe",
|
|
# The rig's Xvfb has no input-method daemon, and a dead ibus context makes a
|
|
# GtkEntry drop every character while the app looks fine. It cannot affect the
|
|
# in-canvas field (ImGui needs no IM) but the app has other text fields, and a
|
|
# display full of IBUS warnings has cost a whole misdiagnosis before.
|
|
GTK_IM_MODULE="gtk-im-context-simple", XMODIFIERS="@im=none",
|
|
# The key tracer is this driver's only positive signal that a keystroke reached
|
|
# the Design panel at all. Without it "the field never opened" is indistinguishable
|
|
# from "we never got into sketch mode", and the first run of this ladder reported
|
|
# seven product failures that were really one driver racing a still-loading app.
|
|
ORCA_CAD_KEYTRACE="1", ORCA_CAD_MCP=A.sock,
|
|
SSL_CERT_FILE="/etc/ssl/certs/ca-certificates.crt",
|
|
WEBKIT_DISABLE_DMABUF_RENDERER="1", WEBKIT_DISABLE_COMPOSITING_MODE="1")
|
|
if os.path.exists(A.sock):
|
|
os.unlink(A.sock) # a stale socket from a dead run answers nothing, slowly
|
|
log = open(TRACE, "wb")
|
|
_proc = subprocess.Popen([A.bin, "--datadir", datadir], env=env,
|
|
stdout=subprocess.DEVNULL, stderr=log)
|
|
for _ in range(120):
|
|
if win_id():
|
|
return
|
|
time.sleep(1)
|
|
die("the app never showed a window on " + DISP)
|
|
|
|
|
|
def window_pid(w):
|
|
"""_NET_WM_PID for a window, or 0. The property is how we tell a live app from its ghost."""
|
|
out = sh(f"DISPLAY={DISP} xprop -id {w} _NET_WM_PID 2>/dev/null")
|
|
m = re.search(r"= *(\d+)", out)
|
|
return int(m.group(1)) if m else 0
|
|
|
|
|
|
def pid_alive(pid):
|
|
return pid > 0 and os.path.isdir(f"/proc/{pid}")
|
|
|
|
|
|
def win_id():
|
|
"""The main window: OURS if we launched it, otherwise the biggest LIVE top-level.
|
|
|
|
Two rules here, each paid for.
|
|
|
|
By PID, not by size, whenever we launched the app. An X window outlives its client if the
|
|
connection is not torn down cleanly, and a killed OrcaSlicer can leave a full-screen ghost
|
|
mapped on the display. It answers geometry queries exactly like the real thing, it wins "the
|
|
biggest window" every time, and every synthetic keystroke sent to it goes nowhere. That is
|
|
indistinguishable, from the driver's side, from an app that ignores the keyboard — which is
|
|
the very defect this ladder exists to measure. One run reported the entire contract broken
|
|
while the real app sat beside the ghost, untouched.
|
|
|
|
Never by title: a saved project renames the main window.
|
|
"""
|
|
if _proc is not None:
|
|
for w in sh(f"DISPLAY={DISP} xdotool search --pid {_proc.pid} --onlyvisible --name '.'").split():
|
|
g = dict(l.split("=", 1) for l in
|
|
sh(f"DISPLAY={DISP} xdotool getwindowgeometry --shell {w}").strip().splitlines()
|
|
if "=" in l)
|
|
if "WIDTH" in g and int(g["WIDTH"]) * int(g["HEIGHT"]) > 400 * 400:
|
|
return (w, int(g["X"]), int(g["Y"]), int(g["WIDTH"]), int(g["HEIGHT"]))
|
|
return None
|
|
best = None
|
|
for w in sh(f"DISPLAY={DISP} xdotool search --onlyvisible --name '.'").split():
|
|
g = dict(l.split("=", 1) for l in
|
|
sh(f"DISPLAY={DISP} xdotool getwindowgeometry --shell {w}").strip().splitlines()
|
|
if "=" in l)
|
|
if "WIDTH" not in g:
|
|
continue
|
|
if not pid_alive(window_pid(w)): # a ghost: no client is behind it any more
|
|
continue
|
|
a = int(g["WIDTH"]) * int(g["HEIGHT"])
|
|
if a > 400 * 400 and (best is None or a > best[0]):
|
|
best = (a, w, int(g["X"]), int(g["Y"]), int(g["WIDTH"]), int(g["HEIGHT"]))
|
|
return best[1:] if best else None
|
|
|
|
|
|
_win = None
|
|
|
|
|
|
def win():
|
|
global _win
|
|
if _win is None:
|
|
w = win_id()
|
|
if w is None:
|
|
die("no app window on " + DISP)
|
|
sh(f"DISPLAY={DISP} xdotool windowactivate --sync {w[0]}")
|
|
sh(f"DISPLAY={DISP} xdotool windowsize {w[0]} 1920 1080")
|
|
sh(f"DISPLAY={DISP} xdotool windowmove {w[0]} 0 0")
|
|
time.sleep(1.0)
|
|
_win = (w[0], 0, 0, 1920, 1080)
|
|
return _win
|
|
|
|
|
|
def click(px, py, pause=0.5, btn=1):
|
|
_, X, Y, _, _ = win()
|
|
xdo(f"mousemove {X+int(px)} {Y+int(py)} click --delay 120 {btn}")
|
|
time.sleep(pause)
|
|
|
|
|
|
def visible_windows():
|
|
"""(id, name, w, h) for every MAPPED top-level, main window included.
|
|
|
|
`--onlyvisible` is what makes this usable. Without it xdotool also returns the app's unmapped
|
|
helper windows — a 10x10 and a 200x200 that exist for the whole session — and a caller that
|
|
tries to reason about "extra windows" from that list is reasoning about furniture.
|
|
"""
|
|
out = []
|
|
for w in sh(f"DISPLAY={DISP} xdotool search --onlyvisible --name '.'").split():
|
|
g = dict(l.split("=", 1) for l in
|
|
sh(f"DISPLAY={DISP} xdotool getwindowgeometry --shell {w}").strip().splitlines()
|
|
if "=" in l)
|
|
if "WIDTH" not in g:
|
|
continue
|
|
if not pid_alive(window_pid(w)): # see win_id(): a ghost cannot be closed, only ignored
|
|
continue
|
|
n = sh(f"DISPLAY={DISP} xdotool getwindowname {w}").strip()
|
|
out.append((w, n, int(g["WIDTH"]), int(g["HEIGHT"])))
|
|
return out
|
|
|
|
|
|
def dismiss_modals(timeout=30):
|
|
"""Close every modal over the main window, and PROVE none is left.
|
|
|
|
This is the rung that decides whether any of the others mean anything. A fresh datadir opens
|
|
"Bambu Network Plug-in Required" — 440x259, centred at 742,450 — which sits exactly on top of
|
|
the point every drawing gesture in TOOLS starts from. The whole ladder then reports eleven
|
|
product failures, all of them the driver clicking a dialog.
|
|
|
|
The old version pressed Escape and moved on. This dialog ignores Escape, so it "dismissed"
|
|
nothing and said so to no one; the run that found this was red for a reason that had nothing
|
|
to do with the contract under test. Escape is still tried first because it is the gentlest
|
|
thing that works on the wizard, then WM_DELETE_WINDOW, and then the function asserts what it
|
|
was supposed to have achieved instead of assuming it.
|
|
"""
|
|
# NEVER run without knowing which window to spare. The first version took `keep = main[0] if
|
|
# main else None`, so a win_id() that raced the app's mapping made keep None and every visible
|
|
# window a modal — this function then sent WM_DELETE to the app's own main window. The app
|
|
# survived as a process, printed "GdkWindow unexpectedly destroyed", and answered nothing
|
|
# afterwards; the ladder reported "no sketch opened" for 180s. Losing the main window is not a
|
|
# state to recover from silently.
|
|
deadline = time.time() + timeout
|
|
keep = None
|
|
while keep is None and time.time() < deadline:
|
|
main = win_id()
|
|
keep = main[0] if main else None
|
|
if keep is None:
|
|
time.sleep(0.5)
|
|
if keep is None:
|
|
die(f"no main window to protect after {timeout}s — refusing to close anything")
|
|
scr = sh(f"DISPLAY={DISP} xdotool getdisplaygeometry").split()
|
|
full = int(scr[0]) * int(scr[1]) if len(scr) == 2 else 1920 * 1080
|
|
while time.time() < deadline:
|
|
# A modal is small. Anything covering half the screen is the app, whatever id win_id()
|
|
# happened to return this instant — a second belt on the rule above, because the cost of
|
|
# being wrong here is an app that looks alive and answers nothing.
|
|
extra = [x for x in visible_windows() if x[0] != keep and x[2] * x[3] < full * 0.5]
|
|
if not extra:
|
|
return
|
|
for (w, n, _, _) in extra:
|
|
sh(f"DISPLAY={DISP} xdotool windowactivate {w}")
|
|
time.sleep(0.4)
|
|
key("Escape", 0.4)
|
|
if any(x[0] == w for x in visible_windows()):
|
|
sh(f"DISPLAY={DISP} xdotool windowclose {w}")
|
|
time.sleep(0.6)
|
|
time.sleep(0.5)
|
|
left = [f"{n!r} ({w}x{h})" for (i, n, w, h) in visible_windows()
|
|
if i != keep and w * h < full * 0.5]
|
|
die("a modal is still covering the canvas after " + str(timeout) + "s: " + ", ".join(left) +
|
|
" — every drawing gesture would land in it, so nothing below this line could be trusted")
|
|
|
|
|
|
def dismiss_first_run():
|
|
dismiss_modals()
|
|
|
|
|
|
# ---------------------------------------------------------------- the trace
|
|
|
|
def trace_lines():
|
|
try:
|
|
with open(TRACE, "r", errors="replace") as f:
|
|
return [l.strip() for l in f if l.startswith("[UX] ")]
|
|
except OSError:
|
|
return []
|
|
|
|
|
|
def trace_mark():
|
|
return len(trace_lines())
|
|
|
|
|
|
def parse(line):
|
|
m = re.match(r"\[UX\] (\w+) title=(.*?) (.*)$", line)
|
|
if not m:
|
|
return None
|
|
ev, title, rest = m.group(1), m.group(2), m.group(3)
|
|
kv = dict(re.findall(r"(\w+)=(\S*)", rest))
|
|
return ev, title, kv
|
|
|
|
|
|
# ---------------------------------------------------------------- grading
|
|
|
|
def check(cond, what):
|
|
"""Returns the verdict so a caller can abandon a rung whose precondition failed."""
|
|
global _fail, _checks
|
|
_checks += 1
|
|
if cond:
|
|
print(f" ok {what}")
|
|
else:
|
|
print(f" FAIL {what}", file=sys.stderr)
|
|
_fail += 1
|
|
return bool(cond)
|
|
|
|
|
|
def type_into_open_field(value, mark):
|
|
"""Type `value` into whatever field is open, WITHOUT clicking it first, and grade the pair.
|
|
|
|
No click: the click is the workaround this ladder exists to refuse. If the field cannot take
|
|
the keyboard on its own, `typed` will be the prefill and this fails — which is the report.
|
|
"""
|
|
# POLL for the field. It opens from a CallAfter that runs after a re-solve, so on llvmpipe it
|
|
# is simply not there yet when a fast driver looks — and "no field opened" is the same message
|
|
# whether the product never opened one or the driver asked too early. Wait, then decide.
|
|
opens = []
|
|
deadline = time.time() + 8.0
|
|
while time.time() < deadline:
|
|
opens = [e for e in (parse(l) for l in trace_lines()[mark:]) if e and e[0] == "open"]
|
|
if opens:
|
|
break
|
|
time.sleep(0.25)
|
|
if not opens:
|
|
check(False, f"a value field opened (nothing did; cannot type {value})")
|
|
return mark
|
|
title = opens[-1][1]
|
|
prefill = opens[-1][2].get("prefill", "")
|
|
m2 = trace_mark()
|
|
# TYPE NORMALLY. NOTHING TO DEFOCUS ANY MORE.
|
|
#
|
|
# The value field is drawn INSIDE the GL canvas by ImGui, so it is not a window: there is no
|
|
# second toplevel for a window manager to grant or refuse the keyboard, and the keystrokes go
|
|
# to the app's one window exactly as a person's would. That is the entire point of the design
|
|
# — the WM has no say — and it is why this ladder no longer tries to manufacture the failing
|
|
# condition.
|
|
#
|
|
# When the field WAS a floating wxFrame, this spot held two attempts to reproduce
|
|
# "field open, keyboard elsewhere", and both are recorded here so neither is tried again:
|
|
# - XSetInputFocus onto the main window (`xdotool windowfocus`): the field's own re-focus
|
|
# CallAfter wins the race every time; four retries all lost, and the ladder passed twice
|
|
# against a binary with the fix compiled out.
|
|
# - XSendEvent at the main window (`xdotool type --window`): GTK discards synthetic key
|
|
# events, so NEITHER build received anything and every run was red regardless of the code.
|
|
# A run that used the second of those is what produced "the app never saw a digit" — a
|
|
# property of xdotool, not of the product.
|
|
#
|
|
# For the in-canvas field the honest gate is simply: type, and see whether the value the app
|
|
# commits is the value that was typed.
|
|
diag = sh(f"DISPLAY={DISP} xdotool getwindowfocus").strip()
|
|
typ(str(value), 0.4)
|
|
key("Return", 0.9)
|
|
after, commits, refused, commit_at = [], [], [], None
|
|
deadline = time.time() + 5.0
|
|
while time.time() < deadline:
|
|
after = [parse(l) for l in trace_lines()[m2:]]
|
|
commits = [(i, e) for i, e in enumerate(after) if e and e[0] == "commit"]
|
|
refused = [e for e in after if e and e[0] == "refused"]
|
|
if commits:
|
|
commit_at = m2 + commits[-1][0]
|
|
commits = [e for _, e in commits]
|
|
if commits or refused:
|
|
break
|
|
time.sleep(0.25)
|
|
if refused and not commits:
|
|
check(False, f"{title}: field REFUSED {value!r} (typed={refused[-1][2].get('typed')!r})")
|
|
key("Escape", 0.5)
|
|
return trace_mark()
|
|
if not commits:
|
|
check(False, f"{title}: typed {value} but nothing committed — the field took no keys")
|
|
key("Escape", 0.5)
|
|
return trace_mark()
|
|
typed = commits[-1][2].get("typed", "")
|
|
got = commits[-1][2].get("value", "")
|
|
check(typed == str(value),
|
|
f"{title}: field received what was typed (typed={typed!r} wanted={value!r}"
|
|
f"{' <-- it committed its PREFILL, so it never got the keyboard' if typed == prefill else ''})")
|
|
# A value that will not parse is a FAILED CHECK, never an exception. An unguarded float()
|
|
# here met a locale-formatted "61,0000" and took the whole run down immediately after the
|
|
# first check in the ladder's history had passed — the seven rungs below it were never tried
|
|
# and the report read as a total failure.
|
|
try:
|
|
ok_val = abs(float(got) - float(value)) < 1e-6
|
|
except (TypeError, ValueError):
|
|
ok_val = False
|
|
check(ok_val, f"{title}: committed value is {got!r} (wanted {value})")
|
|
check(str(value) != prefill, f"{title}: the test value differs from the prefill {prefill!r}")
|
|
# RESUME JUST AFTER THE COMMIT, not at the end of the trace. A queued chain opens its next
|
|
# field from the commit callback, so by the time trace_mark() is read here that "open" line
|
|
# is already written — and the next call, searching only after this mark, never sees it. The
|
|
# rectangle's Height, the slot's Radius and the label reopen all failed as "nothing did"
|
|
# while the trace plainly showed the field open and waiting.
|
|
return (commit_at + 1) if commit_at is not None else trace_mark()
|
|
|
|
|
|
# ---------------------------------------------------------------- the ladder
|
|
|
|
def enter_sketch(timeout=180):
|
|
"""Open a real sketch on a real plane, and PROVE it with the socket before drawing anything.
|
|
|
|
THE SEQUENCE MATTERS AND IT IS NOT OBVIOUS. Shift+S enters sketch MODE and pops the plane
|
|
offer; the offer must be dismissed; and the plane itself is chosen by clicking it in the
|
|
viewport BEFORE Shift+S. check-gui-sketching.py has always done all four steps. This ladder
|
|
did two of them — Design tab, then Shift+S — and went straight to the tool letters.
|
|
|
|
That intermediate state is the trap. `is_sketching` reads 1, every tool key is accepted and
|
|
traced, and not one click draws anything, because there is no plane under them. The ladder
|
|
then reports eleven product failures, all of them "a value field opened (nothing did)", and
|
|
every one is the driver's. Two whole runs were spent on it.
|
|
|
|
So the gate is the ORACLE, not the keytrace: sketch_describe answers only when a sketch is
|
|
genuinely open. Waiting on a mode flag is what allowed the wrong state to pass for the right
|
|
one in the first place.
|
|
"""
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
click(132, 53) # Design tab
|
|
time.sleep(2.0)
|
|
dismiss_modals()
|
|
click(*PLANE_PX) # pick the plane IN THE VIEWPORT — before Shift+S
|
|
key("shift+s", 1.0)
|
|
key("Escape", 0.5) # entering sketch mode pops the offer; dismiss it
|
|
key("p", 0.6) # any sketch tool starts the session on that plane
|
|
if try_call("sketch_describe") is not None:
|
|
# NO Escape here. Every rung already opens with one to drop whatever tool the last
|
|
# one left armed, and Escape in the Design tab walks a LIFO: first press drops the
|
|
# armed tool, second LEAVES THE SKETCH. Pressing it here made that second press the
|
|
# rung's own, so the ladder exited the sketch before drawing anything and then
|
|
# reported all eleven checks failed with "nothing opened" — the tools were arming
|
|
# into an empty Feature-mode document.
|
|
return
|
|
die("no sketch opened after plane click + Shift+S within "
|
|
f"{timeout}s — sketch_describe never answered on {A.sock} (trace {TRACE})")
|
|
|
|
|
|
# tool key, the clicks that draw it, and one distinct value per queued field. The values are
|
|
# deliberately nothing like the as-drawn size, so a committed prefill cannot coincide with them.
|
|
# Where the plane label sits in the viewport before a sketch is open. Same constant the gesture
|
|
# ladder uses; it is a label on the 3D view, not a widget, so it moves only if the camera does.
|
|
PLANE_PX = (913, 359)
|
|
|
|
# Every coordinate below stays inside 1000..1400 x 500..760 — the box check-gui-sketching.py's
|
|
# calibration probes land four Points in, i.e. the region PROVEN to be live canvas on a 1920x1080
|
|
# window. Earlier values started at x=950, which is left of that box and also, on a fresh datadir,
|
|
# underneath the "Bambu Network Plug-in Required" modal.
|
|
TOOLS = [
|
|
("L", "Line", [(1030, 540), (1360, 540)], [61]),
|
|
("R", "Rectangle", [(1030, 540), (1360, 730)], [62, 43]),
|
|
("C", "Circle", [(1180, 620), (1330, 620)], [64]),
|
|
("S", "Slot", [(1030, 580), (1300, 580), (1300, 640)], [66]),
|
|
("G", "Polygon", [(1180, 620), (1320, 620)], [67]),
|
|
("E", "Ellipse", [(1180, 620), (1370, 620), (1180, 720)], [68]),
|
|
("A", "Arc", [(1040, 660), (1340, 660), (1190, 560)], [69]),
|
|
]
|
|
|
|
|
|
def rung_tool(k, name, clicks, values):
|
|
print(f" {name}")
|
|
key("Escape", 0.6) # back to Select, whatever the last tool left armed
|
|
# Every rung re-establishes that a sketch is STILL open. One stray Escape too many leaves it,
|
|
# and from then on every tool arms into a Feature-mode document that cannot open a value
|
|
# field — which the checks below report as eleven independent product failures.
|
|
if try_call("sketch_describe") is None:
|
|
die(f"{name}: the sketch is no longer open before this rung — an earlier rung left it")
|
|
key(k, 0.8)
|
|
# MARK BEFORE THE CLICKS, not after. The field is opened from a CallAfter scheduled by the
|
|
# render that follows the last click, so it can already be open by the time a mark taken
|
|
# afterwards is read — and type_into_open_field, which only looks at events AFTER its mark,
|
|
# then finds none and reports "a value field opened (nothing did)" for a field that is on
|
|
# screen, open, and waiting. That message accused the product of the exact defect the ladder
|
|
# exists to detect, from a bug in the ladder's own bookkeeping.
|
|
mark = trace_mark()
|
|
for (x, y) in clicks:
|
|
click(x, y)
|
|
for v in values:
|
|
mark = type_into_open_field(v, mark)
|
|
|
|
|
|
def rung_rounded_rect():
|
|
"""The shape the user actually reported: a ROUNDED rectangle, Width -> Height -> Radius.
|
|
|
|
It has no keyboard shortcut — the rectangle family binds R to CornerRect and leaves the other
|
|
modes in the toolbar flyout — so TOOLS above cannot reach it and the whole three-step chain
|
|
went untested. `run_verb` arms it the way the offer menu does.
|
|
|
|
NOTE the id: the OFFER verb is `sk_rect_rounded`; `design_rect_rounded` is the ACTION name and
|
|
run_verb throws on it, leaving the tool as Select. A run that misses that draws nothing and
|
|
still reaches its assertions, so arm-and-verify rather than arm-and-hope.
|
|
"""
|
|
print(" Rounded rectangle")
|
|
key("Escape", 0.6)
|
|
tool = None
|
|
for _ in range(8):
|
|
try_call("run_verb", verb="sk_rect_rounded")
|
|
time.sleep(0.8)
|
|
tool = (try_call("sketch_describe") or {}).get("tool")
|
|
if tool == "rect_rounded":
|
|
break
|
|
if not check(tool == "rect_rounded", f"the rounded-rectangle tool armed (tool={tool!r})"):
|
|
return
|
|
mark = trace_mark()
|
|
click(1030, 540); click(1330, 700); click(1300, 660) # corners, then the radius point
|
|
for v in (63, 41, 7):
|
|
mark = type_into_open_field(v, mark)
|
|
|
|
|
|
def rung_label_click():
|
|
"""The user's own report: click an existing dimension label and type a new value into it.
|
|
|
|
KEEP THE SHAPE AS DRAWN. An earlier version committed 55 and 47 into the queued chain first,
|
|
which resized the rectangle — and then clicked the pixel where the label had been before the
|
|
resize. It missed, every time, and reported the reopen broken. The shape's on-screen position
|
|
is only predictable if nothing has moved it, so Escape the chain instead: the rectangle stays
|
|
exactly between the two corners we clicked.
|
|
|
|
FIND THE LABEL, do not assume its offset. A dimension label is drawn beside its edge at an
|
|
offset that depends on zoom and text metrics, so a single hardcoded pixel is a guess that
|
|
silently becomes wrong. Walk a short band across the top edge instead and stop at the first
|
|
click that opens a field; if none of them does, that is a real failure and it says so.
|
|
"""
|
|
print(" label click-to-edit")
|
|
key("Escape", 0.6) # Select mode
|
|
key("R", 0.8)
|
|
click(1020, 530)
|
|
click(1350, 740)
|
|
time.sleep(1.5)
|
|
key("Escape", 0.8) # keep as drawn: abandon the queued value chain
|
|
time.sleep(0.8)
|
|
key("Escape", 0.6) # back to Select so a click picks rather than draws
|
|
|
|
mid_x, top_y = (1020 + 1350) // 2, 530
|
|
candidates = [(mid_x, top_y + dy) for dy in (-26, -20, -14, -8, 0, 8, 14)]
|
|
for (cx, cy) in candidates:
|
|
mark = trace_mark()
|
|
click(cx, cy)
|
|
deadline = time.time() + 2.0
|
|
while time.time() < deadline:
|
|
if [e for e in (parse(l) for l in trace_lines()[mark:]) if e and e[0] == "open"]:
|
|
check(True, f"clicking a dimension label reopened its value field (at {cx},{cy})")
|
|
type_into_open_field(71, mark)
|
|
return
|
|
time.sleep(0.2)
|
|
check(False, "clicking a dimension label reopened its value field "
|
|
f"(tried {len(candidates)} points across the top edge at x={mid_x})")
|
|
|
|
|
|
def main():
|
|
if not A.attach:
|
|
if not os.path.exists(A.bin):
|
|
die(f"no binary at {A.bin}")
|
|
open(TRACE, "w").close()
|
|
launch()
|
|
dismiss_first_run()
|
|
win()
|
|
print(f"click-edit ladder on {DISP}, trace {TRACE}")
|
|
enter_sketch()
|
|
for (k, name, clicks, values) in TOOLS:
|
|
rung_tool(k, name, clicks, values)
|
|
rung_rounded_rect()
|
|
rung_label_click()
|
|
print()
|
|
if _fail:
|
|
print(f"CLICK-EDIT LADDER FAILED — {_fail} of {_checks} checks", file=sys.stderr)
|
|
else:
|
|
print(f"CLICK-EDIT LADDER HELD — {_checks} checks")
|
|
if _proc is not None and not A.keep:
|
|
_proc.send_signal(signal.SIGTERM)
|
|
try:
|
|
_proc.wait(20)
|
|
except subprocess.TimeoutExpired:
|
|
_proc.kill()
|
|
return 1 if _fail else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|