"""Deferred sequence start on beat / downbeat.""" import asyncio import json import os import sys PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) SRC_PATH = os.path.join(PROJECT_ROOT, "src") if SRC_PATH not in sys.path: sys.path.insert(0, SRC_PATH) from util import sequence_playback as sp # noqa: E402 def test_effective_switch_wait_ignores_saved_downbeat_when_audio_off(monkeypatch): class FakeSettings: def get(self, key, default=None): if key == "sequence_switch_wait": return "downbeat" return default monkeypatch.setattr("settings.get_settings", lambda: FakeSettings()) monkeypatch.setattr( "util.audio_detector.shared_beat_detector_timing_sequences", lambda: False ) assert sp.effective_sequence_switch_wait() == "beat" def test_simulated_mode_forces_beat_switch_wait(monkeypatch): class FakeSettings: def get(self, key, default=None): if key == "sequence_switch_wait": return "downbeat" return default monkeypatch.setattr("settings.get_settings", lambda: FakeSettings()) monkeypatch.setattr( "util.audio_detector.shared_beat_detector_timing_sequences", lambda: False ) assert sp._sequence_switch_wait_from_settings() == "beat" monkeypatch.setattr( "util.audio_detector.shared_beat_detector_timing_sequences", lambda: True ) assert sp._sequence_switch_wait_from_settings() == "downbeat" def test_beat_switch_when_audio_running_but_sim_clocks(monkeypatch): """Mic on without timing sequences: still beat-only (not downbeat).""" class FakeSettings: def get(self, key, default=None): if key == "sequence_switch_wait": return "downbeat" return default monkeypatch.setattr("settings.get_settings", lambda: FakeSettings()) monkeypatch.setattr( "util.audio_detector.shared_beat_detector_running", lambda: True ) monkeypatch.setattr( "util.audio_detector.shared_beat_detector_timing_sequences", lambda: False ) assert sp.effective_sequence_switch_wait() == "beat" def test_normalize_wait_for(): assert sp._normalize_wait_for({"wait_for": "beat"}) == "beat" assert sp._normalize_wait_for({"start_on": "downbeat"}) == "downbeat" assert sp._normalize_wait_for({"wait_for": "next_beat"}) == "beat" assert sp._normalize_wait_for({}) is None assert sp._play_options_without_wait({"wait_for": "beat", "zone_id": "1"}) == {"zone_id": "1"} def test_pending_play_status_empty(): sp.clear_pending_play() assert sp.pending_play_status() == {"pending": False} def test_queue_and_clear_pending(): sp.clear_pending_play() sp._queue_pending_start("z1", "s1", "p1", {"simulated_bpm": 120}, "beat", bpm=120.0) st = sp.pending_play_status() assert st["pending"] is True assert st["wait_for"] == "beat" assert st["sequence_id"] == "s1" sp.clear_pending_play() assert sp.pending_play_status()["pending"] is False def test_try_consume_pending_beat(monkeypatch): monkeypatch.setattr( "util.audio_detector.shared_beat_detector_timing_sequences", lambda: False ) sp.clear_pending_play() sp._queue_pending_start("z1", "s1", "p1", None, "beat", bpm=120.0) async def fake_start(*_a, **_k): return None monkeypatch.setattr("util.sequence_playback._start_immediate", fake_start) assert asyncio.run(sp._try_consume_pending_play(is_downbeat=False)) is True assert sp.pending_play_status()["pending"] is False def test_try_consume_pending_beat_accepts_upbeat(monkeypatch): monkeypatch.setattr( "util.audio_detector.shared_beat_detector_timing_sequences", lambda: False ) sp.clear_pending_play() sp._queue_pending_start("z1", "s1", "p1", None, "beat", bpm=120.0) sp._mark_simulated_beat_phase() sp._last_thread_beat_phase = {"bar_beat": 3, "is_downbeat": False} async def fake_start(*_a, **_k): return None monkeypatch.setattr("util.sequence_playback._start_immediate", fake_start) assert asyncio.run(sp._try_consume_pending_play(is_downbeat=False)) is True sp.clear_pending_play() def test_try_consume_pending_downbeat_skips_upbeat(monkeypatch): monkeypatch.setattr( "util.audio_detector.shared_beat_detector_timing_sequences", lambda: True ) class FakeSettings: def get(self, key, default=None): if key == "sequence_switch_wait": return "downbeat" return default monkeypatch.setattr("settings.get_settings", lambda: FakeSettings()) sp.clear_pending_play() sp._queue_pending_start("z1", "s1", "p1", None, "downbeat", bpm=120.0) assert asyncio.run(sp._try_consume_pending_play(is_downbeat=False)) is False assert sp.pending_play_status()["pending"] is True async def fake_start(*_a, **_k): return None sp._start_immediate = fake_start # type: ignore[method-assign] assert asyncio.run(sp._try_consume_pending_play(is_downbeat=True)) is True sp.clear_pending_play() def test_sequence_pass_start_anchors_bar_phase_to_one(): sp.stop() sp._sim_beat_counter = 7 sp._last_thread_beat_phase = {"bar_beat": 3, "is_downbeat": False} ctx = { "lanes": [[{"preset_id": "1", "beats": 6}]], "lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}], "num_lanes": 1, "loop": False, "sequence_loop_beat": 0, "presets_map": {}, } with sp._beat_run_lock: sp._beat_run = ctx assert sp._is_sequence_pass_start(ctx) is True sp._anchor_bar_phase_for_sequence_start() phase = sp.simulated_beat_phase_snapshot() assert phase["bar_beat"] == 1 assert phase["is_downbeat"] is True assert phase["bar_phase_readout"] == "1/4" asyncio.run(sp.process_active_beat_advance()) st = sp.playback_status() assert st["beat_readout"] == "1/6" assert sp.simulated_beat_phase_snapshot()["bar_beat"] == 1 sp.stop() def test_sequence_pass_start_not_mid_pass(): ctx = { "lanes": [[{"preset_id": "1", "beats": 2}, {"preset_id": "2", "beats": 2}]], "lane_states": [{"stepIdx": 1, "beatCount": 0, "done": False}], "num_lanes": 1, "loop": False, } assert sp._is_sequence_pass_start(ctx) is False def test_completed_beat_readout_survives_stop_playback(): sp.stop() sp.clear_completed_beat_readout() ctx = { "lanes": [[{"preset_id": "1", "beats": 6}]], "lane_states": [{"stepIdx": 0, "beatCount": 6, "done": True}], "num_lanes": 1, "loop": False, "sequence_loop_beat": 6, "presets_map": {}, } with sp._beat_run_lock: sp._beat_run = ctx sp.remember_completed_beat_readout(sp._beat_readout_for_ctx(ctx)) asyncio.run(sp.stop_playback(clear_devices=False)) assert sp.last_completed_beat_readout() == "6/6" assert sp.playback_status()["active"] is False sp.stop() def test_playback_beat_readout_six_beat_sequence(): """Beat readout is 1..tot with no duplicate 1 at start or missing final beat.""" sp.stop() ctx = { "lanes": [[{"preset_id": "1", "beats": 6}]], "lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}], "num_lanes": 1, "loop": False, "sequence_loop_beat": 0, "presets_map": {}, } with sp._beat_run_lock: sp._beat_run = ctx assert sp.playback_status()["beat_readout"] == "" for n in range(1, 5): ctx["lane_states"][0]["beatCount"] = n assert sp.playback_status()["beat_readout"] == f"{n}/6" ctx["lane_states"][0]["beatCount"] = 5 assert sp.playback_status()["beat_readout"] == "6/6" ctx["lane_states"][0]["beatCount"] = 6 ctx["lane_states"][0]["done"] = True assert sp.playback_status()["beat_readout"] == "6/6" sp.stop() def test_downbeat_start_counts_trigger_beat(monkeypatch): """The downbeat that starts playback is beat 1 of the step, not beat 0.""" sp.clear_pending_play() sp.stop() async def fake_start(_z, _s, _p, _opts, **_kwargs): sp._beat_run = { "lanes": [[{"preset_id": "1", "beats": 4}]], "lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}], "num_lanes": 1, "sequence_loop_beat": 0, } monkeypatch.setattr(sp, "_start_immediate", fake_start) sp._queue_pending_start("z1", "s1", "p1", None, "downbeat", bpm=120.0) async def run(): assert await sp._try_consume_pending_play(is_downbeat=True) is True await sp.process_active_beat_advance() asyncio.run(run()) assert sp._beat_run["lane_states"][0]["beatCount"] == 1 sp.stop() def _prime_all_lanes_test_mocks(monkeypatch, deliver_log, route_log, reset_log): import types async def fake_deliver(_bridge, messages, _macs, _devices, **_kw): for raw in messages: deliver_log.append(json.loads(raw)) return ([], 0) def fake_clear(lane_index): route_log.append(("clear", lane_index)) monkeypatch.setattr(sp, "_resolve_lane_device_names", lambda _i, _c: ["dev-a"]) monkeypatch.setattr( "util.driver_delivery.deliver_json_messages", fake_deliver, ) fake_transport = types.ModuleType("models.transport") fake_transport.get_current_bridge = lambda: object() monkeypatch.setitem(sys.modules, "models.transport", fake_transport) monkeypatch.setattr( sp, "_reset_after_sequence_change", lambda: reset_log.extend(["route", "audio"]), ) monkeypatch.setattr( "util.beat_driver_route.clear_sequence_manual_lane_route", fake_clear, ) def test_prime_all_lanes_delivery_order(monkeypatch): """Sequence start: step-0 presets, select, rest presets, then beat/route reset.""" deliver_log: list = [] route_log: list = [] reset_log: list = [] _prime_all_lanes_test_mocks(monkeypatch, deliver_log, route_log, reset_log) ctx = { "lanes": [ [ {"preset_id": "p1", "beats": 2}, {"preset_id": "p2", "beats": 2}, ] ], "lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}], "num_lanes": 1, "sequence_doc": { "lanes": [ [ {"preset_id": "p1", "beats": 2}, {"preset_id": "p2", "beats": 2}, ] ], "group_ids": ["g1"], }, "zone_doc": {"group_ids": ["g1"], "brightness": 200}, "presets_map": { "p1": { "pattern": "solid", "colors": ["#FF0000"], "auto": True, }, "p2": { "pattern": "solid", "colors": ["#00FF00"], "auto": True, }, }, "devices": object(), "groups": object(), "settings": {}, "palette_colors": [], } asyncio.run(sp._prime_all_lanes(ctx)) assert len(deliver_log) == 3 step0_msg = deliver_log[0] select_msg = deliver_log[1] rest_msg = deliver_log[2] assert set(step0_msg["presets"]) == {"p1"} assert select_msg["select"] == ["p1"] assert set(rest_msg["presets"]) == {"p2"} for body in deliver_log: assert not ("presets" in body and "select" in body) assert route_log == [("clear", 0)] assert reset_log == ["route", "audio"] assert ctx.get("_sequence_primed") is True def test_prime_all_lanes_single_preset(monkeypatch): """One preset in the lane: step-0 presets, select, reset (no rest phase).""" deliver_log: list = [] route_log: list = [] reset_log: list = [] _prime_all_lanes_test_mocks(monkeypatch, deliver_log, route_log, reset_log) ctx = { "lanes": [[{"preset_id": "p1", "beats": 2}]], "lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}], "num_lanes": 1, "sequence_doc": { "lanes": [[{"preset_id": "p1", "beats": 2}]], "group_ids": ["g1"], }, "zone_doc": {"group_ids": ["g1"], "brightness": 200}, "presets_map": { "p1": { "pattern": "solid", "colors": ["#FF0000"], "auto": True, } }, "devices": object(), "groups": object(), "settings": {}, "palette_colors": [], } asyncio.run(sp._prime_all_lanes(ctx)) assert len(deliver_log) == 2 assert set(deliver_log[0]["presets"]) == {"p1"} assert deliver_log[1]["select"] == ["p1"] assert route_log == [("clear", 0)] assert reset_log == ["route", "audio"] def test_prime_all_lanes_merges_same_groups(monkeypatch): """Lanes sharing group ids get one step-0 broadcast, not one per lane.""" deliver_log: list = [] route_log: list = [] reset_log: list = [] _prime_all_lanes_test_mocks(monkeypatch, deliver_log, route_log, reset_log) ctx = { "lanes": [ [{"preset_id": "p1", "beats": 2}], [{"preset_id": "p2", "beats": 2}], ], "lane_states": [ {"stepIdx": 0, "beatCount": 0, "done": False}, {"stepIdx": 0, "beatCount": 0, "done": False}, ], "num_lanes": 2, "sequence_doc": { "lanes": [ [{"preset_id": "p1", "beats": 2}], [{"preset_id": "p2", "beats": 2}], ], "lanes_group_ids": [["g1"], ["g1"]], }, "zone_doc": {"group_ids": ["g1"], "brightness": 200}, "presets_map": { "p1": {"pattern": "solid", "colors": ["#FF0000"], "auto": True}, "p2": {"pattern": "solid", "colors": ["#00FF00"], "auto": True}, }, "devices": object(), "groups": object(), "settings": {}, "palette_colors": [], } asyncio.run(sp._prime_all_lanes(ctx)) preset_msgs = [b for b in deliver_log if "presets" in b] select_msgs = [b for b in deliver_log if "select" in b] assert len(preset_msgs) == 1 assert set(preset_msgs[0]["presets"]) == {"p1", "p2"} assert preset_msgs[0]["groups"] == ["g1"] assert len(select_msgs) == 2 assert route_log == [("clear", 0), ("clear", 1)]