diff --git a/core/cmd/dms/commands_doctor.go b/core/cmd/dms/commands_doctor.go index 3416240eb..adebb0e33 100644 --- a/core/cmd/dms/commands_doctor.go +++ b/core/cmd/dms/commands_doctor.go @@ -97,6 +97,7 @@ var ( miracleVersionRegex = regexp.MustCompile(`miracle-wm v?(\d+\.\d+\.\d+)`) scrollVersionRegex = regexp.MustCompile(`scroll version (\d+\.\d+)`) aqueousVersionRegex = regexp.MustCompile(`(?i)aqueous v?(\d+\.\d+(?:\.\d+)?)`) + umbrielVersionRegex = regexp.MustCompile(`umbriel (\d+\.\d+\.\d+)`) ) var doctorCmd = &cobra.Command{ @@ -743,6 +744,7 @@ func checkWindowManagers() []checkResult { {"Miracle WM", "miracle-wm", "--version", miracleVersionRegex, []string{"miracle-wm"}}, {"Scroll", "scroll", "--version", scrollVersionRegex, []string{"scroll"}}, {"Aqueous", "aqueous", "-version", aqueousVersionRegex, []string{"aqueous"}}, + {"Umbriel", "umbriel", "--version", umbrielVersionRegex, []string{"umbriel"}}, } var results []checkResult @@ -775,7 +777,7 @@ func checkWindowManagers() []checkResult { results = append(results, checkResult{ catCompositor, "Compositor", statusError, "No supported Wayland compositor found", - "Install Hyprland, niri, Sway, River, Wayfire, labwc, mangowc, miracle-wm, Scroll, or Aqueous", + "Install Hyprland, niri, Sway, River, Wayfire, labwc, mangowc, miracle-wm, Scroll, Aqueous, or Umbriel", doctorDocsURL + "#compositor-checks", }) } @@ -848,6 +850,8 @@ func detectRunningWM() string { return "MangoWC" case os.Getenv("MIRACLESOCK") != "": return "Miracle WM" + case os.Getenv("UMBRIEL_SOCKET") != "": + return "Umbriel" case os.Getenv("XDG_CURRENT_DESKTOP") != "": return os.Getenv("XDG_CURRENT_DESKTOP") } diff --git a/core/internal/screenshot/compositor.go b/core/internal/screenshot/compositor.go index 2f21966de..fe36b834a 100644 --- a/core/internal/screenshot/compositor.go +++ b/core/internal/screenshot/compositor.go @@ -23,6 +23,7 @@ const ( CompositorMiracle CompositorMango CompositorAqueous + CompositorUmbriel ) var detectedCompositor Compositor = -1 @@ -39,6 +40,7 @@ func DetectCompositor() Compositor { }{ {os.Getenv("AQUEOUS_SOCKET"), true, CompositorAqueous}, {os.Getenv("MANGO_INSTANCE_SIGNATURE"), true, CompositorMango}, + {os.Getenv("UMBRIEL_SOCKET"), true, CompositorUmbriel}, {os.Getenv("NIRI_SOCKET"), true, CompositorNiri}, {os.Getenv("SCROLLSOCK"), true, CompositorScroll}, {os.Getenv("MIRACLESOCK"), true, CompositorMiracle}, @@ -362,6 +364,30 @@ func getNiriFocusedMonitor() string { return "" } +type umbrielWorkspace struct { + Output string `json:"output"` + Focused bool `json:"focused"` +} + +func getUmbrielFocusedMonitor() string { + output, err := exec.Command("umbriel", "workspaces", "--json").Output() + if err != nil { + return "" + } + + var workspaces []umbrielWorkspace + if err := json.Unmarshal(output, &workspaces); err != nil { + return "" + } + + for _, ws := range workspaces { + if ws.Focused { + return ws.Output + } + } + return "" +} + func GetFocusedMonitor() string { switch DetectCompositor() { case CompositorAqueous: @@ -379,6 +405,8 @@ func GetFocusedMonitor() string { return getNiriFocusedMonitor() case CompositorMango: return getMangoFocusedMonitor() + case CompositorUmbriel: + return getUmbrielFocusedMonitor() } return "" } diff --git a/dank-qml-common b/dank-qml-common index c9c29deeb..928cc746e 160000 --- a/dank-qml-common +++ b/dank-qml-common @@ -1 +1 @@ -Subproject commit c9c29deeb3b6436583d20d23d3b210c5522e5292 +Subproject commit 928cc746e57e4e2a06a5ee40fe137e425fcd8cfe diff --git a/flake.lock b/flake.lock index 5dcdafc38..161078f79 100644 --- a/flake.lock +++ b/flake.lock @@ -3,11 +3,11 @@ "dank-qml-common": { "flake": false, "locked": { - "lastModified": 1790081738, - "narHash": "sha256-wPe6BGzBoQaH9YtKG+TXgNGxBjgDj+2Qrozg75qLDpc=", + "lastModified": 1790108367, + "narHash": "sha256-eQv3ahrgVH6OAS8o9SGxFACNxMVOwHfRm8KGdJDWa4Y=", "owner": "AvengeMedia", "repo": "dank-qml-common", - "rev": "c9c29deeb3b6436583d20d23d3b210c5522e5292", + "rev": "928cc746e57e4e2a06a5ee40fe137e425fcd8cfe", "type": "github" }, "original": { diff --git a/quickshell/Common/WorkspaceModel.js b/quickshell/Common/WorkspaceModel.js index 1a780c7cb..b28f7e12b 100644 --- a/quickshell/Common/WorkspaceModel.js +++ b/quickshell/Common/WorkspaceModel.js @@ -247,11 +247,29 @@ function hyprlandSpecialWorkspaces(raw, screenName, followFocus, occupiedOnly) { const isVisible = normalized => perMonitor ? visible[screenName] === normalized : Object.values(visible).includes(normalized); const specials = raw.workspaces.filter(ws => hyprlandSpecial(ws) && (!perMonitor || ws.monitor?.name === screenName)).map(ws => { const name = ws.name ?? ""; - return { id: ws.id, idx: null, name: hyprlandSpecialDisplayName(name), output: ws.monitor?.name ?? "", active: isVisible(name === "special" ? "special:special" : name), placeholder: false, urgent: ws.urgent === true, special: true }; + return { + id: ws.id, + idx: null, + name: hyprlandSpecialDisplayName(name), + output: ws.monitor?.name ?? "", + active: isVisible(name === "special" ? "special:special" : name), + placeholder: false, + urgent: ws.urgent === true, + special: true + }; }).filter(ws => !occupiedOnly || ws.active || toplevels.some(tl => hyprlandWorkspaceMatches(tl.workspace, ws))).sort((a, b) => a.name.localeCompare(b.name)); // the default scratchpad is always offered so it can be opened before Hyprland has created it if (!specials.some(ws => ws.name === "special")) - specials.push({ id: null, idx: null, name: "special", output: screenName ?? "", active: isVisible("special:special"), placeholder: false, urgent: false, special: true }); + specials.push({ + id: null, + idx: null, + name: "special", + output: screenName ?? "", + active: isVisible("special:special"), + placeholder: false, + urgent: false, + special: true + }); return specials; } @@ -283,7 +301,15 @@ function hyprlandVisibleSpecial(raw, monitorName) { return null; // the overlay event can land before the workspace list refresh, so the tracked name is the OSD's key const ws = raw.workspaces.find(ws => hyprlandSpecial(ws) && (ws.name === "special" ? "special:special" : ws.name) === tracked); - return { id: ws?.id || tracked, idx: null, name: hyprlandSpecialDisplayName(tracked), output: monitorName, active: true, placeholder: false, special: true }; + return { + id: ws?.id || tracked, + idx: null, + name: hyprlandSpecialDisplayName(tracked), + output: monitorName, + active: true, + placeholder: false, + special: true + }; } function hyprlandActiveWorkspace(raw, screenName) { @@ -562,3 +588,42 @@ function aqueousWindowsOnWorkspace(windows, workspace) { function aqueousWorkspaceOccupied(toplevels, workspace) { return toplevels.some(win => win.aqueousWorkspaceId === workspace.id); } + +function umbrielRecord(ws) { + return { + id: ws.id, + idx: ws.index, + name: ws.named === true ? ws.name : "", + output: ws.output ?? "", + active: ws.active === true, + placeholder: false, + occupied: ws.occupied === true + }; +} + +function umbrielCurrentId(workspaces, screenName) { + return workspaces.find(ws => ws.output === screenName && ws.active)?.id ?? ""; +} + +function umbrielWorkspacesForScreen(workspaces, screenName, occupiedOnly, cache) { + dropStale(cache, new Set(workspaces.map(ws => ws.id))); + const listed = workspaces.filter(ws => ws.output === screenName && (!occupiedOnly || ws.active || ws.occupied)); + return listed.sort((a, b) => a.index - b.index).map(ws => reuse(cache, ws.id, umbrielRecord(ws))); +} + +function umbrielActiveWorkspace(workspaces, screenName) { + const ws = workspaces.find(w => w.output === screenName && w.active); + return ws ? umbrielRecord(ws) : null; +} + +function umbrielWindowsOnWorkspace(windows, workspace) { + return windows.filter(win => win.workspace === workspace.id).sort((a, b) => a.x - b.x || a.y - b.y || a.id.localeCompare(b.id)).map(win => ({ + id: win.id, + app_id: win.app_id, + activated: win.active === true + })); +} + +function umbrielWorkspaceUrgent(windows, workspace) { + return windows.some(win => win.workspace === workspace.id && win.urgent === true); +} diff --git a/quickshell/DMSShellIPC.qml b/quickshell/DMSShellIPC.qml index b92308bac..cc490de7c 100644 --- a/quickshell/DMSShellIPC.qml +++ b/quickshell/DMSShellIPC.qml @@ -1,7 +1,5 @@ import QtQuick -import Quickshell import Quickshell.Io -import Quickshell.Hyprland import Quickshell.Wayland import Quickshell.Services.SystemTray import Quickshell.Services.UPower @@ -449,20 +447,7 @@ Item { IpcHandler { function getFocusedScreenName() { - if (CompositorService.isHyprland && Hyprland.focusedMonitor) { - return Hyprland.focusedMonitor.name; - } - if (CompositorService.isNiri && NiriService.currentOutput) { - return NiriService.currentOutput; - } - if ((CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) && I3.workspaces?.values) { - const focusedWs = I3.workspaces.values.find(ws => ws.focused === true); - return focusedWs?.monitor?.name || ""; - } - if (CompositorService.isMango && MangoService.activeOutput) { - return MangoService.activeOutput; - } - return ""; + return CompositorService.getFocusedScreenName(); } function getActiveNotepadInstance() { diff --git a/quickshell/Modules/DankDash/Media/MediaChromeBase.qml b/quickshell/Modules/DankDash/Media/MediaChromeBase.qml index 8efa07953..80f54b703 100644 --- a/quickshell/Modules/DankDash/Media/MediaChromeBase.qml +++ b/quickshell/Modules/DankDash/Media/MediaChromeBase.qml @@ -1,7 +1,6 @@ pragma ComponentBehavior: Bound import QtQuick -import Quickshell.Widgets import qs.Common import qs.Widgets import qs.Modules.DankDash @@ -55,7 +54,8 @@ Item { return FocusNavigation.focusItem(buttonForPanel(panelId), false); } - ClippingRectangle { + // The backdrop clips itself to the corners, so the card needs no clip of its own. + Rectangle { anchors.fill: parent radius: root.backdropRadius color: root.surfaceColor @@ -64,11 +64,11 @@ Item { Loader { anchors.fill: parent - z: -1 + anchors.margins: Theme.layerOutlineWidth active: root.player.wallpaperEnabled && !!root.presentation sourceComponent: MediaArtBackdrop { - radius: root.backdropRadius + radius: root.backdropRadius - Theme.layerOutlineWidth stableHeight: root.baseHeight activePlayer: root.activePlayer artUrl: root.artUrl @@ -78,7 +78,7 @@ Item { Item { id: cardBody anchors.fill: parent - anchors.margins: root.padding + anchors.margins: root.padding + Theme.layerOutlineWidth } } diff --git a/quickshell/Modules/Settings/AboutTab.qml b/quickshell/Modules/Settings/AboutTab.qml index cff5136ec..6c9b04079 100644 --- a/quickshell/Modules/Settings/AboutTab.qml +++ b/quickshell/Modules/Settings/AboutTab.qml @@ -20,6 +20,7 @@ Item { property bool isMango: CompositorService.isMango property bool isLabwc: CompositorService.isLabwc property bool isAqueous: CompositorService.isAqueous + property bool isUmbriel: CompositorService.isUmbriel property string compositorName: { if (isHyprland) @@ -54,6 +55,8 @@ Item { return "/assets/labwc.png"; if (isAqueous) return "/assets/aqueous.svg"; + if (isUmbriel) + return "/assets/umbriel.svg"; return "/assets/niri.svg"; } @@ -72,6 +75,8 @@ Item { return "https://labwc.github.io/"; if (isAqueous) return ""; + if (isUmbriel) + return "https://github.com/noctalia-dev/umbriel"; return "https://github.com/niri-wm/niri"; } @@ -90,6 +95,8 @@ Item { return I18n.tr("LabWC website"); if (isAqueous) return "Aqueous"; + if (isUmbriel) + return "Umbriel"; return I18n.tr("niri GitHub"); } diff --git a/quickshell/Services/BarWidgetService.qml b/quickshell/Services/BarWidgetService.qml index 2c5399dcf..3bd7056e0 100644 --- a/quickshell/Services/BarWidgetService.qml +++ b/quickshell/Services/BarWidgetService.qml @@ -188,7 +188,7 @@ Singleton { return getWidget(widgetId, getFocusedScreenName()) || getWidget(widgetId); } - readonly property bool focusedScreenDetectionSupported: (CompositorService.isAqueous && AqueousService.available) || CompositorService.isHyprland || CompositorService.isNiri || CompositorService.isMango || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle + readonly property bool focusedScreenDetectionSupported: (CompositorService.isAqueous && AqueousService.available) || CompositorService.isHyprland || CompositorService.isNiri || CompositorService.isMango || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle || CompositorService.isUmbriel function getFocusedScreenName() { return CompositorService.getFocusedScreenName(); diff --git a/quickshell/Services/CompositorService.qml b/quickshell/Services/CompositorService.qml index 951128ef4..9515d7e2d 100644 --- a/quickshell/Services/CompositorService.qml +++ b/quickshell/Services/CompositorService.qml @@ -24,10 +24,11 @@ Singleton { property bool isMiracle: false property bool isLabwc: false property bool isAqueous: false + property bool isUmbriel: false property string compositor: "unknown" property bool compositorDetected: false property bool outputPowerAvailable: false - readonly property bool genericPowerBackend: compositorDetected && !isNiri && !isHyprland && !isMango && !isSway && !isScroll && !isMiracle && !isLabwc + readonly property bool genericPowerBackend: compositorDetected && !isNiri && !isHyprland && !isMango && !isSway && !isScroll && !isMiracle && !isLabwc && !isUmbriel onGenericPowerBackendChanged: probeOutputPower() function probeOutputPower() { @@ -106,6 +107,7 @@ Singleton { case "sway": case "scroll": case "miracle": + case "umbriel": return true; default: return false; @@ -132,7 +134,7 @@ Singleton { readonly property bool supportsCursorConfig: isNiri || isHyprland || isMango readonly property bool supportsDisplayConfig: isNiri || isHyprland || isMango || isAqueous readonly property bool supportsBarAutoHideReveal: isNiri || isHyprland || isMango - readonly property bool supportsWorkspaces: isNiri || isHyprland || isMango || isAqueous + readonly property bool supportsWorkspaces: isNiri || isHyprland || isMango || isAqueous || isUmbriel // compositors where a workspace that does not exist yet is still a valid switch target readonly property bool supportsPersistentWorkspaces: isHyprland || isMango || isSway || isScroll || isMiracle readonly property bool supportsWorkspaceUrgency: isKnownCompositor && !isLabwc @@ -160,6 +162,8 @@ Singleton { return "Labwc"; case "aqueous": return "Aqueous"; + case "umbriel": + return "Umbriel"; default: return ""; } @@ -175,6 +179,7 @@ Singleton { case "miracle": case "labwc": case "aqueous": + case "umbriel": return compositor; default: return ""; @@ -225,6 +230,23 @@ Singleton { } } + Binding { + target: UmbrielService + property: "active" + value: root.isUmbriel + } + + Connections { + target: UmbrielService + enabled: root.isUmbriel + function onWorkspacesChanged() { + root.workspaceStateChanged(); + } + function onWindowsChanged() { + root.workspaceStateChanged(); + } + } + function canMinimize(toplevel) { if (isAqueous && toplevel?.aqueousWindowId) return AqueousService.available && !AqueousService.locked && AqueousService.capabilities.commands && toplevel.canMinimize; @@ -372,6 +394,8 @@ Singleton { } if (isMango && MangoService.activeOutput) return MangoService.activeOutput; + if (isUmbriel) + return UmbrielService.focusedOutput; return ""; } @@ -1352,6 +1376,8 @@ Singleton { return "labwc"; case "aqueous": return "aqueous"; + case "umbriel": + return "umbriel"; default: return ""; } @@ -1366,6 +1392,7 @@ Singleton { isMiracle = name === "miracle"; isLabwc = name === "labwc"; isAqueous = name === "aqueous"; + isUmbriel = name === "umbriel"; compositor = name; compositorDetected = true; if (isNiri) @@ -1418,6 +1445,7 @@ Singleton { function _envDetectionCandidates() { const runtimeDir = Quickshell.env("XDG_RUNTIME_DIR") || ""; const aqueousSocket = Quickshell.env("AQUEOUS_SOCKET") || ""; + const umbrielSocket = Quickshell.env("UMBRIEL_SOCKET") || ""; return [ { name: "aqueous", @@ -1431,6 +1459,12 @@ Singleton { test: ["test", "-S", mangoSignature], detail: "MANGO_INSTANCE_SIGNATURE " + mangoSignature }, + { + name: "umbriel", + present: !!umbrielSocket, + test: ["test", "-S", umbrielSocket], + detail: "UMBRIEL_SOCKET " + umbrielSocket + }, { name: "niri", present: !!niriSocket, @@ -1508,6 +1542,8 @@ Singleton { Quickshell.execDetached(["dms", "dpms", "off"]); return; } + if (isUmbriel) + return UmbrielService.action("dpms-off"); if (outputPowerAvailable) { setOutputPower(false); return; @@ -1532,6 +1568,8 @@ Singleton { Quickshell.execDetached(["dms", "dpms", "on"]); return; } + if (isUmbriel) + return UmbrielService.action("dpms-on"); if (outputPowerAvailable) { setOutputPower(true); return; @@ -1666,6 +1704,8 @@ Singleton { const name = _followedScreen(screenName, followFocus); return WorkspaceModel.aqueousWorkspacesForScreen(_aqueousWorkspaceState(name), name, options.occupiedOnly, _workspaceRecords); } + case "umbriel": + return WorkspaceModel.umbrielWorkspacesForScreen(UmbrielService.workspaces, _followedScreen(screenName, followFocus), options.occupiedOnly, _workspaceRecords); default: return []; } @@ -1685,6 +1725,8 @@ Singleton { return WorkspaceModel.i3CurrentKey(_i3WorkspaceState(), screenName, followFocus); case "aqueous": return WorkspaceModel.aqueousCurrentId(_aqueousWorkspaceState(_followedScreen(screenName, followFocus))); + case "umbriel": + return WorkspaceModel.umbrielCurrentId(UmbrielService.workspaces, _followedScreen(screenName, followFocus)); default: return 1; } @@ -1718,6 +1760,8 @@ Singleton { return WorkspaceModel.i3ActiveWorkspace(_i3WorkspaceState(), screenName); case "aqueous": return WorkspaceModel.aqueousActiveWorkspace(_aqueousWorkspaceState(screenName), screenName); + case "umbriel": + return WorkspaceModel.umbrielActiveWorkspace(UmbrielService.workspaces, screenName); default: return null; } @@ -1739,6 +1783,8 @@ Singleton { return WorkspaceModel.i3WindowsOnWorkspace(sortedToplevels, record); case "aqueous": return WorkspaceModel.aqueousWindowsOnWorkspace(sortedToplevels, record); + case "umbriel": + return WorkspaceModel.umbrielWindowsOnWorkspace(UmbrielService.windows, record); default: return []; } @@ -1753,6 +1799,7 @@ Singleton { case "hyprland": return WorkspaceModel.hyprlandWorkspaceOccupied(Array.from(Hyprland.toplevels?.values || []), record); case "mango": + case "umbriel": return record.occupied === true; case "aqueous": return AqueousService.available && WorkspaceModel.aqueousWorkspaceOccupied(AqueousService.toplevels, record); @@ -1784,6 +1831,7 @@ Singleton { case "sway": case "scroll": case "miracle": + case "umbriel": return loadedUrgent; default: return record?.urgent === true; @@ -1794,6 +1842,8 @@ Singleton { switch (compositor) { case "niri": return WorkspaceModel.niriWorkspaceUrgent(NiriService.windows, record); + case "umbriel": + return WorkspaceModel.umbrielWorkspaceUrgent(UmbrielService.windows, record); default: return record?.urgent ?? false; } @@ -1834,6 +1884,9 @@ Singleton { "aqueousSession": record.session }); return; + case "umbriel": + UmbrielService.action(`workspace-switch:${record.idx}/${record.output}`); + return; } } @@ -1875,6 +1928,12 @@ Singleton { case "aqueous": _scrollAqueousWorkspace(followFocus ? AqueousService.focusedOutput : screenName, direction); return; + case "umbriel": + { + const name = _followedScreen(screenName, followFocus); + stepWorkspace(WorkspaceModel.umbrielWorkspacesForScreen(UmbrielService.workspaces, name, false, _workspaceRecords), WorkspaceModel.umbrielCurrentId(UmbrielService.workspaces, name), direction); + return; + } } } @@ -1952,6 +2011,9 @@ Singleton { case "niri": NiriService.focusWindow(windowId); return true; + case "umbriel": + UmbrielService.action("window-focus:" + windowId); + return true; default: return false; } diff --git a/quickshell/Services/SessionService.qml b/quickshell/Services/SessionService.qml index 362dde09d..92816de5c 100644 --- a/quickshell/Services/SessionService.qml +++ b/quickshell/Services/SessionService.qml @@ -453,6 +453,11 @@ Singleton { return; } + if (CompositorService.isUmbriel) { + UmbrielService.action("session-quit:skip-confirmation"); + return; + } + if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) { try { I3.dispatch("exit"); diff --git a/quickshell/Services/UmbrielService.qml b/quickshell/Services/UmbrielService.qml new file mode 100644 index 000000000..e5b49903f --- /dev/null +++ b/quickshell/Services/UmbrielService.qml @@ -0,0 +1,62 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Common +import qs.Services + +Singleton { + id: root + readonly property var log: Log.scoped("UmbrielService") + + readonly property string socketPath: Quickshell.env("UMBRIEL_SOCKET") + + property bool active: false + property var workspaces: [] + property var windows: [] + readonly property string focusedOutput: workspaces.find(ws => ws.focused)?.output ?? "" + + DankSocket { + path: root.socketPath + connected: root.active && root.socketPath !== "" + + onConnectionStateChanged: { + if (linkUp) + send({ + "cmd": "subscribe", + "events": ["workspaces", "windows"] + }); + } + + parser: SplitParser { + onRead: line => root._handleEvent(line) + } + } + + function _handleEvent(line) { + let message; + try { + message = JSON.parse(line); + } catch (e) { + log.warn("Failed to parse event:", e); + return; + } + switch (message.event) { + case "workspaces": + workspaces = message.data; + return; + case "windows": + windows = message.data; + return; + } + } + + function action(name) { + Proc.runCommand(null, ["umbriel", "msg", name], (output, exitCode) => { + if (exitCode !== 0) + log.warn("umbriel msg", name, "exited with", exitCode); + }, 0); + } +} diff --git a/quickshell/Widgets/LauncherLogo.qml b/quickshell/Widgets/LauncherLogo.qml index d1556181c..60c3f240e 100644 --- a/quickshell/Widgets/LauncherLogo.qml +++ b/quickshell/Widgets/LauncherLogo.qml @@ -64,6 +64,8 @@ Item { return "file://" + Theme.shellDir + "/assets/labwc.png"; case "aqueous": return "file://" + Theme.shellDir + "/assets/aqueous.svg"; + case "umbriel": + return "file://" + Theme.shellDir + "/assets/umbriel.svg"; default: return ""; } diff --git a/quickshell/assets/umbriel.svg b/quickshell/assets/umbriel.svg new file mode 100644 index 000000000..ec6cdebc9 --- /dev/null +++ b/quickshell/assets/umbriel.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + diff --git a/quickshell/tests/README.md b/quickshell/tests/README.md index 72464f9d1..0b0483082 100644 --- a/quickshell/tests/README.md +++ b/quickshell/tests/README.md @@ -1,6 +1,6 @@ # Shell tests -`make test-qml` runs the shell JavaScript tests, Qt output-cycle tests, QML tests for widget ordering and multi-bar/plugin routing, and the media fixtures. It needs Python 3, Node.js, make, 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 the shell JavaScript tests, Qt output-cycle tests and every QML fixture in `qml/`. It needs Python 3, Node.js, make, 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. 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. @@ -14,17 +14,22 @@ python3 quickshell/tests/run-qml.py media Set `QMLTESTRUNNER` to override the Qt 6 executable. Use `--jobs 1` to run the suites sequentially. -The QML fixtures share one X server and the QML disk cache. 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. - -Other compositor, D-Bus, screenshot and resource fixtures remain available through `run-surface-fixture.py`: +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: ```sh -python3 quickshell/tests/run-surface-fixture.py quickshell/tests/qml/system-tray.qml /tmp/system-tray.log --dbus -python3 quickshell/tests/run-surface-fixture.py quickshell/tests/qml/media-presentation.qml /tmp/media-presentation.log --mpris -python3 quickshell/tests/run-surface-fixture.py quickshell/tests/qml/media-playback.qml /tmp/media-playback.log --mpris -python3 quickshell/tests/run-surface-fixture.py quickshell/tests/qml/media-artwork.qml /tmp/media-artwork.log --mpris --artwork -python3 quickshell/tests/run-surface-fixture.py quickshell/tests/qml/media-lyrics.qml /tmp/media-lyrics.log --mpris --artwork -python3 quickshell/tests/run-surface-fixture.py --suite quickshell/tests/qml/bar-content.qml quickshell/tests/qml/instance-routing.qml +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 ``` -`--suite` accepts QML paths followed by the fixture flags they share. It reports each fixture's runtime and runs half the available cores at a time, up to four; set `DMS_FIXTURE_JOBS` to change that. `--mpris` fixtures get their own session bus and player, so they run in parallel like the rest. A failure, missing pass marker, QML binding error or timeout fails the command and prints the failed fixture's full log. +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. diff --git a/quickshell/tests/bar-content.test.mjs b/quickshell/tests/bar-content.test.mjs index 0028bfd32..96be287bd 100644 --- a/quickshell/tests/bar-content.test.mjs +++ b/quickshell/tests/bar-content.test.mjs @@ -34,7 +34,7 @@ test("widget normalization preserves metadata, defaults and positional occurrenc test("focus discovery keeps compositor names separate from first-screen fallback", () => { const source = read("Services/CompositorService.qml"); const context = vm.createContext({ - isAqueous: false, isHyprland: false, isNiri: false, isSway: false, isScroll: false, isMiracle: false, isMango: false, + isAqueous: false, isHyprland: false, isNiri: false, isSway: false, isScroll: false, isMiracle: false, isMango: false, isUmbriel: false, AqueousService: { available: true, focusedOutput: "second" }, Hyprland: { focusedMonitor: { name: "second" } }, NiriService: { currentOutput: "second" }, I3: { workspaces: { values: [{ focused: true, monitor: { name: "second" } }] } }, MangoService: { activeOutput: "second" }, Quickshell: { screens: [{ name: "first" }, { name: "second" }] } diff --git a/quickshell/tests/compositor-capabilities.test.mjs b/quickshell/tests/compositor-capabilities.test.mjs index 1a4290737..617a86475 100644 --- a/quickshell/tests/compositor-capabilities.test.mjs +++ b/quickshell/tests/compositor-capabilities.test.mjs @@ -4,7 +4,7 @@ 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" }; +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"); diff --git a/quickshell/tests/fixtures/sni_item.py b/quickshell/tests/fixtures/sni_item.py deleted file mode 100644 index 4b955db4a..000000000 --- a/quickshell/tests/fixtures/sni_item.py +++ /dev/null @@ -1,97 +0,0 @@ -import asyncio -import sys - -from dbus_next.constants import PropertyAccess -from dbus_next.aio import MessageBus -from dbus_next.service import ServiceInterface, dbus_property, method, signal - - -class StatusNotifierItem(ServiceInterface): - def __init__(self, item_id, title, icon_name, only_menu): - super().__init__("org.kde.StatusNotifierItem") - self._id = item_id - self._title = title - self._icon_name = icon_name - self._only_menu = only_menu - - @dbus_property(access=PropertyAccess.READ) - def Category(self) -> "s": - return "ApplicationStatus" - - @dbus_property(access=PropertyAccess.READ) - def Id(self) -> "s": - return self._id - - @dbus_property(access=PropertyAccess.READ) - def Title(self) -> "s": - return self._title - - @dbus_property(access=PropertyAccess.READ) - def Status(self) -> "s": - return "Active" - - @dbus_property(access=PropertyAccess.READ) - def IconName(self) -> "s": - return self._icon_name - - @dbus_property(access=PropertyAccess.READ) - def IconThemePath(self) -> "s": - return "" - - @dbus_property(access=PropertyAccess.READ) - def ItemIsMenu(self) -> "b": - return self._only_menu - - @dbus_property(access=PropertyAccess.READ) - def Menu(self) -> "o": - return "/NO_DBUSMENU" - - @dbus_property(access=PropertyAccess.READ) - def ToolTip(self) -> "(sa(iiay)ss)": - return ["", [], self._title, ""] - - @method() - def Activate(self, x: "i", y: "i"): - print("ACTIVATE " + self._id, flush=True) - - @method() - def SecondaryActivate(self, x: "i", y: "i"): - pass - - @method() - def ContextMenu(self, x: "i", y: "i"): - print("CONTEXT " + self._id, flush=True) - - @method() - def Scroll(self, delta: "i", orientation: "s"): - pass - - @signal() - def NewIcon(self): - pass - - -async def main(): - bus = await MessageBus().connect() - for spec in sys.argv[1:]: - item_id, title, icon_name, only_menu = spec.split(":") - bus.export("/StatusNotifierItem", StatusNotifierItem(item_id, title, icon_name, only_menu == "1")) - name = "org.kde.StatusNotifierItem-" + str(id(bus)) + "-" + item_id - await bus.request_name(name) - introspection = None - for attempt in range(150): - try: - introspection = await bus.introspect("org.kde.StatusNotifierWatcher", "/StatusNotifierWatcher") - break - except Exception: - await asyncio.sleep(0.2) - if introspection is None: - raise RuntimeError("no StatusNotifierWatcher on the bus") - watcher = bus.get_proxy_object("org.kde.StatusNotifierWatcher", "/StatusNotifierWatcher", introspection).get_interface("org.kde.StatusNotifierWatcher") - await watcher.call_register_status_notifier_item(name) - print("REGISTERED " + name, flush=True) - bus = await MessageBus().connect() - await asyncio.get_event_loop().create_future() - - -asyncio.run(main()) diff --git a/quickshell/tests/qml/audio-sound-players.qml b/quickshell/tests/qml/audio-sound-players.qml deleted file mode 100644 index 1cc0b1fcc..000000000 --- a/quickshell/tests/qml/audio-sound-players.qml +++ /dev/null @@ -1,48 +0,0 @@ -import QtQuick -import QtTest -import Quickshell -import qs.Common -import qs.Services -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - - TestCase { - id: input - when: false - name: "audio-sound-players" - } - AudioSoundPlayers { - id: players - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - } - - function check(value, message) { - if (!value) - throw new Error(message); - } - - Timer { - interval: 0 - running: true - onTriggered: { - try { - root.check(String(players.loginSound.source) === String(AudioService.getSoundPath("desktop-login")), "player source resolves the sound file: " + players.loginSound.source); - root.check(String(players.criticalNotificationSound.source) === String(AudioService.getSoundPath("message-new-instant")), "each player maps its own event"); - AudioService.notificationsVolume = 0.25; - root.check(players.loginSound.audioOutput.volume === 0.25 && players.volumeChangeSound.audioOutput.volume === 0.25, "player volume follows the service"); - console.log("FIXTURE_PASS audio sound players bind source and volume to the service"); - } catch (error) { - console.error("FIXTURE_FAIL", error.message); - } - Qt.quit(); - } - } -} diff --git a/quickshell/tests/qml/bar-content.qml b/quickshell/tests/qml/bar-content.qml index 2688c3ca3..2f5c4aef2 100644 --- a/quickshell/tests/qml/bar-content.qml +++ b/quickshell/tests/qml/bar-content.qml @@ -242,10 +242,13 @@ ShellRoot { Timer { id: checks - interval: 150 + 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); @@ -257,7 +260,7 @@ ShellRoot { 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++) { + switch (step) { case 0: const plugin = pluginContainer.item.item; root.check(plugin.pluginService === PluginService && plugin.popoutService === PopoutService, "plugin service injection"); @@ -379,8 +382,12 @@ ShellRoot { stop(); Qt.quit(); } + step++; + stepStarted = Date.now(); } catch (error) { - console.error("FIXTURE_FAIL", error.message); + if (Date.now() - stepStarted < 20000) + return; + console.error("FIXTURE_FAIL", "step " + step + ": " + error.message); stop(); Qt.quit(); } diff --git a/quickshell/tests/qml/bar-context-menus.qml b/quickshell/tests/qml/bar-context-menus.qml deleted file mode 100644 index 33fae77d5..000000000 --- a/quickshell/tests/qml/bar-context-menus.qml +++ /dev/null @@ -1,271 +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 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"); - } - - property var barConfig: ({ - widgetPadding: 12, - fontScale: 1, - iconScale: 1, - spacing: 4 - }) - property var axes: [ - { - isVertical: false, - edge: "top" - }, - { - isVertical: false, - edge: "bottom" - }, - { - isVertical: true, - edge: "left" - }, - { - isVertical: true, - edge: "right" - } - ] - property var cases: [ - { - name: "clipboard", - component: clipboard - }, - { - name: "notepad", - component: notepad - } - ] - property var loadOnly: [ - { - name: "idle", - component: idle - }, - { - name: "notifications", - component: notifications - }, - { - name: "vpn", - component: vpn - }, - { - name: "focused", - component: focused - }, - { - name: "disk", - component: disk - } - ] - property var phases: ["closed", "open", "closed-again"] - property var instances: [] - property int phase: 0 - - Component { - id: clipboard - ClipboardButton {} - } - Component { - id: notepad - NotepadButton {} - } - Component { - id: idle - IdleInhibitor {} - } - Component { - id: notifications - NotificationCenterButton {} - } - Component { - id: vpn - Vpn {} - } - Component { - id: focused - FocusedApp {} - } - Component { - id: disk - DiskUsage {} - } - - PanelWindow { - id: stageWindow - color: "transparent" - implicitWidth: 1200 - implicitHeight: 700 - anchors { - top: true - left: true - } - - Item { - id: stage - anchors.fill: parent - } - } - - function typeName(item) { - return String(item).split("(")[0].replace(/_QMLTYPE_\d+/, "").replace(/_QML_\d+/, ""); - } - - function dump(item, origin) { - const pos = item.mapToItem(origin, 0, 0); - const entry = { - type: typeName(item), - x: Math.round(pos.x * 100) / 100, - y: Math.round(pos.y * 100) / 100, - w: item.width, - h: item.height, - opacity: Math.round(item.opacity * 100) / 100 - }; - if (typeof item.text === "string" && item.font !== undefined) { - entry.text = item.text; - entry.px = item.font.pixelSize; - } - if (item.color !== undefined) - entry.color = String(item.color); - if (item.radius !== undefined) - entry.radius = item.radius; - if (item.border !== undefined && item.border.width !== undefined) - entry.border = [item.border.width, String(item.border.color)]; - entry.children = (item.children || []).filter(child => child.visible).map(child => dump(child, origin)); - return entry; - } - - function menuOf(item) { - for (let i = 0; i < item.data.length; i++) { - const object = item.data[i]; - if (object && object.contextWindow && typeof object.hide === "function") - return object; - } - return null; - } - - function menuWindow(item) { - return menuOf(item)?.contextWindow ?? null; - } - - function snapshot() { - const phaseName = root.phases[root.phase]; - for (const instance of root.instances) { - const item = instance.item; - const menu = menuWindow(item); - check(item !== null && isFinite(item.minTooltipY), phaseName + " " + instance.name + " " + instance.edge + " loaded"); - if (typeof item.openContextMenu === "function") { - check(menu !== null || phaseName === "closed", phaseName + " " + instance.name + " has a menu window"); - check((menu?.visible ?? false) === (phaseName === "open"), phaseName + " " + instance.name + " " + instance.edge + " menu visibility"); - if (phaseName === "open") - check(menu.screen?.name === stageWindow.screen?.name, phaseName + " " + instance.name + " menu on the widget screen"); - } - const record = { - phase: root.phases[root.phase], - name: instance.name, - edge: instance.edge, - minTooltipY: item.minTooltipY, - w: item.width, - h: item.height, - tree: dump(item.visualContent, item.visualContent), - menuVisible: menu ? menu.visible : null, - menuScreen: menu?.screen?.name ?? null, - menu: menu && menu.visible ? dump(menu.contentItem, menu.contentItem) : null - }; - console.log("PARITY " + JSON.stringify(record)); - } - } - - 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) { - for (const testCase of root.cases) { - const item = testCase.component.createObject(stage, { - axis: axis, - barThickness: 48, - widgetThickness: 30, - barSpacing: 4, - barConfig: root.barConfig, - parentScreen: stageWindow.screen, - x: 300, - y: 80 + y - }); - y += 60; - created.push({ - name: testCase.name, - edge: axis.edge, - item: item - }); - } - } - for (const axis of root.axes) { - for (const testCase of root.loadOnly) { - const item = testCase.component.createObject(stage, { - axis: axis, - barThickness: 48, - widgetThickness: 30, - barSpacing: 4, - barConfig: root.barConfig, - parentScreen: stageWindow.screen, - x: 700, - y: 80 + y - }); - y += 60; - created.push({ - name: testCase.name, - edge: axis.edge, - item: item - }); - } - } - root.instances = created; - } - - Timer { - interval: 1500 - running: true - repeat: true - onTriggered: { - root.snapshot(); - root.phase++; - if (root.phase >= root.phases.length) { - root.finish(); - stop(); - Qt.quit(); - return; - } - for (const instance of root.instances) { - if (typeof instance.item.openContextMenu !== "function") - continue; - if (root.phases[root.phase] === "open") - instance.item.openContextMenu(); - else - root.menuOf(instance.item).hide(); - } - } - } -} diff --git a/quickshell/tests/qml/clipboard-preview.qml b/quickshell/tests/qml/clipboard-preview.qml index c84a4e443..07a6896d3 100644 --- a/quickshell/tests/qml/clipboard-preview.qml +++ b/quickshell/tests/qml/clipboard-preview.qml @@ -78,58 +78,75 @@ ShellRoot { } } - Timer { - interval: 500 - running: true - onTriggered: { - 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; - mouseCheck.start(); + 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 { - id: mouseCheck - interval: 500 + interval: 0 + running: true onTriggered: { - const preview = root.findItem(history, item => item.objectName === "previewEntry" && item.visible); - root.check(preview !== null, "image row exposes a mouse preview action"); - if (preview) { - 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"); + try { + root.run(); + } catch (error) { + root.check(false, error.message); } console.log(root.failed ? "FIXTURE_FAIL see above" : "FIXTURE_PASS"); Qt.quit(); diff --git a/quickshell/tests/qml/connected-surface.qml b/quickshell/tests/qml/connected-surface.qml deleted file mode 100644 index 49141da94..000000000 --- a/quickshell/tests/qml/connected-surface.qml +++ /dev/null @@ -1,185 +0,0 @@ -import QtQuick -import Quickshell -import qs.Common -import qs.Services -import qs.Widgets -import qs.Modules.Frame -import qs.Modules.Dock -import qs.Modules.Notifications.Popup -import qs.Modals.Common -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - - property int modalReads: 0 - property int popoutReads: 0 - readonly property var modalDescriptor: ConnectedModeState.surfaceDescriptor("DP-1", "modal") - readonly property var popoutDescriptor: ConnectedModeState.surfaceDescriptor("DP-1", "popout") - onModalDescriptorChanged: modalReads++ - onPopoutDescriptorChanged: popoutReads++ - - function check(condition, message) { - if (!condition) - throw new Error(message); - } - function near(a, b) { - return Math.abs(a - b) < 0.5; - } - function compile(components) { - for (const component of components) - root.check(component.status === Component.Ready, "compile " + component.errorString()); - } - - Component { - id: leaseComponent - ConnectedSurfaceLease { - claimPrefix: "fixture" - slot: "modal" - isCurrentOwner: () => true - } - } - Component { - id: chromeComponent - ConnectedModalChrome { - modalHandle: null - claimPrefix: "fixture" - } - } - Component { - id: frameComponent - FrameWindow {} - } - Component { - id: dockComponent - DockBody {} - } - Component { - id: popoutComponent - DankPopoutHost {} - } - Component { - id: notificationComponent - NotificationPopupManager {} - } - Component { - id: modalComponent - DankModalHost {} - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - } - - Timer { - interval: 300 - running: true - onTriggered: root.run() - } - - function run() { - try { - compile([leaseComponent, chromeComponent, frameComponent, dockComponent, popoutComponent, notificationComponent, modalComponent]); - const S = ConnectedModeState; - const body = { - bodyX: 10, - bodyY: 20, - bodyW: 300, - bodyH: 200, - phase: "open", - visible: true, - presented: true, - barSide: "top" - }; - - check(!S.claimSurface("", "popout", body, "a"), "claim needs a screen"); - check(!S.updateSurface("DP-1", "popout", body, "a"), "update before claim fails"); - check(S.claimSurface("DP-1", "popout", body, "a", true), "popout claim"); - check(S.surfaceRevisions["DP-1"] === 1, "claim bumps the screen revision"); - check(S.hasSurfaceOwner("DP-1", "popout", "a") && S.hasSurfaceDescriptor("DP-1", "popout", "a"), "popout owner and presence"); - check(popoutDescriptor.phase === "open" && near(popoutDescriptor.bodyRect.width, 300), "popout descriptor geometry"); - check(!S.updateSurface("DP-1", "popout", { - bodyX: 99 - }, "stale"), "stale owner update rejected"); - check(!S.releaseSurface("DP-1", "popout", "stale"), "stale owner release rejected"); - - check(S.claimSurface("DP-1", "modal", Object.assign({}, body, { - kind: "launcher", - barSide: "bottom" - }), "m"), "modal claim"); - check(modalDescriptor.kind === "launcher" && modalDescriptor.barSide === "bottom", "modal keeps launcher kind"); - const modalReadsBefore = modalReads; - const popoutReadsBefore = popoutReads; - check(S.setSurfaceMotion("DP-1", "popout", "a", { - animY: -40 - }), "popout anim motion"); - check(S.setSurfaceMotion("DP-1", "popout", "a", { - bodyX: 15, - bodyW: 320 - }), "popout body motion"); - check(near(popoutDescriptor.animationOffset.y, -40) && near(popoutDescriptor.bodyRect.x, 15) && near(popoutDescriptor.bodyRect.width, 320) && near(popoutDescriptor.bodyRect.height, 200), "motion overlays descriptor"); - check(popoutReads > popoutReadsBefore, "popout binding follows motion"); - check(modalReads === modalReadsBefore, "modal binding ignores popout motion"); - check(!S.setSurfaceMotion("DP-1", "popout", "stale", { - animY: 0 - }), "stale owner motion rejected"); - - const revision = popoutDescriptor.revision; - check(S.updateSurface("DP-1", "popout", { - omitEndConnector: true - }, "a"), "partial update"); - check(near(popoutDescriptor.bodyRect.x, 15) && near(popoutDescriptor.animationOffset.y, -40) && popoutDescriptor.omitEndConnector, "partial update keeps motion values"); - check(popoutDescriptor.revision === revision + 1, "revision bumps once per change"); - check(S.updateSurface("DP-1", "popout", { - omitEndConnector: true - }, "a") && popoutDescriptor.revision === revision + 1, "unchanged update keeps revision"); - const screenRevision = S.surfaceRevisions["DP-1"]; - check(S.updateSurface("DP-1", "popout", { - bodyY: 25 - }, "a") && S.surfaceRevisions["DP-1"] === screenRevision, "geometry update leaves the screen revision alone"); - check(S.updateSurface("DP-1", "popout", { - phase: "closing", - visible: false, - presented: true - }, "a") && S.surfaceRevisions["DP-1"] === screenRevision + 1 && popoutDescriptor.phase === "closing", "visibility change bumps the screen revision"); - - check(S.claimSurface("DP-2", "popout", body, "b", true), "exclusive claim on another screen"); - check(!S.hasSurfaceOwner("DP-1", "popout", "a") && S.hasSurfaceOwner("DP-2", "popout", "b"), "exclusive claim releases other screens"); - check(popoutDescriptor.phase === "hidden", "released popout reads hidden"); - check(S.claimSurface("DP-2", "modal", body, "m2"), "second modal"); - check(S.hasSurfaceOwner("DP-1", "modal", "m"), "modal claims stay per screen"); - - check(S.claimSurface("DP-1", S.surfaceSlot("dock", "d1"), body, "dock1") && S.claimSurface("DP-1", S.surfaceSlot("dock", "d2"), body, "dock2"), "two docks on one screen"); - check(S.setSurfaceMotion("DP-1", "dock:d1", "dock1", { - animX: 8 - }), "dock slide"); - const docks = S.surfaceDescriptorsOfKind("DP-1", "dock"); - check(docks.length === 2 && docks.some(d => near(d.animationOffset.x, 8)) && docks.some(d => near(d.animationOffset.x, 0)), "dock descriptors carry their own motion"); - check(S.surfaceDescriptorsOfKind("DP-1", "modal").length === 1, "kind filter excludes other slots"); - - check(S.releaseSurface("DP-1", "modal", "m") && !S.hasSurfaceOwner("DP-1", "modal", "m") && Object.keys(S.surfaceMotion).every(key => key !== "DP-1|modal"), "release drops descriptor and motion"); - check(!S.releaseSurface("DP-1", "modal", "m"), "double release is false"); - - S.savePopoutMotion("b", "DP-2", "top", Qt.rect(1, 2, 3, 4), Qt.vector4d(0, 0, 0, 0)); - check(S.takePopoutMotion("DP-2", "bottom") === null && S.takePopoutMotion("DP-2", "top") === null, "handoff side mismatch consumes it"); - S.savePopoutMotion("b", "DP-2", "top", Qt.rect(1, 2, 3, 4), Qt.vector4d(0, 0, 0, 0)); - check(S.takePopoutMotion("DP-2", "top")?.rect.width === 3, "handoff taken by matching owner"); - S.savePopoutMotion("stale", "DP-2", "top", Qt.rect(1, 2, 3, 4), Qt.vector4d(0, 0, 0, 0)); - check(S.popoutMotionHandoff === null, "stale owner cannot save handoff"); - - check(S.requestDockRetract("m2", "DP-2", "bottom") && S.dockRetractActiveForSide("DP-2", "bottom") && !S.dockRetractActiveForSide("DP-2", "top"), "dock retract request"); - check(S.releaseDockRetract("m2") && !S.dockRetractActiveForSide("DP-2", "bottom"), "dock retract release"); - - S._pruneToLiveScreens(); - check(Object.keys(S.surfaceDescriptors).length === 0 && Object.keys(S.surfaceMotion).length === 0 && Object.keys(S.surfaceRevisions).length === 0, "prune drops surfaces on absent screens"); - - console.log("FIXTURE_PASS"); - } catch (error) { - console.error("FIXTURE_FAIL", error.message); - } - Qt.quit(); - } -} diff --git a/quickshell/tests/qml/context-menus.qml b/quickshell/tests/qml/context-menus.qml deleted file mode 100644 index 0bcee9989..000000000 --- a/quickshell/tests/qml/context-menus.qml +++ /dev/null @@ -1,202 +0,0 @@ -import QtQuick -import QtTest -import Quickshell -import qs.Common -import qs.Services -import qs.Modals.Clipboard -import qs.Modals.DankLauncherV2 -import qs.Modules.Notifications -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - - property var calls: [] - - TestCase { - id: input - when: false - name: "context-menus" - } - - Item { - id: fakeModal - function copyEntry(entry) { - root.calls.push("copy:" + entry.hash); - } - function pasteEntry(entry) { - root.calls.push("paste:" + entry.hash); - } - function pinEntry(entry) { - root.calls.push("pin:" + entry.hash); - } - function unpinEntry(entry) { - root.calls.push("unpin:" + entry.hash); - } - function editEntry(entry) { - root.calls.push("edit:" + entry.hash); - } - function deleteEntry(entry) { - root.calls.push("delete:" + entry.hash); - } - function deletePinnedEntry(entry) { - root.calls.push("deletePinned:" + entry.hash); - } - } - - QtObject { - id: fakeController - function itemExecuted() { - root.calls.push("executed"); - } - function performSearch() { - root.calls.push("search"); - } - function executeAction(item, action) { - root.calls.push("launcherAction:" + action.name); - } - } - - Item { - id: handler - width: 400 - height: 300 - readonly property var parentModal: null - function contextEntryAtScreen(x, y) { - return x < 100 ? { - entry: { - hash: "h2", - isImage: true - }, - x: x, - y: y - } : null; - } - } - - ClipboardContextMenu { - id: clipboardMenu - modal: fakeModal - parentHandler: handler - } - LauncherContextMenu { - id: launcherMenu - controller: fakeController - parentHandler: handler - } - NotificationContextMenu { - id: notificationMenu - appName: "Mail" - onDismissRequested: root.calls.push("dismiss") - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - } - - function check(value, message) { - if (!value) - throw new Error(message); - } - - function texts(menu) { - return menu.menuItems.map(m => m.type === "separator" ? "|" : m.text).join(","); - } - - function settle() { - for (let i = 0; i < 20 && (clipboardMenu.renderActive || launcherMenu.renderActive || notificationMenu.renderActive); i++) - input.wait(50); - } - - Timer { - interval: 500 - running: true - onTriggered: { - try { - clipboardMenu.show(10, 10, { - hash: "h1", - isImage: false, - altMimeType: "text/plain" - }); - input.wait(50); - root.check(clipboardMenu.renderActive && clipboardMenu.openState && clipboardMenu.contextWindow.visible, "clipboard menu opens a window"); - root.check(clipboardMenu.menuItems.some(item => item.action === clipboardMenu.copyEntryAsText) && clipboardMenu.menuItems.some(item => item.action === clipboardMenu.editEntry), "clipboard items: " + root.texts(clipboardMenu)); - root.check(clipboardMenu.effectiveMenuWidth >= clipboardMenu.minMenuWidth, "menu width respects the minimum"); - clipboardMenu.activate(clipboardMenu.menuItems.find(item => item.action === clipboardMenu.copyEntry)); - root.check(root.calls.join() === "copy:h1" && !clipboardMenu.openState, "copy action runs and closes"); - root.settle(); - root.check(!clipboardMenu.renderActive && !clipboardMenu.contextWindow.visible, "window hides after the fade"); - - clipboardMenu.show(10, 10, { - hash: "h3", - isImage: true - }); - root.check(!clipboardMenu.menuItems.some(item => item.action === clipboardMenu.copyEntryAsText || item.action === clipboardMenu.editEntry), "image entry hides text-only actions: " + root.texts(clipboardMenu)); - clipboardMenu.backdropRightClicked(50, 50); - root.check(clipboardMenu.openState && clipboardMenu.entry.hash === "h2", "right click on the backdrop retargets the hit entry"); - clipboardMenu.backdropRightClicked(200, 50); - root.check(!clipboardMenu.openState, "right click on empty space closes"); - root.settle(); - - root.calls = []; - handler.enabled = true; - launcherMenu.show(20, 20, { - type: "note", - data: { - id: "n" - }, - actions: [ - { - name: "Open", - icon: "open_in_new" - }, - { - name: "Trash" - } - ] - }, true); - input.wait(50); - root.check(!handler.enabled, "launcher disables the handler while open"); - root.check(root.texts(launcherMenu) === "Open,Trash", "generic item actions: " + root.texts(launcherMenu)); - root.check(launcherMenu.keyboardNavigation && launcherMenu.selectedMenuIndex === 0, "keyboard open selects the first item"); - launcherMenu.selectNext(); - launcherMenu.selectNext(); - root.check(launcherMenu.selectedMenuIndex === 0, "selection wraps"); - launcherMenu.selectPrevious(); - launcherMenu.activateSelected(); - root.check(root.calls.join() === "launcherAction:Trash" && !launcherMenu.openState, "activate runs the selected launcher action"); - root.settle(); - root.check(handler.enabled, "launcher re-enables the handler after the fade"); - - launcherMenu.show(20, 20, { - type: "app", - isCore: false, - data: { - id: "app.desktop", - name: "App", - execString: "app" - }, - actions: [] - }, false); - root.check(launcherMenu.selectedMenuIndex === -1 && !launcherMenu.keyboardNavigation, "pointer open starts without a selection"); - launcherMenu.hide(); - root.settle(); - - root.calls = []; - notificationMenu.showAt(30, 30, handler.Window?.window?.screen ?? null); - root.check(notificationMenu.openState, "notification menu opens"); - notificationMenu.activate(notificationMenu.menuItems[3]); - root.check(root.calls.join() === "dismiss" && !notificationMenu.openState, "dismiss action signals and closes"); - root.settle(); - - console.log("FIXTURE_PASS context menus: clipboard model and actions, backdrop retarget, launcher keyboard navigation and handler gating, notification actions"); - } catch (error) { - console.error("FIXTURE_FAIL", error.message); - } - Qt.quit(); - } - } -} diff --git a/quickshell/tests/qml/control-center-layout.qml b/quickshell/tests/qml/control-center-layout.qml deleted file mode 100644 index 8b556143d..000000000 --- a/quickshell/tests/qml/control-center-layout.qml +++ /dev/null @@ -1,121 +0,0 @@ -import QtQuick -import Quickshell -import qs.Common -import qs.Modules.ControlCenter.Components -import qs.Modules.ControlCenter.Widgets -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - - property int scenario: 0 - property var before: null - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - } - - FloatingWindow { - visible: true - implicitWidth: 600 - implicitHeight: 240 - - Item { - id: content - anchors.fill: parent - visible: false - - HeaderPane { - id: header - width: 518 - live: false - LayoutMirroring.enabled: root.scenario >= 2 - } - - CcTile { - id: tile - y: 100 - width: 255 - title: "Output" - subtitle: root.scenario % 2 === 0 ? "Selected device" : "" - iconName: "volume_up" - live: false - LayoutMirroring.enabled: root.scenario >= 2 - } - } - } - - function labels(item) { - const result = []; - for (const child of item.children) { - if (typeof child.text === "string" && child.text !== "") - result.push(child); - result.push(...labels(child)); - } - return result; - } - - function geometry(item) { - return labels(item).map(label => { - const position = label.mapToItem(item, 0, 0); - return { - x: position.x, - y: position.y, - width: label.width, - height: label.height - }; - }); - } - - function check(condition, message) { - if (!condition) - throw new Error(message); - } - - function begin() { - content.visible = false; - Qt.callLater(() => { - root.before = { - tile: geometry(tile), - header: geometry(header) - }; - content.visible = true; - verify.restart(); - }); - } - - Timer { - interval: 500 - running: true - onTriggered: root.begin() - } - - Timer { - id: verify - interval: 80 - onTriggered: { - try { - const after = { - tile: root.geometry(tile), - header: root.geometry(header) - }; - for (const name of ["tile", "header"]) { - root.check(after[name].length > 0, name + " has labels"); - root.check(JSON.stringify(root.before[name]) === JSON.stringify(after[name]), name + " label geometry changed on opening in scenario " + root.scenario); - } - root.scenario++; - if (root.scenario < 4) { - root.begin(); - return; - } - console.log("FIXTURE_PASS control center label layout"); - } catch (error) { - console.error("FIXTURE_FAIL", error.message); - } - Qt.quit(); - } - } -} diff --git a/quickshell/tests/qml/control-center-sizes.qml b/quickshell/tests/qml/control-center-sizes.qml index 44dae03a8..0f0e923e4 100644 --- a/quickshell/tests/qml/control-center-sizes.qml +++ b/quickshell/tests/qml/control-center-sizes.qml @@ -120,7 +120,15 @@ ShellRoot { function settle() { wait(0); const surface = scene.Window.window; - check(!isPolishScheduled(surface) || waitForPolish(surface, 5000), "layout settled"); + 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) { @@ -148,13 +156,6 @@ ShellRoot { })); } - function actionBounds(item, container) { - if (item.isSettingsRow) - inside(item, container, "inline action"); - for (const child of item.children || []) - actionBounds(child, container); - } - function swatches(item, container) { let count = 0; if (item.swatchColor !== undefined && typeof item.click === "function") { @@ -175,7 +176,7 @@ ShellRoot { function run() { try { - check(waitForRendering(scene), "window rendered"); + waitFor(() => scene.Window.active, "fixture window takes keyboard focus"); SettingsData.controlCenterWidgets = [ { id: "wifi", @@ -253,31 +254,6 @@ ShellRoot { grid.destroy(); wait(0); const tile = tileComponent.createObject(scene); - for (const fontScale of [1, 1.5]) { - Theme.fontScale = fontScale; - for (const rtl of [false, true]) { - tile.LayoutMirroring.enabled = rtl; - for (const [columns, rows] of [[1, 1], [2, 1], [1, 2], [2, 2], [3, 2], [4, 3], [10, 12]]) { - size(tile, columns, rows); - settle(); - for (const name of ["tileIconBox", "tileTitle", "tileSubtitle", "tileExpandedContent"]) - inside(named(tile, name), tile, name + " " + columns + "x" + rows + " font " + fontScale); - actionBounds(tile, tile); - } - } - } - for (const scale of [0.5, 1.5]) { - SettingsData.controlCenterIconScale = scale; - for (const dimensions of [[1, 2], [2, 1], [2, 2], [2, 3]]) { - size(tile, dimensions[0], dimensions[1]); - settle(); - inside(named(tile, "tileIconBox"), tile, "scaled icon"); - inside(named(tile, "tileTitle"), tile, "scaled title"); - } - } - SettingsData.controlCenterIconScale = 1; - Theme.fontScale = 1; - tile.LayoutMirroring.enabled = false; size(tile, 4, 3); settle(); check(tile.expanded && tile.expandedItem !== null, "large tile exposes inline actions"); @@ -326,28 +302,12 @@ ShellRoot { } }); check(widget !== null, id + " created"); - if (widget.slider) { + if (widget.slider) check(WidgetUtils.clampSize({ id, w: 1, h: 3 }, 4).h === 3, id + " allows vertical resizing"); - SettingsData.controlCenterIconScale = 1.5; - } - for (const dimensions of [[1, 1], [1, 2], [2, 2], [3, 2], [4, 3], [10, 12]]) { - const supported = WidgetUtils.clampSize({ - id, - w: dimensions[0], - h: dimensions[1] - }, 10, 12); - size(widget, supported.w, supported.h); - settle(); - if (widget.slider) { - check(widget.slider.width >= Theme.minimumTouchTargetSize, id + " retains a usable track at " + dimensions); - inside(named(widget, "sliderTrackArea"), widget, id + " track"); - } - } - SettingsData.controlCenterIconScale = 1; widget.destroy(); wait(0); } diff --git a/quickshell/tests/qml/dash-navigation.qml b/quickshell/tests/qml/dash-navigation.qml deleted file mode 100644 index c5d6710dc..000000000 --- a/quickshell/tests/qml/dash-navigation.qml +++ /dev/null @@ -1,507 +0,0 @@ -import QtQuick -import QtTest -import Quickshell -import qs.Common -import qs.Services -import qs.Widgets -import qs.Modules.DankDash -import qs.Modules.DankIsland -import qs.Modules.DankIsland.Activities -import qs.Modules.Settings as Settings -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - - property var dash: null - property bool captured: false - property int customActions: 0 - readonly property string captureDirectory: Quickshell.env("DMS_FIXTURE_CAPTURE_DIR") - - Component { - id: dashComponent - DankDashPopout {} - } - - Component { - id: settingsComponent - Settings.DankDashTab {} - } - - QtObject { - id: deferredDashLoader - property bool active: false - } - - Component { - id: pluginComponent - Item { - implicitHeight: DashMetrics.tabMinHeight - property var menuActions: [ - { - label: "Test action", - iconName: "refresh", - action: () => root.customActions++ - }, - { - label: "Hidden action", - visible: false - }, - { - label: "Disabled action", - enabled: false, - action: () => root.customActions++ - } - ] - } - } - - IslandController { - id: island - } - - Component { - id: islandHome - HomeExpanded { - controller: island - } - } - - FloatingWindow { - id: window - visible: true - implicitWidth: 1100 - implicitHeight: 800 - color: Theme.surface - - DankNavigationBar { - id: navigation - width: 800 - height: implicitHeight - currentIndex: 0 - model: [ - { - icon: "dashboard", - text: "Overview" - }, - { - icon: "music_note", - text: "Media" - } - ] - onActivated: index => currentIndex = index - } - } - - 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 settle() { - wait(0); - const window = root.dash?.contentLoader.item?.Window.window; - if (window) - check(!isPolishScheduled(window) || waitForPolish(window, 3000), "dashboard layout settled"); - } - - function open(tab) { - root.dash.requestTab(tab); - root.dash.dashVisible = true; - tryVerify(() => root.dash.shouldBeVisible && root.dash.contentLoader.item?.ready, 3000); - settle(); - } - - function pages() { - return find(root.dash.contentLoader.item, item => item.currentHost !== undefined); - } - - function bar() { - return find(root.dash.contentLoader.item, item => typeof item.moveSelection === "function"); - } - - function actionBounds(...buttons) { - return JSON.stringify(buttons.map(button => { - const point = button.mapToItem(root.dash.contentLoader.item, 0, 0); - return [point.x, point.y, button.width, button.height]; - })); - } - - function capture(name) { - if (!root.captureDirectory) - return; - tryVerify(() => Math.abs(root.dash.contentLoader.item.width - root.dash.popupWidth) < 1, 3000, "rendered panel width matches requested width"); - tryVerify(() => Math.abs(root.dash.contentLoader.item.height - root.dash.popupHeight) < 1, 3000, "rendered panel height matches requested height"); - root.captured = false; - root.dash.contentLoader.item.grabToImage(result => { - check(result.saveToFile(root.captureDirectory + "/" + name + ".png"), "saved " + name); - root.captured = true; - }); - tryCompare(root, "captured", true, 3000); - } - - function run() { - try { - SettingsData.weatherEnabled = true; - SettingsData.setDashTabEnabled("overview", false); - check(!DashRegistry.visibleTabIds.includes("overview"), "Overview can be hidden"); - check(DashRegistry.hasTab("overview"), "hidden Overview remains available"); - check(DashRegistry.defaultTabId !== "overview", "default follows enabled destinations"); - root.dash = dashComponent.createObject(root); - root.dash.triggerScreen = Quickshell.screens[0]; - root.dash.setTriggerPosition(640, 0, 40, "center", Quickshell.screens[0]); - open(0); - check(root.dash.activeTabId === DashRegistry.visibleTabIds[0], "numeric default uses first enabled destination"); - open("overview"); - check(root.dash.detailTabId === "overview" && root.dash.showBack, "hidden Overview has a return destination"); - root.dash.closeDetail(); - check(root.dash.activeTabId === DashRegistry.defaultTabId, "Back returns to enabled page"); - - for (const id of DashRegistry.tabIds) - SettingsData.setDashTabEnabled(id, false); - open(0); - check(root.dash.activeTabId === "" && !root.dash.showTabs, "zero tabs opens empty dashboard"); - check(pages().currentItem === null && pages().focusTarget !== null, "empty state releases page and offers settings"); - check(Math.abs(root.dash.contentLoader.item.height - root.dash.popupHeight) < 1, "zero-motion empty state snaps rendered height"); - capture("empty"); - PopoutService.dankDashPopout = root.dash; - const settingsPage = settingsComponent.createObject(window.contentItem, { - width: 800, - height: 600, - visible: false - }); - for (const id of ["overview", "media", "weather"]) { - const row = find(settingsPage, item => item.modelData?.id === id && item.available !== undefined); - const editButton = find(row, item => item.objectName === "editDashTab" && typeof item.clicked === "function"); - check(!!editButton, "Settings exposes editing for hidden " + id); - editButton.clicked(); - check(root.dash.editMode && root.dash.activeTabId === id && !root.dash.showTabs, "Settings edits hidden " + id + " with zero tabs"); - } - settingsPage.destroy(); - root.dash.dashVisible = false; - root.dash.instantClose(); - PopoutService.dankDashPopout = null; - PopoutService.dankDashPopoutLoader = deferredDashLoader; - PopoutService.openDankDashEditor("media", Quickshell.screens[0]); - check(deferredDashLoader.active && PopoutService._dankDashWantsEdit, "cold editor request activates loader"); - PopoutService.dankDashPopout = root.dash; - PopoutService._onDankDashPopoutLoaded(); - check(root.dash.editMode && root.dash.activeTabId === "media" && !PopoutService._dankDashWantsEdit, "cold editor request survives loading"); - root.dash.editMode = false; - for (const id of ["overview", "media", "weather"]) { - open(id); - check(root.dash.activeTabId === id && !root.dash.showTabs && !root.dash.showBack, "standalone hidden " + id); - } - capture("standalone-weather"); - root.dash.closeDetail(); - check(!root.dash.dashVisible, "standalone Back/Escape closes instead of showing empty state"); - - const home = islandHome.createObject(window.contentItem, { - width: 800, - height: 600 - }); - check(home.entryId === "overview", "Island home keeps Overview identity with no enabled tabs"); - tryVerify(() => !!home.tab, 3000); - const islandActions = find(home, item => item.overlayParent !== undefined && item.entryId === "overview"); - check(!!islandActions && islandActions.hasWidgets, "Island uses shared page actions"); - parent = window.contentItem; - island.expanded = true; - home.focusHeader(false); - home.editMode = true; - settle(); - check(home.Window.window.activeFocusItem === home, "Island edit entry clears title focus"); - islandActions.optionsRequested(); - const islandOptions = find(home, item => typeof item.presentFor === "function" && item.entryId === "overview"); - check(islandOptions.shown, "Island Options opened before Tab"); - keyClick(Qt.Key_Tab); - check(islandOptions.shown && !islandActions.focusTargets.some(item => item.activeFocus), "Island Options keeps Tab out of edit controls: shown=" + islandOptions.shown + ", focus=" + home.Window.window.activeFocusItem); - islandOptions.dismiss(); - island.expanded = false; - home.destroy(); - - SettingsData.setDashTabEnabled("media", true); - open(0); - check(root.dash.activeTabId === "media" && !root.dash.showTabs, "one destination has no navigation"); - capture("single"); - SettingsData.setDashTabEnabled("overview", true); - SettingsData.setDashTabOrder(["overview", "media"]); - open(0); - for (const edge of [0, 1, 2, 3, 4, 5, 6, 7]) { - SettingsData.animationDuration = edge % 2 === 0 ? 0 : 200; - root.dash.setBarContext(edge, 0); - settle(); - const nav = bar(); - const page = pages(); - const vertical = edge % 4 >= 2; - check(root.dash.showTabs && root.dash.verticalNavigation === vertical, "orientation follows edge " + edge); - check(nav.orientation === (vertical ? Qt.Vertical : Qt.Horizontal), "navigation orientation " + edge); - check(Math.abs(root.dash.contentLoader.item.width - root.dash.popupWidth) < 1, "zero-motion navigation snaps rendered width " + edge); - const point = nav.mapToItem(root.dash.contentLoader.item, 0, 0); - const contentPoint = page.mapToItem(root.dash.contentLoader.item, 0, 0); - const expectedEdge = [0, 1, 3, 2][edge % 4]; - check(root.dash.navigationEdge === expectedEdge, "side defaults face away from triggering bar " + edge); - switch (expectedEdge) { - case 0: - check(point.y + nav.height <= contentPoint.y, "top navigation before content"); - break; - case 1: - check(point.y >= contentPoint.y + page.height, "bottom navigation after content"); - break; - case 2: - check(point.x + nav.width <= contentPoint.x, "left rail before content"); - break; - case 3: - check(point.x >= contentPoint.x + page.width, "right rail after content"); - break; - } - const size = [root.dash.popupWidth, root.dash.popupHeight]; - root.dash.editMode = true; - settle(); - check(root.dash.popupWidth === size[0] && root.dash.popupHeight === size[1], "edit preserves panel dimensions " + edge); - root.dash.editMode = false; - check(Math.abs(page.width - (root.dash.popupWidth - root.dash.navigationWidth - DashMetrics.contentPadding * 2)) < 1, "content width excludes navigation " + edge); - if (edge < 4) - capture("edge-" + edge); - } - SettingsData.animationDuration = 0; - for (const [position, expectedEdge] of [["left", 2], ["right", 3], ["bottom", 1], ["center", 0]]) { - SettingsData.set("dashTabPosition", position); - root.dash.setBarContext(0, 0); - settle(); - check(root.dash.navigationEdge === expectedEdge && root.dash.verticalNavigation === (expectedEdge >= 2), "position override " + position); - root.dash.setBarContext(3, 0); - check(root.dash.navigationEdge === expectedEdge, "override ignores bar position " + position); - } - SettingsData.set("dashTabPosition", "auto"); - root.dash.setBarContext(0, 0); - SettingsData.resetDashTabs(); - settle(); - capture("default-tabs"); - const embeddedEdit = find(bar(), item => item.revealed !== undefined && item.visible); - mouseMove(embeddedEdit, embeddedEdit.width / 2, embeddedEdit.height / 2); - tryVerify(() => embeddedEdit.revealed, 1000); - capture("hover-edit"); - mouseClick(embeddedEdit, embeddedEdit.width / 2, embeddedEdit.height / 2); - check(root.dash.editMode, "selected icon pencil opens editing"); - root.dash.editMode = false; - SessionData.locale = "ar"; - root.dash.setBarContext(2, 0); - settle(); - check(I18n.isRtl && root.dash.verticalNavigation, "RTL keeps physical right rail"); - capture("rtl-right"); - SessionData.locale = "en"; - settle(); - const verticalEdit = find(bar(), item => item.revealed !== undefined && item.visible); - mouseMove(verticalEdit, verticalEdit.width / 2, verticalEdit.height / 2); - tryVerify(() => verticalEdit.revealed, 1000); - capture("hover-vertical"); - root.dash.setBarContext(0, 0); - settle(); - root.dash.requestTab("overview"); - root.dash.editMode = true; - settle(); - const clear = find(root.dash.contentLoader.item, item => item.actionId === "clear"); - const reset = find(root.dash.contentLoader.item, item => item.actionId === "reset"); - const savedCards = JSON.stringify(SettingsData.dashCards); - const horizontalBounds = actionBounds(clear, reset); - clear.clicked(); - settle(); - check(clear.armed && JSON.stringify(SettingsData.dashCards) === savedCards, "Clear needs confirmation"); - check(actionBounds(clear, reset) === horizontalBounds, "arming Clear does not move or resize either horizontal action"); - capture("confirm-horizontal"); - reset.clicked(); - settle(); - check(actionBounds(clear, reset) === horizontalBounds && !clear.armed && reset.armed, "arming Reset disarms Clear without moving either action: " + horizontalBounds + " vs " + actionBounds(clear, reset)); - clear.clicked(); - clear.clicked(); - check(SettingsData.dashCards.length === 0, "confirmed Clear removes widgets"); - reset.clicked(); - check(SettingsData.dashCards.length === 0, "Reset needs confirmation"); - reset.clicked(); - check(SettingsData.dashCards.length > 0, "confirmed Reset restores widgets"); - clear.clicked(); - root.dash.editMode = false; - root.dash.editMode = true; - check(!clear.armed, "leaving editing cancels confirmation"); - settle(); - const editActions = find(root.dash.contentLoader.item, item => item.overlayParent !== undefined); - check(editActions.focusTargets.every(item => !item.visualFocus), "programmatic edit entry has no persistent focus ring"); - check(root.dash.contentLoader.item.Window.window.activeFocusItem === root.dash.contentLoader.item, "edit entry keeps neutral focus"); - capture("edit-horizontal"); - const editViewport = find(editActions, item => item.contentX !== undefined && item.wheelEnabled !== undefined); - editActions.width = 280; - settle(); - editActions.focusTargets[editActions.focusTargets.length - 1].forceActiveFocus(Qt.TabFocusReason); - check(editViewport.contentX > 0, "overflowed Finish is revealed on keyboard focus"); - editActions.width = Qt.binding(() => Math.min(editActions.parent.width, editActions.implicitWidth)); - root.dash.setBarContext(2, 0); - settle(); - capture("edit-vertical"); - const verticalBounds = actionBounds(clear, reset); - reset.clicked(); - settle(); - check(actionBounds(clear, reset) === verticalBounds && reset.armed, "arming Reset does not move or resize either vertical action"); - capture("confirm-vertical"); - root.dash.editMode = false; - root.dash.setBarContext(0, 0); - settle(); - const integratedNav = bar(); - integratedNav.forceActiveFocus(Qt.TabFocusReason); - const start = root.dash.currentTabId; - keyClick(Qt.Key_Right); - check(integratedNav.activeFocus && root.dash.currentTabId !== start, "first arrow preserves navigation focus"); - const next = root.dash.currentTabId; - keyClick(Qt.Key_Right); - check(integratedNav.activeFocus && root.dash.currentTabId !== next, "second arrow continues navigation"); - root.dash.editMode = true; - settle(); - check(root.dash.contentLoader.item.Window.window.activeFocusItem === root.dash.contentLoader.item, "edit entry releases hidden navigation focus"); - root.dash.requestTab("weather"); - root.dash.editMode = true; - settle(); - const weatherActions = find(root.dash.contentLoader.item, item => item.overlayParent !== undefined); - keyClick(Qt.Key_Tab); - check(weatherActions.focusTargets[0].activeFocus, "Weather edit Tab enters first action"); - root.dash.contentLoader.item.focusInitial(); - keyClick(Qt.Key_Backtab); - check(weatherActions.focusTargets[weatherActions.focusTargets.length - 1].activeFocus, "Weather edit Backtab enters last action"); - root.dash.editMode = false; - - PluginService.pluginDashComponents = { - navigation_test: pluginComponent - }; - PluginService.loadedPlugins = { - navigation_test: { - name: "Test", - surfaces: ["dash"] - } - }; - open("plugin_navigation_test"); - const actions = find(root.dash.contentLoader.item, item => item.overlayParent !== undefined); - check(!actions.visible, "no separate action strip in normal navigation"); - const nav = bar(); - check(nav.editable, "selected destination supports embedded editing"); - nav.editRequested(nav.currentIndex); - settle(); - check(root.dash.editMode && actions.visible, "embedded editing reveals page controls"); - check(actions.implicitWidth <= actions.parent.width, "edit controls fit the header without scrolling: " + actions.implicitWidth + " > " + actions.parent.width); - check(actions.hasCustomActions, "plugin action menu is available"); - const menu = find(root.dash.contentLoader.item, item => item.visibleItems !== undefined && item.parent === root.dash.contentLoader.item); - check(menu.visibleItems.length === 2, "hidden custom action omitted"); - menu._activate(1); - check(root.customActions === 0, "disabled custom action cannot activate"); - menu._activate(0); - check(root.customActions === 1, "custom callback preserved"); - const lastAction = actions.focusTargets[actions.focusTargets.length - 1]; - lastAction.forceActiveFocus(Qt.TabFocusReason); - keyClick(Qt.Key_Tab); - check(root.dash.editMode && root.dash.activeTabId === "plugin_navigation_test", "Tab from Finish stays in editing"); - const editFocusTargets = actions.focusTargets; - root.dash.editMode = false; - settle(); - check(editFocusTargets.every(item => !item.activeFocus), "leaving plugin editing releases hidden action focus"); - SettingsData.setDashTabEnabled("plugin_navigation_test", false); - open("plugin_navigation_test"); - check(root.dash.detailTabId === "plugin_navigation_test", "hidden plugin opens as detail"); - PluginService.loadedPlugins = {}; - settle(); - check(root.dash.detailTabId === "" && root.dash.activeTabId === DashRegistry.defaultTabId, "removed plugin falls back cleanly"); - - root.dash.dashVisible = false; - root.dash.instantClose(); - settle(); - navigation.width = 800; - navigation.orientation = Qt.Horizontal; - settle(); - navigation.evenlySpaced = false; - settle(); - navigation.revealCurrent(); - navigation.evenlySpaced = true; - tester.parent = window.contentItem; - navigation.forceActiveFocus(Qt.TabFocusReason); - keyClick(Qt.Key_Right); - check(navigation.currentIndex === 1, "horizontal arrow selects next destination"); - navigation.orientation = Qt.Vertical; - navigation.width = Theme.navigationRailWidth; - navigation.height = 300; - settle(); - navigation.revealCurrent(); - keyClick(Qt.Key_Up); - check(navigation.currentIndex === 0, "vertical arrow selects previous destination"); - navigation.model = Array.from({ - length: 10 - }, (_, i) => ({ - icon: "extension", - text: "Destination " + i - })); - navigation.currentIndex = 9; - settle(); - const scroll = find(navigation, item => item.contentY !== undefined && item.flickableDirection !== undefined); - check(scroll.contentY > 0, "overflow selection scrolls into view"); - navigation.model = [ - { - icon: "extension", - text: "A long translated destination label" - } - ]; - navigation.currentIndex = 0; - settle(); - const longDestination = find(navigation, item => item.index === 0 && item.selected !== undefined); - check(longDestination.width <= navigation.width && longDestination.height > 0, "long labels keep destinations within the rail"); - navigation.orientation = Qt.Horizontal; - navigation.width = 240; - navigation.model = Array.from({ - length: 10 - }, (_, i) => ({ - icon: "extension", - text: "Destination " + i - })); - navigation.currentIndex = 9; - settle(); - check(scroll.contentX > 0, "horizontal overflow selection scrolls into view"); - SessionData.locale = "ar"; - navigation.currentIndex = 0; - settle(); - navigation.forceActiveFocus(Qt.OtherFocusReason); - keyClick(Qt.Key_Left); - check(navigation.currentIndex === 1, "RTL Left selects the next logical destination"); - SessionData.locale = "en"; - navigation.orientation = Qt.Vertical; - navigation.width = Theme.navigationRailWidth; - settle(); - open("overview"); - console.log("FIXTURE_PASS dashboard navigation and actions"); - } 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 = 0; - SettingsData.reduceMotion = true; - Qt.callLater(tester.run); - } -} diff --git a/quickshell/tests/qml/dash-notifications-keys.qml b/quickshell/tests/qml/dash-notifications-keys.qml deleted file mode 100644 index 405b72fd6..000000000 --- a/quickshell/tests/qml/dash-notifications-keys.qml +++ /dev/null @@ -1,209 +0,0 @@ -import QtQuick -import QtTest -import Quickshell -import Quickshell.Io -import qs.Common -import qs.Services -import qs.Modules.DankDash -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - - property var popout: null - property bool failed: false - property int step: 0 - - function check(condition, label) { - if (condition) - return; - failed = true; - console.log("FIXTURE_FAIL " + label); - } - - function typeName(item) { - return String(item).split("(")[0].replace(/_QMLTYPE_\d+/, "").replace(/_QML_\d+/, ""); - } - - function findBy(item, pred, depth) { - if (!item || depth > 30) - return null; - if (pred(item)) - return item; - for (const child of item.children || []) { - const found = findBy(child, pred, depth + 1); - if (found) - return found; - } - return null; - } - - function content() { - return root.popout.contentLoader?.item ?? null; - } - - function grid() { - return findBy(content(), item => typeName(item) === "DashCardGrid", 0); - } - - function list() { - return findBy(content(), item => typeName(item) === "KeyboardNavigatedNotificationList", 0); - } - - function focusName() { - return typeName(content().Window.window?.activeFocusItem); - } - - function state(label) { - const l = list(); - const c = l?.keyboardController ?? null; - const s = { - label: label, - focus: focusName(), - notifications: NotificationService.notifications.length, - listKeyboardActive: l?.keyboardActive ?? null, - focusAllowed: l?.focusAllowed ?? null, - navActive: c?.keyboardNavigationActive ?? null, - selected: c?.selectedFlatIndex ?? null, - contentY: l ? Math.round(l.contentY) : null - }; - console.log("PARITY " + JSON.stringify(s)); - return s; - } - - TestCase { - id: input - when: false - name: "dash-notifications-keys" - } - - Component { - id: dashComp - DankDashPopout {} - } - - Process { - id: notify - command: ["sh", "-c", "for i in 1 2 3 4; do notify-send -a app$i \"Note $i\" \"Body of note $i\" || echo \"notify-send $i exit $?\"; done; echo bus=$DBUS_SESSION_BUS_ADDRESS"] - stdout: StdioCollector { - onStreamFinished: console.log("NOTIFY stdout: " + text.trim()) - } - stderr: StdioCollector { - onStreamFinished: console.log("NOTIFY stderr: " + text.trim()) - } - onExited: (code, status) => console.log("NOTIFY exit " + code) - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - SettingsData.barConfigs = [ - { - id: "main", - enabled: true, - visible: true, - position: 0, - spacing: 4, - innerPadding: 4, - leftWidgets: [], - centerWidgets: [], - rightWidgets: [] - } - ]; - root.popout = dashComp.createObject(root); - root.popout.screen = Quickshell.screens[0]; - root.popout.triggerX = 640; - root.popout.triggerY = 0; - root.popout.triggerWidth = 40; - } - - Timer { - interval: 1200 - running: true - repeat: true - onTriggered: { - switch (root.step++) { - case 0: - return; - case 1: - notify.running = true; - return; - case 2: - if (NotificationService.notifications.length < 3) - notify.running = true; - root.popout.requestTab("overview"); - root.popout.dashVisible = true; - return; - case 3: - { - const s = root.state("open"); - check(s.notifications >= 3, "notifications arrived: " + s.notifications); - const g = root.grid(); - const slot = g?.slotFor("notifications") ?? null; - check(!!g && !!slot, "overview grid and notifications slot present"); - if (!slot) - return; - g.focusSlot(slot, Qt.TabFocusReason); - return; - } - case 4: - { - const s = root.state("card-focused"); - check(s.focus === "NotificationsTab", "card focus lands on NotificationsTab, got " + s.focus); - input.keyClick(Qt.Key_Down); - return; - } - case 5: - { - const s = root.state("after-down-1"); - check(s.navActive === true && s.listKeyboardActive === true && s.focusAllowed === true, "first Down starts keyboard navigation"); - check(s.selected === 0, "first Down selects the first notification"); - input.keyClick(Qt.Key_Down); - return; - } - case 6: - { - const s = root.state("after-down-2"); - check(s.selected === 1, "second Down selects the second notification, got " + s.selected); - check(s.focus === "NotificationsTab", "focus stays on NotificationsTab, got " + s.focus); - root.popout.requestTab("notifications"); - return; - } - case 7: - { - const s = root.state("tab-open"); - check(root.popout.activeTabId === "notifications", "notifications tab active"); - input.keyClick(Qt.Key_Down); - return; - } - case 8: - { - const s = root.state("tab-down-1"); - input.keyClick(Qt.Key_Down); - return; - } - case 9: - { - const s = root.state("tab-down-2"); - input.keyClick(Qt.Key_Down); - return; - } - case 10: - { - const s = root.state("tab-down-3"); - check(s.navActive === true && s.listKeyboardActive === true && s.focusAllowed === true, "tab: keyboard navigation active after Down presses"); - check(s.selected >= 1, "tab: selection advanced, got " + s.selected); - check(s.focus === "NotificationsTab", "tab: focus on NotificationsTab, got " + s.focus); - root.popout.dashVisible = false; - return; - } - case 11: - console.log(root.failed ? "FIXTURE_FAIL see above" : "FIXTURE_PASS"); - stop(); - Qt.quit(); - } - } - } -} diff --git a/quickshell/tests/qml/dash-open-states.qml b/quickshell/tests/qml/dash-open-states.qml deleted file mode 100644 index 044e716e6..000000000 --- a/quickshell/tests/qml/dash-open-states.qml +++ /dev/null @@ -1,180 +0,0 @@ -import QtQuick -import Quickshell -import qs.Common -import qs.Services -import qs.Modules.DankDash -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - - property var popout: null - property bool failed: false - property int step: 0 - - function typeName(item) { - return String(item).split("(")[0].replace(/_QMLTYPE_\d+/, "").replace(/_QML_\d+/, ""); - } - - function findPages(item, depth) { - if (!item || depth > 20) - return null; - if (item.currentHost !== undefined && item.settledHeight !== undefined) - return item; - for (const child of item.children || []) { - const found = findPages(child, depth + 1); - if (found) - return found; - } - return null; - } - - function count(item, type, depth) { - if (!item || depth > 20) - return 0; - let total = typeName(item) === type && item.visible && item.height > 0 ? 1 : 0; - for (const child of item.children || []) - total += count(child, type, depth + 1); - return total; - } - - function check(condition, label) { - if (condition) - return; - failed = true; - console.log("FIXTURE_FAIL " + label); - } - - function assertOverviewShown(label) { - const content = popout.contentLoader?.item ?? null; - const pages = findPages(content, 0); - const host = pages?.currentHost ?? null; - const slots = count(host, "DashCardSlot", 0); - console.log("PARITY " + JSON.stringify({ - label: label, - visible: popout.shouldBeVisible, - tab: popout.currentTabId, - hostOpacity: host?.opacity, - hostVisible: host?.visible, - hasItem: !!host?.item, - hostHeight: host?.height, - contentHeight: pages?.contentHeight, - slots: slots, - popupHeight: popout.popupHeight - })); - check(popout.shouldBeVisible && popout.currentTabId === "overview", label + ": overview open"); - check(host && host.visible && host.opacity === 1 && !!host.item, label + ": overview host shown"); - check(slots > 0 && host && host.height > DashMetrics.tabMinHeight / 2, label + ": overview cards laid out"); - } - - Component { - id: dashComponent - DankDashPopout {} - } - - function createAndOpenSync() { - root.popout = dashComponent.createObject(root); - root.popout.setBarContext(0, 0); - root.popout.triggerScreen = Quickshell.screens[0]; - root.popout.setTriggerPosition(640, 0, 40, "center", Quickshell.screens[0]); - root.popout.requestTab("overview"); - PopoutManager.requestPopout(root.popout, undefined, "main-center-overview"); - } - - Timer { - id: sequencer - interval: 1200 - onTriggered: { - switch (root.step++) { - case 0: - assertOverviewShown("cold open from a bar widget"); - root.popout.dashVisible = false; - interval = 40; - break; - case 1: - root.popout.requestTab(0); - root.popout.dashVisible = true; - interval = 1200; - break; - case 2: - assertOverviewShown("reopen right after close"); - root.popout.requestTab("media"); - interval = 30; - break; - case 3: - root.popout.requestTab(0); - interval = 1200; - break; - case 4: - assertOverviewShown("tab switched away and back mid fade"); - SettingsData.setDashTabEnabled("media", false); - interval = 60; - break; - case 5: - SettingsData.setDashTabEnabled("media", true); - interval = 1200; - break; - case 6: - assertOverviewShown("tab list rebuilt while open"); - root.popout.dashVisible = false; - interval = 60; - break; - case 7: - root.popout.requestTab(0); - root.popout.dashVisible = true; - interval = 60; - break; - case 8: - root.popout.dashVisible = false; - interval = 200; - break; - case 9: - root.popout.requestTab(0); - root.popout.dashVisible = true; - interval = 1200; - break; - case 10: - assertOverviewShown("reopen after closing during the open fade"); - root.popout.dashVisible = false; - root.popout.destroy(); - root.popout = null; - interval = 300; - break; - case 11: - createAndOpenSync(); - SettingsData.setDashTabEnabled("media", false); - interval = 1200; - break; - case 12: - assertOverviewShown("tab list rebuilt during cold open"); - console.log(root.failed ? "FIXTURE_FAIL see above" : "FIXTURE_PASS"); - stop(); - Qt.quit(); - return; - } - restart(); - } - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - SettingsData.barConfigs = [ - { - id: "main", - enabled: true, - visible: true, - position: 0, - spacing: 4, - innerPadding: 4, - leftWidgets: [], - centerWidgets: [], - rightWidgets: [] - } - ]; - createAndOpenSync(); - sequencer.start(); - } -} diff --git a/quickshell/tests/qml/dash-panel-resize.qml b/quickshell/tests/qml/dash-panel-resize.qml deleted file mode 100644 index bba69ac40..000000000 --- a/quickshell/tests/qml/dash-panel-resize.qml +++ /dev/null @@ -1,403 +0,0 @@ -import QtQuick -import Quickshell -import qs.Common -import qs.Services -import qs.Modules.DankDash -import qs.Modules.ControlCenter -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - - property var popout: null - property var cc: null - property bool failed: false - property int step: 0 - property real baseHeight: 0 - property real closingHeight: 0 - property int baseRows: 0 - property int requestedRows: 0 - property real wallHeight: 0 - property real wallHeightOut: 0 - readonly property var screen: Quickshell.screens[0] - readonly property real rowStep: DashMetrics.gridRowUnit + DashMetrics.gridGap - - function check(condition, label) { - if (condition) - return; - failed = true; - console.log("FIXTURE_FAIL " + label); - } - - function widthFor(columns) { - return DashMetrics.widthFor(SettingsData.showWeekNumber, root.screen.width, columns); - } - - function content() { - return root.popout?.contentLoader?.item ?? null; - } - - function findGrid(item) { - if (!item) - return null; - if (typeof item.previewPanelColumns === "function") - return item; - for (const child of item.children) { - const found = findGrid(child); - if (found) - return found; - } - return null; - } - - function edgeGain(host, w0, w1) { - return host.alignedXFor(w1) + w1 - host.alignedXFor(w0) - w0; - } - - function columnGain() { - return edgeGain(root.popout, widthFor(DashMetrics.defaultGridColumns), widthFor(DashMetrics.defaultGridColumns + 1)); - } - - function ccGain() { - return edgeGain(root.cc, CcMetrics.sheetWidthFor(CcMetrics.defaultColumns), CcMetrics.sheetWidthFor(CcMetrics.defaultColumns + 1)); - } - - property real dragOriginX: 0 - property real dragOriginY: 0 - - function beginDrag(host, target, signX) { - root.dragOriginX = host.renderedAlignedX; - root.dragOriginY = host.renderedAlignedY; - target.panelResizer.begin(0, 0, signX ?? 1); - } - - function dragTo(host, target, dx, dy) { - target.panelResizer.move(root.dragOriginX + dx - host.renderedAlignedX, root.dragOriginY + dy - host.renderedAlignedY); - } - - function snapped(host) { - return host.resizing && host.renderedAlignedX === host.alignedX; - } - - Component { - id: dashComponent - DankDashPopout {} - } - - Component { - id: ccComponent - ControlCenterPopout {} - } - - function openDash(tab) { - root.popout = dashComponent.createObject(root); - root.popout.setBarContext(0, 0); - root.popout.triggerScreen = root.screen; - root.popout.setTriggerPosition(640, 0, 40, "center", root.screen); - root.popout.requestTab(tab); - PopoutManager.requestPopout(root.popout, undefined, "main-center-overview"); - root.popout.dashVisible = true; - } - - function openControlCenter() { - root.cc = ccComponent.createObject(root); - root.cc.setBarContext(0, 0); - root.cc.triggerScreen = root.screen; - root.cc.setTriggerPosition(640, 0, 40, "center", root.screen); - PopoutManager.requestPopout(root.cc, undefined, "main-center-cc"); - root.cc.open(); - } - - Timer { - id: sequencer - interval: 1500 - onTriggered: { - const c = content(); - switch (root.step++) { - case 0: - check(root.popout.shouldBeVisible && c, "dash open"); - check(root.popout.popupWidth === widthFor(DashMetrics.defaultGridColumns), "default width uses the default columns"); - root.baseHeight = root.popout.popupHeight; - root.baseRows = c.panelRows; - check(root.baseRows >= DashMetrics.minimumTabRows && DashMetrics.storedPanelRows("overview") === 0, "unset rows fit the cards: " + root.baseRows); - check(!root.popout.contentWindow.anchors.right, "surface hugs the body outside edit mode"); - root.popout.editMode = true; - check(root.popout.popupWidth === widthFor(DashMetrics.defaultGridColumns), "edit mode keeps the panel width"); - check(!root.popout.contentWindow.anchors.right && root.popout.contentWindow.implicitWidth >= widthFor(root.popout.columnCap) + PopoutMetrics.editOverflow * 4, "edit mode holds a bounded surface wide enough for the column cap plus handle padding"); - beginDrag(root.popout, c); - dragTo(root.popout, c, 2 * columnGain(), 0); - check(DashMetrics.panelPreview?.columns === 8, "preview snaps two columns wider: " + JSON.stringify(DashMetrics.panelPreview)); - check(snapped(root.popout), "drag snaps the body"); - check(c.panelColumns === 8 && DashMetrics.gridColumns === 8, "overview grid follows the preview"); - check(root.popout.popupWidth === widthFor(8), "popout width follows the preview"); - dragTo(root.popout, c, 2 * columnGain(), root.rowStep); - root.requestedRows = DashMetrics.panelPreview.rows; - check(root.requestedRows === root.baseRows + 1 && c.panelRows === root.requestedRows, "pill shows the requested rows: " + JSON.stringify([c.panelRows, root.requestedRows])); - dragTo(root.popout, c, 2 * columnGain(), -4 * root.rowStep); - check(DashMetrics.panelPreview.rows === c.contentRows && c.panelRows === c.contentRows, "dragging below the content clamps to the content rows: " + JSON.stringify([DashMetrics.panelPreview.rows, c.contentRows])); - dragTo(root.popout, c, 2 * columnGain(), root.rowStep); - c.panelResizer.end(); - check(!DashMetrics.panelPreview && !root.popout.resizing, "preview cleared on release"); - check(SettingsData.dashOptions?.overview?.panelColumns === 8, "columns stored: " + JSON.stringify(SettingsData.dashOptions)); - check(SettingsData.dashOptions?.overview?.panelRows === root.requestedRows, "requested rows stored: " + JSON.stringify(SettingsData.dashOptions)); - check(c.panelRows === root.requestedRows, "panel keeps the dragged rows after commit"); - root.popout.editMode = false; - check(root.popout.popupWidth === widthFor(8), "width holds when edit mode ends"); - check(!root.popout.contentWindow.anchors.right, "leaving edit mode releases the surface"); - root.popout.requestTab("media"); - break; - case 1: - check(root.popout.activeTabId === "media", "media tab active"); - check(root.popout.popupWidth === widthFor(DashMetrics.defaultGridColumns), "media keeps its own default width"); - root.popout.editMode = true; - beginDrag(root.popout, c); - dragTo(root.popout, c, -columnGain(), 0); - check(DashMetrics.panelPreview?.id === "media" && DashMetrics.panelPreview.columns === 5, "media preview: " + JSON.stringify(DashMetrics.panelPreview)); - check(DashMetrics.gridColumns === 8, "overview columns untouched by media preview"); - c.panelResizer.end(); - check(SettingsData.dashOptions?.media?.panelColumns === 5 && SettingsData.dashOptions?.media?.panelRows === undefined, "media columns stored without rows: " + JSON.stringify(SettingsData.dashOptions?.media)); - check(root.popout.popupWidth === widthFor(5), "media width committed"); - beginDrag(root.popout, c); - dragTo(root.popout, c, columnGain(), 0); - root.popout.editMode = false; - check(!DashMetrics.panelPreview && SettingsData.dashOptions?.media?.panelColumns === 5, "leaving edit mode cancels the drag"); - DashRegistry.resetPanelSize("media"); - check(SettingsData.dashOptions?.media?.panelColumns === undefined, "reset clears the media size"); - root.popout.editMode = true; - beginDrag(root.popout, c, -1); - dragTo(root.popout, c, -columnGain(), 0); - check(DashMetrics.panelPreview?.columns === DashMetrics.defaultGridColumns + 1, "left handle grows when dragged outward: " + JSON.stringify(DashMetrics.panelPreview)); - c.panelResizer.cancel(); - beginDrag(root.popout, c); - c.panelResizer.end(); - check(!DashMetrics.panelPreview && SettingsData.dashOptions?.media === undefined, "release without movement stores nothing"); - check(c.panelRows === DashMetrics.minimumTabRows && c.contentRows === DashMetrics.minimumTabRows && c.panelAtDefault, "media sizes to its content: " + JSON.stringify([c.panelRows, c.contentRows])); - beginDrag(root.popout, c); - dragTo(root.popout, c, 0, root.rowStep); - c.panelResizer.end(); - check(SettingsData.dashOptions?.media?.panelRows === DashMetrics.minimumTabRows + 1 && c.panelRows === DashMetrics.minimumTabRows + 1 && !c.panelAtDefault, "one row above the content is stored for a content-sized tab: " + JSON.stringify([SettingsData.dashOptions?.media, c.panelRows])); - DashRegistry.resetPanelSize("media"); - check(SettingsData.dashOptions?.media === undefined && c.panelRows === DashMetrics.minimumTabRows, "reset returns media to its content rows: " + c.panelRows); - root.popout.editMode = false; - DashRegistry.setPanelSize("overview", DashMetrics.defaultGridColumns, root.baseRows + 1); - root.popout.requestTab("overview"); - break; - case 2: - check(root.popout.activeTabId === "overview" && c.panelRows === root.baseRows + 1, "stored rows above the content raise the floor: " + c.panelRows); - check(root.popout.popupHeight === root.baseHeight + root.rowStep, "panel height follows the floor: " + JSON.stringify([root.popout.popupHeight, root.baseHeight])); - DashRegistry.setPanelSize("overview", DashMetrics.defaultGridColumns, DashMetrics.minimumTabRows + 1); - check(root.baseRows <= DashMetrics.minimumTabRows + 1 || c.panelRows === root.baseRows, "stored rows below the content are ignored: " + JSON.stringify([c.panelRows, root.baseRows])); - DashRegistry.resetPanelSize("overview"); - check(SettingsData.dashOptions?.overview === undefined, "reset clears the overview size: " + JSON.stringify(SettingsData.dashOptions)); - break; - case 3: - check(c.panelRows === root.baseRows && root.popout.popupHeight === root.baseHeight, "unset rows fit the cards again: " + JSON.stringify([c.panelRows, root.popout.popupHeight, root.baseHeight])); - DashRegistry.setPanelSize("overview", DashMetrics.defaultGridColumns, root.baseRows + 1); - root.popout.editMode = true; - beginDrag(root.popout, c); - dragTo(root.popout, c, 0, -root.rowStep); - c.panelResizer.end(); - check(SettingsData.dashOptions?.overview === undefined, "dragging the floor down to the content clears the stored rows: " + JSON.stringify(SettingsData.dashOptions)); - { - const grid = findGrid(c); - const index = grid.sourceItems.findIndex(card => DashRegistry.entry(card.id)?.card?.maxW === DashMetrics.gridColumns); - const cell = grid.slotLayout.slots[index]; - const slot = grid.slotFor(grid.sourceItems[index].id); - const base = DashMetrics.gridColumns; - const savedCards = JSON.parse(JSON.stringify(DashRegistry.placed)); - const targetW = base - cell.col + 1; - const step = grid.columnWidth + DashMetrics.gridGap; - slot.startResize(0, 0); - slot.resizeTo((targetW - cell.cols) * step, 0); - check(DashMetrics.panelPreview?.columns === base + 1 && DashMetrics.gridColumns === base + 1, "card dragged past the edge widens the panel: " + JSON.stringify([DashMetrics.panelPreview, cell, targetW])); - check(grid.sizePreview?.changes.w === targetW && grid.slotLayout.slots[index].col === cell.col, "card grows in place: " + JSON.stringify([grid.sizePreview, grid.slotLayout.slots[index], cell])); - check(root.popout.popupWidth === widthFor(base + 1), "popout follows the card-driven preview: " + root.popout.popupWidth); - check(c.panelShifted && c.cardResizeColumns === base, "panel pill shows the card-driven column shift: " + JSON.stringify([c.panelShifted, c.panelColumns, c.cardResizeColumns])); - slot.finishResize(); - check(!c.panelShifted, "panel pill hides after release"); - check(!DashMetrics.panelPreview && SettingsData.dashOptions?.overview?.panelColumns === base + 1 && DashRegistry.placed[index].w === targetW, "release stores the wider panel and card: " + JSON.stringify([SettingsData.dashOptions?.overview, DashRegistry.placed[index]])); - slot.startResize(0, 0); - slot.resizeTo(-step, 0); - check(!DashMetrics.panelPreview && grid.sizePreview?.changes.w === targetW - 1, "narrowing the card keeps the stored panel width: " + JSON.stringify(grid.sizePreview)); - slot.cancelResize(); - check(!grid.sizePreview && !DashMetrics.panelPreview && SettingsData.dashOptions?.overview?.panelColumns === base + 1, "cancel keeps the stored width"); - SettingsData.set("dashCards", savedCards); - DashRegistry.resetPanelSize("overview"); - } - { - const grid = findGrid(c); - const savedCards = JSON.parse(JSON.stringify(DashRegistry.placed)); - SettingsData.set("dashCards", [ - { - id: "clock", - w: 2, - h: 1, - col: 0, - row: 0 - }, - { - id: "user", - w: 2, - h: 1, - col: 2, - row: 0 - }, - { - id: "media", - w: 3, - h: 1, - col: 0, - row: 1 - } - ]); - const slots = grid.slotLayout.slots; - const lastRow = Math.max(...slots.map(s => s.row)); - const index = slots.findIndex(s => s.row === lastRow); - const cell = slots[index]; - const slot = grid.slotFor(grid.sourceItems[index].id); - const base = DashMetrics.gridColumns; - const others = slots.map((s, i) => i === index ? null : [s.col, s.row]); - const step = grid.columnWidth + DashMetrics.gridGap; - slot.startResize(0, 0); - slot.resizeTo((base - cell.col + 1 - cell.cols) * step, 0); - check(DashMetrics.panelPreview?.columns === base + 1 && grid.sizePreview?.changes.w === base - cell.col + 1, "a lone card dragged past the edge widens the panel: " + JSON.stringify([DashMetrics.panelPreview, grid.sizePreview, cell])); - check(grid.slotLayout.slots.every((s, i) => i === index || (s.col === others[i][0] && s.row === others[i][1])), "no other card moves for a lone card: " + JSON.stringify(grid.slotLayout.slots.map(s => [s.col, s.row]))); - slot.cancelResize(); - check(!DashMetrics.panelPreview && SettingsData.dashOptions?.overview === undefined, "cancel leaves nothing stored"); - SettingsData.set("dashCards", savedCards); - } - root.popout.editMode = false; - root.popout.requestTab("wallpaper"); - break; - case 4: - check(root.popout.activeTabId === "wallpaper" && c.ready, "wallpaper tab loaded"); - root.wallHeightOut = root.popout.popupHeight; - root.popout.editMode = true; - break; - case 5: - root.wallHeight = root.popout.popupHeight; - check(c.contentRows === DashMetrics.minimumTabRows && c.panelRows === DashMetrics.defaultTabRows, "wallpaper rests on the shared default rows: " + JSON.stringify([c.contentRows, c.panelRows])); - check(c.panelResizer.maxRows > c.panelRows, "screen leaves room for one more row: " + JSON.stringify([c.panelResizer.maxRows, c.panelRows])); - beginDrag(root.popout, c); - dragTo(root.popout, c, 0, root.rowStep); - check(DashMetrics.panelPreview?.rows === DashMetrics.defaultTabRows + 1, "wallpaper preview adds a row: " + JSON.stringify(DashMetrics.panelPreview)); - break; - case 6: - check(root.popout.popupHeight === root.wallHeight + root.rowStep, "one row of drag adds one row of height: " + JSON.stringify([root.popout.popupHeight, root.wallHeight])); - dragTo(root.popout, c, 0, 0); - check(DashMetrics.panelPreview?.rows === DashMetrics.defaultTabRows, "dragging back lands on the default rows"); - break; - case 7: - check(root.popout.popupHeight === root.wallHeight, "the default-row preview matches the resting height: " + JSON.stringify([root.popout.popupHeight, root.wallHeight])); - c.panelResizer.end(); - check(!DashMetrics.panelPreview && SettingsData.dashOptions?.wallpaper === undefined, "release on the start rows stores nothing"); - beginDrag(root.popout, c); - dragTo(root.popout, c, 0, root.rowStep); - c.panelResizer.end(); - check(SettingsData.dashOptions?.wallpaper?.panelRows === DashMetrics.defaultTabRows + 1, "wallpaper rows stored: " + JSON.stringify(SettingsData.dashOptions?.wallpaper)); - break; - case 8: - check(root.popout.popupHeight === root.wallHeight + root.rowStep, "committed rows keep the preview height: " + JSON.stringify([root.popout.popupHeight, root.wallHeight])); - beginDrag(root.popout, c); - dragTo(root.popout, c, 0, -root.rowStep); - c.panelResizer.end(); - check(SettingsData.dashOptions?.wallpaper === undefined, "dragging back to the default clears the stored rows"); - beginDrag(root.popout, c); - dragTo(root.popout, c, 0, -root.rowStep); - c.panelResizer.end(); - check(SettingsData.dashOptions?.wallpaper?.panelRows === DashMetrics.minimumTabRows && c.panelRows === DashMetrics.minimumTabRows, "wallpaper shrinks below the default down to its content: " + JSON.stringify([SettingsData.dashOptions?.wallpaper, c.panelRows])); - break; - case 9: - check(root.popout.popupHeight === root.wallHeight - root.rowStep, "stored rows below the default shrink the panel: " + JSON.stringify([root.popout.popupHeight, root.wallHeight])); - DashRegistry.resetPanelSize("wallpaper"); - check(SettingsData.dashOptions?.wallpaper === undefined && c.panelRows === DashMetrics.defaultTabRows, "reset returns wallpaper to the default rows: " + c.panelRows); - root.popout.editMode = false; - break; - case 10: - check(root.popout.popupHeight === root.wallHeightOut, "reset restores the default height outside edit mode: " + JSON.stringify([root.popout.popupHeight, root.wallHeightOut])); - SettingsData.weatherEnabled = true; - root.popout.requestTab("weather"); - break; - case 11: - check(root.popout.activeTabId === "weather", "weather tab loaded"); - root.popout.editMode = true; - interval = 600; - break; - case 12: - check(root.popout.editMode, "weather edit mode"); - root.closingHeight = c.height; - root.popout.dashVisible = false; - check(root.popout.editMode, "edit mode holds while closing"); - DashRegistry.setPanelSize("weather", DashMetrics.defaultGridColumns, c.panelRows + 1); - interval = 150; - break; - case 13: - check(root.popout.isClosing && root.popout.popupHeight > root.closingHeight, "target height grew during the close: " + JSON.stringify([root.popout.popupHeight, root.closingHeight])); - check(c.height === root.closingHeight, "closing body keeps its size: " + JSON.stringify([c.height, root.closingHeight])); - openControlCenter(); - interval = 1500; - break; - case 14: - { - check(!root.popout.editMode && !root.popout.contentWindow.anchors.right, "edit mode resets once closed"); - const ccContent = root.cc.contentLoader?.item ?? null; - check(root.cc.shouldBeVisible && ccContent, "control center open"); - check(root.cc.popupWidth === CcMetrics.sheetWidthDefault, "control center default width"); - root.cc.editMode = true; - check(root.cc.popupWidth === CcMetrics.sheetWidthDefault, "control center edit mode keeps the sheet width"); - check(!root.cc.contentWindow.anchors.right && root.cc.contentWindow.implicitWidth >= CcMetrics.sheetWidthFor(root.cc.gridColumnCap) + PopoutMetrics.editOverflow * 4, "control center edit mode holds a bounded surface wide enough for the column cap"); - beginDrag(root.cc, ccContent); - dragTo(root.cc, ccContent, 1.4 * ccGain(), 0); - check(CcMetrics.gridColumns === CcMetrics.defaultColumns + 1 && CcMetrics.sheetWidth === CcMetrics.sheetWidthFor(CcMetrics.defaultColumns + 1), "control center preview snaps to a column: " + CcMetrics.sheetWidth); - check(snapped(root.cc), "control center drag snaps the body"); - check(root.cc.popupWidth === CcMetrics.sheetWidthFor(CcMetrics.defaultColumns + 1), "control center popout follows the preview"); - ccContent.panelResizer.end(); - check(SettingsData.controlCenterColumns === CcMetrics.defaultColumns + 1, "control center columns stored: " + SettingsData.controlCenterColumns); - check(CcMetrics.columnPreview === 0, "control center preview cleared"); - beginDrag(root.cc, ccContent); - dragTo(root.cc, ccContent, -400, 0); - check(CcMetrics.gridColumns === CcMetrics.minimumColumns, "control center clamps to the fewest columns: " + CcMetrics.gridColumns); - dragTo(root.cc, ccContent, -1.4 * ccGain(), 0); - check(CcMetrics.gridColumns === CcMetrics.defaultColumns && CcMetrics.sheetWidth === CcMetrics.sheetWidthDefault, "control center lands back on the default"); - ccContent.panelResizer.cancel(); - beginDrag(root.cc, ccContent, -1); - dragTo(root.cc, ccContent, -1.4 * ccGain(), 0); - check(CcMetrics.gridColumns === CcMetrics.defaultColumns + 2, "left handle grows the control center: " + CcMetrics.gridColumns); - root.cc.editMode = false; - check(CcMetrics.gridColumns === CcMetrics.defaultColumns + 1, "leaving edit mode cancels the control center drag"); - root.cc.close(); - interval = 500; - } - break; - case 15: - console.log(root.failed ? "FIXTURE_FAIL see above" : "FIXTURE_PASS"); - stop(); - Qt.quit(); - return; - } - restart(); - } - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - SettingsData.barConfigs = [ - { - id: "main", - enabled: true, - visible: true, - position: 0, - spacing: 4, - innerPadding: 4, - leftWidgets: [], - centerWidgets: [], - rightWidgets: [] - } - ]; - openDash("overview"); - sequencer.start(); - } -} diff --git a/quickshell/tests/qml/desktop-widget-geometry.qml b/quickshell/tests/qml/desktop-widget-geometry.qml deleted file mode 100644 index 223f0f0e2..000000000 --- a/quickshell/tests/qml/desktop-widget-geometry.qml +++ /dev/null @@ -1,83 +0,0 @@ -import QtQuick -import QtTest -import Quickshell -import qs.Common -import qs.Services -import qs.Modules.Plugins -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - - TestCase { - id: input - when: false - name: "desktop-widget-geometry" - } - - DesktopPluginWrapper { - id: perScreen - pluginId: "fixturePlugin" - screen: Quickshell.screens[0] - instanceId: "inst1" - instanceData: ({ - id: "inst1", - config: {} - }) - } - - DesktopPluginWrapper { - id: synced - pluginId: "fixturePlugin" - screen: Quickshell.screens[0] - instanceId: "inst2" - instanceData: ({ - id: "inst2", - config: { - syncPositionAcrossScreens: true - } - }) - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - } - - function check(value, message) { - if (!value) - throw new Error(message); - } - - Timer { - interval: 500 - running: true - onTriggered: { - try { - const screen = Quickshell.screens[0]; - const key = SettingsData.getScreenDisplayName(screen); - - root.check(!perScreen.hasSavedPosition && !perScreen.hasSavedSize, "defaults before any save"); - root.check(perScreen.savedX === screen.width / 2 - perScreen.savedWidth / 2, "unsaved position centers on the screen"); - perScreen.savePosition(10, 20); - perScreen.saveSize(300, 150); - const stored = SessionData.desktopWidgetInstancePositions.inst1[key]; - root.check(stored.x === 10 && stored.y === 20 && stored.width === 300 && stored.height === 150, "per-screen instance stores raw geometry under the screen key"); - root.check(perScreen.hasSavedPosition && perScreen.savedX === 10 && perScreen.savedY === 20 && perScreen.savedWidth === 300 && perScreen.savedHeight === 150, "per-screen instance reads back what it saved"); - - synced.savePosition(screen.width / 4, screen.height / 4); - synced.saveSize(250, 125); - const shared = SessionData.desktopWidgetInstancePositions.inst2._synced; - root.check(shared.x === 0.25 && shared.y === 0.25 && shared.width === 250, "synced instance stores normalized coordinates and raw size"); - root.check(synced.savedX === screen.width / 4 && synced.savedY === screen.height / 4 && synced.savedWidth === 250 && synced.savedHeight === 125, "synced instance scales coordinates back to the screen"); - - console.log("FIXTURE_PASS desktop widget geometry: per-screen and synced instance persistence round-trip"); - } catch (error) { - console.error("FIXTURE_FAIL", error.message); - } - Qt.quit(); - } - } -} diff --git a/quickshell/tests/qml/desktop-widget-settings.qml b/quickshell/tests/qml/desktop-widget-settings.qml deleted file mode 100644 index 47153961d..000000000 --- a/quickshell/tests/qml/desktop-widget-settings.qml +++ /dev/null @@ -1,111 +0,0 @@ -import QtQuick -import Quickshell -import qs.Common -import qs.Services -import qs.Modules.Settings.DesktopWidgetSettings as DWS -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - - readonly property var instance: SettingsData.desktopWidgetInstances.find(i => i.id === "inst1") ?? null - - Item { - width: 600 - height: 3000 - - DWS.ClockSettings { - id: clock - instanceId: "inst1" - instanceData: root.instance - } - DWS.SystemMonitorSettings { - id: sysmon - y: 800 - instanceId: "inst1" - instanceData: root.instance - } - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - SettingsData.set("desktopWidgetInstances", [ - { - id: "inst1", - widgetType: "desktopClock", - config: { - graphInterval: 600, - showTopProcesses: true, - topProcessCount: 10 - } - } - ]); - SessionData.updateDesktopWidgetInstancePosition("inst1", "DP-1", { - x: 10, - y: 20, - width: 300, - height: 200 - }); - SessionData.updateDesktopWidgetInstancePosition("inst1", "_synced", { - x: 0.1, - y: 0.2, - width: 300, - height: 200 - }); - } - - function check(value, message) { - if (!value) - throw new Error(message); - } - - 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; - } - - Timer { - interval: 0 - running: true - onTriggered: { - try { - root.check(root.instance !== null, "fixture instance exists"); - - clock.updateConfig("showDate", false); - root.check(root.instance.config.showDate === false, "updateConfig writes into the instance config"); - root.check(root.instance.config.graphInterval === 600, "updateConfig merges instead of replacing"); - - const intervalRow = root.find(sysmon, c => c.intervals !== undefined); - root.check(intervalRow.intervals[intervalRow.currentIndex] === 600, "stored interval selects its option"); - intervalRow.selectionChanged(1, true); - root.check(root.instance.config.graphInterval === intervalRow.intervals[1], "selecting an interval saves it"); - intervalRow.selectionChanged(0, false); - root.check(root.instance.config.graphInterval === intervalRow.intervals[1], "deselection preserves the saved interval"); - - SessionData.resetDesktopWidgetInstanceGeometry("inst1", ["x", "y"]); - let stored = SessionData.desktopWidgetInstancePositions.inst1; - root.check(stored["DP-1"].x === undefined && stored["DP-1"].y === undefined && stored["DP-1"].width === 300, "reset position clears x/y and keeps size"); - root.check(stored["_synced"].x === undefined && stored["_synced"].width === 300, "reset position also clears the synced entry"); - SessionData.resetDesktopWidgetInstanceGeometry("inst1", ["width", "height"]); - stored = SessionData.desktopWidgetInstancePositions.inst1; - root.check(stored["DP-1"].width === undefined && stored["DP-1"].height === undefined, "reset size clears width/height"); - SessionData.resetDesktopWidgetInstanceGeometry("missing", ["x"]); - root.check(SessionData.desktopWidgetInstancePositions.missing === undefined, "reset on an unknown instance is a no-op"); - - console.log("FIXTURE_PASS desktop widget settings: config merge, interval selection, geometry reset"); - } catch (error) { - console.error("FIXTURE_FAIL", error.message); - } - Qt.quit(); - } - } -} diff --git a/quickshell/tests/qml/dock-hover-bounce.qml b/quickshell/tests/qml/dock-hover-bounce.qml deleted file mode 100644 index f94230fb5..000000000 --- a/quickshell/tests/qml/dock-hover-bounce.qml +++ /dev/null @@ -1,132 +0,0 @@ -import QtQuick -import Quickshell -import qs.Common -import qs.Services -import qs.Modules.Dock -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"); - } - - property var buttons: [] - property int step: 0 - - Component { - id: appButton - DockAppButton {} - } - Component { - id: launcherButton - DockLauncherButton {} - } - - PanelWindow { - color: "transparent" - implicitWidth: 800 - implicitHeight: 400 - anchors { - top: true - left: true - } - - Item { - id: stage - anchors.fill: parent - } - } - - function snapshot(label) { - for (const button of root.buttons) { - const offset = Math.round(button.item.hoverAnimOffset * 1000) / 1000; - const position = Number(button.name.split("-")[1]); - const direction = position === SettingsData.Position.Top || position === SettingsData.Position.Left ? 1 : -1; - switch (label) { - case "hovered": - check(offset * direction > 0, label + " " + button.name + " settled offset " + offset); - break; - default: - check(offset === 0, label + " " + button.name + " offset back to 0, got " + offset); - } - console.log("PARITY " + JSON.stringify({ - label: label, - name: button.name, - hovered: button.item.isHovered, - offset: offset - })); - } - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - const created = []; - let x = 20; - for (const position of [SettingsData.Position.Top, SettingsData.Position.Bottom, SettingsData.Position.Left, SettingsData.Position.Right]) { - for (const [name, component] of [["app", appButton], ["launcher", launcherButton]]) { - const item = component.createObject(stage, { - options: { - position: position, - compact: true - }, - actualIconSize: 40, - width: 48, - height: 48, - x: x, - y: 100 - }); - x += 60; - created.push({ - name: name + "-" + position, - item: item - }); - } - } - root.buttons = created; - } - - Timer { - interval: 700 - running: true - repeat: true - onTriggered: { - switch (root.step++) { - case 0: - root.snapshot("idle"); - for (const button of root.buttons) - button.item.isHovered = true; - return; - case 1: - root.snapshot("hovered"); - for (const button of root.buttons) - button.item.isHovered = false; - return; - case 2: - root.snapshot("left"); - SettingsData.animationDuration = 0; - for (const button of root.buttons) - button.item.isHovered = true; - return; - case 3: - root.snapshot("hovered-motion-off"); - root.finish(); - stop(); - Qt.quit(); - } - } - } -} diff --git a/quickshell/tests/qml/dock-integration.qml b/quickshell/tests/qml/dock-integration.qml deleted file mode 100644 index 4fda9f083..000000000 --- a/quickshell/tests/qml/dock-integration.qml +++ /dev/null @@ -1,230 +0,0 @@ -import QtQuick -import Quickshell -import qs.Common -import qs.Services -import qs.Modules.Dock -import qs.Modules.Frame -import qs.DankCommon.Common as DC -import "Common/settings/DockConfig.js" as DockConfig - -ShellRoot { - id: root - property var retainedBody: null - property var retainedModel: null - property var lease: null - property string retractOwner: "" - property int activations: 0 - - 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 body() { - return BarWidgetService.resolveWidget("attached", { - kind: "dock" - })?.context.surface.host ?? docks.instances[0]?.body; - } - function strip() { - return find(body(), item => typeof item.movePinnedApp === "function"); - } - QtObject { - id: first - property string appId: "browser" - property string title: "first" - property int niriWindowId: 11 - property bool activated: true - property bool minimized: false - property var screens: Quickshell.screens - function activate() { - root.activations++; - } - } - QtObject { - id: second - property string appId: "browser" - property string title: "second" - property int niriWindowId: 12 - property bool activated: false - property bool minimized: false - property var screens: Quickshell.screens - function activate() { - root.activations++; - } - } - Component { - id: leaseComponent - ConnectedSurfaceLease { - claimPrefix: "fixture" - slot: "modal" - retractsDock: true - isCurrentOwner: name => true - } - } - DockContextMenu { - id: menu - } - Dock { - id: docks - contextMenu: menu - } - Frame {} - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - const attached = Qt.createComponent("PLUGINS/AttachedPanelExample/AttachedPanelExample.qml"); - check(attached.status === Component.Ready, attached.errorString()); - PluginService.availablePlugins = { - attached: { - id: "attached" - } - }; - PluginService.pluginWidgetComponents = { - attached - }; - const config = DockConfig.create("fixture", "fixture"); - config.enabled = true; - config.widgetExpansion = "inline"; - config.groupByApp = true; - config.maxVisibleApps = 1; - config.widgets.push({ - id: "attached_a", - widgetId: "attached" - }, { - id: "attached_b", - widgetId: "attached" - }); - BarWidgetService.dockContextMenu = menu; - SettingsData.barConfigs = []; - SettingsData.frameScreenPreferences = ["all"]; - SettingsData.dockConfigs = [config]; - SessionData.setDockPins("fixture", ["browser", "missing"]); - SessionData.setBarPinnedApps(["other"]); - } - Timer { - interval: 600 - repeat: true - running: true - property int step: 0 - onTriggered: { - try { - const screen = Quickshell.screens[0]; - const body = root.body(); - const strip = root.strip(); - switch (step++) { - case 0: - root.check(body && strip, "dock and shared model loaded"); - const from = strip.items.findIndex(item => item.isPinned && item.appId === "browser"); - const to = strip.items.findIndex(item => item.isPinned && item.appId === "missing"); - strip.movePinnedApp(from, to); - root.check(SessionData.getDockPins("fixture").join() === "missing,browser", "runtime pin reorder uses the originating dock store"); - SessionData.setDockPins("fixture", ["browser", "missing"]); - CompositorService.sortedToplevels = [first, second]; - break; - case 1: - root.check(strip.items.find(item => item.appId === "browser").windowCount === 2, "grouped windows"); - root.check(strip.overflowItemCount === 1, "pinned overflow"); - root.check(SessionData.barPinnedApps.join() === "other", "separate bar pins"); - const button = root.find(body, item => item.appData?.appId === "browser" && typeof item.activate === "function"); - root.check(button.isWindowFocused, "group focus from model windows"); - button.activate(); - root.check(root.activations === 1, "group activation"); - strip.contextMenu.showForButton(button, button.appData, button.height, false, null, screen, strip); - root.check(strip.contextMenu.anchorItem === button && strip.contextMenu.surfaceContext.host === body, "menu origin"); - root.check(strip.contextMenu.anchorPos.y > screen.height / 2, "bottom menu uses screen coordinates"); - strip.contextMenu.close(); - const plugin = BarWidgetService.resolveWidget("attached", { - occurrenceId: "attached_b" - }).item; - plugin.triggerPopout(); - root.check(body.expansionOwner === plugin, "duplicate plugin inline origin"); - root.retainedBody = body; - root.retainedModel = strip.items; - SettingsData.updateDockConfig("fixture", { - iconSize: 42 - }); - break; - case 2: - root.check(body === root.retainedBody, "config edits retain dock body"); - root.check(strip.items === root.retainedModel, "appearance edit retains model"); - root.check(body.expansionOwner !== null, "appearance edit retains expansion owner"); - SettingsData.updateDockConfig("fixture", { - widgets: SettingsData.getDockConfig("fixture").widgets.filter(item => item.id !== "attached_b") - }); - strip.draggedIndex = 0; - CompositorService.sortedToplevels = [first]; - break; - case 3: - root.check(body.expansionOwner === null, "removed owner closes expansion"); - root.check(strip.items.find(item => item.appId === "browser").windowCount === 2, "model held during drag"); - strip.draggedIndex = -1; - break; - case 4: - root.check(strip.items.find(item => item.appId === "browser").windowCount === 1, "model catches up after cancelled drag"); - first.appId = "editor"; - break; - case 5: - root.check(strip.items.some(item => item.appId === "editor" && item.isRunning), "native window identity change updates model"); - const anchor = root.find(body, item => item.appData?.appId === "editor" && typeof item.activate === "function"); - menu.showForButton(anchor, anchor.appData, anchor.height, false, null, screen, strip); - SettingsData.frameEnabled = true; - SettingsData.frameMode = "connected"; - Qt.callLater(() => { - FrameTransitionState.acknowledge(FrameTransitionState.revision); - FrameTransitionState.syncEffective(); - NiriService._layoutAppliedRevision = NiriService._frameTransitionRevision; - }); - break; - case 6: - root.check(docks.instances.length === 0 && body, "frame takes over dock host"); - root.check(!menu.visible && !menu.anchorItem, "menu closes when originating host is replaced"); - root.check(ConnectedModeState.surfaceOwnerId(screen.name, "dock:fixture") === body.chromeOwnerId, "connected dock claim"); - ConnectedModeState.claimSurface(screen.name, "dock:fixture", ConnectedModeState.surfaceDescriptor(screen.name, "dock:fixture"), "replacement"); - body._syncDockChromeState(); - root.check(ConnectedModeState.surfaceOwnerId(screen.name, "dock:fixture") === "replacement", "stale dock cannot reclaim newer state"); - ConnectedModeState.releaseSurface(screen.name, "dock:fixture", "replacement"); - body._syncDockChromeState(); - root.check(!ConnectedModeState.surfaceOwnerId(screen.name, "dock:fixture"), "superseded dock cannot recover after replacement leaves"); - root.lease = leaseComponent.createObject(root, { - screenName: screen.name, - enabled: true, - active: true, - presented: true, - dockBlocked: true, - dockSide: "bottom" - }); - root.lease.publish({}); - root.retractOwner = root.lease.claimId; - root.check(ConnectedModeState.dockRetractRequests[root.retractOwner] !== undefined, "retract claim"); - root.lease.destroy(); - SettingsData.dockConfigs = []; - break; - case 7: - root.check(!ConnectedModeState.dockRetractRequests[root.retractOwner], "teardown releases retract claim"); - root.check(!ConnectedModeState.surfaceOwnerId(screen.name, "dock:fixture"), "teardown releases connected dock state"); - root.check(Object.keys(BarWidgetService.widgetRegistry).length === 0, "dock teardown releases widgets"); - console.log("FIXTURE_PASS"); - stop(); - Qt.quit(); - } - } catch (error) { - console.error("FIXTURE_FAIL", error.message); - stop(); - Qt.quit(); - } - } - } -} diff --git a/quickshell/tests/qml/duration-popout.qml b/quickshell/tests/qml/duration-popout.qml deleted file mode 100644 index 5aa58ee03..000000000 --- a/quickshell/tests/qml/duration-popout.qml +++ /dev/null @@ -1,114 +0,0 @@ -import QtQuick -import QtTest -import Quickshell -import qs.Common -import qs.Services -import qs.Modules.DankBar.Popouts -import qs.Modules.DankBar.Widgets -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - - TestCase { - id: input - when: false - name: "duration-popout" - } - DurationPopout { - id: popout - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - SettingsData.barConfigs = [ - { - id: "main", - enabled: true, - visible: true, - position: 0, - spacing: 4, - innerPadding: 4, - leftWidgets: [], - centerWidgets: [], - rightWidgets: [] - } - ]; - SessionData.setDoNotDisturb(false); - } - - function check(value, message) { - if (!value) - throw new Error(message); - } - - function collect(item, predicate, out) { - if (predicate(item)) - out.push(item); - for (const child of item.children ?? []) - collect(child, predicate, out); - return out; - } - - function rows() { - return collect(popout.contentLoader.item, c => typeof c.firstInGroup === "boolean" && c.visible, []); - } - - function statusShown() { - return collect(popout.contentLoader.item, c => c.text === popout.presets.status && c.visible, []).length > 0; - } - - function open(source) { - popout.prepareForTrigger(source); - popout.screen = Quickshell.screens[0]; - popout.triggerX = 640; - popout.triggerY = 0; - popout.triggerWidth = 40; - popout.open(); - input.wait(300); - } - - function settle() { - for (let i = 0; i < 30 && (popout.shouldBeVisible || popout.isClosing); i++) - input.wait(50); - } - - Timer { - interval: 500 - running: true - onTriggered: { - try { - root.open("dndDuration"); - root.check(popout.shouldBeVisible && popout.contentLoader.item, "popout opens"); - root.check(root.rows().length === popout.presets.presetOptions.length, "presets and no turn-off row while off: " + root.rows().length); - - const minutes = popout.presets.presetOptions[0].minutes; - const before = Date.now(); - root.rows()[0].clicked(); - root.check(SessionData.doNotDisturb, "selecting a preset enables do not disturb"); - root.check(Math.abs(SessionData.doNotDisturbUntil - (before + minutes * 60000)) < 2000, "selected duration sets the deadline"); - root.settle(); - root.check(!popout.shouldBeVisible, "selecting a preset closes the popout"); - - root.open("dndDuration"); - root.check(root.statusShown() && root.rows().length === popout.presets.presetOptions.length + 1, "while on, the turn-off row appears with the status"); - root.rows().find(row => row.modelData === undefined).clicked(); - root.check(!SessionData.doNotDisturb, "turn off row disables do not disturb"); - root.settle(); - - root.open("idleInhibit"); - root.check(popout.presets === IdleInhibitPresets && root.rows().length === popout.presets.presetOptions.length, "same popout serves keep awake presets"); - popout.close(); - root.settle(); - - console.log("FIXTURE_PASS duration popout: dnd presets, deadline, turn off, keep awake source"); - } catch (error) { - console.error("FIXTURE_FAIL", error.message); - } - Qt.quit(); - } - } -} diff --git a/quickshell/tests/qml/fade-windows.qml b/quickshell/tests/qml/fade-windows.qml deleted file mode 100644 index 335b497e8..000000000 --- a/quickshell/tests/qml/fade-windows.qml +++ /dev/null @@ -1,120 +0,0 @@ -import QtQuick -import Quickshell -import qs.Common -import qs.Services -import qs.Modules.Lock -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - - property bool failed: false - property int step: 0 - property var events: [] - - 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 take() { - const out = root.events; - root.events = []; - return JSON.stringify(out); - } - - FadeToLockWindow { - id: lockFade - onFadeCompleted: root.events.push("lock:completed") - onFadeCancelled: root.events.push("lock:cancelled") - } - - FadeToDpmsWindow { - id: dpmsFade - onFadeCompleted: root.events.push("dpms:completed") - onFadeCancelled: root.events.push("dpms:cancelled") - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - SettingsData.fadeToLockEnabled = true; - SettingsData.fadeToDpmsEnabled = true; - SettingsData.fadeToLockGracePeriod = 1; - SettingsData.fadeToDpmsGracePeriod = 1; - IdleService.isShellLocked = false; - console.log("FADE colors lock=" + lockFade.contentItem.children[0].color + " dpms=" + dpmsFade.contentItem.children[0].color); - } - - Timer { - interval: 600 - running: true - repeat: true - onTriggered: { - switch (root.step++) { - case 0: - lockFade.startFade(); - dpmsFade.startFade(); - return; - case 1: - check(lockFade.active && lockFade.visible && dpmsFade.active && dpmsFade.visible, "both fades active after start"); - check(root.take() === "[]", "no completion before the grace period"); - lockFade.cancelFade(); - dpmsFade.cancelFade(); - return; - case 2: - check(!lockFade.active && !dpmsFade.active, "cancel mid-fade deactivates both"); - check(root.take() === JSON.stringify(["lock:cancelled", "dpms:cancelled"]), "cancel mid-fade emits cancelled once each"); - lockFade.startFade(); - dpmsFade.startFade(); - return; - case 3: - case 4: - return; - case 5: - check(root.take() === JSON.stringify(["lock:completed", "dpms:completed"]), "both complete once after the grace period"); - check(lockFade.active && dpmsFade.active, "both stay up after completion"); - lockFade.cancelFade(); - dpmsFade.cancelFade(); - return; - case 6: - check(lockFade.active, "lock fade ignores cancel after completion"); - check(!dpmsFade.active, "dpms fade dismisses on cancel after completion"); - check(root.take() === JSON.stringify(["dpms:cancelled"]), "only dpms emits cancelled after completion"); - IdleService.isShellLocked = true; - return; - case 7: - check(lockFade.active, "lock fade stays while the shell is locked"); - IdleService.isShellLocked = false; - return; - case 8: - check(!lockFade.active, "lock fade dismisses on unlock after completion"); - check(root.take() === "[]", "unlock dismiss emits nothing"); - lockFade.startFade(); - dpmsFade.startFade(); - IdleService.isShellLocked = true; - IdleService.isShellLocked = false; - return; - case 9: - check(lockFade.active, "lock fade keeps running through an unlock before completion"); - check(!dpmsFade.active, "dpms fade dismisses on unlock before completion"); - check(root.take() === "[]", "unlock dismiss mid-fade emits nothing"); - lockFade.dismiss(); - SettingsData.fadeToLockEnabled = false; - lockFade.startFade(); - check(!lockFade.active, "disabled lock fade does not start"); - root.finish(); - stop(); - Qt.quit(); - } - } - } -} diff --git a/quickshell/tests/qml/focused-app.qml b/quickshell/tests/qml/focused-app.qml index 9968c4ed2..af535d5d6 100644 --- a/quickshell/tests/qml/focused-app.qml +++ b/quickshell/tests/qml/focused-app.qml @@ -203,7 +203,7 @@ ShellRoot { const current = root.signature(); stable = current === last ? stable + 1 : 0; last = current; - if (++waited > 200) { + if (++waited > 800) { root.failed = true; console.log("FIXTURE_FAIL " + state.name + " never settled, signature " + current); stop(); diff --git a/quickshell/tests/qml/grid-edit.qml b/quickshell/tests/qml/grid-edit.qml deleted file mode 100644 index 2bc64618e..000000000 --- a/quickshell/tests/qml/grid-edit.qml +++ /dev/null @@ -1,261 +0,0 @@ -import QtQuick -import QtTest -import Quickshell -import qs.Common -import qs.Widgets -import qs.Modules.ControlCenter.Components -import qs.Modules.ControlCenter.Models -import qs.Modules.DankDash.Overview -import qs.DankCommon.Common as DC - -ShellRoot { - Component.onCompleted: { - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - DC.Paths.backend = Paths; - } - - WidgetModel { - id: ccModel - } - - FloatingWindow { - visible: true - implicitWidth: 1000 - implicitHeight: 800 - color: Theme.surfaceContainerLow - - Item { - id: scene - anchors.fill: parent - - DankFlickable { - id: flick - x: 30 - y: 30 - width: 520 - height: 160 - contentWidth: width - contentHeight: 800 - - CcTileGrid { - id: ccGrid - width: parent.width - editMode: true - model: ccModel - live: false - } - } - - DashCardGrid { - id: dashGrid - x: 30 - y: 250 - width: 790 - editMode: true - rowBudget: 5 - live: false - } - } - - TestCase { - id: tester - when: false - - function check(condition, label) { - if (!condition) - throw new Error(label); - console.log("PASS " + label); - } - - function slotFor(grid, index) { - return grid.children.find(item => item.index === index && item.json !== undefined); - } - - function reset() { - SessionData.locale = "en"; - ccGrid.editMode = true; - dashGrid.editMode = true; - SettingsData.controlCenterWidgets = [ - { - id: "darkMode", - w: 1, - h: 1 - }, - { - id: "diskUsage", - w: 1, - h: 1 - } - ]; - SettingsData.dashCards = [ - { - id: "clock", - w: 2, - h: 2 - }, - { - id: "user", - w: 2, - h: 2 - } - ]; - wait(400); - } - - function pointOn(item, x, y) { - return item.mapToItem(scene, x, y); - } - - function resize(grid, dx, dy, touch) { - const slot = slotFor(grid, 0); - const x = I18n.isRtl ? 6 : slot.width - 6; - const y = slot.height - 6; - const start = pointOn(slot, x, y); - const sequence = touch ? touchEvent(scene) : null; - if (touch) - sequence.press(0, scene, start.x, start.y).commit(); - else - mousePress(scene, start.x, start.y); - wait(20); - check(slot.resizing && !slot.dragging && slot.resizeOrigin !== null, "resize press retains ownership"); - check(!slotFor(grid, 1).interactionEnabled, "other tile input is blocked"); - for (let i = 1; i <= 10; i++) { - const px = start.x + dx * i / 10; - const py = start.y + dy * i / 10; - if (touch) - sequence.move(0, scene, px, py).commit(); - else - mouseMove(scene, px, py); - wait(20); - } - check(slot.resizing && !slot.dragging, "resize remains active during motion"); - if (touch) - sequence.release(0, scene, start.x + dx, start.y + dy).commit(); - else - mouseRelease(scene, start.x + dx, start.y + dy); - wait(400); - check(!grid.interacting && slot.resizeOrigin === null, "resize release clears ownership"); - check(flick.contentY === 0, "resize does not scroll parent"); - } - - function reorder(grid, touch) { - const slot = slotFor(grid, 0); - const start = pointOn(slot, 24, 24); - const other = slotFor(grid, 1); - const end = pointOn(other, other.width / 2, other.height / 2); - const sequence = touch ? touchEvent(scene) : null; - if (touch) - sequence.press(0, scene, start.x, start.y).commit(); - else - mousePress(scene, start.x, start.y); - wait(20); - check(!grid.interacting, "press alone does not reorder"); - for (let i = 1; i <= 10; i++) { - const px = start.x + (end.x - start.x) * i / 10; - const py = start.y + (end.y - start.y) * i / 10; - if (touch) - sequence.move(0, scene, px, py).commit(); - else - mouseMove(scene, px, py); - wait(20); - } - check(slot.dragging, "drag retains ownership"); - if (touch) - sequence.release(0, scene, end.x, end.y).commit(); - else - mouseRelease(scene, end.x, end.y); - wait(400); - check(!grid.interacting, "move releases ownership"); - } - - function cancelResize(grid) { - const slot = slotFor(grid, 0); - const start = pointOn(slot, slot.width - 6, slot.height - 6); - const before = JSON.stringify(grid.sourceItems); - mousePress(scene, start.x, start.y); - mouseMove(scene, start.x + 140, start.y + 104); - wait(40); - check(slot.resizing, "cancel starts from active resize"); - grid.editMode = false; - wait(20); - mouseRelease(scene, start.x + 140, start.y + 104); - wait(40); - check(!grid.interacting && slot.resizeOrigin === null, "mode exit releases resize"); - check(JSON.stringify(grid.sourceItems) === before, "cancel preserves saved size"); - } - - function run() { - reset(); - resize(ccGrid, ccGrid.cellWidth, ccGrid.cellWidth, false); - check(SettingsData.controlCenterWidgets[0].w === 2 && SettingsData.controlCenterWidgets[0].h === 2, "control center mouse resize persists both axes"); - resize(dashGrid, 140, 104, false); - check(SettingsData.dashCards[0].w === 3 && SettingsData.dashCards[0].h === 3, "dashboard mouse resize persists both axes"); - reset(); - resize(ccGrid, ccGrid.cellWidth, 0, true); - check(SettingsData.controlCenterWidgets[0].w === 2 && SettingsData.controlCenterWidgets[0].h === 1, "control center touch resize persists"); - resize(dashGrid, 140, 104, true); - check(SettingsData.dashCards[0].w === 3 && SettingsData.dashCards[0].h === 3, "dashboard touch resize persists both axes"); - reset(); - reorder(ccGrid, false); - check(SettingsData.controlCenterWidgets[0].id === "darkMode" && SettingsData.controlCenterWidgets[0].col === 1 && SettingsData.controlCenterWidgets[1].row === 1, "control center mouse move lands on the cell and pushes the occupant down"); - reorder(dashGrid, true); - check(SettingsData.dashCards[0].id === "clock" && SettingsData.dashCards[0].col === 3 && SettingsData.dashCards[0].row === 1 && SettingsData.dashCards[1].row === 3, "dashboard touch move lands on the cell and pushes the occupant down"); - reset(); - cancelResize(ccGrid); - cancelResize(dashGrid); - reset(); - SessionData.locale = "ar"; - wait(400); - resize(ccGrid, -ccGrid.cellWidth, 0, false); - check(SettingsData.controlCenterWidgets[0].w === 2, "RTL control center resize persists"); - resize(dashGrid, -140, 104, true); - check(SettingsData.dashCards[0].w === 3 && SettingsData.dashCards[0].h === 3, "RTL dashboard resize persists"); - reset(); - SessionData.locale = "en"; - SettingsData.dashCards = [ - { - id: "clock", - w: 6, - h: 4 - }, - { - id: "user", - w: 2, - h: 1 - } - ]; - wait(400); - resize(dashGrid, 0, 300, false); - check(SettingsData.dashCards[0].h === 4, "dashboard resize stops at the board edge"); - check(dashGrid.addableEntries.every(candidate => candidate.entry.id !== "calendar"), "cards that cannot fit are not addable"); - check(dashGrid.addableEntries.find(candidate => candidate.entry.id === "media")?.size.h === 1, "addable cards shrink to the free space"); - SettingsData.dashCards = [ - { - id: "clock", - w: 6, - h: 5 - } - ]; - wait(400); - check(dashGrid.addableEntries.length === 0, "full board offers nothing to add"); - console.log("GRID_EDIT_PASS"); - Qt.quit(); - } - } - } - - Timer { - interval: 1500 - running: true - onTriggered: { - try { - tester.run(); - } catch (error) { - console.log("GRID_EDIT_FAIL " + error); - Qt.quit(); - } - } - } -} diff --git a/quickshell/tests/qml/include-banner.qml b/quickshell/tests/qml/include-banner.qml deleted file mode 100644 index 66713ff28..000000000 --- a/quickshell/tests/qml/include-banner.qml +++ /dev/null @@ -1,185 +0,0 @@ -import QtQuick -import Quickshell -import qs.Common -import qs.Services -import qs.Modules.Settings.Widgets -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - - readonly property var kinds: ["outputs", "layout", "windowrules", "input", "binds"] - property var includes: ({}) - property var banners: ({}) - property bool failed: false - - Component { - id: includeComponent - ConfigInclude { - autoCheck: false - } - } - - Component { - id: bannerComponent - IncludeSetupBanner {} - } - - PanelWindow { - color: "transparent" - implicitWidth: 900 - implicitHeight: 900 - anchors { - top: true - left: true - } - - Column { - id: stage - anchors.fill: parent - anchors.margins: 20 - spacing: 12 - } - } - - function typeName(item) { - return String(item).split("(")[0].replace(/_QMLTYPE_\d+/, "").replace(/_QML_\d+/, ""); - } - - function collect(item, out) { - switch (typeName(item)) { - case "StyledText": - out.texts.push(item.text); - break; - case "DankButton": - out.button = item; - break; - } - for (const child of item.children || []) - collect(child, out); - return out; - } - - function check(condition, label) { - if (condition) - return; - failed = true; - console.log("FIXTURE_FAIL " + label); - } - - function forceCompositor(name) { - CompositorService.isNiri = name === "niri"; - CompositorService.isHyprland = name === "hyprland"; - CompositorService.isMango = name === "mango"; - CompositorService.compositor = name; - } - - function status(included, readOnly) { - return { - "exists": included, - "included": included, - "configFormat": readOnly ? "hyprlang" : "", - "readOnly": readOnly - }; - } - - function verifySetupOnNiri(kind) { - const include = includes[kind]; - const banner = banners[kind]; - include.status = status(false, false); - include.checking = false; - check(include.compositorSupported, kind + " supported on niri"); - check(!include.included && !include.readOnly, kind + " default status is not included and not read-only"); - check(banner.visible, kind + " banner shows when not included"); - const shown = collect(banner, { - texts: [], - button: null - }); - check(shown.button && shown.button.visible && shown.button.enabled, kind + " banner shows an enabled Setup button"); - include.fixing = true; - check(!shown.button.enabled, kind + " button disables while fixing"); - include.fixing = false; - include.checking = true; - check(!banner.visible, kind + " banner hides while checking"); - include.checking = false; - include.status = status(true, false); - check(include.included, kind + " included after status update"); - check(!banner.visible, kind + " banner hides once included"); - } - - function verifyLegacyOnHyprland(kind) { - const include = includes[kind]; - const banner = banners[kind]; - include.status = status(false, true); - check(include.readOnly, kind + " read-only on hyprland conf mode"); - check(banner.visible, kind + " legacy banner shows"); - const shown = collect(banner, { - texts: [], - button: null - }); - check(shown.button && !shown.button.visible, kind + " legacy banner hides the Setup button"); - include.status = status(true, true); - check(banner.visible && include.readOnly, kind + " legacy banner stays while included but read-only"); - } - - function verifyInputOffNiri() { - const include = includes["input"]; - forceCompositor("hyprland"); - check(!include.compositorSupported, "input unsupported on hyprland"); - forceCompositor("mango"); - check(!include.compositorSupported, "input unsupported on mango"); - forceCompositor("niri"); - check(include.compositorSupported, "input supported again on niri"); - } - - function finish() { - console.log(root.failed ? "FIXTURE_FAIL see above" : "FIXTURE_PASS"); - Qt.quit(); - } - - Timer { - interval: 800 - running: true - repeat: true - property int step: 0 - onTriggered: { - if (step++ === 0) { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - root.forceCompositor("niri"); - const includes = {}; - const banners = {}; - for (const kind of root.kinds) { - includes[kind] = includeComponent.createObject(root, { - includeKind: kind - }); - banners[kind] = bannerComponent.createObject(stage, { - include: includes[kind] - }); - if (!includes[kind] || !banners[kind]) { - console.error("FIXTURE_FAIL " + kind + " " + includeComponent.errorString() + bannerComponent.errorString()); - Qt.quit(); - return; - } - } - root.includes = includes; - root.banners = banners; - return; - } - if (step === 2) { - for (const kind of root.kinds) - root.verifySetupOnNiri(kind); - root.forceCompositor("hyprland"); - return; - } - stop(); - for (const kind of root.kinds.filter(kind => kind !== "input")) - root.verifyLegacyOnHyprland(kind); - root.check(!root.includes["input"].compositorSupported, "input unsupported on hyprland"); - root.verifyInputOffNiri(); - root.finish(); - } - } -} diff --git a/quickshell/tests/qml/instance-routing.qml b/quickshell/tests/qml/instance-routing.qml index de3eec335..905117433 100644 --- a/quickshell/tests/qml/instance-routing.qml +++ b/quickshell/tests/qml/instance-routing.qml @@ -124,7 +124,7 @@ ShellRoot { try { const screen = Quickshell.screens[0]; const entries = Object.values(BarWidgetService.widgetRegistry); - if (++waited > 120) + if (++waited > 800) throw new Error("timed out in step " + step + (waitingFor ? " waiting for " + waitingFor : "")); switch (step) { case 0: diff --git a/quickshell/tests/qml/island-idle.qml b/quickshell/tests/qml/island-idle.qml deleted file mode 100644 index a41720873..000000000 --- a/quickshell/tests/qml/island-idle.qml +++ /dev/null @@ -1,92 +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 - - 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.barConfigs = [ - { - id: "island", - enabled: true, - visible: true, - position: 0, - island: true, - spacing: 4, - innerPadding: 4, - leftWidgets: [], - centerWidgets: [], - rightWidgets: [], - islandShowSatellites: false - } - ]; - } - - Timer { - interval: 1500 - running: true - repeat: true - property int step: 0 - onTriggered: { - const host = islands.hosts()[0]; - switch (step++) { - case 0: - if (!host) { - console.error("FIXTURE_FAIL no island host"); - Qt.quit(); - } - console.log("COMPACT_IDLE_BEGIN"); - break; - case 3: - console.log("COMPACT_IDLE_END"); - host.islandController.requestActivity("home", true, false); - break; - case 4: - if (!host.islandController.expanded) { - console.error("FIXTURE_FAIL island did not expand"); - Qt.quit(); - } - console.log("EXPANDED_IDLE_BEGIN"); - break; - case 7: - console.log("EXPANDED_IDLE_END"); - host.islandController.requestCollapse(); - break; - case 9: - if (host.islandController.expanded) { - console.error("FIXTURE_FAIL island did not collapse"); - Qt.quit(); - } - console.log("FIXTURE_PASS"); - stop(); - Qt.quit(); - } - } - } -} diff --git a/quickshell/tests/qml/island-launcher-focus.qml b/quickshell/tests/qml/island-launcher-focus.qml index daa7c0bb0..aea772415 100644 --- a/quickshell/tests/qml/island-launcher-focus.qml +++ b/quickshell/tests/qml/island-launcher-focus.qml @@ -81,7 +81,7 @@ ShellRoot { onTriggered: { const c = root.controller(); - if (++waited > 120) { + if (++waited > 800) { console.log("FIXTURE_FAIL timed out waiting for " + waitingFor); stop(); Qt.quit(); diff --git a/quickshell/tests/qml/launcher-open-state.qml b/quickshell/tests/qml/launcher-open-state.qml deleted file mode 100644 index 867d857e4..000000000 --- a/quickshell/tests/qml/launcher-open-state.qml +++ /dev/null @@ -1,156 +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 - - readonly property string style: Quickshell.env("DMS_FIXTURE_LAUNCHER_STYLE") || "standalone" - property var launcher: null - property bool failed: false - property int step: 0 - - function check(condition, label) { - if (condition) - return; - failed = true; - console.log("FIXTURE_FAIL " + label); - } - - function controller() { - return root.launcher.spotlightContent.controller; - } - - function state() { - const c = controller(); - return { - searchQuery: c.searchQuery, - searchMode: c.searchMode, - explicit: c.explicitQuerySession, - previousSearchMode: c.previousSearchMode, - autoSwitchedToFiles: c.autoSwitchedToFiles, - isFileSearching: c.isFileSearching, - fileSearchType: c.fileSearchType, - fileSearchExt: c.fileSearchExt, - fileSearchFolder: c.fileSearchFolder, - fileSearchSort: c.fileSearchSort, - pluginFilter: c.pluginFilter, - activePluginId: c.activePluginId, - activePluginName: c.activePluginName, - activePluginCategories: c.activePluginCategories, - activePluginCategory: c.activePluginCategory, - appCategory: c.appCategory, - collapsedSections: c.collapsedSections, - selectedFlatIndex: c.selectedFlatIndex, - historyIndex: c.historyIndex, - results: c.flatModel.length, - fieldText: root.launcher.spotlightContent.searchField.text - }; - } - - function dirty() { - const c = controller(); - c.appCategory = "Games"; - c.collapsedSections = { - "apps": true - }; - c.fileSearchExt = "png"; - c.fileSearchFolder = "/tmp"; - c.fileSearchSort = "name"; - c.pluginFilter = "abc"; - c.activePluginId = "p"; - c.activePluginName = "P"; - c.activePluginCategories = ["x"]; - c.activePluginCategory = "x"; - c.selectedFlatIndex = 3; - c.historyIndex = 2; - c.previousSearchMode = "files"; - c.autoSwitchedToFiles = true; - c.isFileSearching = true; - c.fileSearchType = "documents"; - } - - function expectClean(s, label) { - check(s.appCategory === "", label + " appCategory cleared"); - check(Object.keys(s.collapsedSections).length === 0, label + " collapsedSections cleared"); - check(s.fileSearchExt === "" && s.fileSearchFolder === "" && s.fileSearchSort === "score", label + " file filters cleared"); - check(s.pluginFilter === "" && s.activePluginId === "" && s.activePluginName === "" && s.activePluginCategories.length === 0 && s.activePluginCategory === "", label + " plugin state cleared"); - check(s.historyIndex === -1, label + " history index reset"); - check(s.previousSearchMode === "all" && !s.autoSwitchedToFiles && !s.isFileSearching, label + " mode switch state cleared"); - check(s.fileSearchType === "images", label + " remembered file search type restored"); - check(s.searchMode === "apps", label + " remembered mode restored"); - } - - Component { - id: modalComp - DankLauncherV2Modal {} - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - SettingsData.launcherStyle = root.style; - SettingsData.rememberLastQuery = false; - SettingsData.rememberLastMode = true; - SettingsData.dankLauncherV2UnloadOnClose = false; - SessionData.launcherLastFileSearchType = "images"; - SessionData.launcherLastMode = "apps"; - root.launcher = modalComp.createObject(root); - } - - Timer { - interval: 900 - running: true - repeat: true - onTriggered: { - switch (root.step++) { - case 0: - return; - case 1: - root.launcher.show(); - return; - case 2: - console.log("STATE first-open " + JSON.stringify(root.state())); - root.dirty(); - root.launcher.hide(); - return; - case 3: - root.launcher.show(); - return; - case 4: - { - const s = root.state(); - console.log("STATE reopen " + JSON.stringify(s)); - root.expectClean(s, "reopen"); - check(!s.explicit && s.searchQuery === "" && s.fieldText === "", "reopen has no query"); - check(s.results > 0 || root.launcher.spotlightContent.showResultsWithoutQuery === false, "reopen shows results without a query"); - root.dirty(); - root.launcher.hide(); - return; - } - case 5: - root.launcher.showWithQuery("te"); - return; - case 6: - { - const s = root.state(); - console.log("STATE reopen-query " + JSON.stringify(s)); - root.expectClean(s, "reopen-query"); - check(s.explicit && s.searchQuery === "te" && s.fieldText === "te", "reopen-query carries the query"); - check(s.results > 0, "reopen-query shows results"); - root.launcher.hide(); - return; - } - case 7: - console.log(root.failed ? "FIXTURE_FAIL see above" : "FIXTURE_PASS"); - stop(); - Qt.quit(); - } - } - } -} diff --git a/quickshell/tests/qml/launcher-open-timing.qml b/quickshell/tests/qml/launcher-open-timing.qml deleted file mode 100644 index 18d4af50f..000000000 --- a/quickshell/tests/qml/launcher-open-timing.qml +++ /dev/null @@ -1,263 +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 - - readonly property string style: Quickshell.env("DMS_FIXTURE_LAUNCHER_STYLE") || "standalone" - readonly property string query: Quickshell.env("DMS_FIXTURE_LAUNCHER_QUERY") || "" - readonly property int rounds: 8 - property var launcher: null - property int round: 0 - property real tShow: 0 - property real tSearch: 0 - property real tResults: 0 - property real tHide: 0 - property var results: [] - property var resultsList: null - property bool waitingResults: false - property bool waitingClose: false - property bool typingDone: false - property int typeIndex: 0 - readonly property string typeWord: Quickshell.env("DMS_FIXTURE_LAUNCHER_TYPE") || "terminal" - property var typing: [] - property real tType: 0 - property int typeSearchMs: -1 - property int typeFrames: 0 - property bool typeWaiting: false - - property int typeCompletions: 0 - property int typeModelChanges: 0 - property int typeLastMs: 0 - - Connections { - target: root.launcher?.spotlightContent?.controller ?? null - ignoreUnknownSignals: true - function onSearchCompleted() { - if (root.tType === 0) - return; - root.typeCompletions++; - root.typeLastMs = Date.now() - root.tType; - if (root.typeWaiting && root.typeSearchMs < 0) - root.typeSearchMs = root.typeLastMs; - } - function onFlatModelChanged() { - if (root.tType !== 0) - root.typeModelChanges++; - } - } - - property int typeFirstFrameMs: -1 - - FrameAnimation { - running: root.typeWaiting - onTriggered: { - root.typeFrames++; - if (root.typeFirstFrameMs < 0) - root.typeFirstFrameMs = Date.now() - root.tType; - if (root.typeSearchMs < 0) - return; - root.typing.push({ - q: root.typeWord.slice(0, root.typeIndex), - search: root.typeSearchMs, - firstFrame: root.typeFirstFrameMs, - framesAfter: root.typeFrames, - wall: Date.now() - root.tType, - results: root.launcher.spotlightContent.controller.flatModel.length - }); - root.typeWaiting = false; - } - } - - Timer { - id: typeTimer - interval: 250 - repeat: true - onTriggered: { - if (root.typeIndex >= root.typeWord.length) { - stop(); - const last = root.typing[root.typing.length - 1]; - last.completions = root.typeCompletions; - last.modelChanges = root.typeModelChanges; - last.lastSearchMs = root.typeLastMs; - root.launcher.hide(); - const med = key => { - const v = root.typing.map(r => r[key]).sort((a, b) => a - b); - return v[Math.floor(v.length / 2)]; - }; - console.log("TYPING " + JSON.stringify({ - style: root.style, - medianSearch: med("search"), - maxSearch: Math.max(...root.typing.map(r => r.search)), - medianWall: med("wall"), - steps: root.typing - })); - finishTimer.start(); - return; - } - if (root.typeIndex > 0) { - const last = root.typing[root.typing.length - 1]; - last.completions = root.typeCompletions; - last.modelChanges = root.typeModelChanges; - last.lastSearchMs = root.typeLastMs; - } - root.typeCompletions = 0; - root.typeModelChanges = 0; - root.typeIndex++; - root.tType = Date.now(); - root.typeSearchMs = -1; - root.typeFirstFrameMs = -1; - root.typeFrames = 0; - root.typeWaiting = true; - root.launcher.spotlightContent.searchField.text = root.typeWord.slice(0, root.typeIndex); - } - } - - Timer { - id: finishTimer - interval: 500 - onTriggered: next.restart() - } - - function typeName(item) { - return String(item).split("(")[0].replace(/_QMLTYPE_\d+/, "").replace(/_QML_\d+/, ""); - } - - function backend() { - return root.launcher.children[0]?.item ?? null; - } - - function resultCount() { - return root.launcher?.spotlightContent?.controller?.flatModel?.length ?? -1; - } - - Component { - id: modalComp - DankLauncherV2Modal {} - } - - Connections { - target: root.launcher?.spotlightContent?.controller ?? null - ignoreUnknownSignals: true - function onSearchCompleted() { - if (root.tSearch === 0 && root.tShow > 0) - root.tSearch = Date.now(); - } - } - - Connections { - target: root.launcher - ignoreUnknownSignals: true - function onDialogClosed() { - if (root.waitingClose) { - root.waitingClose = false; - root.results.push({ - round: root.round, - search: root.tSearch - root.tShow, - results: root.tResults - root.tShow, - close: Date.now() - root.tHide - }); - root.round++; - next.restart(); - } - } - } - - FrameAnimation { - id: frames - running: root.waitingResults - onTriggered: { - const b = backend(); - if (!b || !b.contentVisible) - return; - if (resultCount() <= 0) - return; - root.tResults = Date.now(); - root.waitingResults = false; - hideTimer.restart(); - } - } - - Timer { - id: watchdog - interval: 8000 - running: root.waitingResults || root.waitingClose - onTriggered: { - const b = backend(); - console.log("FIXTURE_FAIL round " + root.round + " stuck: backend=" + (b ? root.typeName(b) : "none") + " contentVisible=" + (b?.contentVisible ?? "?") + " open=" + (b?.spotlightOpen ?? "?") + " results=" + resultCount() + " waitingResults=" + root.waitingResults + " waitingClose=" + root.waitingClose); - Qt.quit(); - } - } - - Timer { - id: hideTimer - interval: 250 - onTriggered: { - root.tHide = Date.now(); - root.waitingClose = true; - root.launcher.hide(); - } - } - - Timer { - id: next - interval: 400 - onTriggered: { - if (root.round >= root.rounds && !root.typingDone) { - root.typingDone = true; - root.launcher.show(); - typeTimer.start(); - return; - } - if (root.round >= root.rounds) { - const warm = root.results.slice(1); - const med = key => { - const v = warm.map(r => r[key]).sort((a, b) => a - b); - return v[Math.floor(v.length / 2)]; - }; - console.log("TIMING " + JSON.stringify({ - style: root.style, - query: root.query, - cold: root.results[0], - medianSearch: med("search"), - medianResults: med("results"), - medianClose: med("close"), - all: root.results - })); - console.log("FIXTURE_PASS"); - Qt.quit(); - return; - } - root.tShow = Date.now(); - root.tSearch = 0; - root.tResults = 0; - root.waitingResults = true; - if (root.query) - root.launcher.showWithQuery(root.query); - else - root.launcher.show(); - } - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - SettingsData.launcherStyle = root.style; - SettingsData.rememberLastQuery = false; - SettingsData.dankLauncherV2UnloadOnClose = false; - root.launcher = modalComp.createObject(root); - startup.start(); - } - - Timer { - id: startup - interval: 2500 - onTriggered: next.restart() - } -} diff --git a/quickshell/tests/qml/launcher-plugin-instances.qml b/quickshell/tests/qml/launcher-plugin-instances.qml index 71f074538..eb0ae6759 100644 --- a/quickshell/tests/qml/launcher-plugin-instances.qml +++ b/quickshell/tests/qml/launcher-plugin-instances.qml @@ -121,7 +121,7 @@ ShellRoot { repeat: true property int waited: 0 onTriggered: { - if (++waited > 400) { + if (++waited > 800) { check(false, "timed out in step " + root.step + " state=" + root.stallState()); stop(); Qt.quit(); diff --git a/quickshell/tests/qml/launcher-tab.qml b/quickshell/tests/qml/launcher-tab.qml deleted file mode 100644 index 7618e7913..000000000 --- a/quickshell/tests/qml/launcher-tab.qml +++ /dev/null @@ -1,116 +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 - - 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"); - } - - Component { - id: tabComponent - LauncherTab {} - } - - PanelWindow { - color: "transparent" - implicitWidth: 900 - implicitHeight: 1600 - anchors { - top: true - left: true - } - - Item { - id: stage - anchors.fill: parent - } - } - - function typeName(item) { - return String(item).split("(")[0].replace(/_QMLTYPE_\d+/, "").replace(/_QML_\d+/, ""); - } - - function collect(item, out) { - const type = typeName(item); - if (["QQuickImage", "IconImage", "AppIconRenderer", "StyledText", "DankIcon"].includes(type)) { - const entry = { - type: type, - w: Math.round(item.width), - h: Math.round(item.height), - visible: item.visible - }; - if (typeof item.text === "string") - entry.text = item.text; - if (item.source !== undefined) - entry.source = String(item.source).replace(/^.*\//, ""); - if (item.status !== undefined) - entry.status = item.status; - out.push(entry); - } - for (const child of item.children || []) - collect(child, out); - return out; - } - - Timer { - interval: 1500 - running: true - repeat: true - property int step: 0 - onTriggered: { - if (step++ === 0) { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - SessionData.hiddenApps = ["org.kde.dolphin", "missing.app"]; - SessionData.appOverrides = { - "kitty": { - name: "Kitty Renamed", - icon: "" - }, - "ghost.app": { - name: "Ghost", - icon: "no-such-icon-name" - } - }; - root.tab = tabComponent.createObject(stage, { - width: 860 - }); - if (!root.tab) { - console.error("FIXTURE_FAIL " + tabComponent.errorString()); - Qt.quit(); - } - return; - } - const nodes = collect(root.tab, []); - const visibleTexts = nodes.filter(n => n.type === "StyledText" && n.visible).map(n => n.text); - check(visibleTexts.includes("missing.app") && visibleTexts.includes("Ghost"), "hidden and overridden apps that are not installed still list by id or override name"); - console.log("PARITY " + JSON.stringify({ - count: nodes.length, - images: nodes.filter(n => n.type !== "StyledText" && n.type !== "DankIcon"), - texts: visibleTexts.filter(t => t === "Kitty Renamed" || t === "Ghost" || t === "missing.app" || t.startsWith("Dolphin")) - })); - root.finish(); - stop(); - Qt.quit(); - } - } - - property var tab: null -} diff --git a/quickshell/tests/qml/list-reuse-opacity.qml b/quickshell/tests/qml/list-reuse-opacity.qml deleted file mode 100644 index d4e5e4db1..000000000 --- a/quickshell/tests/qml/list-reuse-opacity.qml +++ /dev/null @@ -1,155 +0,0 @@ -import QtQuick -import QtTest -import Quickshell -import qs.Common -import qs.Widgets -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - - property var values: [] - TestCase { - id: input - when: false - name: "list-reuse-opacity" - } - - function entries(prefix, count) { - const list = []; - for (let i = 0; i < count; i++) - list.push({ - id: prefix + i, - label: prefix + " " + i - }); - return list; - } - - PanelWindow { - color: "transparent" - implicitWidth: 400 - implicitHeight: 600 - anchors { - top: true - left: true - } - - DankListView { - id: list - anchors.fill: parent - reuseItems: true - highlightSelection: true - spacing: 4 - model: ScriptModel { - values: root.values - objectProp: "id" - } - delegate: DankListItem { - required property int index - required property var modelData - width: list.width - height: 48 - StyledText { - anchors.centerIn: parent - text: modelData.label - } - } - } - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - } - - function check(value, message) { - if (!value) - throw new Error(message); - } - - function ghosts(label) { - const bad = []; - for (let i = 0; i < list.count; i++) { - const item = list.itemAtIndex(i); - if (!item) - continue; - if (!item.visible || item.opacity < 0.99) - bad.push(i + ":" + (item.visible ? "opacity " + item.opacity.toFixed(2) : "hidden")); - } - root.check(bad.length === 0, label + " leaves ghost rows " + bad.join(", ")); - } - - Timer { - interval: 500 - running: true - onTriggered: { - try { - root.values = root.entries("a", 8); - input.wait(600); - root.ghosts("initial fill"); - - root.values = []; - root.values = root.entries("b", 8); - input.wait(20); - root.values = root.entries("c", 8); - input.wait(600); - root.ghosts("reset and refill during the add transition"); - - root.values = []; - input.wait(20); - root.values = root.entries("d", 8); - input.wait(30); - list.forceLayout(); - list.positionViewAtBeginning(); - root.values = root.entries("d", 8); - input.wait(600); - root.ghosts("refill with forced layout and same ids"); - - for (let round = 0; round < 6; round++) { - root.values = root.entries("r" + round, 6); - input.wait(round * 15); - } - input.wait(600); - root.ghosts("rapid replacement rounds"); - - root.values = root.entries("s", 40); - input.wait(600); - list.contentY = list.maximumContentY; - input.wait(100); - root.values = root.entries("t", 40); - input.wait(30); - list.contentY = 0; - input.wait(100); - list.contentY = list.maximumContentY / 2; - input.wait(600); - root.ghosts("churn while scrolled then jump"); - - for (let round = 0; round < 8; round++) { - list.contentY = (round % 2) ? list.maximumContentY : 0; - root.values = root.entries("u" + round, 40 - round); - input.wait(40); - } - list.contentY = 0; - input.wait(600); - root.ghosts("alternating scroll and replace"); - - list.reuseItems = false; - for (let round = 0; round < 8; round++) { - list.contentY = (round % 2) ? list.maximumContentY : 0; - root.values = root.entries("v" + round, 40 - round); - input.wait(40); - } - list.contentY = 0; - input.wait(600); - root.ghosts("alternating scroll and replace without reuse"); - - console.log("FIXTURE_PASS list reuse keeps every claimed row visible and opaque"); - } catch (error) { - console.error("FIXTURE_FAIL", error.message); - } - Qt.quit(); - } - } -} diff --git a/quickshell/tests/qml/list-settings.qml b/quickshell/tests/qml/list-settings.qml deleted file mode 100644 index 95f8afaa6..000000000 --- a/quickshell/tests/qml/list-settings.qml +++ /dev/null @@ -1,205 +0,0 @@ -import QtQuick -import Quickshell -import qs.Common -import qs.Modules.Plugins -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - - property bool failed: false - property int step: 0 - - 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 typeName(item) { - return String(item).split("(")[0].replace(/_QMLTYPE_\d+/, "").replace(/_QML_\d+/, ""); - } - - function collect(item, origin, out) { - const kids = (item.children || []).filter(child => child.visible && child.width > 0 && child.height > 0); - const paints = item.color !== undefined || (typeof item.text === "string" && item.font !== undefined); - if (paints || kids.length === 0) { - const pos = item.mapToItem(origin, 0, 0); - const entry = { - type: typeName(item), - x: Math.round(pos.x * 100) / 100, - y: Math.round(pos.y * 100) / 100, - w: Math.round(item.width * 100) / 100, - h: Math.round(item.height * 100) / 100 - }; - if (typeof item.text === "string" && item.font !== undefined) { - entry.text = item.text; - entry.px = item.font.pixelSize; - } - if (item.color !== undefined) - entry.color = String(item.color); - out.push(entry); - } - for (const child of kids) - collect(child, origin, out); - } - - function findAll(item, pred, out) { - if (pred(item)) - out.push(item); - for (const child of item.children || []) - findAll(child, pred, out); - return out; - } - - function snapshot(label) { - for (const pair of [["plain", plain], ["rows", rows]]) { - const nodes = []; - collect(pair[1], host, nodes); - console.log("PARITY " + JSON.stringify({ - label: label, - name: pair[0], - items: pair[1].items, - saves: host.saves, - nodes: nodes - })); - } - } - - PanelWindow { - id: win - implicitWidth: 640 - implicitHeight: 600 - color: "transparent" - - Item { - id: host - anchors.fill: parent - property var store: ({ - "plain": ["alpha", "beta"], - "rows": [ - { - "name": "Docs", - "url": "https://docs" - } - ] - }) - property var saves: [] - - function loadValue(key, fallback) { - return store[key] !== undefined ? store[key] : fallback; - } - - function saveValue(key, value) { - saves = saves.concat([key + "=" + JSON.stringify(value)]); - } - - Column { - width: parent.width - 32 - x: 16 - y: 16 - spacing: 24 - - ListSetting { - id: plain - settingKey: "plain" - label: "Blocked words" - description: "Words to hide" - } - - ListSettingWithInput { - id: rows - settingKey: "rows" - label: "Bookmarks" - description: "Name and address" - fields: [ - { - "id": "name", - "label": "Name", - "width": 140, - "required": true - }, - { - "id": "url", - "label": "URL", - "placeholder": "https://", - "width": 260 - } - ] - } - } - } - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - } - - Timer { - interval: 800 - running: true - repeat: true - onTriggered: { - switch (root.step++) { - case 0: - root.snapshot("loaded"); - check(JSON.stringify(plain.items) === JSON.stringify(["alpha", "beta"]), "plain list loads stored items"); - check(JSON.stringify(rows.items) === JSON.stringify([ - { - "name": "Docs", - "url": "https://docs" - } - ]), "row list loads stored items"); - check(host.saves.length === 0, "loading does not write back " + JSON.stringify(host.saves)); - plain.addItem("gamma"); - plain.removeItem(0); - return; - case 1: - root.snapshot("edited"); - check(JSON.stringify(plain.items) === JSON.stringify(["beta", "gamma"]), "plain add/remove"); - check(JSON.stringify(host.saves) === JSON.stringify(["plain=[\"alpha\",\"beta\",\"gamma\"]", "plain=[\"beta\",\"gamma\"]"]), "plain saves " + JSON.stringify(host.saves)); - const inputs = root.findAll(rows, item => item.placeholderText !== undefined && item.text !== undefined && root.typeName(item) === "DankTextField", []); - const button = root.findAll(rows, item => root.typeName(item) === "DankButton", [])[0]; - check(inputs.length === 2 && !!button, "row input fields and add button present"); - inputs[1].text = "https://x"; - button.clicked(); - check(rows.items.length === 1, "required name blocks add"); - inputs[0].text = "X"; - button.clicked(); - return; - case 2: - root.snapshot("added"); - check(JSON.stringify(rows.items) === JSON.stringify([ - { - "name": "Docs", - "url": "https://docs" - }, - { - "name": "X", - "url": "https://x" - } - ]), "row add " + JSON.stringify(rows.items)); - const fields = root.findAll(rows, item => item.placeholderText !== undefined && item.text !== undefined && root.typeName(item) === "DankTextField", []); - check(fields.every(field => field.text === ""), "inputs cleared after add"); - rows.removeItem(0); - plain.removeItem(0); - plain.removeItem(0); - return; - case 3: - root.snapshot("emptied"); - check(plain.items.length === 0 && rows.items.length === 1, "removals applied"); - root.finish(); - stop(); - Qt.quit(); - } - } - } -} diff --git a/quickshell/tests/qml/lock-status-row.qml b/quickshell/tests/qml/lock-status-row.qml deleted file mode 100644 index 3b34f974f..000000000 --- a/quickshell/tests/qml/lock-status-row.qml +++ /dev/null @@ -1,95 +0,0 @@ -import QtQuick -import QtTest -import Quickshell -import qs.Common -import qs.Services -import qs.Modules.Lock -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - - TestCase { - id: input - when: false - name: "lock-status-row" - } - - Item { - width: 1200 - height: 100 - - LockStatusRow { - id: full - showMediaPlayer: true - } - LockStatusRow { - id: quiet - showMediaPlayer: false - y: 50 - } - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - } - - function check(value, message) { - if (!value) - throw new Error(message); - } - - 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; - } - - Timer { - interval: 0 - running: true - onTriggered: { - try { - input.tryVerify(() => !!MprisController.activePlayer, 3000); - - const quietLoader = quiet.children.find(c => c.sourceComponent !== undefined && c.active !== undefined); - root.check(quietLoader && !quietLoader.active && quietLoader.item === null, "media group stays unloaded when showMediaPlayer is off"); - const fullLoader = full.children.find(c => c.sourceComponent !== undefined && c.active !== undefined); - input.tryVerify(() => fullLoader.active && fullLoader.item && fullLoader.visible, 1000, "media group loads with an active player"); - root.check(root.find(fullLoader.item, c => c.elide === Text.ElideRight).text !== "", "media title renders"); - root.check(quiet.implicitWidth === 0, "empty row takes no width"); - - WeatherService.weather = { - available: true, - temp: 21, - tempF: 70, - wCode: 0 - }; - const temperature = root.find(full, c => c.text !== undefined && String(c.text).includes("21")); - root.check(temperature && temperature.text.includes("21"), "weather shows celsius: " + (temperature ? temperature.text : "none")); - full.useFahrenheit = true; - root.check(temperature.text.includes("70"), "weather follows useFahrenheit"); - full.showWeather = false; - - const keyboardArea = root.find(full.children[0], c => c.hasOwnProperty("cursorShape") && c.hasOwnProperty("hoverEnabled")); - full.interactive = true; - root.check(keyboardArea.enabled, "interactive enables keyboard layout input"); - full.interactive = false; - root.check(!keyboardArea.enabled, "interactive off disables the keyboard layout click"); - - console.log("FIXTURE_PASS lock status row: media loader gating, weather units, interactive"); - } catch (error) { - console.error("FIXTURE_FAIL", error.message); - } - Qt.quit(); - } - } -} diff --git a/quickshell/tests/qml/media-artwork.qml b/quickshell/tests/qml/media-artwork.qml index 0f4c9f70e..936c44d99 100644 --- a/quickshell/tests/qml/media-artwork.qml +++ b/quickshell/tests/qml/media-artwork.qml @@ -68,9 +68,11 @@ ShellRoot { throw new Error(message); } function waitFor(condition, message) { - for (let attempt = 0; attempt < 300 && !condition(); attempt++) + const deadline = Date.now() + 20000; + while (!condition()) { + check(Date.now() < deadline, "timed out: " + message); input.wait(10); - check(condition(), message); + } } function committed(track) { const player = MprisController.activePlayer; @@ -103,16 +105,14 @@ ShellRoot { check(image.visible && image.retainWhileLoading, "cover stays painted during loading"); } Timer { - interval: 100 + interval: 0 running: true onTriggered: { try { root.cover = root.find(tab, item => item.artPixelSize !== undefined); root.check(!!root.cover, "dash artwork component"); - for (let attempt = 0; attempt < 40 && !TrackArtService.artReadyFor(MprisController.activePlayer); attempt++) - input.wait(50); + root.waitFor(() => !!MprisController.activePlayer && TrackArtService.artReadyFor(MprisController.activePlayer), "player discovered with artwork"); const player = MprisController.activePlayer; - root.check(!!player, "player discovered"); SessionData.suppressOSD = false; osd.show(); input.wait(300); @@ -145,27 +145,8 @@ ShellRoot { root.checkTrack(5, 2); const smallerArt = TrackArtService.resolvedArtUrl; root.check(root.coverChanges === 1, "late file completion cannot replace newer art"); - const backdrop = root.find(tab, item => typeof item.syncArt === "function" && item.artOpacity !== undefined); - root.check(!!backdrop, "dash backdrop"); - const x = Math.floor(backdrop.width / 2); - const y = Math.floor(backdrop.height / 2); - root.waitFor(() => !backdrop.transitioning, "backdrop settles before the same-cover commit"); - const reference = input.grabImage(backdrop).pixel(x, y); - const canonicalArt = TrackArtService.resolvedArtUrl; - TrackArtService._commit(player.trackArtUrl, TrackArtService._committedArtKey, player.trackArtUrl); - for (let sample = 0; sample < 15; sample++) { - input.wait(20); - const pixel = input.grabImage(backdrop).pixel(x, y); - root.check(["r", "g", "b", "a"].every(channel => Math.abs(pixel[channel] - reference[channel]) < 0.01), "same-cover crossfade preserves brightness and opacity"); - } - TrackArtService._commit(canonicalArt, TrackArtService._committedArtKey, player.trackArtUrl); - input.wait(300); player.next(); - input.wait(300); - root.check(player.trackTitle === "Track 6" && !TrackArtService.artReadyFor(player), "unchanged old cover is not accepted after the reuse delay"); root.checkTrack(6, 1); - tab.visible = false; - root.check(!backdrop.transitioning, "hidden backdrop stops its fade"); // Chrome attaches the previous track's late, smaller cover to the new track, and the two probes finish in either order const largerArt = TrackArtService.resolvedArtUrl; const key = TrackArtService._pendingArtKey; diff --git a/quickshell/tests/qml/media-lyrics.qml b/quickshell/tests/qml/media-lyrics.qml index 7efe8d303..521c46aa4 100644 --- a/quickshell/tests/qml/media-lyrics.qml +++ b/quickshell/tests/qml/media-lyrics.qml @@ -10,9 +10,6 @@ import qs.DankCommon.Common as DC ShellRoot { id: root - readonly property string shotDir: Quickshell.env("DMS_FIXTURE_SHOTS") - property bool captured: false - property bool unexpectedReply: false property bool recordSeekIndices: false property var seekIndices: [] readonly property int lyricIndex: media.lyrics.activeIndex @@ -106,11 +103,8 @@ ShellRoot { delete pending[id]; } function respond(result) { - for (let attempt = 0; attempt < 300 && !pending[nextId]; attempt++) - input.wait(10); + root.waitFor(() => !!pending[nextId], "a lyrics request is pending"); const callback = pending[nextId]; - if (!callback) - throw new Error("no lyrics request is pending"); delete pending[nextId]; callback(result); } @@ -122,15 +116,6 @@ ShellRoot { name: "media-lyrics" } - Component { - id: requestTimer - Timer { - interval: 20 - running: true - onTriggered: root.unexpectedReply = true - } - } - FloatingWindow { visible: true implicitWidth: 900 @@ -154,9 +139,11 @@ ShellRoot { } function waitFor(condition, message) { - for (let attempt = 0; attempt < 300 && !condition(); attempt++) + const deadline = Date.now() + 20000; + while (!condition()) { + check(Date.now() < deadline, "timed out: " + message); input.wait(10); - check(condition(), message); + } } function find(item, predicate) { @@ -170,34 +157,19 @@ ShellRoot { return null; } - function capture(name) { - if (!root.shotDir) - return; - captured = false; - stage.grabToImage(result => { - result.saveToFile(root.shotDir + "/lyrics-" + name + ".png"); - root.captured = true; - }); - input.tryCompare(root, "captured", true, 3000); + function seekPaused(position) { + source.position = position; + waitFor(() => Math.abs(media.lyrics.sampleTime - position) < 0.01, "lyrics resync to " + position); } - function artCorners(artwork) { - if (artwork.artRadius < 2) - return ""; - const surface = stage.parent; - const rendered = input.grabImage(surface); - const scale = rendered.width / surface.width; - const origin = artwork.mapToItem(surface, 0, 0); - const left = Math.ceil(origin.x * scale); - const top = Math.ceil(origin.y * scale); - const right = Math.floor((origin.x + artwork.width) * scale) - 1; - const bottom = Math.floor((origin.y + artwork.height) * scale) - 1; - return [[left, top], [right, top], [left, bottom], [right, bottom]].map(point => [rendered.red(point[0], point[1]), rendered.green(point[0], point[1]), rendered.blue(point[0], point[1])].join(",")).join(";"); + 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 { - check(!!media.activePlayer, "fixture player discovered"); + waitFor(() => !!media.activePlayer, "fixture player discovered"); DMSService.capabilities = ["lyrics"]; SettingsData.reduceMotion = true; SettingsData.dashOptions = { @@ -209,18 +181,9 @@ ShellRoot { MprisController.stableTitle = "Lyrics fixture"; MprisController.stableArtist = "Fixture artist"; MprisController.activePlayerStableLength = 180; - TrackArtService.artwork = { - url: media.activePlayer.trackArtUrl, - key: "fixture", - source: "fixture", - colors: [Qt.rgba(0.89, 0.77, 0.43, 1)] - }; media.wallpaperEnabled = false; - input.wait(30); - let artwork = find(media, item => item.artRadius !== undefined); - let corners = artCorners(artwork); media.lyricsOpen = true; - input.tryCompare(backend, "calls", 1, 1500); + waitFor(() => backend.calls > 0, "lyrics lookup starts"); check(backend.calls === 1, "metadata changes produce one lookup"); backend.respond({ result: { @@ -231,96 +194,46 @@ ShellRoot { root.recordSeekIndices = true; source.position = 30; source.position = 10; - input.wait(30); + waitFor(() => media.lyrics.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(media.lyrics.lines.length === 5 && media.lyrics.lines[2].x.includes("backing vocal"), "same-time lyrics stay together"); + waitFor(() => media.lyricsFocusTarget?.activeFocus, "lyrics receives keyboard focus"); let overlay = media.lyricsFocusTarget; - check(overlay && overlay.activeFocus, "lyrics receives keyboard focus"); - check(overlay.width === artwork.width && overlay.height === artwork.height, "Bento lyrics stay within the artwork shape"); - const openCorners = artCorners(artwork); - check(openCorners === corners, "opening lyrics leaves pixels outside the artwork untouched: " + corners + " -> " + openCorners); const line = find(overlay, item => item.text === root.longLine && item.truncated !== undefined); check(!!line && !line.truncated && line.lineCount > 2, "long lyrics wrap without eliding"); - const wrappedLineCount = line.lineCount; - source.position = 20; - input.wait(30); - check(line.lineCount === wrappedLineCount, "lyric highlighting preserves line breaks"); - source.position = 10; - input.wait(30); - capture("bento"); input.keyClick(Qt.Key_PageDown); - input.wait(30); check(!overlay.following && input.findChild(overlay, "followPlayback")?.visible, "browsing pauses automatic following"); input.findChild(overlay, "followPlayback").click(); - input.wait(20); check(overlay.following, "follow action resumes synchronized scrolling"); - SettingsData.reduceMotion = false; - source.position = 20; - input.wait(Theme.expressiveDurations.expressiveDefaultSpatial + 20); - capture("following"); source.playbackState = MprisPlaybackState.Paused; - input.wait(30); check(!media.lyrics.tick.running, "paused playback has no lyric timer"); source.playbackState = MprisPlaybackState.Playing; - input.wait(10); check(media.lyrics.tick.running, "resuming schedules the next line"); source.position = 9.5; source.playbackState = MprisPlaybackState.Paused; - input.wait(20); check(Math.abs(media.lyrics.currentTime() - 9.5) < 0.05 && media.lyrics.activeIndex === 0 && !media.lyrics.tick.running, "pausing immediately after a seek retains the seek target"); - source.playbackState = MprisPlaybackState.Playing; - source.position = 0; - input.wait(10); - check(media.lyrics.activeIndex === 0, "seeking backwards restores the correct line"); - source.position = 30; - input.tryCompare(media.lyrics, "wordTime", 30.25, 500); + 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"); - source.playbackState = MprisPlaybackState.Paused; - source.position = 30.3; - input.wait(20); 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"); - const timedLineCount = wordText.lineCount; - capture("words"); - source.position = 31; - input.wait(20); + 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; - const resumed = Date.now(); - waitFor(() => wordRow.wordProgress > 0.27, "word progress animates"); - const wordProgress = wordRow.wordProgress; - // the word spans two seconds from 30.5, so progress may only advance as far as playback has - check(wordProgress < 0.25 + (Date.now() - resumed) / 2000 + 0.05, "word progress follows playback instead of jumping"); - check(wordText.lineCount === timedLineCount, "word progress does not reflow the line"); - overlay.scrollBy(-10000); - input.wait(20); - const offscreenProgress = wordRow.wordProgress; - check(!wordRow.inViewport && !wordRow.animateWords, "browsing away stops offscreen word animation"); - input.wait(80); - check(wordRow.wordProgress === offscreenProgress, "offscreen highlights do not keep animating"); - overlay.following = true; - overlay.snapToCurrent(); - input.wait(30); - check(wordRow.animateWords && Math.abs(wordRow.wordProgress - (media.lyrics.currentTime() - 30.5) / 2) < 0.05, "returning to the active word samples current playback time"); - source.playbackState = MprisPlaybackState.Paused; - source.position = 30.1; - input.tryCompare(media.lyrics, "wordTime", 30, 100); - source.playbackState = MprisPlaybackState.Playing; media.lyricsOpen = false; - input.wait(20); - check(!media.lyrics.tick.running && !media.lyricsFocusTarget, "closing releases the view and stops scheduling"); + waitFor(() => !media.lyricsFocusTarget, "closing releases the view"); + check(!media.lyrics.tick.running, "closing stops scheduling"); media.lyricsOpen = true; - input.wait(30); + waitFor(() => !!media.lyricsFocusTarget, "reopened lyrics load"); check(backend.calls === 1 && media.lyrics.state === "ready", "reopening reuses loaded lyrics"); media.lyrics.request(); const stale = backend.pending[backend.nextId]; MprisController.stableTitle = "Next track"; - input.wait(10); - check(backend.cancelled > 0, "track change cancels old response handler"); + waitFor(() => backend.cancelled > 0, "track change cancels old response handler"); stale({ result: { found: true, @@ -328,44 +241,22 @@ ShellRoot { } }); check(media.lyrics.state !== "ready", "late response cannot populate a new track"); - input.wait(DashMetrics.mediaLyricsLoadingDelay + 20); backend.respond({ result: { found: true, plain: Array(20).fill(root.longLine).join("\n") } }); - input.wait(30); + waitFor(() => !!media.lyricsFocusTarget, "plain lyrics load"); overlay = media.lyricsFocusTarget; - input.keyClick(Qt.Key_End); - input.wait(20); - const list = find(overlay, item => typeof item.positionViewAtEnd === "function"); - check(list.contentY > 0 && !media.lyrics.synced, "plain lyrics scroll to the end"); - capture("plain"); - SettingsData.dashOptions = { - media: { - playerStyle: "material", - lyrics: true - } - }; - input.wait(30); - media.lyricsOpen = true; - input.wait(40); - overlay = media.lyricsFocusTarget; - check(overlay.width > stage.width / 2, "Material gives lyrics the full header width"); - const materialLine = find(overlay, item => item.text === root.longLine && item.truncated !== undefined); - check(materialLine && !materialLine.truncated, "Material preserves complete lyrics"); - const materialSeekbar = find(media, item => typeof item.seekTo === "function"); - check(materialSeekbar.visible && materialSeekbar.canSeek, "Material keeps the timeline available with lyrics open"); - input.mouseClick(materialSeekbar, materialSeekbar.width / 2, materialSeekbar.height / 2); - input.tryVerify(() => media.activePlayer.position > 75, 1000, "lyrics do not intercept timeline input"); - capture("material"); 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(!media.lyrics.synced, "plain lyrics are not synchronized"); input.keyClick(Qt.Key_Escape); - input.wait(20); check(!media.lyricsOpen, "Escape closes lyrics"); media.lyricsOpen = true; - input.wait(20); source.metadata = { "xesam:asText": "Embedded text remains available offline" }; @@ -397,13 +288,13 @@ ShellRoot { plain: "stale provider lyrics" } }); - input.tryCompare(backend, "calls", oldCalls + 1, 1000); + waitFor(() => backend.calls === oldCalls + 1, "provider change requests again"); check(JSON.stringify(backend.lastParams.providers) === '["lrclib","lyricsplus"]' && media.lyrics.state === "loading", "priority changes discard pending results and request enabled providers in order"); SettingsData.mediaLyricsProviders = MediaOptions.lyricsProviders.map(provider => ({ id: provider.id, enabled: false })); - input.tryCompare(backend, "calls", oldCalls + 2, 1000); + 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: { @@ -411,39 +302,6 @@ ShellRoot { } }); check(media.lyrics.state === "ready" && !media.lyrics.synced, "embedded lyrics remain available with all providers off"); - SettingsData.dashOptions = { - media: { - playerStyle: "bento", - lyrics: true, - artStyle: "circle" - } - }; - stage.width = 320; - SessionData.locale = "ar"; - SettingsData.reduceMotion = true; - input.wait(30); - artwork = find(media, item => item.artRadius !== undefined); - corners = artCorners(artwork); - media.lyricsOpen = true; - input.wait(30); - overlay = media.lyricsFocusTarget; - artwork = find(media, item => item.artRadius !== undefined); - check(overlay.width === artwork.width, "compact circular lyrics preserve the artwork boundary"); - check(artCorners(artwork) === corners, "circular lyrics leave the artwork corners untouched"); - capture("compact-rtl"); - stage.width = 780; - input.wait(30); - capture("circle"); - SessionData.locale = "en"; - SettingsData.reduceMotion = false; - SettingsData.dashOptions = { - media: { - playerStyle: "bento", - lyrics: true - } - }; - input.wait(30); - media.lyricsOpen = true; source.playbackState = MprisPlaybackState.Paused; source.position = 13; media.lyrics.request(); @@ -552,75 +410,36 @@ ShellRoot { ] } }); - input.wait(30); + waitFor(() => !!media.lyricsFocusTarget && media.lyrics.synced, "duet lyrics load"); overlay = media.lyricsFocusTarget; overlay.snapToCurrent(); - input.wait(20); - const leadVocal = find(overlay, item => item.part?.x === "Lead held"); - const backingVocal = find(overlay, item => item.part?.x === "Echo"); - const replyVocal = find(overlay, item => item.part?.x === "Reply"); - check(leadVocal?.current && backingVocal?.current && replyVocal?.current, "both singers and backing vocals can be active together"); + 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(media.lyrics.lines[1].parts.length === 2, "backing vocals remain grouped with their lead"); - capture("duet"); - const duetReplyY = replyVocal.mapToItem(overlay, 0, 0).y; - source.position = 17; - input.wait(20); + seekPaused(17); check(leadVocal.current && !backingVocal.current && !replyVocal.current && media.lyrics.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"); - check(Math.abs(replyVocal.mapToItem(overlay, 0, 0).y - duetReplyY) < 1, "vocals ending above the active line leave it in place"); - source.position = 18.5; - const duetList = find(overlay, item => typeof item.positionViewAtEnd === "function"); - const replyLine = duetList.itemAtIndex(2); - input.tryVerify(() => Math.abs(replyLine.y + replyLine.height / 2 - duetList.contentY - duetList.height / 2) < 2, 1000, "the active line centers once the overlapping line above it is done"); - source.position = 22.5; - input.wait(20); - const finishedLine = find(overlay, item => item.part?.x === "After the duet"); + seekPaused(22.5); + const finishedLine = vocal(overlay, "After the duet"); check(!finishedLine.current && finishedLine.highlighted, "completed line timing holds its highlight through the pause"); - source.position = 25.1; - input.wait(20); + seekPaused(25.1); check(!finishedLine.highlighted, "the old line loses its highlight when focus advances"); - source.position = 34; - input.tryVerify(() => media.lyrics.sampleTime >= 33.9, 1000, "seek to 34 resynced"); + seekPaused(34); overlay.snapToCurrent(); - input.tryVerify(() => !!find(overlay, item => item.part?.x === "Held over"), 1000, "held line is realized"); - const heldOver = find(overlay, item => item.part?.x === "Held over"); + const heldOver = vocal(overlay, "Held over"); check(heldOver.highlighted, "overlapping lines from different singers stay colored together"); - source.position = 36.5; - input.tryVerify(() => media.lyrics.sampleTime >= 36.4, 1000, "seek to 36.5 resynced"); + seekPaused(36.5); check(!heldOver.highlighted, "an overlapping line loses its color once it ends, before focus advances"); - source.position = 44; - input.tryVerify(() => media.lyrics.sampleTime >= 43.9, 1000, "seek to 44 resynced"); + seekPaused(44); overlay.snapToCurrent(); - input.tryVerify(() => !!find(overlay, item => item.part?.x === "Overlong"), 1000, "overlong line is realized"); - const overlong = find(overlay, item => item.part?.x === "Overlong"); + 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"); - source.position = 13; - input.wait(20); + seekPaused(13); check(backingVocal.current && replyVocal.current && Math.abs(replyVocal.wordProgress - 0.25) < 0.01, "seeking back restores every overlapping part"); - SessionData.locale = "ar"; - input.wait(20); - capture("duet-rtl"); - SessionData.locale = "en"; - SettingsData.dashOptions = { - media: { - playerStyle: "material", - lyrics: true - } - }; - input.wait(30); - media.lyricsOpen = true; - input.wait(30); - overlay = media.lyricsFocusTarget; - check(find(overlay, item => item.part?.x === "Echo")?.current, "Material shares the same independent vocal renderer"); - overlay.snapToCurrent(); - input.wait(20); - const materialList = find(overlay, item => typeof item.positionViewAtEnd === "function"); - const materialLead = find(overlay, item => item.part?.x === "Lead held"); - const materialReply = find(overlay, item => item.part?.x === "Reply"); - check(materialLead.mapToItem(materialList, 0, 0).y >= 0 && materialReply.mapToItem(materialList, 0, materialReply.height).y <= materialList.height, "Material keeps overlapping vocals in view when they fit together"); - capture("duet-material"); MprisController._syncStableMeta(); media.lyrics.player = media; media.lyrics.request(); @@ -633,39 +452,15 @@ ShellRoot { const seekbar = find(media, item => typeof item.seekTo === "function"); for (const target of [31, 10.3, 20.2, 31.4]) { seekbar.seekTo(target); - input.wait(30); - check(Math.abs(media.lyrics.currentTime() - media.activePlayer.position) < 0.05 && media.lyrics.activeIndex === media.lyrics.indexFor(target, media.lyrics.lines), "DMS seekbar resynchronizes against real MPRIS after forward and backward seeks"); + waitFor(() => media.activePlayer.position >= target - 0.05 && media.activePlayer.position < target + 5 && Math.abs(media.lyrics.currentTime() - media.activePlayer.position) < 0.05 && media.lyrics.activeIndex === media.lyrics.indexFor(target, media.lyrics.lines), "DMS seekbar resynchronizes against real MPRIS after seeking to " + target); } - const revisions = media.lyrics.wordRevision; - input.wait(100); - check(media.lyrics.wordRevision === revisions, "ordinary MPRIS progress notifications do not restart word highlights"); - const beforePause = media.lyrics.currentTime(); media.activePlayer.pause(); - input.tryCompare(media.lyrics, "playing", false, 500); - input.wait(30); - check(media.lyrics.currentTime() >= beforePause - 0.05 && media.lyrics.currentTime() < beforePause + 0.15 && !media.lyrics.tick.running, "pausing freezes the current lyric clock without jumping backwards"); + waitFor(() => !media.lyrics.playing, "real MPRIS pause reaches lyrics"); seekbar.seekTo(30.3); - input.wait(30); - check(Math.abs(media.lyrics.currentTime() - 30.3) < 0.01 && media.lyrics.wordTime === 30.25, "DMS seekbar updates the current word while paused: " + [media.lyrics.currentTime(), media.lyrics.wordTime, media.activePlayer.position, media.lyrics.position, media.lyrics.playing]); + waitFor(() => Math.abs(media.lyrics.currentTime() - 30.3) < 0.01 && media.lyrics.wordTime === 30.25, "DMS seekbar updates the current word while paused"); media.activePlayer.play(); - input.tryCompare(media.lyrics, "playing", true, 500); - input.wait(100); - check(media.lyrics.currentTime() > 30.35 && Math.abs(media.lyrics.currentTime() - media.activePlayer.position) < 0.05, "resuming continues from the seek target"); - media.lyricsOpen = false; - const requestId = "lyrics-cancellation-fixture"; - DMSService.pendingRequests[requestId] = () => root.unexpectedReply = true; - DMSService.requestTimeouts[requestId] = requestTimer.createObject(root); - DMSService.clipboardRequestIds[requestId] = true; - DMSService.cancelRequest(requestId); - check(DMSService.clipboardRequestIds[requestId], "cancelled clipboard replies retain their redaction marker"); - delete DMSService.clipboardRequestIds[requestId]; - DMSService.handleResponse({ - id: requestId, - result: {} - }); - input.wait(30); - check(!root.unexpectedReply, "cancellation suppresses replies and removes timeout work"); - console.log("FIXTURE_PASS lyric wrapping, synchronized scrolling, browsing, lifecycle, stale replies and Material layout"); + waitFor(() => media.lyrics.currentTime() > 30.35 && Math.abs(media.lyrics.currentTime() - media.activePlayer.position) < 0.05, "resuming continues from the seek target"); + console.log("FIXTURE_PASS lyric lookup, seeking, word timing, overlapping vocals, stale replies and provider fallback"); Qt.quit(); } catch (error) { console.error("FIXTURE_FAIL", error); @@ -674,7 +469,7 @@ ShellRoot { } Timer { - interval: 600 + interval: 0 running: true onTriggered: root.run() } diff --git a/quickshell/tests/qml/media-playback.qml b/quickshell/tests/qml/media-playback.qml index a6d9f2abf..095906832 100644 --- a/quickshell/tests/qml/media-playback.qml +++ b/quickshell/tests/qml/media-playback.qml @@ -44,9 +44,11 @@ ShellRoot { throw new Error(message); } function waitFor(condition, message) { - for (let attempt = 0; attempt < 200 && !condition(); attempt++) + const deadline = Date.now() + 20000; + while (!condition()) { + check(Date.now() < deadline, "timed out: " + message); input.wait(10); - check(condition(), message); + } } function find(item) { if (!item) @@ -93,12 +95,12 @@ ShellRoot { MediaAccentService._accent = original; } Timer { - interval: 1000 + interval: 0 running: true onTriggered: { try { + root.waitFor(() => !!MprisController.activePlayer, "player discovered"); const player = MprisController.activePlayer; - root.check(!!player, "player discovered"); root.tracking = true; dash.requestTab("media"); dash.dashVisible = true; @@ -136,9 +138,7 @@ ShellRoot { input.wait(200); root.check(root.playerLosses === 0, "paused track transition retains player"); player.stop(); - for (let attempt = 0; attempt < 120 && MprisController.activePlayer; attempt++) - input.wait(25); - root.check(MprisController.activePlayer === null, "stopped player clears after grace"); + root.waitFor(() => MprisController.activePlayer === null, "stopped player clears after grace"); console.log("FIXTURE_PASS media player continuity, popout retained, album art accent in both OSD layouts"); } catch (error) { console.error("FIXTURE_FAIL", error.message); diff --git a/quickshell/tests/qml/media-presentation.qml b/quickshell/tests/qml/media-presentation.qml index 124bcc41c..63ff007b2 100644 --- a/quickshell/tests/qml/media-presentation.qml +++ b/quickshell/tests/qml/media-presentation.qml @@ -64,24 +64,26 @@ ShellRoot { } function waitFor(condition, label) { - for (let attempt = 0; attempt < 200 && !condition(); attempt++) + const deadline = Date.now() + 20000; + while (!condition()) { + check(Date.now() < deadline, "timed out: " + label); input.wait(10); - check(condition(), label); + } } function waitStable(sample, label) { - // a starved runner can hold the old layout past the sampling window below const surface = media.Window.window; - check(!input.isPolishScheduled(surface) || input.waitForPolish(surface, 5000), label); + check(!input.isPolishScheduled(surface) || input.waitForPolish(surface, 20000), label); + const deadline = Date.now() + 20000; let value = sample(); let repeats = 0; - for (let attempt = 0; attempt < 200 && repeats < 3; attempt++) { + while (repeats < 3) { + check(Date.now() < deadline, "timed out: " + label); input.wait(10); const current = sample(); repeats = current === value ? repeats + 1 : 0; value = current; } - check(repeats >= 3, label); return value; } @@ -105,26 +107,23 @@ ShellRoot { function run() { try { + waitFor(() => !!MprisController.activePlayer, "fixture player discovered"); const player = MprisController.activePlayer; - check(!!player, "fixture player discovered"); SettingsData.reduceMotion = true; MprisController.activePlayerStableLength = 180; - input.wait(30); + 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); - check(seekbar?.visible && next?.visible && !!sheet, "playback controls and sheet loaded"); 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; - input.wait(50); 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; - input.wait(50); 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; @@ -161,7 +160,7 @@ ShellRoot { SettingsData.reduceMotion = true; media.showPanel("players"); player.stop(); - input.wait(80); + 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; @@ -170,7 +169,6 @@ ShellRoot { input.wait(20); waitFor(() => media.panel === "" && media.implicitHeight === height, "sheet dismisses without resizing card"); media.activePlayer = null; - input.wait(30); check(next.visible && seekbar.visible, "player loss holds controls through the grace period"); waitFor(() => !next.visible && !seekbar.visible, "genuine player loss eventually clears controls"); media.activePlayer = player; @@ -185,9 +183,10 @@ ShellRoot { check(media.playerStyle === "material" && media.panel === "" && !media.isSeeking, "style switch closes open sheet and clears seeking"); const materialHeight = media.implicitHeight; media.showPanel("players"); - input.wait(30); - const materialSheet = find(viewport, item => item.dismissible !== undefined); - check(materialSheet.opened && materialSheet.containsItem(materialSheet.windowFocusItem), "Material uses the shared sheet with focus"); + 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); @@ -202,8 +201,7 @@ ShellRoot { playerStyle: "bento" } }; - input.wait(30); - check(!!find(media, item => item.loop === true), "switching back restores Bento"); + waitFor(() => !!find(media, item => item.loop === true), "switching back restores Bento"); media.lyricsOpen = false; media.playerPaneOpen = false; input.wait(30); @@ -220,9 +218,9 @@ ShellRoot { } }; DMSService.capabilities = ["lyrics"]; - input.wait(30); + 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 && !lyricsToggle.visible, "lyrics option gates the button"); + check(!lyricsToggle.visible, "lyrics option gates the button"); SettingsData.dashOptions = { media: { playerStyle: "bento", @@ -231,58 +229,6 @@ ShellRoot { }; input.wait(30); check(lyricsToggle.visible && !media.lyricsFocusTarget, "an enabled lyrics button does not load the overlay"); - media.lyricsOpen = true; - for (const locale of ["ar", "en"]) { - SessionData.locale = locale; - waitFor(() => I18n.isRtl === (locale === "ar"), "locale direction updates"); - check(lyricsToggle.isSelected(1), "locale changes preserve the lyrics selection"); - } - for (const locale of ["en", "ar"]) { - SessionData.locale = locale; - waitFor(() => I18n.isRtl === (locale === "ar"), "layout uses the requested text direction"); - for (const width of [780, 420, 320]) { - viewport.width = width; - media.playerPaneOpen = true; - media.lyricsOpen = false; - input.wait(30); - const source = find(media, item => item.panelId === "players"); - const layout = () => { - const origin = source.mapToItem(media, 0, 0); - return media.implicitHeight + ":" + origin.x + ":" + origin.y; - }; - waitStable(layout, "card settles at " + width + " in " + locale); - const cardHeight = media.implicitHeight; - const sourcePosition = source.mapToItem(media, 0, 0); - for (const panes of [[true, false], [true, true], [false, true], [false, false]]) { - media.playerPaneOpen = panes[0]; - media.lyricsOpen = panes[1]; - input.wait(30); - waitFor(() => { - const position = source.mapToItem(media, 0, 0); - return media.implicitHeight === cardHeight && position.x === sourcePosition.x && position.y === sourcePosition.y; - }, "view combinations keep the card height and the source in place at " + width + " in " + locale); - check(source.visible === (panes[0] || panes[1]), "source hides only in artwork view"); - check(lyricsToggle.isSelected(1) === panes[1], "toggle selection follows the lyrics state"); - check(!!media.lyricsFocusTarget === panes[1], "lyrics focus follows the visible pane"); - check(count(media, item => item.following !== undefined && item.followSnap !== undefined) === (panes[1] ? 1 : 0), "only one lyrics overlay is loaded"); - const artworkPosition = artView.mapToItem(media, 0, 0); - check(artworkPosition.x >= 0 && artworkPosition.x + artView.width <= media.width, "artwork stays inside narrow and mirrored cards"); - const togglePosition = lyricsToggle.mapToItem(media, 0, 0); - check(togglePosition.x >= artworkPosition.x && togglePosition.x + lyricsToggle.width <= artworkPosition.x + artView.width, "view buttons stay inside the artwork horizontally"); - check(togglePosition.y >= artworkPosition.y && togglePosition.y + lyricsToggle.height <= artworkPosition.y + artView.height, "view buttons stay inside the artwork vertically"); - if (panes[0] || panes[1]) - continue; - const artPlay = find(media, item => item.mediaAction === "play" && item.visible); - check(!!artPlay, "artwork view keeps a playback focus target"); - artPlay.forceActiveFocus(Qt.TabFocusReason); - input.wait(20); - const floatingTransport = find(media, item => item.focusWithin !== undefined); - check(floatingTransport.opacity === 1, "keyboard focus reveals artwork playback"); - } - } - } - SessionData.locale = "en"; - viewport.width = 780; media.playerPaneOpen = true; media.lyricsOpen = false; input.wait(30); @@ -309,15 +255,13 @@ ShellRoot { check(media.cycleFocus(true) && (artworkSegment.activeFocus || lyricsSegment.activeFocus), "backward focus returns to the group"); media.playerPaneOpen = true; lyricsToggle.selectItem(1); - input.wait(30); - check(media.lyricsOpen && !!media.lyricsFocusTarget, "lyrics button loads the overlay"); + 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"); - input.wait(50); - check(player.isPlaying !== wasPlaying, "non-modal lyrics let playback keys through"); + waitFor(() => player.isPlaying !== wasPlaying, "non-modal lyrics let playback keys through"); media.showPanel("players"); input.wait(30); check(media.handleKeyEvent({ @@ -332,8 +276,7 @@ ShellRoot { key: Qt.Key_Escape, modifiers: 0 }), "escape falls through once nothing is open"); - input.wait(30); - check(!media.lyricsFocusTarget, "closing lyrics destroys the overlay"); + 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"); @@ -368,7 +311,7 @@ ShellRoot { } Timer { - interval: 500 + interval: 0 running: true onTriggered: root.run() } diff --git a/quickshell/tests/qml/network-info-modal.qml b/quickshell/tests/qml/network-info-modal.qml deleted file mode 100644 index 96c4639a8..000000000 --- a/quickshell/tests/qml/network-info-modal.qml +++ /dev/null @@ -1,147 +0,0 @@ -import QtQuick -import Quickshell -import qs.Common -import qs.Services -import qs.Modals -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"); - } - - property var modals: [] - property int step: 0 - - Component { - id: infoModal - NetworkInfoModal {} - } - - function typeName(item) { - return String(item).split("(")[0].replace(/_QMLTYPE_\d+/, "").replace(/_QML_\d+/, ""); - } - - function texts(modal) { - const content = modal.contentLoader?.item; - if (!content) - return []; - const out = []; - const walk = tree => { - if (tree.text !== undefined) - out.push(tree.text); - for (const child of tree.children) - walk(child); - }; - walk(dump(content, content)); - return out; - } - - function dump(item, origin) { - const pos = item.mapToItem(origin, 0, 0); - const entry = { - type: typeName(item), - x: Math.round(pos.x * 100) / 100, - y: Math.round(pos.y * 100) / 100, - w: item.width, - h: item.height - }; - if (typeof item.text === "string" && item.font !== undefined) { - entry.text = item.text; - entry.px = item.font.pixelSize; - } - if (item.color !== undefined) - entry.color = String(item.color); - entry.children = (item.children || []).filter(child => child.visible).map(child => dump(child, origin)); - return entry; - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - NetworkService.networkInfoDetails = "SSID: Home\\nSignal: 80%"; - NetworkService.networkWiredInfoDetails = "Interface: eth0\\nSpeed: 1000"; - const wifi = infoModal.createObject(root); - const wired = infoModal.createObject(root, { - wired: true - }); - root.modals = [ - { - name: "wifi", - item: wifi - }, - { - name: "wired", - item: wired - } - ]; - } - - Timer { - interval: 1500 - running: true - repeat: true - onTriggered: { - switch (root.step++) { - case 0: - root.modals[0].item.showNetworkInfo("Home WiFi", { - ssid: "Home WiFi" - }); - return; - case 1: - for (const modal of root.modals) { - const content = modal.item.contentLoader?.item; - console.log("PARITY " + JSON.stringify({ - name: modal.name, - visible: modal.item.visible, - namespace: modal.item.layerNamespace, - w: modal.item.alignedWidth, - h: modal.item.alignedHeight, - tree: content ? dump(content, content) : null - })); - } - check(root.modals[0].item.networkInfoModalVisible && !root.modals[1].item.networkInfoModalVisible, "only the wifi modal is open"); - check(texts(root.modals[0].item).some(text => text.includes("Home WiFi")) && texts(root.modals[0].item).includes("SSID: Home\nSignal: 80%"), "wifi modal shows name and details: " + texts(root.modals[0].item)); - root.modals[0].item.hideDialog(); - root.modals[1].item.showNetworkInfo("Wired connection 1", { - uuid: "1234" - }); - return; - case 2: - for (const modal of root.modals) { - const content = modal.item.contentLoader?.item; - console.log("PARITY " + JSON.stringify({ - name: modal.name + "-2", - visible: modal.item.visible, - namespace: modal.item.layerNamespace, - w: modal.item.alignedWidth, - h: modal.item.alignedHeight, - tree: content ? dump(content, content) : null - })); - } - check(!root.modals[0].item.networkInfoModalVisible && root.modals[1].item.networkInfoModalVisible, "only the wired modal is open"); - check(texts(root.modals[1].item).some(text => text.includes("Wired connection 1")) && texts(root.modals[1].item).includes("Interface: eth0\nSpeed: 1000"), "wired modal shows connection name and details: " + texts(root.modals[1].item)); - check(root.modals[0].item.layerNamespace !== root.modals[1].item.layerNamespace, "wifi and wired use distinct layer namespaces"); - root.modals[1].item.hideDialog(); - return; - default: - root.finish(); - stop(); - Qt.quit(); - } - } - } -} diff --git a/quickshell/tests/qml/osd-level.qml b/quickshell/tests/qml/osd-level.qml deleted file mode 100644 index 11da1d892..000000000 --- a/quickshell/tests/qml/osd-level.qml +++ /dev/null @@ -1,148 +0,0 @@ -import QtQuick -import QtTest -import Quickshell -import qs.Common -import qs.Services -import qs.Modules.OSD -import qs.Modules.DankIsland -import qs.Modules.DankIsland.Activities -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - - property var requested: [] - property var osd: null - - TestCase { - id: input - when: false - name: "osd-level" - } - - function findSlider(item) { - if (item.handleWidth !== undefined && item.handleHeight !== undefined) - return item; - for (const child of item.children ?? []) { - const found = findSlider(child); - if (found) - return found; - } - return null; - } - - function check(condition, message) { - if (!condition) - throw new Error(message); - } - - Component { - id: volumeComponent - VolumeOSD {} - } - Component { - id: micComponent - MicVolumeOSD {} - } - Component { - id: mediaComponent - MediaVolumeOSD {} - } - Component { - id: brightnessComponent - BrightnessOSD {} - } - Component { - id: sourceComponent - IslandSystemSource { - controller: null - } - } - Component { - id: homeComponent - HomeCompact { - controller: null - systemModel: null - } - } - Component { - id: levelComponent - LevelOSD { - iconName: "volume_up" - iconInteractive: true - minimum: 10 - maximum: 60 - value: 35 - onLevelRequested: level => root.requested.push(level) - } - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - } - - Timer { - interval: 400 - running: true - repeat: true - property int step: 0 - onTriggered: { - try { - switch (step++) { - case 0: - for (const component of [volumeComponent, micComponent, mediaComponent, brightnessComponent, sourceComponent, homeComponent, levelComponent]) - root.check(component.status === Component.Ready, "compile " + component.errorString()); - root.osd = levelComponent.createObject(root, { - modelData: Quickshell.screens[0] - }); - SettingsData.osdPosition = SettingsData.Position.TopCenter; - SessionData.suppressOSD = false; - root.osd.show(); - break; - case 1: - root.check(root.osd.shouldBeVisible && !root.osd.isVerticalLayout, "horizontal osd shown"); - root.check(root.osd.contentLoader.item.maximum === 60, "horizontal face carries the model range"); - root.osd.requestLevel(42); - root.osd.available = false; - root.osd.requestLevel(50); - root.osd.available = true; - root.check(root.requested.length === 1 && root.requested[0] === 42, "unavailable osd rejects level requests"); - SettingsData.osdPosition = SettingsData.Position.LeftCenter; - break; - case 2: - root.check(root.osd.isVerticalLayout, "vertical layout follows osd position"); - const face = root.findSlider(root.osd.contentLoader.item); - root.check(Math.abs(face.ratio - 0.5) < 0.01, "vertical face ratio uses the model range"); - root.osd.value = 60; - root.check(Math.abs(face.ratio - 1) < 0.01, "vertical face follows value"); - root.osd.value = 35; - for (let position = 0; position < 8; position++) { - SettingsData.osdPosition = position; - input.wait(50); - const row = root.osd.contentLoader.item; - const slider = root.findSlider(row); - root.check(slider.rotation === (root.osd.isVerticalLayout ? -90 : 0), "slider orientation at position " + position); - root.check(slider.height <= (row.vertical ? row.width : row.height), "handle fits across the OSD"); - const track = slider.contentItem.children[1]; - input.mouseClick(track, track.width - slider.handleWidth / 2, track.height / 2); - root.check(root.requested[root.requested.length - 1] === 60, "OSD pointer reaches maximum at position " + position); - input.mouseClick(track, slider.handleWidth / 2, track.height / 2); - root.check(root.requested[root.requested.length - 1] === 10, "OSD pointer reaches minimum at position " + position); - } - SettingsData.osdPosition = SettingsData.Position.TopCenter; - root.osd.hide(); - console.log("FIXTURE_PASS"); - stop(); - Qt.quit(); - } - } catch (error) { - console.error("FIXTURE_FAIL", error.message); - stop(); - Qt.quit(); - } - } - } -} diff --git a/quickshell/tests/qml/polkit-auth.qml b/quickshell/tests/qml/polkit-auth.qml deleted file mode 100644 index a09d7ffc1..000000000 --- a/quickshell/tests/qml/polkit-auth.qml +++ /dev/null @@ -1,154 +0,0 @@ -import QtQuick -import QtTest -import Quickshell -import qs.Common -import qs.Services -import qs.Modals -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - - property int cancellations: 0 - property bool queueNext: false - - component Flow: QtObject { - property bool isCompleted: false - property bool isResponseRequired: true - property bool responseVisible: false - property string message: "Authentication is needed to run /usr/bin/echo as the super user." - property string inputPrompt: "Password:" - property string supplementaryMessage: "" - property bool supplementaryIsError: false - signal authenticationSucceeded - signal authenticationFailed - signal authenticationRequestCancelled - function submit(value) { - isResponseRequired = false; - } - function cancelAuthenticationRequest() { - root.cancellations++; - isCompleted = true; - if (!root.queueNext) - return; - root.queueNext = false; - modal.show(); - modal.currentFlow = nextFlow; - } - } - - Flow { - id: flow - } - Flow { - id: nextFlow - } - PolkitAuthModal { - id: modal - } - PolkitAuthSurfaceModal { - id: surface - } - TestCase { - id: input - when: false - name: "polkit" - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - } - - 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; - } - - function check(value, message) { - if (!value) - throw new Error(message); - } - - function openModal() { - flow.isCompleted = false; - flow.isResponseRequired = true; - modal.show(); - modal.currentFlow = flow; - input.wait(50); - return find(modal.contentItem, item => typeof item.submitAuth === "function"); - } - - Timer { - interval: 500 - running: true - onTriggered: { - try { - openModal(); - modal.closed(); - root.check(root.cancellations === 1 && !modal.visible, "native close cancels the request"); - modal.closed(); - root.check(root.cancellations === 1, "duplicate close does not cancel twice"); - - let content = root.openModal(); - content.actions[0].click(); - root.check(root.cancellations === 2 && !modal.visible, "Cancel button cancels"); - - content = root.openModal(); - input.findChild(content, "closeWindow").click(); - root.check(root.cancellations === 3 && !modal.visible, "header close cancels"); - - content = root.openModal(); - content.submitAuth(); - root.check(content.isLoading, "submission pending"); - content.forceActiveFocus(); - input.keyClick(Qt.Key_Escape); - root.check(root.cancellations === 4 && !modal.visible, "Escape cancels during submission"); - - content = root.openModal(); - content.submitAuth(); - modal.closed(); - root.check(root.cancellations === 5 && !modal.visible, "native close cancels during submission"); - - root.openModal(); - root.queueNext = true; - modal.closed(); - root.check(root.cancellations === 6 && modal.visible && modal.currentFlow === nextFlow, "queued request stays open"); - modal.hide(); - - root.openModal(); - flow.isCompleted = true; - modal.closed(); - root.check(root.cancellations === 6, "completed request is not cancelled"); - - content = root.openModal(); - flow.isCompleted = true; - flow.authenticationSucceeded(); - root.check(root.cancellations === 6 && !modal.visible, "success closes without cancellation"); - - flow.isCompleted = false; - surface.open(); - input.wait(100); - surface.currentFlow = flow; - input.wait(20); - content = surface.contentLoader.item; - content.forceActiveFocus(); - input.keyClick(Qt.Key_Escape); - root.check(root.cancellations === 7 && !surface.shouldBeVisible, "surface Escape cancels"); - - console.log("FIXTURE_PASS polkit close, buttons, Escape, pending submit, queued and completed requests"); - } catch (error) { - console.error("FIXTURE_FAIL", error.message); - } - Qt.quit(); - } - } -} diff --git a/quickshell/tests/qml/popout-gallery.qml b/quickshell/tests/qml/popout-gallery.qml deleted file mode 100644 index 17064ac7f..000000000 --- a/quickshell/tests/qml/popout-gallery.qml +++ /dev/null @@ -1,324 +0,0 @@ -import QtQuick -import QtTest -import Quickshell -import Quickshell.Io -import qs.Common -import qs.Services -import qs.Modules.ProcessList -import qs.Modules.Notifications.Center -import qs.Modules.DankBar.Popouts -import qs.Modules.DankBar.Widgets -import qs.Modules.ControlCenter -import qs.Modules.DankDash -import qs.Modals.Clipboard -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - - readonly property string shotDir: Quickshell.env("DMS_FIXTURE_SHOTS") - property var cases: [ - { - name: "process-list", - component: processList - }, - { - name: "notification-center", - component: notificationCenter - }, - { - name: "battery", - component: battery - }, - { - name: "system-update", - component: systemUpdate - }, - { - name: "vpn", - component: vpn - }, - { - name: "dwl-layout", - component: dwlLayout - }, - { - name: "color-picker", - component: colorPicker - }, - { - name: "clipboard", - component: clipboard - }, - { - name: "focused-window", - component: focusedWindow - }, - { - name: "power-menu", - component: powerMenu - }, - { - name: "duration", - component: duration - }, - { - name: "control-center", - component: controlCenter, - openFocus: "ControlCenterContent", - key: Qt.Key_Tab, - ringsAfterKey: 1 - }, - { - name: "dash", - component: dash, - openFocus: "CalendarOverviewCard", - key: Qt.Key_Right, - modifiers: Qt.AltModifier, - ringsAfterKey: 0 - } - ] - property int caseIndex: -1 - property var current: null - property bool failed: false - - Component { - id: processList - ProcessListPopout {} - } - Component { - id: notificationCenter - NotificationCenterPopout {} - } - Component { - id: battery - BatteryPopout {} - } - Component { - id: systemUpdate - SystemUpdatePopout {} - } - Component { - id: vpn - VpnPopout {} - } - Component { - id: dwlLayout - DWLLayoutPopout {} - } - Component { - id: colorPicker - ColorPickerPopout {} - } - Component { - id: clipboard - ClipboardHistoryPopout {} - } - Component { - id: focusedWindow - FocusedWindowContextMenu { - currentWindow: ({ - appId: "org.kde.dolphin", - title: "Home", - pid: 4242 - }) - } - } - Component { - id: powerMenu - PowerMenuPopout {} - } - Component { - id: duration - DurationPopout {} - } - Component { - id: controlCenter - ControlCenterPopout {} - } - Component { - id: dash - DankDashPopout {} - } - - function typeName(item) { - return String(item).split("(")[0].replace(/_QMLTYPE_\d+/, "").replace(/_QML_\d+/, ""); - } - - function paddingOf(item, depth) { - if (!item || depth > 6) - return null; - for (const child of item.children || []) { - const type = typeName(child); - if ((type.endsWith("Column") || type.endsWith("ColumnLayout") || type.endsWith("Flickable") || type === "QQuickItem") && (child.anchors?.margins > 0 || child.x > 0)) - return { - type: type, - margins: child.anchors?.margins ?? 0, - x: child.x, - y: child.y, - spacing: child.spacing ?? null - }; - const found = paddingOf(child, depth + 1); - if (found) - return found; - } - return null; - } - - function visibleRings(item, depth) { - if (!item || depth > 14) - return 0; - let count = typeName(item) === "FocusRing" && item.visible ? 1 : 0; - for (const child of item.children || []) - count += visibleRings(child, depth + 1); - return count; - } - - function focusName(content) { - return typeName(content.Window.window?.activeFocusItem); - } - - function headerOf(item, depth) { - if (!item || depth > 8) - return null; - for (const child of item.children || []) { - if (typeName(child) === "DankWindowHeader") - return { - shared: true, - height: child.height, - title: child.title - }; - const found = headerOf(child, depth + 1); - if (found) - return found; - } - return null; - } - - function advance() { - if (root.current) { - root.current.close(); - root.current.destroy(); - root.current = null; - } - caseIndex++; - if (caseIndex >= cases.length) { - console.log(root.failed ? "FIXTURE_FAIL see above" : "FIXTURE_PASS"); - Qt.quit(); - return; - } - const popout = cases[caseIndex].component.createObject(root); - if (!popout) { - console.log("FIXTURE_FAIL could not create " + cases[caseIndex].name); - root.failed = true; - Qt.callLater(advance); - return; - } - root.current = popout; - popout.screen = Quickshell.screens[0]; - popout.triggerX = 640; - popout.triggerY = 0; - popout.triggerWidth = 40; - popout.open(); - settle.restart(); - } - - TestCase { - id: input - when: false - name: "popout-gallery" - } - - Process { - id: shot - onExited: root.advance() - } - - function finishCase() { - if (!root.shotDir) { - Qt.callLater(root.advance); - return; - } - shot.command = ["niri", "msg", "action", "screenshot-screen", "--show-pointer", "false", "--path", root.shotDir + "/" + root.cases[root.caseIndex].name + ".png"]; - shot.running = true; - } - - Timer { - id: afterKey - interval: 400 - onTriggered: { - const name = root.cases[root.caseIndex].name; - const content = root.current.contentLoader?.item ?? null; - const entry = root.cases[root.caseIndex]; - const rings = visibleRings(content, 0); - const focus = focusName(content); - console.log("PARITY " + JSON.stringify({ - name: name, - afterKey: true, - rings: rings, - focus: focus - })); - if (rings !== entry.ringsAfterKey || focus === entry.openFocus) { - console.log("FIXTURE_FAIL " + name + " after keyboard navigation focus=" + focus + " rings=" + rings); - root.failed = true; - } - root.finishCase(); - } - } - - Timer { - id: settle - interval: 1500 - onTriggered: { - const popout = root.current; - const content = popout.contentLoader?.item ?? null; - if (!content) { - console.log("FIXTURE_FAIL " + root.cases[root.caseIndex].name + " has no content item"); - root.failed = true; - } - const entry = root.cases[root.caseIndex]; - const rings = visibleRings(content, 0); - console.log("PARITY " + JSON.stringify({ - name: entry.name, - visible: popout.shouldBeVisible, - width: popout.popupWidth, - height: popout.popupHeight, - padding: content ? paddingOf(content, 0) : null, - header: content ? headerOf(content, 0) : null, - rings: rings, - focus: content ? focusName(content) : null - })); - if (rings > 0 || (entry.openFocus !== undefined && content && focusName(content) !== entry.openFocus)) { - console.log("FIXTURE_FAIL " + entry.name + " on open focus=" + (content ? focusName(content) : null) + " rings=" + rings); - root.failed = true; - } - if (entry.key === undefined || !content) { - root.finishCase(); - return; - } - input.keyClick(entry.key, entry.modifiers ?? Qt.NoModifier); - afterKey.restart(); - } - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - SettingsData.barConfigs = [ - { - id: "main", - enabled: true, - visible: true, - position: 0, - spacing: 4, - innerPadding: 4, - leftWidgets: [], - centerWidgets: [], - rightWidgets: [] - } - ]; - Qt.callLater(advance); - } -} diff --git a/quickshell/tests/qml/popout-surface-steps.qml b/quickshell/tests/qml/popout-surface-steps.qml deleted file mode 100644 index 72dd15a8a..000000000 --- a/quickshell/tests/qml/popout-surface-steps.qml +++ /dev/null @@ -1,159 +0,0 @@ -import QtQuick -import Quickshell -import Quickshell.Wayland -import qs.Common -import qs.Services -import qs.Modules.ControlCenter -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - - property var cc: null - property bool failed: false - property int step: 0 - property real originBefore: 0 - property real surfaceBefore: 0 - readonly property var screen: Quickshell.screens[0] - - function check(condition, label) { - if (condition) - return; - failed = true; - console.log("FIXTURE_FAIL " + label); - } - - function origin() { - const window = root.cc.contentWindow; - if (window.anchors.right) - return root.screen.width - window.WlrLayershell.margins.right - window.implicitWidth; - return window.WlrLayershell.margins.left; - } - - function surfaceWidth() { - return root.cc.contentWindow.implicitWidth; - } - - function content() { - return root.cc.contentLoader.item; - } - - function findGrid(item) { - if (!item) - return null; - if (typeof item.displayedItems === "function") - return item; - for (const child of item.children) { - const found = findGrid(child); - if (found) - return found; - } - return null; - } - - Component { - id: ccComponent - ControlCenterPopout {} - } - - function openAt(triggerX, section) { - root.cc = ccComponent.createObject(root); - root.cc.setBarContext(0, 0); - root.cc.triggerScreen = root.screen; - root.cc.setTriggerPosition(triggerX, 0, 40, section, root.screen); - PopoutManager.requestPopout(root.cc, undefined, "main-" + section + "-cc"); - root.cc.open(); - } - - function enterEdit(label) { - root.originBefore = origin(); - root.surfaceBefore = surfaceWidth(); - root.cc.editMode = true; - check(origin() === root.originBefore, label + ": the surface origin holds until the buffer spans the target: " + JSON.stringify([origin(), root.originBefore])); - } - - function settledEdit(label) { - check(origin() < root.originBefore, label + ": the origin moves once the buffer grew: " + JSON.stringify([origin(), root.originBefore])); - } - - function leaveEdit(label) { - root.cc.editMode = false; - check(origin() === root.originBefore && surfaceWidth() === root.surfaceBefore, label + ": leaving edit mode hands the surface back in one step: " + JSON.stringify([origin(), surfaceWidth(), root.originBefore, root.surfaceBefore])); - } - - Timer { - id: sequencer - interval: 1500 - onTriggered: { - switch (root.step++) { - case 0: - enterEdit("centered"); - interval = 400; - break; - case 1: - settledEdit("centered"); - leaveEdit("centered"); - content().navigateTo("network"); - interval = 600; - break; - case 2: - content().goBack(); - interval = 800; - break; - case 3: - { - const grid = findGrid(content()); - SettingsData.set("controlCenterColumns", CcMetrics.defaultColumns + 1); - check(grid.x === 0 && grid.width === grid.parent.width, "resizing after a page visit keeps the grid on the body: " + JSON.stringify([grid.x, grid.width, grid.parent.width])); - SettingsData.set("controlCenterColumns", CcMetrics.defaultColumns); - } - root.cc.close(); - interval = 800; - break; - case 4: - root.cc.destroy(); - openAt(root.screen.width - 60, "right"); - interval = 1500; - break; - case 5: - enterEdit("edge"); - interval = 400; - break; - case 6: - settledEdit("edge"); - leaveEdit("edge"); - root.cc.close(); - interval = 500; - break; - case 7: - console.log(root.failed ? "FIXTURE_FAIL see above" : "FIXTURE_PASS"); - stop(); - Qt.quit(); - return; - } - restart(); - } - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - SettingsData.barConfigs = [ - { - id: "main", - enabled: true, - visible: true, - position: 0, - spacing: 4, - innerPadding: 4, - leftWidgets: [], - centerWidgets: [], - rightWidgets: [] - } - ]; - openAt(root.screen.width / 2, "center"); - sequencer.start(); - } -} diff --git a/quickshell/tests/qml/popup-routing.qml b/quickshell/tests/qml/popup-routing.qml deleted file mode 100644 index ded98b85c..000000000 --- a/quickshell/tests/qml/popup-routing.qml +++ /dev/null @@ -1,161 +0,0 @@ -import QtQuick -import Quickshell -import qs.Common -import qs.Services -import qs.Widgets -import qs.Modules.DankBar -import qs.Modules.Plugins -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - property var firstPopout: null - property var replacement: null - function check(condition, message) { - if (!condition) - throw new Error(message); - } - function target(barId, occurrenceId) { - return { - screenName: Quickshell.screens[0].name, - barId, - section: "left", - occurrenceId - }; - } - Component { - id: plugin - PluginComponent { - horizontalBarPill: Component { - Rectangle { - width: 28 - height: 28 - color: Theme.primary - } - } - verticalBarPill: horizontalBarPill - popoutWidth: 180 - popoutHeight: 120 - popoutContent: Component { - Rectangle { - implicitWidth: 140 - implicitHeight: 80 - color: Theme.primaryContainer - } - } - } - } - Item { - Repeater { - model: ScriptModel { - values: SettingsData.barConfigs - objectProp: "id" - } - delegate: DankBar { - required property var modelData - barConfig: modelData - } - } - Loader { - id: dashLoader - active: false - sourceComponent: DankPopout { - property var triggerScreen: null - property string requestedTab: "" - function requestTab(tab) { - requestedTab = tab; - } - popupWidth: 180 - popupHeight: 120 - content: Component { - Rectangle { - implicitWidth: 140 - implicitHeight: 80 - color: Theme.primaryContainer - } - } - } - } - } - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - PluginService.availablePlugins = { - fixture: { - id: "fixture" - } - }; - PluginService.pluginWidgetComponents = { - fixture: plugin - }; - PopoutService.dankDashPopoutLoader = dashLoader; - SettingsData.barConfigs = ["z", "a"].map(id => ({ - id, - enabled: true, - visible: true, - position: 0, - spacing: 4, - innerPadding: 4, - leftWidgets: ["fixture", "clock"], - centerWidgets: [], - rightWidgets: [] - })); - } - Timer { - interval: 500 - running: true - repeat: true - property int step: 0 - onTriggered: { - try { - const screen = Quickshell.screens[0]; - const active = PopoutManager.currentPopoutsByScreen[screen.name]; - switch (step++) { - case 0: - BarWidgetService.triggerWidgetPopout("fixture", root.target("z", "fixture_0")); - break; - case 1: - root.check(active?.shouldBeVisible && active.sourceRegistration.context.barId === "z", "first plugin popup origin"); - root.firstPopout = active; - BarWidgetService.triggerWidgetPopout("fixture", root.target("a", "fixture_0")); - break; - case 2: - root.check(active?.shouldBeVisible && active !== root.firstPopout && !root.firstPopout.shouldBeVisible, "plugin popup concurrency"); - root.check(active.sourceRegistration.context.barId === "a" && active.triggerY >= ShellLayout.forConfig(screen, "a").rowOffset, "second plugin row anchor"); - BarWidgetService.triggerWidgetPopout("fixture", root.target("a", "fixture_0")); - break; - case 3: - root.check(!active?.shouldBeVisible, "same plugin toggles closed"); - BarWidgetService.triggerWidgetPopout("clock", root.target("z", "clock_1")); - break; - case 4: - root.check(active?.shouldBeVisible && active.sourceRegistration.context.barId === "z" && active.requestedTab === "overview", "built-in factory popup origin"); - BarWidgetService.triggerWidgetPopout("clock", root.target("a", "clock_1")); - break; - case 5: - root.check(active?.shouldBeVisible && active.sourceRegistration.context.barId === "a", "shared built-in popup retargets exactly"); - root.firstPopout = active; - const old = active.sourceRegistration; - root.replacement = BarWidgetService.registerWidget(old.widgetId, old.screenName, old.item, old.instanceId, old.context); - break; - case 6: - root.check(!root.firstPopout.shouldBeVisible, "stale popup registration closes"); - BarWidgetService.releaseWidget(root.replacement); - SettingsData.barConfigs = []; - break; - case 7: - root.check(!PopoutManager.currentPopoutsByScreen[screen.name]?.shouldBeVisible, "popup teardown clears manager"); - console.log("FIXTURE_PASS"); - stop(); - Qt.quit(); - } - } catch (error) { - console.error("FIXTURE_FAIL", error.message); - stop(); - Qt.quit(); - } - } - } -} diff --git a/quickshell/tests/qml/process-list-cache.qml b/quickshell/tests/qml/process-list-cache.qml deleted file mode 100644 index 70131bcb5..000000000 --- a/quickshell/tests/qml/process-list-cache.qml +++ /dev/null @@ -1,94 +0,0 @@ -import QtQuick -import QtTest -import Quickshell -import qs.Common -import qs.Services -import qs.Modules.ProcessList -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - - TestCase { - id: input - when: false - name: "process-list-cache" - } - - PanelWindow { - color: "transparent" - implicitWidth: 600 - implicitHeight: 500 - anchors { - top: true - left: true - } - - ProcessesView { - id: view - anchors.fill: parent - active: false - } - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - } - - function check(value, message) { - if (!value) - throw new Error(message); - } - - function process(pid, command) { - return { - pid: pid, - command: command, - fullCommand: command, - username: UserInfoService.username, - cpu: 1, - memoryKB: 1024 - }; - } - - readonly property var emptyLabel: view.listView.children.find(c => c.text !== undefined && c.font !== undefined) - - Timer { - interval: 0 - running: true - onTriggered: { - try { - root.check(root.emptyLabel && root.emptyLabel.visible && view.cachedProcesses.length === 0, "first open with no data shows an empty state"); - - DgopService.allProcesses = [root.process(1, "init"), root.process(2, "shell")]; - view.active = true; - input.tryCompare(root.emptyLabel, "visible", false); - root.check(view.cachedProcesses.length === 2 && !root.emptyLabel.visible, "rows appear once data and activity meet"); - - view.active = false; - root.check(view.cachedProcesses.length === 2 && !root.emptyLabel.visible, "closing keeps the last rows for the close animation"); - - DgopService.allProcesses = []; - root.check(view.cachedProcesses.length === 2 && !root.emptyLabel.visible, "releasing the data source keeps the last rows"); - - view.active = true; - root.check(view.cachedProcesses.length === 2 && !root.emptyLabel.visible, "reopening before the first sample keeps the last rows"); - - DgopService.allProcesses = [root.process(3, "editor")]; - root.check(view.cachedProcesses.length === 1 && view.cachedProcesses[0].pid === 3, "first sample after reopen replaces the rows"); - - view.searchText = "nomatch"; - input.tryCompare(root.emptyLabel, "visible", true); - root.check(view.cachedProcesses.length === 0 && root.emptyLabel.visible, "a search with no hits shows the empty state"); - - console.log("FIXTURE_PASS process list keeps rows across close, release and reopen; empty state only for real misses"); - } catch (error) { - console.error("FIXTURE_FAIL", error.message); - } - Qt.quit(); - } - } -} diff --git a/quickshell/tests/qml/registry-browsers.qml b/quickshell/tests/qml/registry-browsers.qml deleted file mode 100644 index c74703b71..000000000 --- a/quickshell/tests/qml/registry-browsers.qml +++ /dev/null @@ -1,261 +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 - - property bool failed: false - property int step: 0 - property var theme: null - property var plugin: null - - 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 typeName(item) { - return String(item).split("(")[0].replace(/_QMLTYPE_\d+/, "").replace(/_QML_\d+/, ""); - } - - function collect(item, origin, out) { - const kids = (item.children || []).filter(child => child.visible && child.width > 0 && child.height > 0); - const paints = item.color !== undefined || (typeof item.text === "string" && item.font !== undefined) || item.source !== undefined; - if (paints || kids.length === 0) { - const pos = item.mapToItem(origin, 0, 0); - const entry = { - type: typeName(item), - x: Math.round(pos.x * 100) / 100, - y: Math.round(pos.y * 100) / 100, - w: Math.round(item.width * 100) / 100, - h: Math.round(item.height * 100) / 100 - }; - if (typeof item.text === "string" && item.font !== undefined) { - entry.text = item.text; - entry.px = item.font.pixelSize; - } - if (item.color !== undefined) - entry.color = String(item.color); - if (item.border !== undefined && item.border.width !== undefined) - entry.border = [item.border.width, String(item.border.color)]; - if (item.opacity !== 1) - entry.opacity = Math.round(item.opacity * 100) / 100; - out.push(entry); - } - for (const child of kids) - collect(child, origin, out); - } - - function state(win) { - return { - visible: win.visible, - title: win.title, - objectName: win.objectName, - query: win.searchQuery, - selectedIndex: win.selectedIndex, - nav: win.keyboardNavigationActive, - loading: win.isLoading, - detail: win.detailPluginId ?? null, - hasFocus: win.activeFocusItem ? typeName(win.activeFocusItem) : "" - }; - } - - function snapshot(label) { - for (const pair of [["theme", root.theme], ["plugin", root.plugin]]) { - const win = pair[1]; - const nodes = []; - collect(win.contentItem, win.contentItem, nodes); - console.log("PARITY " + JSON.stringify({ - label: label, - name: pair[0], - state: state(win), - nodes: nodes - })); - } - } - - Component { - id: themeComp - ThemeBrowser {} - } - Component { - id: pluginComp - PluginBrowser {} - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - root.theme = themeComp.createObject(root); - root.plugin = pluginComp.createObject(root); - } - - readonly property var themes: [ - { - id: "nord", - name: "Nord", - description: "Arctic palette", - author: "arctic", - installed: true, - sourceDir: "nord" - }, - { - id: "dracula", - name: "Dracula", - description: "Dark purple", - author: "count", - installed: false, - variants: { - type: "single", - default: "purple", - options: [ - { - id: "purple" - }, - { - id: "blue" - } - ] - } - }, - { - id: "breeze", - name: "Breeze", - description: "Blue KDE", - author: "kde", - installed: false, - wcag: { - dark: { - breakdown: [ - { - level: "AAA" - } - ] - } - } - } - ] - readonly property var plugins: [ - { - id: "cpuGraph", - name: "CPU graph", - description: "Processor history", - author: "ann", - category: "system", - firstParty: true, - capabilities: ["widget"], - installed: false, - upvotes: 12, - status: ["reviewed"], - version: "1.2", - similar: ["ramGraph"] - }, - { - id: "ramGraph", - name: "RAM graph", - description: "Memory history", - author: "bob", - category: "system", - firstParty: true, - capabilities: ["widget"], - installed: true, - upvotes: 4, - status: [] - }, - { - id: "weatherTile", - name: "Weather tile", - description: "Forecast card", - author: "cara", - category: "weather", - firstParty: true, - capabilities: ["desktop"], - installed: false, - upvotes: 7, - status: ["reviewed"] - } - ] - - Timer { - interval: 1200 - running: true - repeat: true - onTriggered: { - const theme = root.theme; - const plugin = root.plugin; - switch (root.step++) { - case 0: - theme.show(); - plugin.show(); - return; - case 1: - SessionData.setPluginBrowserHideInstalled(false); - SessionData.setPluginBrowserInstalledFirst(false); - SessionData.setPluginBrowserSortMode("default"); - SessionData.setShowThirdPartyPlugins(true); - theme.isLoading = false; - theme.loadError = ""; - theme.allThemes = root.themes; - theme.updateFilteredThemes(); - plugin.isLoading = false; - plugin.loadError = ""; - plugin.allPlugins = root.plugins; - plugin.updateFilteredPlugins(); - return; - case 2: - root.snapshot("open"); - check(theme.visible && plugin.visible, "both browsers visible"); - check(JSON.stringify(theme.filteredThemes.map(t => t.id)) === JSON.stringify(["nord", "dracula", "breeze"]), "theme list " + JSON.stringify(theme.filteredThemes.map(t => t.id))); - check(JSON.stringify(plugin.filteredPlugins.map(p => p.id)) === JSON.stringify(["cpuGraph", "weatherTile", "ramGraph"]), "plugin list sorted by votes " + JSON.stringify(plugin.filteredPlugins.map(p => p.id))); - theme.searchQuery = "b"; - theme.updateFilteredThemes(); - plugin.searchQuery = "graph"; - plugin.updateFilteredPlugins(); - return; - case 3: - root.snapshot("searched"); - check(JSON.stringify(theme.filteredThemes.map(t => t.id)) === JSON.stringify(["breeze"]), "theme search narrows to breeze"); - check(JSON.stringify(plugin.filteredPlugins.map(p => p.id)) === JSON.stringify(["cpuGraph", "ramGraph"]), "plugin search narrows to graphs"); - theme.selectNext(); - plugin.selectNext(); - plugin.selectStep(1); - return; - case 4: - root.snapshot("navigated"); - check(theme.selectedIndex === 0 && theme.keyboardNavigationActive, "theme selection on first card"); - check(plugin.selectedIndex === 1 && plugin.keyboardNavigationActive, "plugin selection stepped to second card"); - theme.operationMessage = "Installed: Nord"; - plugin.operationMessage = "Installing: CPU graph"; - plugin.operationPending = true; - plugin.openPluginDetail(plugin.filteredPlugins[1]); - return; - case 5: - root.snapshot("status-detail"); - check(plugin.detailPluginId === "ramGraph", "plugin detail opened"); - plugin.closePluginDetail(); - theme.hide(); - plugin.hide(); - return; - case 6: - root.snapshot("hidden"); - check(!theme.visible && !plugin.visible, "both browsers hidden"); - check(theme.searchQuery === "" && plugin.searchQuery === "" && plugin.detailPluginId === "", "state reset on hide"); - root.finish(); - stop(); - Qt.quit(); - } - } - } -} diff --git a/quickshell/tests/qml/running-apps.qml b/quickshell/tests/qml/running-apps.qml deleted file mode 100644 index 06249426b..000000000 --- a/quickshell/tests/qml/running-apps.qml +++ /dev/null @@ -1,238 +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 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"); - } - - property var barConfig: ({ - widgetPadding: 12, - fontScale: 1, - iconScale: 1, - spacing: 4 - }) - property var axes: [ - { - isVertical: false, - edge: "top" - }, - { - isVertical: true, - edge: "left" - } - ] - property var cases: [ - { - name: "compact", - data: { - runningAppsCurrentWorkspace: false - } - }, - { - name: "compact-grouped", - data: { - runningAppsCurrentWorkspace: false, - runningAppsGroupByApp: true - } - }, - { - name: "full", - data: { - runningAppsCurrentWorkspace: false, - runningAppsCompactMode: false - } - }, - { - name: "full-grouped", - data: { - runningAppsCurrentWorkspace: false, - runningAppsCompactMode: false, - runningAppsGroupByApp: true - } - } - ] - property var phases: [ - { - name: "firefox-active", - active: "0x1" - }, - { - name: "kitty-active", - active: "0x3" - }, - { - name: "none-active", - active: "" - } - ] - property var instances: [] - property int phase: 0 - - Component { - id: runningApps - RunningApps {} - } - - Item { - id: stage - width: 900 - height: 900 - } - - function toplevels(activeAddress) { - const rows = [ - { - address: "0x1", - appId: "firefox", - title: "Mozilla Firefox", - minimized: false - }, - { - address: "0x2", - appId: "firefox", - title: "Downloads", - minimized: false - }, - { - address: "0x3", - appId: "kitty", - title: "zsh", - minimized: true - }, - { - address: "0x4", - appId: "", - title: "", - minimized: false - } - ]; - return rows.map(row => Object.assign({ - activated: row.address === activeAddress - }, row)); - } - - function applyPhase(p) { - CompositorService.compositor = "unknown"; - CompositorService.sortedToplevels = toplevels(p.active); - } - - function delegates(item, out) { - if (item.isFocused !== undefined && item.windowCount !== undefined) - out.push(item); - for (const child of item.children || []) - delegates(child, out); - return out; - } - - property var baselineSizes: ({}) - - function snapshot() { - const phase = root.phases[root.phase]; - for (const instance of root.instances) { - const item = instance.item; - const label = phase.name + " " + instance.name + (instance.vertical ? " vertical" : " horizontal"); - const grouped = instance.name.endsWith("grouped"); - const rows = delegates(item.visualContent, []); - const appIds = rows.map(row => row.appId); - check(item.visible && item.windowCount === (grouped ? 3 : 4), label + " window count " + item.windowCount); - check(rows.length === item.windowCount, label + " renders one delegate per window, got " + rows.length); - check(appIds.includes("kitty"), label + " shows the kitty window"); - if (grouped) - check(rows.some(row => row.appId === "firefox" && row.windowCount === 2), label + " groups the two firefox windows, got " + JSON.stringify(appIds)); - else - check(rows.filter(row => row.appId === "firefox").length === 2, label + " lists both firefox windows, got " + JSON.stringify(appIds)); - const focused = rows.filter(row => row.isFocused).map(row => row.appId); - switch (phase.active) { - case "0x1": - check(focused.length === 1 && focused[0] === "firefox", label + " focuses firefox, got " + JSON.stringify(focused)); - break; - case "0x3": - check(JSON.stringify(focused) === JSON.stringify(["kitty"]), label + " focuses kitty, got " + JSON.stringify(focused)); - break; - default: - check(focused.length === 0, label + " has no focused window, got " + JSON.stringify(focused)); - } - const key = instance.name + (instance.vertical ? "/v" : "/h"); - const size = instance.vertical ? item.height : item.width; - check(size > 0, label + " has extent along the bar"); - if (root.baselineSizes[key] === undefined) - root.baselineSizes[key] = size; - else - check(root.baselineSizes[key] === size, label + " keeps its size across focus changes, " + root.baselineSizes[key] + " vs " + size); - } - if (root.phase === 0) { - const width = name => root.instances.find(instance => instance.name === name && !instance.vertical).item.width; - check(width("full") > width("compact") && width("full-grouped") > width("compact-grouped"), "full mode is wider than compact"); - check(width("compact-grouped") < width("compact"), "grouping shrinks the widget"); - } - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - applyPhase(root.phases[0]); - const created = []; - let y = 0; - for (const axis of root.axes) { - for (const testCase of root.cases) { - const item = runningApps.createObject(stage, { - axis: axis, - barThickness: 48, - widgetThickness: 30, - barSpacing: 4, - barConfig: root.barConfig, - widgetData: testCase.data, - y: y - }); - y += 220; - created.push({ - name: testCase.name, - vertical: axis.isVertical, - item: item - }); - } - } - root.instances = created; - } - - Timer { - interval: 1200 - running: true - repeat: true - property bool armed: false - onTriggered: { - if (!armed) { - armed = true; - root.applyPhase(root.phases[0]); - return; - } - root.snapshot(); - root.phase++; - if (root.phase >= root.phases.length) { - root.finish(); - stop(); - Qt.quit(); - return; - } - root.applyPhase(root.phases[root.phase]); - } - } -} diff --git a/quickshell/tests/qml/settings-scroll.qml b/quickshell/tests/qml/settings-scroll.qml index 7362faeea..73dd1e7ce 100644 --- a/quickshell/tests/qml/settings-scroll.qml +++ b/quickshell/tests/qml/settings-scroll.qml @@ -67,14 +67,14 @@ ShellRoot { function settle() { wait(0); const scene = window.contentItem.Window.window; - check(!isPolishScheduled(scene) || waitForPolish(scene, 5000), "settings layout settled"); + check(!isPolishScheduled(scene) || waitForPolish(scene, 20000), "settings layout settled"); wait(0); } function open(page) { root.content.currentPage = page; try { - tryVerify(() => scrollable() !== null, 5000); + tryVerify(() => scrollable() !== null, 20000); } catch (error) { throw new Error(page + " has a scrollable page"); } @@ -83,7 +83,7 @@ ShellRoot { function expectPosition(y, label) { try { - tryVerify(() => Math.abs(scrollable().contentY - y) < 1, 1000); + tryVerify(() => Math.abs(scrollable().contentY - y) < 1, 20000); } catch (error) { throw new Error(label + ": expected " + y + ", got " + scrollable().contentY); } @@ -91,7 +91,7 @@ ShellRoot { function run() { try { - tryVerify(() => CompositorService.compositor !== "unknown", 5000); + tryVerify(() => CompositorService.compositor !== "unknown", 20000); root.content = contentComponent.createObject(window.contentItem); open("theme_surfaces"); SettingsData.animationDuration = 0; @@ -106,14 +106,14 @@ ShellRoot { 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); + tryCompare(root, "childPage", null, 20000); SettingsData.animationDuration = 250; open("notifications"); - tryCompare(root, "parentPage", null); + tryCompare(root, "parentPage", null, 20000); root.parentPage = root.content.currentPageItem; modal.shouldBeVisible = false; - tryCompare(root, "parentPage", null); + tryCompare(root, "parentPage", null, 20000); modal.shouldBeVisible = true; settle(); @@ -126,7 +126,7 @@ ShellRoot { } ]; root.content.currentPage = "dankbar_widgets"; - tryVerify(() => root.content.currentPageItem?.reorderGroup !== undefined, 1000); + 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"); diff --git a/quickshell/tests/qml/slider-wheel.qml b/quickshell/tests/qml/slider-wheel.qml index 1fa129b97..42f629063 100644 --- a/quickshell/tests/qml/slider-wheel.qml +++ b/quickshell/tests/qml/slider-wheel.qml @@ -69,17 +69,17 @@ ShellRoot { } function wheelOnSlider(target) { + wait(0); + const surface = scene.Window.window; + check(!isPolishScheduled(surface) || waitForPolish(surface, 20000), "layout settles"); const point = target.mapToItem(scene, target.width / 2, target.height / 2); mouseWheel(scene, point.x, point.y, 0, 120); - wait(50); } function run() { - wait(300); wheelOnSlider(slider); check(slider.value === 51, "wheel steps a slider whose container does not scroll"); filler.height = 1000; - wait(50); wheelOnSlider(slider); check(slider.value === 51, "wheel leaves a slider alone once the container scrolls"); row.slider.value = 50; @@ -91,7 +91,7 @@ ShellRoot { } Timer { - interval: 800 + interval: 0 running: true onTriggered: { try { diff --git a/quickshell/tests/qml/surface-handle.qml b/quickshell/tests/qml/surface-handle.qml deleted file mode 100644 index f0bafde4b..000000000 --- a/quickshell/tests/qml/surface-handle.qml +++ /dev/null @@ -1,115 +0,0 @@ -import QtQuick -import QtTest -import Quickshell -import qs.Common -import qs.Services -import qs.Widgets -import qs.Modals.Common -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - - TestCase { - id: input - when: false - name: "surface-handle" - } - - DankModal { - id: modal - modalWidth: 333 - modalHeight: 222 - content: Component { - Item {} - } - } - - DankPopout { - id: popout - popupWidth: 250 - popupHeight: 150 - content: Component { - Item {} - } - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - SettingsData.barConfigs = [ - { - id: "bar", - enabled: true, - visible: true, - position: 0, - spacing: 4, - innerPadding: 4, - leftWidgets: [], - centerWidgets: [], - rightWidgets: [] - } - ]; - SettingsData.frameScreenPreferences = ["all"]; - } - - function check(value, message) { - if (!value) - throw new Error(message); - } - - function connectFrame() { - SettingsData.frameEnabled = true; - SettingsData.frameMode = "connected"; - FrameTransitionState.acknowledge(FrameTransitionState.revision); - FrameTransitionState.syncEffective(); - NiriService._layoutAppliedRevision = NiriService._frameTransitionRevision; - input.wait(100); - } - - Timer { - interval: 500 - running: true - onTriggered: { - try { - const screen = Quickshell.screens[0]; - const standaloneWindow = modal.contentWindow; - root.check(standaloneWindow && modal.alignedWidth === Theme.px(333, modal.dpr), "modal backend reads the consumer width"); - modal.modalWidth = 444; - root.check(modal.alignedWidth === Theme.px(444, modal.dpr), "modal backend follows a later width change"); - modal.open(); - input.wait(50); - root.check(modal.shouldBeVisible && standaloneWindow.visible, "dispatcher open reaches the backend"); - modal.close(); - input.wait(50); - root.check(!modal.shouldBeVisible, "backend close flows back to the dispatcher"); - for (let i = 0; i < 60 && modal.isClosing; i++) - input.wait(50); - root.check(!modal.isClosing, "modal finishes closing"); - - popout.setTriggerPosition(100, 0, 40, "left", screen, 0, 44, 4, SettingsData.barConfigs[0], null); - const standalonePopout = popout.contentWindow; - root.check(standalonePopout && popout.alignedWidth === Theme.px(250, popout.dpr) && popout.screen === screen, "popout backend reads width and screen from the handle"); - - root.connectFrame(); - root.check(FrameTransitionState.effectiveConnectedFrameModeActive, "fixture entered connected frame mode"); - modal.open(); - input.wait(100); - root.check(modal.contentWindow && modal.contentWindow !== standaloneWindow && modal.shouldBeVisible, "modal opens on the connected backend after the mode switch"); - root.check(modal.alignedWidth === Theme.px(444, modal.dpr), "connected modal backend reads the same consumer width"); - modal.close(); - input.wait(50); - root.check(popout.contentWindow && popout.contentWindow !== standalonePopout && popout.alignedWidth === Theme.px(250, popout.dpr), "connected popout backend reads the same consumer width"); - popout.popupWidth = 300; - root.check(popout.alignedWidth === Theme.px(300, popout.dpr), "connected popout follows a later width change"); - - console.log("FIXTURE_PASS surface handle: consumer properties reach both backends across a mode switch, visibility syncs both ways"); - } catch (error) { - console.error("FIXTURE_FAIL", error.message); - } - Qt.quit(); - } - } -} diff --git a/quickshell/tests/qml/surface-layout.qml b/quickshell/tests/qml/surface-layout.qml deleted file mode 100644 index 4ad0d9e31..000000000 --- a/quickshell/tests/qml/surface-layout.qml +++ /dev/null @@ -1,257 +0,0 @@ -import QtQuick -import Quickshell -import Quickshell.Io -import qs.Common -import qs.Services -import qs.Modules.DankBar -import qs.Modules.DankIsland -import qs.Modules.Frame -import qs.Modules.Dock -import qs.DankCommon.Common as DC - -ShellRoot { - id: root - - property var cases: [] - property int caseIndex: -1 - property string rowsBeforeExpansion: "" - property bool captureBaseline: Quickshell.env("DMS_FIXTURE_BASELINE") === "1" - - function config(id, position, island) { - return { - id, - enabled: true, - visible: true, - position, - island, - spacing: 4, - innerPadding: 4, - leftWidgets: ["clock"], - centerWidgets: [], - rightWidgets: [], - islandReserveThickness: 40, - islandCompactThickness: 38, - islandOuterGap: 4, - islandShowSatellites: false - }; - } - function check(condition, message) { - if (!condition) - throw new Error(message); - } - function advance() { - caseIndex++; - if (caseIndex < cases.length) { - applyCase(); - capture.restart(); - return; - } - console.log("FIXTURE_PASS"); - Qt.quit(); - } - IpcHandler { - target: "probe" - function next() { - root.advance(); - } - } - FloatingWindow { - visible: Quickshell.env("DMS_FIXTURE_CAPTURE") === "1" - title: "reservation probe" - color: "#162128" - implicitWidth: 400 - implicitHeight: 300 - } - function applyCase() { - const scenario = cases[caseIndex]; - SettingsData.frameEnabled = scenario.mode !== "off"; - SettingsData.frameMode = scenario.mode === "off" ? "separate" : scenario.mode; - SettingsData.barConfigs = scenario.configs; - Qt.callLater(() => { - FrameTransitionState.acknowledge(FrameTransitionState.revision); - FrameTransitionState.syncEffective(); - NiriService._layoutAppliedRevision = NiriService._frameTransitionRevision; - }); - } - Item { - Repeater { - model: ScriptModel { - values: SettingsData.barConfigs - objectProp: "id" - } - delegate: DankBar { - required property var modelData - barConfig: modelData - } - } - } - DankIsland { - id: islands - } - Frame {} - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - SettingsData.frameScreenPreferences = ["all"]; - const next = []; - for (const mode of ["off", "separate", "connected"]) - for (let edge = 0; edge < 4; edge++) - for (let count = 1; count <= 4; count++) - next.push({ - mode, - edge, - configs: ["z", "a", "m", "b"].slice(0, count).map((id, index) => config(id, edge, index % 2 === 1)) - }); - next.push({ - mode: "off", - edge: 0, - configs: [config("top", 0, false), config("bottom", 1, true), config("left", 2, false), config("right", 3, true)] - }); - for (const mode of ["off", "separate", "connected"]) - for (let edge = 0; edge < 4; edge++) - for (const allIslands of [false, true]) - next.push({ - mode, - edge, - expand: allIslands, - configs: ["outer", "inner"].map(id => config(id, edge, allIslands)) - }); - for (let edge = 0; edge < 4; edge++) { - const outer = Object.assign(config("hidden", edge, false), { - autoHide: true, - gothCornersEnabled: true - }); - const floating = Object.assign(config("floating", edge, true), { - islandFloating: true, - islandShowSatellites: true - }); - const fixed = config("fixed", edge, false); - next.push({ - mode: "off", - edge, - expand: true, - configs: [outer, floating, fixed] - }); - next.push({ - mode: "off", - edge, - configs: [fixed, floating, outer] - }); - next.push({ - mode: "connected", - edge, - configs: [Object.assign(config("overlay", edge, false), { - useOverlayLayer: true - }), config("island", edge, true), config("frame", (edge + 2) % 4, false)] - }); - } - for (const mode of ["off", "separate", "connected"]) { - for (let edge = 0; edge < 4; edge++) - next.push({ - mode, - edge, - configs: [config("single-island", edge, true)] - }); - next.push({ - mode, - edge: 0, - configs: [0, 1, 2, 3].map(edge => config("bar-" + edge, edge, false)) - }); - } - cases = Quickshell.env("DMS_FIXTURE_CORNERS") === "1" ? next.slice(73) : next; - caseIndex = 0; - applyCase(); - } - Timer { - id: capture - interval: 600 - repeat: true - running: true - onTriggered: { - try { - const screen = Quickshell.screens[0]; - const layout = ShellLayout.forScreen(screen); - const scenario = root.cases[root.caseIndex]; - const hosted = Object.values(BarWidgetService.frameHostedBars[screen.name] ?? {}); - const bars = Object.values(BarWidgetService.dankBarItems).reduce((result, bar) => result.concat(bar.barVariants.instances || []), []); - const islandHosts = bars.filter(bar => bar.barConfig?.island === true); - const snapshot = { - mode: scenario.mode, - edge: scenario.edge, - count: scenario.configs.length, - caseIndex: root.caseIndex, - manual: layout.manualPlacement, - screen: { - width: screen.width, - height: screen.height, - scale: CompositorService.getScreenScale(screen) - }, - reservations: Object.keys(layout.edges).reduce((result, edge) => { - result[edge] = layout.edges[edge].reservation; - return result; - }, {}), - instances: layout.instances, - bars: bars.map(bar => ({ - id: bar.barConfig.id, - width: bar.width, - height: bar.height, - exclusiveZone: bar.exclusiveZone, - margins: { - top: bar.margins.top, - bottom: bar.margins.bottom, - left: bar.margins.left, - right: bar.margins.right - } - })), - hosted: hosted.map(bar => ({ - id: bar.barConfig.id, - x: bar.parent.x, - y: bar.parent.y, - width: bar.width, - height: bar.height - })), - islands: islandHosts.map(host => ({ - id: host.barConfig.id, - width: host.width, - height: host.height, - exclusiveZone: host.exclusiveZone - })) - }; - console.log("LAYOUT_SNAPSHOT", JSON.stringify(snapshot)); - if (!root.captureBaseline) { - root.check(layout.instances.length === scenario.configs.length, "every assigned config once"); - root.check(bars.length + hosted.length === scenario.configs.length, "every assigned host once"); - root.check(islands.hosts().filter(host => host.islandController).length === islandHosts.length, "every island window reaches the island router"); - for (const instance of layout.instances) { - root.check(instance.rowOffset >= 0 && instance.rowThickness > 0, "valid row geometry"); - const prior = layout.instances.filter(other => other.edge === instance.edge && other.configOrder < instance.configOrder); - root.check(Math.abs(instance.rowOffset - layout.instances.find(other => other.edge === instance.edge).rowOffset - prior.reduce((sum, other) => sum + other.rowThickness, 0)) < 1, "config order row placement"); - } - } - if (scenario.expand && !scenario.expanded) { - scenario.expanded = true; - root.rowsBeforeExpansion = JSON.stringify(layout.instances); - for (const host of islandHosts) - islands.openActivity("home", screen, "", host.barConfig.id); - for (const config of scenario.configs.filter(config => config.autoHide)) - SettingsData.setBarIpcReveal(config.id, true); - return; - } - if (scenario.expand) - root.check(JSON.stringify(layout.instances) === root.rowsBeforeExpansion, "expansion and reveal retain row geometry"); - if (Quickshell.env("DMS_FIXTURE_CAPTURE") === "1") { - stop(); - console.log("CAPTURE_READY"); - return; - } - root.advance(); - } catch (error) { - console.error("FIXTURE_FAIL", error.message); - stop(); - Qt.quit(); - } - } - } -} diff --git a/quickshell/tests/qml/surface-resources.qml b/quickshell/tests/qml/surface-resources.qml deleted file mode 100644 index 1d814f178..000000000 --- a/quickshell/tests/qml/surface-resources.qml +++ /dev/null @@ -1,119 +0,0 @@ -import QtQuick -import Quickshell -import Quickshell.Io -import qs.Common -import qs.Services -import qs.Modules.DankBar -import qs.Modules.Dock -import qs.Modules.Plugins -import qs.DankCommon.Common as DC -import "Common/settings/DockConfig.js" as DockConfig - -ShellRoot { - id: root - property bool animate: false - Component { - id: plugin - PluginComponent { - horizontalBarPill: Component { - Rectangle { - width: 28 - height: 28 - color: Theme.primary - } - } - verticalBarPill: horizontalBarPill - attachedContent: Component { - Rectangle { - implicitWidth: 320 - implicitHeight: 280 - color: Theme.surfaceContainer - } - } - } - } - Item { - Repeater { - model: ScriptModel { - values: SettingsData.barConfigs - objectProp: "id" - } - delegate: DankBar { - required property var modelData - barConfig: modelData - } - } - } - Dock {} - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - PluginService.availablePlugins = { - fixture: { - id: "fixture" - } - }; - PluginService.pluginWidgetComponents = { - fixture: plugin - }; - SettingsData.barConfigs = ["a", "b"].map(id => ({ - id, - enabled: true, - visible: true, - position: 0, - spacing: 4, - innerPadding: 4, - leftWidgets: [], - centerWidgets: ["clock"], - rightWidgets: [] - })); - const config = DockConfig.create("dock", "dock"); - config.enabled = true; - config.widgetExpansion = "inline"; - config.widgets.push({ - id: "fixture", - widgetId: "fixture" - }); - SettingsData.dockConfigs = [config]; - SessionData.setDockPins("dock", ["browser", "editor", "terminal", "files"]); - } - Timer { - interval: 1500 - running: true - onTriggered: console.log("RESOURCE_READY") - } - Timer { - interval: 700 - running: root.animate - repeat: true - onTriggered: { - const widget = BarWidgetService.getWidget("fixture", Quickshell.screens[0].name); - if (!widget) { - console.log("FIXTURE_FAIL fixture widget missing during animation"); - return; - } - widget.triggerPopout(); - } - } - IpcHandler { - target: "probe" - function play(): void { - root.animate = true; - } - function stop(): void { - root.animate = false; - const widget = BarWidgetService.getWidget("fixture", Quickshell.screens[0].name); - if (!widget) { - console.log("FIXTURE_FAIL fixture widget missing at stop"); - return; - } - widget.closePopout(); - } - function done(): void { - console.log("FIXTURE_PASS"); - Qt.quit(); - } - } -} diff --git a/quickshell/tests/qml/system-tray.qml b/quickshell/tests/qml/system-tray.qml deleted file mode 100644 index 66aa989c4..000000000 --- a/quickshell/tests/qml/system-tray.qml +++ /dev/null @@ -1,265 +0,0 @@ -import QtQuick -import Quickshell -import Quickshell.Services.SystemTray -import qs.Common -import qs.Services -import qs.Modules.DankBar.Widgets -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"); - } - - property var barConfig: ({ - widgetPadding: 12, - fontScale: 1, - iconScale: 1, - spacing: 4 - }) - property var axes: [ - { - isVertical: false, - edge: "top" - }, - { - isVertical: true, - edge: "left" - } - ] - property var cases: [ - { - name: "popup", - section: "right", - data: { - trayAutoOverflow: true, - trayMaxVisibleItems: 2 - } - }, - { - name: "popup-single", - section: "right", - data: { - trayAutoOverflow: true, - trayMaxVisibleItems: 2, - trayPopupSingleLine: true - } - }, - { - name: "popup-all", - section: "right", - data: { - trayAutoOverflow: false - } - }, - { - name: "inline-left", - section: "left", - data: { - trayUseInlineExpansion: true, - trayAutoOverflow: true, - trayMaxVisibleItems: 2 - } - }, - { - name: "inline-right", - section: "right", - data: { - trayUseInlineExpansion: true, - trayAutoOverflow: true, - trayMaxVisibleItems: 2 - } - }, - { - name: "inline-spaced", - section: "left", - data: { - trayUseInlineExpansion: true, - trayAutoOverflow: true, - trayMaxVisibleItems: 1, - trayIconSpacing: 8 - } - } - ] - property var phases: ["closed", "open", "closed-again"] - property var instances: [] - property int phase: 0 - - Component { - id: tray - SystemTrayBar {} - } - - PanelWindow { - color: "transparent" - implicitWidth: 1200 - implicitHeight: 800 - anchors { - top: true - left: true - } - - Item { - id: stage - anchors.fill: parent - } - } - - function typeName(item) { - return String(item).split("(")[0].replace(/_QMLTYPE_\d+/, "").replace(/_QML_\d+/, ""); - } - - function dump(item, origin) { - const pos = item.mapToItem(origin, 0, 0); - const entry = { - type: typeName(item), - x: Math.round(pos.x * 100) / 100, - y: Math.round(pos.y * 100) / 100, - w: item.width, - h: item.height, - iw: item.implicitWidth, - ih: item.implicitHeight, - opacity: Math.round(item.opacity * 100) / 100, - z: item.z - }; - if (typeof item.text === "string" && item.font !== undefined) { - entry.text = item.text; - entry.px = item.font.pixelSize; - } - if (item.color !== undefined) - entry.color = String(item.color); - if (item.source !== undefined) - entry.source = String(item.source); - if (item.itemKey !== undefined) - entry.key = item.itemKey; - if (item.layer !== undefined && item.layer.enabled !== undefined) - entry.layered = item.layer.enabled; - entry.children = (item.children || []).filter(child => child.visible).map(child => dump(child, origin)); - return entry; - } - - function popupContent(item) { - for (let i = 0; i < item.data.length; i++) { - const object = item.data[i]; - if (object && object.contentItem && object.anchors !== undefined && object.visible) - return dump(object.contentItem, object.contentItem); - } - return null; - } - - property var closedSizes: ({}) - - function leaves(tree, out) { - if (tree.text !== undefined) - out.push(tree); - for (const child of tree.children) - leaves(child, out); - return out; - } - - function snapshot() { - const phaseName = root.phases[root.phase]; - for (const instance of root.instances) { - const item = instance.item; - const popup = popupContent(item); - const expectedHidden = { - popup: 2, - "popup-single": 2, - "popup-all": 0, - "inline-left": 2, - "inline-right": 2, - "inline-spaced": 3 - }[instance.name]; - check(item.hiddenBarItems.length === expectedHidden, phaseName + " " + instance.name + " hidden items " + item.hiddenBarItems.length); - check(item.menuOpen === (phaseName === "open"), phaseName + " " + instance.name + " menuOpen state"); - const size = instance.vertical ? item.visualHeight : item.visualWidth; - const key = instance.name + instance.vertical; - if (phaseName === "closed") - root.closedSizes[key] = size; - if (phaseName === "open" && instance.name.startsWith("inline")) - check(size > root.closedSizes[key], phaseName + " " + instance.name + " inline expansion grows the bar widget"); - if (phaseName === "closed-again") - check(size === root.closedSizes[key], phaseName + " " + instance.name + " size restored"); - const record = { - phase: phaseName, - name: instance.name, - vertical: instance.vertical, - visible: item.visible, - trayCount: item.allTrayItems.length, - hidden: item.hiddenBarItems.length, - menuOpen: item.menuOpen, - w: item.width, - h: item.height, - visualW: item.visualWidth, - visualH: item.visualHeight, - tree: dump(item.visualContent, item.visualContent), - popup: popup - }; - console.log("PARITY " + JSON.stringify(record)); - } - } - - 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) { - for (const testCase of root.cases) { - const item = tray.createObject(stage, { - axis: axis, - barThickness: 48, - widgetThickness: 30, - barSpacing: 4, - barConfig: root.barConfig, - widgetData: testCase.data, - section: testCase.section, - y: y - }); - y += 140; - created.push({ - name: testCase.name, - vertical: axis.isVertical, - item: item - }); - } - } - root.instances = created; - } - - Timer { - interval: 1500 - running: true - repeat: true - property int ticks: 0 - onTriggered: { - ticks++; - if (SystemTray.items.values.length < 4 && ticks < 12) - return; - check(SystemTray.items.values.length === 4, "four tray items registered, got " + SystemTray.items.values.length); - root.snapshot(); - root.phase++; - if (root.phase >= root.phases.length) { - root.finish(); - stop(); - Qt.quit(); - return; - } - for (const instance of root.instances) - instance.item.menuOpen = root.phases[root.phase] === "open"; - } - } -} diff --git a/quickshell/tests/qml/weather-service.qml b/quickshell/tests/qml/weather-service.qml deleted file mode 100644 index b0fa5d63b..000000000 --- a/quickshell/tests/qml/weather-service.qml +++ /dev/null @@ -1,82 +0,0 @@ -import QtQuick -import Quickshell -import qs.Common -import qs.Services -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"); - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - } - - Timer { - interval: 1200 - running: true - onTriggered: { - const icons = {}; - for (let code = 0; code < 100; code++) - icons[code] = [WeatherService.getWeatherIcon(code, true), WeatherService.getWeatherIcon(code, false), WeatherService.getWeatherIcon(code)]; - console.log("PARITY " + JSON.stringify({ - label: "icons", - icons: icons, - dayKeys: Object.keys(WeatherService.weatherIcons), - nightKeys: Object.keys(WeatherService.nightWeatherIcons) - })); - check(Object.values(icons).every(triple => triple.every(icon => typeof icon === "string" && icon.length > 0)), "every code yields an icon name"); - check(Object.keys(WeatherService.nightWeatherIcons).length === Object.keys(WeatherService.weatherIcons).length, "night map covers the same codes as the day map"); - check(icons[2][2] === (WeatherService.weather.isDay ? icons[2][0] : icons[2][1]), "default argument follows weather.isDay"); - console.log("PARITY " + JSON.stringify({ - label: "initial", - weather: WeatherService.weather - })); - WeatherService.weather = { - available: true, - loading: false, - temp: 21, - city: "Somewhere", - forecast: [1, 2] - }; - SettingsData.weatherCoordinatesChanged(); - console.log("PARITY " + JSON.stringify({ - label: "afterCoordinates", - weather: WeatherService.weather, - location: WeatherService.location - })); - check(WeatherService.weather.available === false && WeatherService.weather.loading === true && WeatherService.weather.city === "" && WeatherService.location === null, "coordinates change resets weather and location"); - WeatherService.weather = { - available: true, - loading: false, - temp: 22, - city: "Elsewhere", - forecast: [] - }; - SettingsData.useAutoLocationChanged(); - console.log("PARITY " + JSON.stringify({ - label: "afterAutoLocation", - weather: WeatherService.weather, - location: WeatherService.location - })); - check(WeatherService.weather.available === false && WeatherService.weather.forecast.length === 0 && WeatherService.location === null, "auto location change resets weather and location"); - root.finish(); - Qt.quit(); - } - } -} diff --git a/quickshell/tests/qml/widget-pickers.qml b/quickshell/tests/qml/widget-pickers.qml deleted file mode 100644 index 02a49c7f9..000000000 --- a/quickshell/tests/qml/widget-pickers.qml +++ /dev/null @@ -1,173 +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 - - 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"); - } - - property var pickers: [] - property int step: 0 - property var events: [] - property string expectedDesktop: "" - - Component { - id: barPicker - WidgetSelectionPopup { - onWidgetSelected: (widgetId, targetSection) => root.events.push("bar:" + widgetId + ":" + targetSection) - } - } - Component { - id: desktopPicker - DesktopWidgetBrowser { - onWidgetAdded: widgetType => root.events.push("desktop:" + widgetType) - } - } - - function typeName(item) { - return String(item).split("(")[0].replace(/_QMLTYPE_\d+/, "").replace(/_QML_\d+/, ""); - } - - function dump(item, origin) { - const pos = item.mapToItem(origin, 0, 0); - const entry = { - type: typeName(item), - x: Math.round(pos.x * 100) / 100, - y: Math.round(pos.y * 100) / 100, - w: item.width, - h: item.height - }; - if (typeof item.text === "string" && item.font !== undefined) { - entry.text = item.text; - entry.px = item.font.pixelSize; - } - if (item.color !== undefined) - entry.color = String(item.color); - if (item.border !== undefined && item.border.width !== undefined) - entry.border = [item.border.width, String(item.border.color)]; - entry.children = (item.children || []).filter(child => child.visible).map(child => dump(child, origin)); - return entry; - } - - function snapshot(label) { - for (const picker of root.pickers) { - const win = picker.item; - console.log("PARITY " + JSON.stringify({ - label: label, - name: picker.name, - visible: win.visible, - title: win.title, - objectName: win.objectName, - filtered: (win.filteredWidgets || []).map(w => w.id), - selectedIndex: win.selectedIndex, - nav: win.keyboardNavigationActive, - tree: dump(win.contentItem, win.contentItem) - })); - } - } - - Component.onCompleted: { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - const bar = barPicker.createObject(root); - const desktop = desktopPicker.createObject(root); - root.pickers = [ - { - name: "bar", - item: bar - }, - { - name: "desktop", - item: desktop - } - ]; - } - - Timer { - interval: 1200 - running: true - repeat: true - onTriggered: { - const bar = root.pickers[0].item; - const desktop = root.pickers[1].item; - switch (root.step++) { - case 0: - bar.targetSection = "center"; - bar.widgets = [ - { - id: "clock", - text: "Clock", - description: "Time and date", - icon: "schedule" - }, - { - id: "cpuUsage", - text: "CPU usage", - description: "Processor load", - icon: "memory" - }, - { - id: "battery", - text: "Battery", - description: "Charge level", - icon: "battery_full" - } - ]; - bar.show(); - desktop.show(); - return; - case 1: - root.snapshot("open"); - check(bar.visible && desktop.visible, "both pickers visible after show"); - check(JSON.stringify(bar.filteredWidgets.map(w => w.id)) === JSON.stringify(["clock", "cpuUsage", "battery"]), "bar picker lists the injected widgets"); - check(desktop.filteredWidgets.length > 0 && desktop.filteredWidgets.every(w => w.id && w.name), "desktop picker lists registry widgets"); - bar.searchQuery = "cpu"; - bar.updateFilteredWidgets(); - desktop.searchQuery = "clock"; - desktop.updateFilteredWidgets(); - return; - case 2: - root.snapshot("searched"); - check(JSON.stringify(bar.filteredWidgets.map(w => w.id)) === JSON.stringify(["cpuUsage"]), "bar search narrows to cpuUsage"); - check(desktop.filteredWidgets.length > 0 && desktop.filteredWidgets.every(w => (w.name + w.id + (w.description || "")).toLowerCase().includes("clock")), "desktop search narrows to clock widgets"); - root.expectedDesktop = desktop.filteredWidgets[0]?.id ?? ""; - bar.selectNext(); - desktop.selectNext(); - desktop.selectNext(); - return; - case 3: - root.snapshot("navigated"); - check(bar.selectedIndex === 0 && bar.keyboardNavigationActive, "bar keyboard selection on first result"); - check(desktop.selectedIndex === 0 && desktop.keyboardNavigationActive, "desktop keyboard selection clamps to the single result"); - bar.selectWidget(); - desktop.selectWidget(); - return; - case 4: - root.snapshot("chosen"); - check(!bar.visible && !desktop.visible, "both pickers hidden after choosing"); - check(JSON.stringify(root.events) === JSON.stringify(["bar:cpuUsage:center", "desktop:" + root.expectedDesktop]), "chosen events " + JSON.stringify(root.events)); - console.log("PARITY_EVENTS " + JSON.stringify(root.events)); - root.finish(); - stop(); - Qt.quit(); - } - } - } -} diff --git a/quickshell/tests/qml/widgets-tab-plugin-nav.qml b/quickshell/tests/qml/widgets-tab-plugin-nav.qml deleted file mode 100644 index e0a44c10b..000000000 --- a/quickshell/tests/qml/widgets-tab-plugin-nav.qml +++ /dev/null @@ -1,115 +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 - - property bool failed: false - property var tab: null - - function check(condition, label) { - if (condition) - return; - failed = true; - console.log("FIXTURE_FAIL " + label); - } - - function findPluginRow(item) { - if (item.modelData?.pluginId && typeof item.clicked === "function") - return item; - for (const child of item.children || []) { - const found = findPluginRow(child); - if (found) - return found; - } - return null; - } - - QtObject { - id: modalStub - - property var navigated: [] - - function navigateTo(pageId) { - navigated = navigated.concat([pageId]); - return true; - } - } - - Component { - id: tabComponent - WidgetsTab {} - } - - PanelWindow { - color: "transparent" - implicitWidth: 900 - implicitHeight: 1600 - anchors { - top: true - left: true - } - - Item { - id: stage - anchors.fill: parent - } - } - - Timer { - interval: 1500 - running: true - repeat: true - property int step: 0 - onTriggered: { - if (step++ === 0) { - Quickshell.watchFiles = false; - DC.Style.theme = Theme; - DC.Style.settings = SettingsData; - DC.I18n.backend = I18n; - SettingsData.barConfigs = [ - { - "id": "default", - "enabled": true, - "position": SettingsData.Position.Top, - "leftWidgets": ["plugin_fixture"], - "centerWidgets": [], - "rightWidgets": [] - } - ]; - root.tab = tabComponent.createObject(stage, { - width: 860, - height: 1500, - parentModal: modalStub, - baseWidgetDefinitions: [ - { - "id": "plugin_fixture", - "pluginId": "fixture", - "text": "Fixture Plugin", - "description": "", - "icon": "extension", - "enabled": true - } - ] - }); - if (!root.tab) { - console.error("FIXTURE_FAIL " + tabComponent.errorString()); - Qt.quit(); - return; - } - return; - } - const row = findPluginRow(root.tab); - check(!!row, "plugin widget row is rendered"); - row?.clicked(); - check(JSON.stringify(modalStub.navigated) === JSON.stringify([SettingsTabs.pluginPrefix + "fixture"]), "plugin row pushes the plugin page onto the modal history, got " + JSON.stringify(modalStub.navigated)); - console.log(root.failed ? "FIXTURE_FAIL see above" : "FIXTURE_PASS"); - stop(); - Qt.quit(); - } - } -} diff --git a/quickshell/tests/qml/workspace-switcher.qml b/quickshell/tests/qml/workspace-switcher.qml index 993a95d12..31787c0b8 100644 --- a/quickshell/tests/qml/workspace-switcher.qml +++ b/quickshell/tests/qml/workspace-switcher.qml @@ -124,7 +124,7 @@ ShellRoot { onTriggered: { try { - if (++waited > 200) + if (++waited > 800) throw new Error("timed out in step " + step); switch (step) { case 0: @@ -234,7 +234,7 @@ ShellRoot { advance(); return; case 6: - if (root.pills().length !== 3 || waited < 4) + 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"); diff --git a/quickshell/tests/run-grid-edit.py b/quickshell/tests/run-grid-edit.py deleted file mode 100644 index 5d03e9346..000000000 --- a/quickshell/tests/run-grid-edit.py +++ /dev/null @@ -1,37 +0,0 @@ -from pathlib import Path -import json -import os -import shutil -import subprocess -import tempfile - -repo = Path(__file__).resolve().parents[2] -with tempfile.TemporaryDirectory(prefix="dms-grid-edit-test-") as temporary: - root = Path(temporary) - for name in ["qml", "runtime", "config/DankMaterialShell", "cache", "state", "data"]: - (root / name).mkdir(parents=True, exist_ok=True) - (root / "runtime").chmod(0o700) - for source in (repo / "quickshell").iterdir(): - if source.name in ["shell.qml", ".qmlls.ini", "dms-plugins"]: - continue - (root / "qml" / source.name).symlink_to(source) - (root / "qml/shell.qml").write_text((repo / "quickshell/tests/qml/grid-edit.qml").read_text()) - settings = {"configVersion": 27, "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 ["DMS_SOCKET", "WAYLAND_DISPLAY", "WAYLAND_SOCKET", "NIRI_SOCKET", "HYPRLAND_INSTANCE_SIGNATURE", "DISPLAY"]: - env.pop(name, None) - for name in ["runtime", "config", "cache", "state", "data"]: - env["XDG_" + name.upper() + ("_DIR" if name == "runtime" else "_HOME")] = str(root / name) - for name in ["home", "xdg"]: - (root / name).mkdir() - 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"), LANG="C.UTF-8", LC_ALL="C.UTF-8", TZ="UTC") - env.update(QT_QPA_PLATFORM="wayland", QT_QUICK_BACKEND="software", WLR_BACKENDS="headless", WLR_RENDERER="pixman", 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")) - result = subprocess.run(["cage", "--", "qs", "-p", str(root / "qml")], env=env, capture_output=True, text=True, timeout=30) - output = result.stdout + result.stderr - print(output) - if result.returncode or "GRID_EDIT_PASS" not in output or "GRID_EDIT_FAIL" in output or "Binding loop detected" in output: - raise SystemExit(1) diff --git a/quickshell/tests/run-qml.py b/quickshell/tests/run-qml.py index 8b657fc88..e7b5c5ac3 100644 --- a/quickshell/tests/run-qml.py +++ b/quickshell/tests/run-qml.py @@ -3,7 +3,6 @@ from concurrent.futures import ThreadPoolExecutor, as_completed import os from pathlib import Path import shutil -import signal import subprocess import sys import time @@ -23,23 +22,11 @@ def qml_test_runner(): def run(name, command): started = time.monotonic() - env = {key: value for key, value in os.environ.items() if not key.startswith("DMS_FIXTURE_")} - env["QT_QPA_PLATFORM"] = "offscreen" try: - process = subprocess.Popen(command, cwd=repo, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, start_new_session=True) + result = subprocess.run(command, cwd=repo, env=dict(os.environ, QT_QPA_PLATFORM="offscreen"), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) except OSError as error: return name, False, time.monotonic() - started, str(error) - try: - output, _ = process.communicate(timeout=120) - return name, process.returncode == 0, time.monotonic() - started, output - except subprocess.TimeoutExpired: - os.killpg(process.pid, signal.SIGTERM) - try: - output, _ = process.communicate(timeout=5) - except subprocess.TimeoutExpired: - os.killpg(process.pid, signal.SIGKILL) - output, _ = process.communicate() - return name, False, time.monotonic() - started, output + "\nSuite timed out" + return name, result.returncode == 0, time.monotonic() - started, result.stdout def main(): @@ -50,9 +37,10 @@ def main(): if args.jobs < 1: parser.error("--jobs must be positive") selected = args.suites or ["widgets", "media", "qt", "logic"] + fixtures = sorted(str(path.relative_to(repo)) for path in (repo / "quickshell/tests/qml").glob("*.qml")) commands = { - "widgets": [sys.executable, "quickshell/tests/run-surface-fixture.py", "--suite", "quickshell/tests/qml/bar-content.qml", "quickshell/tests/qml/frame-bar-flip.qml", "quickshell/tests/qml/instance-routing.qml", "quickshell/tests/qml/font-weights.qml", "quickshell/tests/qml/theme-selected-container.qml", "quickshell/tests/qml/theme-accents.qml", "quickshell/tests/qml/clipboard-preview.qml", "quickshell/tests/qml/launcher-plugin-instances.qml", "quickshell/tests/qml/island-launcher-focus.qml", "quickshell/tests/qml/workspace-switcher.qml", "quickshell/tests/qml/focused-app.qml", "quickshell/tests/qml/slider-wheel.qml", "quickshell/tests/qml/control-center-sizes.qml", "quickshell/tests/qml/dash-navigation.qml", "quickshell/tests/qml/grid-edit-layout.qml", "quickshell/tests/qml/settings-scroll.qml", "quickshell/tests/qml/settings-group-layout.qml"], - "media": [sys.executable, "quickshell/tests/run-surface-fixture.py", "--suite", "quickshell/tests/qml/media-presentation.qml", "quickshell/tests/qml/media-playback.qml", "quickshell/tests/qml/media-artwork.qml", "quickshell/tests/qml/media-lyrics.qml", "--mpris", "--artwork"], + "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" in selected: @@ -69,7 +57,7 @@ def main(): print(output, flush=True) continue for line in output.splitlines(): - if any(token in line for token in ["FIXTURE_", "Totals:", "# tests "]): + if name in ["widgets", "media"] or 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 index 2c1323973..609a43d81 100644 --- a/quickshell/tests/run-surface-fixture.py +++ b/quickshell/tests/run-surface-fixture.py @@ -1,4 +1,4 @@ -from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path import json import os @@ -42,81 +42,51 @@ def mesa_egl_vendor(): return None -suite = len(sys.argv) > 1 and sys.argv[1] == "--suite" -fixtures = [name for name in sys.argv[2:] if not name.startswith("--")] if suite else [sys.argv[1] if len(sys.argv) > 1 else "quickshell/tests/qml/instance-routing.qml"] +fixtures = [name for name in sys.argv[1:] if not name.startswith("--")] if not fixtures: - raise SystemExit("usage: run-surface-fixture.py --suite fixture.qml [fixture.qml ...] [--mpris] [--artwork]") -log_path = Path(sys.argv[2]) if not suite and len(sys.argv) > 2 and not sys.argv[2].startswith("--") else None + 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 ["qml", "runtime", "config/DankMaterialShell", "cache", "state", "data"]: - (root / name).mkdir(parents=True, exist_ok=True) - (root / "runtime").chmod(0o700) - def link_tree(target, fixture): - target.mkdir(parents=True, exist_ok=True) - for source in (repo / "quickshell").iterdir(): - if source.name in ["shell.qml", ".qmlls.ini", "dms-plugins"]: - continue - (target / source.name).symlink_to(source) - (target / "shell.qml").write_text((repo / fixture).read_text()) - - if not suite: - link_tree(root / "qml", fixtures[0]) + 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 - if os.environ.get("DMS_FIXTURE_SETTINGS"): - settings.update(json.loads(Path(os.environ["DMS_FIXTURE_SETTINGS"]).read_text())) (root / "config/DankMaterialShell/settings.json").write_text(json.dumps(settings)) - if os.environ.get("DMS_FIXTURE_PLUGIN_SETTINGS"): - (root / "config/DankMaterialShell/plugin_settings.json").write_text(Path(os.environ["DMS_FIXTURE_PLUGIN_SETTINGS"]).read_text()) - if os.environ.get("DMS_FIXTURE_PLUGINS"): - plugins = root / "config/DankMaterialShell/plugins" - plugins.mkdir() - for entry in Path(os.environ["DMS_FIXTURE_PLUGINS"]).iterdir(): - if entry.name.startswith("."): - continue - (plugins / entry.name).symlink_to(entry.resolve()) 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_")) and not name.startswith("DMS_FIXTURE_"): + if name.startswith(("DMS_", "QS_", "LC_", "XCURSOR_")): del env[name] - for name in ["home", "xdg", "qmlcache"]: - (root / name).mkdir() 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"), QML_DISK_CACHE_PATH=str(root / "qmlcache"), LANG="C.UTF-8", LC_ALL="C.UTF-8", TZ="UTC") - for name in ["runtime", "config", "cache", "state", "data"]: - env["XDG_" + name.upper() + ("_DIR" if name == "runtime" else "_HOME")] = str(root / name) + env.update(HOME=str(root / "home"), XDG_DATA_DIRS=str(root / "share"), XDG_CONFIG_DIRS=str(root / "xdg"), XDG_RUNTIME_DIR=str(root / "runtime"), 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", 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 - if "--capture" in sys.argv: - env["DMS_FIXTURE_CAPTURE"] = "1" - if "--corners" in sys.argv: - env["DMS_FIXTURE_CORNERS"] = "1" - if "--baseline" in sys.argv: - env["DMS_FIXTURE_BASELINE"] = "1" - (root / "niri.kdl").write_text('prefer-no-csd\nhotkey-overlay { skip-at-startup; }\nlayout { gaps 0; border { off; }; focus-ring { off; }; default-column-width { proportion 1.0; }; }\n' + ('output \"winit\" { scale 1.25; }\n' if '--fractional' in sys.argv else '')) - processes = [] - failed_logs = [] + (root / "niri.kdl").write_text('prefer-no-csd\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")) - processes.append(xvfb) 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(runtime, log): - runtime.mkdir(parents=True, exist_ok=True) - runtime.chmod(0o700) - local = dict(env) - local["XDG_RUNTIME_DIR"] = str(runtime) + + 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): @@ -124,44 +94,21 @@ with tempfile.TemporaryDirectory(prefix="dms-surface-test-") as temporary: ipc = list(runtime.glob("niri.*.sock")) if sockets and ipc: local.update(WAYLAND_DISPLAY=sockets[0].name, NIRI_SOCKET=str(ipc[0])) - return local, niri + return local if niri.poll() is not None: - raise RuntimeError("isolated niri failed to start: " + log.read_text()[-3000:] + "\nXvfb: " + (root / "xvfb.log").read_text()[-3000:]) + 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:]) - if not suite: - env, niri = start_niri(root / "runtime", root / "niri.log") - if "--hyprland" in sys.argv: - (root / "hyprland.conf").write_text("monitor = ,1280x800@60,auto," + ("1.25" if "--fractional" in sys.argv else "1") + "\ngeneral {\n gaps_in = 0\n gaps_out = 0\n border_size = 0\n}\nmisc {\n disable_hyprland_logo = true\n disable_splash_rendering = true\n}\n") - hypr_log = (root / "hyprland.log").open("w") - hypr = subprocess.Popen(["Hyprland", "--config", str(root / "hyprland.conf")], env=env, stdout=hypr_log, stderr=subprocess.STDOUT) - processes.append(hypr) - for attempt in range(100): - signatures = list((root / "runtime/hypr").glob("*/.socket.sock")) - child_sockets = [path for path in (root / "runtime").glob("wayland-*") if path.name != env["WAYLAND_DISPLAY"] and not path.name.endswith(".lock")] - if signatures and child_sockets: - break - if hypr.poll() is not None: - raise RuntimeError("isolated Hyprland failed to start: " + (root / "hyprland.log").read_text()[-6000:]) - time.sleep(0.1) - else: - raise RuntimeError("isolated Hyprland startup timed out: " + (root / "hyprland.log").read_text()[-6000:]) - env.pop("NIRI_SOCKET", None) - env.update(WAYLAND_DISPLAY=child_sockets[0].name, HYPRLAND_INSTANCE_SIGNATURE=signatures[0].parent.name) - # 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') - - def start_dbus(target): + def start_dbus(target, processes): if shutil.which("dbus-daemon"): read_fd, write_fd = os.pipe() - dbus = 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) - processes.append(dbus) + 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 dbus + return if not shutil.which("dbus-broker-launch"): - raise RuntimeError("bus fixtures need dbus-daemon or 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) @@ -170,167 +117,61 @@ with tempfile.TemporaryDirectory(prefix="dms-surface-test-") as temporary: listener.listen() fd = listener.fileno() move = f"exec 3<&{fd} {fd}<&-; " if fd != 3 else "" - dbus = 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) - processes.append(dbus) + 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 - return dbus - def start_mpris_player(target): - player = subprocess.Popen([sys.executable, str(repo / "quickshell/tests/fixtures/mpris_player.py")] + (["--artwork"] if "--artwork" in sys.argv else []), env=target, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - processes.append(player) - return player - - if "--dbus" in sys.argv or ("--mpris" in sys.argv and not suite): - start_dbus(env) - if "--mpris" in sys.argv and not suite: - start_mpris_player(env) - if "--dbus" in sys.argv: - tray_items = ["firefox:Mozilla Firefox:firefox:0", "kitty:Kitty:utilities-terminal:0", "nm-applet:Network:network-wireless:1", "steam:Steam:steam:0"] - sni_log = (root / "sni.log").open("w") - sni = subprocess.Popen([sys.executable, str(repo / "quickshell/tests/fixtures/sni_item.py")] + tray_items, env=env, stdout=sni_log, stderr=subprocess.STDOUT) - processes.append(sni) - if "--capture" in sys.argv and "--hyprland" in sys.argv: - raise SystemExit("--capture drives niri msg and cannot run under --hyprland") - if "--capture" in sys.argv and (len(sys.argv) < 3 or sys.argv[2].startswith("--")): - raise SystemExit("--capture needs an output log path as the second argument") - if "--capture" in sys.argv: - process = subprocess.Popen(["qs", "-p", str(root / "qml")], env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) - processes.append(process) - captured = [] - snapshot = None - for line in process.stdout: - captured.append(line) - if "LAYOUT_SNAPSHOT " in line: - snapshot = json.loads(line.split("LAYOUT_SNAPSHOT ", 1)[1]) - if "CAPTURE_READY" not in line: - continue - time.sleep(0.7) - layers = json.loads(subprocess.run(["niri", "msg", "-j", "layers"], env=env, capture_output=True, text=True, check=True, timeout=10).stdout) - windows = json.loads(subprocess.run(["niri", "msg", "-j", "windows"], env=env, capture_output=True, text=True, check=True, timeout=10).stdout) - probe = next(window for window in windows if window["title"] == "reservation probe") - captured.append("COMPOSITOR_SNAPSHOT " + json.dumps({"caseIndex": snapshot["caseIndex"], "layout": probe["layout"], "layers": layers}) + "\n") - if snapshot["caseIndex"] in [1, 3, 17, 35, 48, 73, 74, 75]: - screenshot = Path(sys.argv[2]).with_suffix("." + str(snapshot["caseIndex"]) + ".png") - subprocess.run(["niri", "msg", "action", "screenshot-screen", "--show-pointer", "false", "--path", str(screenshot)], env=env, capture_output=True, check=True, timeout=10) - if "--baseline" not in sys.argv: - reservations = snapshot["reservations"] - size = probe["layout"]["window_size"] - expected = [snapshot["screen"]["width"] - reservations["left"] - reservations["right"], snapshot["screen"]["height"] - reservations["top"] - reservations["bottom"]] - if any(abs(actual - target) > 2 for actual, target in zip(size, expected)): - captured.append("FIXTURE_FAIL compositor reservation " + json.dumps({"case": snapshot["caseIndex"], "expected": expected, "actual": size}) + "\n") - break - subprocess.run(["qs", "ipc", "-p", str(root / "qml"), "call", "probe", "next"], env=env, capture_output=True, check=True, timeout=10) - if process.poll() is None: - process.terminate() - process.wait(timeout=10) - output = "".join(captured) - returncode = process.returncode - elif "--measure" in sys.argv: - process = subprocess.Popen(["qs", "-p", str(root / "qml")], env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) - processes.append(process) - startup = [] - for line in process.stdout: - startup.append(line) - if "RESOURCE_READY" in line: - break - else: - raise RuntimeError("resource fixture failed: " + "".join(startup)) - def sample(): - fields = Path(f"/proc/{process.pid}/stat").read_text().split() - memory = dict(line.split(":", 1) for line in Path(f"/proc/{process.pid}/smaps_rollup").read_text().splitlines()[1:]) - return {"ticks": int(fields[13]) + int(fields[14]), "pss_kb": int(memory["Pss"].split()[0]), "rss_kb": int(memory["Rss"].split()[0]), "threads": len(list(Path(f"/proc/{process.pid}/task").iterdir())), "children": len(Path(f"/proc/{process.pid}/task/{process.pid}/children").read_text().split())} - def ipc(method): - subprocess.run(["qs", "ipc", "-p", str(root / "qml"), "call", "probe", method], env=env, capture_output=True, check=True, timeout=10) - time.sleep(3) - start = sample() - time.sleep(5) - idle = sample() - ipc("play") - time.sleep(5) - active = sample() - ipc("stop") - time.sleep(2) - settled = sample() - layers = subprocess.run(["niri", "msg", "-j", "layers"], env=env, capture_output=True, text=True, check=True).stdout - measurement = {"idle": idle, "active": active, "settled": settled, "idle_ticks": idle["ticks"] - start["ticks"], "active_ticks": active["ticks"] - idle["ticks"], "sample_seconds": 5, "clock_ticks": os.sysconf("SC_CLK_TCK"), "layers": json.loads(layers)} - ipc("done") - tail, _ = process.communicate(timeout=10) - output = "".join(startup) + tail - output += "\nRESOURCE_MEASUREMENT " + json.dumps(measurement) + "\n" - if measurement["active_ticks"] <= measurement["idle_ticks"]: - output += "FIXTURE_FAIL popout animation did not consume more cpu than idle\n" - if settled["children"] != idle["children"] or settled["threads"] != idle["threads"]: - output += "FIXTURE_FAIL processes or threads leaked across the animation: " + json.dumps({"idle": idle, "settled": settled}) + "\n" - returncode = process.returncode - else: - def run_fixture(index, fixture): - home = root / f"fixture{index}" - started = time.monotonic() - link_tree(home / "qml", fixture) - bus = dict(env) - dbus = start_dbus(bus) if "--mpris" in sys.argv else None - player = start_mpris_player(bus) if dbus else None - fixture_env, niri = start_niri(home / "runtime", home / "niri.log") - if dbus: - fixture_env["DBUS_SESSION_BUS_ADDRESS"] = bus["DBUS_SESSION_BUS_ADDRESS"] - for name in ["config", "state", "data"]: - shutil.copytree(root / name, home / name, symlinks=True) - fixture_env["XDG_" + name.upper() + "_HOME"] = str(home / name) - (home / "cache").mkdir() - fixture_env["XDG_CACHE_HOME"] = str(home / "cache") - try: - result = subprocess.run(["qs", "-p", str(home / "qml")], env=fixture_env, capture_output=True, text=True, timeout=60) - output = result.stdout + result.stderr - failed = result.returncode or "FIXTURE_PASS" not 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 timeout\n" - failed = True - finally: - niri.terminate() - niri.wait() - if player: - player.terminate() - player.wait() - if dbus: - dbus.terminate() - dbus.wait() - if failed: - output += "\nFIXTURE_FAIL " + fixture + "\nniri: " + (home / "niri.log").read_text()[-3000:] + "\n" - return output + f"\nFIXTURE_TIME {Path(fixture).stem}: {time.monotonic() - started:.2f}s\n", failed - - if not suite: - started = time.monotonic() - try: - result = subprocess.run(["qs", "-p", str(root / "qml")], env=env, capture_output=True, text=True, timeout=180) - output = result.stdout + result.stderr - returncode = int(bool(result.returncode) or "FIXTURE_PASS" not 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 timeout\n" - returncode = 1 - else: - default_jobs = max(1, min(8, len(os.sched_getaffinity(0)) // 2)) - jobs = min(len(fixtures), int(os.environ.get("DMS_FIXTURE_JOBS", default_jobs))) - with ThreadPoolExecutor(max_workers=jobs) as pool: - results = list(pool.map(run_fixture, range(len(fixtures)), fixtures)) - returncode = int(any(failed for _, failed in results)) - output = "\n".join(text for text, _ in results) - failed_logs = [text for text, failed in results if failed] - if "--dbus" in sys.argv: - output += "\nSNI_CLIENT " + (root / "sni.log").read_text().replace("\n", "\nSNI_CLIENT ") - if log_path: - log_path.write_text(output) - print("\n".join(line for line in output.splitlines() if any(token in line for token in ["FIXTURE_", "Error", "ERROR", "read-only", "Binding loop", "unavailable"]))) - print("snapshots:", output.count("LAYOUT_SNAPSHOT")) - for text in failed_logs: - print("---- full log of failed fixture ----\n" + text) - if returncode or "FIXTURE_PASS" not in output or any(token in output for token in ["FIXTURE_FAIL", "ReferenceError:", "TypeError:", "Binding loop", "Cannot assign", "is not a function"]): - raise SystemExit(1) - finally: - for process in reversed(processes): - process.terminate() + 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: - process.wait(timeout=5) - except subprocess.TimeoutExpired: - process.kill() - process.wait() + 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)