feat: coordinate authoritative playback and audio routing
This commit is contained in:
@@ -230,6 +230,154 @@ class PlaybackReducerTests(unittest.TestCase):
|
||||
effects = self.reducer.handle_status(payload)
|
||||
self.assertEqual(effects[0].kind, "warning")
|
||||
|
||||
def test_contradictory_slot_and_path_cannot_confirm_play(self):
|
||||
expected = str(self.config.storage.media_base / "slot1" / "playlist.m3u")
|
||||
self.reducer.begin_operation("play", slot=1, expected_path=expected)
|
||||
effects = self.reducer.handle_status(
|
||||
{
|
||||
"state": "playing",
|
||||
"slot": 1,
|
||||
"path": str(
|
||||
self.config.storage.media_base / "slot2" / "playlist.m3u"
|
||||
),
|
||||
}
|
||||
)
|
||||
self.assertEqual(effects[0].kind, "warning")
|
||||
self.assertIsNotNone(self.reducer.pending)
|
||||
self.assertEqual(self.store.player_observation().phase, "unknown")
|
||||
|
||||
def test_mismatched_active_evidence_fails_intent_and_is_not_overwritten(self):
|
||||
self.reducer.begin_operation(
|
||||
"play",
|
||||
slot=1,
|
||||
expected_path="/mnt/Media/YTPlayer/slot1/playlist.m3u",
|
||||
)
|
||||
effects = self.reducer.handle_status(
|
||||
{
|
||||
"state": "playing",
|
||||
"slot": 2,
|
||||
"path": "/mnt/Media/YTPlayer/slot2/playlist.m3u",
|
||||
}
|
||||
)
|
||||
self.assertIn("operation_failed", [effect.kind for effect in effects])
|
||||
self.assertIsNone(self.reducer.pending)
|
||||
self.monotonic.value += 100
|
||||
self.assertIsNone(self.reducer.check_timeout())
|
||||
observation = self.store.player_observation()
|
||||
self.assertEqual(observation.active_slot, 2)
|
||||
self.assertEqual(observation.phase, "playing")
|
||||
self.assertEqual(self.store.list_live_guards()[0].slot, 2)
|
||||
|
||||
def test_bare_idle_does_not_clear_live_playback(self):
|
||||
path = "/mnt/Media/YTPlayer/slot1/playlist.m3u"
|
||||
self.reducer.handle_status({"state": "playing", "slot": 1, "path": path})
|
||||
effects = self.reducer.handle_status({"state": "idle", "slot": 1})
|
||||
self.assertEqual(effects[0].kind, "warning")
|
||||
self.assertEqual(self.store.player_observation().phase, "playing")
|
||||
self.assertEqual(self.store.list_live_guards()[0].slot, 1)
|
||||
|
||||
def test_path_only_evidence_derives_and_protects_managed_slot(self):
|
||||
path = str(self.config.storage.media_base / "slot2" / "playlist.m3u")
|
||||
self.reducer.handle_telemetry(
|
||||
{"state": "telemetry", "slot": None, "path": path, "paused": False}
|
||||
)
|
||||
observation = self.store.player_observation()
|
||||
self.assertEqual(observation.active_slot, 2)
|
||||
self.assertEqual(self.store.list_live_guards()[0].slot, 2)
|
||||
|
||||
def test_duplicate_offline_evidence_does_not_postpone_review(self):
|
||||
path = "/mnt/Media/YTPlayer/slot1/playlist.m3u"
|
||||
self.reducer.handle_status({"state": "playing", "slot": 1, "path": path})
|
||||
self.reducer.handle_status({"state": "offline"})
|
||||
first_deadline = self.store.list_live_guards()[0].review_after
|
||||
self.wall.value += 20
|
||||
self.reducer.handle_status({"state": "offline"})
|
||||
self.assertEqual(
|
||||
self.store.list_live_guards()[0].review_after,
|
||||
first_deadline,
|
||||
)
|
||||
|
||||
def test_repeated_active_telemetry_reuses_guard_without_history_churn(self):
|
||||
path = str(self.config.storage.media_base / "slot1" / "playlist.m3u")
|
||||
payload = {
|
||||
"state": "telemetry",
|
||||
"slot": 1,
|
||||
"path": path,
|
||||
"paused": False,
|
||||
}
|
||||
self.reducer.handle_telemetry(payload)
|
||||
first_guard = self.store.list_live_guards()[0]
|
||||
for _ in range(20):
|
||||
self.reducer.handle_telemetry(payload)
|
||||
self.assertEqual(self.store.list_live_guards()[0].guard_id, first_guard.guard_id)
|
||||
count = self.store.connection.execute(
|
||||
"SELECT COUNT(*) FROM slot_guards"
|
||||
).fetchone()[0]
|
||||
self.assertEqual(count, 1)
|
||||
|
||||
def test_external_play_timeout_transitions_to_review_without_assuming_stop(self):
|
||||
path = "/srv/operator-playlists/special-event.m3u"
|
||||
self.reducer.begin_operation("play", expected_path=path)
|
||||
self.monotonic.value += 6
|
||||
timeout = self.reducer.check_timeout()
|
||||
self.assertEqual(timeout.kind, "operation_timed_out")
|
||||
uncertain = self.store.player_observation()
|
||||
self.assertEqual(uncertain.active_path, path)
|
||||
self.assertEqual(uncertain.reconciliation_state, "awaiting_evidence")
|
||||
self.assertIsNotNone(uncertain.reconciliation_id)
|
||||
|
||||
self.wall.value += 31
|
||||
effects = self.reducer.mark_due_reconciliation()
|
||||
self.assertEqual(len(effects), 1)
|
||||
self.assertEqual(effects[0].kind, "operator_review")
|
||||
self.assertEqual(effects[0].operation_id, uncertain.reconciliation_id)
|
||||
review = self.store.player_observation()
|
||||
self.assertEqual(review.reconciliation_state, "operator_review")
|
||||
self.assertEqual(review.active_path, path)
|
||||
|
||||
def test_external_pause_does_not_bleed_to_a_different_path(self):
|
||||
first_path = "/srv/operator-playlists/first.m3u"
|
||||
second_path = "/srv/operator-playlists/second.m3u"
|
||||
self.reducer.handle_telemetry(
|
||||
{
|
||||
"state": "telemetry",
|
||||
"path": first_path,
|
||||
"paused": True,
|
||||
}
|
||||
)
|
||||
self.assertIs(self.store.player_observation().paused, True)
|
||||
|
||||
self.reducer.handle_status(
|
||||
{"state": "playing", "path": second_path}
|
||||
)
|
||||
observation = self.store.player_observation()
|
||||
self.assertEqual(observation.active_path, second_path)
|
||||
self.assertIsNone(observation.paused)
|
||||
self.assertEqual(observation.phase, "playing")
|
||||
|
||||
def test_stop_timeout_preserves_existing_operator_review_and_token(self):
|
||||
path = str(self.config.storage.media_base / "slot1" / "playlist.m3u")
|
||||
self.reducer.handle_status(
|
||||
{"state": "playing", "slot": 1, "path": path}
|
||||
)
|
||||
self.reducer.handle_status({"state": "offline"})
|
||||
self.wall.value += 31
|
||||
self.reducer.mark_due_reconciliation()
|
||||
before = self.store.player_observation()
|
||||
guard_before = self.store.list_live_guards()[0]
|
||||
self.assertEqual(before.reconciliation_state, "operator_review")
|
||||
|
||||
self.reducer.begin_operation("stop", slot=1, expected_path=path)
|
||||
self.monotonic.value += 6
|
||||
effect = self.reducer.check_timeout()
|
||||
self.assertEqual(effect.kind, "operation_timed_out")
|
||||
after = self.store.player_observation()
|
||||
guard_after = self.store.list_live_guards()[0]
|
||||
self.assertEqual(after.reconciliation_state, "operator_review")
|
||||
self.assertEqual(after.reconciliation_id, before.reconciliation_id)
|
||||
self.assertEqual(guard_after.guard_id, guard_before.guard_id)
|
||||
self.assertEqual(guard_after.state, "review_required")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user