feat(blankpad): desk.sh profile switcher, hotplug autoswitch daemon, keybinds doc generator + bats suite

- desk.sh: list/current/switch/cycle/match/capture over cfg/desk-*.kdl
  profiles; apply = relink display.kdl, niri validate (rollback on fail),
  load-config-file, noctalia notification; match scores // requires: lines
  (EDID long names) against live niri msg outputs
- desk-autoswitch.sh: watch niri msg --json event-stream for
  OutputsChanged, debounced desk.sh match --apply, reconnect loop
- blankpad-desk-autoswitch.service: ConditionHost=blankpad user unit
- gen-keybinds.sh: keybinds.kdl -> markdown cheat sheet (sections from
  // --- headers, hotkey-overlay-title preferred over action)
- tests/: 27 bats cases, all green (desk 16, gen-keybinds 6,
  niri-config 5 incl. real niri validate on the assembled tree with
  each desk profile active)
This commit is contained in:
Frank
2026-10-09 14:40:26 -07:00
parent 4ec47a5b7e
commit f63d11de85
10 changed files with 703 additions and 0 deletions
+223
View File
@@ -0,0 +1,223 @@
#!/usr/bin/env bats
# tests/desk.bats — black-box tests for scripts/desk.sh
#
# desk.sh contract (see script header): subcommands list/current/switch/
# cycle/match/capture driven by env overrides DESK_CFG_DIR, DESK_NIRI,
# DESK_NOTIFY. Everything observable is via stdout/stderr/exit status,
# the display.kdl symlink, and the stub invocation log.
setup() {
TEST_TMP="$(mktemp -d)"
CFG="$TEST_TMP/cfg"
LOG="$TEST_TMP/stub.log"
mkdir -p "$CFG"
# canned `niri msg outputs` payload (live niri human format)
cat > "$TEST_TMP/outputs.txt" <<'EOF'
Output "Sceptre Tech Inc Sceptre M34 Unknown" (DP-2)
Current mode: 3440x1440 @ 239.999 Hz (current)
Output "Lenovo Group Limited 0x40A9 Unknown" (eDP-1)
Current mode: 1920x1080 @ 60.001 Hz (current, preferred)
EOF
# stub niri: logs every invocation, serves canned outputs,
# validate exit code controlled by $STUB_VALIDATE (default 0)
cat > "$TEST_TMP/niri" <<'EOF'
#!/usr/bin/env bash
echo "niri $*" >> "$STUB_LOG"
case "$*" in
"msg outputs") cat "$STUB_OUTPUTS" ;;
"validate") exit "${STUB_VALIDATE:-0}" ;;
"msg action load-config-file") exit 0 ;;
*) exit 0 ;;
esac
EOF
chmod +x "$TEST_TMP/niri"
# stub notifier: records what it was shown
cat > "$TEST_TMP/notify" <<'EOF'
#!/usr/bin/env bash
echo "notify $*" >> "$STUB_LOG"
EOF
chmod +x "$TEST_TMP/notify"
export STUB_LOG="$LOG"
export STUB_OUTPUTS="$TEST_TMP/outputs.txt"
export DESK_CFG_DIR="$CFG"
export DESK_NIRI="$TEST_TMP/niri"
export DESK_NOTIFY="$TEST_TMP/notify"
DESK="$BATS_TEST_DIRNAME/../scripts/desk.sh"
}
teardown() {
rm -rf "$TEST_TMP"
}
make_profiles() {
cat > "$CFG/desk-1-home.kdl" <<'EOF'
// requires: Sceptre Tech Inc Sceptre M34 Unknown
// requires: Lenovo Group Limited 0x40A9 Unknown
output "Sceptre Tech Inc Sceptre M34 Unknown" { scale 1 }
EOF
cat > "$CFG/desk-2-portable.kdl" <<'EOF'
// requires: Sceptre Tech Inc Sceptre M34 Unknown
// requires: PM1601 Portable Monitor Unknown
output "Sceptre Tech Inc Sceptre M34 Unknown" { scale 1 }
EOF
# non-profile files must be ignored
echo 'include "display.kdl"' > "$CFG/config.kdl"
echo 'output "X" {}' > "$CFG/keybinds.kdl"
}
@test "list: shows desk profiles only, sorted" {
make_profiles
run "$DESK" list
[ "$status" -eq 0 ]
[ "$output" = "$(printf 'desk-1-home\ndesk-2-portable')" ]
}
@test "list: no profiles yields empty output" {
run "$DESK" list
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "current: reports none without symlink, profile with symlink" {
make_profiles
run "$DESK" current
[ "$status" -eq 0 ]
[ "$output" = "none" ]
ln -sfn desk-1-home.kdl "$CFG/display.kdl"
run "$DESK" current
[ "$output" = "desk-1-home" ]
}
@test "switch: relinks, validates, reloads, notifies" {
make_profiles
run "$DESK" switch desk-1-home
[[ "$output" == *"switched to desk-1-home"* ]]
[ "$(basename "$(readlink "$CFG/display.kdl")")" = "desk-1-home.kdl" ]
run cat "$LOG"
[[ "$output" == *"niri validate"* ]]
[[ "$output" == *"niri msg action load-config-file"* ]]
[[ "$output" == *"notify Desk switched to desk-1-home"* ]]
}
@test "switch: accepts .kdl suffix" {
make_profiles
run "$DESK" switch desk-2-portable.kdl
[ "$status" -eq 0 ]
[ "$(basename "$(readlink "$CFG/display.kdl")")" = "desk-2-portable.kdl" ]
}
@test "switch: unknown profile fails without touching the link" {
make_profiles
ln -sfn desk-1-home.kdl "$CFG/display.kdl"
run "$DESK" switch nonexistent
[ "$status" -ne 0 ]
[[ "$output" == *"no such profile"* ]]
[ "$(basename "$(readlink "$CFG/display.kdl")")" = "desk-1-home.kdl" ]
# validate must never have run for a missing profile
run cat "$LOG"
[[ "$output" != *"niri validate"* ]]
}
@test "switch: failed validate rolls the link back to the previous profile" {
make_profiles
ln -sfn desk-1-home.kdl "$CFG/display.kdl"
export STUB_VALIDATE=1
run "$DESK" switch desk-2-portable
[ "$status" -ne 0 ]
[[ "$output" == *"validate failed"* ]]
[ "$(basename "$(readlink "$CFG/display.kdl")")" = "desk-1-home.kdl" ]
# no reload, no notification after a failed validate
[[ "$output" != *"load-config-file"* ]]
run cat "$LOG"
[[ "$output" != *"notify"* ]]
}
@test "switch: failed validate with no previous profile removes the link" {
make_profiles
export STUB_VALIDATE=1
run "$DESK" switch desk-1-home
[ "$status" -ne 0 ]
[ ! -L "$CFG/display.kdl" ]
}
@test "match: picks the profile whose requires are all connected" {
make_profiles
run "$DESK" match
[ "$status" -eq 0 ]
[ "$output" = "desk-1-home" ]
# dry run: must not touch the symlink
[ ! -L "$CFG/display.kdl" ]
}
@test "match --apply: applies the winner" {
make_profiles
run "$DESK" match --apply
[ "$status" -eq 0 ]
[[ "$output" == *"switched to desk-1-home"* ]]
[ "$(basename "$(readlink "$CFG/display.kdl")")" = "desk-1-home.kdl" ]
}
@test "match: no matching profile exits nonzero and keeps the link" {
make_profiles
ln -sfn desk-2-portable.kdl "$CFG/display.kdl"
cat > "$TEST_TMP/outputs.txt" <<'EOF'
Output "Some Other Monitor 0x1234 Unknown" (DP-9)
EOF
run "$DESK" match
[ "$status" -ne 0 ]
[[ "$output" == *"no desk profile matches"* ]]
[ "$(basename "$(readlink "$CFG/display.kdl")")" = "desk-2-portable.kdl" ]
}
@test "match: prefers the profile with more matched requires" {
# both profiles match the connected set; desk-1 requires 2, desk-2 requires 1
cat > "$CFG/desk-1-home.kdl" <<'EOF'
// requires: Sceptre Tech Inc Sceptre M34 Unknown
// requires: Lenovo Group Limited 0x40A9 Unknown
EOF
cat > "$CFG/desk-9-guest.kdl" <<'EOF'
// requires: Sceptre Tech Inc Sceptre M34 Unknown
EOF
run "$DESK" match
[ "$status" -eq 0 ]
[ "$output" = "desk-1-home" ]
}
@test "cycle: advances to the next profile and wraps around" {
make_profiles
ln -sfn desk-1-home.kdl "$CFG/display.kdl"
run "$DESK" cycle
[ "$status" -eq 0 ]
[[ "$output" == *"switched to desk-2-portable"* ]]
run "$DESK" cycle
[ "$status" -eq 0 ]
[[ "$output" == *"switched to desk-1-home"* ]]
}
@test "cycle: with no active profile applies the first" {
make_profiles
run "$DESK" cycle
[ "$status" -eq 0 ]
[[ "$output" == *"switched to desk-1-home"* ]]
}
@test "cycle: no profiles at all fails cleanly" {
run "$DESK" cycle
[ "$status" -ne 0 ]
[[ "$output" == *"no desk profiles"* ]]
}
@test "capture: prints requires lines for connected outputs" {
run "$DESK" capture
[ "$status" -eq 0 ]
[[ "$output" == *"// requires: Sceptre Tech Inc Sceptre M34 Unknown"* ]]
[[ "$output" == *"// requires: Lenovo Group Limited 0x40A9 Unknown"* ]]
}
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bats
# tests/gen-keybinds.bats — black-box tests for scripts/gen-keybinds.sh
#
# Contract: parse a keybinds.kdl into a markdown cheat sheet on stdout,
# or into a file with --write OUT. Observable surface: markdown rows,
# section headers, exit status, written file.
setup() {
GEN="$BATS_TEST_DIRNAME/../scripts/gen-keybinds.sh"
TMP="$(mktemp -d)"
cat > "$TMP/keybinds.kdl" <<'EOF'
binds {
// ─── Applications ───
Mod+Return hotkey-overlay-title="Open Terminal: Ghostty" { spawn "ghostty"; }
Mod+D hotkey-overlay-title="Open App Launcher: noctalia launcher" { spawn-sh "noctalia msg panel-toggle launcher"; }
Mod+E { spawn "nautilus"; }
// ─── Screenshots ───
Print hotkey-overlay-title="Fullscreen screenshot" { spawn-sh "noctalia msg screenshot-fullscreen"; }
// a plain comment, not a section
Mod+Shift+ESCAPE { show-hotkey-overlay; }
}
EOF
}
teardown() {
rm -rf "$TMP"
}
@test "renders a row per bind with title preferred over action" {
run "$GEN" "$TMP/keybinds.kdl"
[ "$status" -eq 0 ]
[[ "$output" == *'| `Mod+D` | Open App Launcher: noctalia launcher |'* ]]
[[ "$output" == *'| `Mod+Return` | Open Terminal: Ghostty |'* ]]
}
@test "binds without a title render their action" {
run "$GEN" "$TMP/keybinds.kdl"
[[ "$output" == *'| `Mod+E` | spawn "nautilus" |'* ]]
[[ "$output" == *'| `Mod+Shift+ESCAPE` | show-hotkey-overlay |'* ]]
}
@test "section comments become headers; plain comments are ignored" {
run "$GEN" "$TMP/keybinds.kdl"
[[ "$output" == *"## Applications"* ]]
[[ "$output" == *"## Screenshots"* ]]
[[ "$output" != *"a plain comment"* ]]
}
@test "row count matches bind count" {
run "$GEN" "$TMP/keybinds.kdl"
local rows
rows="$(grep -c '^| `' <<<"$output")"
[ "$rows" -eq 5 ]
}
@test "--write writes the markdown to the given path" {
run "$GEN" "$TMP/keybinds.kdl" --write "$TMP/out/keybindings.md"
[ "$status" -eq 0 ]
[ -f "$TMP/out/keybindings.md" ]
grep -q '| `Mod+D` | Open App Launcher: noctalia launcher |' "$TMP/out/keybindings.md"
grep -q 'GENERATED' "$TMP/out/keybindings.md"
}
@test "missing input file fails loudly" {
run "$GEN" "$TMP/nope.kdl"
[ "$status" -ne 0 ]
[[ "$output" == *"no such file"* ]]
}
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bats
# tests/niri-config.bats — the assembled niri config must pass `niri validate`
#
# Assembly mirrors deploy: base dotfiles/niri config + cfg files, overlaid
# by the per-host dotfiles/zz-blankpad overrides, display.kdl symlinked to a
# desk profile, live noctalia.kdl copied in (the repo include is not optional).
setup() {
REPO="$BATS_TEST_DIRNAME/.."
NIRI_BIN="$(command -v niri || true)"
TMP="$(mktemp -d)"
}
teardown() {
rm -rf "$TMP"
}
# Assemble a config tree with the given desk profile active.
assemble() {
local profile="${1:-desk-1-home}"
mkdir -p "$TMP/niri"
cp -r "$REPO/dotfiles/niri/.config/niri/." "$TMP/niri/"
cp -f "$REPO/dotfiles/zz-blankpad/niri/.config/niri/cfg/"*.kdl "$TMP/niri/cfg/"
ln -sfn "$profile.kdl" "$TMP/niri/cfg/display.kdl"
[ -f "$HOME/.config/niri/noctalia.kdl" ] &&
cp "$HOME/.config/niri/noctalia.kdl" "$TMP/niri/noctalia.kdl"
}
require_niri() {
[ -n "$NIRI_BIN" ] || skip "niri binary not found"
}
@test "assembly: host overrides win over base files" {
assemble
# the cycle-desk bind only exists in the zz-blankpad keybinds
grep -q 'desk.sh cycle' "$TMP/niri/cfg/keybinds.kdl"
! grep -q 'desk.sh cycle' "$REPO/dotfiles/niri/.config/niri/cfg/keybinds.kdl"
}
@test "validate: assembled config with desk-1-home active" {
require_niri
assemble desk-1-home
run "$NIRI_BIN" validate -c "$TMP/niri/config.kdl"
[ "$status" -eq 0 ]
}
@test "validate: assembled config with desk-2-portable active" {
require_niri
assemble desk-2-portable
run "$NIRI_BIN" validate -c "$TMP/niri/config.kdl"
[ "$status" -eq 0 ]
}
@test "validate: config is valid with the optional includes absent" {
require_niri
assemble
rm -f "$TMP/niri/cfg/switch-events.kdl" "$TMP/niri/cfg/display.kdl"
run "$NIRI_BIN" validate -c "$TMP/niri/config.kdl"
[ "$status" -eq 0 ]
}
@test "desk profiles: every profile declares at least one requires line" {
for f in "$REPO"/dotfiles/zz-blankpad/niri/.config/niri/cfg/desk-*.kdl; do
grep -q '^// requires:' "$f"
done
}