Three input methods for the gimbal, all sharing the widget's velocity model: hold to move, release to stop. Keyboard in the applet. WASD and the arrow keys feed the same tick timer the pointer does, through a new vectorFromKeys helper. Opposing keys cancel and a diagonal is normalised to length 1, so two keys do not move faster than one. Auto-repeat is discarded, because it arrives as a press/release cycle and would make the motion stutter. The held keys are cleared when the popup loses focus: otherwise the matching release never arrives and the camera keeps panning on its own. The buttons and zoom slider are set to Qt.NoFocus so the arrow keys still reach the pad after one of them is clicked. Gamepad support, as a separate process. Qt 6 removed QtGamepad and ships no replacement, so the applet cannot read a controller itself. bin/linkctl-gamepad reads /dev/input/js* directly using only the standard library: the event struct is eight bytes and needs no third-party package. Defaults target a DualSense on hid_playstation, but axis and button numbering comes from the driver rather than the controller and varies between kernels and connection modes, so --monitor prints live events for mapping and every number is overridable. Opening a joystick replays the current state of every button with JS_EVENT_INIT set; those are filtered, or a held button would fire its mapped action at startup. --wait reconnects a wireless pad that sleeps. Directional global shortcuts. Meta+Alt plus the arrow keys nudge the gimbal. These are discrete steps by necessity: kglobalaccel fires once per keypress and exposes no press-and-hold, so this cannot be smooth motion and the README says so and points at the widget or a gamepad instead. NUDGE sets the degrees. Tested: 145 checks across five suites, all passing, shellcheck clean, QML free of syntax errors. 13 new node checks cover the key vector, including the cancellation and diagonal-normalisation cases. 36 new python checks cover event decoding, the INIT flag, deadzone rescaling and command construction. A new FIFO-driven suite stands in for /dev/input/js* and plays scripted events through the real select/read/decode/tick path, confirming diagonals emit both axes, a centred stick goes quiet, a deadzone deflection moves nothing, a real button press fires, the initial-state burst does not, and disconnect is handled. Not verified: no physical controller, camera or Plasma session was available, so the synthetic events cannot confirm a real DualSense reports the assumed axis and button numbers.
140 lines
6.5 KiB
JavaScript
140 lines
6.5 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");
|
|
|
|
// --- 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");
|
|
|
|
// --- 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);
|