feat: coordinate authoritative playback and audio routing
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
from controller.config import ControllerConfig, RouterConfig, StorageConfig
|
||||
from controller.mqtt_adapter import PublishReceipt
|
||||
from controller.router import AudioRouterCoordinator
|
||||
from controller.store import ControllerStore
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self, value=1000.0):
|
||||
self.value = value
|
||||
|
||||
def __call__(self):
|
||||
return self.value
|
||||
|
||||
|
||||
class ModePublisher:
|
||||
def __init__(self):
|
||||
self.modes = []
|
||||
self.accepted = True
|
||||
self.reason = None
|
||||
|
||||
def __call__(self, mode):
|
||||
self.modes.append(mode)
|
||||
return PublishReceipt(self.accepted, message_id=len(self.modes), reason=self.reason)
|
||||
|
||||
|
||||
class AudioRouterCoordinatorTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tempdir = tempfile.TemporaryDirectory()
|
||||
base = Path(self.tempdir.name)
|
||||
self.clock = Clock()
|
||||
self.base_config = ControllerConfig(
|
||||
storage=StorageConfig(
|
||||
media_base=base / "media",
|
||||
data_base=base / "data",
|
||||
),
|
||||
router=RouterConfig(
|
||||
required_mode="video",
|
||||
confirmation_timeout_seconds=3,
|
||||
when_state_unknown="attempt",
|
||||
continue_on_confirmation_failure=True,
|
||||
fallback_restore_mode=None,
|
||||
),
|
||||
)
|
||||
self.store = ControllerStore(self.base_config.storage, now=self.clock).open()
|
||||
self.publisher = ModePublisher()
|
||||
|
||||
def tearDown(self):
|
||||
self.store.close()
|
||||
self.tempdir.cleanup()
|
||||
|
||||
def coordinator(self, router_config=None):
|
||||
config = self.base_config
|
||||
if router_config is not None:
|
||||
config = replace(config, router=router_config)
|
||||
return AudioRouterCoordinator(
|
||||
config,
|
||||
self.store,
|
||||
self.publisher,
|
||||
clock=self.clock,
|
||||
)
|
||||
|
||||
def test_known_mode_is_confirmed_before_play_and_restored_afterward(self):
|
||||
router = self.coordinator()
|
||||
router.observe_mode("service")
|
||||
decision = router.acquire("play-one")
|
||||
self.assertEqual(decision.action, "waiting")
|
||||
self.assertEqual(self.publisher.modes, ["video"])
|
||||
self.assertEqual(router.observe_mode("video").action, "ready")
|
||||
lease = self.store.get_audio_lease(decision.lease_id)
|
||||
self.assertEqual(lease.state, "held")
|
||||
self.assertEqual(lease.previous_mode, "service")
|
||||
restore = router.release(decision.lease_id)
|
||||
self.assertEqual(restore.action, "restore_waiting")
|
||||
self.assertEqual(self.publisher.modes, ["video", "service"])
|
||||
self.assertEqual(router.observe_mode("service").action, "restored")
|
||||
self.assertEqual(self.store.get_audio_lease(decision.lease_id).state, "released")
|
||||
|
||||
def test_default_unknown_state_timeout_continues_with_warning_not_hang(self):
|
||||
router = self.coordinator()
|
||||
decision = router.acquire("play-timeout")
|
||||
self.assertEqual(decision.action, "waiting")
|
||||
self.assertEqual(self.publisher.modes, ["video"])
|
||||
self.clock.value += 2.9
|
||||
self.assertIsNone(router.check_deadline())
|
||||
self.clock.value += 0.2
|
||||
decision = router.check_deadline()
|
||||
self.assertEqual(decision.action, "ready")
|
||||
self.assertTrue(decision.continued)
|
||||
self.assertIn("did not confirm", decision.warning)
|
||||
lease = self.store.get_audio_lease(decision.lease_id)
|
||||
self.assertEqual(lease.state, "unconfirmed")
|
||||
self.assertEqual(router.release(lease.lease_id).action, "released")
|
||||
self.assertEqual(self.publisher.modes, ["video"])
|
||||
|
||||
def test_timeout_can_reject_when_continue_policy_is_disabled(self):
|
||||
router = self.coordinator(
|
||||
replace(
|
||||
self.base_config.router,
|
||||
continue_on_confirmation_failure=False,
|
||||
)
|
||||
)
|
||||
decision = router.acquire("play-reject")
|
||||
self.clock.value += 4
|
||||
decision = router.check_deadline()
|
||||
self.assertEqual(decision.action, "rejected")
|
||||
self.assertFalse(decision.continued)
|
||||
self.assertEqual(self.store.get_audio_lease(decision.lease_id).state, "abandoned")
|
||||
|
||||
def test_unknown_state_continue_policy_does_not_wait_or_gain_late_ownership(self):
|
||||
router = self.coordinator(
|
||||
replace(self.base_config.router, when_state_unknown="continue")
|
||||
)
|
||||
decision = router.acquire("play-immediate")
|
||||
self.assertEqual(decision.action, "ready")
|
||||
self.assertTrue(decision.continued)
|
||||
self.assertEqual(self.store.get_audio_lease(decision.lease_id).state, "unconfirmed")
|
||||
self.assertEqual(router.observe_mode("video").action, "observed")
|
||||
self.assertEqual(self.store.get_audio_lease(decision.lease_id).state, "unconfirmed")
|
||||
|
||||
def test_unknown_state_reject_policy_does_not_publish(self):
|
||||
router = self.coordinator(
|
||||
replace(self.base_config.router, when_state_unknown="reject")
|
||||
)
|
||||
decision = router.acquire("play-reject")
|
||||
self.assertEqual(decision.action, "rejected")
|
||||
self.assertEqual(self.publisher.modes, [])
|
||||
|
||||
def test_already_video_requires_no_change_and_no_restore(self):
|
||||
router = self.coordinator()
|
||||
router.observe_mode("video")
|
||||
decision = router.acquire("play-video")
|
||||
self.assertEqual(decision.action, "ready")
|
||||
self.assertEqual(self.publisher.modes, [])
|
||||
self.assertEqual(router.release(decision.lease_id).action, "released")
|
||||
self.assertEqual(self.publisher.modes, [])
|
||||
|
||||
def test_confirmed_unknown_acquisition_can_use_configured_fallback(self):
|
||||
router = self.coordinator(
|
||||
replace(self.base_config.router, fallback_restore_mode="service")
|
||||
)
|
||||
decision = router.acquire("play-fallback")
|
||||
self.assertEqual(router.observe_mode("video").action, "ready")
|
||||
lease = self.store.get_audio_lease(decision.lease_id)
|
||||
self.assertEqual(lease.restoration_source, "configured_fallback")
|
||||
self.assertEqual(router.release(decision.lease_id).action, "restore_waiting")
|
||||
self.assertEqual(self.publisher.modes, ["video", "service"])
|
||||
|
||||
def test_operator_change_is_sticky_even_if_mode_later_returns_to_video(self):
|
||||
router = self.coordinator()
|
||||
router.observe_mode("service")
|
||||
decision = router.acquire("play-operator")
|
||||
router.observe_mode("video")
|
||||
lost = router.observe_mode("announcements")
|
||||
self.assertEqual(lost.action, "ownership_lost")
|
||||
router.observe_mode("video")
|
||||
self.assertEqual(router.release(decision.lease_id).action, "released")
|
||||
self.assertEqual(self.publisher.modes, ["video"])
|
||||
self.assertEqual(
|
||||
self.store.get_audio_lease(decision.lease_id).state,
|
||||
"ownership_lost",
|
||||
)
|
||||
|
||||
def test_connection_loss_relinquishes_confirmed_lease(self):
|
||||
router = self.coordinator()
|
||||
router.observe_mode("service")
|
||||
decision = router.acquire("play-disconnect")
|
||||
router.observe_mode("video")
|
||||
lost = router.connection_lost()
|
||||
self.assertEqual(lost.action, "ownership_lost")
|
||||
self.assertEqual(router.release(decision.lease_id).action, "released")
|
||||
self.assertEqual(self.publisher.modes, ["video"])
|
||||
|
||||
def test_publish_failure_uses_confirmation_failure_policy(self):
|
||||
self.publisher.accepted = False
|
||||
self.publisher.reason = "MQTT disconnected"
|
||||
router = self.coordinator()
|
||||
decision = router.acquire("play-publish-fail")
|
||||
self.assertEqual(decision.action, "ready")
|
||||
self.assertTrue(decision.continued)
|
||||
self.assertIn("MQTT disconnected", decision.warning)
|
||||
|
||||
def test_restoration_timeout_is_bounded_and_not_retried(self):
|
||||
router = self.coordinator()
|
||||
router.observe_mode("service")
|
||||
decision = router.acquire("play-restore-timeout")
|
||||
router.observe_mode("video")
|
||||
router.release(decision.lease_id)
|
||||
self.clock.value += 4
|
||||
timeout = router.check_deadline()
|
||||
self.assertEqual(timeout.action, "restore_failed")
|
||||
self.assertEqual(self.publisher.modes, ["video", "service"])
|
||||
self.assertIsNone(router.check_deadline())
|
||||
|
||||
def test_repeated_previous_state_during_acquisition_waits_for_deadline(self):
|
||||
router = self.coordinator()
|
||||
router.observe_mode("service")
|
||||
waiting = router.acquire("play-repeated-state")
|
||||
self.assertEqual(router.observe_mode("service").action, "observed")
|
||||
self.assertEqual(
|
||||
self.store.get_audio_lease(waiting.lease_id).state,
|
||||
"acquiring",
|
||||
)
|
||||
self.clock.value += 4
|
||||
decision = router.check_deadline()
|
||||
self.assertEqual(decision.action, "ready")
|
||||
self.assertTrue(decision.continued)
|
||||
self.assertEqual(
|
||||
self.store.get_audio_lease(waiting.lease_id).state,
|
||||
"unconfirmed",
|
||||
)
|
||||
router.release(waiting.lease_id)
|
||||
self.assertEqual(self.publisher.modes, ["video"])
|
||||
|
||||
def test_repeated_target_state_is_observation_not_operator_takeover(self):
|
||||
router = self.coordinator()
|
||||
router.observe_mode("service")
|
||||
decision = router.acquire("play-repeated-state")
|
||||
self.assertEqual(router.observe_mode("video").action, "ready")
|
||||
self.assertEqual(router.observe_mode("video").action, "observed")
|
||||
self.assertEqual(
|
||||
self.store.get_audio_lease(decision.lease_id).state,
|
||||
"held",
|
||||
)
|
||||
self.assertEqual(router.release(decision.lease_id).action, "restore_waiting")
|
||||
self.assertEqual(self.publisher.modes, ["video", "service"])
|
||||
|
||||
def test_controller_mode_set_echo_does_not_relinquish_ownership(self):
|
||||
router = self.coordinator()
|
||||
router.observe_mode("service")
|
||||
decision = router.acquire("play-self-echo")
|
||||
self.assertEqual(
|
||||
router.observe_mode_command("video", controller_echo=True).action,
|
||||
"self_echo",
|
||||
)
|
||||
self.assertEqual(router.observe_mode("video").action, "ready")
|
||||
self.assertEqual(router.release(decision.lease_id).action, "restore_waiting")
|
||||
self.assertEqual(
|
||||
router.observe_mode_command("service", controller_echo=True).action,
|
||||
"self_echo",
|
||||
)
|
||||
self.assertEqual(router.observe_mode("service").action, "restored")
|
||||
|
||||
def test_manual_mode_set_during_acquisition_uses_continue_policy(self):
|
||||
router = self.coordinator()
|
||||
router.observe_mode("service")
|
||||
waiting = router.acquire("play-manual-acquire")
|
||||
decision = router.observe_mode_command("announcements")
|
||||
self.assertEqual(decision.action, "ready")
|
||||
self.assertTrue(decision.continued)
|
||||
self.assertEqual(
|
||||
self.store.get_audio_lease(waiting.lease_id).state,
|
||||
"unconfirmed",
|
||||
)
|
||||
self.assertIsNone(self.store.get_audio_lease(waiting.lease_id).confirmation_deadline)
|
||||
|
||||
def test_manual_mode_set_during_acquisition_can_reject(self):
|
||||
router = self.coordinator(
|
||||
replace(
|
||||
self.base_config.router,
|
||||
continue_on_confirmation_failure=False,
|
||||
)
|
||||
)
|
||||
router.observe_mode("service")
|
||||
waiting = router.acquire("play-manual-reject")
|
||||
decision = router.observe_mode_command("announcements")
|
||||
self.assertEqual(decision.action, "rejected")
|
||||
self.assertFalse(decision.continued)
|
||||
self.assertEqual(
|
||||
self.store.get_audio_lease(waiting.lease_id).state,
|
||||
"abandoned",
|
||||
)
|
||||
|
||||
def test_manual_mode_set_while_held_relinquishes_restoration(self):
|
||||
router = self.coordinator()
|
||||
router.observe_mode("service")
|
||||
decision = router.acquire("play-manual-held")
|
||||
router.observe_mode("video")
|
||||
lost = router.observe_mode_command("video")
|
||||
self.assertEqual(lost.action, "ownership_lost")
|
||||
self.assertEqual(router.release(decision.lease_id).action, "released")
|
||||
self.assertEqual(self.publisher.modes, ["video"])
|
||||
|
||||
def test_manual_mode_set_while_restoring_relinquishes_restoration(self):
|
||||
router = self.coordinator()
|
||||
router.observe_mode("service")
|
||||
decision = router.acquire("play-manual-restore")
|
||||
router.observe_mode("video")
|
||||
self.assertEqual(router.release(decision.lease_id).action, "restore_waiting")
|
||||
lost = router.observe_mode_command("announcements")
|
||||
self.assertEqual(lost.action, "ownership_lost")
|
||||
self.assertEqual(
|
||||
self.store.get_audio_lease(decision.lease_id).state,
|
||||
"ownership_lost",
|
||||
)
|
||||
self.assertEqual(self.publisher.modes, ["video", "service"])
|
||||
|
||||
def test_retained_mode_set_does_not_claim_live_operator_intent(self):
|
||||
router = self.coordinator()
|
||||
router.observe_mode("service")
|
||||
decision = router.acquire("play-retained-set")
|
||||
router.observe_mode("video")
|
||||
ignored = router.observe_mode_command("announcements", retained=True)
|
||||
self.assertEqual(ignored.action, "retained_ignored")
|
||||
self.assertEqual(
|
||||
self.store.get_audio_lease(decision.lease_id).state,
|
||||
"held",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user