Files
insta360-link-tools/tests/test_obs_lifecycle.lua
brad cca3c1f2b4 Test the OBS script against a mock OBS API
The OBS script was the largest piece with no coverage, and its failure mode
inside OBS is silence rather than an error, so wiring mistakes would have
surfaced only as "the hotkey does nothing".

Mocks obslua and drives the script through the sequence OBS uses: defaults,
update, load, hotkey callbacks, frontend events, properties, save, unload.
Asserts the behaviour that is easy to get wrong: key-down acts while key-up
does not, the preset hotkey reads the preset name when pressed rather than
when registered, an unmapped scene does nothing, repeated studio-mode events
collapse, and the scene-prefill button preserves actions already assigned.

Confirmed the suite has teeth by mutation: shrinking the debounce window,
dropping --force, and capturing the preset name at registration time are each
caught.
2026-10-07 09:47:57 -07:00

234 lines
8.7 KiB
Lua

-- 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)