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

798 lines
28 KiB
Python

import json
import queue
import tempfile
import threading
import time
import unittest
from dataclasses import replace
from pathlib import Path
from controller.config import (
ControllerConfig,
HomeAssistantConfig,
RouterConfig,
StorageConfig,
)
from controller.downloads import canonical_playlist_path
from controller.models import SlotProtectedError
from controller.mqtt_adapter import MqttEvent, PublishReceipt
from controller.service import ControllerService, ControllerUnavailableError
from controller.store import ControllerStore
class MutableClock:
def __init__(self, value):
self.value = value
def __call__(self):
return self.value
class FakeMqttAdapter:
"""In-memory transport boundary; it never starts a network client."""
def __init__(self, *, connected=True):
self.connected = connected
self.started = False
self.player_commands = []
self.router_modes = []
self.json_messages = []
self.legacy_messages = []
self.acknowledged = []
def start(self):
self.started = True
def stop(self):
self.started = False
def _receipt(self):
if self.connected:
return PublishReceipt(True, message_id=1)
return PublishReceipt(False, reason="MQTT is disconnected")
def publish_player_command(self, payload):
receipt = self._receipt()
if receipt.accepted:
self.player_commands.append(dict(payload))
return receipt
def publish_router_mode(self, mode):
receipt = self._receipt()
if receipt.accepted:
self.router_modes.append(mode)
return receipt
def publish_json(self, topic, payload, *, qos=0, retain=False):
receipt = self._receipt()
if receipt.accepted:
self.json_messages.append((topic, dict(payload), qos, retain))
return receipt
def publish_legacy(self, topic, payload, *, retain=True):
receipt = self._receipt()
if receipt.accepted:
self.legacy_messages.append((topic, payload, retain))
return receipt
def acknowledge(self, event):
self.acknowledged.append(event)
return True
class FakeDownloadManager:
def __init__(self):
self.recovered = False
self.recovery_calls = 0
self.expiry_calls = 0
self.stopped = False
def recover_pending_deletions(self):
self.recovered = True
def schedule_pending_deletion_recovery(self):
self.recovered = True
self.recovery_calls += 1
return True
def expire_due_slots(self):
self.expiry_calls += 1
return []
def shutdown(self, *, wait):
self.stopped = wait
class ControllerServiceTests(unittest.TestCase):
def setUp(self):
self.tempdir = tempfile.TemporaryDirectory()
base = Path(self.tempdir.name)
self.wall = MutableClock(1_700_000_000.0)
self.monotonic = MutableClock(100.0)
self.config = ControllerConfig(
storage=StorageConfig(
media_base=base / "media",
data_base=base / "data",
retention_seconds=3600,
expiry_interval_seconds=3600,
uncertain_review_after_seconds=30,
),
router=RouterConfig(
required_mode="video",
confirmation_timeout_seconds=3,
when_state_unknown="continue",
continue_on_confirmation_failure=True,
fallback_restore_mode=None,
),
homeassistant=HomeAssistantConfig(enabled=False),
)
self.events = queue.Queue()
self.store = None
self.mqtt = None
self.downloads = None
self.service = None
def tearDown(self):
if self.service is not None and self.service.started:
self.service.stop()
self.tempdir.cleanup()
def start_service(self, *, config=None, mqtt=None):
config = config or self.config
self.events = queue.Queue()
self.store = ControllerStore(config.storage, now=self.wall)
self.mqtt = mqtt or FakeMqttAdapter()
self.downloads = FakeDownloadManager()
self.service = ControllerService(
config,
store=self.store,
mqtt_adapter=self.mqtt,
event_queue=self.events,
download_manager=self.downloads,
monotonic=self.monotonic,
wall_clock=self.wall,
)
self.service.start()
self.assertFalse(self.downloads.recovered)
return self.service
def ready_slot(self, slot=1):
reservation = self.store.reserve_download(
f"https://example.test/{slot}",
"tester",
"service test",
job_id=f"ready-{slot}",
)
self.assertEqual(reservation.slot, slot)
self.assertTrue(
self.store.mark_download_running(
reservation.job_id,
reservation.slot,
reservation.generation,
)
)
self.assertTrue(
self.store.complete_download(
reservation.job_id,
reservation.slot,
reservation.generation,
title=f"Slot {slot}",
items=(("One", "one.mp4"),),
)
)
playlist = canonical_playlist_path(self.service.config.storage.media_base, slot)
playlist.parent.mkdir(parents=True, exist_ok=True)
playlist.write_text("one.mp4\n", encoding="utf-8")
return playlist
def send_message(
self,
topic,
payload,
*,
qos=0,
retain=False,
message_id=None,
duplicate=False,
controller_echo=False,
):
if not isinstance(payload, bytes):
payload = (
payload.encode("utf-8")
if isinstance(payload, str)
else json.dumps(payload).encode("utf-8")
)
self.events.put(
MqttEvent(
kind="message",
topic=topic,
payload=payload,
qos=qos,
retain=retain,
duplicate=duplicate,
message_id=message_id,
connection_generation=1,
controller_echo=controller_echo,
)
)
def wait_for(self, predicate, message):
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline:
if predicate():
return
time.sleep(0.01)
self.fail(message)
def result_events(self):
return [
payload
for topic, payload, qos, retain in self.mqtt.json_messages
if topic == self.service.config.topics.controller_result
]
def test_play_waits_for_router_confirmation_and_player_evidence(self):
config = replace(
self.config,
router=replace(self.config.router, when_state_unknown="attempt"),
)
self.start_service(config=config)
playlist = self.ready_slot(1)
self.send_message(config.topics.router_mode_state, "service", qos=1)
self.wait_for(
lambda: self.store.router_observation().mode == "service",
"Router state was not consumed",
)
result = self.service.submit_control({"action": "play", "slot": 1})
self.assertEqual(result["status"], "pending")
self.assertEqual(result["router"], "waiting")
self.assertEqual(self.mqtt.router_modes, ["video"])
self.assertEqual(self.mqtt.player_commands, [])
self.assertEqual(self.service.playback.projected_slot_state(1), "ready")
self.send_message(
config.topics.router_mode_set,
"video",
qos=1,
controller_echo=True,
)
self.wait_for(
lambda: len(self.mqtt.acknowledged) == 2,
"Controller Router command echo was not consumed",
)
self.assertEqual(self.store.live_audio_lease().state, "acquiring")
self.assertEqual(self.mqtt.player_commands, [])
self.send_message(config.topics.router_mode_state, "video", qos=1)
self.wait_for(
lambda: len(self.mqtt.player_commands) == 1,
"Player command was not released after Router confirmation",
)
self.assertEqual(
self.mqtt.player_commands,
[{"action": "play", "slot": 1, "label": "Slot 1"}],
)
self.assertEqual(self.service.playback.projected_slot_state(1), "ready")
self.send_message(
config.topics.player_status,
{"state": "playing", "slot": 1, "path": str(playlist)},
qos=1,
)
self.wait_for(
lambda: self.store.player_observation().phase == "playing",
"authoritative Player status was not reduced",
)
self.assertEqual(self.service.playback.projected_slot_state(1), "playing")
self.assertIn(
"player_operation_confirmed",
{event["type"] for event in self.result_events()},
)
def test_manual_router_set_during_acquisition_resolves_by_failure_policy(self):
config = replace(
self.config,
router=replace(self.config.router, when_state_unknown="attempt"),
)
self.start_service(config=config)
self.ready_slot(1)
self.send_message(config.topics.router_mode_state, "service", qos=1)
self.wait_for(
lambda: self.store.router_observation().mode == "service",
"Router state was not consumed",
)
result = self.service.submit_control({"action": "play", "slot": 1})
self.assertEqual(result["router"], "waiting")
self.send_message(config.topics.router_mode_set, "announcements", qos=1)
self.wait_for(
lambda: len(self.mqtt.player_commands) == 1,
"manual Router intent did not resolve acquisition",
)
lease = self.store.get_audio_lease(self.service._session_lease_id)
self.assertIsNotNone(lease)
self.assertEqual(lease.state, "unconfirmed")
warnings = [
event
for event in self.result_events()
if event["type"] == "audio_router_warning"
]
self.assertTrue(warnings)
self.assertIs(warnings[-1]["continued"], True)
def test_pause_state_remains_telemetry_authoritative(self):
self.start_service()
playlist = self.ready_slot(1)
self.service.submit_control({"action": "play", "slot": 1})
self.wait_for(
lambda: self.mqtt.player_commands == [
{"action": "play", "slot": 1, "label": "Slot 1"}
],
"play command was not published",
)
self.send_message(
self.config.topics.player_status,
{"state": "playing", "slot": 1, "path": str(playlist)},
qos=1,
)
self.wait_for(
lambda: self.service.playback.pending is None,
"play operation was not confirmed",
)
playing_generation = self.store.player_observation().generation
self.service.submit_control({"action": "pause"})
self.send_message(
self.config.topics.player_status,
{"state": "paused", "slot": 1, "path": str(playlist)},
qos=1,
)
self.wait_for(
lambda: self.store.player_observation().generation > playing_generation,
"pause status was not consumed",
)
self.assertIsNotNone(self.service.playback.pending)
self.assertEqual(self.service.playback.projected_slot_state(1), "playing")
self.send_message(
self.config.topics.player_telemetry,
{
"state": "telemetry",
"slot": 1,
"path": str(playlist),
"paused": True,
},
)
self.wait_for(
lambda: self.service.playback.pending is None,
"pause was not confirmed by telemetry",
)
self.assertEqual(self.store.player_observation().paused, True)
self.assertEqual(self.service.playback.projected_slot_state(1), "paused")
self.assertEqual(self.mqtt.player_commands[-1], {"action": "pause"})
def test_retained_controller_command_is_rejected_then_acked_and_reported(self):
self.start_service()
self.send_message(
self.config.topics.controller_command,
{"action": "play", "path": "/must/not/run"},
qos=1,
retain=True,
message_id=42,
)
self.wait_for(
lambda: len(self.mqtt.acknowledged) == 1,
"retained command was not acknowledged after rejection",
)
self.assertEqual(self.mqtt.player_commands, [])
event = self.result_events()[-1]
self.assertEqual(event["version"], 2)
self.assertEqual(event["type"], "retained_command_rejected")
topic, _, qos, retain = self.mqtt.json_messages[-1]
self.assertEqual(topic, "youtube/controller/v2/events")
self.assertEqual((qos, retain), (1, False))
self.assertEqual(self.mqtt.acknowledged[0].message_id, 42)
def test_optional_result_topic_can_be_disabled_without_affecting_ack(self):
config = replace(
self.config,
topics=replace(self.config.topics, controller_result=None),
)
self.start_service(config=config)
self.send_message(
config.topics.controller_command,
{"action": "delete_slot", "slot": 1},
qos=1,
retain=True,
message_id=7,
)
self.wait_for(
lambda: len(self.mqtt.acknowledged) == 1,
"command was not acknowledged with result topic disabled",
)
self.assertEqual(self.mqtt.json_messages, [])
def test_broker_unavailable_does_not_prevent_service_startup(self):
mqtt = FakeMqttAdapter(connected=False)
self.start_service(mqtt=mqtt)
health = self.service.health()
self.assertTrue(self.service.started)
self.assertTrue(mqtt.started)
self.assertTrue(health["coordinator_alive"])
self.assertEqual(health["status"], "degraded")
self.assertIn("mqtt_disconnected", health["degraded_reasons"])
self.assertIn("awaiting_player_evidence", health["degraded_reasons"])
def test_cleanup_and_downloads_wait_for_post_start_player_evidence(self):
self.start_service()
self.service._next_expiry = self.monotonic()
time.sleep(0.2)
self.assertEqual(self.downloads.expiry_calls, 0)
self.assertFalse(self.downloads.recovered)
with self.assertRaisesRegex(
ControllerUnavailableError,
"deferred until fresh Player evidence",
):
self.service.enqueue_download(
"https://example.test/deferred",
None,
None,
)
self.send_message(
self.config.topics.player_state,
"idle",
retain=True,
)
self.wait_for(
lambda: self.downloads.recovered,
"retained Player idle did not release deferred recovery",
)
self.wait_for(
lambda: self.downloads.expiry_calls > 0,
"Player idle did not release deferred expiry",
)
self.assertNotIn(
"awaiting_player_evidence",
self.service.health()["degraded_reasons"],
)
def test_manual_delete_requires_explicit_override_while_player_is_unknown(self):
self.start_service()
self.ready_slot(1)
with self.assertRaisesRegex(
SlotProtectedError,
"confirm_player_unknown=true",
):
self.service.submit_control({"action": "delete_slot", "slot": 1})
def test_administrator_can_clear_exact_uncertain_playback_review(self):
self.start_service()
playlist = self.ready_slot(1)
self.store.set_player_observation(
availability="unknown",
phase="unknown",
active_slot=1,
active_path=str(playlist),
paused=None,
reconciliation_state="operator_review",
reconciliation_due_at=None,
reconciliation_id="review-token",
)
guard = self.store.protect_slot(
1,
"restart_uncertain",
guard_id="review-token",
)
review = self.service.health()["reconciliation_reviews"]
self.assertEqual(review[0]["review_id"], "review-token")
result = self.service.submit_control(
{
"action": "reconcile_playback",
"decision": "clear",
"confirm": True,
"slot": 1,
"review_id": review[0]["review_id"],
"actor": "operator",
"reason": "Player host was replaced",
}
)
self.assertEqual(
result,
{"status": "cleared", "slot": 1, "review_id": "review-token"},
)
self.assertEqual(self.store.list_live_guards(), [])
observation = self.store.player_observation()
self.assertEqual(observation.reconciliation_state, "admin_cleared")
self.assertIsNone(observation.active_slot)
self.assertEqual(self.service.playback.projected_slot_state(1), "ready")
self.assertEqual(self.result_events()[-1]["type"], "playback_reconciled")
def test_busy_player_keeps_video_route_until_actual_playback_finishes(self):
config = replace(
self.config,
router=replace(self.config.router, when_state_unknown="attempt"),
)
self.start_service(config=config)
self.ready_slot(1)
second_playlist = self.ready_slot(2)
self.send_message(config.topics.router_mode_state, "service", qos=1)
self.wait_for(
lambda: self.store.router_observation().mode == "service",
"Router service mode was not observed",
)
self.service.submit_control({"action": "play", "slot": 1})
self.send_message(config.topics.router_mode_state, "video", qos=1)
self.wait_for(
lambda: len(self.mqtt.player_commands) == 1,
"play command was not released",
)
self.send_message(
config.topics.player_status,
{
"state": "busy",
"slot": 2,
"path": str(second_playlist),
"message": "mpv already playing; stop first",
},
qos=1,
)
self.wait_for(
lambda: self.service.playback.pending is None,
"busy response was not reduced",
)
self.assertEqual(self.store.player_observation().active_slot, 2)
self.assertEqual(self.mqtt.router_modes, ["video"])
lease = self.store.live_audio_lease()
self.assertIsNotNone(lease)
self.assertEqual(lease.state, "held")
def test_disconnected_synchronous_play_failure_is_not_reported_pending(self):
self.start_service(mqtt=FakeMqttAdapter(connected=False))
self.ready_slot(1)
result = self.service.submit_control({"action": "play", "slot": 1})
self.assertEqual(result["status"], "unavailable")
self.assertIsNone(self.service.playback.pending)
self.assertEqual(self.store.list_live_guards(), [])
def test_router_timeout_uses_monotonic_clock_and_continues_with_warning(self):
config = replace(
self.config,
router=replace(self.config.router, when_state_unknown="attempt"),
)
self.start_service(config=config)
self.ready_slot(1)
result = self.service.submit_control({"action": "play", "slot": 1})
self.assertEqual(result["router"], "waiting")
self.wall.value -= 10_000
self.monotonic.value += 4
self.wait_for(
lambda: len(self.mqtt.player_commands) == 1,
"Router timeout did not release play within its finite deadline",
)
warnings = [
event
for event in self.result_events()
if event["type"] == "audio_router_warning"
]
self.assertTrue(warnings)
self.assertIs(warnings[-1]["continued"], True)
def test_duplicate_toggle_is_suppressed_and_translated_idempotently(self):
self.start_service()
playlist = self.ready_slot(1)
self.send_message(
self.config.topics.player_telemetry,
{
"state": "telemetry",
"slot": 1,
"path": str(playlist),
"paused": False,
},
)
self.wait_for(
lambda: self.store.player_observation().active_slot == 1,
"active telemetry was not consumed",
)
command = {"action": "toggle"}
self.send_message(
self.config.topics.controller_command,
command,
qos=1,
message_id=71,
)
self.send_message(
self.config.topics.controller_command,
command,
qos=1,
message_id=71,
duplicate=True,
)
self.wait_for(
lambda: sum(
event.message_id == 71 for event in self.mqtt.acknowledged
)
== 2,
"duplicate command packets were not both acknowledged",
)
self.assertEqual(self.mqtt.player_commands, [{"action": "pause"}])
def test_path_only_managed_play_adds_stable_slot_identity(self):
self.start_service()
playlist = self.ready_slot(1)
result = self.service.submit_control(
{"action": "play", "playlist": str(playlist)}
)
self.assertEqual(result["status"], "pending")
self.assertEqual(
self.mqtt.player_commands,
[{"action": "play", "playlist": str(playlist), "slot": 1}],
)
self.assertEqual(self.service.playback.projected_slot_state(1), "ready")
def test_explicit_slot_cannot_be_paired_with_another_managed_slot_path(self):
self.start_service()
self.ready_slot(1)
second_playlist = self.ready_slot(2)
with self.assertRaisesRegex(ValueError, "conflicts"):
self.service.submit_control(
{
"action": "play",
"slot": 1,
"playlist": str(second_playlist),
}
)
self.assertEqual(self.mqtt.player_commands, [])
self.assertEqual(self.store.list_live_guards(), [])
def test_administrator_can_clear_external_playback_review_without_slot(self):
config = replace(
self.config,
topics=replace(self.config.topics, controller_result=None),
)
self.start_service(config=config)
observation = self.store.mark_external_uncertain(
"/srv/operator-playlists/special-event.m3u",
review_after=self.wall.value,
details={"reason": "restart"},
)
self.assertIsNotNone(observation.reconciliation_id)
reviews = self.service.health()["reconciliation_reviews"]
self.assertEqual(len(reviews), 1)
review_id = reviews[0]["review_id"]
result = self.service.submit_control(
{
"action": "reconcile_playback",
"decision": "clear",
"confirm": True,
"review_id": review_id,
"actor": "operator",
"reason": "Player appliance was replaced",
}
)
self.assertEqual(
result,
{"status": "cleared", "review_id": review_id},
)
cleared = self.store.player_observation()
self.assertIsNone(cleared.active_path)
self.assertEqual(cleared.reconciliation_state, "admin_cleared")
def test_external_play_intent_is_durable_immediately_after_puback(self):
self.start_service()
playlist = Path(self.tempdir.name) / "operator-playlist.m3u"
playlist.write_text("/srv/media/opening.mp4\n", encoding="utf-8")
result = self.service.submit_control(
{"action": "play", "playlist": str(playlist)}
)
self.assertEqual(result["status"], "pending")
self.assertEqual(
self.mqtt.player_commands,
[{"action": "play", "playlist": str(playlist)}],
)
reviews = self.service.health()["reconciliation_reviews"]
self.assertEqual(len(reviews), 1)
review_id = reviews[0]["review_id"]
self.assertIs(reviews[0]["clearable"], False)
self.service.stop()
self.service = None
with ControllerStore(self.config.storage, now=self.wall).open() as reopened:
reopened.mark_restart_uncertain()
observation = reopened.player_observation()
self.assertEqual(observation.active_path, str(playlist))
self.assertEqual(observation.reconciliation_id, review_id)
def test_live_external_play_fence_cannot_be_admin_cleared(self):
self.start_service()
playlist = Path(self.tempdir.name) / "operator-playlist.m3u"
playlist.write_text("/srv/media/opening.mp4\n", encoding="utf-8")
self.service.submit_control(
{"action": "play", "playlist": str(playlist)}
)
review = self.service.health()["reconciliation_reviews"][0]
self.assertIs(review["clearable"], False)
with self.assertRaisesRegex(ValueError, "Player operation is pending"):
self.service.submit_control(
{
"action": "reconcile_playback",
"decision": "clear",
"confirm": True,
"review_id": review["review_id"],
"actor": "operator",
"reason": "clear requested too early",
}
)
self.assertIsNotNone(self.service.playback.pending)
observation = self.store.player_observation()
self.assertEqual(observation.reconciliation_id, review["review_id"])
self.assertEqual(observation.active_path, str(playlist))
def test_definite_external_publish_failure_clears_prepublication_guard(self):
self.start_service(mqtt=FakeMqttAdapter(connected=False))
playlist = Path(self.tempdir.name) / "operator-playlist.m3u"
playlist.write_text("/srv/media/opening.mp4\n", encoding="utf-8")
result = self.service.submit_control(
{"action": "play", "playlist": str(playlist)}
)
self.assertEqual(result["status"], "unavailable")
self.assertEqual(self.service.health()["reconciliation_reviews"], [])
observation = self.store.player_observation()
self.assertIsNone(observation.active_path)
self.assertIsNone(observation.reconciliation_id)
def test_unexpected_reducer_failure_is_not_acked_or_deduplicated(self):
self.start_service()
original = self.service.playback.handle_status
failure_seen = threading.Event()
attempts = 0
def fail_once(payload):
nonlocal attempts
attempts += 1
if attempts == 1:
failure_seen.set()
raise RuntimeError("transient reducer failure")
return original(payload)
self.service.playback.handle_status = fail_once
payload = {
"state": "playing",
"slot": 1,
"path": str(self.config.storage.media_base / "slot1" / "playlist.m3u"),
}
self.send_message(
self.config.topics.player_status,
payload,
qos=1,
message_id=88,
)
self.assertTrue(failure_seen.wait(timeout=1))
time.sleep(0.05)
self.assertEqual(self.mqtt.acknowledged, [])
self.send_message(
self.config.topics.player_status,
payload,
qos=1,
message_id=88,
duplicate=True,
)
self.wait_for(
lambda: len(self.mqtt.acknowledged) == 1,
"retried status was not processed and acknowledged",
)
self.assertEqual(attempts, 2)
self.assertEqual(self.store.player_observation().phase, "playing")
if __name__ == "__main__":
unittest.main()