Files
insta360-link-tools/tests/test_joystick_logic.js
T
brad f95853dc25 Add GPL-3.0 licensing and prepare for a public repository
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.
2026-10-13 20:54:23 -07:00

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);