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