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