test_provisioner.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. """Tests for audio sink provisioner orchestration."""
  2. import pytest
  3. from stt_daemon.config import SinkConfig
  4. from stt_daemon.provisioner import Provisioner
  5. from stt_daemon.pulse import PulseAudio
  6. from stt_daemon.state import AudioState, Loopback, StateStore
  7. def test_provisioner_setup_creates_modules_in_order(tmp_path):
  8. """Test that setup creates modules in the correct order."""
  9. state_file = tmp_path / "audio.json"
  10. store = StateStore(state_file)
  11. cfg = SinkConfig(sink_name="VirtualMicSink", remap_source_name="VirtualMic")
  12. call_sequence = []
  13. def fake_runner(args):
  14. if "load-module" in args:
  15. module_id = len(call_sequence) + 10
  16. call_sequence.append((args[1], module_id))
  17. return f"{module_id}\n"
  18. elif "list" in args and "sinks" in args:
  19. return '{"sinks": []}'
  20. elif "list" in args and "sources" in args:
  21. return '{"sources": []}'
  22. else:
  23. return ""
  24. pa = PulseAudio(runner=fake_runner)
  25. provisioner = Provisioner(pa, cfg, store)
  26. provisioner.setup(["alsa_input.usb-Foo", "alsa_output.pci-0000.monitor"])
  27. # Verify creation order: null-sink, remap, then loopbacks
  28. assert call_sequence[0][0] == "module-null-sink"
  29. assert call_sequence[1][0] == "module-remap-source"
  30. assert call_sequence[2][0] == "module-loopback"
  31. assert call_sequence[3][0] == "module-loopback"
  32. # Verify state is saved
  33. loaded_state = store.load()
  34. assert loaded_state is not None
  35. assert loaded_state.sink_name == "VirtualMicSink"
  36. assert len(loaded_state.loopbacks) == 2
  37. def test_provisioner_teardown_unloads_in_reverse_order(tmp_path):
  38. """Test that teardown unloads modules in reverse order."""
  39. state_file = tmp_path / "audio.json"
  40. store = StateStore(state_file)
  41. cfg = SinkConfig(sink_name="VirtualMicSink", remap_source_name="VirtualMic")
  42. # Pre-save a state
  43. state = AudioState(
  44. sink_name="VirtualMicSink",
  45. remap_name="VirtualMic",
  46. null_sink_module=10,
  47. remap_module=11,
  48. loopbacks=[
  49. Loopback(module_id=12, source="alsa_input.usb-Foo"),
  50. Loopback(module_id=13, source="alsa_output.pci-0000.monitor"),
  51. ],
  52. )
  53. store.save(state)
  54. unload_sequence = []
  55. def fake_runner(args):
  56. if "unload-module" in args:
  57. module_id = int(args[1])
  58. unload_sequence.append(module_id)
  59. return ""
  60. elif "list" in args:
  61. return '{"sources": [], "sinks": []}'
  62. else:
  63. return ""
  64. pa = PulseAudio(runner=fake_runner)
  65. provisioner = Provisioner(pa, cfg, store)
  66. provisioner.teardown()
  67. # Verify unload order: loopbacks in reverse, then remap, then null-sink
  68. assert unload_sequence == [13, 12, 11, 10]
  69. # Verify state is cleared
  70. assert store.load() is None
  71. def test_provisioner_ensure_returns_none_if_no_state(tmp_path):
  72. """Test that ensure returns None if no saved state exists."""
  73. state_file = tmp_path / "audio.json"
  74. store = StateStore(state_file)
  75. cfg = SinkConfig(sink_name="VirtualMicSink", remap_source_name="VirtualMic")
  76. def fake_runner(args):
  77. if "list" in args and "sinks" in args:
  78. return '{"sinks": []}'
  79. else:
  80. return '{"sources": []}'
  81. pa = PulseAudio(runner=fake_runner)
  82. provisioner = Provisioner(pa, cfg, store)
  83. result = provisioner.ensure()
  84. assert result is None
  85. def test_provisioner_ensure_returns_state_if_sink_exists(tmp_path):
  86. """Test that ensure returns existing state if sink is present."""
  87. state_file = tmp_path / "audio.json"
  88. store = StateStore(state_file)
  89. cfg = SinkConfig(sink_name="VirtualMicSink", remap_source_name="VirtualMic")
  90. # Pre-save a state
  91. state = AudioState(
  92. sink_name="VirtualMicSink",
  93. remap_name="VirtualMic",
  94. null_sink_module=10,
  95. remap_module=11,
  96. loopbacks=[],
  97. )
  98. store.save(state)
  99. def fake_runner(args):
  100. if "list" in args and "sinks" in args:
  101. return (
  102. '{"sinks": [{"name": "VirtualMicSink", '
  103. '"monitor_source": "VirtualMicSink.monitor"}]}'
  104. )
  105. else:
  106. return '{"sources": []}'
  107. pa = PulseAudio(runner=fake_runner)
  108. provisioner = Provisioner(pa, cfg, store)
  109. result = provisioner.ensure()
  110. assert result is not None
  111. assert result.sink_name == "VirtualMicSink"
  112. def test_provisioner_ensure_reprovisions_if_sink_missing(tmp_path):
  113. """Test that ensure re-provisions if saved state exists but sink is gone."""
  114. state_file = tmp_path / "audio.json"
  115. store = StateStore(state_file)
  116. cfg = SinkConfig(sink_name="VirtualMicSink", remap_source_name="VirtualMic")
  117. # Pre-save a state
  118. original_state = AudioState(
  119. sink_name="VirtualMicSink",
  120. remap_name="VirtualMic",
  121. null_sink_module=10,
  122. remap_module=11,
  123. loopbacks=[Loopback(module_id=12, source="alsa_input.usb-Foo")],
  124. selected_sources=["alsa_input.usb-Foo"],
  125. )
  126. store.save(original_state)
  127. module_counter = [20]
  128. def fake_runner(args):
  129. if "list" in args and "sinks" in args:
  130. # Sink doesn't exist
  131. return '{"sinks": []}'
  132. elif "list" in args and "sources" in args:
  133. return '{"sources": []}'
  134. elif "load-module" in args:
  135. module_id = module_counter[0]
  136. module_counter[0] += 1
  137. return f"{module_id}\n"
  138. else:
  139. return ""
  140. pa = PulseAudio(runner=fake_runner)
  141. provisioner = Provisioner(pa, cfg, store)
  142. result = provisioner.ensure()
  143. assert result is not None
  144. assert result.sink_name == "VirtualMicSink"
  145. assert result.selected_sources == ["alsa_input.usb-Foo"]
  146. # Should have created new modules
  147. assert result.null_sink_module == 20
  148. def test_provisioner_setup_raises_on_untracked_sink(tmp_path):
  149. """Test that setup raises if sink already exists but is not tracked."""
  150. state_file = tmp_path / "audio.json"
  151. store = StateStore(state_file)
  152. cfg = SinkConfig(sink_name="VirtualMicSink", remap_source_name="VirtualMic")
  153. def fake_runner(args):
  154. if "list" in args and "sinks" in args:
  155. # VirtualMicSink already exists (from elsewhere)
  156. return (
  157. '{"sinks": [{"name": "VirtualMicSink", '
  158. '"monitor_source": "VirtualMicSink.monitor"}]}'
  159. )
  160. else:
  161. return '{"sources": []}'
  162. pa = PulseAudio(runner=fake_runner)
  163. provisioner = Provisioner(pa, cfg, store)
  164. with pytest.raises(RuntimeError, match="already exists but is not tracked"):
  165. provisioner.setup(["alsa_input.usb-Foo"])
  166. def test_provisioner_status_reports_live_modules(tmp_path):
  167. """Test that status reports which tracked modules are currently loaded."""
  168. state_file = tmp_path / "audio.json"
  169. store = StateStore(state_file)
  170. cfg = SinkConfig(sink_name="VirtualMicSink", remap_source_name="VirtualMic")
  171. state = AudioState(
  172. sink_name="VirtualMicSink",
  173. remap_name="VirtualMic",
  174. null_sink_module=10,
  175. remap_module=11,
  176. loopbacks=[Loopback(module_id=12, source="alsa_input.usb-Foo")],
  177. )
  178. store.save(state)
  179. def fake_runner(args):
  180. if "list" in args and "sinks" in args:
  181. return '{"sinks": [{"name": "VirtualMicSink"}]}'
  182. elif "list" in args and "short" in args and "modules" in args:
  183. # Only 2 of 3 tracked modules are loaded
  184. return "10\tmodule-null-sink\n11\tmodule-remap-source\n"
  185. else:
  186. return '{"sources": []}'
  187. pa = PulseAudio(runner=fake_runner)
  188. provisioner = Provisioner(pa, cfg, store)
  189. status = provisioner.status()
  190. assert status["state"] is not None
  191. assert status["sink_present"] is True
  192. assert len(status["live_modules"]) == 2
  193. def test_provisioner_teardown_tolerates_missing_modules(tmp_path):
  194. """Test that teardown tolerates modules that are already gone."""
  195. state_file = tmp_path / "audio.json"
  196. store = StateStore(state_file)
  197. cfg = SinkConfig(sink_name="VirtualMicSink", remap_source_name="VirtualMic")
  198. state = AudioState(
  199. sink_name="VirtualMicSink",
  200. remap_name="VirtualMic",
  201. null_sink_module=10,
  202. remap_module=11,
  203. )
  204. store.save(state)
  205. unload_attempts = []
  206. def fake_runner(args):
  207. if "unload-module" in args:
  208. module_id = int(args[1])
  209. unload_attempts.append(module_id)
  210. # Simulate module 11 already being gone
  211. if module_id == 11:
  212. raise RuntimeError("Module 11 not found")
  213. return ""
  214. else:
  215. return '{"sources": [], "sinks": []}'
  216. pa = PulseAudio(runner=fake_runner)
  217. provisioner = Provisioner(pa, cfg, store)
  218. # Should not raise
  219. provisioner.teardown()
  220. # State should still be cleared
  221. assert store.load() is None