#!/usr/bin/env python3 """Checkpoint К2 on the test stand (LINUX_PLAN.md §3): the player window and the system integration, end to end. tools/linux/k2.py runs it; PASS/FAIL per check, screenshots in $STAND/shots/k2-*.png Everything happens on the stand's display :87 and its own D-Bus (stand.sh): the user's screen, focus and desktop are not touched; the video is muted, the app runs niced. The player's state is read back from MPRIS (status, position, volume, title), the windows from X11 (_NET_WM_STATE, geometry, frame), the screensaver from fake_screensaver.py. The player settings start from scratch (first launch) and are put back afterwards, as is the test account's watch data. """ import os, re, shutil, subprocess, sys, time from Xlib import X, XK, display from Xlib.ext import xtest from PIL import Image R = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) STAND = os.environ.get("STAND") or os.path.expanduser("~/.cache/sleepyowl-test") STAND_SH = os.path.join(R, "tools/linux/stand.sh") PREFS = os.path.join(STAND, "config/sleepyowl") LOG = os.path.join(STAND, "state/sleepyowl/log/sleepyowl.log") E2, E3, E4 = "00000000000000000000000000000000", "00000000000000000000000000000000", "00000000000000000000000000000000" results = [] def sh(*args, env=None, check=True): return subprocess.run(args, env=env, check=check, capture_output=True, text=True).stdout def check(name, ok, detail=""): results.append((name, bool(ok))) print(f"{'PASS' if ok else 'FAIL'} {name}" + (f" — {detail}" if detail else ""), flush=True) def near(a, b, tol): return abs(a - b) <= tol def same_place(p, place): # ±2 px: the window manager rounds the size to the video's proportions (874×491.6 → 491 or 492). return all(near(v, w, 2) for v, w in zip((p["x"], p["y"], p["w"], p["h"]), place)) # --- Stand --- sh(STAND_SH, "up") d = display.Display(":87") root = d.screen().root BUS = dict(os.environ, DBUS_SESSION_BUS_ADDRESS=open(os.path.join(STAND, "run/dbus.addr")).read().strip()) def atom(name): return d.intern_atom(name) def prop(w, name, kind=X.AnyPropertyType): p = w.get_full_property(atom(name), kind) return p.value if p else None def windows(): """The app's windows in mapping order (the library first): name, states, client geometry on the screen, frame.""" out = [] for wid in prop(root, "_NET_CLIENT_LIST") or []: w = d.create_resource_object("window", wid) try: name = prop(w, "_NET_WM_NAME", atom("UTF8_STRING")) name = name.decode() if isinstance(name, bytes) else (w.get_wm_name() or "") states = [d.get_atom_name(a).replace("_NET_WM_STATE_", "") for a in (prop(w, "_NET_WM_STATE") or [])] g = w.get_geometry() t = w.translate_coords(root, 0, 0) motif = prop(w, "_MOTIF_WM_HINTS") out.append(dict(id=wid, name=name, states=states, x=-t.x, y=-t.y, w=g.width, h=g.height, framed=bool(motif is None or (motif[0] & 2 == 0) or motif[2] != 0))) except Exception: pass return out def player(): ws = windows() return ws[1] if len(ws) > 1 else None def library(): ws = windows() return ws[0] if ws else None def press(code, down): xtest.fake_input(d, X.KeyPress if down else X.KeyRelease, code) d.sync() def keycode(name): return d.keysym_to_keycode(XK.string_to_keysym(name)) def key(chord, raw=None): *mods, name = chord.split("+") mc = [keycode({"ctrl": "Control_L", "shift": "Shift_L", "alt": "Alt_L"}[m]) for m in mods] for c in mc: press(c, True) c = raw or keycode(name) press(c, True); time.sleep(0.03); press(c, False) for c in reversed(mc): press(c, False) time.sleep(0.15) def move(x, y): xtest.fake_input(d, X.MotionNotify, x=int(x), y=int(y)); d.sync(); time.sleep(0.05) def click(x, y, button=1, times=1): move(x, y) for _ in range(times): xtest.fake_input(d, X.ButtonPress, button); d.sync(); time.sleep(0.03) xtest.fake_input(d, X.ButtonRelease, button); d.sync(); time.sleep(0.06) def drag(x1, y1, x2, y2, steps=25): move(x1, y1) xtest.fake_input(d, X.ButtonPress, 1); d.sync(); time.sleep(0.1) for i in range(1, steps + 1): move(x1 + (x2 - x1) * i / steps, y1 + (y2 - y1) * i / steps) time.sleep(0.15) xtest.fake_input(d, X.ButtonRelease, 1); d.sync(); time.sleep(0.2) def shot(name): path = os.path.join(STAND, "shots", f"k2-{name}.png") sh("import", "-display", ":87", "-window", "root", path) return path def top_line(name, p): """The window's top line (the compact layout's title and ×) as grey pixels: compared on a paused frame.""" return list(Image.open(shot(name)).convert("L").crop((p["x"], p["y"], p["x"] + p["w"], p["y"] + 36)).getdata()) def diff(a, b): return sum(abs(x - y) for x, y in zip(a, b)) / max(1, len(a)) # --- MPRIS and the screensaver --- MPRIS = ["gdbus", "call", "--session", "--dest", "org.mpris.MediaPlayer2.sleepyowl", "--object-path", "/org/mpris/MediaPlayer2"] def mpris_raw(prop_name): return subprocess.run(MPRIS + ["--method", "org.freedesktop.DBus.Properties.Get", "org.mpris.MediaPlayer2.Player", prop_name], env=BUS, capture_output=True, text=True).stdout def mpris(prop_name): out = mpris_raw(prop_name) m = re.search(r"<(?:int64 )?'?([^'>]*)'?>", out) if prop_name == "Metadata": t = re.search(r"xesam:title': <'([^']*)'", out) return t.group(1) if t else None if not m: return None v = m.group(1) return float(v) if re.fullmatch(r"-?[\d.]+", v) else v def mpris_call(method, *args): subprocess.run(MPRIS + ["--method", f"org.mpris.MediaPlayer2.Player.{method}", *args], env=BUS, capture_output=True) def mpris_present(): return "org.mpris.MediaPlayer2.sleepyowl" in sh("busctl", "--user", "list", "--no-pager", env=BUS, check=False) def wait(cond, timeout, step=0.3): end = time.time() + timeout while time.time() < end: v = cond() if v: return v time.sleep(step) return cond() def pos(): return (mpris("Position") or 0) / 1e6 ss_log = os.path.join(STAND, "k2-screensaver.txt") def ss_lines(): return open(ss_log).read().splitlines() def new_log(): """This launch's log: the app starts a new file each time (the previous one becomes sleepyowl.1.log).""" return open(LOG, encoding="utf-8", errors="replace").read() def run_logs(): """Both launches of this run.""" return "".join(open(f, encoding="utf-8", errors="replace").read() for f in (LOG.replace(".log", ".1.log"), LOG) if os.path.exists(f)) def start_app(item, start): env = dict(os.environ, DESKTOP_NOBUILD="1", SLEEPYOWL_MPV_OPTIONS="mute=yes") subprocess.run(["nice", "-n", "15", STAND_SH, "app", "--play", item, "--start", str(start)], env=env, check=True, capture_output=True) ok = wait(lambda: mpris_present() and player() and (mpris("Metadata") or ""), 60, 0.5) time.sleep(2) return ok # --- The run --- backup = os.path.join(STAND, "k2-prefs-backup") os.makedirs(backup, exist_ok=True) sh(STAND_SH, "stop") for f in ("desktop_player.preferences_pb", "desktop.preferences_pb"): if os.path.exists(os.path.join(PREFS, f)): shutil.move(os.path.join(PREFS, f), os.path.join(backup, f)) subprocess.run(["python3", os.path.join(R, "tools/linux/watchdata.py"), "snapshot"], check=True, capture_output=True) fake = subprocess.Popen(["python3", "-u", os.path.join(R, "tools/linux/fake_screensaver.py")], env=BUS, stdout=open(ss_log, "w"), stderr=subprocess.STDOUT) sh("setxkbmap", "-display", ":87", "-layout", "us") time.sleep(1) try: print("== first open (§1.1, §1.2)", flush=True) check("player opens, MPRIS up", start_app(E2, 60)) p = player() check("mini 640×360 in the bottom right corner, 24 px in", p and near(p["x"], 1256, 2) and near(p["y"], 696, 2) and p["w"] == 640 and p["h"] == 360, p) check("mini: on top, no frame, focused once", p and "ABOVE" in p["states"] and not p["framed"] and "FOCUSED" in p["states"], p and p["states"]) check("MPRIS: playing, title", mpris("PlaybackStatus") == "Playing" and "Серия 2" in (mpris("Metadata") or ""), mpris("Metadata")) check("screensaver inhibited", wait(lambda: any(l.startswith("Inhibit") for l in ss_lines()), 5)) print("== keys (§1.3)", flush=True) key("space"); time.sleep(1) check("Space pauses", mpris("PlaybackStatus") == "Paused") check("pause lifts the screensaver inhibition", wait(lambda: any(l.startswith("UnInhibit") for l in ss_lines()), 4)) key("space"); time.sleep(1) check("Space plays again", mpris("PlaybackStatus") == "Playing") p0 = pos() key("Right"); key("Right"); key("Right"); time.sleep(2.5) p1 = pos() check("→ ×3: +30 s in one seek", 26 <= p1 - p0 <= 36, f"{p0:.1f} → {p1:.1f}") key("shift+Left"); time.sleep(2.5) p2 = pos() check("Shift+←: −60 s", -64 <= p2 - p1 <= -54, f"{p1:.1f} → {p2:.1f}") v0 = mpris("Volume") key("Down"); time.sleep(0.5) check("↓: volume −5 %", v0 is not None and near(mpris("Volume"), v0 - 0.05, 0.001), f"{v0} → {mpris('Volume')}") key("Up"); time.sleep(0.5) key("m"); time.sleep(0.5) check("M: mute", mpris("Volume") == 0.0) key("m"); time.sleep(0.5) check("M again: sound back", near(mpris("Volume") or 0, v0, 0.001)) m_code = keycode("m") sh("setxkbmap", "-display", ":87", "-layout", "ru") time.sleep(0.5) key("m", raw=m_code); time.sleep(0.5) muted_ru = mpris("Volume") == 0.0 key("m", raw=m_code); time.sleep(0.5) sh("setxkbmap", "-display", ":87", "-layout", "us") check("Russian layout: physical M (Ь) mutes and unmutes", muted_ru and near(mpris("Volume") or 0, v0, 0.001)) before = len(re.findall(r"смена аудио|aid", new_log())) key("a"); time.sleep(1.5); key("a"); time.sleep(1.5) check("A ×2: audio track switched and back", len(re.findall(r"смена аудио|aid", new_log())) - before >= 2) print("== autohide (§1.3), on a paused frame", flush=True) p = player() key("space"); time.sleep(1.5) move(p["x"] + p["w"] // 2, p["y"] + p["h"] // 2); time.sleep(0.2); move(p["x"] + p["w"] // 2 + 5, p["y"] + p["h"] // 2 + 5) time.sleep(0.4) shown = top_line("osd-shown", p) time.sleep(3.0) rested = top_line("osd-rested", p) check("pointer resting 2 s in mini: controls hidden", diff(shown, rested) > 4, f"diff {diff(shown, rested):.1f}") move(p["x"] + 100, p["y"] + 100); time.sleep(0.4) again = top_line("osd-again", p) check("pointer moves: shown again", diff(shown, again) < 1.5, f"diff {diff(shown, again):.1f}") move(150, 60); time.sleep(0.5) left = top_line("osd-left", p) check("pointer leaves: hidden at once", diff(rested, left) < 1.5 and diff(shown, left) > 4, f"diff {diff(rested, left):.1f} / {diff(shown, left):.1f}") key("space"); time.sleep(1) check("playing again", mpris("PlaybackStatus") == "Playing") print("== drag and resize the mini window (§1.1)", flush=True) p = player() cx, cy = p["x"] + p["w"] // 2, p["y"] + p["h"] // 2 drag(cx, cy, cx - 600, cy - 300); time.sleep(1) q = player() check("drag by the video moves it", near(q["x"], p["x"] - 600, 12) and near(q["y"], p["y"] - 300, 12), f"{p['x']},{p['y']} → {q['x']},{q['y']}") check("a drag is not a click: still playing", mpris("PlaybackStatus") == "Playing") drag(q["x"] + q["w"] - 3, q["y"] + q["h"] // 2, q["x"] + q["w"] + 237, q["y"] + q["h"] // 2); time.sleep(1.2) r = player() check("right edge resizes, 16:9 kept", near(r["w"], q["w"] + 240, 20) and near(r["h"], r["w"] * 9 / 16, 3), f"{q['w']}×{q['h']} → {r['w']}×{r['h']}") mini_place = (r["x"], r["y"], r["w"], r["h"]) print("== modes (§1.1)", flush=True) key("t"); time.sleep(2.5) p = player() check("T: normal window 1280×720 in the middle, framed, not on top", p["w"] == 1280 and p["h"] == 720 and near(p["x"], 320, 2) and near(p["y"], 180, 40) and p["framed"] and "ABOVE" not in p["states"], p) key("f"); time.sleep(2.5) p = player() check("F: full screen", "FULLSCREEN" in p["states"] and p["w"] == 1920 and p["h"] == 1080, p["states"]) key("Escape"); time.sleep(2.5) p = player() check("Esc: back to the normal window", "FULLSCREEN" not in p["states"] and p["w"] == 1280 and p["framed"], p) shot("window") key("t"); time.sleep(2.5) p = player() check("T: mini again at its own place and size", "ABOVE" in p["states"] and same_place(p, mini_place), f"{mini_place} → {(p['x'], p['y'], p['w'], p['h'])}") click(p["x"] + p["w"] // 2, p["y"] + p["h"] // 2, times=2); time.sleep(2.5) p = player() check("double click in mini: normal window, still playing", p["framed"] and "ABOVE" not in p["states"] and mpris("PlaybackStatus") == "Playing", p["states"]) click(p["x"] + p["w"] // 2, p["y"] + p["h"] // 2, times=2); time.sleep(2.5) p = player() check("double click in the window: full screen", "FULLSCREEN" in p["states"]) key("Escape"); time.sleep(2.5) key("t"); time.sleep(2.5) p = player() check("Esc, T: mini", "ABOVE" in p["states"] and not p["framed"]) check("focus stayed with the player through the modes", "FOCUSED" in p["states"]) print("== menu (§1.3)", flush=True) click(p["x"] + p["w"] // 2, p["y"] + p["h"] // 2, button=3); time.sleep(0.8) shot("menu") key("Escape"); time.sleep(0.5) print("== next episode, focus elsewhere (§1.2)", flush=True) lib = library() click(lib["x"] + 200, lib["y"] - 15); time.sleep(1) # the library's title bar check("library has the focus", "FOCUSED" in library()["states"] and "FOCUSED" not in player()["states"]) pid = player()["id"] mpris_call("Next") check("MPRIS Next: S1E3", wait(lambda: "Серия 3" in (mpris("Metadata") or ""), 15), mpris("Metadata")) time.sleep(2) p = player() check("same window, on top, focus not taken", p["id"] == pid and "ABOVE" in p["states"] and "FOCUSED" not in p["states"] and "FOCUSED" in library()["states"], p["states"]) m = re.search(r"mpris:length': ", mpris_raw("Metadata")) mpris_call("SetPosition", f"/ru/stenfredd/sleepyowl/track/{E3}", str(int(m.group(1)) - 8_000_000) if m else "0") check("end of S1E3 plays S1E4 by itself", wait(lambda: "Серия 4" in (mpris("Metadata") or ""), 40), mpris("Metadata")) time.sleep(2) p = player() check("…in the same window, focus still elsewhere", p["id"] == pid and "FOCUSED" not in p["states"], p["states"]) mpris_call("Previous") check("MPRIS Previous: S1E3", wait(lambda: "Серия 3" in (mpris("Metadata") or ""), 15)) time.sleep(3) p0 = pos(); mpris_call("Seek", "60000000"); time.sleep(2.5) check("MPRIS Seek +60 s", 55 <= pos() - p0 <= 66, f"{p0:.1f} → {pos():.1f}") mpris_call("Pause"); time.sleep(1) check("MPRIS Pause", mpris("PlaybackStatus") == "Paused") mpris_call("PlayPause"); time.sleep(1) check("MPRIS PlayPause", mpris("PlaybackStatus") == "Playing") print("== close and reopen (§1.2 п. 7, §1.1)", flush=True) p = player() move(p["x"] + p["w"] // 2, p["y"] + p["h"] // 2); time.sleep(0.2); move(p["x"] + p["w"] - 21, p["y"] + 21); time.sleep(0.5) shot("before-close") click(p["x"] + p["w"] - 21, p["y"] + 21); time.sleep(2) check("× closes the player, library stays", player() is None and library() is not None) calls = [l for l in ss_lines() if l.startswith(("Inhibit", "UnInhibit"))] check("MPRIS gone, screensaver allowed", not mpris_present() and calls and calls[-1].startswith("UnInhibit"), calls[-2:]) sh(STAND_SH, "stop") check("reopens", start_app(E2, 90)) p = player() check("…in mini at the last place and size", "ABOVE" in p["states"] and same_place(p, mini_place), f"{mini_place} → {(p['x'], p['y'], p['w'], p['h'])}") move(p["x"] + p["w"] // 2, p["y"] + p["h"] // 2); time.sleep(0.2); move(p["x"] + p["w"] - 21, p["y"] + 21); time.sleep(0.5) click(p["x"] + p["w"] - 21, p["y"] + 21); time.sleep(2) check("closed again", player() is None) errors = re.findall(r"^\S+ E .*|.*Exception.*", run_logs(), re.M) check("no errors in the log", not errors, errors[:3]) finally: sh(STAND_SH, "stop", check=False) sh("setxkbmap", "-display", ":87", "-layout", "us", check=False) fake.terminate() for f in os.listdir(backup): shutil.move(os.path.join(backup, f), os.path.join(PREFS, f)) time.sleep(5) print(subprocess.run(["python3", os.path.join(R, "tools/linux/watchdata.py"), "restore"], capture_output=True, text=True).stdout.strip(), flush=True) failed = [n for n, ok in results if not ok] print(f"\n{len(results) - len(failed)}/{len(results)} passed" + (": failed " + "; ".join(failed) if failed else "")) sys.exit(1 if failed else 0)