Files
Video-Player-Controller/tests/test_router.py
T

317 lines
13 KiB
Python

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