feat(player): player state machine — controls stream to transitions

This commit is contained in:
Ben Stull
2026-06-05 18:08:34 -07:00
parent 79bb86c6a2
commit 9a13b01a41
2 changed files with 232 additions and 0 deletions
+112
View File
@@ -0,0 +1,112 @@
from dataclasses import dataclass
import pytest
from player.controls import Controls
from player.state import Player, Playback, Transition, TransitionKind
@dataclass(frozen=True)
class FakeClip:
id: str
LIB = [FakeClip("base-a"), FakeClip("base-b")]
def _controls(content="video", left=0, right=0, dark=0, light=0, volume=2, brightness=2):
return Controls(content, left, right, dark, light, volume, brightness)
def test_first_update_to_video_fades_in_from_black():
p = Player(LIB)
t = p.update(_controls(content="video"))
assert t.kind == TransitionKind.FADE_FROM_BLACK
assert t.playback.clip_id == "base-a"
assert t.playback.content.video is True
def test_off_from_video_fades_to_black_and_silences():
p = Player(LIB)
p.update(_controls(content="video"))
t = p.update(_controls(content="off"))
assert t.kind == TransitionKind.FADE_TO_BLACK
assert t.playback.clip_id is None
assert t.playback.content.audio_source == "none"
def test_no_change_yields_none_transition():
p = Player(LIB)
p.update(_controls(content="video", left=1))
t = p.update(_controls(content="video", left=1))
assert t.kind == TransitionKind.NONE
def test_grade_change_is_a_live_update_not_a_crossfade():
# design §4.3: the Dark/Light grade is a continuous runtime op
p = Player(LIB)
p.update(_controls(content="video", dark=0, light=0))
t = p.update(_controls(content="video", dark=4, light=0))
assert t.kind == TransitionKind.LIVE_UPDATE
assert t.playback.plan.grade.tone == -1.0
def test_overlay_change_is_a_live_update():
p = Player(LIB)
p.update(_controls(content="video", left=0))
t = p.update(_controls(content="video", left=4))
assert t.kind == TransitionKind.LIVE_UPDATE
assert t.playback.plan.overlay.intensity == 1.0
def test_restyle_change_crossfades_the_substrate():
# design §4.3: the Right v2v substrate is a pre-baked variant swap
p = Player(LIB)
p.update(_controls(content="video", right=0))
t = p.update(_controls(content="video", right=4))
assert t.kind == TransitionKind.CROSSFADE
assert t.playback.plan.restyle.blend == 1.0
def test_volume_only_change_is_a_live_update():
p = Player(LIB)
p.update(_controls(content="video", volume=1))
t = p.update(_controls(content="video", volume=4))
assert t.kind == TransitionKind.LIVE_UPDATE
assert t.playback.volume == 4
def test_audio_source_change_while_black_is_a_live_update():
p = Player(LIB)
p.update(_controls(content="white_noise"))
t = p.update(_controls(content="music"))
assert t.kind == TransitionKind.LIVE_UPDATE
assert t.playback.content.audio_source == "music"
def test_injected_base_chooser_is_used():
p = Player(LIB, choose_base=lambda lib: lib[1])
t = p.update(_controls(content="video"))
assert t.playback.clip_id == "base-b"
def test_empty_library_with_video_raises():
p = Player([])
with pytest.raises(ValueError):
p.update(_controls(content="video"))
def test_off_with_empty_library_is_fine():
p = Player([])
# levels at 0 match the initial black state, so this is a no-op transition
t = p.update(_controls(content="off", volume=0, brightness=0))
assert t.kind == TransitionKind.NONE # already black at init
assert t.playback.clip_id is None
def test_off_with_levels_from_black_is_a_live_update():
p = Player([])
t = p.update(_controls(content="off", volume=3, brightness=2))
assert t.kind == TransitionKind.LIVE_UPDATE # black->black, levels set
assert t.playback.clip_id is None
assert t.playback.volume == 3