metadata.json claimed MIT while no LICENSE file existed, so the repository had no effective licence at all. It is now GPL-3.0-or-later: the full FSF text in LICENSE, the identifier in metadata.json, and REUSE-style SPDX-FileCopyrightText and SPDX-License-Identifier headers on all 33 source files, which is the convention KDE itself uses. Worth recording that GPL-3.0 is a preference here rather than an obligation. Nothing in the dependency graph forces it: linkctl is MIT and is invoked as a subprocess rather than linked, and the KDE QML modules the applet imports are LGPL. Removed two things that should not ship in a public repository. tests's QML linter had an absolute sandbox path, /tmp/pyside/PySide6/qmllint, baked into its search list; it now looks for Qt's qmllint and PySide6's pyside6-qmllint on PATH and honours a QMLLINT override, and says how to install one when it finds nothing. The systemd unit carried a Documentation= URL pointing at a host that does not exist, and its ExecStart silently assumed a particular checkout directory; the URL is gone and the path is now an obvious PATH/TO/CHECKOUT placeholder next to the comment telling you to edit it. Added a Credits section naming the work this is built on, since the project is mostly glue: linkctl does the camera control, schlarpc's firmware teardown documents the extension-unit protocol, and vrwallace's controller established that the Link 2 needs both pan and tilt axes in a single VIDIOC_S_EXT_CTRLS call, which is the detail the whole thing depends on. Also made the two Python tests executable, matching the other scripts. Audited for operator-specific detail: no credentials, tokens, private hostnames, IP addresses or absolute home paths in any tracked file or anywhere in the history's diffs. Three merge commits do carry Reviewed-on and Reviewed-by trailers naming a private forge, added by the forge at merge time; those are commit metadata rather than file content and are handled separately. Tested: 169 checks across seven suites plus the QML linter, all passing after the header insertion, which confirms it did not disturb the order-sensitive `.pragma library` line in logic.js or the qmldir module definitions. shellcheck clean.
190 lines
9.3 KiB
JavaScript
190 lines
9.3 KiB
JavaScript
// SPDX-FileCopyrightText: 2026 branderson
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
/*
|
|
* 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");
|
|
|
|
// --- vectorFromKeys ----------------------------------------------------
|
|
const K = (o) => Object.assign(
|
|
{ left: false, right: false, up: false, down: false }, o);
|
|
|
|
deep(ctx.vectorFromKeys(K({})), { nx: 0, ny: 0 }, "no keys held is centred");
|
|
deep(ctx.vectorFromKeys(K({ right: true })), { nx: 1, ny: 0 }, "right key");
|
|
deep(ctx.vectorFromKeys(K({ left: true })), { nx: -1, ny: 0 }, "left key");
|
|
deep(ctx.vectorFromKeys(K({ up: true })), { nx: 0, ny: 1 }, "up key");
|
|
deep(ctx.vectorFromKeys(K({ down: true })), { nx: 0, ny: -1 }, "down key");
|
|
|
|
// Opposing keys must cancel, not fight or latch.
|
|
deep(ctx.vectorFromKeys(K({ left: true, right: true })), { nx: 0, ny: 0 },
|
|
"left and right cancel");
|
|
deep(ctx.vectorFromKeys(K({ up: true, down: true })), { nx: 0, ny: 0 },
|
|
"up and down cancel");
|
|
deep(ctx.vectorFromKeys(K({ left: true, right: true, up: true })),
|
|
{ nx: 0, ny: 1 }, "cancelling one axis leaves the other");
|
|
|
|
// A diagonal must not be faster than a straight push.
|
|
const diag = ctx.vectorFromKeys(K({ right: true, up: true }));
|
|
eq(Math.round(Math.sqrt(diag.nx * diag.nx + diag.ny * diag.ny) * 1e6) / 1e6, 1,
|
|
"diagonal is normalised to length 1");
|
|
eq(diag.nx.toFixed(4), "0.7071", "diagonal x component");
|
|
eq(diag.ny.toFixed(4), "0.7071", "diagonal y component");
|
|
|
|
// And a diagonal still produces a command on both axes at the default step.
|
|
eq(ctx.tickCommand(B, diag.nx, diag.ny, 6, false, false),
|
|
B + " right 4.2 ; " + B + " up 4.2", "diagonal keys move both axes");
|
|
|
|
// All four held at once is a standstill, not a crash.
|
|
deep(ctx.vectorFromKeys(
|
|
K({ left: true, right: true, up: true, down: true })),
|
|
{ nx: 0, ny: 0 }, "all four keys cancel out");
|
|
|
|
// --- 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");
|
|
|
|
// --- uniqueify ---------------------------------------------------------
|
|
// Plasma's executable engine keys containers by command string and serves a
|
|
// pre-existing container's cached output instead of re-running, so repeating
|
|
// a command verbatim reports success without moving the camera.
|
|
eq(ctx.uniqueify("'linkctl' --force 'center'", 1),
|
|
"'linkctl' --force 'center' # 1", "uniqueify appends a comment");
|
|
eq(ctx.uniqueify("a", 1) === ctx.uniqueify("a", 2), false,
|
|
"successive sequence numbers differ");
|
|
// The suffix must be a shell comment so it cannot alter what runs.
|
|
eq(ctx.uniqueify("x", 7).includes(" # "), true, "suffix is a shell comment");
|
|
// A diagonal tick is two chained commands; the suffix must land at the end
|
|
// and so comment out nothing meaningful.
|
|
const tc = ctx.tickCommand(B, 1, 1, 6, false, false);
|
|
eq(ctx.uniqueify(tc, 3), tc + " # 3", "uniqueify appends after a chained command");
|
|
|
|
// --- resolveCommand ----------------------------------------------------
|
|
// Plasma runs applet commands through `sh -c` with plasmashell's environment,
|
|
// which never sources a login profile. A bare name therefore has to be
|
|
// searched for explicitly or the widget cannot run it at all.
|
|
const rc = ctx.resolveCommand("linkctl");
|
|
eq(rc.indexOf("n='linkctl';") === 0, true, "resolveCommand sets the name first");
|
|
eq(rc.includes('command -v "$n"'), true, "resolveCommand tries PATH first");
|
|
eq(rc.includes('"$HOME/.local/share/mise/shims"'), true,
|
|
"resolveCommand searches mise shims");
|
|
eq(rc.includes('"$HOME/.local/bin"'), true, "resolveCommand searches ~/.local/bin");
|
|
eq(rc.includes("/usr/local/bin"), true, "resolveCommand searches /usr/local/bin");
|
|
eq(rc.includes('[ -x "$d/$n" ]'), true, "resolveCommand tests for executable");
|
|
// $HOME must reach the shell unquoted-but-protected, never single-quoted,
|
|
// or it would be looked up literally.
|
|
eq(rc.includes("'$HOME"), false, "resolveCommand does not single-quote $HOME");
|
|
|
|
// A hostile configured name must stay inert rather than running.
|
|
const rcEvil = ctx.resolveCommand("x'; touch /tmp/pwned; '");
|
|
eq(rcEvil.indexOf("n='x'\\''; touch /tmp/pwned; '\\'''") === 0, true,
|
|
"resolveCommand quotes a malicious binary name");
|
|
|
|
// --- errorMessage ------------------------------------------------------
|
|
eq(ctx.errorMessage("", "", 127),
|
|
"linkctl could not be run. Set its full path in settings.",
|
|
"errorMessage explains exit 127 as a missing binary");
|
|
eq(ctx.errorMessage("", "", 3).includes("node number changes"), true,
|
|
"errorMessage points at a stale pinned device for exit 3");
|
|
eq(ctx.errorMessage("", "", 6).includes("video"), true,
|
|
"errorMessage names the video group for exit 6");
|
|
// A real message from linkctl still wins over the canned text.
|
|
eq(ctx.errorMessage('{"error":"something specific"}', "", 3),
|
|
"something specific", "errorMessage prefers linkctl's own message");
|
|
|
|
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);
|