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

237 lines
8.7 KiB
Lua

-- SPDX-FileCopyrightText: 2026 branderson
-- SPDX-License-Identifier: GPL-3.0-or-later
-- Lifecycle tests for obs/obs-linkctl.lua against a mock OBS API.
-- Run: lua tests/test_obs_lifecycle.lua
--
-- This cannot validate OBS's real semantics, but it does exercise the script
-- the way OBS does (defaults, update, load, frontend events, properties) and
-- so catches nil dereferences, wrong call shapes and broken wiring that would
-- otherwise only show up as a silent failure inside OBS.
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 ok(cond, label) eq(not not cond, true, label) end
----------------------------------------------------------------------------
-- Mock obslua
----------------------------------------------------------------------------
local executed = {} -- commands the script tried to run
local logged = {}
local event_cbs = {}
local registered_hotkeys = {}
local fake_now = 0 -- nanoseconds, advanced manually
local current_scene = "Talking Head"
local all_scenes = { "Talking Head", "Overhead", "Slides" }
local function new_data()
return { strings = {}, bools = {}, arrays = {} }
end
local obs = {
LOG_INFO = 1, LOG_WARNING = 2,
OBS_PATH_FILE = 1, OBS_TEXT_DEFAULT = 0, OBS_TEXT_MULTILINE = 1,
OBS_FRONTEND_EVENT_SCENE_CHANGED = 10,
OBS_FRONTEND_EVENT_FINISHED_LOADING = 20,
script_log = function(_, msg) table.insert(logged, msg) end,
os_gettime_ns = function() return fake_now end,
obs_data_get_string = function(d, k) return d.strings[k] or "" end,
obs_data_get_bool = function(d, k) return d.bools[k] or false end,
obs_data_set_string = function(d, k, v) d.strings[k] = v end,
obs_data_set_default_string = function(d, k, v)
if d.strings[k] == nil then d.strings[k] = v end
end,
obs_data_set_default_bool = function(d, k, v)
if d.bools[k] == nil then d.bools[k] = v end
end,
obs_data_get_array = function(d, k) return d.arrays[k] or {} end,
obs_data_set_array = function(d, k, v) d.arrays[k] = v end,
obs_data_array_release = function() end,
obs_data_create = function() return new_data() end,
obs_data_release = function() end,
obs_hotkey_register_frontend = function(name, label, cb)
table.insert(registered_hotkeys, { name = name, label = label, cb = cb })
return #registered_hotkeys
end,
obs_hotkey_load = function() end,
obs_hotkey_save = function() return {} end,
obs_frontend_add_event_callback = function(cb) table.insert(event_cbs, cb) end,
obs_frontend_remove_event_callback = function() event_cbs = {} end,
obs_frontend_get_current_scene = function() return { name = current_scene } end,
obs_source_get_name = function(s) return s.name end,
obs_source_release = function() end,
obs_frontend_get_scenes = function()
local t = {}
for _, n in ipairs(all_scenes) do table.insert(t, { name = n }) end
return t
end,
source_list_release = function() end,
obs_properties_create = function() return { items = {} } end,
obs_properties_add_path = function(p, n) table.insert(p.items, n); return {} end,
obs_properties_add_text = function(p, n) table.insert(p.items, n); return {} end,
obs_properties_add_bool = function(p, n) table.insert(p.items, n); return {} end,
obs_properties_add_button = function(p, n, _, cb)
table.insert(p.items, n); p[n] = cb; return {}
end,
obs_property_set_long_description = function() end,
}
_G.obslua = obs
-- Capture what the script would run instead of actually running it.
local real_execute = os.execute
os.execute = function(cmd) table.insert(executed, cmd); return true end
----------------------------------------------------------------------------
-- Load the script the way OBS does
----------------------------------------------------------------------------
dofile("obs/obs-linkctl.lua")
ok(type(script_description) == "function", "script_description defined")
ok(type(script_properties) == "function", "script_properties defined")
ok(type(script_defaults) == "function", "script_defaults defined")
ok(type(script_update) == "function", "script_update defined")
ok(type(script_load) == "function", "script_load defined")
ok(type(script_save) == "function", "script_save defined")
ok(type(script_unload) == "function", "script_unload defined")
local settings = new_data()
script_defaults(settings)
eq(settings.strings["preset"], "home", "default preset")
eq(settings.strings["startup_action"], "preset load home", "default startup action")
eq(settings.bools["force"], true, "force defaults on")
settings.strings["linkctl"] = "/usr/local/bin/linkctl"
settings.strings["scene_map_text"] =
"Talking Head = tracking on\nOverhead = preset load overhead"
script_update(settings)
script_load(settings)
eq(#registered_hotkeys, 5, "five hotkeys registered")
eq(registered_hotkeys[1].name, "linkctl_tracking_toggle", "first hotkey name")
eq(#event_cbs, 1, "one frontend callback registered")
-- A hotkey press on key-down runs the command; key-up does nothing.
executed = {}
registered_hotkeys[1].cb(false)
eq(#executed, 0, "hotkey ignores release")
registered_hotkeys[1].cb(true)
eq(#executed, 1, "hotkey acts on press")
eq(executed[1],
"'/usr/local/bin/linkctl' --force 'tracking' 'toggle' >/dev/null 2>&1 &",
"hotkey command")
-- The preset hotkey must pick up the preset name live, not at registration.
settings.strings["preset"] = "desk"
script_update(settings)
executed = {}
fake_now = fake_now + 10e9
for _, h in ipairs(registered_hotkeys) do
if h.name == "linkctl_preset_load" then h.cb(true) end
end
eq(executed[1],
"'/usr/local/bin/linkctl' --force 'preset' 'load' 'desk' >/dev/null 2>&1 &",
"preset hotkey uses current setting")
-- Scene automation
local fire = event_cbs[1]
executed = {}
fake_now = fake_now + 10e9
current_scene = "Talking Head"
fire(obs.OBS_FRONTEND_EVENT_SCENE_CHANGED)
eq(executed[1],
"'/usr/local/bin/linkctl' --force 'tracking' 'on' >/dev/null 2>&1 &",
"scene change runs mapped action")
executed = {}
fake_now = fake_now + 10e9
current_scene = "Overhead"
fire(obs.OBS_FRONTEND_EVENT_SCENE_CHANGED)
eq(executed[1],
"'/usr/local/bin/linkctl' --force 'preset' 'load' 'overhead' >/dev/null 2>&1 &",
"scene change runs multi-arg action")
-- An unmapped scene must do nothing at all.
executed = {}
fake_now = fake_now + 10e9
current_scene = "Slides"
fire(obs.OBS_FRONTEND_EVENT_SCENE_CHANGED)
eq(#executed, 0, "unmapped scene does nothing")
-- Debounce: studio mode emits repeated events for the same scene.
executed = {}
fake_now = fake_now + 10e9
current_scene = "Talking Head"
fire(obs.OBS_FRONTEND_EVENT_SCENE_CHANGED)
fire(obs.OBS_FRONTEND_EVENT_SCENE_CHANGED)
fire(obs.OBS_FRONTEND_EVENT_SCENE_CHANGED)
eq(#executed, 1, "identical rapid events collapse to one")
fake_now = fake_now + 10e9 -- past the 250ms window
fire(obs.OBS_FRONTEND_EVENT_SCENE_CHANGED)
eq(#executed, 2, "same action runs again after the window")
-- Scene automation can be switched off.
settings.bools["enable_scenes"] = false
script_update(settings)
executed = {}
fake_now = fake_now + 10e9
current_scene = "Overhead"
fire(obs.OBS_FRONTEND_EVENT_SCENE_CHANGED)
eq(#executed, 0, "disabled scene automation does nothing")
settings.bools["enable_scenes"] = true
script_update(settings)
-- Startup action
executed = {}
fake_now = fake_now + 10e9
fire(obs.OBS_FRONTEND_EVENT_FINISHED_LOADING)
eq(executed[1],
"'/usr/local/bin/linkctl' --force 'preset' 'load' 'home' >/dev/null 2>&1 &",
"startup action runs on finished loading")
settings.strings["startup_action"] = ""
script_update(settings)
executed = {}
fake_now = fake_now + 10e9
fire(obs.OBS_FRONTEND_EVENT_FINISHED_LOADING)
eq(#executed, 0, "blank startup action does nothing")
-- Properties pane builds, and the scene-prefill button rewrites the mapping
-- while preserving actions already assigned.
local props = script_properties()
ok(#props.items > 0, "properties built")
ok(props["fill_scenes"] ~= nil, "prefill button present")
eq(props["fill_scenes"](), true, "prefill requests a UI refresh")
eq(settings.strings["scene_map_text"],
"Talking Head = tracking on\nOverhead = preset load overhead\nSlides = ",
"prefill lists all scenes and keeps existing actions")
-- script_save must round-trip every hotkey without error.
script_save(settings)
local saved = 0
for _ in pairs(settings.arrays) do saved = saved + 1 end
eq(saved, 5, "all five hotkeys saved")
script_unload()
eq(#event_cbs, 0, "unload removes the frontend callback")
os.execute = real_execute
io.write(string.format("\n%d checks, %d failures\n", checks, failures))
os.exit(failures == 0 and 0 or 1)