Files
Radxa_MIXER/tests/test_manager.py
T
jsahnandClaude Opus 5.5 7ac8cd1dbb Reduce mixer and Bluetooth buffers from 500 ms to 200 ms
SBC medium, not the larger buffer, removed the headphone relay dropouts,
so cut dmix, bluealsa-aplay and alsaloop buffers to 200 ms and the
Shairport backend buffer to 0.15 s. Verified by listening (iPhone ->
headphones, with AirPlay mixed in) with no RTP loss or underruns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:07:34 +09:00

107 lines
5.8 KiB
Python

import importlib.util
from pathlib import Path
import subprocess
import unittest
from unittest.mock import patch
spec=importlib.util.spec_from_file_location('manager',Path(__file__).parents[1]/'scripts/radxa-audio.py')
m=importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
class ManagerTests(unittest.TestCase):
def config(self, **extra):
return dict(dict(name='Radxa Desk', dac_pcm='hw:CARD=Audio,DEV=0',
output='dac', bluetooth_device=''), **extra) if not extra else {
**self.config(), **extra}
def test_rejects_unsafe_hardware_and_addresses(self):
for key, value in [('dac_pcm', 'hw:CARD=Audio"; include "evil'),
('bluetooth_device', 'AA:BB:CC:DD:EE:FF;reboot'),
('name', 'line\nbreak')]:
with self.subTest(key=key), self.assertRaises(ValueError):
m.templates(self.config(**{key:value}))
def test_bluetooth_requires_address_before_any_action(self):
with patch.object(m, 'root'), patch.object(m, 'run') as run:
with self.assertRaises(ValueError): m.apply(self.config(output='bluetooth'))
run.assert_not_called()
def test_unavailable_headphone_does_not_stop_working_inputs(self):
def fake(*args, **kwargs):
return subprocess.CompletedProcess(args, 0, stdout='no device')
with patch.object(m, 'root'), patch.object(m, 'run', side_effect=fake) as run, patch.object(m, 'snapshot') as snapshot:
with self.assertRaises(ValueError):
m.apply(self.config(output='bluetooth',bluetooth_device='AA:BB:CC:DD:EE:FF'))
snapshot.assert_not_called()
self.assertFalse(any('stop' in c.args for c in run.call_args_list))
def test_two_stereo_inputs_share_same_mixer(self):
f=m.templates(self.config())
self.assertIn('"radxa_mix"',f['/etc/shairport-sync.conf'])
self.assertIn('--pcm=radxa_mix',f['/etc/systemd/system/bluealsa-aplay.service.d/10-radxa-mixer.conf'])
self.assertNotIn('--single-audio', ''.join(f.values()))
self.assertIn('ttable { 0.0 0.707945784 1.1 0.707945784 }',f['/etc/asound.conf'])
self.assertIn('rate 44100',f['/etc/asound.conf'])
def test_output_device_is_not_hardcoded(self):
address='AA:BB:CC:DD:EE:FF'
f=m.templates(self.config(output='bluetooth',bluetooth_device=address))
unit=f['/etc/systemd/system/radxa-bt-output.service']
self.assertIn('DEV='+address,unit)
self.assertNotIn('E4:58',unit)
self.assertIn('Loopback', f['/etc/asound.conf'])
self.assertIn('rate 48000',f['/etc/asound.conf'])
self.assertIn('--sbc-quality=medium',f['/etc/systemd/system/bluealsa.service.d/10-radxa-sink.conf'])
self.assertIn('-t 200000',unit)
def test_name_quotes_are_escaped(self):
f=m.templates(self.config(name='Desk "Two"'))
self.assertIn('name = "Desk \\"Two\\"";',f['/etc/shairport-sync.conf'])
def test_pair_skips_repairing_registered_device(self):
def fake(*args, **kwargs):
return subprocess.CompletedProcess(args, 0, stdout='Paired: yes')
with patch.object(m, 'root'), patch.object(m, 'run', side_effect=fake) as run:
m.pair('AA:BB:CC:DD:EE:FF')
self.assertFalse(any('pair' in call.args for call in run.call_args_list))
self.assertTrue(any('trust' in call.args for call in run.call_args_list))
def test_bond_request_must_not_report_temporary_pair_as_success(self):
def fake(*args, **kwargs):
return subprocess.CompletedProcess(args,0,stdout='Paired: yes\nBonded: no')
with patch.object(m,'root'),patch.object(m,'run',side_effect=fake) as run, \
patch.object(m,'agent_pair') as agent:
with self.assertRaises(ValueError):m.pair('AA:BB:CC:DD:EE:FF',output=True,bond=True)
agent.assert_called_once_with('AA:BB:CC:DD:EE:FF')
self.assertTrue(any('remove' in call.args for call in run.call_args_list))
self.assertFalse(any('trust' in call.args for call in run.call_args_list))
def test_bond_request_keeps_existing_bond(self):
def fake(*args, **kwargs):
return subprocess.CompletedProcess(args,0,stdout='Paired: yes\nBonded: yes')
with patch.object(m,'root'),patch.object(m,'run',side_effect=fake) as run:
m.pair('AA:BB:CC:DD:EE:FF',bond=True)
self.assertFalse(any('pair' in call.args for call in run.call_args_list))
def test_reconfigure_does_not_require_headphone_to_be_on(self):
c=self.config(output='bluetooth',bluetooth_device='AA:BB:CC:DD:EE:FF')
with patch.object(m,'root'), patch.object(m,'run') as run, patch.object(m,'load',return_value=c), \
patch.object(m,'snapshot',return_value=Path('/mock')), patch.object(m,'save'), \
patch.object(m,'active',return_value=True),patch.object(m,'enabled',return_value=True):
m.apply(c,reconfigure=True)
self.assertFalse(any('connect' in call.args for call in run.call_args_list))
def test_failed_service_restores_configuration(self):
c=self.config()
with patch.object(m, 'root'), patch.object(m, 'run') as run, \
patch.object(m, 'snapshot', return_value=Path('/mock-backup')), \
patch.object(m, 'restore_files') as restore, \
patch.object(m, 'save'), patch.object(m, 'check_dac'), patch.object(m, 'active', return_value=False), \
patch.object(m, 'enabled', return_value=False):
with self.assertRaises(ValueError): m.apply(c)
restore.assert_called_once_with(Path('/mock-backup'))
self.assertTrue(any(c.args[:2]==('systemctl','daemon-reload') for c in run.call_args_list))
if __name__=='__main__': unittest.main()