test_pulse.py 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. """Tests for PulseAudio interface with injectable runner.
  2. Fixtures mirror the ACTUAL shape of `pactl -f json list sources|sinks`:
  3. a top-level JSON array, where monitor sources carry a non-empty
  4. ``monitor_source`` (the sink they mirror) and real inputs carry ``""``.
  5. """
  6. import json
  7. from stt_daemon.pulse import Device, PulseAudio
  8. REAL_SOURCES = [
  9. {
  10. "index": 12,
  11. "name": "alsa_output.pci-0000_05_00.1.hdmi-stereo.monitor",
  12. "description": "Monitor of Radeon HDMI",
  13. "monitor_source": "alsa_output.pci-0000_05_00.1.hdmi-stereo",
  14. },
  15. {
  16. "index": 15,
  17. "name": "alsa_input.usb-C-Media.mono-fallback",
  18. "description": "USB Audio Mono",
  19. "monitor_source": "",
  20. },
  21. ]
  22. REAL_SINKS = [
  23. {
  24. "index": 12,
  25. "name": "alsa_output.pci-0000_05_00.1.hdmi-stereo",
  26. "description": "Radeon HDMI",
  27. "monitor_source": "alsa_output.pci-0000_05_00.1.hdmi-stereo.monitor",
  28. },
  29. {
  30. "index": 13,
  31. "name": "alsa_output.usb-analog-stereo",
  32. "description": "USB Analog",
  33. "monitor_source": "alsa_output.usb-analog-stereo.monitor",
  34. },
  35. ]
  36. def _make_pa(sources=None, sinks=None):
  37. def fake_runner(args):
  38. if "list" in args and "sources" in args:
  39. return json.dumps(sources if sources is not None else [])
  40. if "list" in args and "sinks" in args:
  41. return json.dumps(sinks if sinks is not None else [])
  42. raise RuntimeError(f"Unexpected args: {args}")
  43. return PulseAudio(runner=fake_runner)
  44. def test_list_devices_excludes_monitors_and_uses_sink_monitor_source():
  45. """A .monitor source must NOT be an input; each sink contributes its monitor as output."""
  46. pa = _make_pa(REAL_SOURCES, REAL_SINKS)
  47. devices = pa.list_devices()
  48. inputs = [d for d in devices if d.kind == "input"]
  49. outputs = [d for d in devices if d.kind == "output"]
  50. assert [d.name for d in inputs] == ["alsa_input.usb-C-Media.mono-fallback"]
  51. assert [d.name for d in outputs] == [
  52. "alsa_output.pci-0000_05_00.1.hdmi-stereo.monitor",
  53. "alsa_output.usb-analog-stereo.monitor",
  54. ]
  55. assert outputs[0].description == "Radeon HDMI"
  56. def test_list_devices_exclude():
  57. """Excluded names (our own virtual devices) are omitted."""
  58. pa = _make_pa(REAL_SOURCES, REAL_SINKS)
  59. devices = pa.list_devices(
  60. exclude={
  61. "alsa_input.usb-C-Media.mono-fallback",
  62. "alsa_output.usb-analog-stereo.monitor",
  63. }
  64. )
  65. assert [d.name for d in devices] == [
  66. "alsa_output.pci-0000_05_00.1.hdmi-stereo.monitor"
  67. ]
  68. def test_list_sources_tolerates_dict_wrapped_output():
  69. """Parser must not crash on the legacy object-wrapped form."""
  70. def runner(args):
  71. return json.dumps(
  72. {"sources": [{"name": "in1", "description": "In 1", "monitor_source": ""}]}
  73. )
  74. pa = PulseAudio(runner=runner)
  75. assert [s["name"] for s in pa.list_sources()] == ["in1"]
  76. def test_sink_exists():
  77. """Test sink existence check."""
  78. pa = _make_pa(REAL_SOURCES, REAL_SINKS)
  79. assert pa.sink_exists("alsa_output.usb-analog-stereo")
  80. assert not pa.sink_exists("VirtualMicSink")
  81. def test_source_exists():
  82. """Test source existence check."""
  83. pa = _make_pa(REAL_SOURCES, REAL_SINKS)
  84. assert pa.source_exists("alsa_input.usb-C-Media.mono-fallback")
  85. assert pa.source_exists("alsa_output.pci-0000_05_00.1.hdmi-stereo.monitor")
  86. assert not pa.source_exists("nonexistent")
  87. def test_load_module_parses_id():
  88. """Test that load_module parses the numeric ID from pactl output."""
  89. def fake_runner(args):
  90. if "load-module" in args:
  91. return "42\n"
  92. raise RuntimeError(f"Unexpected args: {args}")
  93. pa = PulseAudio(runner=fake_runner)
  94. assert pa.load_module("module-null-sink", "sink_name=VirtualMicSink") == 42
  95. def test_unload_module_success():
  96. """Test unload_module returns True on success."""
  97. calls = []
  98. def fake_runner(args):
  99. if "unload-module" in args:
  100. calls.append(args)
  101. return ""
  102. raise RuntimeError(f"Unexpected args: {args}")
  103. pa = PulseAudio(runner=fake_runner)
  104. assert pa.unload_module(42) is True
  105. assert len(calls) == 1
  106. def test_unload_module_already_gone_real_message():
  107. """pactl reports a vanished module as 'Failure: No such entity'."""
  108. def fake_runner(args):
  109. if "unload-module" in args:
  110. raise RuntimeError("pactl failed: Failure: No such entity\n")
  111. raise RuntimeError(f"Unexpected args: {args}")
  112. pa = PulseAudio(runner=fake_runner)
  113. assert pa.unload_module(42) is False
  114. def test_short_modules_parsing():
  115. """Test that short_modules parses pactl list short modules output."""
  116. output = "0\tmodule-cli-protocol-tcp\n12\tmodule-null-sink\n13\tmodule-loopback\n"
  117. def fake_runner(args):
  118. if "short" in args and "modules" in args:
  119. return output
  120. raise RuntimeError(f"Unexpected args: {args}")
  121. pa = PulseAudio(runner=fake_runner)
  122. modules = pa.short_modules()
  123. assert modules == [
  124. (0, "module-cli-protocol-tcp"),
  125. (12, "module-null-sink"),
  126. (13, "module-loopback"),
  127. ]
  128. def test_default_sink_name():
  129. """Test default_sink_name returns the sink name."""
  130. def fake_runner(args):
  131. if "get-default-sink" in args:
  132. return "alsa_output.usb-analog-stereo\n"
  133. raise RuntimeError(f"Unexpected args: {args}")
  134. pa = PulseAudio(runner=fake_runner)
  135. assert pa.default_sink_name() == "alsa_output.usb-analog-stereo"
  136. def test_device_dataclass():
  137. """Test Device dataclass."""
  138. device = Device(name="alsa_input.usb-Foo", description="USB Input", kind="input")
  139. assert device.name == "alsa_input.usb-Foo"
  140. assert device.description == "USB Input"
  141. assert device.kind == "input"