Files
Panama/tests/quickshell/accent-controls-contract

275 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
# The theme editor's colour controls.
#
# The editor used to be a curated swatch strip (AccentPicker), a profile
# dropdown (ThemeProfilePicker) and six HSV sliders. It is now four colour
# wells -- primary, secondary, background, foreground -- with the HSV rows
# demoted to a fine-tune behind a disclosure. Both deleted components are
# pinned as gone here, because a file left behind and still registered in the
# qmldir is a component the next person wires back up.
#
# What must hold:
#
# 1. Every colour route ends in ONE function. Typed hex, the colour wheel and
# the eyedropper all call ThemeEditorWells.apply(), so validation cannot
# differ between them -- and every write ends in ThemeProfiles.commitActive,
# which recomputes accentName. That is what stops GNOME's accent enum,
# kitty's border and the lock screen going stale after a custom edit.
# 2. Colour is never the only signal. Each well has a hex field, each button
# a spoken name, each HSV row a numeric readout, and all of them take
# keyboard focus. A hue ring alone tells someone with a colour vision
# deficiency nothing.
# 3. Writes are debounced. Committing per slider move meant a single drag
# spent the whole gesture in apply-and-verify round trips with the desktop
# repainting behind the pointer.
# 4. Nothing here animates continuously. These are settings controls on a
# high-refresh display; a shimmer costs real frame time forever.
set -euo pipefail
repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
settings="$repo_dir/config/dot/quickshell/modules/settings"
theme="$repo_dir/config/dot/quickshell/config/Theme.qml"
profiles="$repo_dir/config/dot/quickshell/services/ThemeProfiles.qml"
scheme="$repo_dir/config/dot/quickshell/services/ColorScheme.qml"
search="$repo_dir/config/dot/quickshell/services/SettingsSearch.qml"
fail() {
printf 'accent controls contract: %s\n' "$1" >&2
exit 1
}
# ── The components that exist, and the two that must not ────────────────────
for component in AccentEditor ColorWell ThemeEditorWells ThemeStartChips \
ThemeSaturationRow ThemeSaveRow ThemeCard ThemeGallery ThemeModeHero; do
[[ -f "$settings/$component.qml" ]] || fail "$component is missing"
rg -Fq "$component 1.0 $component.qml" "$settings/qmldir" \
|| fail "$component is not registered in the settings module"
done
for retired in AccentPicker ThemeProfilePicker; do
if [[ -f "$settings/$retired.qml" ]]; then
fail "$retired was deleted in the redesign but the file is back"
fi
if rg -Fq "$retired" "$settings/qmldir"; then
fail "$retired is still registered in the settings qmldir"
fi
done
# ── The four wells are the fast path ────────────────────────────────────────
for role in '"primary", role: "Primary"' '"secondary", role: "Secondary"' \
'"background", role: "Background"' '"foreground", role: "Foreground"'; do
rg -Fq "$role" "$settings/ThemeEditorWells.qml" \
|| fail "the editor is missing the $role well"
done
rg -Fq 'function apply(which: string, hex: string)' "$settings/ThemeEditorWells.qml" \
|| fail 'the wells no longer share one apply path'
rg -Fq 'ThemeProfiles.setGroundColors(' "$settings/ThemeEditorWells.qml" \
|| fail 'background and foreground do not derive the surface and text families'
rg -Fq 'ThemeProfiles.setAccentPair(' "$settings/ThemeEditorWells.qml" \
|| fail 'the accent wells do not write the accent pair'
rg -Fq 'ThemeProfiles.activePalette' "$settings/ThemeEditorWells.qml" \
|| fail 'the ground wells do not show the palette actually in effect'
# ── One eyedropper, one wheel, both aimed by a remembered target ────────────
rg -Fq '["hyprpicker", "--format=hex", "--lowercase-hex", "--quiet", "--no-fancy"]' \
"$settings/ThemeEditorWells.qml" \
|| fail 'screen picking does not use the validated hyprpicker hex invocation'
[[ "$(rg -c 'hyprpicker' "$settings/ThemeEditorWells.qml")" == "1" ]] \
|| fail 'the eyedropper is invoked from more than one place'
rg -Fq 'root.target = which' "$settings/ThemeEditorWells.qml" \
|| fail 'the eyedropper and wheel do not remember which well they answer'
rg -Fq 'onStreamFinished: root.apply(root.target, this.text)' "$settings/ThemeEditorWells.qml" \
|| fail 'the picked colour does not return through the shared apply path'
rg -Fq 'root.apply(root.target, Qt.rgba(' "$settings/ThemeEditorWells.qml" \
|| fail 'the colour wheel does not return through the shared apply path'
for target in 'Open the color wheel for " + root.role' 'Pick " + root.role + " from the screen'; do
rg -Fq "$target" "$settings/ColorWell.qml" \
|| fail "a glyph-only button has no spoken name: $target"
done
# ── Hex validation lives in the well, and commits on intent ─────────────────
rg -Fq '/^#[0-9a-f]{6}$/.test(prefixed)' "$settings/ColorWell.qml" \
|| fail 'ColorWell does not validate typed hex'
rg -Fq 'property bool invalid' "$settings/ColorWell.qml" \
|| fail 'a rejected hex is not marked in the field'
rg -Fq 'onAccepted: root.commit()' "$settings/ColorWell.qml" \
|| fail 'a typed hex does not commit on Enter'
rg -Fq 'onActiveFocusChanged:' "$settings/ColorWell.qml" \
|| fail 'a typed hex does not commit when focus leaves'
# Per keystroke would repaint the desktop on the way to the colour you meant:
# "#82a" is a real colour.
if rg -q 'onTextChanged:.*commit|onTextEdited:.*commit' "$settings/ColorWell.qml"; then
fail 'the hex field commits per keystroke'
fi
rg -Fq 'Accessible.name: root.role + " color, hex"' "$settings/ColorWell.qml" \
|| fail 'the hex field is not programmatically labelled'
rg -Fq 'activeFocusOnTab: true' "$settings/ColorWell.qml" \
|| fail 'the hex field cannot receive keyboard focus'
# ── The six HSV rows, still labelled, still keyboard-operable ───────────────
for label in \
'Primary hue' 'Primary saturation' 'Primary value' \
'Secondary hue' 'Secondary saturation' 'Secondary value'; do
rg -Fq "$label" "$settings/AccentEditor.qml" \
|| fail "HSV control is missing the visible label $label"
done
rg -Fq 'ValueSlider {' "$settings/AccentEditor.qml" \
|| fail 'the fine-tune does not reuse ValueSlider'
rg -Fq 'Accessible.role: Accessible.Slider' "$settings/AccentEditor.qml" \
|| fail 'HSV controls do not expose slider semantics'
rg -Fq 'Accessible.name: row.label' "$settings/AccentEditor.qml" \
|| fail 'HSV controls are not programmatically labelled'
rg -Fq 'Accessible.description:' "$settings/AccentEditor.qml" \
|| fail 'HSV controls do not expose their numeric value as a non-hue signal'
rg -Fq 'activeFocusOnTab: true' "$settings/AccentEditor.qml" \
|| fail 'HSV controls cannot receive keyboard focus'
rg -Fq 'Keys.onPressed:' "$settings/AccentEditor.qml" \
|| fail 'HSV controls cannot be adjusted from the keyboard'
rg -Fq 'border.width: keyboardSlider.activeFocus ? 2 : 1' "$settings/AccentEditor.qml" \
|| fail 'HSV controls have no visible keyboard focus treatment'
# ── Debounced, not per-move ─────────────────────────────────────────────────
rg -Fq 'property var pending' "$settings/AccentEditor.qml" \
|| fail 'the sliders no longer track the drag locally'
rg -Fq 'commitTimer.restart();' "$settings/AccentEditor.qml" \
|| fail 'a slider move does not restart the debounce'
python3 - "$settings/AccentEditor.qml" <<'PY' || fail 'the HSV commit is no longer debounced through a timer'
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
# The write must happen inside a Timer's onTriggered, never in changeChannel.
commit = re.search(r'Timer\s*\{\s*id: commitTimer(.*?)\n \}', text, re.S)
if not commit or 'ThemeProfiles.setAccentPair(' not in commit.group(1):
raise SystemExit('the commit timer does not write the accent pair')
change = re.search(r'function changeChannel\(.*?\n \}', text, re.S)
if not change or 'ThemeProfiles.' in change.group(0):
raise SystemExit('changeChannel writes directly instead of scheduling a commit')
# A refused write must snap the sliders back to what is really in effect.
if not re.search(r'Timer\s*\{\s*id: releaseTimer.*?editor\.pending = null', text, re.S):
raise SystemExit('nothing hands the sliders back to the stored pair')
PY
# ── One commit path, and it keeps accentName in sync ────────────────────────
rg -Fq 'ThemeProfileModel.nearestCuratedName(profile.scheme, profile.accent)' "$profiles" \
|| fail 'commitActive does not recompute accentName from the chosen colour'
python3 - "$profiles" <<'PY' || fail 'a write path bypasses commitActive'
import re
import sys
text = re.sub(r"//.*", "", open(sys.argv[1], encoding="utf-8").read())
commit = re.search(r'function commitActive\(.*?\n \}', text, re.S)
if not commit:
raise SystemExit('commitActive is gone')
for fragment in (
'SystemSettings.commitPreference("colorScheme"',
'SystemSettings.commitPreference("themeProfileId"',
'SystemSettings.commitPreference("accentName"',
'root.applyProfileEffects(profile)',
):
if fragment not in commit.group(0):
raise SystemExit(f'commitActive no longer does: {fragment}')
# Everything that changes the active theme routes through it.
for func in ('selectProfile', 'updateActive'):
block = re.search(r'function ' + func + r'\(.*?\n \}', text, re.S)
if not block or 'root.commitActive(' not in block.group(0):
raise SystemExit(f'{func} does not end in commitActive')
PY
rg -Fq 'ThemeProfiles.activePalette' "$theme" \
|| fail 'Theme roles do not read the resolved palette'
rg -Fq 'root.hyprColor(Theme.accent, ' "$scheme" \
|| fail 'the focused border start does not follow Theme.accent'
rg -Fq 'root.hyprColor(Theme.accentSecondary, ' "$scheme" \
|| fail 'the focused border end does not follow Theme.accentSecondary'
# ── The editor is on the page, under its own tab ────────────────────────────
for component in ThemeStartChips ThemeEditorWells ThemeSaturationRow \
AccentEditor ThemeSaveRow ThemeModeHero ThemeGallery; do
rg -Fq "$component {" "$settings/AppearancePage.qml" \
|| fail "Appearance does not include $component"
done
for tab in '{ value: "themes", label: "Themes" }' '{ value: "editor", label: "Theme editor" }'; do
rg -Fq "$tab" "$settings/AppearancePage.qml" \
|| fail "Appearance is missing the tab $tab"
done
# ── Findable by name ────────────────────────────────────────────────────────
for term in 'Themes' 'Theme editor' 'Theme profiles' 'Advanced accent' \
'Pick color from screen'; do
rg -Fq "\"$term\"" "$search" || fail "Settings search is missing $term"
done
# ── No decorative or continuously repainting animation ──────────────────────
if rg -n 'NumberAnimation|ColorAnimation|SequentialAnimation|ParallelAnimation|loops:[[:space:]]*Animation\.Infinite' \
"$settings/AccentEditor.qml" "$settings/ColorWell.qml" \
"$settings/ThemeEditorWells.qml" "$settings/ThemeStartChips.qml" \
"$settings/ThemeSaturationRow.qml" "$settings/ThemeSaveRow.qml" \
"$settings/ThemeCard.qml" "$settings/ThemeGallery.qml" \
"$settings/ThemeModeHero.qml"; then
fail 'theme controls introduce continuously repainting or decorative animation'
fi
# ── Live: the editor, headless ──────────────────────────────────────────────
state_home="$(mktemp -d /tmp/panama-accent-controls.XXXXXX)"
harness="$repo_dir/config/dot/quickshell/accent-controls-harness.qml"
qs_for_test() {
XDG_CONFIG_HOME="$state_home/config" XDG_STATE_HOME="$state_home/state" \
QS_DISABLE_CRASH_HANDLER=1 qs -p "$harness" "$@"
}
cleanup() {
qs_for_test kill >/dev/null 2>&1 || true
rm -rf "$state_home"
}
trap cleanup EXIT
qs_for_test --daemonize >/dev/null
for _ in $(seq 1 40); do
qs_for_test ipc show 2>/dev/null | rg -q '^target accent-controls-test$' && break
sleep 0.1
done
qs_for_test ipc show 2>/dev/null | rg -q '^target accent-controls-test$' \
|| fail 'headless theme editor harness did not start'
status() { qs_for_test ipc call accent-controls-test status; }
jq -e '.id == "moon" and .shipped == true and .accent == "#82aaff"
and .accentName == "blue"' <<<"$(status)" >/dev/null \
|| fail 'the headless editor did not begin on Moon'
# A slider move shows immediately and writes on a beat, not per move.
moved="$(qs_for_test ipc call accent-controls-test adjust primary h 0)"
jq -e '.pending == true and .shown.accent != "#82aaff"
and .stored.accent == "#82aaff"' <<<"$moved" >/dev/null \
|| fail 'an HSV move either did not show or wrote before the debounce elapsed'
sleep 0.8
settled="$(status)"
jq -e '.shipped == false and .accent != "#82aaff" and .secondary == "#b172b0"
and .pending == false' <<<"$settled" >/dev/null \
|| fail 'the debounced commit did not fork a custom profile with the unchanged secondary'
# The wells: a valid hex lands, and accentName follows it without being asked.
jq -e '.accent == "#86e1fc" and .accentName == "teal" and .wellError == ""' \
<<<"$(qs_for_test ipc call accent-controls-test well primary '#86e1fc')" >/dev/null \
|| fail 'a typed primary hex did not land, or accentName did not follow it'
jq -e '.accent == "#86e1fc" and (.wellError | length) > 0' \
<<<"$(qs_for_test ipc call accent-controls-test well primary 'not-a-colour')" >/dev/null \
|| fail 'an unusable hex was accepted, or was refused without saying so'
# The ground wells move the whole surface and text family, not one token.
ground="$(qs_for_test ipc call accent-controls-test well background '#101020')"
jq -e '.bg == "#101020" and .fg == "#c8d3f5" and .fgDim != "#828bb8"' \
<<<"$ground" >/dev/null \
|| fail 'changing the background did not re-derive the surface and text family'
trap - EXIT
cleanup
printf 'accent controls contract: PASS\n'