#!/usr/bin/env bash # The sidebar search indexes the schema, not the page names. # # Before this, the field filtered twelve page labels, so "gaps", "wallpaper", # and "screenshot" all returned nothing on an app that has all three. That is # the single clearest way a settings app feels smaller than it is, and it # regresses invisibly: nothing breaks, results just quietly stop appearing. # # This pins that every schema entry is reachable by its own label, that # shortcuts are searchable by what they do, and that results route to a page # that actually exists. set -euo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" harness="$repo_dir/config/dot/quickshell/settings-search-harness.qml" fail() { printf 'settings search contract: %s\n' "$1" >&2 exit 1 } qs_for_harness() { qs -p "$harness" "$@" } cleanup() { qs_for_harness kill >/dev/null 2>&1 || true } trap cleanup EXIT qs_for_harness --daemonize >/dev/null for _ in $(seq 1 40); do qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-search-test$' && break sleep 0.1 done qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-search-test$' || fail 'test IPC target did not start' find_top() { qs_for_harness ipc call settings-search-test find "$1" } # ── An empty query is navigation, not a search ─────────────────────────────── [[ "$(find_top '' | jq -r .count)" == "0" ]] || fail 'an empty query returned results' # Whitespace is an empty query wearing a hat. Tokenizing it produces no tokens, # which must mean "no search" rather than "every setting matches nothing". [[ "$(find_top ' ' | jq -r .count)" == "0" ]] || fail 'a whitespace query returned results' # ── Real settings are findable by what they are ────────────────────────────── while IFS='|' read -r query expect_label expect_page; do result="$(find_top "$query")" got_label="$(jq -r .top <<<"$result")" got_page="$(jq -r .topPage <<<"$result")" [[ "$got_label" == "$expect_label" ]] \ || fail "searching '$query' put '$got_label' first, expected '$expect_label'" [[ "$got_page" == "$expect_page" ]] \ || fail "searching '$query' routes to '$got_page', expected '$expect_page'" done <<'CASES' wallpaper|Wallpaper|appearance blur|Blur|appearance timezone|Timezone|datetime repeat delay|Repeat delay|shortcuts system health|System Health|services doctor|Copy health report|services lock screen background|Lock screen background|appearance password field|Password field|appearance slideshow|Wallpaper mode|appearance shuffle|Shuffle|appearance per-display wallpaper|Per-display wallpaper|appearance arrange displays|Arrange displays|displays monitor position|Monitor position|displays primary display|Primary display|displays themes|Themes|appearance theme editor|Theme editor|appearance dark mode|Dark mode|appearance catppuccin|Catppuccin|appearance gruvbox|Gruvbox|appearance video wallpaper|Video wallpaper|appearance titlebar|Titlebar on Panama windows|appearance CASES ! rg -Fq 'Startup & Services' "$repo_dir/config/dot/quickshell/services/SettingsSearch.qml" \ || fail 'search index still uses the retired Startup & Services name' # ── Words, not one contiguous substring ────────────────────────────────────── # # The 21 cases above are all single-token or exactly-adjacent, and they were # passing before tokenization -- they are here to prove tokenizing did not move # what was already right. These are the ones that were returning nothing at all: # every word is present in the index, just not adjacent and not in that order. # # "wifi password" is the sharpest: the words live in two different fields of the # same entry, so no substring of any haystack contains the query as typed. while IFS='|' read -r query expect_label expect_page; do result="$(find_top "$query")" got_label="$(jq -r .top <<<"$result")" got_page="$(jq -r .topPage <<<"$result")" [[ "$got_label" == "$expect_label" ]] \ || fail "searching '$query' put '$got_label' first, expected '$expect_label'" [[ "$got_page" == "$expect_page" ]] \ || fail "searching '$query' routes to '$got_page', expected '$expect_page'" done <<'TOKENS' log out|Log out|power metered|Metered connection|connectivity ethernet|Ethernet|connectivity gestures|Gestures|mouse saved networks|Saved networks|connectivity hidden network|Hidden network|connectivity static ip|Static IP address|connectivity color filter|Color filter|accessibility custom shortcut|Custom shortcut|shortcuts app rules|App rules|tiling window rules|Window rules|tiling do not disturb|Do Not Disturb|notifications four-finger swipe|Four-finger swipe|mouse TOKENS # Every word must match, in any order and in any field. Order was the # accidental part of substring matching, and it was doing the most damage. for query in 'wifi password' 'password wifi'; do [[ "$(find_top "$query" | jq -r .count)" != "0" ]] \ || fail "searching '$query' found nothing, on a page that shows the Wi-Fi password" [[ "$(find_top "$query" | jq -r .topPage)" == "connectivity" ]] \ || fail "searching '$query' did not lead with a network result" done # AND, not OR. A query holding a word the index does not have anywhere must # return nothing, or tokenizing has only made the field louder. [[ "$(find_top 'wallpaper zzzznotathing' | jq -r .count)" == "0" ]] \ || fail 'a query containing an unmatchable word still returned results' # ── A result may name the tab it lives on ──────────────────────────────────── # # Landing on Appearance's Themes tab after searching "theme editor" is the # search half-working: the page is right and the thing searched for is behind # another click. Results that name no section route exactly as before, which is # every result the schema produces. [[ "$(find_top 'theme editor' | jq -r .topSection)" == "editor" ]] \ || fail 'the theme editor result does not name the tab it lives on' [[ "$(find_top 'video wallpaper' | jq -r .topSection)" == "background" ]] \ || fail 'the video wallpaper result does not name the tab it lives on' [[ "$(find_top 'timezone' | jq -r .topSection)" == "" ]] \ || fail 'a result on a page with no tabs still names a section' # And the sidebar has to consume it, or the field is decorative. sidebar="$repo_dir/config/dot/quickshell/modules/settings/SettingsSidebar.qml" rg -Fq 'ShellState.openSettingsSection' "$sidebar" \ || fail 'the sidebar never opens a result at its section' rg -Fq 'root.pageRequested' "$sidebar" \ || fail 'the sidebar lost the plain page route, which most results still use' # ── Shortcuts are searchable by what they do ───────────────────────────────── [[ "$(find_top screenshot | jq -r .topPage)" == "shortcuts" ]] \ || fail 'searching a shortcut description did not route to the shortcuts page' # ── Every non-internal schema entry is reachable by its own label ──────────── # A setting that cannot be found by typing its name is a setting the user # cannot discover, which is the failure this whole page structure exists to fix. schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml" missing=0 while IFS= read -r label; do [[ -n "$label" ]] || continue count="$(find_top "$label" | jq -r .count)" if [[ "$count" == "0" ]]; then printf 'settings search contract: no result for schema label "%s"\n' "$label" >&2 missing=$((missing + 1)) fi done < <(python3 - "$schema" <<'EXTRACT' import re, sys # Entries marked `internal` are shell state the user never edits, so they are # deliberately excluded from search. Extract only the user-facing labels. text = open(sys.argv[1]).read() for block in re.findall(r"\{\s*\n\s+key:.*?\n\s{8}\}", text, re.S): if "internal: true" in block: continue match = re.search(r'label: "([^"]+)"', block) if match: print(match.group(1)) EXTRACT ) [[ "$missing" -eq 0 ]] || fail "$missing schema label(s) are not findable by search" # ── Results only ever route to leaves the shell can open ───────────────────── # A search result carries a leaf id, never a category: it has to land on the # exact tab holding the setting, not on whichever tab that category opens # first. SettingsRoutes is where the leaves are declared -- a tabless category # is one, and so is every tab. routes="$repo_dir/config/dot/quickshell/services/SettingsRoutes.qml" leaves="$( { grep -oE '\{ page: "[a-z-]+", label: "[^"]*", icon: "[^"]*", tabs: \[\] \}' "$routes" grep -oE '\{ page: "[a-z-]+", label: "[^"]*" \}' "$routes" } | sed -E 's/\{ page: "([a-z-]+)".*/\1/' | sort -u)" [[ -n "$leaves" ]] || fail 'no leaf pages could be read from SettingsRoutes, so this proves nothing' for query in wallpaper blur timezone screenshot pointer lock volume gaps dictation; do page="$(find_top "$query" | jq -r .topPage)" grep -qx "$page" <<<"$leaves" || fail "search routed '$query' to '$page', which is not a page anyone can land on" done trap - EXIT cleanup printf 'settings search contract: PASS\n'