The shortcuts wrote the Plasma 5 value format and so never bound anything, which is what produced "No default shortcuts" in System Settings. kglobalacceld's KServiceActionComponent::loadSettings reads the services _launch key as a bare key sequence and hands it straight to QKeySequence::fromString. Plasma 5 stored a "keys,default,name" triplet there and Plasma 6 does not, so "Meta+Alt+T,none,Insta360: Toggle AI tracking" parsed to an empty sequence and bound no key. The value is now just the key sequence; alternates would be tab-separated and "none" means unbound. Two supporting changes. The desktop entries now carry X-KDE-Shortcuts, which is where kglobalacceld reads an entry's default shortcut from, so the binding shows up in System Settings as a default instead of as nothing. And they are no longer NoDisplay by default: KApplicationTrader skips NoDisplay services, so hiding them from the launcher also hid them from the Shortcuts page, which is the opposite of helpful. HIDE_FROM_MENU=1 restores the old behaviour. --list is now a diagnostic rather than a bare dump: it reports the registered key, the default from the desktop entry, whether the entry file exists, and flags any comma-bearing value as the unparseable Plasma 5 form. It would have identified this fault directly. The new plasmoid is a Plasma 6 applet: a velocity-style virtual joystick plus zoom slider and Home/Center/Tracking buttons. It drives the gimbal with relative moves, not absolute ones, because linkctl status reports no pan or tilt while the camera is idle, leaving no trustworthy position to aim from; relative steps work in either state and cannot drift, and linkctl clamps them to the device range. The pure logic lives in a .js library so it is testable outside Plasma. Tested: 36 new node checks over shell quoting, tick command construction, inversion, the per-axis threshold and the multi-object JSON merge, each confirmed meaningful by mutation. Reproduced the broken shortcut state in a throwaway HOME and verified --list flags it and reinstalling repairs it. All QML passes qmllint with no syntax errors (KDE modules are unresolvable offline). shellcheck clean. The applet has not been loaded in a real Plasma session.
106 lines
4.8 KiB
JavaScript
106 lines
4.8 KiB
JavaScript
/*
|
|
* Tests for the joystick applet's pure helpers.
|
|
* Run: node tests/test_joystick_logic.js
|
|
*
|
|
* logic.js is a QML .js library, so it is loaded by stripping the `.pragma`
|
|
* line (meaningless to node) and evaluating the rest. That keeps one copy of
|
|
* the logic: the file under test is the file the applet ships.
|
|
*/
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const vm = require("vm");
|
|
|
|
const src = fs.readFileSync(
|
|
path.join(__dirname, "../plasmoid/package/contents/ui/logic.js"), "utf8");
|
|
const ctx = { Math, JSON, String };
|
|
vm.createContext(ctx);
|
|
vm.runInContext(src.replace(/^\s*\.pragma\s+library\s*$/m, ""), ctx);
|
|
|
|
let checks = 0, failures = 0;
|
|
function eq(got, want, label) {
|
|
checks++;
|
|
if (got !== want) {
|
|
failures++;
|
|
console.log(`FAIL ${label}\n got: ${got}\n want: ${want}`);
|
|
}
|
|
}
|
|
function deep(got, want, label) {
|
|
eq(JSON.stringify(got), JSON.stringify(want), label);
|
|
}
|
|
|
|
// --- shq: the shell boundary -------------------------------------------
|
|
eq(ctx.shq("home"), "'home'", "shq plain");
|
|
eq(ctx.shq("my desk"), "'my desk'", "shq space");
|
|
eq(ctx.shq("it's"), "'it'\\''s'", "shq embedded quote");
|
|
eq(ctx.shq("a; rm -rf /"), "'a; rm -rf /'", "shq neutralises separator");
|
|
eq(ctx.shq("$(whoami)"), "'$(whoami)'", "shq neutralises substitution");
|
|
eq(ctx.shq("`id`"), "'`id`'", "shq neutralises backticks");
|
|
// A preset name chosen to break out of the quoting must stay inert.
|
|
eq(ctx.shq("x'; touch /tmp/pwned; '"), "'x'\\''; touch /tmp/pwned; '\\'''",
|
|
"shq contains an escape attempt");
|
|
|
|
// --- baseCmd -----------------------------------------------------------
|
|
eq(ctx.baseCmd("linkctl", ""), "'linkctl' --force --json", "baseCmd plain");
|
|
eq(ctx.baseCmd("", ""), "'linkctl' --force --json", "baseCmd defaults binary");
|
|
eq(ctx.baseCmd("/usr/local/bin/linkctl", "/dev/video0"),
|
|
"'/usr/local/bin/linkctl' --force --json --device '/dev/video0'",
|
|
"baseCmd with device");
|
|
|
|
// --- tickCommand -------------------------------------------------------
|
|
const B = "'linkctl' --force --json";
|
|
eq(ctx.tickCommand(B, 0, 0, 6, false, false), "", "centred stick does nothing");
|
|
eq(ctx.tickCommand(B, 0.01, 0.01, 6, false, false), "",
|
|
"tiny deflection does nothing");
|
|
eq(ctx.tickCommand(B, 1, 0, 6, false, false), B + " right 6.0", "full right");
|
|
eq(ctx.tickCommand(B, -1, 0, 6, false, false), B + " left 6.0", "full left");
|
|
eq(ctx.tickCommand(B, 0, 1, 6, false, false), B + " up 6.0", "full up");
|
|
eq(ctx.tickCommand(B, 0, -1, 6, false, false), B + " down 6.0", "full down");
|
|
eq(ctx.tickCommand(B, 0.5, 0, 6, false, false), B + " right 3.0",
|
|
"half deflection halves the step");
|
|
|
|
// Diagonal: two single-axis calls chained.
|
|
eq(ctx.tickCommand(B, 1, 1, 6, false, false),
|
|
B + " right 6.0 ; " + B + " up 6.0", "diagonal chains both axes");
|
|
|
|
// A mostly-horizontal push must not creep vertically.
|
|
eq(ctx.tickCommand(B, 1, 0.05, 6, false, false), B + " right 6.0",
|
|
"small off-axis component is dropped");
|
|
|
|
// Inversion
|
|
eq(ctx.tickCommand(B, 1, 0, 6, true, false), B + " left 6.0", "invert pan");
|
|
eq(ctx.tickCommand(B, 0, 1, 6, false, true), B + " down 6.0", "invert tilt");
|
|
eq(ctx.tickCommand(B, 1, 1, 6, true, true), B + " left 6.0 ; " + B + " down 6.0",
|
|
"invert both");
|
|
|
|
// --- absorb ------------------------------------------------------------
|
|
deep(ctx.absorb('{"pan":12.3,"tilt":null}'), { pan: 12.3 },
|
|
"absorb takes pan, ignores null tilt");
|
|
// The diagonal case: two objects, each carrying one axis.
|
|
deep(ctx.absorb('{"pan":12.3,"tilt":null}\n{"pan":null,"tilt":-4}'),
|
|
{ pan: 12.3, tilt: -4 }, "absorb merges both axes across lines");
|
|
deep(ctx.absorb('{"tracking":true}'), { tracking: true }, "absorb tracking true");
|
|
deep(ctx.absorb('{"tracking":false}'), { tracking: false },
|
|
"absorb tracking false is kept, not treated as absent");
|
|
deep(ctx.absorb('{"state":"inactive","pan":null}'), { state: "inactive" },
|
|
"absorb state");
|
|
deep(ctx.absorb('{"pan":0,"tilt":0}'), { pan: 0, tilt: 0 },
|
|
"absorb keeps zero values");
|
|
deep(ctx.absorb(""), {}, "absorb empty");
|
|
deep(ctx.absorb("not json at all"), {}, "absorb ignores non-json");
|
|
deep(ctx.absorb('{"pan":1}\nbroken {\n{"tilt":2}'), { pan: 1, tilt: 2 },
|
|
"absorb survives a malformed line between good ones");
|
|
deep(ctx.absorb('Pan: 12.3°'), {}, "absorb ignores human-readable output");
|
|
|
|
// --- errorMessage ------------------------------------------------------
|
|
eq(ctx.errorMessage('{"error":"camera not found"}', "", 3), "camera not found",
|
|
"errorMessage prefers the json error");
|
|
eq(ctx.errorMessage("", "permission denied", 6), "permission denied",
|
|
"errorMessage falls back to stderr");
|
|
eq(ctx.errorMessage("", "", 7), "linkctl exited 7",
|
|
"errorMessage falls back to the exit code");
|
|
eq(ctx.errorMessage("not json", "stderr text", 8), "stderr text",
|
|
"errorMessage uses stderr when stdout is not json");
|
|
|
|
console.log(`\n${checks} checks, ${failures} failures`);
|
|
process.exit(failures === 0 ? 0 : 1);
|