Files
ttree/apps/windows/tests/repaint-regression.py
T

258 lines
9.4 KiB
Python

"""Reproduce hover flicker and scroll ghosts using naturally presented screen pixels.
Run on an unlocked Windows desktop, for example:
python apps/windows/tests/repaint-regression.py --exe target/release/ttree-windows.exe --artifacts target/repaint-regression
Starts and closes only its own TTREE process. It changes unsaved controls, never
saves settings, and verifies the original settings file remains byte-for-byte intact.
The natural screen capture is taken before any forced redraw; a static screenshot
alone cannot detect the stale pixels this regression originally produced.
"""
import os, json, time, traceback, ctypes, ctypes.wintypes as wt, hashlib, subprocess
from pathlib import Path
from PIL import ImageGrab, ImageChops
from pywinauto import Application, mouse
from pywinauto.timings import wait_until
import argparse
parser = argparse.ArgumentParser(
description="Windows native repaint regression; requires an interactive desktop, pywinauto and Pillow."
)
parser.add_argument(
"--exe", required=True, type=Path, help="Native TTREE executable to test"
)
parser.add_argument(
"--artifacts",
required=True,
type=Path,
help="Directory for screen samples and JSON result",
)
args = parser.parse_args()
exe = args.exe.resolve()
out = args.artifacts.resolve()
out.mkdir(exist_ok=True, parents=True)
report = {"checks": []}
proc = None
win = None
root_hwnd = None
u = ctypes.windll.user32
dwm = ctypes.windll.dwmapi
u.SetProcessDpiAwarenessContext(ctypes.c_void_p(-4))
u.SendMessageW.argtypes = [wt.HWND, wt.UINT, wt.WPARAM, wt.LPARAM]
u.SendMessageW.restype = ctypes.c_ssize_t
class RECT(ctypes.Structure):
_fields_ = [
("left", wt.LONG),
("top", wt.LONG),
("right", wt.LONG),
("bottom", wt.LONG),
]
class SCROLLINFO(ctypes.Structure):
_fields_ = [
("size", wt.UINT),
("mask", wt.UINT),
("min", ctypes.c_int),
("max", ctypes.c_int),
("page", wt.UINT),
("pos", ctypes.c_int),
("track", ctypes.c_int),
]
class SCROLLBARINFO(ctypes.Structure):
_fields_ = [
("size", wt.DWORD),
("rect", RECT),
("line", ctypes.c_int),
("top", ctypes.c_int),
("bottom", ctypes.c_int),
("reserved", ctypes.c_int),
("states", wt.DWORD * 6),
]
profile = Path(os.environ["APPDATA"]) / "com.samwstu.ttree/settings.json"
before = profile.read_bytes() if profile.exists() else None
try:
proc = subprocess.Popen(
[str(exe)],
stdout=(out / "stdout.log").open("w"),
stderr=(out / "stderr.log").open("w"),
)
app = Application(backend="win32").connect(process=proc.pid, timeout=15)
win = app.window(class_name="TTreePureRustWin32MainClass", visible_only=False)
win.wait("exists", timeout=10)
root_hwnd = win.handle
for _ in range(6):
assert not u.IsWindowVisible(win.handle)
time.sleep(0.05)
report["checks"].append("startup_layout_keeps_hidden_window_hidden")
u.SendMessageW(win.handle, 0x111, 901, 0)
win.wait("visible enabled ready", timeout=10)
u.SendMessageW(win.handle, 0x111, 105, 0)
win.set_focus()
def c(i):
return win.child_window(control_id=i, visible_only=False)
def shot():
dwm.DwmFlush()
r = win.rectangle()
return ImageGrab.grab(bbox=(r.left, r.top, r.right, r.bottom))
hover = {}
for ident in [104, 105, 103, 507]:
r = c(ident).rectangle()
u.SetCursorPos(r.left + 8, r.top + 8)
time.sleep(0.08)
frames = []
for i in range(60):
u.SetCursorPos(r.left + 8 + i % 12, r.top + 8 + i % 5)
f = ImageGrab.grab(bbox=(r.left, r.top, r.right, r.bottom))
frames.append(f)
hashes = {hashlib.sha256(f.tobytes()).hexdigest() for f in frames}
hover[str(ident)] = len(hashes)
frames[-1].save(out / f"hover-{ident}.png")
assert len(hashes) == 1, (
f"button {ident} changed while pointer stayed inside: {len(hashes)} frames"
)
report["hover_distinct_frames"] = hover
report["checks"].append("240_hover_frames_remain_stable_inside_each_button")
c(103).click_input()
c(503).click_input()
win.move_window(width=480, height=440)
for i, ident in enumerate([417, 418, 419, 420, 421]):
c(ident).set_edit_text("Ctrl+Shift+" + str(i + 1))
view = c(508)
vr = view.rectangle()
u.SetCursorPos(vr.right - 28, vr.top + 35)
time.sleep(0.1)
def scrollinfo():
s = SCROLLINFO()
s.size = ctypes.sizeof(s)
s.mask = 0x17
assert u.GetScrollInfo(view.handle, 1, ctypes.byref(s))
return s
def canonical_difference():
natural = shot()
u.RedrawWindow(win.handle, None, None, 0x585)
reference = shot()
diff = ImageChops.difference(natural, reference)
return sum(1 for p in diff.getdata() if max(p[:3]) > 8), natural, reference
diffs = []
frames = []
for cycle in range(8):
for i in range(12):
delta = -120 if cycle % 2 == 0 else 120
u.SendMessageW(view.handle, 0x20A, (delta & 0xFFFF) << 16, 0)
if i in [0, 5, 11]:
frames.append(shot())
changed, natural, reference = canonical_difference()
diffs.append(changed)
if changed:
natural.save(out / f"cycle-{cycle}-natural.png")
reference.save(out / f"cycle-{cycle}-reference.png")
assert max(diffs) == 0, f"scroll left stale pixels: {diffs}"
report["scroll_pixels_changed_by_forced_redraw"] = diffs
report["checks"].append("96_wheel_steps_leave_no_stale_pixels")
# A wheel event over a normal field must move the settings viewport, retaining its value.
u.SendMessageW(view.handle, 0x115, 6, 0)
start = scrollinfo().pos
field = c(417)
text = field.window_text()
u.SendMessageW(field.handle, 0x20A, (0xFF88 << 16), 0)
assert scrollinfo().pos > start and field.window_text() == text
report["checks"].append("wheel_over_field_scrolls_page_without_changing_value")
# Drag the actual native scrollbar thumb, exercising WM_VSCROLL/SIF_TRACKPOS.
u.SendMessageW(view.handle, 0x115, 6, 0)
bar = SCROLLBARINFO()
bar.size = ctypes.sizeof(bar)
assert u.GetScrollBarInfo(view.handle, ctypes.c_long(-5), ctypes.byref(bar))
x = (bar.rect.left + bar.rect.right) // 2
y = bar.rect.top + (bar.top + bar.bottom) // 2
target = min(bar.rect.bottom - bar.line - 4, y + 90)
mouse.press(coords=(x, y))
for i in range(1, 11):
u.SetCursorPos(x, y + (target - y) * i // 10)
time.sleep(0.01)
mouse.release(coords=(x, target))
assert scrollinfo().pos > 0
changed, natural, reference = canonical_difference()
report["thumb_drag_stale_pixels"] = changed
natural.save(out / "thumb-drag.png")
assert changed == 0
report["checks"].append("native_thumb_drag_keeps_scene_and_fields_aligned")
# Moving between pages with and without scrollbars must not retain old client widths.
for _ in range(5):
c(504).click_input()
assert c(423).is_visible()
c(503).click_input()
assert c(417).is_visible()
changed, natural, reference = canonical_difference()
report["section_switch_stale_pixels"] = changed
assert changed == 0
report["checks"].append(
"section_switch_and_scrollbar_visibility_do_not_leave_ghosts"
)
# Resize through narrow/wide row breakpoints while scrolled.
for width in [380, 640, 500, 480]:
win.move_window(width=width, height=440)
u.SendMessageW(view.handle, 0x115, 7, 0)
changed, _, _ = canonical_difference()
assert changed == 0
report["checks"].append("responsive_resize_while_scrolled_remains_aligned")
# Exercise a real dropdown list; buffered owner draw must preserve popup item coordinates.
c(504).click_input()
combo = c(423)
combo.select(2)
u.SendMessageW(win.handle, 0x111, (1 << 16) | 423, combo.handle)
assert combo.selected_index() == 2
report["checks"].append("native_dropdown_selection_still_works")
u.SendMessageW(view.handle, 0x115, 6, 0)
shot().save(out / "settings.png")
c(505).click_input()
u.SendMessageW(win.handle, 0x111, 507, 0)
assert not u.IsWindowVisible(win.handle)
# Hidden relayout triggered by a settings command must not display the app unexpectedly.
u.SendMessageW(win.handle, 0x111, 904, 0)
win.wait("visible", timeout=10)
c(504).click_input()
shot().save(out / "final.png")
report["checks"].append("hide_and_reopen_still_work_after_batched_layout")
report["profile_unchanged"] = (
profile.read_bytes() if profile.exists() else None
) == before
assert report["profile_unchanged"]
report["ok"] = True
except Exception:
report["ok"] = False
report["error"] = traceback.format_exc()
if win:
try:
shot().save(out / "failure.png")
except Exception:
pass
finally:
if proc and proc.poll() is None:
if root_hwnd:
u.PostMessageW(root_hwnd, 0x111, 905, 0)
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.terminate()
proc.wait(timeout=5)
report["owned_process_exited"] = proc is None or proc.poll() is not None
(out / "report.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
)
raise SystemExit(0 if report.get("ok") else 1)