Files
Panama/tests/quickshell/sound-page-contract
T
Gabriel Brown 5490fd285d Fold thirty-one settings pages into fifteen categories with tabs
The sidebar was a flat scan of thirty-one rows; now it reads like a
settings app. Multi-subject categories (Input, Network & Sharing,
Applications, Users & Accounts, Privacy & Security, System) carry an
Appearance-style tab strip above the page, drawn by the shell so the
leaf pages themselves are untouched. The taxonomy lives in one new
file, services/SettingsRoutes.qml; the sidebar, the strip, route
validation, search breadcrumbs, and both generators derive from it.

ShellState.settingsPage still holds leaf ids, so every deep link, IPC
call, and search result keeps working — and now lands on the exact
tab. Dictation moves out of Sound onto its own page under Input, with
a handoff back to Sound for the microphone. The strip scrolls when
System's nine tabs outgrow a tiled window. All 161 contracts pass.

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-23 20:21:31 -04:00

151 lines
8.7 KiB
Bash
Executable File

#!/usr/bin/env bash
# The Sound page is a first-class PipeWire control surface, not a launcher for
# another settings app. This contract keeps the real device plumbing shared
# with Quick Settings and verifies the controls that must remain available.
#
# It also owns the line between Sound and Dictation. Dictation used to be a
# card on this page, because it listens through the input device chosen here.
# It is an input method, so it now sits under Input with the keyboard -- and
# the thing that made the old arrangement legible, that the microphone and the
# dictation setup were visibly the same subject, has to survive the move as an
# explicit handoff rather than as a second device picker.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
sound_page="$repo_dir/config/dot/quickshell/modules/settings/SoundPage.qml"
dictation_page="$repo_dir/config/dot/quickshell/modules/settings/DictationPage.qml"
device_list="$repo_dir/config/dot/quickshell/modules/settings/SoundDeviceList.qml"
device_row="$repo_dir/config/dot/quickshell/modules/settings/SoundDeviceRow.qml"
application_mixer="$repo_dir/config/dot/quickshell/modules/settings/ApplicationMixer.qml"
application_row="$repo_dir/config/dot/quickshell/modules/settings/ApplicationVolumeRow.qml"
balance="$repo_dir/config/dot/quickshell/modules/settings/AudioBalance.qml"
audio_devices="$repo_dir/config/dot/quickshell/services/AudioDevices.qml"
sound_feedback="$repo_dir/config/dot/quickshell/services/SoundFeedback.qml"
quick_devices="$repo_dir/config/dot/quickshell/modules/quicksettings/AudioDeviceList.qml"
harness="$repo_dir/config/dot/quickshell/sound-page-harness.qml"
config_home="$(mktemp -d /tmp/panama-sound-config.XXXXXX)"
state_home="$(mktemp -d /tmp/panama-sound-state.XXXXXX)"
shell_log="$state_home/quickshell.log"
fail() {
printf 'sound page contract: %s\n' "$1" >&2
exit 1
}
qs_for_harness() {
XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" qs -p "$harness" "$@"
}
cleanup() {
qs_for_harness kill >/dev/null 2>&1 || true
rm -rf "$config_home" "$state_home"
}
trap cleanup EXIT
for file in "$sound_page" "$dictation_page" "$device_list" "$device_row" "$application_mixer" "$application_row" "$balance" "$audio_devices" "$sound_feedback" "$quick_devices"; do
[[ -f "$file" ]] || fail "missing ${file#"$repo_dir/"}"
done
# One shared source of truth owns device discovery and default selection.
rg -Fq 'pragma Singleton' "$audio_devices" || fail 'audio device service is not a singleton'
rg -Fq 'Singleton {' "$audio_devices" || fail 'audio device service has no singleton root'
rg -Fq 'PwNodeType.AudioSource' "$audio_devices" || fail 'audio sources are not filtered by PipeWire type'
rg -Fq 'Pipewire.preferredDefaultAudioSink = node;' "$audio_devices" || fail 'output selection does not reach PipeWire'
rg -Fq 'Pipewire.preferredDefaultAudioSource = node;' "$audio_devices" || fail 'input selection does not reach PipeWire'
rg -Fq 'AudioDevices.outputs' "$quick_devices" || fail 'Quick Settings does not share output discovery'
rg -Fq 'AudioDevices.inputs' "$quick_devices" || fail 'Quick Settings does not share input discovery'
rg -Fq 'AudioDevices.select(root.output, node)' "$quick_devices" || fail 'Quick Settings does not share device selection'
# Every hardware row binds its node before reading/writing audio state.
rg -Fq 'PwObjectTracker {' "$device_row" || fail 'device rows do not bind PipeWire objects'
rg -Fq 'root.node.audio.muted = !root.node.audio.muted;' "$device_row" || fail 'device mute is not writable'
rg -Fq 'root.node.audio.volume = value;' "$device_row" || fail 'per-device volume is not writable'
rg -Fq 'PwNodePeakMonitor {' "$device_row" || fail 'input rows have no level monitor'
rg -Fq 'enabled: !root.output && root.selected' "$device_row" || fail 'input monitoring is not scoped to the selected source'
# Stereo hardware gets a real channel balance control.
rg -Fq 'PwAudioChannel.FrontLeft' "$balance" || fail 'balance does not identify the left channel'
rg -Fq 'PwAudioChannel.FrontRight' "$balance" || fail 'balance does not identify the right channel'
rg -Fq 'root.node.audio.volumes = next;' "$balance" || fail 'balance does not write per-channel volume'
[[ "$(rg -c 'SoundDeviceList \{' "$sound_page")" -eq 2 ]] || fail 'Sound page does not expose output and input device lists'
rg -Fq 'AudioBalance {' "$sound_page" || fail 'Sound page has no output balance control'
rg -Fq 'ApplicationMixer {' "$sound_page" || fail 'Sound page has no Applications mixer'
rg -Fq 'label: "Device profiles"' "$sound_page" || fail 'advanced handoff is not limited to device profiles'
rg -Fq 'SystemSettings.openGnomePanel("sound")' "$sound_page" || fail 'advanced GNOME Sound handoff was removed'
rg -Fq 'SoundFeedback.setEventSounds(checked)' "$sound_page" || fail 'event sounds are not controllable'
rg -Fq 'SoundFeedback.setInputFeedback(checked)' "$sound_page" || fail 'input feedback sounds are not controllable'
rg -Fq 'org.gnome.desktop.sound' "$sound_feedback" || fail 'sound feedback does not use the desktop sound schema'
# ── Dictation lives on its own page under Input ──────────────────────────────
# One page owns the dictation controls. Two would mean two setup buttons
# driving the same one-time install, and whichever one someone found second
# would report state it did not cause.
! rg -Fq 'Dictation.' "$sound_page" \
|| fail 'the Sound page reads Dictation state again -- dictation belongs to DictationPage, and two pages showing the same setup is how one of them goes stale'
rg -Fq 'label: "Set up dictation"' "$dictation_page" \
|| fail 'DictationPage has no setup action, so the one-time install cannot be started from Settings'
rg -Fq 'onTriggered: Dictation.setup()' "$dictation_page" \
|| fail 'the dictation setup action does not call the helper that installs both the speech server and the model'
rg -Fq 'label: "Speech server"' "$dictation_page" \
|| fail 'DictationPage does not report whether the speech server is installed'
rg -Fq 'label: "Speech model"' "$dictation_page" \
|| fail 'DictationPage does not report whether the speech model is installed'
rg -Fq 'Dictation.typingAvailable' "$dictation_page" \
|| fail 'DictationPage does not say when wtype is missing, so dictated text would silently go to the clipboard'
rg -Fq 'Dictation.lastError' "$dictation_page" \
|| fail 'DictationPage never surfaces a setup failure'
# The microphone is chosen on the Sound page, and dictation listens through it.
# Saying so, with a way to get there, is what replaces the two cards having sat
# side by side.
rg -Fq 'title: "Microphone"' "$dictation_page" \
|| fail 'DictationPage does not name the input device it listens through'
rg -Fq 'ShellState.openSettings("sound")' "$dictation_page" \
|| fail 'DictationPage does not hand off to Sound, so the device it listens through is unreachable from it'
! rg -Fq 'SoundDeviceList {' "$dictation_page" \
|| fail 'DictationPage grew its own device picker -- there is one input device, and two places to change it disagree'
# Native bindings are the supported path. Shelling out would race the service
# that owns these same objects and regress Quick Settings coherence.
if rg -q '\b(Process|pactl|wpctl)\b' "$audio_devices" "$device_list" "$device_row" "$balance"; then
fail 'Sound controls bypass the Quickshell PipeWire service'
fi
printf 'sound page static contract: PASS\n'
# Instantiate the complete page against the real, read-only PipeWire graph.
# Merely constructing these controls must never change a default or volume.
qs_for_harness --daemonize >"$shell_log" 2>&1
for _ in $(seq 1 60); do
qs_for_harness ipc show 2>/dev/null | rg -q '^target sound-page-test$' && break
sleep 0.1
done
qs_for_harness ipc show 2>/dev/null | rg -q '^target sound-page-test$' \
|| fail 'Sound page harness did not start'
for _ in $(seq 1 60); do
status="$(qs_for_harness ipc call sound-page-test status)"
[[ "$(jq -r .ready <<<"$status")" == "true" ]] && break
sleep 0.1
done
jq -e '.ready == true and .outputs > 0 and .inputs > 0
and .applications >= 0
and (.defaultOutput | length > 0) and (.defaultInput | length > 0)
and .populatedRows == 1 and .populatedStatus == ""
and .emptyRows == 0 and .emptyStatus == "Applications playing sound will appear here"
and .unavailableRows == 0 and .unavailableStatus == "PipeWire is unavailable"' \
<<<"$status" >/dev/null \
|| fail "real PipeWire graph was not represented: $status"
if rg -n 'ReferenceError|TypeError|Binding loop|Unable to assign|Cannot assign|PwObjectTracker' "$shell_log"; then
fail 'Sound page emitted a QML runtime warning'
fi
trap - EXIT
cleanup
printf 'sound page runtime: PASS (%s outputs, %s inputs)\n' \
"$(jq -r .outputs <<<"$status")" "$(jq -r .inputs <<<"$status")"