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