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()