diff --git a/dotfiles/zz-blankpad/.config/systemd/user/blankpad-desk-autoswitch.service b/dotfiles/zz-blankpad/.config/systemd/user/blankpad-desk-autoswitch.service new file mode 100644 index 0000000..5d68c7a --- /dev/null +++ b/dotfiles/zz-blankpad/.config/systemd/user/blankpad-desk-autoswitch.service @@ -0,0 +1,19 @@ +[Unit] +Description=Desk Profile Auto-Switch for Blankpad +After=graphical-session.target +ConditionHost=blankpad + +[Service] +Type=simple +User=%u +Group=%u +ExecStart=/usr/bin/env bash %h/.omp/scripts/desk-autoswitch.sh watch +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal +Environment=DESK_NIRI=/usr/bin/niri +Environment=DESK_NOTIFY=noctalia msg notification-show + +[Install] +WantedBy=default.target diff --git a/dotfiles/zz-blankpad/.local/bin/desk.sh b/dotfiles/zz-blankpad/.local/bin/desk.sh new file mode 120000 index 0000000..a7a55e7 --- /dev/null +++ b/dotfiles/zz-blankpad/.local/bin/desk.sh @@ -0,0 +1 @@ +../../../../scripts/desk.sh \ No newline at end of file diff --git a/dotfiles/zz-blankpad/.local/share/com.pais.handy/README.md b/dotfiles/zz-blankpad/.local/share/com.pais.handy/README.md new file mode 100644 index 0000000..724f062 --- /dev/null +++ b/dotfiles/zz-blankpad/.local/share/com.pais.handy/README.md @@ -0,0 +1,5 @@ +# Handy Settings Template + +This directory contains a template for Handy's settings store. The app rewrites the live store at `~/.local/share/com.pais.handy/settings_store.json`. + +Keys that might contain sensitive information (e.g., API tokens, base URLs) should stay out of the repo. diff --git a/dotfiles/zz-blankpad/.local/share/com.pais.handy/settings_store.template.json b/dotfiles/zz-blankpad/.local/share/com.pais.handy/settings_store.template.json new file mode 100644 index 0000000..1c978a2 --- /dev/null +++ b/dotfiles/zz-blankpad/.local/share/com.pais.handy/settings_store.template.json @@ -0,0 +1,22 @@ +{ + "// template: Handy settings store, rewritten live by the app": true, + "// app rewrites live store: ~/.local/share/com.pais.handy/settings_store.json": true, + "// keys stay out of repo": true, + "autostart_enabled": false, + "post_process_enabled": true, + "provider": "custom", + "base_url": "http://192.168.0.143:7771/v1", + "model": "frank-secondary", + "prompt": "default_improve_transcriptions", + "push_to_talk": true, + "bindings": { + "transcribe": "ctrl+space", + "transcribe_with_post_process": "ctrl+shift+space", + "cancel": "escape" + }, + "keyboard_implementation": "handy_keys", + "overlay_position": "bottom", + "overlay_style": "live", + "paste_method": "none", + "clipboard_handling": "copy_to_clipboard" +} diff --git a/scripts/desk-autoswitch.sh b/scripts/desk-autoswitch.sh new file mode 100755 index 0000000..5efc57f --- /dev/null +++ b/scripts/desk-autoswitch.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# scripts/desk-autoswitch.sh — hotplug daemon: apply the matching desk profile +# whenever niri reports an OutputsChanged event. +# +# Usage: desk-autoswitch.sh watch +# +# Environment overrides: +# DESK_AUTO_HOSTNAME only run on this hostname (default: blankpad) +# DESK_SCRIPT path to desk.sh (default ~/.local/bin/desk.sh) +# DESK_NIRI niri binary (default niri) +# DESK_DEBOUNCE_S debounce window in seconds (default 1.5) + +set -euo pipefail + +HOST="${DESK_AUTO_HOSTNAME:-blankpad}" +DESK="${DESK_SCRIPT:-$HOME/.local/bin/desk.sh}" +NIRI="${DESK_NIRI:-niri}" +DEBOUNCE="${DESK_DEBOUNCE_S:-1.5}" + +usage() { + sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//' +} + +watch_mode() { + [ "$(hostname)" = "$HOST" ] || { echo "desk-autoswitch: not on $HOST, exiting"; exit 0; } + echo "desk-autoswitch: initial match" + "$DESK" match --apply || true + + local last=0 now + # One JSON event per line; reconnect forever (niri restart drops the stream). + while "$NIRI" msg --json event-stream 2>/dev/null | while IFS= read -r line; do + case "$line" in + *OutputsChanged*) + now="$(date +%s.%N)" + # swallow bursts inside the debounce window + if awk -v l="$last" -v n="$now" -v d="$DEBOUNCE" 'BEGIN { exit !(n - l >= d) }'; then + last="$now" + echo "desk-autoswitch: OutputsChanged" + "$DESK" match --apply || true + fi + ;; + esac + done; do :; done +} + +case "${1:-}" in + watch) watch_mode ;; + -h|--help) usage ;; + *) usage >&2; exit 1 ;; +esac diff --git a/scripts/desk.sh b/scripts/desk.sh new file mode 100755 index 0000000..c0b8ed1 --- /dev/null +++ b/scripts/desk.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +# scripts/desk.sh — Desk profile management for niri (blankpad) +# +# Desk profiles are cfg/desk-*.kdl files. The active profile is whatever +# file cfg/display.kdl symlinks to. Profiles declare the monitors they +# need via comment lines: +# +# // requires: +# // optional: +# +# Subcommands: +# list list available desk profiles +# current show currently active profile (basename of display.kdl) +# switch apply a specific profile (name or *.kdl) +# cycle apply the next profile (wraps) +# match [--apply] pick the profile matching connected outputs +# capture print `// requires:` lines for current outputs +# +# Environment overrides (for tests and deploys): +# DESK_CFG_DIR config dir (default ~/.config/niri/cfg) +# DESK_NIRI niri binary (default niri) +# DESK_NOTIFY notification command prefix (default "noctalia msg notification-show") + +set -euo pipefail + +CFG_DIR="${DESK_CFG_DIR:-$HOME/.config/niri/cfg}" +NIRI="${DESK_NIRI:-niri}" +NOTIFY="${DESK_NOTIFY:-noctalia msg notification-show}" + +usage() { + sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//' +} + +# Profile names (basenames without .kdl), sorted. +profiles() { + local f + for f in "$CFG_DIR"/desk-*.kdl; do + [ -e "$f" ] || continue + basename "$f" .kdl + done | sort +} + +# Active profile name, or "none". +current() { + if [ -L "$CFG_DIR/display.kdl" ]; then + basename "$(readlink -f "$CFG_DIR/display.kdl")" .kdl + else + echo none + fi +} + +# Connected output long names, one per line. +outputs() { + "$NIRI" msg outputs 2>/dev/null | grep -oP 'Output "\K[^"]+' || true +} + +# requires lines of a profile, one per line. +requires() { + grep -oP '^//\s*requires:\s*\K.+' "$CFG_DIR/$1.kdl" 2>/dev/null || true +} + +notify() { + # shellcheck disable=SC2086 # word splitting of the prefix is intended + $NOTIFY "Desk" "$1" 2>/dev/null || true +} + +# Apply a profile: relink, validate live config, roll back on failure, +# then reload + notify. +apply_profile() { + local name="$1" + local file="$CFG_DIR/$name.kdl" + local link="$CFG_DIR/display.kdl" + + [ -f "$file" ] || { echo "error: no such profile: $name" >&2; return 1; } + + local old="" + [ -L "$link" ] && old="$(readlink "$link")" + + ln -sfn "$name.kdl" "$link" + + if ! "$NIRI" validate 2>/dev/null; then + if [ -n "$old" ]; then ln -sfn "$old" "$link"; else rm -f "$link"; fi + echo "error: niri validate failed; kept $( [ -n "$old" ] && basename "$old" .kdl || echo 'no' ) profile" >&2 + return 1 + fi + + "$NIRI" msg action load-config-file 2>/dev/null || true + notify "switched to $name" + echo "switched to $name" +} + +cmd_switch() { + local target="${1:-}" + [ -n "$target" ] || { echo "usage: desk.sh switch " >&2; return 1; } + target="${target%.kdl}" + apply_profile "$target" +} + +cmd_match() { + local apply="${1:-}" + local connected best="" best_n=0 + connected="$(outputs)" + + local name req count + while IFS= read -r name; do + [ -n "$name" ] || continue + count=0 + local ok=1 + while IFS= read -r req; do + [ -n "$req" ] || continue + if grep -qxF "$req" <<<"$connected"; then + count=$((count + 1)) + else + ok=0 + break + fi + done < <(requires "$name") + # requires=0 profiles never match; ties go to more requires, then alphabetical + [ "$ok" -eq 1 ] && [ "$count" -gt 0 ] && [ "$count" -gt "$best_n" ] && { best="$name"; best_n=$count; } + done < <(profiles) + + if [ -z "$best" ]; then + echo "no desk profile matches connected outputs" >&2 + return 2 + fi + if [ "$apply" = "--apply" ]; then + apply_profile "$best" + else + echo "$best" + fi +} + +cmd_cycle() { + local list cur next + list="$(profiles)" + [ -n "$list" ] || { echo "no desk profiles found in $CFG_DIR" >&2; return 1; } + cur="$(current)" + next="$(awk -v cur="$cur" ' + { a[NR]=$0 } + END { for (i=1;i<=NR;i++) if (a[i]==cur) { print a[(i%NR)+1]; exit } + print a[1] }' <<<"$list")" + apply_profile "$next" +} + +cmd_capture() { + local n + n="$(outputs | wc -l)" + [ "$n" -gt 0 ] || { echo "error: no connected outputs (is niri running?)" >&2; return 1; } + echo "// paste into the profile:" + outputs | sed 's/^/\/\/ requires: /' +} + +main() { + case "${1:-}" in + list) profiles ;; + current) current ;; + switch) shift; cmd_switch "$@" ;; + cycle) cmd_cycle ;; + match) shift; cmd_match "${1:-}" ;; + capture) cmd_capture ;; + -h|--help) usage ;; + *) usage >&2; exit 1 ;; + esac +} + +main "$@" diff --git a/scripts/gen-keybinds.sh b/scripts/gen-keybinds.sh new file mode 100755 index 0000000..45c948d --- /dev/null +++ b/scripts/gen-keybinds.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# scripts/gen-keybinds.sh — generate the niri keybinding cheat sheet (markdown) +# from a keybinds.kdl file. +# +# Usage: +# gen-keybinds.sh [keybinds.kdl] # markdown to stdout +# gen-keybinds.sh [keybinds.kdl] --write OUT # markdown to OUT +# +# Default input: ~/.config/niri/cfg/keybinds.kdl +# Recognized: section comments `// ─── Name ───`, bind lines with optional +# hotkey-overlay-title="..."; the action inside { } is used when there is no title. + +set -euo pipefail + +usage() { sed -n '2,11p' "$0" | sed 's/^# \{0,1\}//'; } + +KBD="" +OUT="" +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) usage; exit 0 ;; + --write) OUT="${2:?--write needs an output path}"; shift 2 ;; + *) KBD="$1"; shift ;; + esac +done +KBD="${KBD:-$HOME/.config/niri/cfg/keybinds.kdl}" +[ -f "$KBD" ] || { echo "error: no such file: $KBD" >&2; exit 1; } + +gen() { + echo "# Niri Keybindings" + echo + echo "> GENERATED by \`scripts/gen-keybinds.sh\` from \`$(basename "$KBD")\` — do not edit by hand." + echo + awk ' + # section header: // ─── Name ─── + /^[[:space:]]*\/\/[[:space:]]*─+ (.+) ─+[[:space:]]*$/ { + s = $0 + sub(/^[[:space:]]*\/\/[[:space:]]*─+ /, "", s) + sub(/ ─+[[:space:]]*$/, "", s) + if (in_table) print "" + print "## " s + print "" + print "| Key | Action |" + print "|-----|--------|" + in_table = 1 + next + } + # bind line: KEY [flags] [hotkey-overlay-title="..."] { action; } + /^[[:space:]]*[A-Z][A-Za-z0-9+]*( |$)/ && /\{/ { + key = $1 + title = "" + if (match($0, /hotkey-overlay-title="[^"]*"/)) { + title = substr($0, RSTART + 22, RLENGTH - 23) + } + act = "" + if (match($0, /\{[^}]*\}/)) { + act = substr($0, RSTART + 1, RLENGTH - 2) + sub(/;[[:space:]]*$/, "", act) + gsub(/[[:space:]]+/, " ", act) + sub(/^ /, "", act); sub(/ $/, "", act) + } + out = (title != "") ? title : act + gsub(/\|/, "\\|", out) + print "| `" key "` | " out " |" + n++ + next + } + END { + if (in_table) print "" + printf("\n", n) > "/dev/stderr" + } + ' "$KBD" +} + +if [ -n "$OUT" ]; then + mkdir -p "$(dirname "$OUT")" + gen > "$OUT" + echo "wrote $OUT" >&2 +else + gen +fi diff --git a/tests/desk.bats b/tests/desk.bats new file mode 100755 index 0000000..0fec5ca --- /dev/null +++ b/tests/desk.bats @@ -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"* ]] +} diff --git a/tests/gen-keybinds.bats b/tests/gen-keybinds.bats new file mode 100755 index 0000000..2c3831b --- /dev/null +++ b/tests/gen-keybinds.bats @@ -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"* ]] +} diff --git a/tests/niri-config.bats b/tests/niri-config.bats new file mode 100755 index 0000000..6825035 --- /dev/null +++ b/tests/niri-config.bats @@ -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 +}