Files

213 lines
12 KiB
JavaScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import vm from "node:vm";
import { loadScript } from "./qml-script.mjs";
const OutputModel = loadScript(new URL("../Common/OutputModel.js", import.meta.url));
const BlurStrength = loadScript(new URL("../Common/BlurStrength.js", import.meta.url));
const SPEC = loadScript(new URL("../Common/settings/SettingsSpec.js", import.meta.url)).SPEC;
const source = readFileSync(new URL("../Services/HyprlandService.qml", import.meta.url), "utf8");
const extract = name => {
const found = source.match(new RegExp(`^ function ${name}\\([^)]*\\) \\{\\n[\\s\\S]*?^ \\}`, "m"));
if (!found)
throw new Error(`${name} not found in HyprlandService.qml`);
return found[0];
};
const live = {};
// Every option is reported as supported unless a test shrinks the set
const scope = vm.createContext({
OutputModel,
BlurStrength,
SettingsData: { displayNameMode: "system" },
liveMonitor: name => live[name] ?? null,
hyprOptionNames: new Proxy({}, { get: (names, key) => key in names ? names[key] : true })
});
for (const fn of ["getOutputIdentifier", "luaQuoted", "forceFlagValue", "buildOutputsLua", "luaString", "luaTable", "hyprSupports", "blurLines", "decorationLines", "groupbarLines", "hyprScrollMethod", "touchpadNames", "inputLines", "touchpadDeviceLines", "refreshFollowMouse"])
vm.runInContext(extract(fn), scope);
const withOptions = (names, run) => {
const previous = scope.hyprOptionNames;
scope.hyprOptionNames = names;
try {
return run();
} finally {
scope.hyprOptionNames = previous;
}
};
// Arrays from the vm realm fail deepStrictEqual against local ones
const decorationLines = (s, rounding) => [...scope.decorationLines(s, rounding)];
const groupbarLines = s => [...scope.groupbarLines(s)];
const inputLines = s => [...scope.inputLines(s)].map(line => line.trim());
const deviceLines = (s, names) => [...scope.touchpadDeviceLines(s, names)];
const settingsFor = prefix => {
const defaults = Object.fromEntries(Object.keys(SPEC).filter(key => prefix.test(key)).map(key => [key, SPEC[key].def]));
return overrides => Object.assign({}, defaults, overrides);
};
const layout = settingsFor(/^(hyprland|blurStrength)/);
const input = settingsFor(/^(mouse|touchpad|keyboard)[A-Z]/);
const table = (lines, name) => {
const start = lines.indexOf(`${name} = {`);
return start < 0 ? null : lines.slice(start + 1, lines.indexOf("},", start)).map(line => line.trim());
};
const fallback = 'hl.monitor({ output = "", mode = "preferred", position = "auto", scale = "auto" })';
const saved = OutputModel.parseHyprlandOutputs([
fallback,
'hl.monitor({ output = "eDP-1", mode = "[email protected]", position = "0x0", scale = 1.25, vrr = 0 })',
'hl.monitor({ output = "DP-1", mode = "preferred", position = "auto", scale = "auto" })',
'hl.monitor({ output = "DP-2", mode = "preferred", position = "auto-right", scale = "auto" })'
].join("\n"));
const connected = (name, x, y, scale) => ({ name, logical: { x, y, scale, transform: "Normal" }, modes: [], current_mode: -1 });
const monitorLine = (content, name) => content.split("\n").find(line => line.includes(`output = "${name}"`));
test("outputs: the catch-all line is not a monitor and is rewritten verbatim, once, last", () => {
assert.deepEqual(Object.keys(saved), ["eDP-1", "DP-1", "DP-2"]);
const content = scope.buildOutputsLua({ "": { name: "", logical: { x: 0, y: 0, scale: 1 } }, "eDP-1": connected("eDP-1", 0, 0, 1.25) }, {}, saved);
assert.deepEqual(content.split("\n").filter(line => line.includes('output = ""')), [fallback]);
assert.equal(content.trimEnd().split("\n").at(-1), fallback);
});
test("outputs: auto position and scale survive a rewrite until the written value differs from what Hyprland resolved", () => {
live["eDP-1"] = { x: 0, y: 0, scale: 1.25 };
live["DP-1"] = { x: 1920, y: 0, scale: 1.5 };
const cases = [
[connected("DP-1", 1920, 0, 1.5 + 1 / 512), 'position = "auto", scale = "auto"'],
[connected("DP-1", 2000, 0, 1.5), 'position = "2000x0", scale = "auto"'],
[connected("DP-1", 1920, 0, 2), 'position = "auto", scale = 2']
];
for (const [output, want] of cases) {
const line = monitorLine(scope.buildOutputsLua({ "eDP-1": connected("eDP-1", 0, 0, 1.25), "DP-1": output }, {}, saved), "DP-1");
assert.ok(line.includes(want), line);
}
const disconnected = monitorLine(scope.buildOutputsLua({ "eDP-1": connected("eDP-1", 0, 0, 1.25), "DP-2": saved["DP-2"] }, {}, saved), "DP-2");
assert.ok(disconnected.includes('position = "auto-right", scale = "auto"'), disconnected);
});
test("outputs: vrr is written only for a chosen mode, so untouched monitors inherit misc:vrr", () => {
const vrrFile = OutputModel.parseHyprlandOutputs([
'hl.monitor({ output = "eDP-1", mode = "[email protected]", position = "0x0", scale = 1.25, vrr = 0 })',
'hl.monitor({ output = "DP-1", mode = "preferred", position = "2400x0", scale = 1, vrr = 2 })'
].join("\n"));
const supported = name => Object.assign(connected(name, 0, 0, 1), { vrr_supported: true, vrr_enabled: false });
const vrrOf = (name, settings) => monitorLine(scope.buildOutputsLua({ [name]: supported(name) }, settings, vrrFile), name).match(/vrr = (-?\d+)/)?.[1];
const cases = [
["eDP-1", {}, undefined],
["DP-1", {}, "2"],
["DP-1", { "DP-1": { vrr: -1 } }, undefined],
["eDP-1", { "eDP-1": { vrr: 0 } }, "0"],
["eDP-1", { "eDP-1": { vrrFullscreenOnly: true } }, "2"]
];
assert.deepEqual(cases.map(([name, settings]) => vrrOf(name, settings)), cases.map(c => c[2]));
});
test("outputs: written scale snaps to Hyprland's n/120 grid so live float32 scales round-trip unchanged", () => {
live["eDP-1"] = { x: 0, y: 0, scale: 1.25 };
live["DP-1"] = { x: 1920, y: 0, scale: 1.3333333730697632 };
const scaleOf = (name, scale) => monitorLine(scope.buildOutputsLua({ [name]: connected(name, name === "DP-1" ? 1920 : 0, 0, scale) }, {}, saved), name).match(/scale = ([^,}\s]+)/)[1];
assert.deepEqual([scaleOf("eDP-1", 1.3333333730697632), scaleOf("eDP-1", 1.33), scaleOf("DP-1", 1.3333333730697632)], ["1.333333", "1.333333", '"auto"']);
});
test("layout: a blur variant forces blur on and writes only its own tuning table", () => {
assert.equal(table(decorationLines(layout({ hyprlandBlurVariant: "kawase", blurStrength: 16 }), 0), "blur"), null);
const frost = table(decorationLines(layout({ hyprlandBlurVariant: "frost", blurStrength: 16 }), 0), "blur");
assert.deepEqual(frost, ["enabled = true,", 'variant = "frost",']);
const aurora = table(decorationLines(layout({ hyprlandBlurVariant: "aurora", blurStrength: 16 }), 0), "blur");
assert.deepEqual(aurora.slice(0, 2), ["enabled = true,", 'variant = "aurora",']);
assert.deepEqual(table(aurora, "aurora"), ["intensity = 0.35,", "speed = 1,"]);
assert.equal(table(aurora, "haze"), null);
});
test("layout: blur strength writes Hyprland's size and passes, default reach gives two passes and skips the stock size", () => {
assert.deepEqual(table(decorationLines(layout(), 0), "blur"), ["passes = 2,"]);
assert.equal(table(decorationLines(layout({ blurStrength: 16 }), 0), "blur"), null);
assert.deepEqual(table(decorationLines(layout({ blurStrength: 112, hyprlandBlurVariant: "frost" }), 0), "blur"), ["passes = 3,", "enabled = true,", 'variant = "frost",']);
});
test("layout: effects the running Hyprland does not know are not written even when enabled", () => {
const enabled = layout({
blurStrength: 16,
hyprlandBlurVariant: "aurora",
hyprlandGlowEnabled: true,
hyprlandWobbleEnabled: true,
hyprlandMotionBlurEnabled: true,
hyprlandGroupbarBlur: true
});
withOptions({ "decoration:glow:enabled": true }, () => {
assert.deepEqual(decorationLines(enabled, 12).filter(line => line.endsWith("= {")), ["glow = {"]);
assert.deepEqual(groupbarLines(enabled), []);
});
});
test("input: turning natural scroll off is written so the template's true cannot shadow it", () => {
assert.ok(table(inputLines(input({ touchpadNaturalScroll: false })), "touchpad").includes("natural_scroll = false,"));
});
test("input: focus follows mouse writes both on and off, leaves unmanaged modes alone, and skips unsupported options", () => {
const modeLine = mode => inputLines(input({ focusFollowsMouse: mode })).filter(line => line.startsWith("follow_mouse ="));
assert.deepEqual(modeLine(1), ["follow_mouse = 1,"]);
assert.deepEqual(modeLine(0), ["follow_mouse = 0,"]);
assert.deepEqual(modeLine(-1), []);
withOptions({}, () => {
assert.deepEqual(modeLine(1), []);
});
});
test("input: the live focus mode accepts all Hyprland modes and keeps the last value on failed reads", () => {
scope.CompositorService = { isHyprland: true };
scope.log = { warn() {} };
let callback;
scope.Proc = { runCommand(id, args, cb) { callback = cb; } };
for (const mode of [0, 1, 2, 3]) {
scope.refreshFollowMouse();
callback(JSON.stringify({ int: mode }), 0);
assert.equal(scope.followMouseMode, mode);
}
for (const [output, status] of [["", 1], ["invalid", 0], ['{"int":4}', 0], ['{"int":"1"}', 0]]) {
scope.refreshFollowMouse();
callback(output, status);
assert.equal(scope.followMouseMode, 3);
}
scope.CompositorService.isHyprland = false;
callback('{"int":1}', 0);
assert.equal(scope.followMouseMode, 3);
});
test("input: keyboard keys stay out until one is configured, then numlock is explicit and kb_file replaces the xkb names", () => {
const untouched = inputLines(input({ keyboardTrackLayout: "window" }));
assert.equal(untouched.some(line => /^(kb_|repeat_|numlock)/.test(line)), false);
const keyboard = inputLines(input({ keyboardLayouts: "us,de", keyboardOptions: "grp:alt_shift_toggle", keyboardRepeatRate: 40 }));
assert.deepEqual(keyboard.slice(keyboard.lastIndexOf("},") + 1), [
'kb_layout = "us,de",',
'kb_options = "grp:alt_shift_toggle",',
"repeat_rate = 40,",
"numlock_by_default = false,"
]);
const file = inputLines(input({ keyboardKeymapFile: "/k.xkb", keyboardLayouts: "us" }));
assert.ok(file.includes('kb_file = "/k.xkb",'));
assert.equal(file.some(line => line.startsWith("kb_layout")), false);
});
test("input: click method maps to clickfinger_behavior and default leaves it out", () => {
const clickfinger = method => table(inputLines(input({ touchpadClickMethod: method })), "touchpad").find(line => line.startsWith("clickfinger_behavior"));
assert.equal(clickfinger("clickfinger"), "clickfinger_behavior = true,");
assert.equal(clickfinger("button-areas"), "clickfinger_behavior = false,");
assert.equal(clickfinger("default"), undefined);
});
test("input: an option the running Hyprland does not know is never written", () => {
withOptions({ "input:sensitivity": false, "input:touchpad:natural_scroll": true, "input:touchpad:tap-to-click": false }, () => {
assert.deepEqual(inputLines(input({ mouseAccelSpeed: 0.5, touchpadDragLock: true })), ["touchpad = {", "natural_scroll = true,", "},"]);
assert.deepEqual(deviceLines(input({ touchpadAccelSpeed: 0.3 }), ["elan-touchpad"]), []);
});
});
test("input: touchpad speed goes per device and pins its own values against the global mouse ones", () => {
assert.deepEqual(deviceLines(input({ touchpadAccelSpeed: 0.3 }), ["elan-touchpad"]), ['hl.device({ name = "elan-touchpad", sensitivity = 0.30 })']);
assert.deepEqual(deviceLines(input({ mouseAccelSpeed: -0.5, mouseAccelProfile: "flat", mouseLeftHanded: true }), ["a-touchpad"]), [
'hl.device({ name = "a-touchpad", sensitivity = 0.00, accel_profile = "", left_handed = false })'
]);
assert.deepEqual([...scope.touchpadNames({ mice: [{ name: "logitech-mx-master-3" }, { name: "elan0670:00-04f3:3150-touchpad" }, { name: "apple-inc.-magic-trackpad" }] })], ["elan0670:00-04f3:3150-touchpad", "apple-inc.-magic-trackpad"]);
});