From dedcab71bca0c5e2c2de506a9a66ef3241f8596a Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Fri, 30 Oct 2026 16:18:32 -0700 Subject: [PATCH] test(nix): share the wallpaper scenario, and the files kfw must sync Wallpapers and the colors of the borders that come from them are in the config of both ports. wallpaper.run is the neutral part: a monitor's wallpaper at start, a workspace's own while it is shown, the ring in the hue of the wallpaper, and a changed wallpaper put back. Platform gets put_file, wallpaper and set_wallpaper; in a Plasma session restart_with restarts the module's unit with the user's komorebi.json. The KDE extra keeps what only the shell tells. sync-e2e-core carries the two fakes the shared tests import. --- docs/dev/BEHAVIOUR.md | 1 + docs/dev/platform-contract.md | 1 + docs/dev/suite-ledger.md | 12 +- nix/tests/vm/lib/komorebi_vm/core/platform.py | 14 ++ .../komorebi_vm/core/scenarios/wallpaper.py | 160 ++++++++++++++++++ nix/tests/vm/lib/komorebi_vm/kde_platform.py | 37 ++++ .../platforms/kde/extras/wallpaper.py | 144 ++-------------- nix/tests/vm/lib/tests/fake_desk.py | 9 + nix/tests/vm/lib/tests/fake_platform.py | 9 + nix/tests/vm/lib/tests/fake_wallpaper.py | 64 +++++++ nix/tests/vm/lib/tests/test_focus_moving.py | 18 ++ nix/tests/vm/lib/tests/test_tiling.py | 9 + nix/tests/vm/suites/wallpaper/test.py | 35 ++-- scripts/sync-e2e-core | 2 +- 14 files changed, 374 insertions(+), 141 deletions(-) create mode 100644 nix/tests/vm/lib/komorebi_vm/core/scenarios/wallpaper.py create mode 100644 nix/tests/vm/lib/tests/fake_wallpaper.py diff --git a/docs/dev/BEHAVIOUR.md b/docs/dev/BEHAVIOUR.md index b1be931d..6c6343ee 100644 --- a/docs/dev/BEHAVIOUR.md +++ b/docs/dev/BEHAVIOUR.md @@ -61,6 +61,7 @@ runs, and goes stale when the code changes. | `tiling_off` | `toggle-tiling` leaves the windows where they are, a layout change moves none, and tiling again tiles them | pass | pass | | `scrolling` | in the scrolling layout each window in turn has the focus and is inside the work area | pass | pass | | `scrolling.columns` | five windows in two columns: the ones out of view rest at an edge with their width, the strip follows the focus and `pan`, and `consume`, `expel`, `toggle-column-stack` and `scrolling-layout-full-width` work | pass | not run yet | +| `wallpaper` | a monitor's wallpaper at start, a workspace's own while shown, the ring in the hue of the wallpaper, and a changed wallpaper put back | pass 12 | not run yet | | `scrolling.two_monitors` | a strip with a display next to it: scrolling with `focus` and `pan` shows no window on the second display | pass | not run yet | | `monitors` | the monitor commands (`send-to-monitor`, `move-to-monitor`, their cycle, workspace and named forms, `focus-monitor`, `cycle-monitor`, `focus-monitor-workspace`, `cycle-move-workspace-to-monitor`, `focus-monitor-at-cursor` where there is a pointer) put each window on the monitor and workspace asked for, tiled in its display, and the focus where each leaves it | not run | not run | | `layout_flip` | `layout_flip` in the config (`Horizontal`, `Vertical`, `HorizontalAndVertical`) mirrors a workspace's layout from the start, `flip-layout` turns it off and on, and it is the named workspace's own | not run | not run | diff --git a/docs/dev/platform-contract.md b/docs/dev/platform-contract.md index ba15fdb2..b985b9f3 100644 --- a/docs/dev/platform-contract.md +++ b/docs/dev/platform-contract.md @@ -23,6 +23,7 @@ docstrings of `Platform` are the contract; this is the audit behind them. | `displays()` | each display's rectangle in the desktop's coordinates, left to right, as the system says | displays are not one size (on Windows the first is the basic adapter, 1280x800, and the second, the only one whose mode can change, is 1024x768 by default and follows `resize_display`) and a scenario does not compute where the next begins | | `add_display()`, `remove_display()` | plugs a display in to the right, unplugs the last; return when the system agrees | Windows removes only a display `add_display` plugged in (a hotplug port is used once); KDE enables a disabled output and disables the last enabled one, so a headless session with two outputs can unplug at once. A scenario unplugs only after plugging in, unless its suite started with more than one display | | `work_area()` | the area of the first display that windows may take, as the system says: the display less panels, docks and the taskbar | KDE asks KWin (a script: its placement area), and answers with the whole output only if KWin does not answer. A headless session has no panel, so there it is the output | +| `put_file(path, data)`, `wallpaper(display)`, `set_wallpaper(path, display)` | a file the daemon can read; the picture a display shows; the wallpaper as the user sets it | KDE reads and sets it through the Plasma shell's D-Bus API. Windows: `IDesktopWallpaper`. `restart_with` in a real Plasma session restarts the module's unit with the user's `komorebi.json` | | `resize_grab(frame, grab)` | where the pointer is pressed to take a window by an edge or corner | KDE: the gap outside the frame on every side (`resize.outside_grab`). Windows: left, right and bottom are the invisible border outside the visible frame; the top has none, so it is inside the frame's top row | | `close`, `minimize`, `activate`, `move_window` | act on the window as the user would; raise if there is no such window | `close` returns before the window is gone (a scenario waits for `os_window` to be `None`); `move_window` puts the top left of the *visible* frame at (x, y) | | `lock()`, `unlock()` | lock and unlock the screen and return when it is so | the lock screen is `LogonUI` on Windows and the Plasma greeter on KDE; a scenario asks only what the daemon and the windows do | diff --git a/docs/dev/suite-ledger.md b/docs/dev/suite-ledger.md index 2b28aaf0..71b2932e 100644 --- a/docs/dev/suite-ledger.md +++ b/docs/dev/suite-ledger.md @@ -226,6 +226,16 @@ Not kept: for features A, "komorebi still has all three" (the shared scenario ch it is a check of the window it moves). The shared `fullscreen` is looser than E in one way: the window covers the display up to a pixel, where E compared the frame with the monitor exactly. +## wallpaper + +Wallpapers and the colors of the borders that come from them are in the config of both ports, so +the shared scenario `wallpaper.run` has what is neutral: a monitor's wallpaper at start, a +workspace's own while it is shown (a path with a space in it), the ring of the focused window in +the hue of the wallpaper, and a wallpaper the user changed being put back. It uses `put_file`, +`wallpaper` and `set_wallpaper`. The extra `wallpaper.run` keeps what only the Plasma shell tells: +the fill mode and how long a switch takes, a switch by a pager, the palette cache, and a daemon +that starts before the shell. 12 checks went to the shared scenario, 12 stayed. + ## The KDE-only suites Moved as they were into `komorebi_vm.platforms.kde.extras`, one module per suite (`kwin`, @@ -256,7 +266,7 @@ move: | module | 33 | | live-resize | 7 | | look | 40 | -| wallpaper | 22 | +| wallpaper | 12 | | features | 30 | | keys-engine | 34 | | run-kde | 22 | diff --git a/nix/tests/vm/lib/komorebi_vm/core/platform.py b/nix/tests/vm/lib/komorebi_vm/core/platform.py index cd1f8c05..78473c0e 100644 --- a/nix/tests/vm/lib/komorebi_vm/core/platform.py +++ b/nix/tests/vm/lib/komorebi_vm/core/platform.py @@ -258,6 +258,20 @@ class Platform(Protocol): size.""" ... + def put_file(self, path: str, data: bytes) -> None: + """Writes `data` to `path` on the machine, where the daemon can read it, and makes the + directories above it. `path` is one `temp_path` made.""" + ... + + def wallpaper(self, display: int = 0) -> str: + """The path of the picture a display shows, as the operating system says it (the Plasma + shell on KDE, `IDesktopWallpaper` on Windows), or the empty string when it does not know.""" + ... + + def set_wallpaper(self, path: str, display: int = 0) -> None: + """Sets the wallpaper of a display as the user does, and returns when it is set.""" + ... + def resize_grab(self, frame: Rect, grab: str) -> tuple[int, int]: """Where the pointer is pressed to take a window of this frame by `grab`: `left`, `right`, `top`, `bottom`, or a corner (`topleft`, `topright`, `bottomleft`, `bottomright`). diff --git a/nix/tests/vm/lib/komorebi_vm/core/scenarios/wallpaper.py b/nix/tests/vm/lib/komorebi_vm/core/scenarios/wallpaper.py new file mode 100644 index 00000000..39c70384 --- /dev/null +++ b/nix/tests/vm/lib/komorebi_vm/core/scenarios/wallpaper.py @@ -0,0 +1,160 @@ +"""Wallpapers, and the colors of the borders that come from them. + +A monitor's wallpaper is set when the daemon starts, and the wallpaper of a workspace replaces it +while the workspace is shown. The borders of the windows take the colors of the wallpaper's +palette, and follow it from workspace to workspace. A wallpaper the user changed by hand is put back +when its workspace is shown again. + +The pictures are a dozen bands of color each, since a palette wants at least eight: a warm one for +the monitor, a cool one for the second workspace, and a green one the user sets by hand. Both ports +read the same `wallpaper` of `komorebi.json`, and keep a palette for each picture. +""" + +import io + +from PIL import Image + +from komorebi_vm.core import pixels +from komorebi_vm.core.checks import Checks +from komorebi_vm.core.geometry import Rect +from komorebi_vm.core.platform import Client, Platform + + +def picture(hue: tuple[int, int, int], ramp: int) -> bytes: + """A 192 by 108 PNG of diagonal bands of `hue`, going dark and light, `ramp` varying the hue.""" + data: list[tuple[int, int, int]] = [] + for y in range(108): + for x in range(192): + band = (x // 16 + y // 16) % 12 + shade = 40 + (band % 4) * 55 + red, green, blue = hue + green = min(255, green + band * ramp) + data.append( + (min(255, red * shade // 255), min(255, green * shade // 255), blue * shade // 255) + ) + image = Image.new("RGB", (192, 108)) + image.putdata(data) + out = io.BytesIO() + image.save(out, "PNG") + return out.getvalue() + + +def _hue(red: int, blue: int) -> str: + return "warm" if red > blue + 40 else "cool" if blue > red + 40 else "neither" + + +def hue_of(image: Image.Image) -> str: + """Warm, cool or neither: the average color of a grid of points of the screen.""" + width, height = image.size + points = [ + pixels.pixel(image, width * i // 10, height * j // 12) + for i in range(1, 10) + for j in range(1, 9) + ] + return _hue( + round(sum(p[0] for p in points) / len(points)), + round(sum(p[2] for p in points) / len(points)), + ) + + +def ring_hue(frame: Rect, image: Image.Image, reach: int = 12) -> str: + """The hue of the ring around a window: the colors within `reach` pixels of the left edge of + its frame, on either side of it, at its middle. The window's own color is neutral, so what has + a hue is the border; where the ports draw it, outside the frame or on its edge, differs.""" + seen = set() + for x in range(max(0, frame.x - reach), min(image.size[0], frame.x + reach)): + red, _, blue = pixels.pixel(image, x, frame.y + frame.h // 2) + seen.add(_hue(red, blue)) + seen.discard("neither") + return seen.pop() if len(seen) == 1 else "neither" if not seen else "mixed" + + +def run(platform: Platform, checks: Checks) -> None: + """The daemon starts with a wallpaper for the monitor and one for its second workspace; the + scenario follows them from workspace to workspace, with the borders, and sets one by hand. + The workspace is empty, and so it is when the scenario ends.""" + warm = platform.temp_path("wallpapers/warm.png") + cool = platform.temp_path("wallpapers/my pictures/cool.png") + other = platform.temp_path("wallpapers/other.png") + platform.put_file(warm, picture((255, 0, 20), 20)) + platform.put_file(cool, picture((20, 80, 255), 14)) + platform.put_file(other, picture((60, 255, 60), 0)) + + def shows(path: str) -> bool: + return platform.wallpaper() == path + + def screen_is(hue: str, name: str) -> bool: + """Takes pictures of the screen until it is `hue`: one can catch the shell half way.""" + return platform.wait_for(lambda: hue_of(platform.screenshot(name)) == hue, timeout=20) + + def has_ring(client: Client, hue: str, name: str) -> bool: + """The window has a ring of `hue` in a screenshot taken until it does.""" + + def ring() -> bool: + window = platform.os_window(client) + return window is not None and ring_hue(window.frame, platform.screenshot(name)) == hue + + return platform.wait_for(ring, timeout=20) + + platform.set_wallpaper(other) + checks.check( + "the screen shows a picture of the user's choice to begin with", + platform.wait_for(lambda: shows(other), timeout=20), + ) + + platform.restart_with( + { + "monitors": [ + { + "wallpaper": {"path": warm}, + "workspaces": [ + {"name": "one"}, + {"name": "two", "wallpaper": {"path": cool}}, + ], + } + ] + } + ) + checks.check( + "at start the screen shows the monitor's wallpaper", + platform.wait_for(lambda: shows(warm), timeout=20), + ) + checks.check("and the screen is warm", screen_is("warm", "wallpaper-a1")) + + platform.komorebic("focus-workspace", "1") + checks.check( + "workspace two's own wallpaper is shown when it is (the path has a space in it)", + platform.wait_for(lambda: shows(cool), timeout=10), + ) + checks.check("and the screen is cool", screen_is("cool", "wallpaper-a2")) + platform.komorebic("focus-workspace", "0") + checks.check( + "back on workspace one, the monitor's wallpaper again", + platform.wait_for(lambda: shows(warm), timeout=10), + ) + + left = platform.spawn("wallpaper-left") + checks.check( + "on the warm workspace the focused window's ring is warm", + has_ring(left, "warm", "wallpaper-c1"), + ) + platform.komorebic("focus-workspace", "1") + right = platform.spawn("wallpaper-right") + checks.check("on the cool one it is cool", has_ring(right, "cool", "wallpaper-c2")) + platform.komorebic("focus-workspace", "0") + checks.check("and warm again when the first is back", has_ring(left, "warm", "wallpaper-c3")) + + platform.set_wallpaper(other) + checks.check("the user changed it", platform.wait_for(lambda: shows(other), timeout=10)) + platform.komorebic("focus-workspace", "1") + platform.wait_for(lambda: shows(cool), timeout=10) + platform.komorebic("focus-workspace", "0") + checks.check( + "showing the workspace again set it back", + platform.wait_for(lambda: shows(warm), timeout=10), + ) + + platform.close(left) + platform.komorebic("focus-workspace", "1") + platform.close(right) + platform.komorebic("focus-workspace", "0") diff --git a/nix/tests/vm/lib/komorebi_vm/kde_platform.py b/nix/tests/vm/lib/komorebi_vm/kde_platform.py index 2703de98..e66cf966 100644 --- a/nix/tests/vm/lib/komorebi_vm/kde_platform.py +++ b/nix/tests/vm/lib/komorebi_vm/kde_platform.py @@ -1,10 +1,12 @@ """KDE as a [`Platform`](komorebi_vm.core.platform.Platform): a `Kde` session, seen through the neutral vocabulary.""" +import base64 import json import re import shlex from typing import Any +from urllib.parse import unquote from PIL import Image @@ -75,7 +77,24 @@ class KdePlatform: def crash(self) -> None: self.kde.kill_komorebi() + def _restart_the_modules_daemon(self, config: dict[str, Any]) -> None: + """A real Plasma session has the module's unit run the daemon, with the user's own + `komorebi.json`. The unit `komorebi@session` of a headless run has a bus of its own, + and a daemon on it has no Plasma shell to talk to.""" + kde = self.kde + kde.systemctl("stop", "komorebi.service") + kde.wait_for(lambda: kde.run("komorebic state")[0] != 0, timeout=30) + kde.close_colored_windows() + kde.sh("mkdir -p ~/.config/komorebi") + kde.sh(f"printf %s {shlex.quote(json.dumps(config))} > ~/.config/komorebi/komorebi.json") + kde.systemctl("start", "komorebi.service") + if not kde.wait_for(lambda: kde.run("komorebic state")[0] == 0, timeout=60): + raise AssertionError("the module's daemon did not answer komorebic") + def restart_with(self, config: dict[str, Any]) -> None: + if self.session: + self._restart_the_modules_daemon(config) + return if self.kde.komorebi_running(): self.kde.komorebic("stop") self.kde.wait_for(lambda: not self.kde.komorebi_running(), timeout=30) @@ -227,6 +246,24 @@ class KdePlatform: if not self.kde.wait_for(lambda: self._locked(False), timeout=30): raise AssertionError("the screen did not unlock") + def put_file(self, path: str, data: bytes) -> None: + directory = shlex.quote(path.rsplit("/", 1)[0]) + encoded = base64.b64encode(data).decode() + self.kde.sh(f"mkdir -p {directory} && chmod a+rx {directory}") + self.kde.sh( + f"echo {encoded} | base64 -d > {shlex.quote(path)} && chmod a+r {shlex.quote(path)}" + ) + + def wallpaper(self, display: int = 0) -> str: + shell = "org.kde.plasmashell /PlasmaShell org.kde.PlasmaShell" + out = self.kde.run(f"busctl --user call {shell} wallpaper u {display}")[1] + image = re.search(r'"Image" s "([^"]*)"', out) + url = image.group(1) if image else "" + return unquote(url.removeprefix("file://")) + + def set_wallpaper(self, path: str, display: int = 0) -> None: + self.kde.sh(f"plasma-apply-wallpaperimage {shlex.quote(path)}") + def resize_grab(self, frame: Rect, grab: str) -> tuple[int, int]: return outside_grab(frame, grab) diff --git a/nix/tests/vm/lib/komorebi_vm/platforms/kde/extras/wallpaper.py b/nix/tests/vm/lib/komorebi_vm/platforms/kde/extras/wallpaper.py index acfa8124..ec02c58c 100644 --- a/nix/tests/vm/lib/komorebi_vm/platforms/kde/extras/wallpaper.py +++ b/nix/tests/vm/lib/komorebi_vm/platforms/kde/extras/wallpaper.py @@ -1,28 +1,23 @@ -r"""Wallpapers, and the borders' colors that come from them, in a real Plasma session: what the shell -shows and what is on the screen, read from the shell's D-Bus API and from screenshots. +r"""What only the Plasma shell tells about wallpapers, beside the shared `wallpaper` scenario, which +sets them, follows them from workspace to workspace with the borders, and sets one back: how the +shell fills the screen, a switch that komorebi did not make, the palette cache and a shell that +starts late. - A a monitor's wallpaper is set at start, and a workspace's own replaces it when it is shown + A the fill mode of the wallpaper, and how long a workspace takes to change it B a switch komorebi did not make (a pager's) sets the wallpaper too - C the borders take the colors of the wallpaper's palette, and follow it from workspace to - workspace - D a wallpaper the user changed by hand is put back when its workspace is shown again - E the palette cache, and a path with a space in it (the second picture's) + E the palette cache F komorebi that starts before the Plasma shell does The pictures are a dozen bands of color each, since a palette wants at least eight: a warm one for the monitor, a cool one for the second workspace, and a green one the test sets by hand. """ -import base64 -import io import re import shlex import time from typing import Any -from PIL import Image - -from komorebi_vm import Checks, Kde, Rect, pixels +from komorebi_vm import Checks, Kde UNIT = "komorebi.service" SHELL_UNIT = "plasma-plasmashell.service" @@ -44,36 +39,6 @@ def run(machine: Any, checks: Checks, scale: float) -> None: SCALE = scale # noqa: N806 kde = Kde.session(machine, scale=SCALE) - def picture(hue: tuple[int, int, int], ramp: int) -> bytes: - """A 192 by 108 PNG of diagonal bands of `hue`, going dark and light, `ramp` varying the hue.""" - data: list[tuple[int, int, int]] = [] - for y in range(108): - for x in range(192): - band = (x // 16 + y // 16) % 12 - shade = 40 + (band % 4) * 55 - red, green, blue = hue - green = min(255, green + band * ramp) - data.append( - ( - min(255, red * shade // 255), - min(255, green * shade // 255), - blue * shade // 255, - ) - ) - image = Image.new("RGB", (192, 108)) - image.putdata(data) - out = io.BytesIO() - image.save(out, "PNG") - return out.getvalue() - - def put(path: str, data: bytes) -> None: - """Writes `data` to `path` in the machine, where the test user can read it.""" - directory = shlex.quote(path.rsplit("/", 1)[0]) - encoded = base64.b64encode(data).decode() - machine.succeed(f"mkdir -p {directory} && chmod a+rx {directory}") - machine.succeed(f"echo {encoded} | base64 -d > {shlex.quote(path)}") - machine.succeed(f"chmod a+r {shlex.quote(path)}") - def running() -> bool: return kde.run(f"systemctl --user is-active --quiet {UNIT}")[0] == 0 @@ -101,78 +66,30 @@ def run(machine: Any, checks: Checks, scale: float) -> None: def shows(url: str) -> bool: return shell_wallpaper()[0] == url - def hue_of(image: Image.Image) -> str: - """Warm, cool or neither: the average color of a grid of points of the screen.""" - width, height = image.size - points = [ - pixels.pixel(image, width * i // 10, height * j // 12) - for i in range(1, 10) - for j in range(1, 9) - ] - red = sum(p[0] for p in points) / len(points) - blue = sum(p[2] for p in points) / len(points) - return "warm" if red > blue + 40 else "cool" if blue > red + 40 else "neither" - - def screen_is(hue: str, name: str) -> bool: - """Takes pictures of the screen until it is `hue`: one can catch the shell half way.""" - return kde.wait_for(lambda: hue_of(kde.shot(name)) == hue, timeout=20) - - def ring_hue(frame: Rect, image: Image.Image) -> str: - """The hue of the ring just outside the left edge of a frame.""" - red, _, blue = pixels.pixel(image, frame.x - 5, frame.y + frame.h // 2) - return "warm" if red > blue + 40 else "cool" if blue > red + 40 else "neither" - - def has_ring(title: str, hue: str, name: str) -> bool: - """Returns true if the window has a ring of `hue` in a screenshot taken until it does.""" - return kde.wait_for( - lambda: ring_hue(kde.rect(kde.wid(title)), kde.shot(name)) == hue, timeout=20 - ) - - print("== the pictures, and the wallpapers of the configuration") - put(WARM, picture((255, 0, 20), 20)) - put(COOL, picture((20, 80, 255), 14)) - put(OTHER, picture((60, 255, 60), 0)) - kde.sh(f"mkdir -p {shlex.quote(CONFIG.rsplit('/', 1)[0])}") + # the shared `wallpaper` scenario ran before this: the daemon runs with the wallpapers of its + # configuration, the pictures are in PICTURES, and the first workspace shows the warm one + # the shared scenario restarted the module's unit with the user's `komorebi.json` + # the unit starts komorebi with the user's own `komorebi.json`: the wallpapers are in it too config = ( '{ "monitors": [ { "wallpaper": { "path": "' + WARM + '" },' ' "workspaces": [ { "name": "one" },' ' { "name": "two", "wallpaper": { "path": "' + COOL + '" } } ] } ] }' ) + kde.sh(f"mkdir -p {shlex.quote(CONFIG.rsplit('/', 1)[0])}") kde.sh(f"echo {shlex.quote(config)} > {CONFIG}") - kde.sh("rm -rf ~/.cache/komorebi") - - # the session started the module's unit when it logged in: this test starts its own runs - stop_komorebi() - kde.sh(f"plasma-apply-wallpaperimage {shlex.quote(OTHER)}") - checks.check( - "the shell shows a picture of the user's choice to begin with", - kde.wait_for(lambda: shows(f"file://{OTHER}"), timeout=20), - ) - - print("== A. set at start, and by workspace") - start_komorebi() - checks.check( - "at start the shell shows the monitor's wallpaper", - kde.wait_for(lambda: shows(WARM_URL), timeout=20), - ) - checks.check("it fills the screen", shell_wallpaper()[1] == FILL) - checks.check("and the screen is warm", screen_is("warm", "a1")) + print("== A. what only the Plasma shell tells") + checks.check("the monitor's wallpaper fills the screen", shell_wallpaper()[1] == FILL) began = time.monotonic() kde.komorebic("focus-workspace", "1") checks.check( - "workspace two's own wallpaper is shown when it is (the path has a space in it)", + "workspace two's own wallpaper is shown when it is", kde.wait_for(lambda: shows(COOL_URL), timeout=10), ) took = time.monotonic() - began checks.check(f"it took under two seconds ({took:.1f} s)", took < 2 * max(1, SCALE)) - checks.check("and the screen is cool", screen_is("cool", "a2")) - kde.komorebic("focus-workspace", "0") - checks.check( - "back on workspace one, the monitor's wallpaper again", - kde.wait_for(lambda: shows(WARM_URL), timeout=10), - ) + kde.wait_for(lambda: shows(WARM_URL), timeout=10) print("== B. a switch somebody else made") kde.sh("kdotool set_desktop 2") @@ -183,35 +100,6 @@ def run(machine: Any, checks: Checks, scale: float) -> None: kde.sh("kdotool set_desktop 1") checks.check("and back", kde.wait_for(lambda: shows(WARM_URL), timeout=10)) - print("== C. the borders follow the wallpaper") - kde.colored_window("left", "303030") - time.sleep(1.5) - checks.check( - "on the warm workspace the focused window's ring is warm", has_ring("left", "warm", "c1") - ) - kde.komorebic("focus-workspace", "1") - time.sleep(2) - kde.colored_window("right", "303030") - time.sleep(2) - checks.check("on the cool one it is cool", has_ring("right", "cool", "c2")) - kde.komorebic("focus-workspace", "0") - time.sleep(2.5) - checks.check("and warm again when the first is back", has_ring("left", "warm", "c3")) - - print("== D. a wallpaper changed by hand is put back") - kde.sh(f"plasma-apply-wallpaperimage {shlex.quote(OTHER)}") - checks.check( - "the user changed it", - kde.wait_for(lambda: shows(f"file://{OTHER}"), timeout=10), - ) - kde.komorebic("focus-workspace", "1") - time.sleep(1) - kde.komorebic("focus-workspace", "0") - checks.check( - "showing the workspace again set it back", - kde.wait_for(lambda: shows(WARM_URL), timeout=10), - ) - print("== E. the palette is kept") cache = kde.sh("ls ~/.cache/komorebi 2>/dev/null || true").split() print(f" the cache: {cache}") diff --git a/nix/tests/vm/lib/tests/fake_desk.py b/nix/tests/vm/lib/tests/fake_desk.py index b67c3581..ec27e52d 100644 --- a/nix/tests/vm/lib/tests/fake_desk.py +++ b/nix/tests/vm/lib/tests/fake_desk.py @@ -157,6 +157,15 @@ class FakeDesk: def unlock(self) -> None: raise NotImplementedError + def put_file(self, path: str, data: bytes) -> None: # noqa: ARG002 + pass + + def wallpaper(self, display: int = 0) -> str: # noqa: ARG002 + return "" + + def set_wallpaper(self, path: str, display: int = 0) -> None: + pass + def resize_grab(self, frame, grab): # noqa: ANN001, ANN201 return outside_grab(frame, grab) diff --git a/nix/tests/vm/lib/tests/fake_platform.py b/nix/tests/vm/lib/tests/fake_platform.py index 072af36b..fc222eff 100644 --- a/nix/tests/vm/lib/tests/fake_platform.py +++ b/nix/tests/vm/lib/tests/fake_platform.py @@ -215,6 +215,15 @@ class FakePlatform: if self.retiles_on_unlock: self.moved.clear() + def put_file(self, path: str, data: bytes) -> None: # noqa: ARG002 + pass + + def wallpaper(self, display: int = 0) -> str: # noqa: ARG002 + return "" + + def set_wallpaper(self, path: str, display: int = 0) -> None: + pass + def resize_grab(self, frame, grab): # noqa: ANN001, ANN201 return outside_grab(frame, grab) diff --git a/nix/tests/vm/lib/tests/fake_wallpaper.py b/nix/tests/vm/lib/tests/fake_wallpaper.py new file mode 100644 index 00000000..ca640c54 --- /dev/null +++ b/nix/tests/vm/lib/tests/fake_wallpaper.py @@ -0,0 +1,64 @@ +"""A fake that sets wallpapers from the configuration it is started with, shows them on its +screenshots, and draws a ring around the focused window in a hue of the wallpaper.""" + +from typing import Any + +from PIL import Image, ImageDraw + +from komorebi_vm.core.platform import Client +from tests.fake_platform import FakePlatform + +HUES = {"warm": (230, 40, 40), "cool": (40, 60, 230), "other": (60, 230, 60)} + + +# the ring is the 4 pixels of the wallpaper's hue that the window leaves at its left +class FakeWallpaper(FakePlatform): + def __init__(self, *args, **kwargs) -> None: # noqa: ANN002, ANN003 + super().__init__(*args, **kwargs) + self.shown = "" + self.monitor_wallpaper = "" + self.workspace_wallpapers: dict[int, str] = {} + self.workspace = 0 + self.puts_back = True + + def put_file(self, path: str, data: bytes) -> None: + assert data.startswith(b"\x89PNG"), "a picture" + + def wallpaper(self, display: int = 0) -> str: + return self.shown + + def set_wallpaper(self, path: str, display: int = 0) -> None: + self.shown = path + + def restart_with(self, config: dict[str, Any]) -> None: + self.running = True + monitor = config["monitors"][0] + self.monitor_wallpaper = monitor["wallpaper"]["path"] + self.workspace_wallpapers = { + i: w["wallpaper"]["path"] + for i, w in enumerate(monitor["workspaces"]) + if "wallpaper" in w + } + self._show() + + def _show(self) -> None: + self.shown = self.workspace_wallpapers.get(self.workspace, self.monitor_wallpaper) + + def komorebic(self, *args: str) -> str: + if args[0] == "focus-workspace": + self.workspace = int(args[1]) + if self.puts_back or self.shown != self.wallpaper(): + self._show() + return "" + return super().komorebic(*args) + + def screenshot(self, name: str) -> Image.Image: + which = "other" if "other" in self.shown else "cool" if "cool" in self.shown else "warm" + image = Image.new("RGB", (1920, 1080), HUES[which]) + draw = ImageDraw.Draw(image) + for client in (Client(t) for c in self.containers for t in c): + window = self.os_window(client) + if window is not None and window.focused: + f = window.frame + draw.rectangle((f.x + 4, f.y, f.x + f.w, f.y + f.h), fill=(48, 48, 48)) + return image diff --git a/nix/tests/vm/lib/tests/test_focus_moving.py b/nix/tests/vm/lib/tests/test_focus_moving.py index d7067f55..a63614a5 100644 --- a/nix/tests/vm/lib/tests/test_focus_moving.py +++ b/nix/tests/vm/lib/tests/test_focus_moving.py @@ -36,11 +36,13 @@ from komorebi_vm.core.scenarios import ( state_fidelity, tiling, tiling_off, + wallpaper, workspace_moves, workspaces, ) from tests.fake_platform import FakePlatform from tests.fake_strip import FakeStrip +from tests.fake_wallpaper import FakeWallpaper def opened(platform: FakePlatform, checks: Checks): # noqa: ANN201 @@ -955,3 +957,19 @@ def test_a_daemon_that_rests_windows_on_the_next_monitor_fails_two_monitors() -> checks = Checks() scrolling.two_monitors(platform, checks, []) assert checks.failed > 0 + + +def test_a_daemon_that_sets_the_wallpapers_passes_wallpaper() -> None: + platform = FakeWallpaper() + checks = Checks() + wallpaper.run(platform, checks) + assert checks.failed == 0, checks.failures + assert checks.passed > 8 + + +def test_a_daemon_that_leaves_a_changed_wallpaper_fails_wallpaper() -> None: + platform = FakeWallpaper() + platform.puts_back = False + checks = Checks() + wallpaper.run(platform, checks) + assert checks.failed > 0 diff --git a/nix/tests/vm/lib/tests/test_tiling.py b/nix/tests/vm/lib/tests/test_tiling.py index 2018cf02..3c0bc391 100644 --- a/nix/tests/vm/lib/tests/test_tiling.py +++ b/nix/tests/vm/lib/tests/test_tiling.py @@ -104,6 +104,15 @@ class FakePlatform: def unlock(self) -> None: pass + def put_file(self, path: str, data: bytes) -> None: # noqa: ARG002 + pass + + def wallpaper(self, display: int = 0) -> str: # noqa: ARG002 + return "" + + def set_wallpaper(self, path: str, display: int = 0) -> None: + pass + def resize_grab(self, frame: Rect, grab: str) -> tuple[int, int]: return outside_grab(frame, grab) diff --git a/nix/tests/vm/suites/wallpaper/test.py b/nix/tests/vm/suites/wallpaper/test.py index 4817d03e..57035859 100644 --- a/nix/tests/vm/suites/wallpaper/test.py +++ b/nix/tests/vm/suites/wallpaper/test.py @@ -1,25 +1,38 @@ # Wallpapers, and the borders' colors that come from them, in a real Plasma session: what the shell # shows and what is on the screen, read from the shell's D-Bus API and from screenshots. # -# A a monitor's wallpaper is set at start, and a workspace's own replaces it when it is shown -# B a switch komorebi did not make (a pager's) sets the wallpaper too -# C the borders take the colors of the wallpaper's palette, and follow it from workspace to -# workspace -# D a wallpaper the user changed by hand is put back when its workspace is shown again -# E the palette cache, and a path with a space in it (the second picture's) -# F komorebi that starts before the Plasma shell does +# The shared scenario `wallpaper` (`komorebi_vm.core.scenarios.wallpaper`, which runs on Windows +# too) sets a wallpaper for a monitor and one for a workspace, follows them from workspace to +# workspace with the colors of the borders, and sets one back that the user changed. The extra +# is what only the Plasma shell tells: # -# The pictures are a dozen bands of color each, since a palette wants at least eight: a warm one for -# the monitor, a cool one for the second workspace, and a green one the test sets by hand. +# A the fill mode of the wallpaper, and how long a workspace takes to change it +# B a switch komorebi did not make (a pager's) sets the wallpaper too +# E the palette cache +# F komorebi that starts before the Plasma shell does from komorebi_vm import Checks -from komorebi_vm.core.suite import platform_only, run_suite +from komorebi_vm import Kde +from komorebi_vm.core.scenarios import wallpaper +from komorebi_vm.core.suite import platform_only, run_suite, shared +from komorebi_vm.kde_platform import KdePlatform from komorebi_vm.platforms.kde.extras import wallpaper as kde_wallpaper start_all() machine.wait_for_unit("multi-user.target") checks = Checks() +kde = Kde.session(machine, scale=SCALE) +# the session started the module's unit when it logged in: the scenario restarts that daemon with +# the wallpapers it writes to the user's `komorebi.json` +checks.check("komorebi answers", kde.wait_for(lambda: kde.run("komorebic state")[0] == 0, 120)) +kde.wait_for_panel_room() +size = kde.state()["monitors"]["elements"][0]["size"] +platform = KdePlatform(kde, (size["right"], size["bottom"]), session=True) -run_suite(checks, platform_only("kde", kde_wallpaper.run, machine, checks, SCALE)) +run_suite( + checks, + shared(wallpaper.run, platform, checks), + platform_only("kde", kde_wallpaper.run, machine, checks, SCALE), +) checks.finish() diff --git a/scripts/sync-e2e-core b/scripts/sync-e2e-core index f7889a52..395d6f14 100755 --- a/scripts/sync-e2e-core +++ b/scripts/sync-e2e-core @@ -20,7 +20,7 @@ dest=${1:?usage: sync-e2e-core [--check] DEST} repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) lib=$repo/nix/tests/vm/lib -tests=(fake_platform.py test_checks.py test_suite.py test_pixels.py test_snapshot.py test_recorded.py test_tiling.py +tests=(fake_platform.py fake_strip.py fake_wallpaper.py test_checks.py test_suite.py test_pixels.py test_snapshot.py test_recorded.py test_tiling.py test_focus_moving.py) status=0