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.
237 lines
8.7 KiB
Lua
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)
|