Files
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

88 lines
3.2 KiB
Lua

-- SPDX-FileCopyrightText: 2026 branderson
-- SPDX-License-Identifier: GPL-3.0-or-later
-- Offline tests for the pure helpers in obs/obs-linkctl.lua.
-- Run: lua tests/test_obs_linkctl.lua
--
-- These cover the parts that do not need OBS or a camera: shell quoting,
-- action tokenizing, the scene-mapping parser and command construction.
local m = dofile("obs/obs-linkctl.lua")
local failures, checks = 0, 0
local function eq(got, want, label)
checks = checks + 1
if got ~= want then
failures = failures + 1
io.write(string.format("FAIL %s\n got: %s\n want: %s\n",
label, tostring(got), tostring(want)))
end
end
local function eqlist(got, want, label)
eq(table.concat(got, "|"), table.concat(want, "|"), label)
end
-- shquote: the boundary between user input and /bin/sh
eq(m.shquote("home"), "'home'", "shquote plain")
eq(m.shquote("my desk"), "'my desk'", "shquote space")
eq(m.shquote("it's"), [['it'\''s']], "shquote embedded single quote")
eq(m.shquote("a;rm -rf /"), "'a;rm -rf /'", "shquote neutralises metacharacters")
eq(m.shquote("$(whoami)"), "'$(whoami)'", "shquote neutralises substitution")
-- tokenize: splitting an action string into linkctl arguments
eqlist(m.tokenize("tracking on"), { "tracking", "on" }, "tokenize simple")
eqlist(m.tokenize(" center "), { "center" }, "tokenize trims")
eqlist(m.tokenize("preset load home"), { "preset", "load", "home" }, "tokenize three")
eqlist(m.tokenize('preset load "my desk"'),
{ "preset", "load", "my desk" }, "tokenize quoted")
eqlist(m.tokenize('preset load "unterminated'),
{ "preset", "load", "unterminated" }, "tokenize unterminated quote")
eqlist(m.tokenize(""), {}, "tokenize empty")
-- parse_scene_map
local map = m.parse_scene_map([[
Talking Head = tracking on
Overhead = preset load overhead
# a comment
Slides = tracking off
Blank Action =
]])
eq(map["Talking Head"], "tracking on", "map simple")
eq(map["Overhead"], "preset load overhead", "map multiword action")
eq(map["Slides"], "tracking off", "map after comment and blank")
eq(map["# a comment"], nil, "map ignores comments")
eq(map["Blank Action"], nil, "map ignores empty action")
local spaced = m.parse_scene_map(" Scene With Spaces = tracking toggle ")
eq(spaced["Scene With Spaces"], "tracking toggle", "map trims around separator")
-- An action containing '=' must keep it; only the first separator splits.
local eqaction = m.parse_scene_map("S = brightness 1=2")
eq(eqaction["S"], "brightness 1=2", "map splits on first separator only")
-- build_cmd honours configuration
m.cfg.linkctl = "linkctl"
m.cfg.device = ""
m.cfg.force = true
eq(m.build_cmd({ "tracking", "on" }),
"'linkctl' --force 'tracking' 'on'", "build_cmd with force")
m.cfg.force = false
eq(m.build_cmd({ "center" }), "'linkctl' 'center'", "build_cmd without force")
m.cfg.device = "/dev/video0"
eq(m.build_cmd({ "center" }),
"'linkctl' --device '/dev/video0' 'center'", "build_cmd with device")
m.cfg.linkctl = "/usr/local/bin/linkctl"
m.cfg.device = ""
eq(m.build_cmd({ "preset", "load", "my desk" }),
"'/usr/local/bin/linkctl' 'preset' 'load' 'my desk'",
"build_cmd quotes preset with space")
io.write(string.format("\n%d checks, %d failures\n", checks, failures))
os.exit(failures == 0 and 0 or 1)