From 6b4f68666aadda07ee11bf415caafad63821b13e Mon Sep 17 00:00:00 2001 From: JarbasAi Date: Sun, 12 Jul 2026 16:17:49 +0100 Subject: [PATCH] test: bats suite for lib/audio-utils.sh + CI workflow 28 tests over the shared library using PATH-prepended command stubs and canned aplay/wpctl/pactl fixtures: card-priority ordering (USB > HAT > headphones > HDMI), USB card picking, wpctl/pactl sink parsing and card-to-sink mapping, udev env var extraction, i2c_platform hint handling and sound-server detection. --- .github/workflows/test.yml | 21 +++ test/audio-utils.bats | 226 +++++++++++++++++++++++++++ test/fixtures/aplay_all_cards.txt | 13 ++ test/fixtures/aplay_hat_only.txt | 10 ++ test/fixtures/aplay_hdmi_only.txt | 4 + test/fixtures/aplay_none.txt | 1 + test/fixtures/aplay_onboard_only.txt | 7 + test/fixtures/aplay_two_usb.txt | 10 ++ test/fixtures/pactl_list_sinks.txt | 21 +++ test/fixtures/pactl_short_sinks.txt | 4 + test/fixtures/wpctl_inspect_50.txt | 8 + test/fixtures/wpctl_inspect_55.txt | 8 + test/fixtures/wpctl_status.txt | 23 +++ test/stubs/aplay | 7 + test/stubs/pactl | 12 ++ test/stubs/wpctl | 26 +++ 16 files changed, 401 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 test/audio-utils.bats create mode 100644 test/fixtures/aplay_all_cards.txt create mode 100644 test/fixtures/aplay_hat_only.txt create mode 100644 test/fixtures/aplay_hdmi_only.txt create mode 100644 test/fixtures/aplay_none.txt create mode 100644 test/fixtures/aplay_onboard_only.txt create mode 100644 test/fixtures/aplay_two_usb.txt create mode 100644 test/fixtures/pactl_list_sinks.txt create mode 100644 test/fixtures/pactl_short_sinks.txt create mode 100644 test/fixtures/wpctl_inspect_50.txt create mode 100644 test/fixtures/wpctl_inspect_55.txt create mode 100644 test/fixtures/wpctl_status.txt create mode 100755 test/stubs/aplay create mode 100755 test/stubs/pactl create mode 100755 test/stubs/wpctl diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..4f96b93 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,21 @@ +name: Tests + +on: + push: + branches: [dev] + pull_request: + branches: [dev] + workflow_dispatch: + +jobs: + bats: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Install bats + run: sudo apt-get update -qq && sudo apt-get install -y -qq bats + + - name: Run tests + run: bats test/ diff --git a/test/audio-utils.bats b/test/audio-utils.bats new file mode 100644 index 0000000..98cb253 --- /dev/null +++ b/test/audio-utils.bats @@ -0,0 +1,226 @@ +#!/usr/bin/env bats +# Unit tests for lib/audio-utils.sh +# The lib's external commands (aplay, wpctl, pactl, pgrep...) are replaced by +# PATH-prepended stubs in test/stubs, fed from canned output in test/fixtures. + +setup() { + REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + FIXTURES="$REPO_ROOT/test/fixtures" + STUBS="$REPO_ROOT/test/stubs" + PATH="$STUBS:$PATH" + + export WPCTL_FIXTURE_DIR="$FIXTURES" + export WPCTL_STATUS_FIXTURE="$FIXTURES/wpctl_status.txt" + export PACTL_SINKS_FIXTURE="$FIXTURES/pactl_list_sinks.txt" + export PACTL_SHORT_SINKS_FIXTURE="$FIXTURES/pactl_short_sinks.txt" + + # Keep the lib's logging out of /tmp during tests + export LOG_FILE="$BATS_TEST_TMPDIR/test.log" + + # shellcheck source=../lib/audio-utils.sh + source "$REPO_ROOT/lib/audio-utils.sh" +} + +# ---------------------------------------------------------------- aplay parsing + +@test "get_card_number finds the bcm2835 headphones card" { + export APLAY_FIXTURE="$FIXTURES/aplay_all_cards.txt" + run get_card_number "bcm2835" + [ "$status" -eq 0 ] + [ "$output" == "1" ] +} + +@test "get_card_number returns empty for an absent card" { + export APLAY_FIXTURE="$FIXTURES/aplay_all_cards.txt" + run get_card_number "snd_rpi_proto" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "pick_usb_card returns the single USB card" { + export APLAY_FIXTURE="$FIXTURES/aplay_all_cards.txt" + run pick_usb_card + [ "$status" -eq 0 ] + [ "$output" == "3" ] +} + +@test "pick_usb_card picks the LAST card and warns when several USB cards exist" { + export APLAY_FIXTURE="$FIXTURES/aplay_two_usb.txt" + run pick_usb_card + [ "$status" -eq 0 ] + [[ "$output" == *"Multiple USB soundcards detected"* ]] + [ "${lines[-1]}" == "2" ] +} + +@test "pick_usb_card returns empty when no USB card exists" { + export APLAY_FIXTURE="$FIXTURES/aplay_onboard_only.txt" + run pick_usb_card + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +# ------------------------------------------- fallback priority (USB > HAT > headphones > HDMI) + +@test "priority: USB wins over HAT, headphones and HDMI" { + export APLAY_FIXTURE="$FIXTURES/aplay_all_cards.txt" + run select_fallback_card + [ "$output" == "usb 3" ] +} + +@test "priority: HAT (user-installed card) wins when no USB is present" { + export APLAY_FIXTURE="$FIXTURES/aplay_hat_only.txt" + run select_fallback_card + [ "$output" == "other 2" ] +} + +@test "priority: headphones (bcm2835) win when only onboard cards are present" { + export APLAY_FIXTURE="$FIXTURES/aplay_onboard_only.txt" + run select_fallback_card + [ "$output" == "headphones 1" ] +} + +@test "priority: HDMI is the last resort" { + export APLAY_FIXTURE="$FIXTURES/aplay_hdmi_only.txt" + run select_fallback_card + [ "$output" == "hdmi 0" ] +} + +@test "priority: empty result when no card at all is detected" { + export APLAY_FIXTURE="$FIXTURES/aplay_none.txt" + run select_fallback_card + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +# ---------------------------------------------------------------- wpctl parsing + +@test "pw_list_sink_ids parses sink IDs from wpctl status (incl. the default sink)" { + run pw_list_sink_ids + [ "$status" -eq 0 ] + [ "${lines[0]}" == "50" ] + [ "${lines[1]}" == "55" ] + [ "${#lines[@]}" -eq 2 ] +} + +@test "pw_card2sink maps an ALSA card index to the PipeWire sink ID" { + run pw_card2sink 3 + [ "$status" -eq 0 ] + [ "$output" == "55" ] +} + +@test "pw_card2sink returns nothing for an unknown card index" { + run pw_card2sink 9 + [ -z "$output" ] +} + +@test "pw_sink_by_name finds a sink by node.name" { + run pw_sink_by_name "alsa_output.usb-0d8c_USB_Audio_Device-00.analog-stereo" + [ "$status" -eq 0 ] + [ "$output" == "55" ] +} + +@test "pw_sink_by_name fails (rc 1) for an unknown node.name" { + run pw_sink_by_name "auto_combined" + [ "$status" -eq 1 ] +} + +# ---------------------------------------------------------------- pactl parsing + +@test "pulse_card2sink maps an ALSA card index to the sink name" { + run pulse_card2sink 3 + [ "$status" -eq 0 ] + [ "$output" == "alsa_output.usb-0d8c_USB_Audio_Device-00.analog-stereo" ] +} + +@test "pulse_card2sink reports 'Card index not found' for an unknown index" { + run pulse_card2sink 9 + [ "$status" -eq 0 ] + [ "$output" == "Card index not found" ] +} + +@test "pulse_list_sinks excludes auto_null and auto_combined" { + run pulse_list_sinks + [ "$status" -eq 0 ] + [ "${#lines[@]}" -eq 2 ] + [ "${lines[0]}" == "alsa_output.platform-bcm2835_audio.stereo-fallback" ] + [ "${lines[1]}" == "alsa_output.usb-0d8c_USB_Audio_Device-00.analog-stereo" ] +} + +# ---------------------------------------------------------------- udev env vars + +@test "udev_usb_card extracts the card number from DEVPATH" { + DEVPATH="/devices/platform/soc/usb/1-1.2:1.0/sound/card3" run udev_usb_card + [ "$output" == "3" ] +} + +@test "udev_usb_card extracts the card number from DEVNAME" { + DEVNAME="/dev/snd/controlC5" run udev_usb_card + [ "$output" == "5" ] +} + +@test "udev_usb_card returns empty outside of udev" { + run udev_usb_card + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +# ---------------------------------------------------------------- i2c hint + +@test "read_i2c_platform returns the hint written by ovos-i2csound" { + I2C_PLATFORM_FILE="$BATS_TEST_TMPDIR/i2c_platform" + echo "MARK1" > "$I2C_PLATFORM_FILE" + run read_i2c_platform + [ "$output" == "MARK1" ] +} + +@test "read_i2c_platform returns empty when the hint file is missing" { + I2C_PLATFORM_FILE="$BATS_TEST_TMPDIR/does_not_exist" + run read_i2c_platform + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +# ---------------------------------------------------------------- sound server detection + +# detect_sound_server probes with pgrep and command -v; run it in a controlled +# PATH containing only the binaries each scenario should "have". +_detect_with() { # $1: csv of fake binaries to provide + local dir="$BATS_TEST_TMPDIR/bin_$BATS_TEST_NUMBER" + mkdir -p "$dir" + local IFS=',' + for b in $1; do + printf '#!/bin/sh\nexit 1\n' > "$dir/$b" # pgrep stub: nothing running + chmod +x "$dir/$b" + done + PATH="$dir" run detect_sound_server +} + +@test "detect_sound_server: running pipewire wins" { + local dir="$BATS_TEST_TMPDIR/bin_pw" + mkdir -p "$dir" + printf '#!/bin/sh\n[ "$2" = "pipewire" ] && exit 0 || exit 1\n' > "$dir/pgrep" + chmod +x "$dir/pgrep" + PATH="$dir" run detect_sound_server + [ "$output" == "pipewire" ] +} + +@test "detect_sound_server: installed pipewire beats installed pulseaudio" { + _detect_with "pgrep,pipewire,pulseaudio" + [ "$output" == "pipewire" ] +} + +@test "detect_sound_server: pulse when only pulseaudio is installed" { + _detect_with "pgrep,pulseaudio" + [ "$output" == "pulse" ] +} + +@test "detect_sound_server: alsa when only alsa-utils are installed" { + _detect_with "pgrep,aplay,amixer" + [ "$output" == "alsa" ] +} + +@test "detect_sound_server: graceful error when nothing is available" { + _detect_with "pgrep" + [ "$status" -eq 1 ] + [[ "$output" == *"No sound server detected"* ]] +} diff --git a/test/fixtures/aplay_all_cards.txt b/test/fixtures/aplay_all_cards.txt new file mode 100644 index 0000000..7d9a80a --- /dev/null +++ b/test/fixtures/aplay_all_cards.txt @@ -0,0 +1,13 @@ +**** List of PLAYBACK Hardware Devices **** +card 0: vc4hdmi0 [vc4-hdmi-0], device 0: MAI PCM i2s-hifi-0 [MAI PCM i2s-hifi-0] + Subdevices: 1/1 + Subdevice #0: subdevice #0 +card 1: Headphones [bcm2835 Headphones], device 0: bcm2835 Headphones [bcm2835 Headphones] + Subdevices: 8/8 + Subdevice #0: subdevice #0 +card 2: wm8960soundcard [wm8960-soundcard], device 0: bcm2835-i2s-wm8960-hifi wm8960-hifi-0 [bcm2835-i2s-wm8960-hifi wm8960-hifi-0] + Subdevices: 1/1 + Subdevice #0: subdevice #0 +card 3: Device [USB Audio Device], device 0: USB Audio [USB Audio] + Subdevices: 1/1 + Subdevice #0: subdevice #0 diff --git a/test/fixtures/aplay_hat_only.txt b/test/fixtures/aplay_hat_only.txt new file mode 100644 index 0000000..155bc12 --- /dev/null +++ b/test/fixtures/aplay_hat_only.txt @@ -0,0 +1,10 @@ +**** List of PLAYBACK Hardware Devices **** +card 0: vc4hdmi0 [vc4-hdmi-0], device 0: MAI PCM i2s-hifi-0 [MAI PCM i2s-hifi-0] + Subdevices: 1/1 + Subdevice #0: subdevice #0 +card 1: Headphones [bcm2835 Headphones], device 0: bcm2835 Headphones [bcm2835 Headphones] + Subdevices: 8/8 + Subdevice #0: subdevice #0 +card 2: sndrpihifiberry [snd_rpi_hifiberry_dacplus], device 0: HiFiBerry DAC+ Pro HiFi pcm512x-hifi-0 [HiFiBerry DAC+ Pro HiFi pcm512x-hifi-0] + Subdevices: 1/1 + Subdevice #0: subdevice #0 diff --git a/test/fixtures/aplay_hdmi_only.txt b/test/fixtures/aplay_hdmi_only.txt new file mode 100644 index 0000000..24e5eb4 --- /dev/null +++ b/test/fixtures/aplay_hdmi_only.txt @@ -0,0 +1,4 @@ +**** List of PLAYBACK Hardware Devices **** +card 0: vc4hdmi0 [vc4-hdmi-0], device 0: MAI PCM i2s-hifi-0 [MAI PCM i2s-hifi-0] + Subdevices: 1/1 + Subdevice #0: subdevice #0 diff --git a/test/fixtures/aplay_none.txt b/test/fixtures/aplay_none.txt new file mode 100644 index 0000000..b011d5b --- /dev/null +++ b/test/fixtures/aplay_none.txt @@ -0,0 +1 @@ +**** List of PLAYBACK Hardware Devices **** diff --git a/test/fixtures/aplay_onboard_only.txt b/test/fixtures/aplay_onboard_only.txt new file mode 100644 index 0000000..d976a30 --- /dev/null +++ b/test/fixtures/aplay_onboard_only.txt @@ -0,0 +1,7 @@ +**** List of PLAYBACK Hardware Devices **** +card 0: vc4hdmi0 [vc4-hdmi-0], device 0: MAI PCM i2s-hifi-0 [MAI PCM i2s-hifi-0] + Subdevices: 1/1 + Subdevice #0: subdevice #0 +card 1: Headphones [bcm2835 Headphones], device 0: bcm2835 Headphones [bcm2835 Headphones] + Subdevices: 8/8 + Subdevice #0: subdevice #0 diff --git a/test/fixtures/aplay_two_usb.txt b/test/fixtures/aplay_two_usb.txt new file mode 100644 index 0000000..aa0a432 --- /dev/null +++ b/test/fixtures/aplay_two_usb.txt @@ -0,0 +1,10 @@ +**** List of PLAYBACK Hardware Devices **** +card 0: Headphones [bcm2835 Headphones], device 0: bcm2835 Headphones [bcm2835 Headphones] + Subdevices: 8/8 + Subdevice #0: subdevice #0 +card 1: Device [USB Audio Device], device 0: USB Audio [USB Audio] + Subdevices: 1/1 + Subdevice #0: subdevice #0 +card 2: Speaker [Plugable USB Audio Speaker], device 0: USB Audio [USB Audio] + Subdevices: 1/1 + Subdevice #0: subdevice #0 diff --git a/test/fixtures/pactl_list_sinks.txt b/test/fixtures/pactl_list_sinks.txt new file mode 100644 index 0000000..1342657 --- /dev/null +++ b/test/fixtures/pactl_list_sinks.txt @@ -0,0 +1,21 @@ +Sink #0 + State: SUSPENDED + Name: alsa_output.platform-bcm2835_audio.stereo-fallback + Description: Built-in Audio Stereo + Driver: PipeWire + Sample Specification: s16le 2ch 48000Hz + Properties: + api.alsa.card = "1" + api.alsa.card.name = "bcm2835 Headphones" + node.name = "alsa_output.platform-bcm2835_audio.stereo-fallback" + +Sink #1 + State: RUNNING + Name: alsa_output.usb-0d8c_USB_Audio_Device-00.analog-stereo + Description: USB Audio Device Analog Stereo + Driver: PipeWire + Sample Specification: s16le 2ch 48000Hz + Properties: + api.alsa.card = "3" + api.alsa.card.name = "USB Audio Device" + node.name = "alsa_output.usb-0d8c_USB_Audio_Device-00.analog-stereo" diff --git a/test/fixtures/pactl_short_sinks.txt b/test/fixtures/pactl_short_sinks.txt new file mode 100644 index 0000000..5cb3b0d --- /dev/null +++ b/test/fixtures/pactl_short_sinks.txt @@ -0,0 +1,4 @@ +0 auto_null module-null-sink.c s16le 2ch 44100Hz SUSPENDED +1 alsa_output.platform-bcm2835_audio.stereo-fallback PipeWire s16le 2ch 48000Hz SUSPENDED +2 alsa_output.usb-0d8c_USB_Audio_Device-00.analog-stereo PipeWire s16le 2ch 48000Hz RUNNING +3 auto_combined module-combine-sink.c s16le 2ch 48000Hz IDLE diff --git a/test/fixtures/wpctl_inspect_50.txt b/test/fixtures/wpctl_inspect_50.txt new file mode 100644 index 0000000..d061b4a --- /dev/null +++ b/test/fixtures/wpctl_inspect_50.txt @@ -0,0 +1,8 @@ +id 50, type PipeWire:Interface:Node + api.alsa.card = "1" + api.alsa.pcm.card = "1" + api.alsa.path = "front:1" + audio.channels = "2" + node.name = "alsa_output.platform-bcm2835_audio.stereo-fallback" + node.description = "Built-in Audio Stereo" + media.class = "Audio/Sink" diff --git a/test/fixtures/wpctl_inspect_55.txt b/test/fixtures/wpctl_inspect_55.txt new file mode 100644 index 0000000..e043bb5 --- /dev/null +++ b/test/fixtures/wpctl_inspect_55.txt @@ -0,0 +1,8 @@ +id 55, type PipeWire:Interface:Node + api.alsa.card = "3" + api.alsa.pcm.card = "3" + api.alsa.path = "front:3" + audio.channels = "2" + node.name = "alsa_output.usb-0d8c_USB_Audio_Device-00.analog-stereo" + node.description = "USB Audio Device Analog Stereo" + media.class = "Audio/Sink" diff --git a/test/fixtures/wpctl_status.txt b/test/fixtures/wpctl_status.txt new file mode 100644 index 0000000..e2a08e8 --- /dev/null +++ b/test/fixtures/wpctl_status.txt @@ -0,0 +1,23 @@ +PipeWire 'pipewire-0' [1.0.5, ovos@raspovos, cookie:12345] + └─ Clients: + 32. WirePlumber [1.0.5, ovos@raspovos, pid:812] + 33. WirePlumber [export] [1.0.5, ovos@raspovos, pid:812] + +Audio + ├─ Devices: + │ 41. Built-in Audio [alsa] + │ 42. USB Audio Device [alsa] + │ + ├─ Sinks: + │ * 50. Built-in Audio Stereo [vol: 0.74] + │ 55. USB Audio Device Analog Stereo [vol: 1.00] + │ + ├─ Sources: + │ 60. USB Audio Device Mono [vol: 1.00] + │ + └─ Streams: + +Video + ├─ Devices: + │ + └─ Sinks: diff --git a/test/stubs/aplay b/test/stubs/aplay new file mode 100755 index 0000000..f3c5040 --- /dev/null +++ b/test/stubs/aplay @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Test stub for aplay: prints the fixture pointed at by $APLAY_FIXTURE +if [ -z "${APLAY_FIXTURE:-}" ]; then + echo "aplay stub: APLAY_FIXTURE not set" >&2 + exit 1 +fi +cat "$APLAY_FIXTURE" diff --git a/test/stubs/pactl b/test/stubs/pactl new file mode 100755 index 0000000..3a73f91 --- /dev/null +++ b/test/stubs/pactl @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Test stub for pactl: +# pactl list sinks -> cat $PACTL_SINKS_FIXTURE +# pactl list short sinks -> cat $PACTL_SHORT_SINKS_FIXTURE +if [ "$1" == "list" ] && [ "$2" == "short" ] && [ "$3" == "sinks" ]; then + cat "$PACTL_SHORT_SINKS_FIXTURE" +elif [ "$1" == "list" ] && [ "$2" == "sinks" ]; then + cat "$PACTL_SINKS_FIXTURE" +else + echo "pactl stub: unhandled args: $*" >&2 + exit 1 +fi diff --git a/test/stubs/wpctl b/test/stubs/wpctl new file mode 100755 index 0000000..9fd2647 --- /dev/null +++ b/test/stubs/wpctl @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Test stub for wpctl: +# wpctl status -> cat $WPCTL_STATUS_FIXTURE +# wpctl inspect N -> cat $WPCTL_FIXTURE_DIR/wpctl_inspect_N.txt +# wpctl set-default -> record the call in $WPCTL_CALL_LOG +case "$1" in + status) + cat "$WPCTL_STATUS_FIXTURE" + ;; + inspect) + f="$WPCTL_FIXTURE_DIR/wpctl_inspect_$2.txt" + if [ -f "$f" ]; then + cat "$f" + else + echo "wpctl stub: no fixture for sink $2" >&2 + exit 1 + fi + ;; + set-default) + echo "set-default $2" >> "${WPCTL_CALL_LOG:-/dev/null}" + ;; + *) + echo "wpctl stub: unhandled args: $*" >&2 + exit 1 + ;; +esac