Added helper functions alongside their unit tests

This commit is contained in:
2025-09-03 23:25:27 +02:00
parent 343d1d2f93
commit 7aafaa41ed
13 changed files with 978 additions and 993 deletions

View File

@@ -1,407 +0,0 @@
import pytest
from unittest.mock import Mock, patch, mock_open, MagicMock
import json
import subprocess
from pathlib import Path
from src.commands.audio import (
detect_wsl,
get_windows_audio_devices,
get_linux_audio_devices,
_categorize_device_type,
display_windows_devices,
display_linux_devices,
WindowsAudioDevice,
LinuxAudioDevice
)
from src.config import AppConfig, AudioConfig, ServerConfig
# WSL Detection Tests
@patch('pathlib.Path.exists')
@patch('builtins.open', mock_open(read_data='Linux version 5.4.0-microsoft-standard'))
def test_detect_wsl_via_proc_version_microsoft(mock_exists):
"""Test WSL detection via /proc/version containing 'microsoft'."""
mock_exists.return_value = True
result = detect_wsl()
assert result is True
@patch('pathlib.Path.exists')
@patch('builtins.open', mock_open(read_data='Linux version 5.4.0-wsl2-standard'))
def test_detect_wsl_via_proc_version_wsl(mock_exists):
"""Test WSL detection via /proc/version containing 'wsl'."""
mock_exists.return_value = True
result = detect_wsl()
assert result is True
@patch('pathlib.Path.exists')
@patch('builtins.open', mock_open(read_data='Linux version 5.4.0-generic'))
@patch('os.getenv')
def test_detect_wsl_via_env_distro_name(mock_getenv, mock_exists):
"""Test WSL detection via WSL_DISTRO_NAME environment variable."""
mock_exists.return_value = True
mock_getenv.side_effect = lambda key: 'Ubuntu' if key == 'WSL_DISTRO_NAME' else None
result = detect_wsl()
assert result is True
@patch('pathlib.Path.exists')
@patch('builtins.open', mock_open(read_data='Linux version 5.4.0-generic'))
@patch('os.getenv')
def test_detect_wsl_via_env_wslenv(mock_getenv, mock_exists):
"""Test WSL detection via WSLENV environment variable."""
mock_exists.return_value = True
mock_getenv.side_effect = lambda key: 'PATH/l' if key == 'WSLENV' else None
result = detect_wsl()
assert result is True
@patch('pathlib.Path.exists')
@patch('builtins.open', mock_open(read_data='Linux version 5.4.0-generic'))
@patch('os.getenv', return_value=None)
def test_detect_wsl_false_native_linux(mock_getenv, mock_exists):
"""Test WSL detection returns False on native Linux."""
mock_exists.return_value = True
result = detect_wsl()
assert result is False
@patch('pathlib.Path.exists', return_value=False)
@patch('os.getenv', return_value=None)
def test_detect_wsl_false_no_proc_version(mock_getenv, mock_exists):
"""Test WSL detection returns False when /proc/version doesn't exist."""
result = detect_wsl()
assert result is False
@patch('pathlib.Path.exists')
@patch('builtins.open', side_effect=Exception("Permission denied"))
@patch('os.getenv', return_value=None)
def test_detect_wsl_handles_proc_version_read_error(mock_getenv, mock_exists):
"""Test WSL detection handles /proc/version read errors gracefully."""
mock_exists.return_value = True
result = detect_wsl()
assert result is False
# Device Type Categorization Tests
def test_categorize_input_device():
"""Test categorization of input device."""
instance_id = "SWD\\MMDEVAPI\\{0.0.1.00000000}.{a1b2c3d4-e5f6-7890-abcd-ef1234567890}\\capture"
result = _categorize_device_type(instance_id)
assert result == "Input"
def test_categorize_output_device():
"""Test categorization of output device."""
instance_id = "SWD\\MMDEVAPI\\{0.0.0.00000000}.{a1b2c3d4-e5f6-7890-abcd-ef1234567890}\\render"
result = _categorize_device_type(instance_id)
assert result == "Output"
def test_categorize_unknown_device():
"""Test categorization of unknown device type."""
instance_id = "SWD\\MMDEVAPI\\{0.0.2.00000000}.{a1b2c3d4-e5f6-7890-abcd-ef1234567890}\\unknown"
result = _categorize_device_type(instance_id)
assert result == "Unknown"
def test_categorize_empty_instance_id():
"""Test categorization with empty instance ID."""
result = _categorize_device_type("")
assert result == "Unknown"
# Windows Audio Devices Tests
@patch('subprocess.run')
@patch('typer.echo')
def test_get_windows_devices_success_single_device(mock_echo, mock_run):
"""Test successful retrieval of single Windows device."""
mock_result = Mock()
mock_result.returncode = 0
mock_result.stdout = json.dumps({
"FriendlyName": "Blue Yeti Microphone",
"Status": "OK",
"InstanceId": "USB\\VID_0B05&PID_1234\\capture"
})
mock_result.stderr = ""
mock_run.return_value = mock_result
result = get_windows_audio_devices()
assert len(result) == 1
assert result[0].name == "Blue Yeti Microphone"
assert result[0].status == "OK"
assert result[0].device_type == "Input"
assert "USB\\VID_0B05&PID_1234\\capture" in result[0].instance_id
@patch('subprocess.run')
@patch('typer.echo')
def test_get_windows_devices_success_multiple_devices(mock_echo, mock_run):
"""Test successful retrieval of multiple Windows devices."""
mock_result = Mock()
mock_result.returncode = 0
mock_result.stdout = json.dumps([
{
"FriendlyName": "Microphone",
"Status": "OK",
"InstanceId": "capture_device"
},
{
"FriendlyName": "Speakers",
"Status": "OK",
"InstanceId": "render_device"
}
])
mock_result.stderr = ""
mock_run.return_value = mock_result
result = get_windows_audio_devices()
assert len(result) == 2
assert result[0].device_type == "Input"
assert result[1].device_type == "Output"
@patch('subprocess.run')
@patch('typer.echo')
def test_get_windows_devices_powershell_error(mock_echo, mock_run):
"""Test PowerShell command failure."""
mock_result = Mock()
mock_result.returncode = 1
mock_result.stdout = ""
mock_result.stderr = "Get-PnpDevice : Access denied"
mock_run.return_value = mock_result
result = get_windows_audio_devices()
assert result == []
mock_echo.assert_called()
@patch('subprocess.run')
@patch('typer.echo')
def test_get_windows_devices_empty_output(mock_echo, mock_run):
"""Test PowerShell returning empty output."""
mock_result = Mock()
mock_result.returncode = 0
mock_result.stdout = ""
mock_result.stderr = ""
mock_run.return_value = mock_result
result = get_windows_audio_devices()
assert result == []
mock_echo.assert_called()
@patch('subprocess.run')
@patch('typer.echo')
def test_get_windows_devices_invalid_json(mock_echo, mock_run):
"""Test invalid JSON response from PowerShell."""
mock_result = Mock()
mock_result.returncode = 0
mock_result.stdout = "Invalid JSON {"
mock_result.stderr = ""
mock_run.return_value = mock_result
result = get_windows_audio_devices()
assert result == []
mock_echo.assert_called()
@patch('subprocess.run', side_effect=subprocess.TimeoutExpired("powershell.exe", 15))
@patch('typer.echo')
def test_get_windows_devices_timeout(mock_echo, mock_run):
"""Test PowerShell command timeout."""
result = get_windows_audio_devices()
assert result == []
mock_echo.assert_called()
@patch('subprocess.run')
@patch('typer.echo')
def test_get_windows_devices_filters_empty_names(mock_echo, mock_run):
"""Test filtering of devices with empty names."""
mock_result = Mock()
mock_result.returncode = 0
mock_result.stdout = json.dumps([
{
"FriendlyName": "Valid Device",
"Status": "OK",
"InstanceId": "capture_device"
},
{
"FriendlyName": "",
"Status": "OK",
"InstanceId": "empty_name_device"
},
{
"FriendlyName": None,
"Status": "OK",
"InstanceId": "null_name_device"
}
])
mock_result.stderr = ""
mock_run.return_value = mock_result
result = get_windows_audio_devices()
assert len(result) == 1
assert result[0].name == "Valid Device"
# Linux Audio Devices Tests
@patch('sounddevice.query_devices')
@patch('sounddevice.query_hostapis')
@patch('typer.echo')
def test_get_linux_devices_success(mock_echo, mock_hostapis, mock_devices):
"""Test successful retrieval of Linux devices."""
mock_devices.return_value = [
{
'name': 'pulse',
'max_input_channels': 32,
'max_output_channels': 32,
'default_samplerate': 44100.0,
'hostapi': 0
},
{
'name': 'default',
'max_input_channels': 32,
'max_output_channels': 32,
'default_samplerate': 44100.0,
'hostapi': 0
}
]
mock_hostapis.return_value = {'name': 'ALSA'}
result = get_linux_audio_devices()
assert len(result) == 2
assert result[0].name == 'pulse'
assert result[0].device_id == 0
assert result[0].hostapi_name == 'ALSA'
assert result[1].name == 'default'
assert result[1].device_id == 1
@patch('sounddevice.query_devices', side_effect=Exception("ALSA error"))
@patch('typer.echo')
def test_get_linux_devices_sounddevice_error(mock_echo, mock_devices):
"""Test sounddevice query failure."""
with pytest.raises(SystemExit):
get_linux_audio_devices()
# Display Functions Tests
@patch('typer.echo')
def test_display_windows_devices_empty_list(mock_echo):
"""Test display with empty device list."""
display_windows_devices([], True, True)
mock_echo.assert_called()
@patch('typer.echo')
@patch('src.commands.audio._display_single_windows_device')
def test_display_windows_devices_input_only(mock_display_single, mock_echo):
"""Test display showing only input devices."""
devices = [
WindowsAudioDevice('Microphone', 'OK', 'Input', 'mic1'),
WindowsAudioDevice('Speakers', 'OK', 'Output', 'spk1')
]
display_windows_devices(devices, show_inputs=True, show_outputs=False)
mock_display_single.assert_called_once_with(devices[0])
@patch('typer.echo')
@patch('src.commands.audio._display_single_windows_device')
def test_display_windows_devices_with_unknown(mock_display_single, mock_echo):
"""Test display including unknown device types."""
devices = [
WindowsAudioDevice('Microphone', 'OK', 'Input', 'mic1'),
WindowsAudioDevice('Unknown Device', 'OK', 'Unknown', 'unk1')
]
display_windows_devices(devices, show_inputs=True, show_outputs=True)
assert mock_display_single.call_count == 2
@patch('typer.echo')
def test_display_linux_devices_no_matching_devices(mock_echo):
"""Test display with no matching devices."""
devices = [
LinuxAudioDevice(0, 'pulse', 0, 32, 44100.0, 'ALSA')
]
config = AppConfig(ServerConfig(), AudioConfig())
with pytest.raises(SystemExit):
display_linux_devices(devices, show_inputs=True, show_outputs=False, config=config)
@patch('typer.echo')
@patch('sounddevice.default')
def test_display_linux_devices_success(mock_default, mock_echo):
"""Test successful display of Linux devices."""
mock_default.device = [0, 1]
devices = [
LinuxAudioDevice(0, 'pulse', 32, 32, 44100.0, 'ALSA')
]
config = AppConfig(ServerConfig(), AudioConfig())
display_linux_devices(devices, show_inputs=True, show_outputs=True, config=config)
# Verify that echo was called with device information
assert mock_echo.call_count > 0
@patch('typer.echo')
@patch('sounddevice.default')
def test_display_linux_devices_with_configured_device(mock_default, mock_echo):
"""Test display with configured device marked."""
mock_default.device = [0, 1]
devices = [
LinuxAudioDevice(0, 'pulse', 32, 32, 44100.0, 'ALSA'),
LinuxAudioDevice(1, 'default', 32, 32, 44100.0, 'ALSA')
]
config = AppConfig(ServerConfig(), AudioConfig(device=1))
display_linux_devices(devices, show_inputs=True, show_outputs=True, config=config)
# Verify that echo was called and device is marked as configured
assert mock_echo.call_count > 0

95
tests/test_expando.py Normal file
View File

@@ -0,0 +1,95 @@
import pytest
from core.Expando import Expando
def test_i_can_get_properties():
props = {"a": 10,
"b": {
"c": "value",
"d": 20
}}
dynamic = Expando(props)
assert dynamic.a == 10
assert dynamic.b.c == "value"
with pytest.raises(AttributeError):
assert dynamic.unknown == "some_value"
def test_i_can_get():
props = {"a": 10,
"b": {
"c": "value",
"d": 20
}}
dynamic = Expando(props)
assert dynamic.get("a") == 10
assert dynamic.get("b.c") == "value"
assert dynamic.get("unknown") is None
def test_i_can_get_insensitive():
props = {"A": 10,
"b": 20}
dynamic = Expando(props)
assert dynamic.geti("a") == 10
assert dynamic.geti("A") == 10
assert dynamic.geti("a.c", None) is None
with pytest.raises(AttributeError):
assert dynamic.geti("unknown") == "some_value"
def test_i_can_get_insensitive_when_the_two_entries_exist():
props = {"A": 10,
"a": 20}
dynamic = Expando(props)
assert dynamic.geti("A") == 10
assert dynamic.geti("a") == 20
def test_i_can_get_from_list():
props = {"a": [{"c": "value1", "d": 1}, {"c": "value2", "d": 2}]}
dynamic = Expando(props)
assert dynamic.get("a.c") == ["value1", "value2"]
def test_none_is_returned_when_get_from_list_and_property_does_not_exist():
props = {"a": [{"c": "value1", "d": 1},
{"a": "value2", "d": 2} # 'c' does not exist in the second row
]}
dynamic = Expando(props)
assert dynamic.get("a.c") == ["value1"]
def test_i_can_manage_none_values():
props = {"a": 10,
"b": None}
dynamic = Expando(props)
assert dynamic.get("b.c") is None
def test_i_can_manage_none_values_in_list():
props = {"a": [{"b": {"c": "value"}},
{"b": None}
]}
dynamic = Expando(props)
assert dynamic.get("a.b.c") == ["value"]
def test_i_can_add_new_properties():
props = {"a": 10,
"b": 20}
dynamic = Expando(props)
dynamic["c"] = 30
assert dynamic.a == 10
assert dynamic.b == 20
assert dynamic.c == 30

165
tests/test_filter_by.py Normal file
View File

@@ -0,0 +1,165 @@
import pytest
from src.core.utils import FilterConf, filter_by
from src.core.Expando import Expando
def test_i_can_filter_by_matching_string():
"""Test basic filtering with string matching."""
items = [
Expando({"name": "alice"}),
Expando({"name": "bob"}),
Expando({"name": "charlie"})
]
filter_conf = FilterConf(property="name", value="alice")
result = filter_by(items, filter_conf)
assert len(result) == 1
assert result[0].name == "alice"
def test_i_can_filter_by_case_insensitive():
"""Test case-insensitive filtering."""
items = [
Expando({"name": "ALICE"}),
Expando({"name": "Bob"}),
Expando({"name": "charlie"})
]
filter_conf = FilterConf(property="name", value="alice")
result = filter_by(items, filter_conf)
assert len(result) == 1
assert result[0].name == "ALICE"
def test_i_can_filter_by_partial_match():
"""Test partial substring matching."""
items = [
Expando({"name": "alice smith"}),
Expando({"name": "bob jones"}),
Expando({"name": "charlie brown"})
]
filter_conf = FilterConf(property="name", value="smith")
result = filter_by(items, filter_conf)
assert len(result) == 1
assert result[0].name == "alice smith"
def test_i_can_handle_none_filter_conf():
"""Test that None filter configuration returns items unchanged."""
items = [
Expando({"name": "alice"}),
Expando({"name": "bob"})
]
result = filter_by(items, None)
assert result == items
assert len(result) == 2
def test_i_can_handle_empty_list():
"""Test that empty list returns empty list."""
items = []
filter_conf = FilterConf(property="name", value="alice")
result = filter_by(items, filter_conf)
assert result == []
def test_i_can_handle_none_property_in_filter_conf():
"""Test that FilterConf with None property returns items unchanged."""
items = [
Expando({"name": "alice"}),
Expando({"name": "bob"})
]
# Create FilterConf with None property (simulating invalid state)
filter_conf = FilterConf(property=None, value="alice")
result = filter_by(items, filter_conf)
assert result == items
assert len(result) == 2
def test_i_can_filter_with_no_matches():
"""Test filtering when no items match the criteria."""
items = [
Expando({"name": "alice"}),
Expando({"name": "bob"}),
Expando({"name": "charlie"})
]
filter_conf = FilterConf(property="name", value="xyz")
result = filter_by(items, filter_conf)
assert result == []
def test_i_cannot_filter_with_missing_properties():
"""Test that missing properties raise AttributeError."""
items = [
Expando({"name": "alice"}),
Expando({"age": 25}), # no name property
Expando({"name": "bob"})
]
filter_conf = FilterConf(property="name", value="alice")
# Should raise AttributeError when trying to access missing property
with pytest.raises(AttributeError):
filter_by(items, filter_conf)
def test_i_can_filter_with_none_values():
"""Test filtering when properties have None values."""
items = [
Expando({"name": "alice"}),
Expando({"name": None}),
Expando({"name": "bob"})
]
filter_conf = FilterConf(property="name", value="alice")
result = filter_by(items, filter_conf)
# None gets converted to "none" string, shouldn't match "alice"
assert len(result) == 1
assert result[0].name == "alice"
def test_i_can_filter_with_case_insensitive_property_names():
"""Test filtering using case-insensitive property names."""
items = [
Expando({"Name": "alice"}), # uppercase N
Expando({"Name": "bob"}),
Expando({"Name": "charlie"})
]
filter_conf = FilterConf(property="name", value="alice") # lowercase n
result = filter_by(items, filter_conf)
assert len(result) == 1
assert result[0].Name == "alice"
def test_i_can_filter_with_numbers_and_mixed_types():
"""Test filtering with mixed types (numbers, strings)."""
items = [
Expando({"value": "123"}),
Expando({"value": 123}),
Expando({"value": "hello123"}),
Expando({"value": "world"})
]
filter_conf = FilterConf(property="value", value="123")
result = filter_by(items, filter_conf)
# Should match "123", 456 (converted to "456"), and "hello123"
assert len(result) == 3
expected_values = ["123", 123, "hello123"]
actual_values = [item.value for item in result]
assert all(val in expected_values for val in actual_values)

85
tests/test_filter_conf.py Normal file
View File

@@ -0,0 +1,85 @@
from src.core.utils import FilterConf
def test_i_can_create_filter_conf_from_valid_text():
"""Test creating FilterConf from valid property=value format."""
result = FilterConf.new("property=value")
assert result is not None
assert isinstance(result, FilterConf)
assert result.property == "property"
assert result.value == "value"
def test_i_can_handle_none_input():
"""Test that None input returns None."""
result = FilterConf.new(None)
assert result is None
def test_i_can_handle_whitespace_around_equals():
"""Test that whitespace around property and value is stripped."""
result = FilterConf.new(" property = value ")
assert result is not None
assert result.property == "property"
assert result.value == "value"
def test_i_cannot_create_filter_conf_without_equals():
"""Test that text without equals sign returns None."""
result = FilterConf.new("property")
assert result is None
def test_i_cannot_create_filter_conf_with_multiple_equals():
"""Test that text with multiple equals signs returns None."""
result = FilterConf.new("property=value=another")
assert result is None
def test_i_cannot_create_filter_conf_from_empty_string():
"""Test that empty string returns None."""
result = FilterConf.new("")
assert result is None
def test_i_cannot_create_filter_conf_with_empty_property():
"""Test that empty property name returns None."""
result = FilterConf.new("=value")
assert result is None
def test_i_cannot_create_filter_conf_with_empty_value():
"""Test that empty value returns None."""
result = FilterConf.new("property=")
assert result is None
def test_i_cannot_create_filter_conf_with_both_empty():
"""Test that both empty property and value returns None."""
result = FilterConf.new("=")
assert result is None
def test_i_cannot_create_filter_conf_with_whitespace_only():
"""Test that whitespace-only parts return None."""
result = FilterConf.new(" = ")
assert result is None
def test_i_can_handle_special_characters_in_values():
"""Test that special characters in property names and values are handled correctly."""
result = FilterConf.new("device_type=USB Audio Device (2.0)")
assert result is not None
assert result.property == "device_type"
assert result.value == "USB Audio Device (2.0)"

152
tests/test_sort_by.py Normal file
View File

@@ -0,0 +1,152 @@
from src.core.utils import FilterConf, SortConf, sort_by
from src.core.Expando import Expando
def test_i_can_sort_by_ascending_strings():
"""Test sorting strings in ascending order."""
items = [
Expando({"name": "charlie"}),
Expando({"name": "alice"}),
Expando({"name": "bob"})
]
sort_conf = SortConf(property="name", ascending=True)
result = sort_by(items, sort_conf)
assert len(result) == 3
assert result[0].name == "alice"
assert result[1].name == "bob"
assert result[2].name == "charlie"
def test_i_can_sort_by_descending_strings():
"""Test sorting strings in descending order."""
items = [
Expando({"name": "alice"}),
Expando({"name": "charlie"}),
Expando({"name": "bob"})
]
sort_conf = SortConf(property="name", ascending=False)
result = sort_by(items, sort_conf)
assert len(result) == 3
assert result[0].name == "charlie"
assert result[1].name == "bob"
assert result[2].name == "alice"
def test_i_can_handle_none_sort_conf():
"""Test that None sort configuration returns items unchanged."""
items = [
Expando({"name": "charlie"}),
Expando({"name": "alice"})
]
result = sort_by(items, None)
assert result == items
assert result[0].name == "charlie"
assert result[1].name == "alice"
def test_i_can_handle_empty_list():
"""Test that empty list returns empty list."""
items = []
sort_conf = SortConf(property="name")
result = sort_by(items, sort_conf)
assert result == []
def test_i_can_sort_with_missing_properties():
"""Test sorting when some objects don't have the property."""
items = [
Expando({"name": "bob"}),
Expando({"age": 25}), # no name property
Expando({"name": "alice"})
]
sort_conf = SortConf(property="name", ascending=True)
result = sort_by(items, sort_conf)
assert len(result) == 3
# Items without the property should get empty string and sort first
assert result[0].geti("name", "") == "" # the one with missing property
assert result[1].name == "alice"
assert result[2].name == "bob"
def test_i_can_sort_with_none_values():
"""Test sorting when properties have None values."""
items = [
Expando({"name": "bob"}),
Expando({"name": None}),
Expando({"name": "alice"})
]
sort_conf = SortConf(property="name", ascending=True)
result = sort_by(items, sort_conf)
assert len(result) == 3
# None values should be treated as empty strings and sort first
assert result[0].name is None
assert result[1].name == "alice"
assert result[2].name == "bob"
def test_i_can_sort_with_case_insensitive_property_names():
"""Test sorting using case-insensitive property names."""
items = [
Expando({"Name": "charlie"}), # uppercase N
Expando({"Name": "alice"}),
Expando({"Name": "bob"})
]
sort_conf = SortConf(property="name", ascending=True) # lowercase n
result = sort_by(items, sort_conf)
assert len(result) == 3
assert result[0].Name == "alice"
assert result[1].Name == "bob"
assert result[2].Name == "charlie"
def test_i_can_sort_mixed_types():
"""Test sorting with mixed types (numbers, strings, None)."""
items = [
Expando({"value": "zebra"}),
Expando({"value": 10}),
Expando({"value": None}),
Expando({"value": "apple"})
]
sort_conf = SortConf(property="value", ascending=True)
result = sort_by(items, sort_conf)
assert len(result) == 4
# Mixed types will be converted to strings for comparison
# None -> "", numbers -> str(number), strings stay strings
assert result[0].value is None # None becomes "" and sorts first
assert result[1].value == 10 # "10" sorts before "apple"
assert result[2].value == "apple" # "apple" sorts before "zebra"
assert result[3].value == "zebra"
def test_i_can_sort_with_whitespace_in_property_name():
"""Test that property names are stripped of whitespace."""
items = [
Expando({"name": "charlie"}),
Expando({"name": "alice"}),
Expando({"name": "bob"})
]
sort_conf = SortConf(property=" name ", ascending=True) # whitespace around property
result = sort_by(items, sort_conf)
assert len(result) == 3
assert result[0].name == "alice"
assert result[1].name == "bob"
assert result[2].name == "charlie"