compositor: initial support for umbriel

tests: remove a lot of them

fixes #3214
This commit is contained in:
bbedward
2026-09-22 16:43:41 -04:00
parent 27434cf016
commit 89484b5d85
75 changed files with 625 additions and 8177 deletions
+5 -1
View File
@@ -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")
}
+28
View File
@@ -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 ""
}
Generated
+3 -3
View File
@@ -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": {
+68 -3
View File
@@ -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);
}
+1 -16
View File
@@ -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() {
@@ -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
}
}
+7
View File
@@ -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");
}
+1 -1
View File
@@ -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();
+64 -2
View File
@@ -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;
}
+5
View File
@@ -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");
+62
View File
@@ -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);
}
}
+2
View File
@@ -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 "";
}
+81
View File
@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
id="Layer_1"
data-name="Layer 1"
viewBox="225.7 215.0 445.6 463.6"
version="1.1"
sodipodi:docname="umbriel2.svg"
inkscape:export-filename="boo.svg"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview8"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="1.2246853"
inkscape:cx="162.49073"
inkscape:cy="379.28112"
inkscape:window-width="445.6"
inkscape:window-height="463.6"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="0"
inkscape:current-layer="Layer_1" />
<defs
id="defs1">
<style
id="style1">.cls-1{fill:#fefefe;}.cls-2{fill:#110f3d;}.cls-3{fill:#a4a7fb;}.cls-4{fill:#fff59b;}.cls-5{fill:#696ac4;}</style>
</defs>
<path
class="cls-2"
d="m 1056.5485,447.86439 c 11,3.87 25.768,0.0539 53.3515,15.82561 24.24,17.26 38.21,41.12 43.69,70.05 4,21 2,41.87 -1.65,62.85 -2.2,12.56 -4.44,26 4.71,37.38 2.6973,3.57436 6.6768,5.96207 11.1,6.66 26.5,5 40.27,-11.51 52.26,-30.44 7.86,-12.41 20.16,-17.3 31.87,-10.83 7.21,4 11.4,10.29 11.52,18.47 0.14,9.73 0.16,19.56 -1.06,29.18 -2.2535,18.18671 -6.5335,36.06443 -12.76,53.3 -10.06,27.69 -24.36,52.72 -44.14,74.79 -15.2962,17.27937 -33.8723,31.34827 -54.65,41.39 -19.23,9.24 -38.7804,16.65997 -60.0949,18.45993 -18.8559,1.19355 -37.9751,1.62007 -56.8551,0.28007 -19.496,-1.2663 -38.75452,-4.98563 -57.32,-11.07 C 952.31189,816.00294 929.67353,803.77404 909.58,788 880.78195,765.23871 857.65022,736.11354 842,702.91 c -7.66887,-16.74663 -13.05045,-34.44866 -16,-52.63 -2.31,-13.56 -4,-27.21 -3.81,-40.91 0.45926,-23.50495 4.09556,-46.8398 10.81,-69.37 16.11526,-54.04248 52.33319,-99.85171 101.2,-128 20.85589,-12.0992 43.62213,-20.55369 67.32,-25 19.0535,-3.63785 38.5378,-4.45994 57.83,-2.44 11.2303,1.21666 22.3611,3.22161 33.31,6 14.58,3.63 20.9996,18.49123 12.93,28.47 -9.3053,8.83474 -38.5615,19.76439 -49.0415,28.83439 z M 938.14304,639.14101 c 7.75868,54.8593 20.30641,86.38522 65.94646,129.97446 28.5,23 65.6905,38.07453 103.6405,33.93453 33.21,-3.61 59.12,-21.35 80.94,-45.74 2.77,-3 5.76,-5.87 8.27,-9.09 18.81,-24.11 31.06,-51.32 37.63,-81.09 3.18,-14.4 5.76,-28.93 4.85,-43.8 l 0.56,-0.23 c -0.09,0 -0.23,-0.09 -0.27,-0.06 -0.088,0.11928 -0.1652,0.2465 -0.23,0.38 -1.27,1.9 -2.58,3.77 -3.81,5.69 -9.41,14.67 -21.82,25.84 -38,32.66 -26,11 -51.48,3.08 -64.33,-20 -7,-12.61 -8,-26.28 -6.17,-40.23 1.74,-13.51 4.82,-26.87 4.75,-40.58 -0.2,-36.3 -12.88,-66.25 -46.69,-83 -34.64,-17.13 -68.45,-13.23 -98.64,11 -46.86809,37.03067 -53.34061,96.5336 -48.44696,150.18101 z M 1082.37,413.52 c -0.438,-0.30276 -0.9034,-0.5639 -1.39,-0.78 -1.12,-0.29 -2.26,-0.56 -3.4,-0.78 -46.46,-9.15333 -90.29,-2.05333 -131.49,21.3 -73.08,41.44 -113.75,132 -95.28,217 17.52,80.51 68.19,131.34 146.73,154.8 5.41,1.62 11,3.13 18.3,3.1 -14.28,-8.46 -26.74,-17 -37.69,-27.38 -52.85,-50.26 -74.32,-112.64 -69.25,-184.43 5.94,-84.15 61.89,-152.44 142.64,-175.92 10,-2.91 20.22,-4.81 30.87,-6.95 z"
transform="translate(-594.28,-159.98)"
id="path2"
style="display:inline"
inkscape:label="outline"
sodipodi:nodetypes="ccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" />
<path
class="cls-3"
d="m 1239.46,622.63828 c 0.91,14.88 -1.67,29.41 -4.85,43.81 -6.57,29.77 -17.9539,57.14434 -36.7639,81.23434 -19.254,20.83004 -6.3661,5.92566 -9.1361,8.94566 -6.6322,7.11378 -13.0605,14.06564 -23.064,21.79717 -23.7553,16.17904 -34.9058,28.87857 -84.74,24.8596 -33.4287,-5.11961 -57.6738,-18.15435 -81.00358,-37.87625 -22.30285,-21.98995 -46.51814,-45.85187 -60.43545,-92.84041 -7.36425,-33.38945 -21.94082,-133.18534 47.18303,-184.26011 30.19,-24.93173 64,-28.11 98.64,-11 33.81,16.71 46.49,46.66 46.69,83 0.07,13.71 -3,27.07 -4.75,40.58 -1.79,14 -0.87,27.62 6.17,40.23 12.85,23 38.36,30.91 64.33,20 16.16,-6.82 28.57,-18 38,-32.66 1.23,-1.92 2.54,-3.79 3.81,-5.69 z"
transform="translate(-594.28,-159.98)"
id="path3"
style="display:inline"
inkscape:label="body"
sodipodi:nodetypes="ccccccccccccccccc" />
<path
class="cls-4"
d="m 1082.41,413.48 c -10.65,2.14 -20.87,4 -30.87,6.95 -80.75,23.48 -136.7,91.77 -142.64,175.92 -5.07,71.79 16.4,134.17 69.25,184.43 11,10.42 23.41,18.92 37.69,27.38 -7.32,0 -12.89,-1.48 -18.3,-3.1 C 919,781.6 868.33,730.77 850.85,650.25 c -18.47,-85 22.2,-175.59 95.28,-217 q 61.71,-35 131.49,-21.3 c 1.14,0.22 2.28,0.49 3.4,0.78 a 8.54,8.54 0 0 1 1.39,0.75 z"
transform="translate(-594.28,-159.98)"
id="path4"
style="display:inline"
inkscape:label="crescent" />
<path
class="cls-5"
d="m 342.74311,446.82311 c 0.865,10.37437 2.56415,34.87054 13.63415,62.76054 14.13034,31.66854 30.89052,56.28411 49.91314,76.51928 31.38925,33.23262 54.70552,44.2861 107.20332,41.11914 28.25026,-4.20254 39.97261,-11.6741 58.09868,-18.65376 38.04352,-18.3757 20.90772,-11.75611 22.77772,-11.27611 -21.82,24.39 -46.91347,43.45687 -80.12347,47.06687 -37.79586,5.87514 -76.91549,-7.55455 -105.41549,-30.52455 -45.6,-36.86 -58.49365,-76.96843 -66.49399,-104.09 -9.87429,-46.27998 -6.85001,-72.94427 -0.23353,-95.98799 -0.55964,14.54124 0.093,24.62926 0.63947,33.06658 z"
id="path5"
style="display:inline"
inkscape:label="shadow"
sodipodi:nodetypes="ccccccccccc" />
<path
d="m 503.2261,395.75234 c 0,7.79256 -6.31886,14.12023 -14.12024,14.12023 -7.79256,0 -14.12024,-6.32767 -14.12024,-14.12023 0,-7.80138 6.32768,-14.12024 14.12024,-14.12024 7.80138,0 14.12024,6.31886 14.12024,14.12024 z"
id="path2-9"
style="fill:#110f3d;fill-opacity:1;stroke-width:2.25923" />
<path
d="m 457.73481,451.98334 c -11.65943,0 -22.34484,-2.56602 -28.58202,-6.86649 -2.65357,-1.82745 -3.32112,-5.46583 -1.49367,-8.11954 1.82745,-2.6481 5.46582,-3.32658 8.11406,-1.4882 3.21174,2.21586 10.89889,4.80387 21.96248,4.80387 11.06359,0 18.74514,-2.58801 21.96247,-4.80387 2.64264,-1.83292 6.28116,-1.15993 8.11407,1.4882 1.82745,2.65357 1.1654,6.28662 -1.49368,8.11954 -6.23731,4.30047 -16.92287,6.86649 -28.58202,6.86649 z"
id="path3-1"
style="fill:#110f3d;fill-opacity:1;stroke-width:1.40066" />
<path
d="m 437.32865,407.51619 c -2.14452,0 -4.28894,-0.73249 -6.06289,-2.22395 -3.68007,-3.11526 -12.40819,-3.11526 -16.08826,0 -3.98892,3.36243 -9.92822,2.84167 -13.26421,-1.12078 -3.35361,-3.97129 -2.8593,-9.91061 1.11197,-13.26422 10.59018,-8.93981 29.80161,-8.93981 40.39292,0 3.97128,3.35361 4.46561,9.29293 1.11197,13.26422 -1.85328,2.20627 -4.50967,3.3448 -7.20131,3.3448 z"
id="path4-2"
style="fill:#110f3d;fill-opacity:1;stroke-width:2.25923" />
</svg>

After

Width:  |  Height:  |  Size: 6.9 KiB

+16 -11
View File
@@ -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.
+1 -1
View File
@@ -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" }] }
@@ -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");
-97
View File
@@ -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())
@@ -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();
}
}
}
+10 -3
View File
@@ -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();
}
-271
View File
@@ -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();
}
}
}
}
+64 -47
View File
@@ -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();
-185
View File
@@ -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();
}
}
-202
View File
@@ -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();
}
}
}
@@ -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();
}
}
}
+11 -51
View File
@@ -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);
}
-507
View File
@@ -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);
}
}
@@ -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();
}
}
}
}
-180
View File
@@ -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();
}
}
-403
View File
@@ -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();
}
}
@@ -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();
}
}
}
@@ -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();
}
}
}
-132
View File
@@ -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();
}
}
}
}
-230
View File
@@ -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();
}
}
}
}
-114
View File
@@ -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();
}
}
}
-120
View File
@@ -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();
}
}
}
}
+1 -1
View File
@@ -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();
-261
View File
@@ -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();
}
}
}
}
-185
View File
@@ -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();
}
}
}
+1 -1
View File
@@ -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:
-92
View File
@@ -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();
}
}
}
}
@@ -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();
@@ -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();
}
}
}
}
@@ -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()
}
}
@@ -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();
-116
View File
@@ -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
}
-155
View File
@@ -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();
}
}
}
-205
View File
@@ -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();
}
}
}
}
-95
View File
@@ -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();
}
}
}
+6 -25
View File
@@ -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;
+50 -255
View File
@@ -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("&lt;softly&gt; &amp;"), "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()
}
+7 -7
View File
@@ -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);
+22 -79
View File
@@ -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()
}
-147
View File
@@ -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();
}
}
}
}
-148
View File
@@ -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();
}
}
}
}
-154
View File
@@ -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();
}
}
}
-324
View File
@@ -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);
}
}
@@ -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();
}
}
-161
View File
@@ -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();
}
}
}
}
@@ -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();
}
}
}
-261
View File
@@ -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();
}
}
}
}
-238
View File
@@ -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]);
}
}
}
+8 -8
View File
@@ -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");
+4 -4
View File
@@ -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 {
-115
View File
@@ -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();
}
}
}
-257
View File
@@ -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();
}
}
}
}
-119
View File
@@ -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();
}
}
}
-265
View File
@@ -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";
}
}
}
-82
View File
@@ -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();
}
}
}
-173
View File
@@ -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();
}
}
}
}
@@ -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();
}
}
}
+2 -2
View File
@@ -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");
-37
View File
@@ -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)
+6 -18
View File
@@ -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)
+80 -239
View File
@@ -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('<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-Bus Bus Configuration 1.0//EN" "http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">\n<busconfig><type>session</type><listen>unix:tmpdir=' + str(root) + '</listen><policy context="default"><allow send_destination="*" eavesdrop="true"/><allow eavesdrop="true"/><allow own="*"/></policy></busconfig>\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('<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-Bus Bus Configuration 1.0//EN" "http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">\n<busconfig><type>session</type><listen>unix:tmpdir=' + str(root) + '</listen><policy context="default"><allow send_destination="*" eavesdrop="true"/><allow eavesdrop="true"/><allow own="*"/></policy></busconfig>\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)