Files
Panama/tests/hypr/keybind-categories-contract
T

163 lines
7.5 KiB
Bash
Executable File

#!/usr/bin/env bash
# What each keybind is FOR.
#
# The cheatsheet and the Shortcuts settings page both group by this, and until
# now the grouping was guessed from the description by substring matching --
# which put "Close window" and "Close the notification list" in the same group
# and left anything phrased unusually in whichever bucket matched first.
#
# hypr/keybinds.lua now says it outright. The sections of that file already ARE
# the categories, so a section sets one and every bind below it inherits it,
# which keeps the annotation to one line per section rather than one per bind.
# That is cheap, and it has exactly one failure mode worth testing: a section
# that forgets to set its category silently inherits the previous section's,
# and the binds land somewhere plausible-looking but wrong. That is what
# happened to the media keys the first time this was written -- the dictation
# section sits in the middle of them and its category leaked onto everything
# below it.
#
# So this checks the manifest the compositor actually produced, not the source.
# Generating it needs a running Hyprland; without one, the structural checks
# still run.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
keybinds="$repo_dir/config/dot/hypr/keybinds.lua"
service="$repo_dir/config/dot/quickshell/services/Keybinds.qml"
manifest="${XDG_STATE_HOME:-$HOME/.local/state}/panama/keybind-categories.json"
findings=()
note() { findings+=("$1"); }
# ── The source says what it is doing ─────────────────────────────────────────
grep -q 'local function category' "$keybinds" \
|| note 'keybinds.lua has no category() marker, so nothing records what a bind is for'
grep -q 'categories\[chord\]' "$keybinds" \
|| note 'the bind wrapper does not record a category'
grep -q 'write_categories()' "$keybinds" \
|| note 'the manifest is never written'
# Writing must never cost the keymap. A read-only state directory is a bad day,
# not a machine without shortcuts.
grep -q 'if file == nil then' "$keybinds" \
|| note 'the manifest writer does not tolerate being unable to open the file'
# The user's own shortcuts are a category too, and they are the one group whose
# membership is not written in this file. They are emitted last and outside the
# `bind` wrapper -- deliberately, so keybindOverrides (which is keyed by a
# SHIPPED chord) can never reach one -- which means they would be invisible to
# the manifest unless the loop records the category itself.
grep -q 'category("Custom")' "$keybinds" \
|| note 'custom shortcuts are emitted without a category, so they land in Other'
grep -q 'customBinds' "$keybinds" \
|| note 'keybinds.lua never reads customBinds, so shortcuts the user invents are not bound'
# The shell prefers the authored category and still works without one.
grep -q 'categoryManifest' "$service" \
|| note 'the shell never reads the category manifest'
grep -q 'onLoadFailed: root.categoryManifest' "$service" \
|| note 'a missing manifest is not handled, so a machine that has not reloaded its compositor would break'
# ── Every section sets a category ────────────────────────────────────────────
#
# Walk the file: each `-- ── Name ──` header should be followed by a category()
# call before the next bind(). A header with binds under it and no category
# between is a section inheriting the previous one's, which is the mistake.
python3 - "$keybinds" <<'PY' || note 'a section of keybinds.lua has binds but never sets a category, so they inherit the section above'
import re, sys
lines = open(sys.argv[1], encoding="utf-8").read().splitlines()
pending_header = None
seen_category = True
problems = []
for number, line in enumerate(lines, 1):
if re.match(r"^-- ── ", line):
pending_header = (number, line)
seen_category = False
continue
if re.match(r"^category\(", line):
seen_category = True
continue
if re.match(r"^bind\(", line) and not seen_category and pending_header:
problems.append(f"line {pending_header[0]}: {pending_header[1][:60]}")
seen_category = True
if problems:
print("\n".join(problems), file=sys.stderr)
raise SystemExit(1)
PY
# ── The manifest the compositor produced ─────────────────────────────────────
if ! command -v hyprctl >/dev/null 2>&1 || ! hyprctl version >/dev/null 2>&1; then
printf 'keybind categories contract: PASS (structure only; no running compositor)\n'
exit 0
fi
[[ -r "$manifest" ]] || {
# A reload regenerates it. Ask for one rather than failing on a machine
# that simply has not reloaded since this landed.
hyprctl reload >/dev/null 2>&1
sleep 2
}
[[ -r "$manifest" ]] || { note 'the compositor produced no category manifest'; }
if [[ -r "$manifest" ]]; then
total="$(jq 'length' "$manifest" 2>/dev/null || echo 0)"
(( total > 100 )) || note "the manifest holds $total categories; the keymap has well over a hundred binds"
# Every category is one the shell knows how to order. A typo produces a
# group that sorts last and looks like a bug in the cheatsheet.
#
# Custom is the one group the user fills: shortcuts they invented, read
# from customBinds at the end of keybinds.lua. It is legitimately empty on
# a machine nobody has customized and legitimately full on one somebody
# has, so unlike Other it carries no count expectation -- only the
# requirement that it be a name the shell orders (Keybinds.qml's groupOrder
# puts it first) rather than an unknown that sorts last.
known='Windows Workspaces Applications Shell Session Media & hardware Custom Other'
while read -r value; do
[[ -n "$value" ]] || continue
grep -qF "$value" <<<"$known" \
|| note "the manifest contains an unknown category: $value"
done < <(jq -r '[.[]] | unique | .[]' "$manifest" 2>/dev/null)
# Nothing should land in Other: it is the default for a bind written above
# the first category() call, which means somebody added a section without
# one.
others="$(jq -r '[to_entries[] | select(.value == "Other") | .key] | join(", ")' "$manifest" 2>/dev/null)"
[[ -n "$others" && "$others" != "" ]] \
&& note "these binds have no category and fell back to Other: $others"
# Spot checks. Chosen because each one sits at a boundary where the
# category is inherited rather than obvious, which is where this breaks.
check() {
local chord="$1" want="$2"
local got
got="$(jq -r --arg c "$chord" '.[$c] // ""' "$manifest" 2>/dev/null)"
[[ "$got" == "$want" ]] \
|| note "$chord is categorised as '${got:-nothing}', expected '$want'"
}
check "SUPER + Q" "Windows"
check "ALT + 1" "Workspaces"
check "SUPER + T" "Applications"
check "CTRL + ALT + L" "Session"
check "SUPER + D" "Shell"
# The two that leaked the first time: both sit below the dictation section,
# which is physically in the middle of the media binds.
check "XF86AudioPlay" "Media & hardware"
check "XF86MonBrightnessUp" "Media & hardware"
# And the hardware application keys at the very end of the file.
check "XF86Calculator" "Applications"
fi
if (( ${#findings[@]} > 0 )); then
printf 'keybind categories contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'keybind categories contract: PASS\n'