From 27f7a1b67b44f11ec803e6c40490780cbcb1e62f Mon Sep 17 00:00:00 2001 From: bbedward Date: Sun, 4 Oct 2026 11:28:34 -0400 Subject: [PATCH] tests: drop the surface fixtures and the ui source slicing tests --- .github/workflows/qml-tests.yml | 4 +- AGENTS.md | 1 + Makefile | 2 +- flake.nix | 21 +- quickshell/tests/README.md | 31 +- quickshell/tests/bar-content.test.mjs | 34 -- .../tests/compositor-capabilities.test.mjs | 108 ---- quickshell/tests/dock-click.test.mjs | 38 -- quickshell/tests/fixtures/mpris_player.py | 166 ------ .../share/applications/firefox.desktop | 7 - .../fixtures/share/applications/kitty.desktop | 6 - .../fixtures/share/icons/hicolor/index.theme | 11 - .../mimetypes/application-x-executable.svg | 1 - quickshell/tests/island-routing.test.mjs | 128 ----- quickshell/tests/qml/bar-content.qml | 394 ------------- quickshell/tests/qml/bar-overflow.qml | 234 -------- quickshell/tests/qml/bar-widget-cursor.qml | 122 ---- quickshell/tests/qml/clipboard-preview.qml | 162 ------ quickshell/tests/qml/control-center-sizes.qml | 346 ------------ quickshell/tests/qml/focused-app.qml | 223 -------- quickshell/tests/qml/font-weights.qml | 47 -- quickshell/tests/qml/frame-bar-flip.qml | 93 ---- quickshell/tests/qml/grid-edit-layout.qml | 172 ------ quickshell/tests/qml/instance-routing.qml | 225 -------- quickshell/tests/qml/island-free.qml | 85 --- .../tests/qml/island-launcher-focus.qml | 134 ----- quickshell/tests/qml/island-pulses.qml | 149 ----- quickshell/tests/qml/island-sheet-hover.qml | 134 ----- .../tests/qml/launcher-plugin-instances.qml | 154 ------ quickshell/tests/qml/media-lyrics.qml | 513 ----------------- quickshell/tests/qml/media-playback.qml | 137 ----- quickshell/tests/qml/media-presentation.qml | 307 ----------- .../tests/qml/settings-group-layout.qml | 76 --- quickshell/tests/qml/settings-keyboard.qml | 521 ------------------ quickshell/tests/qml/settings-overflow.qml | 131 ----- quickshell/tests/qml/settings-panes.qml | 201 ------- quickshell/tests/qml/settings-scroll.qml | 149 ----- quickshell/tests/qml/theme-accents.qml | 66 --- .../tests/qml/theme-selected-container.qml | 78 --- quickshell/tests/qml/workspace-switcher.qml | 283 ---------- quickshell/tests/run-qml.py | 13 +- quickshell/tests/run-surface-fixture.py | 177 ------ quickshell/tests/wayland-commits.py | 34 -- 43 files changed, 19 insertions(+), 5899 deletions(-) delete mode 100644 quickshell/tests/compositor-capabilities.test.mjs delete mode 100644 quickshell/tests/dock-click.test.mjs delete mode 100644 quickshell/tests/fixtures/mpris_player.py delete mode 100644 quickshell/tests/fixtures/share/applications/firefox.desktop delete mode 100644 quickshell/tests/fixtures/share/applications/kitty.desktop delete mode 100644 quickshell/tests/fixtures/share/icons/hicolor/index.theme delete mode 100644 quickshell/tests/fixtures/share/icons/hicolor/scalable/mimetypes/application-x-executable.svg delete mode 100644 quickshell/tests/qml/bar-content.qml delete mode 100644 quickshell/tests/qml/bar-overflow.qml delete mode 100644 quickshell/tests/qml/bar-widget-cursor.qml delete mode 100644 quickshell/tests/qml/clipboard-preview.qml delete mode 100644 quickshell/tests/qml/control-center-sizes.qml delete mode 100644 quickshell/tests/qml/focused-app.qml delete mode 100644 quickshell/tests/qml/font-weights.qml delete mode 100644 quickshell/tests/qml/frame-bar-flip.qml delete mode 100644 quickshell/tests/qml/grid-edit-layout.qml delete mode 100644 quickshell/tests/qml/instance-routing.qml delete mode 100644 quickshell/tests/qml/island-free.qml delete mode 100644 quickshell/tests/qml/island-launcher-focus.qml delete mode 100644 quickshell/tests/qml/island-pulses.qml delete mode 100644 quickshell/tests/qml/island-sheet-hover.qml delete mode 100644 quickshell/tests/qml/launcher-plugin-instances.qml delete mode 100644 quickshell/tests/qml/media-lyrics.qml delete mode 100644 quickshell/tests/qml/media-playback.qml delete mode 100644 quickshell/tests/qml/media-presentation.qml delete mode 100644 quickshell/tests/qml/settings-group-layout.qml delete mode 100644 quickshell/tests/qml/settings-keyboard.qml delete mode 100644 quickshell/tests/qml/settings-overflow.qml delete mode 100644 quickshell/tests/qml/settings-panes.qml delete mode 100644 quickshell/tests/qml/settings-scroll.qml delete mode 100644 quickshell/tests/qml/theme-accents.qml delete mode 100644 quickshell/tests/qml/theme-selected-container.qml delete mode 100644 quickshell/tests/qml/workspace-switcher.qml delete mode 100644 quickshell/tests/run-surface-fixture.py delete mode 100644 quickshell/tests/wayland-commits.py diff --git a/.github/workflows/qml-tests.yml b/.github/workflows/qml-tests.yml index 3de90109f..25eb3558a 100644 --- a/.github/workflows/qml-tests.yml +++ b/.github/workflows/qml-tests.yml @@ -38,11 +38,11 @@ jobs: - name: Install test dependencies run: | pacman-key --init - pacman -Syu --noconfirm --needed git make nodejs python lua quickshell qt6-declarative niri xorg-server-xvfb libxcursor ttf-dejavu dbus python-dbus-next + pacman -Syu --noconfirm --needed git make nodejs python lua quickshell qt6-declarative ttf-dejavu - name: Record runtime versions run: | - pacman -Q quickshell qt6-declarative niri + pacman -Q quickshell qt6-declarative - name: Checkout uses: actions/checkout@v6 diff --git a/AGENTS.md b/AGENTS.md index dbfbb9e84..46f6e89a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,7 @@ DMS is an open-source desktop shell for Wayland compositors on linux, built to w - A test must fail on the broken code. Otherwise do not write it. - No useless tests: "main.go contains func main()", defaults, "it instantiates", behavior the change didn't touch. - QML tests must be fast. Call logic directly, load the smallest component, no fixed delays or animation waits, few meaningful assertions over a matrix. +- Tests cover `.js` modules, functions of `Services/` and `Common/` singletons, and IPC handlers. Never load, slice or mock anything under `Modules/`, `Modals/` or `Widgets/`, and never spawn a compositor, display server or shell. Those tests only ever broke on UI tweaks. ## Connections vs bound properties diff --git a/Makefile b/Makefile index d91eb07ce..f7a7b8837 100644 --- a/Makefile +++ b/Makefile @@ -153,7 +153,7 @@ help: @echo " build - Same as 'all'" @echo " clean - Clean build artifacts" @echo " lint-qml - Run qmllint on shell entrypoints using the Quickshell tooling VFS" - @echo " test-qml - Run shell logic, Qt unit tests and QML widget regressions" + @echo " test-qml - Run shell logic and Qt unit tests" @echo "" @echo "Install:" @echo " install - Build and install everything (requires sudo)" diff --git a/flake.nix b/flake.nix index 07994774a..1bd064bbf 100644 --- a/flake.nix +++ b/flake.nix @@ -235,22 +235,6 @@ kdePackages.qtdeclarative ] ++ (qmlPkgs pkgs); - # the surface fixtures run niri on winit/X11, which dlopens these - niriForTests = pkgs.symlinkJoin { - name = "niri-x11"; - paths = [ pkgs.niri ]; - nativeBuildInputs = [ pkgs.makeWrapper ]; - postBuild = '' - wrapProgram $out/bin/niri --prefix LD_LIBRARY_PATH : ${ - pkgs.lib.makeLibraryPath [ - (pkgs.libx11 or pkgs.xorg.libX11) - (pkgs.libxcb or pkgs.xorg.libxcb) - (pkgs.libxcursor or pkgs.xorg.libXcursor) - (pkgs.libxi or pkgs.xorg.libXi) - ] - } - ''; - }; in { default = pkgs.mkShell { @@ -265,7 +249,7 @@ gnumake nodejs lua - (python3.withPackages (ps: [ ps.dbus-next ])) + python3 matugen prek @@ -276,8 +260,7 @@ nixd nil ] - ++ devQmlPkgs - ++ pkgs.lib.optionals pkgs.stdenv.hostPlatform.isLinux [ niriForTests pkgs.xvfb pkgs.dbus ]; + ++ devQmlPkgs; shellHook = '' touch quickshell/.qmlls.ini 2>/dev/null diff --git a/quickshell/tests/README.md b/quickshell/tests/README.md index db49a2726..111786cb2 100644 --- a/quickshell/tests/README.md +++ b/quickshell/tests/README.md @@ -1,36 +1,21 @@ # Shell tests -`make test-qml` runs the shell JavaScript tests, Qt output-cycle tests and every QML fixture in `qml/`. It needs Python 3, Node.js, make, Lua, Qt 6 qmltestrunner, Quickshell, niri, Xvfb, dbus-daemon or dbus-broker, and the `dbus_next` Python module. The Nix development shell includes these dependencies on Linux. +`make test-qml` runs three suites: `logic` (the `*.test.mjs` files here under `node --test`), `qt` (`qmltestrunner` over `unit/`) and `lock` (`qmltestrunner` over `lock/` with the mocks in `lock/mocks`). It needs Python 3, Node.js, Lua, make and the Qt 6 `qmltestrunner`. The Nix development shell includes them. -The `qml-tests` pre-commit hook runs the same checks when shell code, tests or their configuration changes. CI runs that hook in the QML tests job. The other pre-commit job skips it to avoid running it twice. +The `qml-tests` pre-commit hook runs the same suites when shell code, tests or their configuration changes. CI runs that hook in the QML tests job. ```sh prek run qml-tests --all-files python3 quickshell/tests/run-qml.py logic python3 quickshell/tests/run-qml.py qt python3 quickshell/tests/run-qml.py lock -python3 quickshell/tests/run-qml.py widgets -python3 quickshell/tests/run-qml.py media ``` -Set `QMLTESTRUNNER` to override the Qt 6 executable. Use `--jobs 1` to run the suites sequentially. +Set `QMLTESTRUNNER` to override the Qt 6 executable. -Every file in `qml/` runs. `media-*.qml` fixtures get a session bus and a fake MPRIS player with artwork, the rest run in the widgets suite. To run some fixtures on their own: +## Writing a test -```sh -python3 quickshell/tests/run-surface-fixture.py quickshell/tests/qml/bar-content.qml quickshell/tests/qml/instance-routing.qml -python3 quickshell/tests/run-surface-fixture.py --mpris --artwork quickshell/tests/qml/media-lyrics.qml -``` - -The runner prints one line per fixture as it finishes and the full log of a failed one. A fixture fails on a nonzero exit, a missing `FIXTURE_PASS`, a `FIXTURE_FAIL` line or its 60 second cap. Half the available cores run at a time, up to eight; set `DMS_FIXTURE_JOBS` to change that. - -The fixtures share one X server. Each fixture gets its own niri, Quickshell process, QML tree and config, cache, state and data directories, so nothing one fixture does is visible to another. The host is hidden too: `HOME`, `XDG_DATA_DIRS` and `XDG_CONFIG_DIRS` point into the temporary root, the locale is `C.UTF-8`, the timezone is UTC, and host `DMS_*`, `QS_*` and Qt theme and scale variables are dropped. The only desktop entries and icons a fixture sees are the ones in `fixtures/share`; add to it when a fixture needs an app or icon. Idle, lock and suspend timeouts are disabled. The runner does not connect to the live shell or session bus. - -## Writing a fixture - -- Wait on the condition, never on the clock. Poll with a 20 second deadline and a label, then assert. A pass returns as soon as the condition holds, so the budget costs nothing. -- Start from the real precondition (player on the bus, window active, settings loaded), not a startup timer. -- Settle layout with `isPolishScheduled`/`waitForPolish` on `item.Window.window` before reading geometry or clicking. -- Keys only reach a window niri has activated. Wait for `Window.active` before `keyClick`. -- A step `Timer` retries its checks until they pass or its deadline runs out, then moves on. Put every check before the step's mutations so a retry is safe. Give it an interval above 0: a repeating 0ms `Timer` never finishes under qs. -- Assert behavior that would break on a regression. No pixel sampling, no size or locale matrices, no screenshots, no private state unless it is the only deterministic way to order a race. +- A test exercises a `.js` module, a function of a `Services/` or `Common/` singleton, or an IPC handler. Nothing under `Modules/`, `Modals/` or `Widgets/` is read, sliced or mocked. Those files change with every UI tweak, and tests over them only ever failed on the tweak. +- No compositor, display server, running shell, timers or waits. Each suite finishes in well under a second. +- A test must fail on the broken code. No defaults, no tables asserted against a copy of themselves, no "it instantiates". +- When a test slices QML source, the mock has to honour the arguments the function passes. A mock that ignores them passes on the old code and fails on an equivalent rewrite. diff --git a/quickshell/tests/bar-content.test.mjs b/quickshell/tests/bar-content.test.mjs index 96be287bd..bd3ce0d08 100644 --- a/quickshell/tests/bar-content.test.mjs +++ b/quickshell/tests/bar-content.test.mjs @@ -57,25 +57,6 @@ test("focus discovery keeps compositor names separate from first-screen fallback assert.equal(context.getFocusedScreen(), null); }); -test("bar actions preserve first-instance fallback and reject a focused output without a bar", () => { - const source = read("Modules/DankBar/DankBar.qml"); - let focused = ""; - const calls = []; - const instance = name => ({ modelData: { name }, triggerControlCenter() { calls.push("cc:" + name); }, triggerWallpaperBrowser() { calls.push("wallpaper:" + name); } }); - const context = vm.createContext({ CompositorService: { getFocusedScreenName: () => focused }, barVariants: { instances: [instance("second"), instance("first")] } }); - for (const name of ["focusedBarInstance", "triggerControlCenterOnFocusedScreen", "triggerWallpaperBrowserOnFocusedScreen"]) - vm.runInContext(method(source, name), context); - assert.equal(context.triggerControlCenterOnFocusedScreen(), true); - focused = "first"; - assert.equal(context.triggerWallpaperBrowserOnFocusedScreen(), true); - focused = "missing"; - assert.equal(context.triggerControlCenterOnFocusedScreen(), false); - focused = ""; - context.barVariants.instances = []; - assert.equal(context.triggerWallpaperBrowserOnFocusedScreen(), false); - assert.deepEqual(calls, ["cc:second", "wallpaper:first"]); -}); - test("center placement preserves configured anchors, visible fallbacks and geometric extents", () => { const layout = vm.createContext({}); vm.runInContext(read("Modules/DankBar/CenterLayout.js").replace(/^\.pragma.*$/m, ""), layout); @@ -110,18 +91,3 @@ test("center placement yields to side sections without overlapping them", () => for (const [bounds, positions] of cases) assert.deepEqual(plain(layout.resolve([30, 80, 60], 600, 4, "geometric", bounds)), { positions, totalSize: 178 }); }); - -test("widget lookup preserves component aliases and plugin variant fallback", () => { - const builtin = { clockComponent: {}, mediaComponent: {}, mediaActivityComponent: {}, networkComponent: {}, keyboardLayoutNameComponent: {}, appsDockComponent: {} }; - const plugins = { example: {}, "example:specific": {} }; - const components = Object.assign(model.builtinComponents(builtin), plugins); - const context = vm.createContext({}); - vm.runInContext(method(read("Modules/SurfaceWidgets/SurfaceWidgetHost.qml"), "getWidgetComponent"), context); - for (const [id, name] of [["clock", "clockComponent"], ["music", "mediaComponent"], ["mediaActivity", "mediaActivityComponent"], ["network_speed_monitor", "networkComponent"], ["keyboard_layout_name", "keyboardLayoutNameComponent"], ["appsDock", "appsDockComponent"]]) - assert.equal(context.getWidgetComponent(id, components), builtin[name]); - assert.equal(context.getWidgetComponent("example:variant", components), plugins.example); - assert.equal(context.getWidgetComponent("example:specific", components), plugins["example:specific"]); - assert.equal(context.getWidgetComponent("clock", {}), null); - assert.equal(context.getWidgetComponent("unknown", components), null); - assert.equal(context.getWidgetComponent("clock", null), null); -}); diff --git a/quickshell/tests/compositor-capabilities.test.mjs b/quickshell/tests/compositor-capabilities.test.mjs deleted file mode 100644 index 617a86475..000000000 --- a/quickshell/tests/compositor-capabilities.test.mjs +++ /dev/null @@ -1,108 +0,0 @@ -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import test from "node:test"; -import vm from "node:vm"; - -const COMPOSITORS = ["niri", "hyprland", "mango", "sway", "scroll", "miracle", "labwc", "aqueous"]; -const IDENTITY = { niri: "isNiri", hyprland: "isHyprland", mango: "isMango", sway: "isSway", scroll: "isScroll", miracle: "isMiracle", labwc: "isLabwc", aqueous: "isAqueous", umbriel: "isUmbriel" }; - -const service = readFileSync(new URL("../Services/CompositorService.qml", import.meta.url), "utf8"); - -function declarations(source) { - const found = new Map(); - const re = /^ readonly property (bool|string) (isKnownCompositor|supports[A-Z]\w*|displayName|configKey):\s*/gm; - let match; - while ((match = re.exec(source)) !== null) { - const name = match[2]; - const start = re.lastIndex; - let end = start; - if (source[start] === "{") { - let depth = 0; - for (let i = start; i < source.length; i++) { - if (source[i] === "{") - depth++; - if (source[i] === "}") - depth--; - if (depth === 0) { - end = i + 1; - break; - } - } - } else { - end = source.indexOf("\n", start); - } - if (found.has(name)) - found.get(name).push(source.slice(start, end)); - else - found.set(name, [source.slice(start, end)]); - } - return found; -} - -const DECLS = declarations(service); - -function run(name, scope) { - const body = DECLS.get(name)[0]; - const expression = body.startsWith("{") ? `(() => ${body})()` : `(${body})`; - return vm.runInNewContext(expression, scope); -} - -function evaluate(name, compositor) { - const scope = { compositor: compositor ?? "unknown" }; - for (const [key, flag] of Object.entries(IDENTITY)) - scope[flag] = key === compositor; - if (name === "configKey") - return run(name, scope); - scope.configKey = run("configKey", scope); - if (name === "isKnownCompositor") - return run(name, scope); - scope.isKnownCompositor = run("isKnownCompositor", scope); - return run(name, scope); -} - -function setOf(name) { - return COMPOSITORS.filter(c => evaluate(name, c) === true); -} - -function expectSet(name, expected) { - assert.deepEqual(setOf(name), expected, name); - assert.equal(evaluate(name, undefined), false, `${name} on an undetected compositor`); -} - -test("window rules, layout, cursor and bar auto hide cover niri, hyprland and mango", () => { - expectSet("supportsWindowRules", ["niri", "hyprland", "mango"]); - expectSet("supportsLayoutConfig", ["niri", "hyprland", "mango"]); - expectSet("supportsCursorConfig", ["niri", "hyprland", "mango"]); - expectSet("supportsBarAutoHideReveal", ["niri", "hyprland", "mango"]); -}); - -test("display config, workspaces and smart dock add aqueous", () => { - expectSet("supportsDisplayConfig", ["niri", "hyprland", "mango", "aqueous"]); - expectSet("supportsWorkspaces", ["niri", "hyprland", "mango", "aqueous"]); - expectSet("supportsSmartDock", ["niri", "hyprland", "mango", "aqueous"]); -}); - -test("workspace urgency and follow focus cover every compositor but labwc", () => { - expectSet("supportsWorkspaceUrgency", ["niri", "hyprland", "mango", "sway", "scroll", "miracle", "aqueous"]); - expectSet("supportsWorkspaceFollowFocus", ["niri", "hyprland", "mango", "sway", "scroll", "miracle", "aqueous"]); -}); - -test("persistent workspaces cover compositors that open a missing workspace on demand", () => { - expectSet("supportsPersistentWorkspaces", ["hyprland", "mango", "sway", "scroll", "miracle"]); -}); - -test("native overview is niri and aqueous, pointer is niri and mango, input is niri", () => { - expectSet("supportsNativeOverview", ["niri", "aqueous"]); - expectSet("supportsPointerConfig", ["niri", "mango"]); - expectSet("supportsInputConfig", ["niri"]); -}); - -test("every compositor is known and an undetected one is not", () => { - expectSet("isKnownCompositor", COMPOSITORS); -}); - -test("configKey is the lowercase key and empty when undetected", () => { - for (const compositor of COMPOSITORS) - assert.equal(evaluate("configKey", compositor), compositor); - assert.equal(evaluate("configKey", undefined), ""); -}); diff --git a/quickshell/tests/dock-click.test.mjs b/quickshell/tests/dock-click.test.mjs deleted file mode 100644 index c191eafe3..000000000 --- a/quickshell/tests/dock-click.test.mjs +++ /dev/null @@ -1,38 +0,0 @@ -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import vm from "node:vm"; -import test from "node:test"; - -const source = readFileSync(new URL("../Modules/Dock/DockAppButton.qml", import.meta.url), "utf8"); -const helpers = source.slice(source.indexOf(" function getToplevelObject("), source.indexOf(" function activate(")); -const click = source.slice(source.indexOf(" function handleLeftClick("), source.indexOf(" onPositionChanged:")); - -test("left click cycles grouped windows and wraps, including from overview", () => { - for (const initialFocus of ["none", "active", "overview"]) { - const windows = [{ activated: false }, { activated: initialFocus === "active" }, { activated: false }]; - const activated = []; - let overviewFocused = initialFocus === "overview" ? windows[1] : null; - const context = vm.createContext({ - appData: { type: "grouped", windowCount: windows.length, allWindows: windows.map(toplevel => ({ toplevel })) }, - options: { restoreSpecialWorkspaceOnClick: false }, - contextMenu: { showForButton() { assert.fail("left click opened the context menu"); } }, - height: 0, - cachedDesktopEntry: null, - parentDockScreen: null, - dockApps: null, - CompositorService: { - overviewFocusedToplevel: () => overviewFocused, - activateToplevel(toplevel) { - activated.push(windows.indexOf(toplevel)); - windows.forEach(window => { window.activated = window === toplevel; }); - overviewFocused = null; - }, - }, - }); - context.root = context; - vm.runInContext(helpers + click, context); - for (let i = 0; i < 4; i++) - context.handleLeftClick(); - assert.deepEqual(activated, initialFocus === "none" ? [0, 1, 2, 0] : [2, 0, 1, 2], initialFocus); - } -}); diff --git a/quickshell/tests/fixtures/mpris_player.py b/quickshell/tests/fixtures/mpris_player.py deleted file mode 100644 index 6e9774ef0..000000000 --- a/quickshell/tests/fixtures/mpris_player.py +++ /dev/null @@ -1,166 +0,0 @@ -import asyncio -import struct -import sys -import zlib -from pathlib import Path -import tempfile -import time -from dbus_next import Variant -from dbus_next.aio import MessageBus -from dbus_next.constants import PropertyAccess -from dbus_next.service import ServiceInterface, dbus_property, method, signal - - -def png_chunk(kind, data): - return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data)) - - -class Player(ServiceInterface): - def __init__(self): - super().__init__("org.mpris.MediaPlayer2.Player") - self.state = "Playing" - self.position = 0 - self.position_time = time.monotonic() - self.loop = "None" - self.shuffle = False - self.track = 1 - self.art_directory = tempfile.TemporaryDirectory(prefix="dms-mpris-art-") if "--artwork" in sys.argv else None - - def art_url(self): - if not self.art_directory: - return "" - path = Path(self.art_directory.name) / (str(self.track) + ".png") - if not path.exists(): - width = height = 64 - color = [(204, 40, 40), (40, 40, 204), (40, 204, 40)][(self.track - 1) % 3] - rows = (b"\x00" + bytes(color) * width) * height - path.write_bytes(b"\x89PNG\r\n\x1a\n" + png_chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)) + png_chunk(b"IDAT", zlib.compress(rows)) + png_chunk(b"IEND", b"")) - return path.as_uri() - - @dbus_property(access=PropertyAccess.READ) - def Position(self) -> "x": - elapsed = time.monotonic() - self.position_time if self.state == "Playing" else 0 - return self.position + int(elapsed * 1000000) - - @dbus_property(access=PropertyAccess.READ) - def CanSeek(self) -> "b": - return True - - @method() - def SetPosition(self, track_id: "o", position: "x"): - self.position = position - self.position_time = time.monotonic() - self.Seeked(position) - - @signal() - def Seeked(self, position) -> "x": - return position - - @dbus_property(access=PropertyAccess.READ) - def PlaybackStatus(self) -> "s": - return self.state - - @dbus_property(access=PropertyAccess.READ) - def Metadata(self) -> "a{sv}": - metadata = {"xesam:title": Variant("s", "Track " + str(self.track)), "xesam:artist": Variant("as", ["Fixture"]), "mpris:trackid": Variant("o", "/track/" + str(self.track))} - if self.art_directory: - metadata["mpris:artUrl"] = Variant("s", self.art_url()) - return metadata - - @dbus_property(access=PropertyAccess.READ) - def CanControl(self) -> "b": - return True - - @dbus_property(access=PropertyAccess.READ) - def CanPlay(self) -> "b": - return True - - @dbus_property(access=PropertyAccess.READ) - def CanPause(self) -> "b": - return True - - @dbus_property(access=PropertyAccess.READ) - def CanGoNext(self) -> "b": - return True - - @dbus_property(access=PropertyAccess.READ) - def CanGoPrevious(self) -> "b": - return True - - @dbus_property() - def LoopStatus(self) -> "s": - return self.loop - - @LoopStatus.setter - def LoopStatus(self, value: "s"): - self.loop = value - self.emit_properties_changed({"LoopStatus": value}) - - @dbus_property() - def Shuffle(self) -> "b": - return self.shuffle - - @Shuffle.setter - def Shuffle(self, value: "b"): - self.shuffle = value - self.emit_properties_changed({"Shuffle": value}) - - def set_state(self, state): - self.position = self.Position - self.position_time = time.monotonic() - self.state = state - self.emit_properties_changed({"PlaybackStatus": state}) - - @method() - def Play(self): - self.set_state("Playing") - - @method() - def Pause(self): - self.set_state("Paused") - - @method() - def PlayPause(self): - self.set_state("Paused" if self.state == "Playing" else "Playing") - - @method() - def Stop(self): - self.set_state("Stopped") - - # Chromium reports Stopped between tracks - async def skip(self, delta): - state = self.state - if state == "Playing": - self.set_state("Stopped") - await asyncio.sleep(0.08) - self.track = max(1, self.track + delta) - self.emit_properties_changed({"Metadata": self.Metadata}) - self.set_state(state) - - @method() - async def Next(self): - await self.skip(1) - - @method() - async def Previous(self): - await self.skip(-1) - - -class Application(ServiceInterface): - def __init__(self): - super().__init__("org.mpris.MediaPlayer2") - - @dbus_property(access=PropertyAccess.READ) - def Identity(self) -> "s": - return "DMS MPRIS fixture" - - -async def main(): - bus = await MessageBus().connect() - bus.export("/org/mpris/MediaPlayer2", Application()) - bus.export("/org/mpris/MediaPlayer2", Player()) - await bus.request_name("org.mpris.MediaPlayer2.dms_fixture") - await asyncio.Future() - - -asyncio.run(main()) diff --git a/quickshell/tests/fixtures/share/applications/firefox.desktop b/quickshell/tests/fixtures/share/applications/firefox.desktop deleted file mode 100644 index 90ecc67bb..000000000 --- a/quickshell/tests/fixtures/share/applications/firefox.desktop +++ /dev/null @@ -1,7 +0,0 @@ -[Desktop Entry] -Type=Application -Name=Firefox -Exec=firefox %u -Icon=firefox -StartupWMClass=firefox -Categories=Network;WebBrowser; diff --git a/quickshell/tests/fixtures/share/applications/kitty.desktop b/quickshell/tests/fixtures/share/applications/kitty.desktop deleted file mode 100644 index 20121b8a7..000000000 --- a/quickshell/tests/fixtures/share/applications/kitty.desktop +++ /dev/null @@ -1,6 +0,0 @@ -[Desktop Entry] -Type=Application -Name=kitty -Exec=kitty -Icon=kitty -Categories=System;TerminalEmulator; diff --git a/quickshell/tests/fixtures/share/icons/hicolor/index.theme b/quickshell/tests/fixtures/share/icons/hicolor/index.theme deleted file mode 100644 index e414d4b84..000000000 --- a/quickshell/tests/fixtures/share/icons/hicolor/index.theme +++ /dev/null @@ -1,11 +0,0 @@ -[Icon Theme] -Name=Hicolor -Comment=Fixture icons -Directories=scalable/mimetypes - -[scalable/mimetypes] -Size=48 -MinSize=8 -MaxSize=512 -Type=Scalable -Context=MimeTypes diff --git a/quickshell/tests/fixtures/share/icons/hicolor/scalable/mimetypes/application-x-executable.svg b/quickshell/tests/fixtures/share/icons/hicolor/scalable/mimetypes/application-x-executable.svg deleted file mode 100644 index 1cfb4ca84..000000000 --- a/quickshell/tests/fixtures/share/icons/hicolor/scalable/mimetypes/application-x-executable.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/quickshell/tests/island-routing.test.mjs b/quickshell/tests/island-routing.test.mjs index 4dc5638a4..0db8e7c59 100644 --- a/quickshell/tests/island-routing.test.mjs +++ b/quickshell/tests/island-routing.test.mjs @@ -17,7 +17,6 @@ const widgetDefaults = vm.createContext({}); vm.runInContext(readFileSync(new URL("../Common/settings/BarWidgetDefaults.js", import.meta.url), "utf8").replace(/^\.pragma.*$/m, ""), widgetDefaults); const plain = value => JSON.parse(JSON.stringify(value)); const islandDashActivities = ["home", "media", "weather", "wallpaper"]; -const islandSource = readFileSync(new URL("../Modules/DankIsland/DankIsland.qml", import.meta.url), "utf8"); test("shared shortcuts follow the last eligible surface independently on each screen", () => { const screens = [{ name: "internal" }, { name: "external" }]; @@ -404,26 +403,6 @@ test("hidden bars drop out of routing and hosted defaults keep popups and OSDs s assert.deepEqual(settings.activeIslandConfigsForScreen(screen), [], "a hidden island-layout bar swallows nothing either"); }); -test("dot IPC has island command parity and preserves monitor and instance addressing", () => { - const handlers = {}; - for (const kind of ["island", "dot"]) { - const source = islandSource.split(`target: "${kind}"`)[1].split("\n IpcHandler {")[0]; - const root = Object.fromEntries(["Open", "Toggle", "Show", "Close", "Cycle", "Status", "Move", "Center"].map(name => [`ipc${name}`, (...args) => args])); - handlers[kind] = functions(source, " ", vm.createContext({ root })); - } - const names = handler => Object.keys(handler).filter(key => key !== "root").sort(); - assert.deepEqual(names(handlers.dot), names(handlers.island)); - assert.deepEqual(handlers.island.notifications(), ["notificationcenter", "", "", "island"]); - for (const name of ["openOn", "toggleOn", "showOn"]) - assert.deepEqual(handlers.dot[name]("weather", "external"), ["weather", "external", "", "dot"]); - for (const name of ["closeOn", "cycleOn", "statusOn"]) - assert.deepEqual(handlers.dot[name]("external"), ["external", "", "dot"]); - assert.deepEqual(handlers.dot.notificationsOn("external"), ["notificationcenter", "external", "", "dot"]); - for (const name of ["openInstance", "toggleInstance"]) - assert.deepEqual(handlers.dot[name]("launcher", "external", "second-dot"), ["launcher", "external", "second-dot", "dot"]); -}); - - test("shared launcher entry points bypass a previously loaded launcher and forward query/mode", () => { const calls = []; let fallbacks = 0; @@ -529,113 +508,6 @@ test("control-center IPC hide and status agree on both surfaces", () => { assert.deepEqual(calls.splice(0), ["island:toggle"]); }); -test("an IPC close collapses the activity on every monitor showing it", () => { - const collapsed = []; - const host = (name, activity) => ({ screen: { name }, islandController: { activeActivity: activity, expanded: true, requestCollapse: () => collapsed.push(name) } }); - const island = functions(islandSource, " ", vm.createContext({ - islandVariants: { instances: [host("internal", "controlcenter"), host("external", "controlcenter"), host("third", "launcher")] }, - freeIslandVariants: { instances: [] } - })); - assert.equal(island.closeActivity("controlcenter"), true); - assert.deepEqual(collapsed.splice(0), ["internal", "external"], "both monitors close, the launcher island is left alone"); - assert.equal(island.closeActivity("media"), false, "nothing showed it"); -}); - -test("the island launcher only answers for the screen routing resolves to", () => { - const hosts = { internal: { islandController: { requestLauncher: () => "internal" } }, external: { islandController: { requestLauncher: () => "external" } } }; - let config = null; - const island = functions(islandSource, " ", vm.createContext({ - root: { hostForExactScreen: (screen, barId) => (barId === "island" ? hosts[screen.name] : null) ?? null, focusedHost: () => hosts.external }, - CompositorService: { getFocusedScreen: () => ({ name: "internal" }) }, - SettingsData: { islandLauncherHostConfig: () => config } - })); - assert.equal(island.openLauncher("", "", null, ""), false, "no island routed to the focused screen: the modal opens, never another monitor's island"); - config = { id: "island" }; - assert.equal(island.openLauncher("", "", null, ""), "internal"); - assert.equal(island.openLauncher("", "", { name: "external" }, ""), "external"); - assert.equal(island.openLauncher("", "", { name: "external" }, "other"), false, "an explicit bar id is exact"); -}); - -test("an expanding hosted island closes its own bar's popout only for deliberate opens", () => { - const hostSource = readFileSync(new URL("../Modules/DankIsland/IslandBarHost.qml", import.meta.url), "utf8"); - const closed = []; - const popouts = {}; - const modals = {}; - const controller = { keyboardDismissRequested: true }; - const root = { embedded: true, connectedChrome: false, screenName: "internal", screen: { name: "internal" }, barId: "bar", edge: "top" }; - const host = functions(hostSource, " ", vm.createContext({ - root, - controller, - PopoutManager: { currentPopoutsByScreen: popouts, closePopoutForScreen: screen => closed.push("popout:" + screen.name) }, - ModalManager: { currentModalsByScreen: modals, closeModal: modal => closed.push("modal:" + modal.id) }, - ConnectedModeState: { surfaceDescriptors: {} } - })); - root.ownBarPopout = host.ownBarPopout; - root.sameEdgeSlots = host.sameEdgeSlots; - popouts.internal = { shouldBeVisible: true, sourceRegistration: { context: { barId: "bar" } } }; - host.closeSameEdgeSurfaces(); - assert.deepEqual(closed.splice(0), ["popout:internal"], "a click or keybind open closes the bar's own popout"); - controller.keyboardDismissRequested = false; - host.closeSameEdgeSurfaces(); - assert.deepEqual(closed.splice(0), [], "a hover peek or an arriving notification leaves the popout alone"); - controller.keyboardDismissRequested = true; - popouts.internal.sourceRegistration.context.barId = "other-bar"; - host.closeSameEdgeSurfaces(); - assert.deepEqual(closed.splice(0), [], "another bar's popout is not on this edge"); - root.embedded = false; - popouts.internal.sourceRegistration.context.barId = "bar"; - host.closeSameEdgeSurfaces(); - assert.deepEqual(closed.splice(0), [], "island-layout bars and dots keep the old one-way rule"); - root.embedded = true; - root.connectedChrome = true; - modals.internal = { id: "launcher" }; - host.ConnectedModeState.surfaceDescriptors.internal = { modal: { visible: true, barSide: "top" }, popout: { visible: true, barSide: "bottom" } }; - host.closeSameEdgeSurfaces(); - assert.deepEqual(closed.splice(0), ["modal:launcher"], "connected chrome closes only the same-edge slot, and only this screen's modal"); -}); - -const controllerSource = readFileSync(new URL("../Modules/DankIsland/IslandController.qml", import.meta.url), "utf8"); - -function controllerContext() { - const context = vm.createContext({ - systemLevelActivities: ["volume", "brightness", "mic"], - systemStateActivities: ["capslock", "powerprofile", "idleinhibitor", "charging"], - activeActivity: "home", - expanded: false, - notificationActive: false, - launcherSessionActive: false, - transientReturnActivity: "", - hoverExpanded: false, - keyboardDismissRequested: false, - transientTimer: { stop() {}, restart() {} }, - notificationTimer: { stop() {} }, - hoverOpenTimer: { stop() {} }, - hoverCloseTimer: { stop() {} }, - destinations: [], - destinationState: {}, - destinationRevision: 0, - timeoutSuspended: false - }); - context.root = context; - context.systemActivities = context.systemLevelActivities.concat(context.systemStateActivities); - Object.defineProperty(context, "systemActivityActive", { get: () => context.isSystemActivity(context.activeActivity) }); - return functions(controllerSource, " ", context); -} - -test("state pulses are system transients that return to the interrupted activity", () => { - const controller = controllerContext(); - controller.activeActivity = "media"; - assert.equal(controller.requestSystemActivity("capslock"), true); - assert.equal(controller.activeActivity, "capslock"); - assert.equal(controller.transientReturnActivity, "media"); - assert.equal(controller.requestSystemActivity("charging"), true, "a second pulse replaces the first"); - assert.equal(controller.transientReturnActivity, "media", "the return point is kept across chained pulses"); - assert.equal(controller.requestSystemActivity("bogus"), false); - assert.equal(controller.activeActivity, "charging"); - controller.expanded = true; - assert.equal(controller.requestSystemActivity("mic"), false, "an open sheet is not interrupted"); -}); - test("the privacy slot is a known home layout entry appended after stored ones", () => { const groupIds = JSON.parse(settingsSource.match(/_islandHomeGroupIds: (\[.*\])/)[1]); const layoutDefault = JSON.parse(settingsSource.match(/_islandHomeLayoutDefault: (\[[\s\S]*?\n \])/)[1]); diff --git a/quickshell/tests/qml/bar-content.qml b/quickshell/tests/qml/bar-content.qml deleted file mode 100644 index 43dd9d478..000000000 --- a/quickshell/tests/qml/bar-content.qml +++ /dev/null @@ -1,394 +0,0 @@ -import QtQuick -import Quickshell -import qs.Common -import qs.Services -import qs.Widgets -import qs.Modules.DankBar -import qs.Modules.SurfaceWidgets -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - - function check(condition, message) { - if (!condition) - throw new Error(message); - } - - function hosts(section) { - const found = []; - function visit(item) { - if (item.sectionContext !== undefined && item.widgetId !== undefined) - found.push(item); - for (const child of item.children || []) - visit(child); - } - visit(section); - return found; - } - - function texts(item) { - if (!item.visible) - return []; - if (typeof item.text === "string") - return [item.text]; - return (item.children || []).reduce((result, child) => result.concat(texts(child)), []); - } - - property var retainedClockItem: null - - property var axis: ({ - isVertical: false, - edge: "top" - }) - property var entries: [ - { - widgetId: "clock", - id: "clock_0", - testWidth: 30, - testHeight: 22, - enabled: true - }, - { - widgetId: "clock", - id: "clock_1", - testWidth: 80, - testHeight: 30, - enabled: false - }, - { - widgetId: "clock", - id: "clock_2", - testWidth: 60, - testHeight: 44, - enabled: true - } - ] - ScriptModel { - id: widgets - values: root.entries - } - property var components: ({ - clock: mockWidget, - spacer: mockWidget - }) - - Component { - id: mockWidget - Item { - property var widgetData: null - property var surfaceContext: null - property string widgetInstanceId: "" - property bool surfaceLive: true - property var axis: null - property var parentScreen: null - property string section: "" - property real widgetThickness: 0 - property real barThickness: 0 - property real barSpacing: 0 - property var barConfig: null - property var blurBarWindow: null - property bool isFirst: false - property bool isLast: false - property real sectionSpacing: 0 - property real sectionAvailablePrimarySize: 0 - property bool isLeftBarEdge: false - property bool isRightBarEdge: false - property bool isTopBarEdge: false - property bool isBottomBarEdge: false - property real crossEdgeExtension: 0 - property string segmentRole: "solo" - width: axis?.isVertical ? widgetThickness : (widgetData?.testWidth ?? 0) - height: axis?.isVertical ? (widgetData?.testHeight ?? 0) : widgetThickness - } - } - - Component { - id: pluginWidget - Item { - property var axis: null - readonly property bool isVertical: axis?.isVertical ?? false - property var widgetData: null - property var parentScreen: null - property var pluginService: null - property var popoutService: null - property string pluginId: "" - property var variantId: null - property var variantData: null - signal clicked - width: 20 - height: 20 - } - } - - FloatingWindow { - visible: true - implicitWidth: 800 - implicitHeight: 700 - - Loader { - id: pluginContainer - sourceComponent: SurfaceWidgetHost { - widgetId: "fixture:variant" - components: PluginService.getWidgetComponents() - axis: root.axis - isInColumn: root.axis.isVertical - parentScreen: ({ - name: "fixture" - }) - widgetData: ({ - enabled: true, - payload: 42 - }) - } - } - - LeftSection { - id: left - width: 600 - height: 80 - axis: root.axis - widgetsModel: widgets - components: root.components - surfaceContext: ({ - kind: "bar", - id: "fixture", - live: false - }) - parentScreen: ({ - name: "fixture" - }) - widgetThickness: 32 - barThickness: 48 - barSpacing: 6 - barConfig: ({ - id: "fixture", - widgetOutlineEnabled: true, - widgetOutlineThickness: 2, - widgetStyle: "segments" - }) - sectionAvailablePrimarySize: 500 - crossEdgeExtension: 7 - } - RightSection { - id: right - y: 90 - width: 600 - height: 80 - axis: root.axis - widgetsModel: widgets - components: root.components - barConfig: ({ - id: "fixture", - widgetStyle: "segments" - }) - crossEdgeExtension: 7 - } - Item { - y: 180 - width: 600 - height: 300 - CenterSection { - id: center - axis: root.axis - widgetsModel: widgets - components: root.components - barConfig: ({ - id: "fixture", - widgetStyle: "segments" - }) - } - } - ClockContent { - id: clock - y: 500 - date: new Date(2026, 8, 9, 0, 5, 9) - locale: Qt.locale("en_US") - } - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - } - - Timer { - interval: 0 - running: SettingsData._hasLoaded && SessionData._hasLoaded - onTriggered: { - PluginService.availablePlugins = { - fixture: { - id: "fixture" - } - }; - SettingsData.setPluginSetting("fixture", "variants", [ - { - id: "variant", - payload: 17 - } - ]); - PluginService.pluginWidgetComponents = { - fixture: pluginWidget - }; - SettingsData.centeringMode = "geometric"; - SettingsData.clockDateFormat = "ddd d"; - SettingsData.clockFormat = "24h"; - SettingsData.showSeconds = true; - checks.start(); - } - } - - Timer { - id: checks - interval: 16 - repeat: true - property int step: 0 - property real stepStarted: 0 - onTriggered: { - if (!stepStarted) - stepStarted = Date.now(); - try { - const leftHosts = root.hosts(left); - const rightHosts = root.hosts(right); - const centerHosts = root.hosts(center); - root.check(leftHosts.length === 3 && rightHosts.length === 3 && centerHosts.length === 3, "all sections load each occurrence"); - const first = leftHosts[0].item; - const last = leftHosts[2].item; - root.check((leftHosts[1].item !== null) === (step >= 8) && last.isLast && first.isFirst, "enabled and boundary injection"); - root.check(first.section === "left" && rightHosts[0].item.section === "right" && centerHosts[0].item.section === "center", "section injection"); - root.check(first.widgetThickness === left.widgetThickness && first.barThickness === 48 && first.barSpacing === 6 && first.parentScreen.name === "fixture" && first.sectionAvailablePrimarySize === 500 && first.barConfig.id === "fixture", "host context injection"); - root.check(first.surfaceContext === left.surfaceContext && first.widgetInstanceId === "clock_0" && first.surfaceLive === (step >= 8), "surface context and instance injection"); - switch (step) { - case 0: - const plugin = pluginContainer.item.item; - root.check(plugin.pluginService === PluginService && plugin.popoutService === PopoutService, "plugin service injection"); - root.check(plugin.pluginId === "fixture" && plugin.variantId === "variant" && plugin.variantData.payload === 17 && plugin.widgetData.payload === 42, "plugin variant and widget metadata"); - root.check(BarWidgetService.getWidget("fixture:variant", "fixture") === plugin, "plugin registration"); - root.check(BarWidgetService.getWidget("clock", "fixture", "bar:fixture:left:clock_0") === first && BarWidgetService.getWidget("clock", "fixture", "bar:fixture:left:clock_2") === last, "duplicate widget instance registration"); - root.check(first.isLeftBarEdge && rightHosts[2].item.isRightBarEdge && first.crossEdgeExtension === 7, "horizontal edge injection"); - root.check(first.segmentRole === "first" && last.segmentRole === "last" && centerHosts[0].item.segmentRole === "first" && centerHosts[2].item.segmentRole === "last", "segment roles skip the hidden middle occurrence"); - root.check(left.widgetSpacing === 6 && left.segmented, "segments spacing adds the outline"); - root.check(clock.timeText === "00:05:09" && clock.dateText === "Wed 9", "configured 24-hour time and date"); - SettingsData.clockFormat = "12h"; - SettingsData.padHours12Hour = false; - break; - case 1: - root.check(clock.timeText === "12:05:09 AM", "midnight in 12-hour time"); - clock.date = new Date(2026, 8, 9, 7, 5, 9); - SettingsData.showSeconds = false; - break; - case 2: - root.check(clock.timeText === "7:05 AM", "unpadded hours and hidden seconds"); - SettingsData.padHours12Hour = true; - SettingsData.clockDateFormat = "yyyy/MM/dd 'custom'"; - clock.vertical = true; - root.axis = { - isVertical: true, - edge: "left" - }; - break; - case 3: - root.check(pluginContainer.item.item.isVertical && pluginContainer.item.item.parentScreen.name === "fixture", "readonly plugin orientation and optional properties"); - root.check(clock.timeText === "07:05 AM" && clock.timeLines.join("|") === "07|05|AM", "vertical uses the same padding and period, got " + clock.timeText + " / " + clock.timeLines.join("|")); - root.check(clock.dateText === "2026/09/09 custom", "custom date preserved vertically"); - root.check(first.isTopBarEdge && !first.isLeftBarEdge && first.crossEdgeExtension === 0 && rightHosts[2].item.isBottomBarEdge, "vertical edge injection"); - left.edgeIsScreenEdge = false; - SettingsData.showSeconds = true; - clock.date = new Date(2026, 8, 9, 12, 5, 9); - break; - case 4: - root.check(!first.isTopBarEdge, "edge changes remain bound"); - root.check(clock.timeText === "12:05:09 PM" && clock.timeLines.join("|") === "12|05|09|PM", "noon and vertical seconds"); - clock.locale = Qt.locale("ar_EG"); - SettingsData.clockDateFormat = "dddd d MMMM yyyy"; - root.check(clock.dateText === clock.date.toLocaleDateString(Qt.locale("ar_EG"), "dddd d MMMM yyyy"), "custom date follows the clock locale"); - root.check(clock.timeText === clock.date.toLocaleTimeString(Qt.locale("ar_EG"), "hh:mm:ss AP"), "time follows the clock locale"); - clock.locale = Qt.locale("en_US"); - SettingsData.clockDateFormat = "ddd d"; - clock.vertical = false; - clock.displayMode = "time"; - break; - case 5: - root.check(root.texts(clock).join("|") === "12:05:09 PM", "time-only presentation"); - clock.displayMode = "date"; - break; - case 6: - root.check(root.texts(clock).join("|") === "Wed 9", "date-only presentation"); - clock.displayMode = "both"; - clock.dateFirst = true; - break; - case 7: - root.check(root.texts(clock).indexOf(clock.dateText) >= 0 && root.texts(clock).indexOf(clock.dateText) < root.texts(clock).indexOf(clock.timeText), "date-first presentation"); - left.widgetThickness = 36; - left.surfaceContext = { - kind: "bar", - id: "fixture", - live: true - }; - root.entries = root.entries.map(entry => Object.assign({}, entry, { - enabled: true - })); - break; - case 8: - root.check(leftHosts[1].item.visible && first.widgetThickness === 36, "enabled and context updates remain bound"); - root.check(first.segmentRole === "first" && leftHosts[1].item.segmentRole === "middle" && last.segmentRole === "last" && centerHosts[1].item.segmentRole === "middle", "segment roles after enabling the middle occurrence: " + JSON.stringify([first.segmentRole, leftHosts[1].item.segmentRole, last.segmentRole, centerHosts[1].item.segmentRole, left.roles, center.roles])); - left.barConfig = { - id: "fixture", - widgetOutlineEnabled: true, - widgetOutlineThickness: 2, - widgetStyle: "pills" - }; - function findTime(item) { - if (typeof item.reserveText === "string" && item.text === clock.timeText) - return item; - for (const child of item.children || []) { - const found = findTime(child); - if (found) - return found; - } - return null; - } - pluginContainer.active = false; - root.retainedClockItem = findTime(clock); - clock.date = new Date(2026, 8, 9, 12, 5, 10); - break; - case 9: - root.check(!BarWidgetService.getWidget("fixture:variant", "fixture"), "plugin host teardown unregisters its widget"); - root.check(root.retainedClockItem && root.retainedClockItem.text === "12:05:10 PM", "clock ticks update retained text items"); - root.check(!left.segmented && first.segmentRole === "solo" && leftHosts[1].item.segmentRole === "solo" && last.segmentRole === "solo" && left.widgetSpacing === 8, "pills style keeps every widget solo"); - left.barConfig = { - id: "fixture", - widgetOutlineEnabled: true, - widgetOutlineThickness: 2, - widgetStyle: "segments" - }; - root.entries = [root.entries[0], - { - widgetId: "spacer", - id: "spacer_1", - testWidth: 10, - testHeight: 10, - enabled: true - }, - root.entries[2]]; - break; - case 10: - root.check(first.segmentRole === "solo" && leftHosts[1].item.segmentRole === "solo" && last.segmentRole === "solo" && centerHosts[2].item.segmentRole === "solo", "a spacer breaks the run"); - console.log("FIXTURE_PASS"); - stop(); - Qt.quit(); - } - step++; - stepStarted = Date.now(); - } catch (error) { - if (Date.now() - stepStarted < 20000) - return; - console.error("FIXTURE_FAIL", "step " + step + ": " + error.message); - stop(); - Qt.quit(); - } - } - } -} diff --git a/quickshell/tests/qml/bar-overflow.qml b/quickshell/tests/qml/bar-overflow.qml deleted file mode 100644 index 2ddf6d1a3..000000000 --- a/quickshell/tests/qml/bar-overflow.qml +++ /dev/null @@ -1,234 +0,0 @@ -import QtQuick -import Quickshell -import Quickshell.Wayland -import qs.Common -import qs.Services -import qs.Modules.DankBar -import qs.DankCommon.Common as DC -import "Modules/DankBar/WidgetModel.js" as WidgetModel - -ShellRoot { - id: root - - property real barLength: 600 - property real titleWidth: 80 - property bool vertical: false - property bool fitted: false - property var retainedItem: null - property var openSurface: null - property var retainedRegistration: null - property var config: ({ - id: "overflow-fixture", enabled: true, visible: true, position: vertical ? 2 : 0, - innerPadding: 4, widgetPadding: 8, spacing: 4, popupGapsAuto: true, - widgetStyle: "segments", leftOverflowPosition: 1, leftOverflowMode: "auto", - leftWidgets: [ - { id: "clock", testWidth: 100, overflowMode: "bar" }, - { id: "fixture:variant", testWidth: 40, overflowMode: "always", collapses: true }, - { id: "clock", dynamic: true } - ], - centerWidgets: [{ id: "clock", testWidth: 20, overflowMode: "always" }, { id: "clock", testWidth: 80 }, { id: "clock", testWidth: 30 }], - rightWidgets: [{ id: "clock", testWidth: 50 }] - }) - - function check(condition, message) { - if (!condition) - throw new Error(message); - } - function section() { return vertical ? bar.vLeftSection : bar.hLeftSection; } - function automatic() { return section().entryRepeater.itemAt(2); } - - QtObject { - id: fixtureAxis - readonly property bool isVertical: root.vertical - readonly property string edge: root.vertical ? "left" : "top" - } - QtObject { - id: fixtureBarWindow - readonly property var screen: window.screen - readonly property var axis: fixtureAxis - readonly property var hostWindow: window - readonly property bool isVertical: root.vertical - readonly property bool fitToWidgets: root.fitted - readonly property bool usesFrameBarChrome: false - readonly property bool hostsIsland: false - readonly property bool hasAdjacentTopBar: false - readonly property bool hasAdjacentBottomBar: false - readonly property bool hasAdjacentLeftBar: false - readonly property bool hasAdjacentRightBar: false - readonly property real effectiveBarThickness: 48 - readonly property real widgetThickness: 32 - readonly property real fittedAvailableLength: root.barLength - readonly property real lengthPaddingStartPx: 0 - readonly property real width: window.width - readonly property real height: window.height - readonly property string screenName: window.screen.name - } - ScriptModel { - id: leftModel - values: WidgetModel.normalize(root.config.leftWidgets) - objectProp: "id" - } - ScriptModel { - id: centerModel - values: WidgetModel.normalize(root.config.centerWidgets) - objectProp: "id" - } - ScriptModel { - id: rightModel - values: WidgetModel.normalize(root.config.rightWidgets) - objectProp: "id" - } - Component { - id: widget - Rectangle { - property var widgetData: null - property var surfaceContext: null - property var axis: null - property bool surfaceLive: true - property string widgetInstanceId: "" - property string segmentRole: "solo" - property real barThickness: 0 - readonly property real naturalSize: widgetData?.dynamic ? root.titleWidth : (widgetData?.collapses && root.titleWidth === 0 ? 0 : (widgetData?.testWidth ?? 0)) - width: axis?.isVertical ? barThickness : naturalSize - height: axis?.isVertical ? naturalSize : barThickness - color: Theme.primary - } - } - PanelWindow { - id: window - visible: true - anchors.top: true - anchors.left: true - implicitWidth: root.vertical ? 48 : root.barLength - implicitHeight: root.vertical ? root.barLength : 48 - WlrLayershell.namespace: "dms:test-bar-overflow" - DankBarContent { - id: bar - barWindow: fixtureBarWindow - rootWindow: window - barConfig: root.config - leftWidgetsModel: leftModel - centerWidgetsModel: centerModel - rightWidgetsModel: rightModel - } - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - for (const section of [bar.hLeftSection, bar.hCenterSection, bar.hRightSection, bar.vLeftSection, bar.vCenterSection, bar.vRightSection]) - section.components = { clock: widget, fixture: widget }; - } - Timer { - interval: 0 - running: SettingsData._hasLoaded && SessionData._hasLoaded - onTriggered: { - SettingsData.barConfigs = [root.config]; - SettingsData.centeringMode = "index"; - checks.start(); - } - } - Timer { - id: checks - interval: 16 - repeat: true - property int step: 0 - property real stepStarted: 0 - onTriggered: { - if (!stepStarted) - stepStarted = Date.now(); - try { - const section = root.section(); - const automatic = root.automatic(); - root.check(automatic?.item && (step === 8 || section.overflowButton), "widget and holder loaded"); - const center = root.vertical ? bar.vCenterSection : bar.hCenterSection; - const clock = center.entryRepeater.itemAt(1); - if (!root.fitted) - root.check(clock && Math.abs((root.vertical ? clock.y + clock.height / 2 - center.height / 2 : clock.x + clock.width / 2 - center.width / 2)) < 0.01, "the center holder preserves the configured clock anchor"); - switch (step) { - case 0: - root.check(!automatic.inOverflow && section.hiddenEntries.length === 1, "only the always-overflow occurrence is hidden"); - root.check(automatic.host.height === 48 && automatic.host.y === 0 && automatic.slot.parent === automatic, "inline widgets sit centered in their wrapper: " + JSON.stringify([automatic.host.y, automatic.host.height, automatic.height])); - root.check(section.overflowButton.segmentRole === "middle" && automatic.item.segmentRole === "last", "a moved holder joins its neighboring segments"); - root.retainedItem = automatic.item; - root.retainedRegistration = automatic.host.registration; - root.barLength = 400; - break; - case 1: - root.check(automatic.inOverflow && section.hiddenEntries.length === 2 && bar.overflowPlan.fits, "all sections resolve their collision"); - root.check(automatic.item === root.retainedItem && automatic.host.registration === root.retainedRegistration, "overflow retains the item and registration"); - root.check(automatic.primarySize === 80 && automatic.slot.parent === section.parkingHost && !automatic.item.surfaceLive, "parked widgets retain natural size and suspend visual work"); - root.check(section.overflowButton.segmentRole === "last" && automatic.item.segmentRole === "solo", "overflowed widgets leave the bar's segment run"); - root.barLength = 600; - break; - case 2: - root.check(!automatic.inOverflow && automatic.item === root.retainedItem && automatic.item.surfaceLive, "restoration keeps the item and resumes visual work"); - root.titleWidth = 240; - break; - case 3: - root.check(automatic.inOverflow && automatic.primarySize === 240, "dynamic title growth triggers overflow"); - section.overflowButton.clicked(); - break; - case 4: - root.check(section.overflowOpen && automatic.inPopup && automatic.slot.parent === section.overflowParent && automatic.item.surfaceLive, "the actual widget moves into the popup"); - root.check(automatic.item === root.retainedItem && automatic.host.registration === root.retainedRegistration, "opening overflow does not duplicate a widget"); - root.check(automatic.popupY > 0 && automatic.slot.y === automatic.popupY && automatic.host.y === 0, "a widget that was inline stacks below earlier popup entries: " + JSON.stringify([automatic.popupY, automatic.slot.y, automatic.host.y])); - root.openSurface = section.overflowSurface; - const anchor = automatic.item.surfaceContext.popupAnchor(automatic.item); - root.check(anchor && anchor.trigger.y >= section.overflowSurface.renderedAlignedY + section.overflowSurface.renderedAlignedHeight, "child popouts clear the overflow surface"); - root.titleWidth = 0; - break; - case 5: - root.check(section.overflowCount === 0 && section.overflowOpen && section.overflowSurface === root.openSurface, "a widget collapsing while the popup is open keeps the holder and its popout alive: " + JSON.stringify([section.overflowCount, section.overflowOpen, section.overflowSurface === root.openSurface, !!section.overflowItem, automatic.inOverflow, automatic.available])); - root.titleWidth = 240; - section.overflowSurface.backgroundClicked(); - break; - case 6: - root.check(!section.overflowOpen && automatic.slot.parent === section.parkingHost && !automatic.item.surfaceLive, "clicking outside closes the popup and returns the widget to its parking host"); - root.titleWidth = 80; - break; - case 7: - root.check(!automatic.inOverflow && automatic.item === root.retainedItem, "a shorter title restores the same item"); - root.barLength = 900; - root.config = Object.assign({}, root.config, { leftWidgets: root.config.leftWidgets.map(entry => Object.assign({}, entry, { overflowMode: "bar" })) }); - break; - case 8: - root.check(section.overflowCount === 0 && !section.overflowSurface && automatic.item === root.retainedItem, "an empty holder releases its popup and retains its widgets"); - root.config = Object.assign({}, root.config, { position: 2, leftWidgets: root.config.leftWidgets.map((entry, index) => Object.assign({}, entry, { overflowMode: index === 2 ? "auto" : index === 1 ? "always" : "bar" })) }); - root.vertical = true; - root.barLength = 400; - break; - case 9: - root.check(automatic.inOverflow && automatic.primarySize === 80 && bar.overflowPlan.fits, "vertical bars overflow along their height"); - root.retainedItem = automatic.item; - section.overflowButton.clicked(); - break; - case 10: - root.check(automatic.inPopup && automatic.slot.parent === section.overflowParent, "vertical overflow widgets remain interactive"); - const verticalAnchor = automatic.item.surfaceContext.popupAnchor(automatic.item); - root.check(verticalAnchor && verticalAnchor.trigger.x >= section.overflowSurface.renderedAlignedX + section.overflowSurface.renderedAlignedWidth, "vertical child popouts clear the overflow surface"); - section.overflowButton.clicked(); - root.fitted = true; - root.barLength = 600; - break; - case 11: - root.check(!automatic.inOverflow && automatic.item === root.retainedItem && bar.overflowPlan.fits, "fitted bars restore the retained widget within their available length"); - console.log("FIXTURE_PASS"); - stop(); - Qt.quit(); - return; - } - step++; - stepStarted = Date.now(); - } catch (error) { - if (Date.now() - stepStarted < 10000) - return; - console.error("FIXTURE_FAIL", "step " + step + ": " + error.message); - stop(); - Qt.quit(); - } - } - } -} diff --git a/quickshell/tests/qml/bar-widget-cursor.qml b/quickshell/tests/qml/bar-widget-cursor.qml deleted file mode 100644 index f33c3ed7e..000000000 --- a/quickshell/tests/qml/bar-widget-cursor.qml +++ /dev/null @@ -1,122 +0,0 @@ -import QtQuick -import Quickshell -import qs.Common -import qs.Services -import qs.Modules.DankBar -import qs.DankCommon.Common as DC - -// Every MouseArea owns an arrow cursor, so one painted over a widget hides the widget's pointer cursor. -ShellRoot { - id: root - - Item { - Repeater { - model: ScriptModel { - values: SettingsData.barConfigs - objectProp: "id" - } - delegate: DankBar { - required property var modelData - barConfig: modelData - } - } - } - - function mouseAreas(item, found) { - if (item.containsPress !== undefined && item.acceptedButtons !== undefined) - found.push(item); - for (const child of item.children || []) - mouseAreas(child, found); - return found; - } - - function chain(item) { - const items = []; - for (let node = item; node; node = node.parent) - items.unshift(node); - return items; - } - - // Paint order: z first, then declaration order under the first diverging ancestor. - function paintsAbove(a, b) { - const ca = chain(a), cb = chain(b); - let i = 0; - while (i < ca.length && i < cb.length && ca[i] === cb[i]) - i++; - if (i === ca.length) - return false; - if (i === cb.length) - return true; - if (ca[i].z !== cb[i].z) - return ca[i].z > cb[i].z; - const siblings = ca[i - 1].children; - return siblings.indexOf(ca[i]) > siblings.indexOf(cb[i]); - } - - function areasCovering(widget) { - const x = widget.width / 2, y = widget.height / 2; - return mouseAreas(widget.Window.window.contentItem, []).filter(area => { - if (!area.visible || !area.enabled || chain(area).includes(widget) || !paintsAbove(area, widget)) - return false; - return area.contains(widget.mapToItem(area, x, y)); - }); - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - } - - Timer { - interval: 0 - running: SettingsData._hasLoaded && SessionData._hasLoaded - onTriggered: { - SettingsData.frameEnabled = false; - SettingsData.reduceMotion = true; - SettingsData.barConfigs = [ - { - id: "bar", - enabled: true, - visible: true, - position: 0, - leftWidgets: ["launcherButton"], - rightWidgets: ["clock"] - } - ]; - } - } - - Timer { - id: steps - - property int waited: 0 - readonly property var widget: BarWidgetService.getWidget("launcherButton", Quickshell.screens[0].name) - - interval: 25 - repeat: true - running: SettingsData.barConfigs.length === 1 - - function finish(message) { - running = false; - if (message) - console.error("FIXTURE_FAIL", message); - else - console.info("FIXTURE_PASS"); - Qt.quit(); - } - - // Before niri configures the bar its width is 0 and the clock's screen-edge hit area folds back over the launcher, so only an overlap that never clears fails. - onTriggered: { - const covering = widget && widget.width > 0 && widget.visible ? root.areasCovering(widget) : null; - if (covering && !covering.length) { - finish(""); - return; - } - if (++waited <= 800) - return; - finish(covering ? "MouseArea painted over the launcher button: " + covering : "launcher button never appeared: widget=" + widget + " width=" + widget?.width + " visible=" + widget?.visible); - } - } -} diff --git a/quickshell/tests/qml/clipboard-preview.qml b/quickshell/tests/qml/clipboard-preview.qml deleted file mode 100644 index 07a6896d3..000000000 --- a/quickshell/tests/qml/clipboard-preview.qml +++ /dev/null @@ -1,162 +0,0 @@ -import QtQuick -import QtTest -import Quickshell -import Quickshell.Wayland -import qs.Common -import qs.Services -import qs.Modals.Clipboard -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - property bool failed: false - - function findItem(item, predicate) { - if (predicate(item)) - return item; - for (const child of item.children ?? []) { - const found = findItem(child, predicate); - if (found) - return found; - } - return null; - } - - function check(condition, label) { - if (condition) - return; - failed = true; - console.log("FIXTURE_FAIL " + label); - } - - QtObject { - id: testModal - property string activeTab: "recents" - property int pinnedCount: 1 - property bool showKeyboardHints: false - property bool clearsFilteredOnly: false - property string mode: "history" - property bool contextMenuActive: false - property var modalFocusScope: scope - function openPreview() { - mode = "preview"; - } - function closePreview() { - mode = "history"; - } - function confirmClearAll() { - } - } - - ClipboardKeyboardController { - id: controller - modal: testModal - } - PanelWindow { - WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive - implicitWidth: 800 - implicitHeight: 600 - TestCase { - id: input - name: "clipboard-preview" - when: false - } - FocusScope { - id: scope - anchors.fill: parent - focus: true - Keys.onPressed: event => controller.handleKey(event) - ClipboardActions { - id: actions - modal: testModal - } - } - ClipboardHistoryContent { - id: history - anchors.fill: parent - visible: false - } - } - - function waitFor(condition, label) { - const deadline = Date.now() + 20000; - while (!condition()) { - if (Date.now() > deadline) - throw new Error("timed out: " + label); - input.wait(10); - } - } - - function settle(item) { - input.wait(0); - const surface = item.Window.window; - check(!input.isPolishScheduled(surface) || input.waitForPolish(surface, 20000), "layout settles"); - } - - function run() { - waitFor(() => scope.Window.active, "clipboard surface takes keyboard focus"); - settle(scope); - ClipboardService.keyboardNavigationActive = true; - const help = input.findChild(actions, "keyboardHints"); - input.mouseClick(help, help.width / 2, help.height / 2); - root.check(testModal.showKeyboardHints, "mouse opens keyboard hints"); - root.check(help.activeFocus, "help button receives focus"); - input.keyClick(Qt.Key_Space, Qt.ControlModifier); - root.check(testModal.mode === "preview", "Ctrl+Space opens preview after clicking help"); - input.keyClick(Qt.Key_Escape); - root.check(testModal.mode === "history", "Escape closes preview"); - input.keyClick(Qt.Key_F10); - root.check(!testModal.showKeyboardHints, "F10 closes keyboard hints"); - input.keyClick(Qt.Key_Space, Qt.ControlModifier); - root.check(testModal.mode === "preview", "Ctrl+Space still works after hiding hints"); - scope.visible = false; - history.visible = true; - ClipboardService.unpinnedEntries = [ - { - id: 1, - preview: "Text", - mimeType: "text/plain", - isImage: false - }, - { - id: 2, - preview: "Image", - mimeType: "image/png", - isImage: true - } - ]; - ClipboardService.selectedIndex = 0; - const findPreview = () => root.findItem(history, item => item.objectName === "previewEntry" && item.visible); - waitFor(() => findPreview() !== null, "image row exposes a mouse preview action"); - settle(history); - const preview = findPreview(); - input.mouseClick(preview, preview.width / 2, preview.height / 2); - root.check(history.mode === "preview", "mouse action opens preview"); - root.check(history.currentEntry?.id === 2, "mouse action selects its image rather than the previous selection"); - input.keyClick(Qt.Key_Escape); - root.check(history.mode === "history", "Escape closes mouse-opened preview"); - input.keyClick(Qt.Key_Space, Qt.ControlModifier); - root.check(history.mode === "preview", "keyboard reopens the mouse-selected image"); - } - - Timer { - interval: 0 - running: true - onTriggered: { - try { - root.run(); - } catch (error) { - root.check(false, error.message); - } - console.log(root.failed ? "FIXTURE_FAIL see above" : "FIXTURE_PASS"); - Qt.quit(); - } - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - } -} diff --git a/quickshell/tests/qml/control-center-sizes.qml b/quickshell/tests/qml/control-center-sizes.qml deleted file mode 100644 index 5fae4c562..000000000 --- a/quickshell/tests/qml/control-center-sizes.qml +++ /dev/null @@ -1,346 +0,0 @@ -import QtQuick -import QtTest -import Quickshell -import qs.Common -import qs.Services -import qs.Modules.ControlCenter -import qs.Modules.DankIsland -import qs.Modules.ControlCenter.Components -import qs.Modules.ControlCenter.Widgets -import "Modules/ControlCenter/utils/widgets.js" as WidgetUtils -import qs.Modules.ControlCenter.Models -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - property int toggles: 0 - property int expansions: 0 - property int actions: 0 - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - SettingsData.animationDuration = 0; - Qt.callLater(tester.run); - } - - WidgetModel { - id: registry - } - IslandController { - id: island - dashboardAvailableWidth: CcMetrics.sheetWidthFor(20) + controlCenterSheetInset + PopoutMetrics.editOverflow * 2 - dashboardAvailableHeight: CcMetrics.fallbackScreenHeight - controlCenterMaxHeight: dashboardAvailableHeight - } - - Component { - id: tileComponent - CcTile { - title: "Headphones with a long device name" - subtitle: "Connected · 75%" - iconName: "headphones" - showExpand: true - onClicked: root.toggles++ - onExpandClicked: root.expansions++ - expandedContent: Component { - CcTileActions { - actions: [ - { - text: "Device one with a name long enough to wrap over several lines", - icon: "headphones", - trigger: () => root.actions++ - }, - { - text: "Device two", - icon: "speaker", - enabled: false, - trigger: () => root.actions++ - } - ] - } - } - } - } - - Component { - id: sliderComponent - CcSliderRow { - iconName: "volume_up" - iconLabel: "Mute" - sliderLabel: "Volume" - } - } - - Component { - id: detailComponent - CcDetailPage { - model: registry - } - } - - Component { - id: gridComponent - CcTileGrid { - id: testGrid - columns: CcMetrics.defaultColumns - width: CcMetrics.sheetWidthFor(columns) - CcMetrics.sheetPadding * 2 - availableHeight: CcMetrics.gridRowUnit * 12 + CcMetrics.gridGap * 11 - editMode: true - live: false - model: WidgetModel { - columns: testGrid.columns - maximumRows: testGrid.maximumRows - } - } - } - - FloatingWindow { - id: window - visible: true - implicitWidth: 1200 - implicitHeight: 800 - color: Theme.surface - Item { - id: scene - anchors.fill: parent - } - } - - TestCase { - id: tester - when: false - - function check(condition, message) { - if (!condition) - throw new Error(message); - } - - function settle() { - wait(0); - const surface = scene.Window.window; - check(!isPolishScheduled(surface) || waitForPolish(surface, 20000), "layout settled"); - } - - function waitFor(condition, label) { - const deadline = Date.now() + 20000; - while (!condition()) { - check(Date.now() < deadline, "timed out: " + label); - wait(10); - } - } - - function named(item, name) { - if (item.objectName === name) - return item; - for (const child of item.children || []) { - const found = named(child, name); - if (found) - return found; - } - return null; - } - - function inside(item, container, label) { - if (!item || !item.visible) - return; - const point = item.mapToItem(container, 0, 0); - check(item.width >= 0 && item.height >= 0 && point.x >= -0.5 && point.y >= -0.5 && point.x + item.width <= container.width + 0.5 && point.y + item.height <= container.height + 0.5, label + " out of bounds " + JSON.stringify({ - x: point.x, - y: point.y, - w: item.width, - h: item.height, - cw: container.width, - ch: container.height - })); - } - - function swatches(item, container) { - let count = 0; - if (item.swatchColor !== undefined && typeof item.click === "function") { - inside(item, container, "color swatch"); - count++; - } - for (const child of item.children || []) - count += swatches(child, container); - return count; - } - - function size(tile, columns, rows) { - tile.columns = columns; - tile.rows = rows; - tile.width = CcMetrics.columnWidth * columns + CcMetrics.gridGap * (columns - 1); - tile.height = CcMetrics.gridRowUnit * rows + CcMetrics.gridGap * (rows - 1); - } - - function run() { - try { - waitFor(() => scene.Window.active, "fixture window takes keyboard focus"); - SettingsData.controlCenterWidgets = [ - { - id: "wifi", - w: 10, - h: 12 - }, - { - id: "colorPicker", - w: 2, - h: 2 - } - ]; - const grid = gridComponent.createObject(scene); - check(grid.maximumRows === 12 && grid.slotLayout.slots[0].rows === 12, "list height is independent of panel columns"); - check(grid.slotLayout.slots[0].cols === 8, "wide saved tiles fit the current panel"); - check(island.controlCenterColumnCap === 20, "Island columns use available screen width"); - island.setDestinationContentHeight("controlcenter", CcMetrics.fallbackScreenHeight); - check(island.controlCenterHeight === CcMetrics.fallbackScreenHeight, "Island allows taller lists within its screen height"); - const square = grid.slotLayout.slots[1]; - check(Math.abs(square.w - square.h) < 0.01, "2x2 has square geometry"); - grid.width += Theme.spacingL; - check(Math.abs(grid.slotLayout.slots[1].w - grid.slotLayout.slots[1].h) < 0.01, "host inset preserves square geometry"); - grid.availableHeight = CcMetrics.gridRowUnit * 5 + CcMetrics.gridGap * 4; - check(grid.slotLayout.slots[0].rows <= 5, "tile height fits the available screen"); - grid.beginDrag(1); - grid.updateDragTarget(0, 0); - grid.endDrag(); - check(SettingsData.controlCenterWidgets[0].w === 10 && SettingsData.controlCenterWidgets[0].h === 12 && SettingsData.controlCenterWidgets[0].row === 2 && SettingsData.controlCenterWidgets[1].row === 0, "moving on a smaller screen preserves saved spans and pushes the collided tile " + JSON.stringify(SettingsData.controlCenterWidgets)); - grid.beginDrag(0); - grid.updateDragTarget(0, grid.cellWidth * 4); - grid.endDrag(); - check(SettingsData.controlCenterWidgets[0].row === 2, "gravity closes the gap a drop below free space would leave " + JSON.stringify(SettingsData.controlCenterWidgets)); - grid.previewSize(0, { - w: 6 - }); - grid.commitSize(); - check(SettingsData.controlCenterWidgets[0].w === 6 && SettingsData.controlCenterWidgets[0].h === 12, "width edits preserve the saved height on a smaller screen"); - grid.previewSize(0, { - w: 2.5 - }); - grid.commitSize(); - grid.previewSize(0, { - h: 1.5 - }); - grid.commitSize(); - const half = grid.slotLayout.slots[0]; - const cell = grid.cellWidth - CcMetrics.gridGap; - check(SettingsData.controlCenterWidgets[0].w === 2.5 && SettingsData.controlCenterWidgets[0].h === 1.5 && half.cols === 2.5 && half.rows === 1.5, "half steps are saved on both axes"); - check(Math.abs(half.w - (cell * 2.5 + CcMetrics.gridGap * 1.5)) < 0.01 && Math.abs(half.h - (cell * 1.5 + CcMetrics.gridGap * 0.5)) < 0.01, "half spans pack at half pitch"); - check(WidgetUtils.clampSize({ - id: "volumeSlider", - w: 1.5, - h: 1 - }, 8, 1).w === 2, "sliders under two cells on both axes keep a usable track"); - check(WidgetUtils.clampSize({ - id: "volumeSlider", - w: 1, - h: 1 - }, 8, 12).h === 2, "sliders keep room for their action and track"); - check(CcMetrics.columnCapFor(CcMetrics.sheetWidthFor(20)) === 20, "wide screens allow more than six columns"); - SettingsData.controlCenterWidgets = [ - { - id: "volumeSlider", - w: 2, - h: 1 - } - ]; - const sliderSlot = grid.children.find(child => typeof child.beginResize === "function"); - check(sliderSlot !== undefined, "slider slot available"); - sliderSlot.resizeRequested(grid.cellWidth - CcMetrics.gridGap, grid.cellWidth - CcMetrics.gridGap); - grid.commitSize(); - check(SettingsData.controlCenterWidgets[0].w === 1 && SettingsData.controlCenterWidgets[0].h === 2, "narrowing a slider retains a usable portrait layout"); - sliderSlot.resizeRequested(grid.cellWidth - CcMetrics.gridGap, grid.cellWidth - CcMetrics.gridGap); - grid.commitSize(); - check(SettingsData.controlCenterWidgets[0].w === 2 && SettingsData.controlCenterWidgets[0].h === 1, "shortening a slider retains a usable strip layout"); - sliderSlot.resizeRequested(grid.cellWidth * 3.5 - CcMetrics.gridGap, grid.cellWidth - CcMetrics.gridGap); - grid.commitSize(); - check(SettingsData.controlCenterWidgets[0].w === 3.5 && sliderSlot.tileItem.columns === 3.5, "resizing snaps to half columns"); - grid.destroy(); - wait(0); - const tile = tileComponent.createObject(scene); - size(tile, 4, 3); - settle(); - check(tile.expanded && tile.expandedItem !== null, "large tile exposes inline actions"); - const inline = tile.expandedItem; - check(inline.tile === tile && inline.columns === 4 && inline.rows === 3, "plugin content receives its tile context"); - const before = root.toggles; - const content = named(tile, "tileExpandedContent"); - mouseClick(tile, tile.width / 2, content.y + Theme.spacingM + Theme.listItemHeight / 2); - check(root.actions === 1 && root.toggles === before && root.expansions === 0, "inline action does not activate tile"); - mouseClick(tile, tile.width / 2, tile.height - Theme.spacingL * 2); - check(root.actions === 1 && root.expansions === 0 && root.toggles === before, "disabled inline action does not activate tile"); - mouseClick(named(tile, "tileIconBox")); - check(root.toggles === before + 1, "icon toggles independently"); - tile.forceActiveFocus(); - keyClick(Qt.Key_Return); - check(root.expansions === 1, "tile keyboard activation opens details"); - tile.live = false; - check(tile.expandedItem === null, "hidden control center unloads inline controls"); - tile.destroy(); - wait(0); - - const verticalSlider = sliderComponent.createObject(scene); - size(verticalSlider, 1, 3); - settle(); - check(verticalSlider.vertical, "portrait slider uses vertical track"); - verticalSlider.slider.forceActiveFocus(); - keyClick(Qt.Key_End); - check(verticalSlider.slider.value === 100, "vertical slider End reaches maximum"); - keyClick(Qt.Key_Home); - keyClick(Qt.Key_Up); - check(verticalSlider.slider.value === 1, "vertical slider Up increases value"); - mouseClick(verticalSlider.slider, verticalSlider.slider.width * 0.75, verticalSlider.slider.height / 2); - check(verticalSlider.slider.value >= 74 && verticalSlider.slider.value <= 77, "portrait pointer coordinates follow slider value"); - verticalSlider.destroy(); - wait(0); - - SessionData.recentColors = Array.from({ - length: 18 - }, (_, i) => Qt.rgba(i / 18, 0.5, 0.5, 1).toString()); - const colorTile = registry.componentForWidget({ - id: "colorPicker" - }).createObject(scene); - size(colorTile, 4, 6); - settle(); - check(swatches(colorTile, colorTile) === 18, "taller color tiles show multiple rows of recent colors"); - colorTile.destroy(); - wait(0); - const detail = detailComponent.createObject(scene, { - width: 600, - height: 400 - }); - NetworkService.wifiEnabled = true; - NetworkService.wifiInterface = "wlan0"; - NetworkService.wifiNetworks = Array.from({ - length: 8 - }, (_, i) => ({ - ssid: "Net" + i, - signal: 90 - i * 8, - secured: true - })); - detail.section = "wifi"; - waitFor(() => !detail.transitioning && detail.pageItem !== null, "wifi detail page loads"); - settle(); - const networkList = named(detail.pageItem, "networkList"); - waitFor(() => networkList.count === 8, "wifi rows load"); - settle(); - check(networkList.atYBeginning, "network popup opens at the top, contentY " + networkList.contentY + " vs origin " + networkList.originY); - detail.section = "doNotDisturb"; - settle(); - detail.section = ""; - settle(); - check(!detail.visible, "closed detail page hides"); - detail.width = 800; - settle(); - check(!detail.visible, "closed detail page stays hidden after the panel widens"); - detail.destroy(); - wait(0); - console.log("FIXTURE_PASS control center sizes and actions"); - } catch (error) { - console.error("FIXTURE_FAIL " + error.message); - } - Qt.quit(); - } - } -} diff --git a/quickshell/tests/qml/focused-app.qml b/quickshell/tests/qml/focused-app.qml deleted file mode 100644 index af535d5d6..000000000 --- a/quickshell/tests/qml/focused-app.qml +++ /dev/null @@ -1,223 +0,0 @@ -import QtQuick -import Quickshell -import qs.Common -import qs.Services -import qs.Modules.DankBar.Widgets -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - - property var browser: ({ - appId: "firefox", - title: "Mozilla Firefox", - pid: 7 - }) - property var barConfig: ({ - widgetPadding: 12, - fontScale: 1, - iconScale: 1, - spacing: 4 - }) - property var axes: [ - { - isVertical: false, - edge: "top" - }, - { - isVertical: true, - edge: "left" - } - ] - property var instances: [] - property int step: 0 - - function stateFor(index) { - const here = Quickshell.screens[0].name; - const workspaces = [ - { - id: 1, - idx: 1, - name: null, - output: here, - is_active: true, - is_focused: true - }, - { - id: 2, - idx: 1, - name: null, - output: "OTHER", - is_active: true, - is_focused: false - } - ]; - const window = workspaceId => ({ - id: 1, - pid: 100, - app_id: "firefox", - title: "Mozilla Firefox", - workspace_id: workspaceId, - is_focused: true, - is_floating: false, - layout: { - tile_pos_in_workspace_view: [0, 0], - window_size: [1280, 800], - tile_size: [1280, 800] - } - }); - switch (index) { - case 0: - return { - name: "no-windows", - workspaces, - windows: [], - currentOutput: here, - expectWindows: false, - expectAppId: null - }; - case 1: - return { - name: "focused-here", - workspaces, - windows: [window(1)], - currentOutput: here, - expectWindows: true, - expectAppId: "firefox" - }; - case 2: - return { - name: "focused-elsewhere", - workspaces, - windows: [window(2)], - currentOutput: "OTHER", - expectWindows: false, - expectAppId: null - }; - case 3: - return { - name: "current-output-elsewhere", - workspaces, - windows: [window(1)], - currentOutput: "OTHER", - expectWindows: true, - expectAppId: "firefox" - }; - default: - return null; - } - } - - function applyBackend(state) { - NiriService.allWorkspaces = state.workspaces; - NiriService.currentOutput = state.currentOutput; - NiriService.windows = state.windows; - } - - function applySorted(state) { - CompositorService.sortedToplevels = state.windows.map(w => ({ - niriWindowId: w.id, - niriWorkspaceId: w.workspace_id, - appId: w.app_id, - title: w.title, - sourceToplevel: root.browser - })); - CompositorService.toplevelsChanged(); - } - - property bool failed: false - - function signature() { - return root.instances.map(instance => { - const item = instance.item; - return [item.hasWindowsOnCurrentWorkspace, item.activeWindow?.appId ?? null, item.visible, item.width > 0 && item.height > 0].join(); - }).join("|"); - } - - function check(state, instance, item) { - const appId = item.activeWindow?.appId ?? null; - const shown = item.hasWindowsOnCurrentWorkspace && item.visible && item.width > 0 && item.height > 0; - if (item.hasWindowsOnCurrentWorkspace === state.expectWindows && appId === state.expectAppId && shown === state.expectWindows) - return; - root.failed = true; - console.log("FIXTURE_FAIL " + state.name + (instance.vertical ? " vertical" : " horizontal") + " expected windows=" + state.expectWindows + " appId=" + state.expectAppId); - } - - Item { - id: stage - width: 900 - height: 400 - } - - Component { - id: focusedApp - FocusedApp {} - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - const created = []; - let y = 0; - for (const axis of root.axes) { - const item = focusedApp.createObject(stage, { - axis: axis, - parentScreen: Quickshell.screens[0], - barThickness: 48, - widgetThickness: 30, - barSpacing: 4, - barConfig: root.barConfig, - widgetData: {}, - y: y - }); - y += 120; - created.push({ - vertical: axis.isVertical, - item: item - }); - } - root.instances = created; - } - - Timer { - interval: 25 - running: true - repeat: true - property int waited: 0 - property int stable: 0 - property string last: "" - onTriggered: { - if (CompositorService.compositor !== "niri" || Quickshell.screens.length === 0) - return; - const state = root.stateFor(root.step); - if (!state) { - console.log(root.failed ? "FIXTURE_FAIL" : "FIXTURE_PASS"); - stop(); - Qt.quit(); - return; - } - root.applyBackend(state); - root.applySorted(state); - const current = root.signature(); - stable = current === last ? stable + 1 : 0; - last = current; - if (++waited > 800) { - root.failed = true; - console.log("FIXTURE_FAIL " + state.name + " never settled, signature " + current); - stop(); - Qt.quit(); - return; - } - if (stable < 4) - return; - for (const instance of root.instances) - root.check(state, instance, instance.item); - waited = 0; - stable = 0; - last = ""; - root.step++; - } - } -} diff --git a/quickshell/tests/qml/font-weights.qml b/quickshell/tests/qml/font-weights.qml deleted file mode 100644 index 61b92651c..000000000 --- a/quickshell/tests/qml/font-weights.qml +++ /dev/null @@ -1,47 +0,0 @@ -import QtQuick -import Quickshell -import qs.Common -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - - property bool failed: false - - function check(condition, label) { - if (condition) - return; - failed = true; - console.log("FIXTURE_FAIL " + label); - } - - function finish() { - console.log(root.failed ? "FIXTURE_FAIL see above" : "FIXTURE_PASS"); - } - - function ladder(owner) { - return [owner.fontWeight, owner.fontWeightMedium, owner.fontWeightBold]; - } - - Timer { - interval: 0 - running: true - onTriggered: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - - for (const base of [Font.Thin, Font.Normal, Font.Bold, Font.Black]) { - SettingsData.fontWeight = base; - const weights = ladder(Theme); - check(weights[0] === base, `Theme ladder starts at the setting, got ${JSON.stringify(weights)}`); - check(weights.every((weight, index) => weight <= Font.Black && (index === 0 || weight >= weights[index - 1])), `Theme ladder rises and clamps at ${base}, got ${JSON.stringify(weights)}`); - check(JSON.stringify(ladder(DC.Style)) === JSON.stringify(weights), `Style ladder differs from Theme at ${base}: ${JSON.stringify(ladder(DC.Style))}`); - } - SettingsData.fontWeight = Font.Normal; - - root.finish(); - Qt.quit(); - } - } -} diff --git a/quickshell/tests/qml/frame-bar-flip.qml b/quickshell/tests/qml/frame-bar-flip.qml deleted file mode 100644 index e6cfc3efe..000000000 --- a/quickshell/tests/qml/frame-bar-flip.qml +++ /dev/null @@ -1,93 +0,0 @@ -import QtQuick -import Quickshell -import qs.Common -import qs.Modules.DankBar -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - - readonly property var positions: [0, 2, 1, 3, 0] - property int step: 0 - readonly property int position: positions[step] - readonly property bool vertical: position === SettingsData.Position.Left || position === SettingsData.Position.Right - property var barConfig: ({ - id: "fixture", - position, - spacing: 0, - innerPadding: 4 - }) - property var hyprlandOverviewLoader: null - property bool systemTrayMenuOpen: false - - ScriptModel { - id: widgets - values: [ - { - widgetId: "clock", - id: "clock_0" - } - ] - } - - FloatingWindow { - id: window - visible: true - implicitWidth: 800 - implicitHeight: 600 - - Item { - width: root.vertical ? Theme.px(body.effectiveBarThickness + body.effectiveSpacing, body._dpr) : 800 - height: root.vertical ? 600 : Theme.px(body.effectiveBarThickness + body.effectiveSpacing, body._dpr) - - // FrameBarHost retains the body while its slot changes orientation. - DankBarBody { - id: body - anchors.fill: parent - rootWindow: root - hostWindow: window - modelData: Quickshell.screens[0] - barConfig: root.barConfig - leftWidgetsModel: widgets - centerWidgetsModel: widgets - rightWidgetsModel: widgets - } - } - } - - function checkSections() { - for (const section of [body._leftSection, body._centerSection, body._rightSection]) { - section.widgetLayoutLoader?.item?.forceLayout(); - const point = section.mapToItem(body, 0, 0); - const crossCenter = root.vertical ? point.x + section.width / 2 : point.y + section.height / 2; - const expectedCenter = (root.vertical ? body.width : body.height) / 2; - if (Math.abs(crossCenter - expectedCenter) > 1) - throw new Error(`step ${step}, edge ${body.axis.edge}, ${section.section}: center ${crossCenter}, expected ${expectedCenter}`); - if (point.x < -1 || point.y < -1 || point.x + section.width > body.width + 1 || point.y + section.height > body.height + 1) - throw new Error(`step ${step}, edge ${body.axis.edge}, ${section.section}: section outside bar`); - } - } - - function advance() { - try { - checkSections(); - if (step === positions.length - 1) { - console.log("FIXTURE_PASS"); - Qt.quit(); - return; - } - step++; - Qt.callLater(advance); - } catch (error) { - console.error("FIXTURE_FAIL", error.message); - Qt.quit(); - } - } - - Component.onCompleted: { - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - Qt.callLater(advance); - } -} diff --git a/quickshell/tests/qml/grid-edit-layout.qml b/quickshell/tests/qml/grid-edit-layout.qml deleted file mode 100644 index 1d0892dea..000000000 --- a/quickshell/tests/qml/grid-edit-layout.qml +++ /dev/null @@ -1,172 +0,0 @@ -import QtQuick -import QtTest -import Quickshell -import qs.Common -import qs.Widgets -import "Common/GridLayout.js" as GridUtils -import qs.DankCommon.Common as DC - -ShellRoot { - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - Qt.callLater(tester.run); - } - - FloatingWindow { - visible: true - implicitWidth: 600 - implicitHeight: 400 - - DankEditableGrid { - id: editGrid - property bool mirrored: false - width: 568 - editMode: true - slotLayout: GridUtils.packCards(layoutItems, placementOrder, 8, width, 8, 64, mirrored) - onLayoutCommitted: items => sourceItems = items - - Repeater { - id: slots - model: editGrid.tileModel - DankEditableGridSlot { - grid: editGrid - } - } - } - } - - TestCase { - id: tester - when: false - property var failures: [] - - function check(condition, message) { - if (!condition) - failures.push(message); - } - - function placed(label) { - for (let i = 0; i < slots.count; i++) { - const item = slots.itemAt(i); - const target = item.slot; - check(item.x === target.x && item.y === target.y && item.width === target.w && item.height === target.h, label + " " + i + " starts outside its slot"); - } - } - - function heldHeight() { - editGrid.mirrored = false; - editGrid.animationsEnabled = false; - editGrid.sourceItems = [ - { - id: "a", - w: 2, - h: 1, - col: 0, - row: 0 - }, - { - id: "b", - w: 2, - h: 1, - col: 0, - row: 1 - } - ]; - const twoRows = editGrid.implicitHeight; - const rowUnit = editGrid.slotLayout.rowUnit + editGrid.slotLayout.gap; - editGrid.beginDrag(1); - editGrid.updateDragTarget(slots.itemAt(0).slot.w + editGrid.slotLayout.gap, 0); - check(editGrid.slotLayout.rows === 1 && editGrid.implicitHeight === twoRows, "dragging the bottom tile up keeps the grid height"); - editGrid.updateDragTarget(0, rowUnit * 2); - const threeRows = editGrid.implicitHeight; - check(threeRows === twoRows + rowUnit, "dragging below the last row grows the grid"); - editGrid.updateDragTarget(0, rowUnit); - check(editGrid.implicitHeight === threeRows, "the grid never shrinks while a drag is in progress"); - editGrid.endDrag(); - check(editGrid.implicitHeight === twoRows, "releasing the drag settles to the committed layout"); - } - - function run() { - for (const mirrored of [false, true]) { - editGrid.animationsEnabled = false; - editGrid.mirrored = mirrored; - editGrid.sourceItems = [ - { - id: "a", - w: 2, - h: 1 - }, - { - id: "b", - w: 2, - h: 1 - }, - { - id: "c", - w: 2, - h: 1 - } - ]; - editGrid.animationsEnabled = true; - editGrid.animateLayout = true; - editGrid.sourceItems = editGrid.sourceItems.concat([ - { - id: "d", - w: 2, - h: 1 - } - ]); - placed("adding a tile"); - - editGrid.animateLayout = true; - const resized = slots.itemAt(1); - resized.beginResize(resized.width, resized.height); - editGrid.previewSize(1, { - w: 4, - h: 2 - }); - check(resized.width === resized.slot.w && resized.height === resized.slot.h, "resizing follows the pointer without a second layout animation"); - editGrid.commitSize(); - placed("committing a resize"); - - editGrid.animationsEnabled = false; - editGrid.beginDrag(0); - const destination = slots.itemAt(1).slot; - const dragged = slots.itemAt(0); - editGrid.updateDragTarget(mirrored ? destination.x + destination.w - dragged.slot.w : destination.x, destination.y); - dragged.x = dragged.slot.x; - dragged.y = dragged.slot.y; - const before = Array.from({ - length: slots.count - }, (_, i) => { - const item = slots.itemAt(i); - return { - item, - id: JSON.parse(item.json).id, - x: item.x, - y: item.y - }; - }); - editGrid.animationsEnabled = true; - editGrid.animateLayout = true; - editGrid.endDrag(); - placed("committing a move"); - for (const entry of before) - check(JSON.parse(entry.item.json).id === entry.id && entry.item.x === entry.x && entry.item.y === entry.y, "move preserves each tile and its preview position"); - check(editGrid.sourceItems[0].col === 2 && editGrid.sourceItems[1].row === 1 && editGrid.sourceItems[2].col === 4, "move lands on the cell and pushes only the collided tile "); - - editGrid.animateLayout = true; - editGrid.sourceItems = editGrid.sourceItems.slice(1); - placed("removing a tile"); - } - heldHeight(); - if (failures.length > 0) - console.error("FIXTURE_FAIL", failures.join("; ")); - else - console.log("FIXTURE_PASS grid edit layout"); - Qt.quit(); - } - } -} diff --git a/quickshell/tests/qml/instance-routing.qml b/quickshell/tests/qml/instance-routing.qml deleted file mode 100644 index 7e1f0c096..000000000 --- a/quickshell/tests/qml/instance-routing.qml +++ /dev/null @@ -1,225 +0,0 @@ -import QtQuick -import Quickshell -import qs.Common -import qs.Services -import qs.Modules.DankBar -import qs.Modules.Plugins -import qs.Modules.SurfaceWidgets -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - - property var oldRegistration: null - property var oldItem: null - property var replacement: null - property var oldKey: "" - property int calls: 0 - property var lastAnchor: null - - function check(condition, label) { - if (!condition) - throw new Error(label); - } - function target(barId, section, occurrenceId) { - return { - screenName: Quickshell.screens[0].name, - barId, - section, - occurrenceId, - kind: "bar" - }; - } - function config(id, position, autoHide) { - return { - id, - enabled: true, - visible: true, - position, - autoHide, - spacing: 4, - innerPadding: 4, - leftWidgets: ["clock", "clock", "fixture", "fixture"], - centerWidgets: ["clock", "fixture"], - rightWidgets: ["clock", "fixture"] - }; - } - Component { - id: plugin - PluginComponent { - horizontalBarPill: Component { - Rectangle { - width: 32 - height: 28 - } - } - verticalBarPill: Component { - Rectangle { - width: 28 - height: 32 - } - } - pillClickAction: (x, y, width, section, screen) => { - root.calls++; - root.lastAnchor = { - x, - y, - width, - section, - screen: screen.name - }; - } - } - } - Item { - Repeater { - model: ScriptModel { - values: SettingsData.barConfigs - objectProp: "id" - } - delegate: DankBar { - required property var modelData - barConfig: modelData - } - } - } - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - } - Timer { - interval: 0 - running: SettingsData._hasLoaded && SessionData._hasLoaded - onTriggered: { - PluginService.availablePlugins = { - fixture: { - id: "fixture" - } - }; - PluginService.pluginWidgetComponents = { - fixture: plugin - }; - SettingsData.barConfigs = [config("z", 0, false), config("a", 1, true)]; - checks.start(); - } - } - - Timer { - id: checks - interval: 25 - repeat: true - property int step: 0 - property int waited: 0 - property string waitingFor: "" - - function advance() { - step++; - waited = 0; - waitingFor = ""; - } - - onTriggered: { - try { - const screen = Quickshell.screens[0]; - const entries = Object.values(BarWidgetService.widgetRegistry); - if (++waited > 800) - throw new Error("timed out in step " + step + (waitingFor ? " waiting for " + waitingFor : "")); - switch (step) { - case 0: - if (entries.length < 16) - return; - root.check(entries.length === 16, "all built-in/plugin duplicates registered"); - root.check(BarWidgetService.getWidget("clock", screen.name) === BarWidgetService.getWidgetInstance("clock", root.target("z", "left", "clock_0")), "legacy config/section order"); - for (const entry of entries.filter(entry => entry.widgetId === "fixture")) { - entry.item.triggerPopout(); - root.check(root.lastAnchor.section === entry.context.section && root.lastAnchor.screen === screen.name, "plugin action origin"); - const expected = entry.context.surface.popupAnchor(entry.item, entry.context.section); - root.check(Math.abs(root.lastAnchor.y - expected.trigger.y) < 1, "plugin action edge"); - } - root.check(root.calls === 8, "every duplicate plugin action"); - root.check(!BarWidgetService.triggerWidgetPopout("fixture", root.target("missing", "left", "fixture_2")), "explicit bar miss"); - root.check(BarWidgetService.triggerWidgetPopout("fixture", root.target("a", "left", "fixture_2")), "hidden bar routing"); - root.check(BarWidgetService.getBarWindowForScreen(screen.name, "a").barRevealed, "originating hidden bar reveals"); - root.oldRegistration = BarWidgetService.resolveWidget("clock", root.target("z", "left", "clock_0")); - root.oldItem = root.oldRegistration.item; - root.oldKey = root.oldRegistration.key; - root.replacement = BarWidgetService.registerWidget("clock", screen.name, root.oldItem, root.oldRegistration.instanceId, root.oldRegistration.context); - BarWidgetService.releaseWidget(root.oldRegistration); - root.check(BarWidgetService.widgetRegistry[root.oldKey] === root.replacement, "same-object stale token cannot release replacement"); - SettingsData.barConfigs = SettingsData.barConfigs.map(config => config.id !== "z" ? config : Object.assign({}, config, { - leftWidgets: ["fixture", "clock", "fixture", "clock"] - })); - advance(); - break; - case 1: - waitingFor = "the reorder to register clock_3 and release clock_0"; - if (!BarWidgetService.getWidgetInstance("clock", root.target("z", "left", "clock_3"))) - return; - if (BarWidgetService.getWidgetInstance("clock", root.target("z", "left", "clock_0"))) - return; - BarWidgetService.releaseWidget(root.replacement); - root.check(BarWidgetService.getWidgetInstance("clock", root.target("z", "left", "clock_1")) !== null, "stale teardown preserves reordered sibling"); - const legacy = BarWidgetService.registerWidget("legacy", screen.name, root); - root.check(BarWidgetService.getWidget("legacy", screen.name) === root, "three-argument plugin registration"); - BarWidgetService.unregisterWidget("legacy", screen.name, root); - root.check(!BarWidgetService.hasWidget("legacy"), "legacy owner release"); - SettingsData.barConfigs = [root.config("a", 2, false)]; - advance(); - break; - case 2: - if (entries.length === 0 || entries.some(entry => entry.context.barId !== "a")) - return; - root.check(!BarWidgetService.getWidgetInstance("fixture", root.target("z", "left", "fixture_0")), "deleted bar is ineligible"); - root.check(Object.values(BarWidgetService.widgetRegistry).every(entry => entry.context.barId === "a"), "deleted hosts release registrations"); - const fixtures = Object.values(BarWidgetService.widgetRegistry).filter(entry => entry.widgetId === "fixture"); - for (const entry of fixtures) { - entry.item.triggerPopout(); - root.check(root.lastAnchor.x > 0 && root.lastAnchor.x < screen.width / 2, "vertical plugin origin"); - } - for (const entry of fixtures) - entry.item.setVisibilityOverride(false); - const hiddenCalls = root.calls; - root.check(BarWidgetService.triggerWidgetPopout("fixture") && root.calls === hiddenCalls + 1, "hidden plugin still opens"); - const shown = fixtures.find(entry => entry.context.section === "right").item; - shown.setVisibilityOverride(true); - root.check(BarWidgetService.getWidget("fixture") === shown, "shown duplicate wins over hidden ones"); - SettingsData.barConfigs = [Object.assign(root.config("a", 2, false), { - leftWidgets: ["clock", - { - id: "fixture", - enabled: false - } - ], - centerWidgets: [], - rightWidgets: [] - })]; - advance(); - break; - case 3: - waitingFor = "only the enabled clock to register"; - if (entries.length !== 1 || entries[0].widgetId !== "clock") - return; - const disabledCalls = root.calls; - root.check(BarWidgetService.ensureWidget("fixture"), "disabled widget loads on demand"); - root.check(BarWidgetService.triggerWidgetPopout("fixture") && root.calls === disabledCalls + 1, "disabled widget opens"); - SettingsData.barConfigs = []; - advance(); - break; - case 4: - if (entries.length !== 0) - return; - root.check(Object.keys(BarWidgetService.widgetRegistry).length === 0, "all hosts released"); - console.log("FIXTURE_PASS"); - stop(); - Qt.quit(); - } - } catch (error) { - console.error("FIXTURE_FAIL", error.message); - stop(); - Qt.quit(); - } - } - } -} diff --git a/quickshell/tests/qml/island-free.qml b/quickshell/tests/qml/island-free.qml deleted file mode 100644 index 247e70ace..000000000 --- a/quickshell/tests/qml/island-free.qml +++ /dev/null @@ -1,85 +0,0 @@ -import QtQuick -import Quickshell -import qs.Common -import qs.Services -import qs.Modules.DankIsland -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - - property var hosts: [] - - Component { - id: hostComponent - IslandFreeHostWindow {} - } - - function check(condition, message) { - if (!condition) - throw new Error(message); - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - } - - Timer { - interval: 0 - running: SettingsData._hasLoaded && SessionData._hasLoaded - onTriggered: { - SettingsData.frameEnabled = false; - SettingsData.barConfigs = [ - { - id: "dot", - enabled: true, - visible: true, - dot: true, - position: 0 - }, - { - id: "vertical", - enabled: true, - visible: true, - island: true, - islandFloating: true, - islandPlacement: "free", - position: 2 - } - ]; - root.hosts = SettingsData.barConfigs.map(config => hostComponent.createObject(root, { - barId: config.id, - screen: Quickshell.screens[0] - })); - } - } - - Timer { - interval: 0 - running: root.hosts.length === 2 && root.hosts.every(host => host.width > 0 && host.height > 0) - onTriggered: { - try { - for (const host of root.hosts) { - const body = IslandHostRegistry.hostFor(host.screen.name, host.barId); - const surface = body.surface; - root.check(host.width < host.screen.width / 2 && host.height < host.screen.height / 2, "compact window must not allocate a fullscreen buffer"); - for (const point of [[host.screen.width * 0.8, host.screen.height * 0.7], [0, 0]]) { - host.moveTo(point[0], point[1]); - surface.surfaceMotion.settle(); - root.check(host.implicitWidth <= Math.ceil(surface.currentVisualWidth) + 1, "moving buffer stays bounded to the face"); - } - host.islandController.requestActivity("controlcenter", true, false); - surface.surfaceMotion.settle(); - root.check(host.implicitWidth < host.screen.width && host.implicitHeight < host.screen.height, "expanded buffer is bounded to the sheet"); - } - console.log("FIXTURE_PASS"); - } catch (error) { - console.error("FIXTURE_FAIL", error.message); - } - Qt.quit(); - } - } -} diff --git a/quickshell/tests/qml/island-launcher-focus.qml b/quickshell/tests/qml/island-launcher-focus.qml deleted file mode 100644 index 0ae0bb920..000000000 --- a/quickshell/tests/qml/island-launcher-focus.qml +++ /dev/null @@ -1,134 +0,0 @@ -import QtQuick -import Quickshell -import qs.Common -import qs.Services -import qs.Modules.DankBar -import qs.Modules.DankIsland -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - - property int step: 0 - property bool failed: false - - function check(condition, label) { - if (condition) - return; - failed = true; - console.log("FIXTURE_FAIL " + label); - } - - function controller() { - return islands.hosts()[0]?.body?.islandController ?? null; - } - - Item { - Repeater { - model: ScriptModel { - values: SettingsData.barConfigs - objectProp: "id" - } - delegate: DankBar { - required property var modelData - barConfig: modelData - } - } - } - - DankIsland { - id: islands - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - SettingsData.frameEnabled = false; - SettingsData.reduceMotion = true; - SettingsData.launcherStyle = "island"; - SettingsData.rememberLastQuery = false; - SettingsData.barConfigs = [ - { - id: "island", - enabled: true, - visible: true, - position: 0, - island: true, - spacing: 4, - innerPadding: 4, - leftWidgets: [], - centerWidgets: [], - rightWidgets: [], - islandShowSatellites: false - } - ]; - } - - Timer { - interval: 25 - running: true - repeat: true - property int waited: 0 - - property string waitingFor: "the island host" - - function advance(label) { - root.step++; - waited = 0; - waitingFor = label; - } - - onTriggered: { - const c = root.controller(); - if (++waited > 800) { - console.log("FIXTURE_FAIL timed out waiting for " + waitingFor); - stop(); - Qt.quit(); - return; - } - switch (root.step) { - case 0: - if (!c) - return; - c.requestLauncher("", "", false); - advance("cold open to expand and focus the search field"); - return; - case 1: - if (!c.expanded || !c.launcherInputFocused) - return; - root.check(c.activeActivity === "launcher", "cold open expands the launcher"); - c.requestCollapse(); - advance("collapse to drop search focus"); - return; - case 2: - if (c.launcherInputFocused) - return; - c.requestLauncher("", "", false); - advance("warm reopen to focus the search field"); - return; - case 3: - if (!c.launcherInputFocused) - return; - c.requestControlCenter("", false); - advance("control center to take over and release launcher focus"); - return; - case 4: - if (c.activeActivity !== "controlcenter" || c.launcherInputFocused) - return; - c.requestLauncher("", "", false); - advance("launcher to refocus after an activity switch"); - return; - case 5: - if (!c.launcherInputFocused) - return; - c.requestCollapse(); - console.log(root.failed ? "FIXTURE_FAIL see above" : "FIXTURE_PASS"); - stop(); - Qt.quit(); - return; - } - } - } -} diff --git a/quickshell/tests/qml/island-pulses.qml b/quickshell/tests/qml/island-pulses.qml deleted file mode 100644 index 86fffc21f..000000000 --- a/quickshell/tests/qml/island-pulses.qml +++ /dev/null @@ -1,149 +0,0 @@ -import QtQuick -import Quickshell -import qs.Common -import qs.Services -import qs.Modules.DankBar -import qs.Modules.DankIsland -import qs.Modules.Frame -import qs.DankCommon.Common as DC - -// State pulses (caps lock, charging) and the mic level share the system transient with volume, and the duo status glyph loads in the home face. -ShellRoot { - id: root - - readonly property var screen: Quickshell.screens[0] - readonly property string barId: "main" - - Item { - Repeater { - model: ScriptModel { - values: SettingsData.barConfigs - objectProp: "id" - } - delegate: DankBar { - required property var modelData - barConfig: modelData - } - } - } - - Frame {} - - DankIsland {} - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - } - - Timer { - interval: 0 - running: SettingsData._hasLoaded && SessionData._hasLoaded - onTriggered: { - SettingsData.reduceMotion = true; - SettingsData.frameMode = "connected"; - SettingsData.frameEnabled = true; - FrameTransitionState.acknowledge(FrameTransitionState.revision); - SettingsData.barConfigs = [ - { - id: root.barId, - enabled: true, - visible: true, - position: 0, - screenPreferences: ["all"], - leftWidgets: ["clock"], - centerWidgets: [ - { - id: "island", - enabled: true, - islandBatteryStyle: "duo", - islandHomeLayout: [ - { - id: "status", - enabled: true - }, - { - id: "clock", - enabled: true - } - ] - } - ], - rightWidgets: ["clock"] - } - ]; - } - } - - Timer { - id: steps - - property int step: 0 - property int waited: 0 - readonly property var body: IslandHostRegistry.hostFor(root.screen.name, root.barId) - - interval: 25 - repeat: true - running: !!body && !!body.islandController && SettingsData.frameEnabled - - function finish(message) { - if (message) - console.error("FIXTURE_FAIL", message); - else - console.log("FIXTURE_PASS"); - stop(); - Qt.quit(); - } - - function expectActivity(activityId) { - if (body.islandController.activeActivity === activityId) - return true; - finish("expected " + activityId + " but the island shows " + body.islandController.activeActivity); - return false; - } - - onTriggered: { - if (++waited > 800) { - finish("timed out at step " + step); - return; - } - if (body.motionRunning || body.bandThickness <= 0) - return; - const controller = body.islandController; - waited = 0; - switch (step++) { - case 0: - if (!controller.requestSystemActivity("capslock")) { - finish("caps lock is not a system transient"); - return; - } - expectActivity("capslock"); - return; - case 1: - if (!controller.requestSystemActivity("charging")) - finish("charging is not a system transient"); - return; - case 2: - if (!expectActivity("charging")) - return; - if (controller.compactTargetFor("charging").width === controller.compactTargetFor("volume").width) - finish("a state pulse borrows the level pill width"); - return; - case 3: - if (!controller.requestSystemActivity("mic")) - finish("mic is not a system level"); - return; - case 4: - if (!expectActivity("mic")) - return; - controller.requestCollapse(); - return; - default: - if (expectActivity("home")) - finish(""); - } - } - } -} diff --git a/quickshell/tests/qml/island-sheet-hover.qml b/quickshell/tests/qml/island-sheet-hover.qml deleted file mode 100644 index ce2cd97b0..000000000 --- a/quickshell/tests/qml/island-sheet-hover.qml +++ /dev/null @@ -1,134 +0,0 @@ -import QtQuick -import QtTest -import Quickshell -import qs.Common -import qs.Services -import qs.Modules.DankBar -import qs.Modules.DankIsland -import qs.Modules.Frame -import qs.DankCommon.Common as DC - -// Qt 6.11 caches pointer bounds at first hover (PointerOverflowMarker): the sheet grown past the band must still take the pointer. -ShellRoot { - id: root - - readonly property var screen: Quickshell.screens[0] - readonly property string barId: "main" - - TestCase { - id: input - when: false - } - - Item { - Repeater { - model: ScriptModel { - values: SettingsData.barConfigs - objectProp: "id" - } - delegate: DankBar { - required property var modelData - barConfig: modelData - } - } - } - - Frame {} - - DankIsland {} - - function config(withIsland) { - return [ - { - id: root.barId, - enabled: true, - visible: true, - position: 0, - screenPreferences: ["all"], - leftWidgets: ["clock"], - centerWidgets: withIsland ? [ - { - id: "island", - enabled: true - }, - "clock"] : ["clock"], - rightWidgets: ["clock"] - } - ]; - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - } - - Timer { - interval: 0 - running: SettingsData._hasLoaded && SessionData._hasLoaded - onTriggered: { - SettingsData.reduceMotion = true; - SettingsData.frameMode = "connected"; - SettingsData.frameEnabled = true; - FrameTransitionState.acknowledge(FrameTransitionState.revision); - SettingsData.barConfigs = root.config(false); - } - } - - Timer { - id: steps - - property bool expanding: false - property int waited: 0 - readonly property var body: IslandHostRegistry.hostFor(root.screen.name, root.barId) - readonly property var bareBody: BarWidgetService.frameHostedBars[root.screen.name]?.[root.barId] ?? null - readonly property var frame: body?.hostWindow ?? null - - interval: 25 - repeat: true - running: (!!body && !!body.islandController || !!bareBody) && SettingsData.frameEnabled - - function finish(message) { - if (message) - console.error("FIXTURE_FAIL", message); - else - console.log("FIXTURE_PASS"); - stop(); - Qt.quit(); - } - - onTriggered: { - if (++waited > 800) { - finish("timed out " + (expanding ? "expanding the sheet" : "waiting for the island at rest")); - return; - } - if (!body) { - const content = bareBody.hostWindow?.contentItem ?? null; - if (!content || content.width <= 0 || bareBody.width <= 0) - return; - input.mouseMove(content, 40, bareBody.hostOffsetY + bareBody.height / 2, 0); - SettingsData.barConfigs = root.config(true); - waited = 0; - return; - } - const controller = body.islandController; - if (!expanding) { - if (body.motionRunning || body.bandThickness <= 0 || frame.cutoutTopInset <= 0) - return; - input.mouseMove(frame.contentItem, 40, frame.cutoutTopInset / 2, 0); - controller.requestToggle(true); - expanding = true; - waited = 0; - return; - } - if (!controller.expanded || body.motionRunning) - return; - const sheet = ConnectedModeState.surfaceDescriptor(root.screen.name, "island"); - if (!sheet.presented) - return; - input.mouseMove(frame.contentItem, sheet.bodyRect.x + sheet.bodyRect.width / 2, sheet.bodyRect.y + sheet.bodyRect.height - 30, 0); - finish(controller.pointerInside ? "" : "hover past the band reaches the sheet"); - } - } -} diff --git a/quickshell/tests/qml/launcher-plugin-instances.qml b/quickshell/tests/qml/launcher-plugin-instances.qml deleted file mode 100644 index eb0ae6759..000000000 --- a/quickshell/tests/qml/launcher-plugin-instances.qml +++ /dev/null @@ -1,154 +0,0 @@ -import QtQuick -import Quickshell -import qs.Common -import qs.Services -import qs.Modals.DankLauncherV2 -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - - property var launcher: null - property bool failed: false - property int step: 0 - property var created: [] - - function check(condition, label) { - if (condition) - return; - failed = true; - console.log("FIXTURE_FAIL " + label + " created=" + JSON.stringify(root.created)); - } - - function shows(pluginId) { - return root.launcher.spotlightContent.controller.flatModel.some(entry => entry.item?.pluginId === pluginId); - } - - function stallState() { - const content = root.launcher?.spotlightContent; - const controller = content?.controller; - return JSON.stringify({ - open: root.launcher?.spotlightOpen, - contentVisible: root.launcher?.contentVisible, - content: !!content, - searchMode: controller?.searchMode, - isSearching: controller?.isSearching, - pluginPhasePending: controller?._pluginPhasePending, - rows: controller?.flatModel?.length, - cacheValid: AppSearchService.isCacheValid(), - launchers: Object.keys(PluginService.getLauncherPlugins()), - emptyTrigger: PluginService.getPluginsWithEmptyTrigger(), - pluginSettings: SettingsData.pluginSettings, - visibility: SettingsData.launcherPluginVisibility - }); - } - - component FixtureLauncher: QtObject { - property var pluginService: null - required property string pluginId - - Component.onCompleted: root.created = root.created.concat([pluginId]) - - function getItems(query) { - return [ - { - name: pluginId - } - ]; - } - } - - Component { - id: allowedLauncher - FixtureLauncher { - pluginId: "allowed" - } - } - - Component { - id: triggerLauncher - FixtureLauncher { - pluginId: "triggered" - } - } - - Component { - id: modalComp - DankLauncherV2Modal {} - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - SettingsData.launcherStyle = "standalone"; - SettingsData.rememberLastQuery = false; - SettingsData.rememberLastMode = false; - root.launcher = modalComp.createObject(root); - } - - Timer { - interval: 0 - running: SettingsData._hasLoaded && SessionData._hasLoaded - onTriggered: { - PluginService.availablePlugins = { - allowed: { - id: "allowed", - name: "Allowed", - loaded: true - }, - triggered: { - id: "triggered", - name: "Triggered", - trigger: "z", - loaded: true - } - }; - SettingsData.setPluginSetting("allowed", "noTrigger", true); - SettingsData.setPluginAllowWithoutTrigger("triggered", false); - PluginService.pluginLauncherComponents = { - allowed: allowedLauncher, - triggered: triggerLauncher - }; - steps.start(); - } - } - - Timer { - id: steps - interval: 25 - repeat: true - property int waited: 0 - onTriggered: { - if (++waited > 800) { - check(false, "timed out in step " + root.step + " state=" + root.stallState()); - stop(); - Qt.quit(); - return; - } - switch (root.step) { - case 0: - root.launcher.show(); - waited = 0; - root.step++; - return; - case 1: - if (root.created.length === 0) - return; - check(JSON.stringify(root.created) === '["allowed"]', "empty query creates only the plugin allowed without a trigger"); - root.launcher.spotlightContent.searchField.text = "z"; - waited = 0; - root.step++; - return; - case 2: - if (!root.shows("triggered")) - return; - check(root.shows("triggered"), "typing the trigger creates the plugin and shows its items"); - console.log(root.failed ? "FIXTURE_FAIL see above" : "FIXTURE_PASS"); - stop(); - Qt.quit(); - } - } - } -} diff --git a/quickshell/tests/qml/media-lyrics.qml b/quickshell/tests/qml/media-lyrics.qml deleted file mode 100644 index af957eba4..000000000 --- a/quickshell/tests/qml/media-lyrics.qml +++ /dev/null @@ -1,513 +0,0 @@ -import QtQuick -import QtTest -import Quickshell -import Quickshell.Services.Mpris -import qs.Common -import qs.Services -import qs.Modules.DankDash -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - - property bool recordSeekIndices: false - property var seekIndices: [] - readonly property int lyricIndex: LyricsService.controller.activeIndex - onLyricIndexChanged: { - if (recordSeekIndices) - seekIndices.push(lyricIndex); - } - readonly property string longLine: "A complete lyric line that keeps every word visible, even when the player is narrow and the sentence needs several lines to fit." - readonly property string wordLine: "Sing & stay" - readonly property var timed: [ - { - t: 0, - x: "The first line starts here" - }, - { - t: 10, - x: longLine - }, - { - t: 20, - x: "The next line moves into view" - }, - { - t: 20, - x: "And its backing vocal stays with it" - }, - { - t: 30, - x: wordLine, - w: [ - { - t: 30, - e: 30.25, - x: "Sing " - }, - { - t: 30.25, - e: 30.5, - x: " & " - }, - { - t: 30.5, - e: 32.5, - x: "stay" - } - ] - }, - { - t: 40, - x: "The final line is here" - } - ] - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - LyricsService.controller.backend = backend; - LyricsService.activePlayer = source; - } - - QtObject { - id: source - property real position: 0 - property real rate: 1 - property int playbackState: MprisPlaybackState.Playing - property var metadata: ({}) - } - - QtObject { - id: backend - property int nextId: 0 - property int calls: 0 - property int cancelled: 0 - property var pending: ({}) - property var lastParams: null - function sendRequest(method, params, callback, timeout) { - calls++; - lastParams = params; - pending[++nextId] = callback; - return nextId; - } - function cancelRequest(id) { - if (pending[id]) - cancelled++; - delete pending[id]; - } - function respond(result) { - root.waitFor(() => !!pending[nextId], "a lyrics request is pending"); - const callback = pending[nextId]; - delete pending[nextId]; - callback(result); - } - } - - Component { - id: plainLyrics - LyricsController { - enabled: true - track: ({ - title: "Plugin song", - artist: "Plugin artist", - length: 60 - }) - player: QtObject { - property real position: 12 - } - } - } - - TestCase { - id: input - when: false - name: "media-lyrics" - } - - FloatingWindow { - visible: true - implicitWidth: 900 - implicitHeight: 700 - Item { - id: stage - anchors.centerIn: parent - width: 780 - height: media.implicitHeight - MediaPlayerTab { - id: media - anchors.fill: parent - live: true - } - } - } - - function check(value, message) { - if (!value) - throw new Error(message); - } - - function waitFor(condition, message) { - const deadline = Date.now() + 20000; - while (!condition()) { - check(Date.now() < deadline, "timed out: " + message); - input.wait(10); - } - } - - function find(item, predicate) { - if (predicate(item)) - return item; - for (const child of item.children ?? []) { - const result = find(child, predicate); - if (result) - return result; - } - return null; - } - - function seekPaused(position) { - source.position = position; - waitFor(() => Math.abs(LyricsService.controller.sampleTime - position) < 0.01, "lyrics resync to " + position); - } - - function vocal(overlay, text) { - waitFor(() => !!find(overlay, item => item.part?.x === text), text + " is realized"); - return find(overlay, item => item.part?.x === text); - } - - function run() { - try { - waitFor(() => !!media.activePlayer, "fixture player discovered"); - DMSService.capabilities = ["lyrics"]; - SettingsData.reduceMotion = true; - SettingsData.dashOptions = { - media: { - playerStyle: "bento", - lyrics: true - } - }; - MprisController.stableTitle = "Lyrics fixture"; - MprisController.stableArtist = "Fixture artist"; - MprisController.activePlayerStableLength = 180; - media.wallpaperEnabled = false; - media.lyricsOpen = true; - waitFor(() => backend.calls > 0, "lyrics lookup starts"); - check(backend.calls === 1, "metadata changes produce one lookup"); - backend.respond({ - result: { - found: true, - synced: root.timed - } - }); - root.recordSeekIndices = true; - source.position = 30; - source.position = 10; - waitFor(() => LyricsService.controller.activeIndex === 1, "seek lands on the second line"); - root.recordSeekIndices = false; - check(root.seekIndices.length === 1 && root.seekIndices[0] === 1, "seek updates publish only the settled lyric line"); - check(LyricsService.controller.lines.length === 5 && LyricsService.controller.lines[2].x.includes("backing vocal"), "same-time lyrics stay together"); - waitFor(() => media.lyricsFocusTarget?.activeFocus, "lyrics receives keyboard focus"); - let overlay = media.lyricsFocusTarget; - const line = find(overlay, item => item.text === root.longLine && item.truncated !== undefined); - check(!!line && !line.truncated && line.lineCount > 2, "long lyrics wrap without eliding"); - input.keyClick(Qt.Key_PageDown); - check(!overlay.following && input.findChild(overlay, "followPlayback")?.visible, "browsing pauses automatic following"); - input.findChild(overlay, "followPlayback").click(); - check(overlay.following, "follow action resumes synchronized scrolling"); - const settledCalls = backend.calls; - MprisController.stableAlbum = "Late album"; - waitFor(() => backend.calls === settledCalls + 1, "a late album refreshes the lookup"); - check(LyricsService.controller.state === "ready" && LyricsService.controller.tick.running, "the shown lyrics keep following during the refresh"); - backend.respond({ - result: { - found: true, - synced: root.timed - } - }); - check(LyricsService.controller.tick.running, "an unchanged refresh keeps the timer running"); - source.playbackState = MprisPlaybackState.Stopped; - check(LyricsService.controller.activeIndex === 1, "stopped playback holds the lyric position"); - source.playbackState = MprisPlaybackState.Playing; - source.playbackState = MprisPlaybackState.Paused; - check(!LyricsService.controller.tick.running, "paused playback has no lyric timer"); - source.playbackState = MprisPlaybackState.Playing; - check(LyricsService.controller.tick.running, "resuming schedules the next line"); - source.position = 9.5; - source.playbackState = MprisPlaybackState.Paused; - check(Math.abs(LyricsService.controller.currentTime() - 9.5) < 0.05 && LyricsService.controller.activeIndex === 0 && !LyricsService.controller.tick.running, "pausing immediately after a seek retains the seek target"); - SettingsData.reduceMotion = false; - seekPaused(30.3); - const wordText = find(overlay, item => item.Accessible.name === root.wordLine && item.textFormat !== undefined); - check(wordText?.textFormat === Text.StyledText && wordText.text.includes("<softly> &"), "word highlights preserve literal lyric text"); - const wordRow = find(overlay, item => item.current && item.timedWords && item.wordProgress !== undefined); - check(wordRow && Math.abs(wordRow.wordProgress - 0.2) < 0.01, "paused seeking restores progress within a word"); - seekPaused(31); - check(Math.abs(wordRow.wordProgress - 0.25) < 0.01, "held words use their end timestamp"); - input.wait(40); - check(Math.abs(wordRow.wordProgress - 0.25) < 0.01, "paused word highlights stay still"); - source.playbackState = MprisPlaybackState.Playing; - media.lyricsOpen = false; - waitFor(() => !media.lyricsFocusTarget, "closing releases the view"); - check(!LyricsService.controller.tick.running, "closing stops scheduling"); - media.lyricsOpen = true; - waitFor(() => !!media.lyricsFocusTarget, "reopened lyrics load"); - check(backend.calls === settledCalls + 2 && LyricsService.controller.state === "ready", "reopening reuses loaded lyrics"); - LyricsService.controller.request(); - const stale = backend.pending[backend.nextId]; - MprisController.stableTitle = "Next track"; - waitFor(() => backend.cancelled > 0, "track change cancels old response handler"); - stale({ - result: { - found: true, - plain: "stale lyrics" - } - }); - check(LyricsService.controller.state !== "ready", "late response cannot populate a new track"); - backend.respond({ - result: { - found: true, - plain: Array(20).fill(root.longLine).join("\n") - } - }); - waitFor(() => !!media.lyricsFocusTarget, "plain lyrics load"); - overlay = media.lyricsFocusTarget; - overlay.forceActiveFocus(); - input.keyClick(Qt.Key_End); - const list = find(overlay, item => typeof item.positionViewAtEnd === "function"); - waitFor(() => list.contentY > 0, "plain lyrics scroll to the end"); - check(!LyricsService.controller.synced, "plain lyrics are not synchronized"); - source.metadata = { - "xesam:asText": "Embedded text remains available offline" - }; - LyricsService.controller.request(); - backend.respond({ - error: "offline" - }); - backend.respond({ - error: "offline" - }); - check(LyricsService.controller.state === "ready" && LyricsService.controller.plainLines[0] === source.metadata["xesam:asText"], "network errors fall back to embedded lyrics"); - LyricsService.controller.request(); - const oldProviderReply = backend.pending[backend.nextId]; - const oldCalls = backend.calls; - SettingsData.mediaLyricsProviders = [ - { - id: "lrclib", - enabled: true - }, - { - id: "betterlyrics", - enabled: false - }, - { - id: "lyricsplus", - enabled: true - } - ]; - oldProviderReply({ - result: { - found: true, - plain: "stale provider lyrics" - } - }); - waitFor(() => backend.calls === oldCalls + 1, "provider change requests again"); - check(JSON.stringify(backend.lastParams.providers) === '["lrclib","lyricsplus"]' && LyricsService.controller.state === "loading", "priority changes discard pending results and request enabled providers in order"); - SettingsData.mediaLyricsProviders = MediaOptions.lyricsProviders.map(provider => ({ - id: provider.id, - enabled: false - })); - waitFor(() => backend.calls === oldCalls + 2, "disabling providers requests again"); - check(backend.lastParams.providers.length === 0 && !backend.lastParams.allowNetwork, "disabling every provider requests local lyrics only"); - backend.respond({ - result: { - found: false - } - }); - check(LyricsService.controller.state === "ready" && !LyricsService.controller.synced, "embedded lyrics remain available with all providers off"); - source.playbackState = MprisPlaybackState.Paused; - source.position = 13; - LyricsService.controller.request(); - backend.respond({ - result: { - found: true, - voices: { - a: { - name: "First singer", - type: "person" - }, - b: { - name: "Second singer", - type: "person" - } - }, - synced: [ - { - t: 0, - e: 2, - x: "Before the duet" - }, - { - t: 10, - e: 18, - x: "Lead held", - voice: "a", - group: 1, - w: [ - { - t: 10, - e: 14, - x: "Lead " - }, - { - t: 14, - e: 18, - x: "held" - } - ] - }, - { - t: 11, - e: 17, - x: "Echo", - voice: "a", - group: 1, - background: true, - w: [ - { - t: 11, - e: 17, - x: "Echo" - } - ] - }, - { - t: 12, - e: 16, - x: "Reply", - voice: "b", - w: [ - { - t: 12, - e: 16, - x: "Reply" - } - ] - }, - { - t: 20, - e: 22, - x: "After the duet", - voice: "b" - }, - { - t: 25, - e: 28, - x: "The next verse", - voice: "a" - }, - { - t: 30, - e: 36, - x: "Held over", - voice: "a" - }, - { - t: 33, - e: 38, - x: "Starts early", - voice: "b" - }, - { - t: 40, - e: 48, - x: "Overlong", - voice: "a" - }, - { - t: 43, - e: 46, - x: "Same singer next", - voice: "a" - } - ] - } - }); - waitFor(() => !!media.lyricsFocusTarget && LyricsService.controller.synced, "duet lyrics load"); - overlay = media.lyricsFocusTarget; - overlay.snapToCurrent(); - const leadVocal = vocal(overlay, "Lead held"); - const backingVocal = vocal(overlay, "Echo"); - const replyVocal = vocal(overlay, "Reply"); - check(leadVocal.current && backingVocal.current && replyVocal.current, "both singers and backing vocals can be active together"); - check(Math.abs(leadVocal.wordProgress - 0.75) < 0.01 && Math.abs(backingVocal.wordProgress - 1 / 3) < 0.01 && Math.abs(replyVocal.wordProgress - 0.25) < 0.01, "each voice uses its own word duration"); - check(LyricsService.controller.lines[1].parts.length === 2, "backing vocals remain grouped with their lead"); - seekPaused(17); - check(leadVocal.current && !backingVocal.current && !replyVocal.current && LyricsService.controller.activeIndex === 2, "parts end independently without scrolling backwards"); - check(replyVocal.highlighted && replyVocal.wordProgress === 1, "completed words retain their highlight while the line stays in focus"); - seekPaused(22.5); - const finishedLine = vocal(overlay, "After the duet"); - check(!finishedLine.current && finishedLine.highlighted, "completed line timing holds its highlight through the pause"); - seekPaused(25.1); - check(!finishedLine.highlighted, "the old line loses its highlight when focus advances"); - seekPaused(34); - overlay.snapToCurrent(); - const heldOver = vocal(overlay, "Held over"); - check(heldOver.highlighted, "overlapping lines from different singers stay colored together"); - seekPaused(36.5); - check(!heldOver.highlighted, "an overlapping line loses its color once it ends, before focus advances"); - seekPaused(44); - overlay.snapToCurrent(); - const overlong = vocal(overlay, "Overlong"); - check(overlong.part.e === 43, "a voice cannot hold a line past its own next line"); - check(!overlong.highlighted, "a stale provider end does not keep the previous line colored"); - seekPaused(13); - check(backingVocal.current && replyVocal.current && Math.abs(replyVocal.wordProgress - 0.25) < 0.01, "seeking back restores every overlapping part"); - MprisController._syncStableMeta(); - LyricsService.activePlayer = media.activePlayer; - LyricsService.controller.request(); - backend.respond({ - result: { - found: true, - synced: root.timed - } - }); - const seekbar = find(media, item => typeof item.seekTo === "function"); - for (const target of [31, 10.3, 20.2, 31.4]) { - seekbar.seekTo(target); - waitFor(() => media.activePlayer.position >= target - 0.05 && media.activePlayer.position < target + 5 && Math.abs(LyricsService.controller.currentTime() - media.activePlayer.position) < 0.05 && LyricsService.controller.activeIndex === LyricsService.controller.indexFor(target, LyricsService.controller.lines), "DMS seekbar resynchronizes against real MPRIS after seeking to " + target); - } - media.activePlayer.pause(); - waitFor(() => !LyricsService.controller.playing, "real MPRIS pause reaches lyrics"); - seekbar.seekTo(30.3); - waitFor(() => Math.abs(LyricsService.controller.currentTime() - 30.3) < 0.01 && LyricsService.controller.wordTime === 30.25, "DMS seekbar updates the current word while paused"); - media.activePlayer.play(); - waitFor(() => LyricsService.controller.currentTime() > 30.35 && Math.abs(LyricsService.controller.currentTime() - media.activePlayer.position) < 0.05, "resuming continues from the seek target"); - const plain = plainLyrics.createObject(root, { - backend: backend - }); - waitFor(() => backend.lastParams.title === "Plugin song", "a plain source requests its own track"); - backend.respond({ - result: { - found: true, - synced: root.timed - } - }); - check(plain.activeIndex === 1, "a plain source follows its own position"); - plain.destroy(); - console.log("FIXTURE_PASS lyric lookup, seeking, word timing, overlapping vocals, stale replies, provider fallback and plain sources"); - Qt.quit(); - } catch (error) { - console.error("FIXTURE_FAIL", error); - Qt.exit(1); - } - } - - Timer { - interval: 0 - running: true - onTriggered: root.run() - } -} diff --git a/quickshell/tests/qml/media-playback.qml b/quickshell/tests/qml/media-playback.qml deleted file mode 100644 index e37669157..000000000 --- a/quickshell/tests/qml/media-playback.qml +++ /dev/null @@ -1,137 +0,0 @@ -import QtQuick -import QtTest -import Quickshell -import qs.Common -import qs.Services -import qs.Modules.OSD -import qs.Modules.DankDash -import qs.DankCommon.Common as DC -import "DankCommon/Common/Contrast.js" as Contrast - -ShellRoot { - id: root - property bool tracking: false - property int playerLosses: 0 - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - } - TestCase { - id: input - when: false - name: "media" - } - Connections { - target: MprisController - function onActivePlayerChanged() { - if (root.tracking && !MprisController.activePlayer) - root.playerLosses++; - } - } - MediaPlaybackOSD { - id: osd - modelData: Quickshell.screens[0] - autoHideInterval: 10000 - } - DankDashPopout { - id: dash - triggerScreen: Quickshell.screens[0] - } - function check(value, message) { - if (!value) - throw new Error(message); - } - function waitFor(condition, message) { - const deadline = Date.now() + 20000; - while (!condition()) { - check(Date.now() < deadline, "timed out: " + message); - input.wait(10); - } - } - function find(item) { - if (!item) - return null; - if (item.checkable === true && typeof item.click === "function") - return item; - for (const child of item.children ?? []) { - const found = find(child); - if (found) - return found; - } - return null; - } - function checkAccentPairs() { - for (let hue = 0; hue < 12; hue++) { - for (const saturation of [0.1, 0.35, 0.6, 1]) { - MediaAccentService._accent = Qt.hsva(hue / 12, saturation, 0.7, 1); - const container = MediaAccentService.accentContainer; - const onContainer = MediaAccentService.onAccentContainer; - const label = " at hue " + hue + " saturation " + saturation; - check(Contrast.ratio(container, onContainer) >= 4.5, "accent container pair clears 4.5:1" + label); - const secondary = MediaAccentService.accentSecondaryContainer; - check(Contrast.ratio(secondary, MediaAccentService.onAccentSecondaryContainer) >= 4.5, "accent secondary pair clears 4.5:1" + label); - check(Contrast.ratio(MediaAccentService.accent, MediaAccentService.onAccent) >= 4.5, "accent foreground clears 4.5:1" + label); - } - } - } - function checkAccent(button, enabled) { - SettingsData.dashOptions = Object.assign({}, SettingsData.dashOptions, { - media: Object.assign({}, SettingsData.dashOptions?.media, { - albumArtAccent: enabled - }) - }); - const original = MediaAccentService._accent; - const fills = []; - for (const accent of [Qt.rgba(1, 0, 0, 1), Qt.rgba(0, 1, 1, 1)]) { - MediaAccentService._accent = accent; - const fill = osd.useVertical ? osd.surfaceColor : button.backgroundColor; - fills.push(fill.toString()); - if (enabled) - check(Contrast.ratio(fill, button.iconColor) >= 4.5, "play/pause keeps a readable foreground"); - } - check((fills[0] !== fills[1]) === enabled, "artwork changes the control color only when album accents are enabled"); - MediaAccentService._accent = original; - } - Timer { - interval: 0 - running: true - onTriggered: { - try { - root.waitFor(() => !!MprisController.activePlayer, "player discovered"); - SettingsData.reduceMotion = true; - const player = MprisController.activePlayer; - root.tracking = true; - dash.requestTab("media"); - dash.dashVisible = true; - player.next(); - root.waitFor(() => player.trackTitle === "Track 2", "next track reaches the shell"); - root.check(root.playerLosses === 0, "playing track transition retains player"); - root.waitFor(() => dash.shouldBeVisible, "track transition leaves dash open"); - const orientations = []; - for (const position of [SettingsData.Position.BottomCenter, SettingsData.Position.LeftCenter]) { - SettingsData.osdPosition = position; - SessionData.suppressOSD = false; - osd.show(); - root.waitFor(() => osd.shouldBeVisible && !!osd.contentLoader.item, "OSD presents at " + position); - orientations.push(osd.useVertical); - const button = root.find(osd.contentLoader.item); - root.checkAccent(button, false); - root.checkAccent(button, true); - const wasPlaying = player.isPlaying; - button.click(); - root.waitFor(() => player.isPlaying !== wasPlaying, "play/pause updates player " + position); - root.check(osd.shouldBeVisible, "play/pause keeps OSD open " + position); - root.check(dash.shouldBeVisible, "OSD leaves dash open " + position); - } - root.check(orientations.includes(true) && orientations.includes(false), "both OSD layouts exercised"); - root.checkAccentPairs(); - console.log("FIXTURE_PASS media player continuity, popout retained, album art accent in both OSD layouts"); - } catch (error) { - console.error("FIXTURE_FAIL", error.message); - } - Qt.quit(); - } - } -} diff --git a/quickshell/tests/qml/media-presentation.qml b/quickshell/tests/qml/media-presentation.qml deleted file mode 100644 index cc17bf8b2..000000000 --- a/quickshell/tests/qml/media-presentation.qml +++ /dev/null @@ -1,307 +0,0 @@ -import QtQuick -import QtTest -import Quickshell -import Quickshell.Services.Mpris -import qs.Common -import qs.Services -import qs.Modules.DankDash -import qs.Modules.DankDash.Media -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - } - - TestCase { - id: input - when: false - name: "media-presentation" - } - - FloatingWindow { - visible: true - implicitWidth: 780 - implicitHeight: 500 - - Item { - id: viewport - width: 780 - height: 350 - - MediaPlayerTab { - id: media - width: parent.width - height: implicitHeight - contentViewport: viewport - live: true - } - } - } - - QtObject { - id: capabilities - property var presentation: null - property var activePlayer: null - } - - Component { - id: transportProbe - - MediaTransportButton { - player: capabilities - } - } - - function check(value, label) { - if (!value) - throw new Error(label); - } - - function waitFor(condition, label) { - const deadline = Date.now() + 20000; - while (!condition()) { - check(Date.now() < deadline, "timed out: " + label); - input.wait(10); - } - } - - function waitStable(sample, label) { - const surface = media.Window.window; - check(!input.isPolishScheduled(surface) || input.waitForPolish(surface, 20000), label); - const deadline = Date.now() + 20000; - let value = sample(); - let repeats = 0; - while (repeats < 3) { - check(Date.now() < deadline, "timed out: " + label); - input.wait(10); - const current = sample(); - repeats = current === value ? repeats + 1 : 0; - value = current; - } - return value; - } - - function count(item, predicate) { - let total = predicate(item) ? 1 : 0; - for (const child of item.children ?? []) - total += count(child, predicate); - return total; - } - - function find(item, predicate) { - if (predicate(item)) - return item; - for (const child of item.children ?? []) { - const result = find(child, predicate); - if (result) - return result; - } - return null; - } - - function run() { - try { - waitFor(() => !!MprisController.activePlayer, "fixture player discovered"); - const player = MprisController.activePlayer; - SettingsData.reduceMotion = true; - MprisController.activePlayerStableLength = 180; - waitFor(() => find(media, item => typeof item.seekTo === "function")?.visible && find(media, item => item.mediaAction === "next" && typeof item.click === "function")?.visible && !!find(viewport, item => item.dismissible !== undefined), "playback controls and sheet loaded"); - const seekbar = find(media, item => typeof item.seekTo === "function"); - const next = find(media, item => item.mediaAction === "next" && typeof item.click === "function"); - const sheet = find(viewport, item => item.dismissible !== undefined); - const cover = find(media, item => item.artPixelSize !== undefined); - check(cover.artPixelSize < Math.max(cover.width, cover.height) * 2, "art decodes at the render scale, not a fixed 2x oversample"); - const height = waitStable(() => media.implicitHeight, "card height settles before the player drops"); - const position = next.mapToItem(media, 0, 0); - media.activePlayer = null; - check(next.visible && !next.enabled && seekbar.visible, "brief player loss preserves controls but disables actions"); - waitFor(() => next.mapToItem(media, 0, 0).y === position.y && media.implicitHeight === height, "brief player loss preserves layout"); - media.activePlayer = player; - MprisController.activePlayerStableLength = 0; - check(seekbar.visible && !seekbar.enabled, "duration gap retains a noninteractive timeline"); - waitFor(() => next.mapToItem(media, 0, 0).y === position.y, "duration gap does not move transport"); - MprisController.activePlayerStableLength = 180; - MprisController.stableTitle = "A much longer title that cannot fit in the title row"; - input.wait(20); - waitFor(() => next.mapToItem(media, 0, 0).y === position.y, "long track titles preserve transport alignment"); - const title = find(media, item => item.loop === true && item.text === MprisController.stableTitle); - check(!!title && title.needsScrolling && !title.scrollActive, "reduced motion keeps overflowing titles still"); - check(count(title, item => item !== title && item.text === title.text) === 2, "a looping title renders its trailing copy"); - SettingsData.reduceMotion = false; - title.scrollHoldMs = 0; - waitFor(() => title.scrollOffset > 0, "overflowing title scrolls during playback"); - title.scrollOffset = title.maxScrollOffset - 1; - title.stepScroll(1000); - check(title.scrollOffset >= 0 && title.scrollOffset < title.maxScrollOffset, "a looping title wraps instead of running off"); - check(title.scrollDirection === 1, "a looping title never reverses"); - const shuffle = find(media, item => item.mediaAction === "shuffle"); - const repeat = find(media, item => item.mediaAction === "repeat"); - check(shuffle.visible && shuffle.enabled && !shuffle.checked, "a player advertising shuffle shows an unchecked toggle"); - shuffle.click(); - waitFor(() => shuffle.checked, "clicking shuffle turns it on"); - check(repeat.visible && !repeat.checked, "repeat starts unchecked"); - repeat.click(); - waitFor(() => repeat.checked, "clicking repeat turns it on"); - repeat.click(); - waitFor(() => player.loopState === MprisLoopState.Track, "the second repeat step loops one track"); - check(repeat.checked, "the second repeat step loops one track"); - repeat.click(); - waitFor(() => !repeat.checked, "the third repeat step turns looping off"); - media.live = false; - input.wait(20); - check(!title.scrollActive && title.scrollOffset === 0, "hidden player stops and resets scrolling"); - media.live = true; - SettingsData.reduceMotion = true; - media.showPanel("players"); - player.stop(); - waitFor(() => player.playbackState === MprisPlaybackState.Stopped, "player stops"); - const row = find(sheet, item => item.title === player.identity && item.selected === true); - check(!!row && row.visible, "stopped transition keeps active player in picker"); - const rowBottom = row.mapToItem(viewport, 0, row.height).y; - check(rowBottom <= viewport.height, "sheet stays within a shorter viewport"); - input.keyClick(Qt.Key_Escape); - input.wait(20); - waitFor(() => media.panel === "" && media.implicitHeight === height, "sheet dismisses without resizing card"); - media.showPanel("players"); - media.isSeeking = true; - SettingsData.dashOptions = { - media: { - playerStyle: "material" - } - }; - input.wait(30); - check(media.playerStyle === "material" && media.panel === "" && !media.isSeeking, "style switch closes open sheet and clears seeking"); - const materialHeight = media.implicitHeight; - media.showPanel("players"); - waitFor(() => { - const sheet = find(viewport, item => item.dismissible !== undefined); - return sheet?.opened && sheet.containsItem(sheet.windowFocusItem); - }, "Material uses the shared sheet with focus"); - waitFor(() => media.implicitHeight === materialHeight, "Material sheet does not expand the card"); - input.keyClick(Qt.Key_Escape); - input.wait(20); - check(media.panel === "", "Material sheet dismisses"); - viewport.width = 320; - input.wait(20); - const materialSeekbar = find(media, item => typeof item.seekTo === "function"); - check(materialSeekbar.width > 0, "Material retains a usable timeline at narrow widths"); - viewport.width = 780; - SettingsData.dashOptions = { - media: { - playerStyle: "bento" - } - }; - waitFor(() => !!find(media, item => item.loop === true), "switching back restores Bento"); - media.lyricsOpen = false; - media.playerPaneOpen = false; - input.wait(30); - const artView = find(media, item => item.artRadius !== undefined); - const chrome = find(media, item => item.splitPanes !== undefined); - const artPos = artView.mapToItem(chrome, 0, 0); - check(Math.abs(artPos.y + artView.height / 2 - chrome.height / 2) <= chrome.height / 2, "both panes off keeps the art inside the card"); - media.playerPaneOpen = true; - input.wait(30); - SettingsData.dashOptions = { - media: { - playerStyle: "bento", - lyrics: false - } - }; - DMSService.capabilities = ["lyrics"]; - waitFor(() => !!find(media, item => item.selectionMode === "single" && item.checkEnabled === false), "view buttons load"); - const lyricsToggle = find(media, item => item.selectionMode === "single" && item.checkEnabled === false); - check(!lyricsToggle.visible, "lyrics option gates the button"); - SettingsData.dashOptions = { - media: { - playerStyle: "bento", - lyrics: true - } - }; - input.wait(30); - check(lyricsToggle.visible && !media.lyricsFocusTarget, "an enabled lyrics button does not load the overlay"); - media.playerPaneOpen = true; - media.lyricsOpen = false; - input.wait(30); - const artworkSegment = find(lyricsToggle, item => item.visualFirst === true && typeof item.click === "function"); - const lyricsSegment = find(lyricsToggle, item => item.visualLast === true && typeof item.click === "function"); - check(!!artworkSegment && !!lyricsSegment, "both view buttons are available"); - lyricsToggle.forceActiveFocus(Qt.TabFocusReason); - check(artworkSegment.activeFocus || lyricsSegment.activeFocus, "tab entry focuses a view button"); - input.mouseClick(lyricsSegment, lyricsSegment.width / 2, lyricsSegment.height / 2); - input.wait(30); - check(media.lyricsOpen && media.playerPaneOpen, "lyrics button opens lyrics over the artwork"); - media.live = false; - media.live = true; - check(media.lyricsOpen, "the lyrics view returns when the dash reopens"); - input.mouseClick(artworkSegment, artworkSegment.width / 2, artworkSegment.height / 2); - input.wait(30); - check(!media.lyricsOpen && media.playerPaneOpen, "artwork button closes only the lyrics"); - check(!artworkSegment.visualFocus && !lyricsSegment.visualFocus, "mouse selection does not restore a keyboard focus ring"); - media.playerPaneOpen = false; - input.wait(30); - lyricsToggle.requestFocus(false); - check(media.cycleFocus(false), "focus advances from the group to artwork playback"); - check(find(media, item => item.focusWithin !== undefined).focusWithin, "artwork playback receives cycled focus"); - check(media.cycleFocus(true) && (artworkSegment.activeFocus || lyricsSegment.activeFocus), "backward focus returns to the group"); - media.playerPaneOpen = true; - lyricsToggle.selectItem(1); - waitFor(() => media.lyricsOpen && !!media.lyricsFocusTarget, "lyrics button loads the overlay"); - const wasPlaying = player.isPlaying; - check(media.handleKeyEvent({ - key: Qt.Key_Space, - modifiers: 0 - }), "space is handled while lyrics are open"); - waitFor(() => player.isPlaying !== wasPlaying, "non-modal lyrics let playback keys through"); - media.showPanel("players"); - input.wait(30); - check(media.handleKeyEvent({ - key: Qt.Key_Escape, - modifiers: 0 - }) && media.panel === "" && media.lyricsOpen, "escape closes the sheet before lyrics"); - media.lyricsOpen = false; - waitFor(() => !media.lyricsFocusTarget, "closing lyrics destroys the overlay"); - DMSService.capabilities = []; - input.wait(30); - check(!lyricsToggle.visible, "lyrics button hides against a core without the capability"); - const probe = transportProbe.createObject(viewport, { - mediaAction: "previous" - }); - capabilities.presentation = { - previous: true - }; - capabilities.activePlayer = { - canGoPrevious: true, - canSeek: false - }; - check(probe.visible && probe.enabled, "an advertised control is visible and enabled"); - capabilities.presentation = { - previous: false - }; - capabilities.activePlayer = { - canGoPrevious: false, - canSeek: false - }; - check(!probe.visible, "an unsupported control is hidden rather than greyed out"); - probe.mediaAction = "nonsense"; - check(!probe.visible && !probe.enabled && !probe.checked && probe.iconName === "", "an unknown action renders nothing"); - probe.destroy(); - console.log("FIXTURE_PASS media track gaps, stable layout, marquee lifecycle, style switching, shared sheets and lyrics"); - Qt.quit(); - } catch (error) { - console.error("FIXTURE_FAIL", error); - Qt.exit(1); - } - } - - Timer { - interval: 0 - running: true - onTriggered: root.run() - } -} diff --git a/quickshell/tests/qml/settings-group-layout.qml b/quickshell/tests/qml/settings-group-layout.qml deleted file mode 100644 index b10c15099..000000000 --- a/quickshell/tests/qml/settings-group-layout.qml +++ /dev/null @@ -1,76 +0,0 @@ -import QtQuick -import Quickshell -import qs.Common -import qs.Widgets -import qs.Modules.Settings.Widgets -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - property bool finished: false - - FloatingWindow { - id: window - visible: true - implicitWidth: 600 - implicitHeight: 600 - - Column { - width: 400 - SettingsGroup { - id: outer - SettingsRow { - id: bodyRow - body: Column { - width: parent.width - StyledText { - text: "Heading" - } - SettingsGroup { - id: nested - SettingsNavRow { - title: "First" - hint: "Description" - } - SettingsNavRow { - title: "Second" - hint: "Description" - } - } - } - } - } - SettingsGroup { - id: following - SettingsNavRow { - title: "Following" - } - } - } - } - - Connections { - target: window.contentItem.Window.window - function onFrameSwapped() { - if (root.finished) - return; - root.finished = true; - const required = bodyRow.implicitHeight; - const passed = outer.height === required && following.y === outer.height && required > nested.height; - console.log((passed ? "FIXTURE_PASS" : "FIXTURE_FAIL") + " first painted group bounds " + JSON.stringify({ - outer: outer.height, - required: required, - following: following.y, - nested: nested.height - })); - Qt.quit(); - } - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - } -} diff --git a/quickshell/tests/qml/settings-keyboard.qml b/quickshell/tests/qml/settings-keyboard.qml deleted file mode 100644 index ed699f782..000000000 --- a/quickshell/tests/qml/settings-keyboard.qml +++ /dev/null @@ -1,521 +0,0 @@ -import QtQuick -import QtTest -import Quickshell -import Quickshell.Wayland -import qs.Common -import qs.Services -import qs.Modals.Settings -import qs.DankCommon.Common as DC -import qs.Modules.Settings.Widgets -import qs.Widgets - -ShellRoot { - id: root - - property bool failed: false - property int sidebarRequests: 0 - property string sidebarRequestedPage: "" - - function find(item, predicate) { - if (!item) - return null; - if (predicate(item)) - return item; - for (const child of item.children ?? []) { - const found = find(child, predicate); - if (found) - return found; - } - return null; - } - - function inside(ancestor, item) { - for (let p = item; p; p = p.parent) { - if (p === ancestor) - return true; - } - return false; - } - - function check(condition, label) { - if (condition) - return; - failed = true; - console.log("FIXTURE_FAIL " + label); - } - - function waitFor(condition, label) { - try { - input.tryVerify(condition, 20000); - } catch (error) { - throw new Error("timed out: " + label); - } - } - - QtObject { - id: modal - property bool shouldBeVisible: true - property bool isCompactMode: false - property bool menuVisible: true - property bool canGoBack: false - readonly property bool searchFocused: sidebar.searchFocused - readonly property string focusPane: navigation.activePane - readonly property Item modalFocusScope: navigation - property var pageHistory: [] - property int backs: 0 - property int searches: 0 - property int pageFocuses: 0 - function goBack() { - backs++; - } - function focusSearch() { - searches++; - navigation.focusSearch(); - } - function focusSidebar() { - searches++; - navigation.focusSidebar(); - } - function focusCurrentPage() { - pageFocuses++; - navigation.focusContent(); - } - function toggleMenu() { - } - } - - PanelWindow { - WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive - implicitWidth: 900 - implicitHeight: 700 - - TestCase { - id: input - name: "settings-keyboard" - when: false - } - - SettingsPaneNavigation { - id: navigation - anchors.fill: parent - sidebar: sidebar - content: content - parentModal: modal - - SettingsSidebar { - id: sidebar - width: 300 - height: parent.height - parentModal: modal - onPageRequested: pageId => { - root.sidebarRequests++; - root.sidebarRequestedPage = pageId; - } - } - - SettingsContent { - id: content - anchors.left: sidebar.right - anchors.right: parent.right - height: parent.height - parentModal: modal - } - } - } - - function run() { - waitFor(() => CompositorService.compositor !== "unknown", "compositor detected"); - waitFor(() => content.Window.active, "settings window takes keyboard focus"); - SettingsData.animationDuration = 0; - content.currentPage = "notifications"; - waitFor(() => find(content.currentPageItem, item => item.upDownKeysStep !== undefined && item.visible) !== null, "notifications page loads"); - const page = content.currentPageItem; - const focused = () => content.Window.activeFocusItem; - - page.forceActiveFocus(); - const before = focused(); - input.keyClick(Qt.Key_Down); - const first = focused(); - check(first !== before && inside(page, first), "Down moves focus to a control on the page"); - input.keyClick(Qt.Key_Down); - const second = focused(); - check(second !== first && inside(page, second), "Down moves to the next control"); - input.keyClick(Qt.Key_Up); - check(focused() === first, "Up returns to the previous control"); - - const slider = find(page, item => item.upDownKeysStep === false && item.visible && item.enabled); - slider.forceActiveFocus(Qt.TabFocusReason); - const value = slider.value; - input.keyClick(Qt.Key_Down); - check(slider.value === value && !inside(slider, focused()), "Down leaves a settings slider without stepping it"); - - const dropdown = find(page, item => item.downKeyOpens === false && item.visible && item.enabled); - dropdown.forceActiveFocus(Qt.TabFocusReason); - input.keyClick(Qt.Key_Down); - check(!dropdown.menuOpen && !inside(dropdown, focused()), "Down leaves a settings dropdown without opening it"); - - page.forceActiveFocus(); - input.keyClick(Qt.Key_Escape); - check(modal.searches === 1 && modal.backs === 0, "Escape on a top-level page returns to the sidebar"); - modal.canGoBack = true; - page.forceActiveFocus(); - input.keyClick(Qt.Key_Escape); - input.keyClick(Qt.Key_Left, Qt.AltModifier); - check(modal.backs === 2, "Escape and Alt+Left go back from a sub-page"); - - SettingsData.desktopWidgetInstances = [ - { - id: "a", - widgetType: "desktopClock", - name: "A", - enabled: true - }, - { - id: "b", - widgetType: "desktopClock", - name: "B", - enabled: true - } - ]; - content.currentPage = "desktop_widgets"; - const isHandle = item => item.upDownKeysMove === false && item.visible && item.enabled; - waitFor(() => find(content.currentPageItem, isHandle) !== null, "desktop widgets page shows reorder handles"); - const handle = find(content.currentPageItem, isHandle); - handle.forceActiveFocus(Qt.TabFocusReason); - input.keyClick(Qt.Key_Down); - check(SettingsData.desktopWidgetInstances[0].id === "a" && !handle.activeFocus, "Down leaves a reorder handle without moving its item"); - - const deleteButton = find(content.currentPageItem, item => item.confirming !== undefined && item.visible); - deleteButton.forceActiveFocus(Qt.TabFocusReason); - input.keyClick(Qt.Key_Space); - check(deleteButton.confirming && SettingsData.desktopWidgetInstances.length === 2, "first Space only arms the delete button"); - input.keyClick(Qt.Key_Tab); - check(!deleteButton.confirming, "leaving an armed delete button disarms it"); - deleteButton.forceActiveFocus(Qt.TabFocusReason); - input.keyClick(Qt.Key_Space); - input.keyClick(Qt.Key_Space); - check(SettingsData.desktopWidgetInstances.length === 1, "second Space deletes exactly once"); - - SettingsData.desktopWidgetGroups = [ - { - id: "g", - name: "Group" - } - ]; - const isRename = item => item.iconName === "edit" && item.tooltipText === "Rename" && item.visible; - waitFor(() => find(content.currentPageItem, isRename) !== null, "desktop widgets page shows the group rename button"); - const renameButton = find(content.currentPageItem, isRename); - renameButton.forceActiveFocus(Qt.TabFocusReason); - renameButton.clicked(); - waitFor(() => focused() !== renameButton && focused()?.cursorPosition !== undefined, "rename dialog focuses its field"); - const dialogField = focused(); - let renameDialog = dialogField; - while (renameDialog && renameDialog.Accessible.role !== Accessible.Dialog) - renameDialog = renameDialog.parent; - input.keyClick(Qt.Key_Tab, Qt.ControlModifier); - check(inside(renameDialog, focused()), "pane shortcut stays inside the rename dialog"); - input.keyClick(Qt.Key_F, Qt.ControlModifier); - check(inside(renameDialog, focused()), "Search shortcut does not leave the rename dialog"); - dialogField.forceActiveFocus(); - input.keyClick(Qt.Key_Escape); - check(focused() === renameButton, "closing the rename dialog returns focus to the rename button"); - - SettingsData.barConfigs = [ - { - id: "bar", - enabled: true, - visible: true, - position: 0, - leftWidgets: ["launcherButton", "workspaceSwitcher", "focusedWindow", "clock"], - centerWidgets: [], - rightWidgets: [] - } - ]; - SettingsUiState.selectedBarId = "bar"; - content.currentPage = "dankbar_widgets"; - const isThirdRow = item => item.reorderList !== undefined && item.modelData?.id === "focusedWindow"; - waitFor(() => find(content.currentPageItem, isThirdRow) !== null, "bar widgets page lists the rows"); - const widgetsTab = find(content.currentPageItem, item => item.focusedWidgetRow !== undefined); - find(content.currentPageItem, isThirdRow).forceActiveFocus(Qt.TabFocusReason); - input.keyClick(Qt.Key_Down); - check(widgetsTab.highlightedSection === "left" && widgetsTab.highlightedId === "clock", "Down from a Tab-focused widget row moves the cursor to the next row"); - - sidebar.focusSearch(); - waitFor(() => focused()?.cursorPosition !== undefined && inside(sidebar, focused()), "sidebar search takes focus"); - input.keyClick(Qt.Key_Tab); - const afterTab = focused(); - check(inside(sidebar, afterTab) && afterTab.tooltipText === "Users & accounts" && modal.pageFocuses === 0, "Tab from the sidebar search reaches the avatar button"); - - const sidebarScroll = find(sidebar, item => item.contentY !== undefined && typeof item.flick === "function"); - const categoryIds = sidebar.navigableIds(); - const originalSidebarHeight = sidebar.height; - const originalFocusRings = SettingsData.focusRingEnabled; - sidebar.height = 400; - sidebar.currentPage = categoryIds[0]; - sidebar.activeCategoryId = categoryIds[0]; - - function checkCategory(id) { - input.wait(0); - const row = focused(); - check(inside(sidebar, row) && row.modelData?.id === id, "Tab focuses sidebar category " + id); - const top = row.mapToItem(sidebarScroll.contentItem, 0, 0).y; - check(top >= sidebarScroll.contentY && top + row.height <= sidebarScroll.contentY + sidebarScroll.height, "focused sidebar category stays in view: " + id); - return row; - } - - // Rings off is the harder case: focus must still read through the row tint alone - SettingsData.focusRingEnabled = false; - sidebar.focusSearch(); - input.keyClick(Qt.Key_Tab); - const profile = focused(); - check(profile === afterTab && find(profile, item => item.stateOpacity !== undefined).stateOpacity > 0, "avatar button shows keyboard focus"); - input.keyClick(Qt.Key_Tab); - const firstRow = checkCategory(categoryIds[0]); - const ring = find(firstRow, item => item.pointerFocused !== undefined); - check(ring.visible, "sidebar category shows focus with rings off"); - for (const id of categoryIds.slice(1)) { - input.keyClick(Qt.Key_Tab); - checkCategory(id); - } - check(sidebar.currentPage === categoryIds[0] && root.sidebarRequests === 0, "Tab preserves the selected page"); - input.keyClick(Qt.Key_Tab); - check(inside(content, focused()), "Tab leaves the sidebar for page controls"); - input.keyClick(Qt.Key_Backtab, Qt.ShiftModifier); - checkCategory(categoryIds[categoryIds.length - 1]); - for (let i = categoryIds.length - 2; i >= 0; i--) { - input.keyClick(Qt.Key_Backtab, Qt.ShiftModifier); - checkCategory(categoryIds[i]); - } - input.keyClick(Qt.Key_Backtab, Qt.ShiftModifier); - check(focused() === profile && sidebarScroll.contentY === 0, "Shift+Tab returns to the avatar button"); - input.keyClick(Qt.Key_Backtab, Qt.ShiftModifier); - check(focused()?.cursorPosition !== undefined && inside(sidebar, focused()), "Shift+Tab returns to search"); - - input.keyClick(Qt.Key_Tab); - input.keyClick(Qt.Key_Tab); - input.keyClick(Qt.Key_Return); - check(root.sidebarRequests === 1 && root.sidebarRequestedPage === categoryIds[0], "Enter opens the Tab-focused category exactly once"); - waitFor(() => inside(content.currentPageItem, focused()), "category Enter actually enters the page"); - sidebar.focusSearch(); - focused().text = "bar"; - waitFor(() => SettingsSearchService.results.length > 1, "sidebar search finds several results"); - const resultRequests = root.sidebarRequests; - input.keyClick(Qt.Key_Tab, Qt.ControlModifier); - waitFor(() => inside(content.currentPageItem, focused()), "Ctrl+Tab leaves a populated Search without selecting a result"); - check(root.sidebarRequests === resultRequests, "pane switching does not activate Search results"); - input.keyClick(Qt.Key_Backtab, Qt.ControlModifier | Qt.ShiftModifier); - check(sidebar.searchFocused && sidebar.searchActive, "pane switching restores the populated Search"); - input.keyClick(Qt.Key_Tab); - check(sidebar.searchSelectedIndex === 1 && focused()?.cursorPosition !== undefined, "Tab in a populated search advances its result cursor"); - input.keyClick(Qt.Key_Escape); - check(!sidebar.searchActive, "Escape clears search before normal Tab navigation resumes"); - input.keyClick(Qt.Key_Down); - check(sidebar.keyboardHighlightId === categoryIds[1] && focused()?.cursorPosition !== undefined, "Down in empty search browses categories without moving focus"); - input.keyClick(Qt.Key_Tab); - check(sidebar.keyboardHighlightId === categoryIds[2] && focused()?.cursorPosition !== undefined, "Tab advances an existing sidebar row cursor"); - input.keyClick(Qt.Key_Escape); - input.keyClick(Qt.Key_Tab); - check(focused() === afterTab, "clearing the sidebar cursor restores the normal Tab chain"); - sidebar.height = originalSidebarHeight; - SettingsData.focusRingEnabled = originalFocusRings; - - sidebar.focusSearch(); - waitFor(() => focused()?.cursorPosition !== undefined && inside(sidebar, focused()), "sidebar search takes focus again"); - input.keyClick(Qt.Key_Return); - waitFor(() => inside(content.currentPageItem, focused()), "empty Search Enter actually focuses the page"); - check(modal.pageFocuses === 2, "Enter in the empty sidebar search requests page focus once"); - - checkPaneNavigation(categoryIds, afterTab, focused); - } - - function checkPaneNavigation(categoryIds, profile, focused) { - sidebar.focusSearch(); - input.keyClick(Qt.Key_Tab); - input.keyClick(Qt.Key_Down); - check(focused()?.modelData?.id === categoryIds[0], "Down after Tab moves from profile to the first category"); - input.keyClick(Qt.Key_Down); - const category = focused(); - check(category.modelData?.id === categoryIds[1], "Down moves category focus without opening it"); - const requestsBefore = sidebarRequests; - input.keyClick(Qt.Key_Up); - check(focused()?.modelData?.id === categoryIds[0] && sidebarRequests === requestsBefore, "Up changes focus without activating a page"); - input.keyClick(Qt.Key_Down); - input.keyClick(Qt.Key_Tab, Qt.ControlModifier); - waitFor(() => inside(content.currentPageItem, focused()), "Ctrl+Tab transfers actual sidebar focus into content"); - const pageControl = focused(); - input.keyClick(Qt.Key_Backtab, Qt.ControlModifier | Qt.ShiftModifier); - check(focused() === category, "Ctrl+Shift+Tab restores the sidebar row"); - input.keyClick(Qt.Key_Tab, Qt.ControlModifier); - waitFor(() => focused() === pageControl, "Ctrl+Tab restores the content control"); - input.waitForPolish(content.Window.window); - input.mouseClick(category, category.width / 2, category.height / 2); - input.wait(0); - check(focused() === category && sidebarRequests === requestsBefore + 1, "sidebar click activates once and retains actual sidebar focus"); - input.keyClick(Qt.Key_Up); - check(focused()?.modelData?.id === categoryIds[0], "sidebar arrows work after a mouse click"); - input.mouseClick(content, content.width - 2, 2); - waitFor(() => inside(content, focused()) && !inside(content.currentPageItem, focused()), "content background click parks keyboard focus without highlighting a control"); - input.keyClick(Qt.Key_Tab, Qt.ControlModifier); - check(inside(sidebar, focused()), "keyboard pane transfer works after a content background click"); - - content.currentPage = "notifications"; - waitFor(() => find(content.currentPageItem, item => item.upDownKeysStep === false) !== null, "content loads the pointer test page"); - waitFor(() => content.currentPageSettled, "pointer test page is presented"); - const slider = find(content.currentPageItem, item => item.upDownKeysStep === false && item.visible && item.enabled); - input.waitForPolish(content.Window.window); - content._reveal(slider); - input.waitForPolish(content.Window.window); - input.waitForRendering(slider); - sidebar.focusSearch(); - input.mouseClick(slider, slider.width / 2, slider.height / 2); - input.wait(0); - check(inside(slider, focused()), "content click preserves clicked slider focus"); - const sliderValue = slider.value; - input.keyClick(Qt.Key_Down); - check(!inside(slider, focused()) && slider.value === sliderValue, "content arrows work after clicking a slider"); - input.keyClick(Qt.Key_F, Qt.ControlModifier); - check(sidebar.searchFocused, "Ctrl+F restores actual Search focus"); - const pageScroll = find(content.currentPageItem, item => item.contentY !== undefined && typeof item.flick === "function"); - pageScroll.contentY = Math.max(0, pageScroll.contentHeight - pageScroll.height); - const pointerScroll = pageScroll.contentY; - input.mouseClick(content, content.width - 2, 2); - waitFor(() => inside(content, focused()) && !inside(content.currentPageItem, focused()), "background click parks focus after scrolling"); - check(Math.abs(pageScroll.contentY - pointerScroll) < 1, "background click does not jump the page"); - input.keyClick(Qt.Key_Down); - check(inside(content.currentPageItem, focused()) && pageScroll.contentY < pointerScroll, "the next arrow resumes at the remembered control and reveals it"); - checkPointerControls(focused); - checkKeyCapture(focused); - modal.focusCurrentPage(); - modal.focusSearch(); - input.wait(0); - check(sidebar.searchFocused, "a later sidebar interaction cancels deferred content focus"); - } - - function checkKeyCapture(focused) { - content.currentPage = "lock_screen"; - waitFor(() => content.currentPageSettled && find(content.currentPageItem, item => item.capturing !== undefined) !== null, "security key capture loads"); - const capture = find(content.currentPageItem, item => item.capturing !== undefined); - capture.forceActiveFocus(Qt.TabFocusReason); - input.keyClick(Qt.Key_Return); - check(capture.capturing, "Enter starts security key capture"); - const backs = modal.backs; - for (const key of [Qt.Key_Up, Qt.Key_Down, Qt.Key_Backspace]) - input.keyClick(key); - check(focused() === capture && capture.capturing && modal.backs === backs, "capture owns arrows and Backspace"); - input.keyClick(Qt.Key_Tab); - input.keyClick(Qt.Key_Backtab, Qt.ShiftModifier); - check(focused() === capture && capture.capturing, "capture consumes forward and reverse Tab traversal"); - content._focusPage(); - input.wait(0); - check(focused() === capture && capture.capturing, "deferred page focus preserves key capture"); - input.keyClick(Qt.Key_Tab, Qt.ControlModifier); - check(focused() === capture && !sidebar.searchFocused, "capture owns the pane shortcut"); - capture.startCapture(); - input.keyClick(Qt.Key_Escape); - check(!capture.capturing && focused() === capture && modal.backs === backs, "Escape ends capture before page navigation"); - modal.focusSearch(); - } - - Component { - id: pointerControls - Column { - objectName: "pointer-controls" - width: 400 - z: 100 - property int toggles: 0 - property int choices: 0 - DankTextField { - objectName: "pointer-text" - width: parent.width - text: "abcd" - } - SettingsToggleRow { - objectName: "pointer-toggle" - width: parent.width - text: "Fixture" - onToggled: value => { - checked = value; - parent.toggles++; - } - } - SettingsSwatchGrid { - objectName: "pointer-swatches" - width: parent.width - compact: true - options: [{value: "a", label: "A", primary: Theme.primary, secondary: Theme.secondary, tertiary: Theme.tertiary}, {value: "b", label: "B", primary: Theme.primary, secondary: Theme.secondary, tertiary: Theme.tertiary}] - onSelected: value => { - currentValue = value; - parent.choices++; - } - } - } - } - - function checkPointerControls(focused) { - const panel = pointerControls.createObject(content.currentPageItem); - input.waitForPolish(content.Window.window); - input.waitForRendering(panel); - const field = find(panel, item => item.objectName === "pointer-text"); - modal.focusSearch(); - input.mouseClick(field, field.width / 2, field.height / 2); - input.wait(0); - check(inside(field, focused()), "clicking a content text field preserves actual field focus"); - const editor = focused(); - editor.cursorPosition = editor.text.length; - const backs = modal.backs; - input.keyClick(Qt.Key_Left); - check(editor.cursorPosition === 3, "text field keeps horizontal caret navigation"); - input.keyClick(Qt.Key_Backspace); - check(editor.text === "abd" && modal.backs === backs, "Backspace edits content text without navigating back"); - const toggle = find(panel, item => item.objectName === "pointer-toggle"); - input.mouseClick(toggle, toggle.width / 2, toggle.height / 2); - input.wait(0); - check(focused() === toggle && panel.toggles === 1, "mouse toggle activates once and retains control focus"); - input.keyClick(Qt.Key_Space); - check(panel.toggles === 2 && !toggle.checked, "Space toggles the clicked control once"); - const rowTint = find(toggle, item => item.parent === toggle && item.color?.toString() === toggle.contentColor.toString()); - check(rowTint.opacity === 0, "a mouse-focused row stays untinted"); - const rings = SettingsData.focusRingEnabled; - SettingsData.focusRingEnabled = false; - modal.focusSearch(); - toggle.forceActiveFocus(Qt.TabFocusReason); - check(rowTint.visible && rowTint.opacity === Theme.stateLayerFocus, "a keyboard-focused row tints with rings off"); - SettingsData.focusRingEnabled = rings; - const tile = find(panel, item => item.modelData?.value === "b"); - input.mouseClick(tile, tile.width / 2, tile.height / 2); - input.wait(0); - check(focused() === tile && panel.choices === 1, "swatch click focuses and applies the actual tile once"); - const tileRing = find(tile, item => item.pointerFocused !== undefined); - const tileTint = find(tile, item => item.stateOpacity !== undefined); - check(!tileRing.visible, "a mouse-focused swatch draws no focus ring"); - input.keyClick(Qt.Key_Return); - check(panel.choices === 2, "Enter acts on the mouse-focused swatch"); - // Focus must actually move: forceActiveFocus keeps the old reason on an already focused item - toggle.forceActiveFocus(Qt.TabFocusReason); - tile.forceActiveFocus(Qt.TabFocusReason); - check(tileRing.visible && tileTint.stateOpacity === Theme.stateLayerFocus, "a keyboard-focused swatch draws its focus ring and tint"); - panel.destroy(); - modal.focusSearch(); - } - - Timer { - interval: 0 - running: true - onTriggered: { - try { - root.run(); - } catch (error) { - root.check(false, error.message); - } - console.log(root.failed ? "FIXTURE_FAIL see above" : "FIXTURE_PASS"); - Qt.quit(); - } - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - } -} diff --git a/quickshell/tests/qml/settings-overflow.qml b/quickshell/tests/qml/settings-overflow.qml deleted file mode 100644 index 200f01e25..000000000 --- a/quickshell/tests/qml/settings-overflow.qml +++ /dev/null @@ -1,131 +0,0 @@ -import QtQuick -import Quickshell -import qs.Common -import qs.Services -import qs.Modules.Settings -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - - function check(condition, label) { - if (!condition) - throw new Error(label); - } - function find(item, predicate) { - if (predicate(item)) - return item; - for (const child of item.children || []) { - const found = find(child, predicate); - if (found) - return found; - } - return null; - } - - FloatingWindow { - visible: true - implicitWidth: 800 - implicitHeight: 700 - - WidgetsTabSection { - id: section - width: parent.width - title: "fixture section" - sectionId: "left" - barId: "overflow-settings-fixture" - overflowSettingsExpanded: true - items: [ - { id: "weather", text: "weather fixture", icon: "partly_cloudy_day", enabled: true }, - { id: "futureWidget", text: "future fixture", icon: "extension", enabled: true } - ] - } - BarWidgetTab { - id: page - y: section.height - width: parent.width - height: parent.height - y - } - } - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - } - Timer { - interval: 0 - running: SettingsData._hasLoaded && SessionData._hasLoaded - onTriggered: { - SettingsData.barConfigs = [{ - id: "overflow-settings-fixture", enabled: true, visible: true, - leftWidgets: ["weather", "futureWidget"], centerWidgets: [], rightWidgets: [] - }]; - SettingsUiState.selectedBarId = "overflow-settings-fixture"; - SettingsUiState.selectedDockId = ""; - SettingsUiState.selectedWidgetSection = "left"; - SettingsUiState.selectedWidgetIndex = 0; - checks.start(); - } - } - Timer { - id: checks - interval: 16 - repeat: true - property int step: 0 - property real started: 0 - onTriggered: { - if (!started) - started = Date.now(); - try { - const config = SettingsData.getBarConfig(section.barId); - const placement = root.find(page, item => item.placementLabels !== undefined); - switch (step) { - case 0: - root.check(page.entry && section.autoOverflow && section.overflowPosition === 2, "sections default to auto overflow"); - const weather = root.find(section, item => item.title === "weather fixture" && item.configurable !== undefined); - const future = root.find(section, item => item.title === "future fixture" && item.configurable !== undefined); - root.check(weather?.configurable && future?.configurable, "general options are accessible without a widget-specific options file"); - root.check(page.value("overflowMode") === "section" && page.isDefault(["overflowMode"]), "new widgets inherit the section without an override"); - const position = root.find(section, item => item.positionLabels !== undefined); - root.check(position.positionLabels.length === 3 && position.currentValue === position.positionLabels[2], "position dropdown lists the start and every entry, defaulting to the end"); - root.check(section.overflowStore.isDefault(["leftOverflowMode", "leftOverflowPosition"]), "omitted section keys read as default"); - root.check(placement.placementLabels.length === 3 && placement.currentValue === "Auto" && placement.placementLabels.includes("Keep in Bar"), "placement leads with the section mode and lists no duplicate of it"); - section.setOverflowOption("Mode", "bar"); - position.valueChanged(position.positionLabels[1]); - break; - case 1: - root.check(!section.autoOverflow && section.overflowPosition === 1, "section controls save and update their bindings"); - root.check(!section.overflowStore.isDefault(["leftOverflowMode"]) && !section.overflowStore.isDefault(["leftOverflowPosition"]), "changed section keys are no longer default"); - root.check(placement.currentValue === "Keep in Bar" && placement.placementLabels.includes("Auto"), "placement follows the section when it switches to the bar"); - page.set("overflowMode", "bar"); - root.check(placement.currentValue === "Keep in Bar" && placement.placementValue === "section", "an explicit copy of the section mode shows as the section entry"); - section.setOverflowOption("Mode", "auto"); - section.overflowStore.resetToDefault(["leftOverflowPosition"]); - root.check(config.leftWidgets.every(entry => typeof entry === "string"), "section-wide overflow does not rewrite each widget"); - page.set("overflowMode", "bar"); - break; - case 2: - root.check(page.value("overflowMode") === "bar" && config.leftOverflowMode === "auto", "a widget override preserves the section default"); - root.check(config.leftOverflowPosition === undefined && section.overflowPosition === 2, "resetting the position clears the key so it follows the widget count"); - page.resetToDefault(["overflowMode"]); - break; - case 3: - root.check(page.value("overflowMode") === "section" && config.leftWidgets[1] === "futureWidget", "reset restores inheritance without changing another widget"); - console.log("FIXTURE_PASS"); - stop(); - Qt.quit(); - return; - } - step++; - started = Date.now(); - } catch (error) { - if (Date.now() - started < 10000) - return; - console.error("FIXTURE_FAIL", "step " + step + ": " + error.message); - stop(); - Qt.quit(); - } - } - } -} diff --git a/quickshell/tests/qml/settings-panes.qml b/quickshell/tests/qml/settings-panes.qml deleted file mode 100644 index 076e92647..000000000 --- a/quickshell/tests/qml/settings-panes.qml +++ /dev/null @@ -1,201 +0,0 @@ -import QtQuick -import QtTest -import Quickshell -import qs.Common -import qs.Services -import qs.Modals.Settings -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - - SettingsModal { - id: modal - } - - TestCase { - id: input - name: "settings-panes" - when: false - } - - function find(item, predicate) { - if (!item) - return null; - if (predicate(item)) - return item; - for (const child of item.children ?? []) { - const found = find(child, predicate); - if (found) - return found; - } - return null; - } - - function inside(ancestor, item) { - for (let p = item; p; p = p.parent) { - if (p === ancestor) - return true; - } - return false; - } - - function check(condition, label) { - if (!condition) - throw new Error(label); - } - - function until(condition, label) { - try { - input.tryVerify(condition, 20000); - } catch (error) { - throw new Error(label); - } - } - - function run() { - until(() => CompositorService.compositor !== "unknown", "compositor detected"); - SettingsData.animationDuration = 0; - modal.show(); - const navigation = modal.modalFocusScope; - const content = navigation.content; - const focused = () => navigation.Window.activeFocusItem; - until(() => modal.visible && navigation.Window.active && modal.searchFocused, "initial Settings load focuses Search"); - input.keyClick(Qt.Key_Tab); - input.keyClick(Qt.Key_Down); - const row = focused(); - check(inside(modal.sidebar, row) && row.modelData?.id, "initial Tab then Down focuses a category"); - input.mouseClick(row, row.width / 2, row.height / 2); - until(() => content.currentPageSettled, "clicked category finishes loading"); - check(focused() === row, "cold mouse category load retains sidebar focus"); - input.keyClick(Qt.Key_Return); - until(() => inside(content.currentPageItem, focused()), "Enter on the selected category enters content"); - - const avatar = find(modal.sidebar, item => item.tooltipText === "Users & accounts"); - let profileRequests = 0; - modal.sidebar.pageRequested.connect(() => profileRequests++); - input.mouseClick(avatar, avatar.width / 2, avatar.height / 2); - check(profileRequests === 1, "avatar click requests its page exactly once"); - modal.sidebar.focusSearch(); - input.keyClick(Qt.Key_Tab); - check(focused() === avatar, "Tab from search reaches the avatar button"); - input.keyClick(Qt.Key_Return); - check(profileRequests === 2, "Enter on the avatar requests its page"); - until(() => inside(content.currentPageItem, focused()), "Enter on the avatar enters its page"); - - modal.setPage("personalization"); - modal.focusCurrentPage(); - until(() => content.currentPageSettled && content.currentPageItem?.hubId === "personalization", "personalization hub loads"); - const group = find(content.currentPageItem, item => item.arrowKeysSelect === false && item.model?.[0]?.icon === "light_mode"); - group.requestFocus(false); - const mode = SessionData.isLightMode; - input.keyClick(Qt.Key_Right); - check(SessionData.isLightMode === mode && focused()?.selected === false, "Light/Dark arrows focus without applying a theme"); - input.keyClick(Qt.Key_Space); - check(SessionData.isLightMode !== mode, "Space applies the focused Light/Dark mode"); - input.keyClick(Qt.Key_Tab, Qt.ControlModifier); - check(inside(modal.sidebar, focused()), "Ctrl+Tab returns to sidebar"); - input.keyClick(Qt.Key_Tab, Qt.ControlModifier); - until(() => inside(group, focused()), "Ctrl+Tab restores the focused Light/Dark choice"); - - const opener = find(content.currentPageItem, item => item.settingKey === "themeNav" && item.clickable); - opener.forceActiveFocus(Qt.TabFocusReason); - content._reveal(opener); - const scroll = find(content.currentPageItem, item => item.contentY !== undefined && typeof item.flick === "function"); - const position = scroll.contentY; - input.keyClick(Qt.Key_Return); - until(() => modal.currentPage === "theme" && content.currentPageSettled && inside(content.currentPageItem, focused()), "keyboard opens subpage and focuses a control"); - input.keyClick(Qt.Key_Left, Qt.AltModifier); - until(() => modal.currentPage === "personalization" && focused() === opener, "Back restores the actual hub opener"); - check(Math.abs(scroll.contentY - position) < 1, "Back preserves hub scroll"); - - // Parked keyboard focus sits on the content anchor, so no control is highlighted - const parked = () => focused()?.parent === content; - input.mouseClick(opener, opener.width / 2, opener.height / 2); - until(() => modal.currentPage === "theme" && content.currentPageSettled && parked(), "mouse-opened subpage parks focus instead of highlighting a control"); - input.keyClick(Qt.Key_Down); - check(inside(content.currentPageItem, focused()), "Down after a mouse-opened subpage enters it"); - input.mouseClick(content, content.width / 2, content.height / 2, Qt.BackButton); - until(() => modal.currentPage === "personalization" && parked(), "mouse Back button returns a page and parks focus"); - input.keyClick(Qt.Key_Down); - check(focused() === opener, "Down after mouse Back resumes at the hub opener"); - - input.mouseClick(row, row.width / 2, row.height / 2); - input.mouseClick(content, content.width / 2, Theme.spacingM); - until(parked, "content background click parks keyboard focus without highlighting a control"); - input.keyClick(Qt.Key_Down); - check(focused() === opener, "Down after a background click resumes at the last control"); - - const hubPage = content.currentPageItem; - modal.setPage("typography"); - modal.focusSearch(); - until(() => modal.currentPage === "typography" && content.currentPageItem !== hubPage && content.currentPageSettled, "typography page loads"); - const textRow = find(content.currentPageItem, item => item.clickable === false && item.hasBody === true); - input.waitForPolish(navigation.Window.window); - input.mouseClick(textRow, textRow.width / 2, textRow.height / 2); - until(parked, "click on a page body row parks keyboard focus in content"); - - SettingsData.barConfigs = [{ - id: "fixture", enabled: true, visible: true, position: 0, - leftWidgets: ["clock"], centerWidgets: [], rightWidgets: [] - }]; - SettingsUiState.selectedBarId = "fixture"; - modal.focusSearch(); - focused().text = "clock"; - const widgetResult = () => find(modal.sidebar, item => item.modelData?.runtimeType === "barWidget" && item.modelData.runtimeId === "clock"); - until(() => widgetResult() !== null, "search finds the existing Clock widget"); - const result = widgetResult(); - modal.sidebar.ensureRowVisible(result); - input.waitForPolish(navigation.Window.window); - const previousPage = content.currentPageItem; - input.mouseClick(result, result.width / 2, result.height / 2); - until(() => modal.currentPage === "bar_widget" && content.currentPageItem !== previousPage && content.currentPageSettled, "mouse search result loads widget options"); - check(inside(modal.sidebar, focused()), "mouse search drilldown retains sidebar focus"); - input.keyClick(Qt.Key_Tab, Qt.ControlModifier); - until(() => inside(content.currentPageItem, focused()), "pane shortcut enters searched widget options"); - modal.focusSearch(); - input.keyClick(Qt.Key_Escape); - modal.setPage("personalization"); - modal.focusCurrentPage(); - until(() => content.currentPageItem?.hubId === "personalization" && content.currentPageSettled, "personalization returns after search drilldown"); - - // The test compositor owns tiled window width; exercise the compact layout directly. - modal.isCompactMode = true; - modal.focusSearch(); - input.keyClick(Qt.Key_Tab, Qt.ControlModifier); - until(() => !modal.menuVisible && inside(content.currentPageItem, focused()), "compact pane shortcut enters visible content"); - input.keyClick(Qt.Key_Tab, Qt.ControlModifier); - check(modal.menuVisible && inside(modal.sidebar, focused()), "compact pane shortcut reveals the sidebar"); - input.keyClick(Qt.Key_Tab); - input.keyClick(Qt.Key_Return); - until(() => !modal.menuVisible && inside(content.currentPageItem, focused()), "compact sidebar activation enters content"); - modal.hide(); - until(() => !content.currentPageItem, "closing Settings releases its pages"); - modal.menuVisible = false; - navigation.activePane = "content"; - modal.show(); - until(() => modal.visible && modal.menuVisible && modal.searchFocused, "compact reopening starts in visible Search"); - modal.hide(); - } - - Timer { - interval: 0 - running: true - onTriggered: { - try { - root.run(); - console.log("FIXTURE_PASS"); - } catch (error) { - console.log("FIXTURE_FAIL " + error.message); - } - Qt.quit(); - } - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - } -} diff --git a/quickshell/tests/qml/settings-scroll.qml b/quickshell/tests/qml/settings-scroll.qml deleted file mode 100644 index 73dd1e7ce..000000000 --- a/quickshell/tests/qml/settings-scroll.qml +++ /dev/null @@ -1,149 +0,0 @@ -import QtQuick -import QtTest -import Quickshell -import qs.Common -import qs.Services -import qs.Modals.Settings -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - - property var content: null - property Item parentPage: null - property Item childPage: null - - QtObject { - id: modal - property bool shouldBeVisible: true - property bool isCompactMode: false - property bool menuVisible: true - property bool canGoBack: true - property var pageHistory: [] - } - - Component { - id: contentComponent - SettingsContent { - width: 900 - height: 500 - parentModal: modal - } - } - - FloatingWindow { - id: window - visible: true - implicitWidth: 900 - implicitHeight: 500 - } - - TestCase { - id: tester - when: false - - function check(condition, message) { - if (!condition) - throw new Error(message); - } - - function find(item, predicate) { - if (!item) - return null; - if (predicate(item)) - return item; - for (const child of item.children ?? []) { - const found = find(child, predicate); - if (found) - return found; - } - return null; - } - - function scrollable() { - return find(root.content.currentPageItem, item => item.contentY !== undefined && item.contentItem !== undefined); - } - - function settle() { - wait(0); - const scene = window.contentItem.Window.window; - check(!isPolishScheduled(scene) || waitForPolish(scene, 20000), "settings layout settled"); - wait(0); - } - - function open(page) { - root.content.currentPage = page; - try { - tryVerify(() => scrollable() !== null, 20000); - } catch (error) { - throw new Error(page + " has a scrollable page"); - } - settle(); - } - - function expectPosition(y, label) { - try { - tryVerify(() => Math.abs(scrollable().contentY - y) < 1, 20000); - } catch (error) { - throw new Error(label + ": expected " + y + ", got " + scrollable().contentY); - } - } - - function run() { - try { - tryVerify(() => CompositorService.compositor !== "unknown", 20000); - root.content = contentComponent.createObject(window.contentItem); - open("theme_surfaces"); - SettingsData.animationDuration = 0; - const position = Math.min(700, scrollable().contentHeight - scrollable().height); - check(position > 0, "interface style scrolls"); - scrollable().contentY = position; - root.parentPage = root.content.currentPageItem; - modal.pageHistory = ["theme_surfaces"]; - open("surface_shadows"); - root.childPage = root.content.currentPageItem; - modal.pageHistory = []; - open("theme_surfaces"); - check(root.content.currentPageItem === root.parentPage, "Back reuses the parent page"); - expectPosition(position, "Back preserves the scroll position"); - tryCompare(root, "childPage", null, 20000); - - SettingsData.animationDuration = 250; - open("notifications"); - tryCompare(root, "parentPage", null, 20000); - root.parentPage = root.content.currentPageItem; - modal.shouldBeVisible = false; - tryCompare(root, "parentPage", null, 20000); - modal.shouldBeVisible = true; - settle(); - - SettingsData.barConfigs = [ - { - id: "default", - leftWidgets: ["clock"], - centerWidgets: [], - rightWidgets: [] - } - ]; - root.content.currentPage = "dankbar_widgets"; - tryVerify(() => root.content.currentPageItem?.reorderGroup !== undefined, 20000); - settle(); - const widgetRow = find(root.content.currentPageItem, item => item.modelData?.id === "clock"); - check(widgetRow && widgetRow.visible && widgetRow.height > 0, "bar widgets renders the configured clock row"); - console.log("FIXTURE_PASS settings page retention and cleanup"); - } catch (error) { - console.error("FIXTURE_FAIL " + error.message); - } - Qt.quit(); - } - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - SettingsData.animationDuration = 250; - Qt.callLater(tester.run); - } -} diff --git a/quickshell/tests/qml/theme-accents.qml b/quickshell/tests/qml/theme-accents.qml deleted file mode 100644 index d5eb1e1bb..000000000 --- a/quickshell/tests/qml/theme-accents.qml +++ /dev/null @@ -1,66 +0,0 @@ -import QtQuick -import Quickshell -import qs.Common -import "DankCommon/Common/Contrast.js" as Contrast - -ShellRoot { - id: root - - property bool failed: false - - readonly property var gruvbox: ({ - "primary": "#8ec07c", - "primaryText": "#282828", - "primaryContainer": "#427b58", - "secondary": "#83a598", - "surface": "#282828", - "surfaceText": "#ebdbb2", - "surfaceVariant": "#3c3836", - "surfaceVariantText": "#d5c4a1", - "surfaceTint": "#8ec07c", - "background": "#1d2021", - "backgroundText": "#ebdbb2", - "outline": "#665c54", - "surfaceContainer": "#32302f", - "surfaceContainerHigh": "#3c3836", - "error": "#fb4934", - "accents": { - "blue": "#fb4934" - } - }) - - function check(condition, label) { - if (condition) - return; - failed = true; - console.log("FIXTURE_FAIL " + label); - } - - function readable(pair) { - return pair && Contrast.ratio(pair.onContainer, pair.container) >= 4.5; - } - - Timer { - interval: 0 - running: true - onTriggered: { - Theme.isLightMode = false; - Theme.currentTheme = "purple"; - for (const slot of ["red", "orange", "yellow", "green", "teal", "blue", "purple", "pink"]) - check(readable(Theme.accent(slot)), "stock dark " + slot + " glyph readable"); - Theme.isLightMode = true; - check(readable(Theme.accent("blue")), "stock light blue glyph readable"); - check(Theme.accent("nope") === null, "unknown slot is null"); - - Theme.isLightMode = false; - Theme.customThemeData = gruvbox; - Theme.currentTheme = "custom"; - const overridden = Theme.accent("blue").container; - check(overridden.r > overridden.b, "custom accents override the slot hue"); - check(readable(Theme.accent("blue")), "overridden slot glyph readable"); - check(readable(Theme.accent("green")), "derived slot glyph readable under custom theme"); - console.log(root.failed ? "FIXTURE_FAIL see above" : "FIXTURE_PASS"); - Qt.quit(); - } - } -} diff --git a/quickshell/tests/qml/theme-selected-container.qml b/quickshell/tests/qml/theme-selected-container.qml deleted file mode 100644 index 51c3ceb0f..000000000 --- a/quickshell/tests/qml/theme-selected-container.qml +++ /dev/null @@ -1,78 +0,0 @@ -import QtQuick -import Quickshell -import qs.Common -import "DankCommon/Common/Contrast.js" as Contrast -import "DankCommon/Common/Hct.js" as Hct - -ShellRoot { - id: root - - property bool failed: false - - readonly property var nord: ({ - "primary": "#81a1c1", - "primaryText": "#2e3440", - "primaryContainer": "#88c0d0", - "secondary": "#88c0d0", - "surface": "#2e3440", - "surfaceText": "#eceff4", - "surfaceVariant": "#3b4252", - "surfaceVariantText": "#d8dee9", - "surfaceTint": "#81a1c1", - "background": "#2e3440", - "backgroundText": "#eceff4", - "outline": "#4c566a", - "surfaceContainer": "#3b4252", - "surfaceContainerHigh": "#4c566a", - "error": "#bf616a" - }) - - function check(condition, label) { - if (condition) - return; - failed = true; - console.log("FIXTURE_FAIL " + label); - } - - function readable(foreground, background, target) { - return Contrast.ratio(foreground, background) >= target; - } - - Timer { - interval: 0 - running: true - onTriggered: { - Theme.isLightMode = false; - - Theme.customThemeData = nord; - Theme.currentTheme = "custom"; - check(readable(Theme.onSelectedContainer, Theme.selectedContainer, 4.5), "selection text readable on tinted fill"); - check(readable(Theme.surfaceText, Theme.selectedContainer, 4.5), "surface text readable on the selected fill"); - check(readable(Theme.accentOnSelectedContainer, Theme.selectedContainer, 3), "selected icon readable"); - check(Contrast.ratio(Theme.selectedContainer, Theme.surfaceContainerHigh) > 1.05, "selected fill differs from the row surface"); - check(readable(Theme.surfaceText, Theme.primaryContainer, 4.5), "painted container is derived from primary, not the theme accent"); - check(readable(Theme.onPrimaryContainer, Theme.primaryContainer, 4.5), "derived onPrimaryContainer readable"); - check(readable(Theme.accentOnPrimaryContainer, Theme.primaryContainer, 3), "icon box glyph readable"); - check(readable(Theme.onSecondaryContainer, Theme.secondaryContainer, 4.5), "derived onSecondaryContainer readable"); - - Theme.customThemeData = Object.assign({}, nord, { - "containerTint": 0 - }); - check(Math.abs(Hct.toHct(Theme.primaryContainer).chroma - Hct.toHct(Theme.surfaceContainer).chroma) <= 2, "theme containerTint sets the accent share of the derived container"); - - Theme.customThemeData = Object.assign({}, nord, { - "softPrimaryContainer": "#5e81ac", - "onPrimaryContainer": "#2e3440", - "selectedContainer": "#5e81ac", - "onSelectedContainer": "#eceff4", - "accentOnSelectedContainer": "#a3be8c", - "accentOnPrimaryContainer": "#bf616a" - }); - check(Theme.selectedContainer.toString() === "#5e81ac" && Theme.onSelectedContainer.toString() === "#eceff4", "explicit selection colors are not derived over"); - check(Theme.accentOnSelectedContainer.toString() === "#a3be8c" && Theme.accentOnPrimaryContainer.toString() === "#bf616a", "explicit accent colors are not derived over"); - check(Theme.primaryContainer.toString() === "#5e81ac" && Theme.onPrimaryContainer.toString() === "#2e3440", "explicit container colors are not derived over"); - console.log(root.failed ? "FIXTURE_FAIL see above" : "FIXTURE_PASS"); - Qt.quit(); - } - } -} diff --git a/quickshell/tests/qml/workspace-switcher.qml b/quickshell/tests/qml/workspace-switcher.qml deleted file mode 100644 index 8fda5e728..000000000 --- a/quickshell/tests/qml/workspace-switcher.qml +++ /dev/null @@ -1,283 +0,0 @@ -import QtQuick -import Quickshell -import qs.Common -import qs.Services -import qs.Modules.DankBar -import qs.Modules.DankBar.Widgets -import qs.Modules.OSD -import qs.DankCommon.Common as DC -import "Common/WorkspaceModel.js" as WorkspaceModel - -ShellRoot { - id: root - - property var switcher: null - property var osd: null - property var content: null - property string output: "" - property var workspaceIds: [] - property real dotWidth: 0 - - function check(condition, message) { - if (!condition) - throw new Error(message); - } - - function pills() { - const found = []; - function visit(item) { - if (item.isPlaceholder !== undefined && item.isActive !== undefined) - found.push(item); - for (const child of item.children || []) - visit(child); - } - visit(root.switcher); - return found; - } - - function texts(item) { - if (!item.visible) - return []; - if (typeof item.text === "string") - return [item.text]; - return (item.children || []).reduce((result, child) => result.concat(texts(child)), []); - } - - function hyprlandRaw(id, name) { - return { - "id": id, - "name": name, - "monitor": { - "name": root.output - } - }; - } - - function activeIdx() { - return NiriService.allWorkspaces.find(ws => ws.output === root.output && ws.is_active)?.idx ?? -1; - } - - Component { - id: switcherComponent - WorkspaceSwitcher {} - } - - Component { - id: osdComponent - WorkspaceOSD {} - } - - Component { - id: contentComponent - DankBarContent {} - } - - QtObject { - id: barAxis - property bool isVertical: false - property string edge: "top" - } - - QtObject { - id: barWindow - property string screenName: root.output - property var screen: Quickshell.screens[0] ?? null - property var axis: barAxis - property bool isVertical: false - property real widgetThickness: 30 - property bool usesFrameBarChrome: false - property bool hasAdjacentTopBar: false - property bool hasAdjacentBottomBar: false - property bool hasAdjacentLeftBar: false - property bool hasAdjacentRightBar: false - } - - FloatingWindow { - visible: true - implicitWidth: 600 - implicitHeight: 200 - - Item { - id: stage - anchors.fill: parent - } - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - } - - Timer { - id: checks - interval: 25 - repeat: true - running: SettingsData._hasLoaded && SessionData._hasLoaded - property int step: 0 - property int waited: 0 - - function advance() { - step++; - waited = 0; - } - - onTriggered: { - try { - if (++waited > 800) - throw new Error("timed out in step " + step); - switch (step) { - case 0: - if (!CompositorService.isNiri || NiriService.allWorkspaces.length === 0 || Quickshell.screens.length === 0) - return; - root.output = Quickshell.screens[0].name; - NiriService.send({ - "Action": { - "SetWorkspaceName": { - "name": "alpha", - "workspace": null - } - } - }); - advance(); - return; - case 1: - if (NiriService.allWorkspaces.filter(ws => ws.output === root.output).length < 2) - return; - SettingsData.osdWorkspaceEnabled = true; - root.switcher = switcherComponent.createObject(stage, { - "axis": { - "isVertical": false, - "edge": "top" - }, - "screenName": root.output, - "parentScreen": Quickshell.screens[0], - "widgetThickness": 30, - "barThickness": 40, - "barConfig": { - "id": "fixture", - "widgetStyle": "pills" - }, - "widgetData": { - "id": "workspaceSwitcher", - "showWorkspaceIndex": true, - "showWorkspaceName": true, - "showWorkspacePadding": true - } - }); - root.osd = osdComponent.createObject(root, { - "screen": Quickshell.screens[0], - "modelData": Quickshell.screens[0] - }); - root.content = contentComponent.createObject(stage, { - "barWindow": barWindow, - "rootWindow": null, - "barConfig": { - "id": "fixture" - } - }); - root.check(root.switcher && root.osd && root.content, "components instantiate"); - advance(); - return; - case 2: - if (root.pills().length !== 3 || !root.texts(root.pills()[0]).join("|").includes(":")) - return; - root.workspaceIds = NiriService.allWorkspaces.filter(ws => ws.output === root.output).map(ws => ws.id); - root.check(root.switcher.workspaceList.length === 3, "two workspaces plus one padding placeholder, got " + root.switcher.workspaceList.length); - root.check(root.switcher.currentWorkspace === 1, "current workspace is idx 1, got " + root.switcher.currentWorkspace); - root.check(root.pills().map(pill => pill.isActive).join() === "true,false,false", "first pill active"); - root.check(root.pills().map(pill => pill.isPlaceholder).join() === "false,false,true", "padding pill is a placeholder"); - root.check(root.osd.activeWorkspace?.id === root.workspaceIds[0] && root.osd.activeWorkspace?.name === "alpha" && root.osd.activeWorkspace?.idx === 1, "osd active workspace " + JSON.stringify(root.osd.activeWorkspace)); - root.switcher.switchWorkspace(1); - advance(); - return; - case 3: - if (root.activeIdx() !== 2 || root.switcher.currentWorkspace !== 2) - return; - root.check(root.pills().map(pill => pill.isActive).join() === "false,true,false", "second pill active after scroll"); - root.check(root.osd.activeWorkspace?.id === root.workspaceIds[1] && root.osd.activeWorkspace?.idx === 2, "osd follows the switch, label " + root.osd.workspaceLabel); - root.content.switchWorkspace(-1); - advance(); - return; - case 4: - if (root.activeIdx() !== 1 || root.switcher.currentWorkspace !== 1) - return; - root.check(root.osd.activeWorkspace?.name === "alpha" && root.osd.activeWorkspace?.idx === 1, "osd label with name, got " + root.osd.workspaceLabel); - root.content.switchWorkspace(-1); - root.switcher.switchToWorkspaceByModelData(root.switcher.workspaceList[2]); - advance(); - return; - case 5: - if (waited < 12) - return; - root.check(root.activeIdx() === 1, "scrolling past the first workspace and pressing a placeholder do nothing"); - NiriService.send({ - "Action": { - "UnsetWorkspaceName": { - "reference": { - "Name": "alpha" - } - } - } - }); - root.switcher.widgetData = { - "id": "workspaceSwitcher", - "showWorkspaceName": true, - "showWorkspacePadding": true - }; - root.switcher.workspaceList = root.switcher.hyprlandSlotList(WorkspaceModel.hyprlandWorkspacesForScreen({ - "workspaces": [root.hyprlandRaw(1, "web"), root.hyprlandRaw(3, "")], - "monitors": [], - "focusedWorkspace": null, - "toplevels": [] - }, root.output, false, false, 3)); - advance(); - return; - case 6: - if (waited < 800 && root.pills().map(pill => root.texts(pill).join("|")).join() !== "web,2,3") - return; - root.check(root.pills().map(pill => root.texts(pill).join("|")).join() === "web,2,3", "hyprland slot pills label from their record, got " + root.pills().map(pill => root.texts(pill).join("|")).join()); - root.check(root.pills().every(pill => !pill.isPlaceholder), "hyprland padding slots are real workspaces"); - root.switcher.widgetData = { - "id": "workspaceSwitcher", - "workspaceIndicatorStyle": "dots", - "workspaceIndicatorRoundness": 0, - "showWorkspacePadding": true - }; - advance(); - return; - case 7: { - const idle = root.pills().filter(pill => !pill.isActive); - if (idle.length === 0 || root.pills().some(pill => pill.visualWidth !== pill.visualHeight)) - return; - const surface = pill => (pill.children || []).find(child => child.radiusOverride !== undefined); - root.check(idle.every(pill => surface(pill)?.radius === 0), "roundness 0 squares the dots, got " + idle.map(pill => surface(pill)?.radius).join()); - const active = root.pills().find(pill => pill.isActive); - root.check(!active || active.visualWidth > idle[0].visualWidth, "the active dot is a larger square"); - root.dotWidth = idle[0].visualWidth; - root.switcher.widgetData = Object.assign({}, root.switcher.widgetData, { - "workspaceIndicatorCompact": true - }); - advance(); - return; - } - case 8: { - const idle = root.pills().filter(pill => !pill.isActive); - if (idle.some(pill => pill.visualWidth !== pill.visualHeight || pill.visualWidth >= root.dotWidth)) - return; - root.check(idle.every(pill => pill.visualWidth < root.dotWidth), "compact shrinks the dots"); - console.log("FIXTURE_PASS"); - stop(); - Qt.quit(); - return; - } - } - } catch (error) { - console.error("FIXTURE_FAIL", error.message); - stop(); - Qt.quit(); - } - } - } -} diff --git a/quickshell/tests/run-qml.py b/quickshell/tests/run-qml.py index 045d1c9d1..c79149ecc 100644 --- a/quickshell/tests/run-qml.py +++ b/quickshell/tests/run-qml.py @@ -30,17 +30,14 @@ def run(name, command): def main(): - parser = argparse.ArgumentParser(description="Run shell logic, Qt unit tests and QML widget regressions") - parser.add_argument("--jobs", type=int, default=max(1, min(4, len(os.sched_getaffinity(0)) // 4))) - parser.add_argument("suites", nargs="*", choices=["widgets", "media", "qt", "lock", "logic"]) + parser = argparse.ArgumentParser(description="Run shell logic and Qt unit tests") + parser.add_argument("--jobs", type=int, default=3) + parser.add_argument("suites", nargs="*", choices=["qt", "lock", "logic"]) args = parser.parse_args() if args.jobs < 1: parser.error("--jobs must be positive") - selected = args.suites or ["widgets", "media", "qt", "lock", "logic"] - fixtures = sorted(str(path.relative_to(repo)) for path in (repo / "quickshell/tests/qml").glob("*.qml")) + selected = args.suites or ["qt", "lock", "logic"] commands = { - "widgets": [sys.executable, "quickshell/tests/run-surface-fixture.py", *(path for path in fixtures if not path.startswith("quickshell/tests/qml/media-"))], - "media": [sys.executable, "quickshell/tests/run-surface-fixture.py", "--mpris", "--artwork", *(path for path in fixtures if path.startswith("quickshell/tests/qml/media-"))], "logic": ["node", "--test", *sorted(str(path.relative_to(repo)) for path in (repo / "quickshell/tests").glob("*.test.mjs"))], } if {"qt", "lock"} & set(selected): @@ -59,7 +56,7 @@ def main(): print(output, flush=True) continue for line in output.splitlines(): - if name in ["widgets", "media"] or any(token in line for token in ["Totals:", "# tests "]): + if any(token in line for token in ["Totals:", "# tests "]): print(line.strip(), flush=True) print(f"QML checks: {time.monotonic() - started:.2f}s", flush=True) return int(failed) diff --git a/quickshell/tests/run-surface-fixture.py b/quickshell/tests/run-surface-fixture.py deleted file mode 100644 index 15b76d56d..000000000 --- a/quickshell/tests/run-surface-fixture.py +++ /dev/null @@ -1,177 +0,0 @@ -from concurrent.futures import ThreadPoolExecutor, as_completed -from pathlib import Path -import json -import os -import re -import shutil -import socket -import subprocess -import tempfile -import time -import sys - -repo = Path(__file__).resolve().parents[2] - - -def settings_config_version(): - source = (repo / "quickshell/Common/SettingsData.qml").read_text() - match = re.search(r"readonly property int settingsConfigVersion: (\d+)", source) - return int(match.group(1)) - - -def read_line(fd): - data = b"" - while not data.endswith(b"\n"): - chunk = os.read(fd, 300) - if not chunk: - break - data += chunk - os.close(fd) - return data.decode().strip() - - -def mesa_egl_vendor(): - for directory in ["/etc/glvnd/egl_vendor.d", "/usr/share/glvnd/egl_vendor.d"]: - for path in sorted(Path(directory).glob("*.json")): - try: - library = json.loads(path.read_text())["ICD"]["library_path"] - except (OSError, ValueError, KeyError): - continue - if "libEGL_mesa" in library: - return str(path) - return None - - -fixtures = [name for name in sys.argv[1:] if not name.startswith("--")] -if not fixtures: - raise SystemExit("usage: run-surface-fixture.py [--mpris] [--artwork] fixture.qml [fixture.qml ...]") -mpris = "--mpris" in sys.argv -with tempfile.TemporaryDirectory(prefix="dms-surface-test-") as temporary: - root = Path(temporary) - for name in ["config/DankMaterialShell", "state", "data", "cache", "home", "xdg"]: - (root / name).mkdir(parents=True) - (root / "runtime").mkdir(mode=0o700) - settings = {"configVersion": settings_config_version(), "barConfigs": [], "showDock": False, "frameEnabled": False, "disableLockScreen": True, "loginctlLockIntegration": False, "enableDynamicTheming": False} - for name in ["acMonitorTimeout", "acLockTimeout", "acSuspendTimeout", "batteryMonitorTimeout", "batteryLockTimeout", "batterySuspendTimeout"]: - settings[name] = 0 - (root / "config/DankMaterialShell/settings.json").write_text(json.dumps(settings)) - env = dict(os.environ) - for name in ["WAYLAND_DISPLAY", "WAYLAND_SOCKET", "NIRI_SOCKET", "HYPRLAND_INSTANCE_SIGNATURE", "SWAYSOCK", "I3SOCK", "MANGO_SOCKET", "MIRACLESOCK", "PULSE_SERVER", "PIPEWIRE_REMOTE", "DISPLAY", "QSG_USE_SIMPLE_ANIMATION_DRIVER", "QT_QPA_PLATFORMTHEME", "QT_STYLE_OVERRIDE", "QT_SCALE_FACTOR", "QT_SCREEN_SCALE_FACTORS", "QT_AUTO_SCREEN_SCALE_FACTOR", "QT_ENABLE_HIGHDPI_SCALING", "QT_FONT_DPI", "GTK_THEME", "LANGUAGE", "XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "XDG_SESSION_TYPE", "DESKTOP_SESSION"]: - env.pop(name, None) - for name in list(env): - if name.startswith(("DMS_", "QS_", "LC_", "XCURSOR_")): - del env[name] - shutil.copytree(repo / "quickshell/tests/fixtures/share", root / "share") - env.update(HOME=str(root / "home"), XDG_DATA_DIRS=str(root / "share"), XDG_CONFIG_DIRS=str(root / "xdg"), XDG_RUNTIME_DIR=str(root / "runtime"), TMPDIR=str(root), LANG="C.UTF-8", LC_ALL="C.UTF-8", TZ="UTC") - env.update({"XDG_" + name.upper() + "_HOME": str(root / name) for name in ["config", "state", "data", "cache"]}) - env.update(QT_QPA_PLATFORM="wayland", QT_LOGGING_RULES="qml.debug=true", LIBGL_ALWAYS_SOFTWARE="1", LP_NUM_THREADS="0", DMS_DISABLE_HOT_RELOAD="1", DMS_DISABLE_MATUGEN="1", DBUS_SESSION_BUS_ADDRESS="unix:path=" + str(root / "no-session-bus"), DBUS_SYSTEM_BUS_ADDRESS="unix:path=" + str(root / "no-system-bus"), PULSE_SERVER="unix:" + str(root / "no-pulse"), PIPEWIRE_REMOTE="no-pipewire") - # glvnd loads the nvidia vendor first, which powers up a sleeping dGPU - mesa_vendor = mesa_egl_vendor() - if mesa_vendor: - env["__EGL_VENDOR_LIBRARY_FILENAMES"] = mesa_vendor - (root / "niri.kdl").write_text('prefer-no-csd\nanimations { off; }\nhotkey-overlay { skip-at-startup; }\nlayout { gaps 0; border { off; }; focus-ring { off; }; default-column-width { proportion 1.0; }; }\n') - # a bus with service dirs activates gvfs and friends, whose fuse mounts break the temporary directory cleanup - (root / "dbus.conf").write_text('\nsessionunix:tmpdir=' + str(root) + '\n') - xvfb = None - try: - read_fd, write_fd = os.pipe() - xvfb = subprocess.Popen(["Xvfb", "-displayfd", str(write_fd), "-screen", "0", "1280x800x24", "-nolisten", "tcp"], env=env, pass_fds=(write_fd,), stdout=subprocess.DEVNULL, stderr=(root / "xvfb.log").open("w")) - os.close(write_fd) - display = read_line(read_fd) - if not display: - raise RuntimeError("Xvfb failed to start: " + (root / "xvfb.log").read_text()[-3000:]) - env["DISPLAY"] = ":" + display - - def start_niri(home, processes): - runtime = home / "runtime" - runtime.mkdir(mode=0o700) - log = home / "niri.log" - local = dict(env, XDG_RUNTIME_DIR=str(runtime)) - niri = subprocess.Popen(["niri", "-c", str(root / "niri.kdl")], env=local, stdout=subprocess.DEVNULL, stderr=log.open("w")) - processes.append(niri) - for attempt in range(100): - sockets = [path for path in runtime.glob("wayland-*") if not path.name.endswith(".lock")] - ipc = list(runtime.glob("niri.*.sock")) - if sockets and ipc: - local.update(WAYLAND_DISPLAY=sockets[0].name, NIRI_SOCKET=str(ipc[0])) - return local - if niri.poll() is not None: - raise RuntimeError("isolated niri failed to start: " + log.read_text()[-3000:]) - time.sleep(0.1) - raise RuntimeError("isolated niri startup timed out: " + log.read_text()[-3000:]) - - def start_dbus(target, processes): - if shutil.which("dbus-daemon"): - read_fd, write_fd = os.pipe() - processes.append(subprocess.Popen(["dbus-daemon", "--config-file=" + str(root / "dbus.conf"), "--nofork", "--print-address=" + str(write_fd)], env=target, pass_fds=(write_fd,), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)) - os.close(write_fd) - target["DBUS_SESSION_BUS_ADDRESS"] = read_line(read_fd) - return - if not shutil.which("dbus-broker-launch"): - raise RuntimeError("--mpris fixtures need dbus-daemon or dbus-broker-launch") - # dbus-broker only takes its listener through systemd socket activation, and the launcher joins its own bus through the session address - path = Path(tempfile.mkdtemp(prefix="bus-", dir=root)) / "socket" - address = "unix:path=" + str(path) - listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - listener.bind(str(path)) - listener.listen() - fd = listener.fileno() - move = f"exec 3<&{fd} {fd}<&-; " if fd != 3 else "" - processes.append(subprocess.Popen(["sh", "-c", move + 'LISTEN_PID=$$ LISTEN_FDS=1 exec dbus-broker-launch --scope user --config-file "$0"', str(root / "dbus.conf")], env=dict(target, DBUS_SESSION_BUS_ADDRESS=address), pass_fds=(fd,), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)) - listener.close() - target["DBUS_SESSION_BUS_ADDRESS"] = address - - def run_fixture(index, fixture): - started = time.monotonic() - home = root / f"fixture{index}" - (home / "qml").mkdir(parents=True) - for source in (repo / "quickshell").iterdir(): - if source.name not in ["shell.qml", ".qmlls.ini", "dms-plugins"]: - (home / "qml" / source.name).symlink_to(source) - (home / "qml/shell.qml").write_text((repo / fixture).read_text()) - for name in ["config", "state", "data"]: - shutil.copytree(root / name, home / name, symlinks=True) - (home / "cache").mkdir() - processes = [] - try: - fixture_env = start_niri(home, processes) - fixture_env.update({"XDG_" + name.upper() + "_HOME": str(home / name) for name in ["config", "state", "data", "cache"]}) - if mpris: - start_dbus(fixture_env, processes) - processes.append(subprocess.Popen([sys.executable, str(repo / "quickshell/tests/fixtures/mpris_player.py")] + (["--artwork"] if "--artwork" in sys.argv else []), env=fixture_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)) - result = subprocess.run(["qs", "-p", str(home / "qml")], env=fixture_env, capture_output=True, text=True, timeout=60) - output = result.stdout + result.stderr - failed = bool(result.returncode) or "FIXTURE_PASS" not in output or "FIXTURE_FAIL" in output - except subprocess.TimeoutExpired as expired: - output = "".join(part.decode() if isinstance(part, bytes) else part for part in [expired.stdout or "", expired.stderr or ""]) + "\nFIXTURE_FAIL timed out after 60s\n" - failed = True - except RuntimeError as error: - output = str(error) - failed = True - finally: - for process in reversed(processes): - process.terminate() - try: - process.wait(timeout=5) - except subprocess.TimeoutExpired: - process.kill() - process.wait() - if failed and (home / "niri.log").exists(): - output += "\nniri: " + (home / "niri.log").read_text()[-3000:] - return Path(fixture).stem, failed, time.monotonic() - started, output - - jobs = min(len(fixtures), int(os.environ.get("DMS_FIXTURE_JOBS", max(1, min(8, len(os.sched_getaffinity(0)) // 2))))) - failures = 0 - with ThreadPoolExecutor(max_workers=jobs) as pool: - for future in as_completed([pool.submit(run_fixture, index, fixture) for index, fixture in enumerate(fixtures)]): - name, failed, elapsed, output = future.result() - print(f"{'FAIL' if failed else 'PASS'} {name} {elapsed:.2f}s", flush=True) - if failed: - failures += 1 - print(output, flush=True) - finally: - if xvfb: - xvfb.terminate() - xvfb.wait() -if failures: - raise SystemExit(1) diff --git a/quickshell/tests/wayland-commits.py b/quickshell/tests/wayland-commits.py deleted file mode 100644 index 6522e77e2..000000000 --- a/quickshell/tests/wayland-commits.py +++ /dev/null @@ -1,34 +0,0 @@ -import collections -import re -import sys - -log = open(sys.argv[1]).read().splitlines() -stamp = re.compile(r"^\[(\d+):(\d+):(\d+)\.(\d+)\]") - - -def seconds(line): - match = stamp.match(line) - if not match: - return None - hours, minutes, whole, fraction = (int(part) for part in match.groups()) - return hours * 3600 + minutes * 60 + whole + fraction / 10 ** len(match.group(4)) - - -surfaces = {} -for line in log: - match = re.search(r'get_layer_surface\(new id zwlr_layer_surface_v1#\d+, wl_surface#(\d+), [^,]*, \d+, "([^"]+)"\)', line) - if match: - surfaces[match.group(2)] = match.group(1) -start = min(t for t in (seconds(line) for line in log) if t is not None) -print("layer surfaces:", surfaces) -for namespace, surface in surfaces.items(): - events = collections.defaultdict(list) - for line in log: - at = seconds(line) - if at is None or f"wl_surface#{surface}." not in line: - continue - events[re.search(r"wl_surface#\d+\.(\w+)\(", line).group(1)].append(at - start) - print(f"\n== {namespace} wl_surface#{surface}") - for kind, times in sorted(events.items()): - buckets = collections.Counter(int(t) for t in times) - print(f" {kind:<24} total {len(times):4} per-second {dict(sorted(buckets.items()))}")