Added helper functions alongside their unit tests
This commit is contained in:
3
main.py
3
main.py
@@ -8,8 +8,7 @@ Usage:
|
||||
python main.py audio test [--device ID] [--duration N] [--save]
|
||||
python main.py audio config
|
||||
"""
|
||||
|
||||
from src.cli import app
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
app()
|
||||
|
||||
@@ -12,6 +12,7 @@ server_app.command("check")(server.check)
|
||||
audio_app.command("list")(audio.list_devices)
|
||||
audio_app.command("test")(audio.test_device)
|
||||
audio_app.command("config")(audio.config_info)
|
||||
audio_app.command("install")(audio.install)
|
||||
|
||||
# Register subcommands
|
||||
app.add_typer(server_app, name="server")
|
||||
|
||||
@@ -1,79 +1,14 @@
|
||||
import sounddevice as sd
|
||||
import subprocess
|
||||
import typer
|
||||
import numpy as np
|
||||
import os
|
||||
import json
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, List
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from ..utils import SortConf, sort_by, FilterConf, filter_by
|
||||
from ..devices import get_audio_devices_windows, install_audio_cmdlets
|
||||
from ..core.utils import *
|
||||
from ..config import AppConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class WindowsAudioDevice:
|
||||
"""Windows audio device data structure."""
|
||||
name: str
|
||||
status: str
|
||||
device_type: str # "Input", "Output", "Unknown"
|
||||
instance_id: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class LinuxAudioDevice:
|
||||
"""Linux audio device data structure."""
|
||||
device_id: int
|
||||
name: str
|
||||
max_input_channels: int
|
||||
max_output_channels: int
|
||||
default_samplerate: float
|
||||
hostapi_name: str
|
||||
|
||||
|
||||
def list_devices(
|
||||
device: Optional[int] = typer.Argument(default=None, help="Show details of a device, by its Id"),
|
||||
pulse: bool = typer.Option(False, "--pulse", help="Show PulseAudio sources"),
|
||||
native: bool = typer.Option(False, "--native", help="Force native Linux view (WSL only)"),
|
||||
sort_by: Optional[str] = typer.Option(None, "--sort", help="Sort by column"),
|
||||
desc: bool = typer.Option(False, "--desc", help="Set the sorting order to descending"),
|
||||
filter_by: Optional[str] = typer.Option(None, "--filter", help="Filter by a column value. Use column=value"),
|
||||
config_file: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file")
|
||||
):
|
||||
"""List audio devices."""
|
||||
|
||||
# Load configuration for context
|
||||
config = AppConfig.load(config_file)
|
||||
|
||||
# Determine title based on environment and options
|
||||
is_wsl = detect_wsl()
|
||||
use_windows = is_wsl and not native
|
||||
sort_config = SortConf(sort_by, not desc) if sort_by else None
|
||||
filter_config = FilterConf.new(filter_by) if filter_by else None
|
||||
if use_windows:
|
||||
typer.echo(typer.style("WSL detected, showing Windows audio devices (use --native for WSL view)",
|
||||
fg=typer.colors.BLUE))
|
||||
windows_devices = get_windows_audio_devices()
|
||||
display_windows_devices(windows_devices, sort_config, filter_config)
|
||||
|
||||
else:
|
||||
linux_devices = get_linux_audio_devices()
|
||||
display_linux_devices(linux_devices, config, pulse, sort_config)
|
||||
|
||||
# Show legend
|
||||
typer.echo(f"\nLegend:")
|
||||
if use_windows:
|
||||
typer.echo(typer.style(" ✓ = Device working", fg=typer.colors.GREEN))
|
||||
typer.echo(typer.style(" ✗ = Device problem", fg=typer.colors.RED))
|
||||
else:
|
||||
typer.echo(typer.style(" * = Configured device", fg=typer.colors.YELLOW))
|
||||
typer.echo(f" IN/OUT = Input/Output channel count")
|
||||
|
||||
|
||||
def detect_wsl() -> bool:
|
||||
def _detect_wsl() -> bool:
|
||||
"""Detect if running under Windows Subsystem for Linux."""
|
||||
try:
|
||||
# Method 1: Check /proc/version for Microsoft
|
||||
@@ -99,365 +34,7 @@ def detect_wsl() -> bool:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def get_windows_audio_devices_com() -> List[WindowsAudioDevice]:
|
||||
"""
|
||||
Retrieve Windows audio devices using Core Audio (COM) via an inline C# helper compiled by PowerShell.
|
||||
Returns a list of WindowsAudioDevice with accurate device_type ('Input' or 'Output') based on DataFlow.
|
||||
"""
|
||||
ps_script = r"""
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;
|
||||
|
||||
$code = @"
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Collections.Generic;
|
||||
|
||||
[ComImport]
|
||||
[Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")]
|
||||
class MMDeviceEnumerator {}
|
||||
|
||||
[Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
interface IMMDeviceEnumerator {
|
||||
int EnumAudioEndpoints(EDataFlow dataFlow, int dwStateMask, out IMMDeviceCollection ppDevices);
|
||||
// Other methods not used are omitted
|
||||
}
|
||||
|
||||
enum EDataFlow { eRender = 0, eCapture = 1, eAll = 2 }
|
||||
|
||||
[Flags]
|
||||
enum DEVICE_STATE : uint { ACTIVE = 0x1, DISABLED = 0x2, NOTPRESENT = 0x4, UNPLUGGED = 0x8, ALL = 0xF }
|
||||
|
||||
[Guid("0BD7A1BE-7A1A-44DB-8397-C0A794AF5630"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
interface IMMDeviceCollection {
|
||||
int GetCount(out int pcDevices);
|
||||
int Item(int nDevice, out IMMDevice ppDevice);
|
||||
}
|
||||
|
||||
[Guid("D666063F-1587-4E43-81F1-B948E807363F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
interface IMMDevice {
|
||||
int Activate(ref Guid iid, int dwClsCtx, IntPtr pActivationParams, out IntPtr ppInterface);
|
||||
int OpenPropertyStore(int stgmAccess, out IPropertyStore ppProperties);
|
||||
int GetId(out string ppstrId);
|
||||
int GetState(out int pdwState);
|
||||
}
|
||||
|
||||
[Guid("886d8eeb-8cf2-4446-8d02-cdba1dbdcf99"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
interface IPropertyStore {
|
||||
int GetCount(out int cProps);
|
||||
int GetAt(int iProp, out PROPERTYKEY pkey);
|
||||
int GetValue(ref PROPERTYKEY key, out PROPVARIANT pv);
|
||||
int SetValue(ref PROPERTYKEY key, ref PROPVARIANT propvar);
|
||||
int Commit();
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
struct PROPERTYKEY {
|
||||
public Guid fmtid;
|
||||
public int pid;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Explicit)]
|
||||
struct PROPVARIANT {
|
||||
[FieldOffset(0)] public ushort vt;
|
||||
[FieldOffset(8)] public IntPtr pointerValue;
|
||||
}
|
||||
|
||||
static class PKEY {
|
||||
// PKEY_Device_FriendlyName
|
||||
public static PROPERTYKEY Device_FriendlyName = new PROPERTYKEY {
|
||||
fmtid = new Guid("A45C254E-DF1C-4EFD-8020-67D146A850E0"), pid = 14
|
||||
};
|
||||
}
|
||||
|
||||
public class DeviceInfo {
|
||||
public string Name { get; set; }
|
||||
public string Id { get; set; }
|
||||
public string Type { get; set; } // "Input" or "Output"
|
||||
public string Status { get; set; } // "OK", "Disabled", "Unplugged", "NotPresent", "Unknown"
|
||||
}
|
||||
|
||||
public static class CoreAudioEnumerator {
|
||||
[DllImport("ole32.dll")]
|
||||
private static extern int PropVariantClear(ref PROPVARIANT pvar);
|
||||
|
||||
private static string ReadString(IPropertyStore store, PROPERTYKEY key) {
|
||||
PROPVARIANT pv = new PROPVARIANT();
|
||||
try {
|
||||
store.GetValue(ref key, out pv);
|
||||
// VT_LPWSTR = 31
|
||||
if (pv.vt == 31) {
|
||||
return Marshal.PtrToStringUni(pv.pointerValue);
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
PropVariantClear(ref pv);
|
||||
}
|
||||
}
|
||||
|
||||
public static List<DeviceInfo> GetAll() {
|
||||
var result = new List<DeviceInfo>();
|
||||
var enumerator = (IMMDeviceEnumerator)new MMDeviceEnumerator();
|
||||
|
||||
foreach (EDataFlow flow in new[] { EDataFlow.eCapture, EDataFlow.eRender }) {
|
||||
IMMDeviceCollection coll;
|
||||
enumerator.EnumAudioEndpoints(flow, (int)DEVICE_STATE.ALL, out coll);
|
||||
|
||||
int count;
|
||||
coll.GetCount(out count);
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
IMMDevice dev;
|
||||
coll.Item(i, out dev);
|
||||
|
||||
string id;
|
||||
dev.GetId(out id);
|
||||
|
||||
int state;
|
||||
dev.GetState(out state);
|
||||
|
||||
IPropertyStore store;
|
||||
dev.OpenPropertyStore(0 /* STGM_READ */, out store);
|
||||
string name = ReadString(store, PKEY.Device_FriendlyName) ?? "(unknown)";
|
||||
|
||||
string type = flow == EDataFlow.eCapture ? "Input" : "Output";
|
||||
|
||||
string status =
|
||||
(state & (int)DEVICE_STATE.ACTIVE) != 0 ? "OK" :
|
||||
(state & (int)DEVICE_STATE.DISABLED) != 0 ? "Disabled" :
|
||||
(state & (int)DEVICE_STATE.UNPLUGGED) != 0 ? "Unplugged" :
|
||||
(state & (int)DEVICE_STATE.NOTPRESENT) != 0 ? "NotPresent" :
|
||||
"Unknown";
|
||||
|
||||
result.Add(new DeviceInfo { Name = name, Id = id, Type = type, Status = status });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
"@
|
||||
|
||||
Add-Type -TypeDefinition $code -Language CSharp -ErrorAction Stop
|
||||
|
||||
$devices = [CoreAudioEnumerator]::GetAll() | ForEach-Object {
|
||||
[PSCustomObject]@{
|
||||
FriendlyName = $_.Name
|
||||
InstanceId = $_.Id
|
||||
Type = $_.Type
|
||||
Status = $_.Status
|
||||
}
|
||||
}
|
||||
|
||||
$devices | ConvertTo-Json
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
ps_script,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
err = result.stderr.strip() if result.stderr else f"exit code {result.returncode}"
|
||||
typer.echo(typer.style(f"PowerShell (CoreAudio) command failed: {err}", fg=typer.colors.RED))
|
||||
return []
|
||||
|
||||
output = result.stdout.strip()
|
||||
if not output:
|
||||
typer.echo(typer.style("PowerShell (CoreAudio) returned empty output", fg=typer.colors.YELLOW))
|
||||
return []
|
||||
|
||||
data = json.loads(output)
|
||||
items = data if isinstance(data, list) else [data]
|
||||
|
||||
devices: List[WindowsAudioDevice] = []
|
||||
for it in items:
|
||||
name = (it.get("FriendlyName") or "").strip()
|
||||
instance_id = it.get("InstanceId") or ""
|
||||
dev_type = it.get("Type") or "Unknown"
|
||||
status = it.get("Status") or "Unknown"
|
||||
|
||||
if not name:
|
||||
continue
|
||||
|
||||
devices.append(
|
||||
WindowsAudioDevice(
|
||||
name=name,
|
||||
status=status,
|
||||
device_type=dev_type,
|
||||
instance_id=instance_id,
|
||||
)
|
||||
)
|
||||
|
||||
return devices
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
typer.echo(typer.style(f"Failed to parse CoreAudio JSON output: {e}", fg=typer.colors.RED))
|
||||
return []
|
||||
except subprocess.TimeoutExpired:
|
||||
typer.echo(typer.style("PowerShell (CoreAudio) command timed out after 30 seconds", fg=typer.colors.RED))
|
||||
return []
|
||||
except Exception as e:
|
||||
typer.echo(typer.style(f"Unexpected error calling CoreAudio via PowerShell: {type(e).__name__}: {e}",
|
||||
fg=typer.colors.RED))
|
||||
return []
|
||||
|
||||
|
||||
def get_windows_default_devices() -> Dict[str, str]:
|
||||
"""Get Windows default audio devices via PowerShell."""
|
||||
ps_script = r"""
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;
|
||||
|
||||
$code = @"
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
[ComImport]
|
||||
[Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")]
|
||||
class MMDeviceEnumerator {}
|
||||
|
||||
[Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
interface IMMDeviceEnumerator {
|
||||
int GetDefaultAudioEndpoint(EDataFlow dataFlow, ERole role, out IMMDevice ppEndpoint);
|
||||
}
|
||||
|
||||
enum EDataFlow { eRender = 0, eCapture = 1 }
|
||||
enum ERole { eConsole = 0, eMultimedia = 1, eCommunications = 2 }
|
||||
|
||||
[Guid("D666063F-1587-4E43-81F1-B948E807363F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
interface IMMDevice {
|
||||
int GetId(out string ppstrId);
|
||||
}
|
||||
|
||||
public static class DefaultDeviceHelper {
|
||||
public static string GetDefaultInputDevice() {
|
||||
try {
|
||||
var enumerator = (IMMDeviceEnumerator)new MMDeviceEnumerator();
|
||||
IMMDevice device;
|
||||
enumerator.GetDefaultAudioEndpoint(EDataFlow.eCapture, ERole.eConsole, out device);
|
||||
string id;
|
||||
device.GetId(out id);
|
||||
return id;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
public static string GetDefaultOutputDevice() {
|
||||
try {
|
||||
var enumerator = (IMMDeviceEnumerator)new MMDeviceEnumerator();
|
||||
IMMDevice device;
|
||||
enumerator.GetDefaultAudioEndpoint(EDataFlow.eRender, ERole.eConsole, out device);
|
||||
string id;
|
||||
device.GetId(out id);
|
||||
return id;
|
||||
} catch { return null; }
|
||||
}
|
||||
}
|
||||
"@
|
||||
|
||||
Add-Type -TypeDefinition $code -Language CSharp -ErrorAction Stop
|
||||
|
||||
$defaultInput = [DefaultDeviceHelper]::GetDefaultInputDevice()
|
||||
$defaultOutput = [DefaultDeviceHelper]::GetDefaultOutputDevice()
|
||||
|
||||
[PSCustomObject]@{
|
||||
DefaultInput = $defaultInput
|
||||
DefaultOutput = $defaultOutput
|
||||
} | ConvertTo-Json
|
||||
"""
|
||||
|
||||
try:
|
||||
result = subprocess.run([
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
ps_script,
|
||||
], capture_output=True, text=True, encoding="utf-8", timeout=15)
|
||||
|
||||
if result.returncode != 0:
|
||||
return {"default_input": None, "default_output": None}
|
||||
|
||||
if not result.stdout.strip():
|
||||
return {"default_input": None, "default_output": None}
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
return {
|
||||
"default_input": data.get("DefaultInput"),
|
||||
"default_output": data.get("DefaultOutput")
|
||||
}
|
||||
|
||||
except Exception:
|
||||
return {"default_input": None, "default_output": None}
|
||||
|
||||
|
||||
def get_windows_audio_devices() -> List[WindowsAudioDevice]:
|
||||
"""Get detailed Windows audio devices via PowerShell from WSL."""
|
||||
try:
|
||||
result = subprocess.run([
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
# Force UTF-8 output from PowerShell before running the command
|
||||
"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; "
|
||||
"Get-PnpDevice -Class AudioEndpoint | "
|
||||
"Select-Object FriendlyName, Status, InstanceId | ConvertTo-Json"
|
||||
], capture_output=True, text=True, encoding="utf-8", timeout=15)
|
||||
|
||||
if result.returncode != 0:
|
||||
error_msg = result.stderr.strip() if result.stderr else f"PowerShell exit code: {result.returncode}"
|
||||
typer.echo(typer.style(f"PowerShell command failed: {error_msg}", fg=typer.colors.RED))
|
||||
return []
|
||||
|
||||
if not result.stdout.strip():
|
||||
typer.echo(typer.style("PowerShell returned empty output", fg=typer.colors.YELLOW))
|
||||
return []
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
devices = data if isinstance(data, list) else [data]
|
||||
|
||||
windows_devices = []
|
||||
for device in devices:
|
||||
name = device.get('FriendlyName', '').strip()
|
||||
status = device.get('Status', 'Unknown')
|
||||
instance_id = device.get('InstanceId', '')
|
||||
|
||||
if not name:
|
||||
continue
|
||||
|
||||
# Determine device type from InstanceId
|
||||
device_type = _categorize_device_type(instance_id)
|
||||
|
||||
windows_devices.append(WindowsAudioDevice(
|
||||
name=name,
|
||||
status=status,
|
||||
device_type=device_type,
|
||||
instance_id=instance_id
|
||||
))
|
||||
|
||||
return windows_devices
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
typer.echo(typer.style(f"Failed to parse PowerShell JSON output: {e}", fg=typer.colors.RED))
|
||||
return []
|
||||
except subprocess.TimeoutExpired:
|
||||
typer.echo(typer.style("PowerShell command timed out after 15 seconds", fg=typer.colors.RED))
|
||||
return []
|
||||
except Exception as e:
|
||||
typer.echo(typer.style(f"Unexpected error calling PowerShell: {type(e).__name__}: {e}", fg=typer.colors.RED))
|
||||
return []
|
||||
|
||||
|
||||
def _categorize_device_type(instance_id: str) -> str:
|
||||
def _get_device_type(instance_id: str) -> str:
|
||||
"""Categorize device type based on InstanceId."""
|
||||
instance_lower = instance_id.lower()
|
||||
if '0.0.1.00000000' in instance_lower:
|
||||
@@ -467,8 +44,69 @@ def _categorize_device_type(instance_id: str) -> str:
|
||||
else:
|
||||
return "Unknown"
|
||||
|
||||
def list_devices(
|
||||
device: Optional[int] = typer.Argument(default=None, help="Show details of a device, by its Id"),
|
||||
pulse: bool = typer.Option(False, "--pulse", help="Show PulseAudio sources"),
|
||||
native: bool = typer.Option(False, "--native", help="Force native Linux view (WSL only)"),
|
||||
sort_info: Optional[str] = typer.Option(None, "--sort", help="Sort by column"),
|
||||
desc_info: bool = typer.Option(False, "--desc", help="Set the sorting order to descending"),
|
||||
filter_info: Optional[str] = typer.Option(None, "--filter", help="Filter by a column value. Use column=value"),
|
||||
config_file: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file")
|
||||
):
|
||||
"""List audio devices."""
|
||||
|
||||
def get_linux_audio_devices() -> List[LinuxAudioDevice]:
|
||||
# Load configuration for context
|
||||
config = AppConfig.load(config_file)
|
||||
|
||||
# Determine title based on environment and options
|
||||
is_wsl = _detect_wsl()
|
||||
use_windows = is_wsl and not native
|
||||
if use_windows:
|
||||
typer.echo(typer.style("WSL detected, showing Windows audio devices (use --native for WSL view)",
|
||||
fg=typer.colors.BLUE))
|
||||
result = get_audio_devices_windows()
|
||||
if not check_result(result):
|
||||
return
|
||||
|
||||
windows_devices = get_results(result.stdout, {"Index": '', "ID": '', 'Name': '', 'Type': '', 'Default': ''})
|
||||
|
||||
# apply sorting and filtering
|
||||
windows_devices = filter_and_sort(windows_devices, filter_info, sort_info, desc_info)
|
||||
|
||||
display_as_table(windows_devices, [
|
||||
{"name": "Index"},
|
||||
{"name": "ID"},
|
||||
{"name": "Name"},
|
||||
{"name": "Type"},
|
||||
{"name": "Default"},
|
||||
])
|
||||
|
||||
|
||||
else:
|
||||
linux_devices = get_linux_audio_devices()
|
||||
display_linux_devices(linux_devices, config, pulse, sort_config)
|
||||
|
||||
# Show legend
|
||||
typer.echo(f"\nLegend:")
|
||||
if use_windows:
|
||||
typer.echo(typer.style(" ✓ = Device working", fg=typer.colors.GREEN))
|
||||
typer.echo(typer.style(" ✗ = Device problem", fg=typer.colors.RED))
|
||||
else:
|
||||
typer.echo(typer.style(" * = Configured device", fg=typer.colors.YELLOW))
|
||||
typer.echo(f" IN/OUT = Input/Output channel count")
|
||||
|
||||
|
||||
def install():
|
||||
result = install_audio_cmdlets()
|
||||
if not check_result(result):
|
||||
return
|
||||
|
||||
typer.echo(result.stdout)
|
||||
|
||||
|
||||
|
||||
|
||||
def get_linux_audio_devices() -> list:
|
||||
"""Get Linux audio devices using sounddevice."""
|
||||
try:
|
||||
devices = sd.query_devices()
|
||||
@@ -492,87 +130,7 @@ def get_linux_audio_devices() -> List[LinuxAudioDevice]:
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def display_windows_devices(devices: List[WindowsAudioDevice],
|
||||
sort_conf: Optional[SortConf] = None,
|
||||
filter_conf: Optional[FilterConf] = None
|
||||
) -> None:
|
||||
"""Display Windows audio devices with optimized formatting."""
|
||||
|
||||
def _get_id(_instance_id):
|
||||
return _instance_id[31:39]
|
||||
|
||||
if not devices:
|
||||
typer.echo(typer.style("No Windows audio devices found!", fg=typer.colors.RED))
|
||||
return
|
||||
|
||||
# Get default devices
|
||||
default_devices = get_windows_default_devices()
|
||||
default_input_id = default_devices.get("default_input")
|
||||
default_output_id = default_devices.get("default_output")
|
||||
|
||||
if not devices:
|
||||
typer.echo(typer.style("No audio devices found!", fg=typer.colors.RED))
|
||||
|
||||
# Create rich table
|
||||
table = Table(show_header=True)
|
||||
table.add_column("ID", width=8, no_wrap=True, overflow="ellipsis")
|
||||
table.add_column("Name", width=55, no_wrap=True, overflow="ellipsis")
|
||||
table.add_column("Type", width=8, justify="center")
|
||||
table.add_column("Status", width=3, justify="center")
|
||||
table.add_column("*", width=2, justify="center")
|
||||
|
||||
# Add devices to table
|
||||
rows = [] # list of dict
|
||||
for device in devices:
|
||||
# Determine if device is selected (default)
|
||||
is_selected = False
|
||||
if device.device_type == "Input" and device.instance_id == default_input_id:
|
||||
is_selected = True
|
||||
elif device.device_type == "Output" and device.instance_id == default_output_id:
|
||||
is_selected = True
|
||||
|
||||
selected_marker = "*" if is_selected else ""
|
||||
|
||||
# Color coding for status
|
||||
if device.status == "OK":
|
||||
status_text = f"[green]{device.status}[/green]"
|
||||
else:
|
||||
status_text = f"[red]{device.status}[/red]"
|
||||
|
||||
# Style selected row
|
||||
if is_selected:
|
||||
row_style = "bold"
|
||||
id_text = f"[bold]{_get_id(device.instance_id)}[/bold]"
|
||||
name_text = f"[bold]{device.name}[/bold]"
|
||||
type_text = f"[bold]{device.device_type}[/bold]"
|
||||
selected_text = f"[bold yellow]{selected_marker}[/bold yellow]"
|
||||
else:
|
||||
row_style = None
|
||||
id_text = _get_id(device.instance_id)
|
||||
name_text = device.name
|
||||
type_text = device.device_type
|
||||
selected_text = selected_marker
|
||||
|
||||
rows.append({
|
||||
"id": id_text,
|
||||
"name": name_text,
|
||||
"type": type_text,
|
||||
"status": status_text,
|
||||
"selected": selected_text
|
||||
})
|
||||
|
||||
rows = sort_by(rows, sort_conf)
|
||||
rows = filter_by(rows, filter_conf)
|
||||
|
||||
for row in rows:
|
||||
table.add_row(*row.values())
|
||||
|
||||
# Display table
|
||||
console = Console()
|
||||
console.print(table)
|
||||
|
||||
|
||||
def display_linux_devices(devices: List[LinuxAudioDevice],
|
||||
def display_linux_devices(devices: list,
|
||||
config: AppConfig,
|
||||
show_pulse: bool = False,
|
||||
sort_conf: Optional[SortConf] = None) -> None:
|
||||
@@ -625,14 +183,14 @@ def display_linux_devices(devices: List[LinuxAudioDevice],
|
||||
|
||||
|
||||
# Legacy function for backwards compatibility
|
||||
def get_audio_devices():
|
||||
"""Get comprehensive information about audio devices."""
|
||||
try:
|
||||
devices = sd.query_devices()
|
||||
return devices
|
||||
except Exception as e:
|
||||
typer.echo(typer.style(f"Error querying audio devices: {e}", fg=typer.colors.RED, bold=True))
|
||||
raise typer.Exit(1)
|
||||
# def get_audio_devices():
|
||||
# """Get comprehensive information about audio devices."""
|
||||
# try:
|
||||
# devices = sd.query_devices()
|
||||
# return devices
|
||||
# except Exception as e:
|
||||
# typer.echo(typer.style(f"Error querying audio devices: {e}", fg=typer.colors.RED, bold=True))
|
||||
# raise typer.Exit(1)
|
||||
|
||||
|
||||
def get_pulseaudio_sources():
|
||||
|
||||
104
src/core/Expando.py
Normal file
104
src/core/Expando.py
Normal file
@@ -0,0 +1,104 @@
|
||||
NO_DEFAULT = object()
|
||||
|
||||
|
||||
class Expando:
|
||||
"""
|
||||
Readonly dynamic class that eases the access to attributes and sub attributes
|
||||
It is initialized with a dict
|
||||
You can then access the property using dot '.' (ex. obj.prop1.prop2)
|
||||
"""
|
||||
|
||||
def __init__(self, props):
|
||||
self._props = props
|
||||
|
||||
def __getattr__(self, item):
|
||||
if item not in self._props:
|
||||
raise AttributeError(item)
|
||||
|
||||
current = self._props[item]
|
||||
return Expando(current) if isinstance(current, dict) else current
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
self._props[key] = value
|
||||
|
||||
def get(self, path):
|
||||
"""
|
||||
returns the value, from a string with represents the path
|
||||
:param path:
|
||||
:return:
|
||||
"""
|
||||
current = self._props
|
||||
for attr in path.split("."):
|
||||
if isinstance(current, list):
|
||||
temp = []
|
||||
for value in current:
|
||||
if value and attr in value:
|
||||
temp.append(value[attr])
|
||||
current = temp
|
||||
|
||||
else:
|
||||
if current is None or attr not in current:
|
||||
return None
|
||||
current = current[attr]
|
||||
|
||||
return current
|
||||
|
||||
def geti(self, item, default=NO_DEFAULT):
|
||||
"""
|
||||
insensitive get
|
||||
:param item:
|
||||
:param default:
|
||||
:return:
|
||||
"""
|
||||
if item in self._props:
|
||||
return self._props[item]
|
||||
|
||||
for k, v in self._props.items():
|
||||
if k.lower() == item.lower():
|
||||
return v
|
||||
|
||||
if default is not NO_DEFAULT:
|
||||
return default
|
||||
|
||||
raise AttributeError(f"Item '{item}' not found")
|
||||
|
||||
def as_dict(self):
|
||||
"""
|
||||
Return the information as a dictionary
|
||||
:return:
|
||||
"""
|
||||
return self._props.copy()
|
||||
|
||||
def to_dict(self, mappings: dict) -> dict:
|
||||
"""
|
||||
Return the information as a dictionary, with the given mappings
|
||||
"""
|
||||
return {prop_name: self.get(path) for path, prop_name in mappings.items() if prop_name is not None}
|
||||
|
||||
def __hasattr__(self, item):
|
||||
return item in self._props
|
||||
|
||||
def values(self):
|
||||
return self._props.values()
|
||||
|
||||
def copy(self):
|
||||
return Expando(self._props.copy())
|
||||
|
||||
def __repr__(self):
|
||||
if "key" in self._props:
|
||||
return f"Expando(key={self._props["key"]})"
|
||||
|
||||
props_as_str = str(self._props)
|
||||
if len(props_as_str) > 50:
|
||||
props_as_str = props_as_str[:50] + "..."
|
||||
|
||||
return f"Expando({props_as_str})"
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, Expando):
|
||||
return False
|
||||
|
||||
return self._props == other._props
|
||||
|
||||
def __hash__(self):
|
||||
return hash(tuple(sorted(self._props.items())))
|
||||
0
src/core/__init__.py
Normal file
0
src/core/__init__.py
Normal file
221
src/core/utils.py
Normal file
221
src/core/utils.py
Normal file
@@ -0,0 +1,221 @@
|
||||
import json
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from subprocess import CompletedProcess
|
||||
from typing import Any
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from .Expando import Expando
|
||||
|
||||
|
||||
@dataclass
|
||||
class SortConf:
|
||||
property: str
|
||||
ascending: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class FilterConf:
|
||||
property: str
|
||||
value: str
|
||||
|
||||
@staticmethod
|
||||
def new(text):
|
||||
"""Initialize FilterConf from text."""
|
||||
if text is None:
|
||||
return None
|
||||
|
||||
parts = text.split("=")
|
||||
if len(parts) != 2:
|
||||
return None
|
||||
|
||||
if not parts[0].strip() or not parts[1].strip():
|
||||
return None
|
||||
|
||||
return FilterConf(parts[0].strip(), parts[1].strip())
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessResult:
|
||||
result: Any
|
||||
error: str = None
|
||||
|
||||
|
||||
def sort_by(items: list[Expando], sort_conf: SortConf):
|
||||
"""Sort a list of items by a given property."""
|
||||
|
||||
def _safe_sort_key(item):
|
||||
"""Get sort key with safe handling of None values."""
|
||||
value = item.geti(property_name, "")
|
||||
# Convert None to empty string for consistent sorting
|
||||
return "" if value is None else str(value)
|
||||
|
||||
|
||||
if sort_conf is None:
|
||||
return items
|
||||
|
||||
if len(items) == 0:
|
||||
return items
|
||||
|
||||
if sort_conf.property is None:
|
||||
return items
|
||||
|
||||
property_name = sort_conf.property.strip()
|
||||
return sorted(items, key=_safe_sort_key, reverse=not sort_conf.ascending)
|
||||
|
||||
|
||||
def filter_by(items, filter_conf: FilterConf):
|
||||
"""Filter a list of items by a given property."""
|
||||
|
||||
def _contains(item, value):
|
||||
return str(value).lower() in str(item).lower()
|
||||
|
||||
if (filter_conf is None
|
||||
or filter_conf.property is None
|
||||
or len(items) == 0):
|
||||
return items
|
||||
|
||||
predicate = _contains
|
||||
property_name = filter_conf.property.strip()
|
||||
|
||||
return [
|
||||
item for item in items if predicate(item.geti(property_name), filter_conf.value)
|
||||
]
|
||||
|
||||
|
||||
def run_ps_command(command: str) -> CompletedProcess[str]:
|
||||
"""Run a PowerShell command and return the output"""
|
||||
completed = subprocess.run(
|
||||
["powershell.exe",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;",
|
||||
command],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8", timeout=5
|
||||
)
|
||||
|
||||
return completed
|
||||
|
||||
|
||||
def run_ps_command_live(command: str) -> CompletedProcess[str]:
|
||||
"""Run a PowerShell command and display output in real-time"""
|
||||
process = subprocess.Popen(
|
||||
["powershell.exe",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;",
|
||||
command],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
stdout_lines = []
|
||||
stderr_lines = []
|
||||
|
||||
# Read output in real-time
|
||||
while True:
|
||||
stdout_line = process.stdout.readline()
|
||||
stderr_line = process.stderr.readline()
|
||||
|
||||
if stdout_line:
|
||||
print(stdout_line.rstrip())
|
||||
stdout_lines.append(stdout_line)
|
||||
|
||||
if stderr_line:
|
||||
print(f"ERROR: {stderr_line.rstrip()}")
|
||||
stderr_lines.append(stderr_line)
|
||||
|
||||
# Check if process is finished
|
||||
if process.poll() is not None:
|
||||
break
|
||||
|
||||
# Read any remaining lines
|
||||
remaining_stdout, remaining_stderr = process.communicate()
|
||||
if remaining_stdout:
|
||||
print(remaining_stdout.rstrip())
|
||||
stdout_lines.append(remaining_stdout)
|
||||
if remaining_stderr:
|
||||
print(f"ERROR: {remaining_stderr.rstrip()}")
|
||||
stderr_lines.append(remaining_stderr)
|
||||
|
||||
# Create CompletedProcess object to maintain compatibility
|
||||
completed = CompletedProcess(
|
||||
args=["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command],
|
||||
returncode=process.returncode,
|
||||
stdout=''.join(stdout_lines),
|
||||
stderr=''.join(stderr_lines)
|
||||
)
|
||||
|
||||
return completed
|
||||
|
||||
|
||||
def get_results(stdout: str, mappings) -> list[dict]:
|
||||
stripped = stdout.strip()
|
||||
if not stripped:
|
||||
typer.echo(typer.style("PowerShell returned empty output", fg=typer.colors.YELLOW))
|
||||
return []
|
||||
|
||||
data = json.loads(stripped)
|
||||
as_list = data if isinstance(data, list) else [data]
|
||||
|
||||
res = []
|
||||
for item in as_list:
|
||||
mapped = {key: item.get(key, default_value) for key, default_value in mappings.items()}
|
||||
res.append(Expando(mapped))
|
||||
|
||||
return res
|
||||
|
||||
|
||||
def display_as_table(result, columns_settings: list):
|
||||
formatters = {}
|
||||
table = Table(show_header=True)
|
||||
for col in [c for c in columns_settings if "name" in c]:
|
||||
col_name = col["name"]
|
||||
table.add_column(col_name, **col["attrs"] if "attrs" in col else {})
|
||||
formatter = col.pop("formatter", None)
|
||||
if formatter:
|
||||
formatters[col_name] = formatter
|
||||
|
||||
for item in result:
|
||||
cloned = item.copy()
|
||||
# apply formatters
|
||||
for col, formatter in formatters.items():
|
||||
cloned[col] = formatter(item)
|
||||
|
||||
table.add_row(*[str(value) for value in cloned.values()])
|
||||
|
||||
# Display table
|
||||
console = Console()
|
||||
console.print(table)
|
||||
|
||||
|
||||
def check_result(result):
|
||||
if result.returncode != 0:
|
||||
error_msg = result.stderr.strip() if result.stderr else f"PowerShell exit code: {result.returncode}"
|
||||
typer.echo(typer.style(f"PowerShell command failed: {error_msg}", fg=typer.colors.RED))
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def filter_and_sort(items, filter_info, sort_info, desc_info=False):
|
||||
if filter_info:
|
||||
filter_config = FilterConf.new(filter_info) if filter_by else None
|
||||
items = filter_by(items, filter_config)
|
||||
|
||||
if sort_info:
|
||||
sort_config = SortConf(sort_info, not desc_info) if sort_by else None
|
||||
items = sort_by(items, sort_config)
|
||||
|
||||
return items
|
||||
78
src/devices.py
Normal file
78
src/devices.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from subprocess import CompletedProcess
|
||||
import sounddevice as sd
|
||||
|
||||
from .core.Expando import Expando
|
||||
from .core.utils import run_ps_command, run_ps_command_live, ProcessResult
|
||||
|
||||
|
||||
def _is_audio_device_cmdlets_installed():
|
||||
# PowerShell command to check if module is available
|
||||
ps_command = "Get-Module -ListAvailable -Name AudioDeviceCmdlets"
|
||||
|
||||
result = run_ps_command(ps_command)
|
||||
|
||||
# If something is returned in stdout, the module is installed
|
||||
return bool(result.stdout.strip())
|
||||
|
||||
|
||||
def install_audio_cmdlets():
|
||||
"""Install AudioDevice cmdlet for better audio device management"""
|
||||
if _is_audio_device_cmdlets_installed():
|
||||
return CompletedProcess(
|
||||
args=["is_audio_device_cmdlets_installed()"],
|
||||
returncode=0,
|
||||
stdout="AudioDevice cmdlet is already installed",
|
||||
stderr="")
|
||||
|
||||
ps_script = r"""
|
||||
# 1) Trust PSGallery
|
||||
Set-PSRepository -Name "PSGallery" -InstallationPolicy Trusted -ErrorAction SilentlyContinue
|
||||
|
||||
# 2) Ensure NuGet provider is available
|
||||
if (-not (Get-PackageProvider -Name NuGet -ErrorAction SilentlyContinue)) {
|
||||
Install-PackageProvider -Name NuGet -Force -Scope CurrentUser
|
||||
}
|
||||
|
||||
# 3) Install AudioDeviceCmdlets if missing
|
||||
if (-not (Get-Module -ListAvailable -Name AudioDeviceCmdlets)) {
|
||||
Install-Module -Name AudioDeviceCmdlets -Scope CurrentUser -Force -AllowClobber
|
||||
}
|
||||
|
||||
# 4) Import the module
|
||||
Import-Module AudioDeviceCmdlets -ErrorAction SilentlyContinue
|
||||
"""
|
||||
return run_ps_command_live(ps_script)
|
||||
|
||||
|
||||
def get_audio_devices_windows():
|
||||
"""Get all audio devices using AudioDevice cmdlet"""
|
||||
ps_script = "Get-AudioDevice -List | ConvertTo-Json"
|
||||
return run_ps_command(ps_script)
|
||||
|
||||
|
||||
def get_audio_devices_windows_from_pnp():
|
||||
ps_script = "Get-PnpDevice -Class AudioEndpoint | Select-Object FriendlyName, Status, InstanceId | ConvertTo-Json"
|
||||
return run_ps_command(ps_script)
|
||||
|
||||
|
||||
def get_audio_devices_linux():
|
||||
"""Get all audio devices using sounddevice module"""
|
||||
try:
|
||||
devices = sd.query_devices()
|
||||
result = []
|
||||
|
||||
for i, device in enumerate(devices):
|
||||
hostapi_name = sd.query_hostapis(device['hostapi'])['name']
|
||||
|
||||
result.append(Expando({
|
||||
"device_id": i,
|
||||
"name": device['name'],
|
||||
"max_input_channels": device['max_input_channels'],
|
||||
"max_output_channels": device['max_output_channels'],
|
||||
"default_samplerate": device['default_samplerate'],
|
||||
"hostapi_name": hostapi_name}
|
||||
))
|
||||
|
||||
return ProcessResult(result)
|
||||
except Exception as e:
|
||||
ProcessResult(None, f"Error querying Linux audio devices: {e}")
|
||||
66
src/utils.py
66
src/utils.py
@@ -1,66 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class SortConf:
|
||||
property: str
|
||||
ascending: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class FilterConf:
|
||||
property: str
|
||||
value: str
|
||||
|
||||
@staticmethod
|
||||
def new(text):
|
||||
"""Initialize FilterConf from text."""
|
||||
if text is None:
|
||||
return None
|
||||
|
||||
parts = text.split("=")
|
||||
if len(parts) != 2:
|
||||
return None
|
||||
|
||||
return FilterConf(parts[0].strip(), parts[1].strip())
|
||||
|
||||
|
||||
def sort_by(items, sort_conf: SortConf):
|
||||
"""Sort a list of items by a given property."""
|
||||
if sort_conf is None:
|
||||
return items
|
||||
|
||||
if len(items) == 0:
|
||||
return items
|
||||
|
||||
if isinstance(items[0], dict):
|
||||
property_name = sort_conf.property.lower().strip()
|
||||
return sorted(items, key=lambda item: item.get(property_name), reverse=not sort_conf.ascending)
|
||||
else:
|
||||
return sorted(items, key=lambda item: getattr(item, sort_conf.property), reverse=not sort_conf.ascending)
|
||||
|
||||
|
||||
def filter_by(items, filter_conf: FilterConf):
|
||||
"""Filter a list of items by a given property."""
|
||||
|
||||
def _contains(item, value):
|
||||
return str(value).lower() in str(item).lower()
|
||||
|
||||
if (filter_conf is None
|
||||
or filter_conf.property is None
|
||||
or len(items) == 0):
|
||||
return items
|
||||
|
||||
predicate = _contains
|
||||
|
||||
if isinstance(items[0], dict):
|
||||
property_name = filter_conf.property.lower().strip()
|
||||
return [
|
||||
item for item in items if predicate(item.get(property_name), filter_conf.value)
|
||||
]
|
||||
|
||||
else:
|
||||
|
||||
return [
|
||||
item for item in items if getattr(item, filter_conf.property) == filter_conf.value
|
||||
]
|
||||
@@ -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
95
tests/test_expando.py
Normal 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
165
tests/test_filter_by.py
Normal 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
85
tests/test_filter_conf.py
Normal 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
152
tests/test_sort_by.py
Normal 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"
|
||||
Reference in New Issue
Block a user