feat(ring,api): pick destination clip then resolve matching chained morph
Tasks 3-6. Transition is now a directed clip-pair morph; ScaleRing.morph_for looks up (from,to)->file. advance_ring returns structural steps (no file); resolve_move picks each crossed altitude's clip THEN its morph, chained, and reports the locked target. Fast spin keeps the whole chain (blended) instead of collapsing. /api/ring/advance takes from_clip_id, draws picks, returns the resolved chain. Dropped ring_move_to_dict + _rev_file. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+26
-15
@@ -2,12 +2,12 @@ import json
|
||||
|
||||
import pytest
|
||||
|
||||
from player.ring import RingMove, ScaleRing, TransitionStep, advance_ring
|
||||
from player.ring import RingMove, ScaleRing, advance_ring, resolve_move
|
||||
from simulator.clips import (
|
||||
Clip,
|
||||
load_manifest,
|
||||
load_ring,
|
||||
ring_move_to_dict,
|
||||
resolved_move_to_dict,
|
||||
ring_to_dict,
|
||||
)
|
||||
|
||||
@@ -86,9 +86,12 @@ def _ring_manifest_dict():
|
||||
{"id": "abyss", "clip_id": "abyss"},
|
||||
],
|
||||
"transitions": [
|
||||
{"file": "transitions/cosmos-forest.mp4", "model": "placeholder"},
|
||||
{"file": "transitions/forest-abyss.mp4", "model": "placeholder"},
|
||||
{"file": "transitions/abyss-cosmos.mp4", "model": "placeholder"},
|
||||
{"from": "cosmos", "to": "forest", "file": "transitions/cosmos__forest.mp4", "model": "placeholder"},
|
||||
{"from": "forest", "to": "cosmos", "file": "transitions/cosmos__forest.rev.mp4", "model": "placeholder"},
|
||||
{"from": "forest", "to": "abyss", "file": "transitions/forest__abyss.mp4", "model": "placeholder"},
|
||||
{"from": "abyss", "to": "forest", "file": "transitions/forest__abyss.rev.mp4", "model": "placeholder"},
|
||||
{"from": "abyss", "to": "cosmos", "file": "transitions/abyss__cosmos.mp4", "model": "placeholder"},
|
||||
{"from": "cosmos", "to": "abyss", "file": "transitions/abyss__cosmos.rev.mp4", "model": "placeholder"},
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -102,7 +105,8 @@ def test_load_ring_builds_a_scale_ring(tmp_path):
|
||||
assert len(ring) == 3
|
||||
assert ring.scales[0].id == "cosmos"
|
||||
assert ring.scales[0].clip_id == "cosmos"
|
||||
assert ring.transitions[2].file == "transitions/abyss-cosmos.mp4"
|
||||
assert ring.morph_for("abyss", "cosmos") == "transitions/abyss__cosmos.mp4" # zoom in (wrap)
|
||||
assert ring.morph_for("cosmos", "forest") == "transitions/cosmos__forest.mp4"
|
||||
assert ring.transitions[0].model == "placeholder"
|
||||
|
||||
|
||||
@@ -127,32 +131,39 @@ def test_ring_to_dict_carries_scale_titles_from_clips(tmp_path):
|
||||
assert [s["id"] for s in d["scales"]] == ["cosmos", "forest", "abyss"]
|
||||
assert d["scales"][0]["title"] == "NASA cosmos"
|
||||
assert d["scales"][0]["clip_id"] == "cosmos"
|
||||
assert d["transitions"][2]["file"] == "transitions/abyss-cosmos.mp4"
|
||||
assert d["transitions"][0] == {
|
||||
"from": "cosmos", "to": "forest",
|
||||
"file": "transitions/cosmos__forest.mp4", "model": "placeholder",
|
||||
}
|
||||
|
||||
|
||||
def test_ring_move_to_dict_serializes_steps(tmp_path):
|
||||
def test_resolved_move_to_dict_serializes_chained_morphs(tmp_path):
|
||||
p = tmp_path / "manifest.json"
|
||||
p.write_text(json.dumps(_ring_manifest_dict()))
|
||||
ring = load_ring(p)
|
||||
move = advance_ring(ring, from_index=2, delta=1) # abyss -> wrap -> cosmos
|
||||
d = ring_move_to_dict(move, ring)
|
||||
resolved = resolve_move(ring, move, from_clip_id="abyss", picks=(0.0,))
|
||||
d = resolved_move_to_dict(move, resolved)
|
||||
assert d["from_index"] == 2
|
||||
assert d["to_index"] == 0
|
||||
assert d["wrapped"] is True
|
||||
assert d["target_clip_id"] == "cosmos"
|
||||
assert d["steps"][0]["file"] == "transitions/abyss-cosmos.mp4"
|
||||
assert d["steps"][0]["reversed"] is False
|
||||
assert d["steps"][0]["from_clip"] == "abyss"
|
||||
assert d["steps"][0]["to_clip"] == "cosmos"
|
||||
assert d["steps"][0]["file"] == "transitions/abyss__cosmos.mp4" # zoom in (wrap)
|
||||
assert d["steps"][0]["to_index"] == 0
|
||||
assert d["fast"] is False
|
||||
assert d["steps"][0]["blended"] is False
|
||||
|
||||
|
||||
def test_ring_move_to_dict_marks_fast_blended_pass(tmp_path):
|
||||
def test_resolved_move_to_dict_marks_fast_blended_chain(tmp_path):
|
||||
p = tmp_path / "manifest.json"
|
||||
p.write_text(json.dumps(_ring_manifest_dict()))
|
||||
ring = load_ring(p)
|
||||
move = advance_ring(ring, from_index=0, delta=4, fast_spin_threshold=3)
|
||||
d = ring_move_to_dict(move, ring)
|
||||
resolved = resolve_move(ring, move, from_clip_id="cosmos",
|
||||
picks=tuple(0.0 for _ in move.steps))
|
||||
d = resolved_move_to_dict(move, resolved)
|
||||
assert d["fast"] is True
|
||||
assert len(d["steps"]) == 1
|
||||
assert d["steps"][0]["blended"] is True
|
||||
assert len(d["steps"]) == 4 # whole chain kept, played fast
|
||||
assert all(s["blended"] for s in d["steps"])
|
||||
|
||||
+125
-55
@@ -9,12 +9,20 @@ from player.ring import (
|
||||
TransitionStep,
|
||||
advance_ring,
|
||||
pick_clip_id,
|
||||
resolve_move,
|
||||
scale_at,
|
||||
)
|
||||
|
||||
|
||||
def _pair(a, b):
|
||||
"""Both directed morphs for an adjacent clip pair (zoom-in + zoom-out)."""
|
||||
return (Transition(a, b, f"t/{a}__{b}.mp4"),
|
||||
Transition(b, a, f"t/{a}__{b}.rev.mp4"))
|
||||
|
||||
|
||||
def _ring():
|
||||
# cosmos (largest, idx 0) -> forest -> abyss (smallest) -> wraps to cosmos
|
||||
# cosmos (largest, idx 0) -> forest -> abyss (smallest) -> wraps to cosmos.
|
||||
# Pool-of-one scales, so each adjacent pair is a single clip pair.
|
||||
return ScaleRing(
|
||||
scales=(
|
||||
Scale(id="cosmos", clip_id="cosmos"),
|
||||
@@ -22,19 +30,20 @@ def _ring():
|
||||
Scale(id="abyss", clip_id="abyss"),
|
||||
),
|
||||
transitions=(
|
||||
Transition(file="t/cosmos-forest.mp4"), # edge 0: cosmos->forest
|
||||
Transition(file="t/forest-abyss.mp4"), # edge 1: forest->abyss
|
||||
Transition(file="t/abyss-cosmos.mp4"), # edge 2: abyss->cosmos (wrap)
|
||||
*_pair("cosmos", "forest"),
|
||||
*_pair("forest", "abyss"),
|
||||
*_pair("abyss", "cosmos"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_ring_needs_one_transition_per_scale():
|
||||
with pytest.raises(RingError):
|
||||
ScaleRing(
|
||||
scales=(Scale("a", "a"), Scale("b", "b")),
|
||||
transitions=(Transition("t/ab.mp4"),), # 1 edge for 2 scales
|
||||
)
|
||||
def test_morph_for_returns_directed_file():
|
||||
r = _ring()
|
||||
assert r.morph_for("cosmos", "forest") == "t/cosmos__forest.mp4" # zoom in
|
||||
assert r.morph_for("forest", "cosmos") == "t/cosmos__forest.rev.mp4" # zoom out
|
||||
# cosmos<->abyss is adjacent via the wrap edge, so it resolves too:
|
||||
assert r.morph_for("cosmos", "abyss") == "t/abyss__cosmos.rev.mp4"
|
||||
assert r.morph_for("cosmos", "nope") is None # no such pair baked
|
||||
|
||||
|
||||
def test_ring_rejects_empty():
|
||||
@@ -42,6 +51,13 @@ def test_ring_rejects_empty():
|
||||
ScaleRing(scales=(), transitions=())
|
||||
|
||||
|
||||
def test_ring_allows_any_transition_count():
|
||||
# The old "N scales need N transitions" rule is gone — the per-clip-pair model
|
||||
# has a variable count, and a ring with no morphs is valid (plain cuts).
|
||||
r = ScaleRing(scales=(Scale("a", "a"), Scale("b", "b")), transitions=())
|
||||
assert r.morph_for("a", "b") is None
|
||||
|
||||
|
||||
def test_scale_at_wraps_modulo():
|
||||
r = _ring()
|
||||
assert scale_at(r, 0).id == "cosmos"
|
||||
@@ -50,27 +66,23 @@ def test_scale_at_wraps_modulo():
|
||||
assert scale_at(r, 4).id == "forest"
|
||||
|
||||
|
||||
def test_advance_one_step_inward_plays_edge_forward():
|
||||
def test_advance_one_step_inward_crosses_edge_forward():
|
||||
r = _ring()
|
||||
move = advance_ring(r, from_index=0, delta=1)
|
||||
assert isinstance(move, RingMove)
|
||||
assert move.from_index == 0
|
||||
assert move.to_index == 1
|
||||
assert move.wrapped is False
|
||||
assert move.steps == (
|
||||
TransitionStep(edge=0, reversed=False, file="t/cosmos-forest.mp4", to_index=1),
|
||||
)
|
||||
assert move.steps == (TransitionStep(edge=0, reversed=False, to_index=1),)
|
||||
|
||||
|
||||
def test_advance_one_step_outward_plays_edge_reversed():
|
||||
def test_advance_one_step_outward_crosses_edge_reversed():
|
||||
r = _ring()
|
||||
move = advance_ring(r, from_index=1, delta=-1)
|
||||
assert move.to_index == 0
|
||||
assert move.wrapped is False
|
||||
# going forest(1) -> cosmos(0) is edge 0 played in reverse
|
||||
assert move.steps == (
|
||||
TransitionStep(edge=0, reversed=True, file="t/cosmos-forest.mp4", to_index=0),
|
||||
)
|
||||
# going forest(1) -> cosmos(0) is edge 0 crossed in reverse
|
||||
assert move.steps == (TransitionStep(edge=0, reversed=True, to_index=0),)
|
||||
|
||||
|
||||
def test_advance_inward_past_smallest_wraps_to_largest():
|
||||
@@ -79,9 +91,7 @@ def test_advance_inward_past_smallest_wraps_to_largest():
|
||||
move = advance_ring(r, from_index=2, delta=1)
|
||||
assert move.to_index == 0
|
||||
assert move.wrapped is True
|
||||
assert move.steps == (
|
||||
TransitionStep(edge=2, reversed=False, file="t/abyss-cosmos.mp4", to_index=0),
|
||||
)
|
||||
assert move.steps == (TransitionStep(edge=2, reversed=False, to_index=0),)
|
||||
|
||||
|
||||
def test_advance_outward_past_largest_wraps_to_smallest():
|
||||
@@ -90,19 +100,17 @@ def test_advance_outward_past_largest_wraps_to_smallest():
|
||||
move = advance_ring(r, from_index=0, delta=-1)
|
||||
assert move.to_index == 2
|
||||
assert move.wrapped is True
|
||||
assert move.steps == (
|
||||
TransitionStep(edge=2, reversed=True, file="t/abyss-cosmos.mp4", to_index=2),
|
||||
)
|
||||
assert move.steps == (TransitionStep(edge=2, reversed=True, to_index=2),)
|
||||
|
||||
|
||||
def test_advance_multi_detent_chains_transitions():
|
||||
def test_advance_multi_detent_chains_steps():
|
||||
r = _ring()
|
||||
move = advance_ring(r, from_index=0, delta=2)
|
||||
assert move.to_index == 2
|
||||
assert move.wrapped is False
|
||||
assert move.steps == (
|
||||
TransitionStep(edge=0, reversed=False, file="t/cosmos-forest.mp4", to_index=1),
|
||||
TransitionStep(edge=1, reversed=False, file="t/forest-abyss.mp4", to_index=2),
|
||||
TransitionStep(edge=0, reversed=False, to_index=1),
|
||||
TransitionStep(edge=1, reversed=False, to_index=2),
|
||||
)
|
||||
|
||||
|
||||
@@ -125,7 +133,6 @@ def test_advance_zero_is_a_noop():
|
||||
|
||||
def test_advance_normalizes_from_index():
|
||||
r = _ring()
|
||||
# from_index out of range is taken modulo N first
|
||||
move = advance_ring(r, from_index=3, delta=1)
|
||||
assert move.from_index == 0
|
||||
assert move.to_index == 1
|
||||
@@ -142,10 +149,10 @@ def test_degenerate_single_scale_ring_is_a_noop():
|
||||
# --- fast-spin: a faster blended pass past a speed threshold (scales design §3) ---
|
||||
#
|
||||
# A fast encoder spin batches many detents into one advance() call, so |delta| is
|
||||
# the input adapter's proxy for spin speed. Past a configured threshold the move
|
||||
# collapses to a SINGLE blended pass (the arrival-edge clip, played fast) instead
|
||||
# of chaining N full transitions. The policy is opt-in (default off) because the
|
||||
# pure function cannot know wall-clock spin speed — the input layer enables it.
|
||||
# the input adapter's proxy for spin speed. Past a configured threshold every step
|
||||
# is marked `blended` so the renderer plays the WHOLE chain fast (each crossed
|
||||
# altitude still morphs). The policy is opt-in (default off) because the pure
|
||||
# function cannot know wall-clock spin speed — the input layer enables it.
|
||||
|
||||
|
||||
def test_fast_spin_disabled_by_default_chains_every_detent():
|
||||
@@ -156,46 +163,40 @@ def test_fast_spin_disabled_by_default_chains_every_detent():
|
||||
assert all(not s.blended for s in move.steps)
|
||||
|
||||
|
||||
def test_fast_spin_below_threshold_still_chains():
|
||||
def test_fast_spin_below_threshold_still_chains_unblended():
|
||||
r = _ring()
|
||||
move = advance_ring(r, from_index=0, delta=2, fast_spin_threshold=3)
|
||||
assert move.fast is False
|
||||
assert len(move.steps) == 2
|
||||
assert move.steps[0].edge == 0 and move.steps[1].edge == 1
|
||||
assert all(not s.blended for s in move.steps)
|
||||
|
||||
|
||||
def test_fast_spin_at_or_above_threshold_collapses_to_one_blended_step():
|
||||
def test_fast_spin_at_or_above_threshold_keeps_all_steps_blended():
|
||||
r = _ring()
|
||||
# 4 inward detents from cosmos(0): 0->1->2->0->1, lands on forest(1),
|
||||
# crosses the abyss->cosmos seam, last edge crossed is edge 0 (forward).
|
||||
# crosses the abyss->cosmos seam. Every step is kept, marked blended.
|
||||
move = advance_ring(r, from_index=0, delta=4, fast_spin_threshold=3)
|
||||
assert move.fast is True
|
||||
assert move.to_index == 1
|
||||
assert move.wrapped is True
|
||||
assert move.steps == (
|
||||
TransitionStep(
|
||||
edge=0, reversed=False, file="t/cosmos-forest.mp4", to_index=1, blended=True
|
||||
),
|
||||
)
|
||||
assert len(move.steps) == 4
|
||||
assert all(s.blended for s in move.steps)
|
||||
# structural path preserved (not collapsed)
|
||||
assert [s.edge for s in move.steps] == [0, 1, 2, 0]
|
||||
|
||||
|
||||
def test_fast_spin_outward_blended_step_is_reversed():
|
||||
def test_fast_spin_outward_keeps_all_steps_blended_and_reversed():
|
||||
r = _ring()
|
||||
# 4 outward detents from cosmos(0): 0->2->1->0->2, lands on abyss(2);
|
||||
# outward arrival crosses edge 2 (abyss->cosmos) reversed.
|
||||
move = advance_ring(r, from_index=0, delta=-4, fast_spin_threshold=3)
|
||||
assert move.fast is True
|
||||
assert move.to_index == 2
|
||||
assert move.wrapped is True
|
||||
assert move.steps == (
|
||||
TransitionStep(
|
||||
edge=2, reversed=True, file="t/abyss-cosmos.mp4", to_index=2, blended=True
|
||||
),
|
||||
)
|
||||
assert len(move.steps) == 4
|
||||
assert all(s.blended and s.reversed for s in move.steps)
|
||||
|
||||
|
||||
def test_fast_spin_threshold_below_two_is_ignored():
|
||||
# A threshold of 0 or 1 would blend even single deliberate steps — treat as off.
|
||||
r = _ring()
|
||||
move = advance_ring(r, from_index=0, delta=3, fast_spin_threshold=1)
|
||||
assert move.fast is False
|
||||
@@ -215,7 +216,6 @@ def test_fast_spin_noop_on_degenerate_ring():
|
||||
def test_scale_without_pool_is_a_pool_of_one():
|
||||
s = Scale(id="cosmos", clip_id="cosmos")
|
||||
assert s.members == ("cosmos",)
|
||||
# any r maps to the single member
|
||||
assert pick_clip_id(s, 0.0) == "cosmos"
|
||||
assert pick_clip_id(s, 0.99) == "cosmos"
|
||||
|
||||
@@ -227,7 +227,6 @@ def test_scale_with_pool_exposes_all_members():
|
||||
|
||||
def test_pick_clip_id_selects_member_by_injected_r():
|
||||
s = Scale(id="coast", clip_id="a", pool=("a", "b", "c", "d"))
|
||||
# r in [0,1) partitions the pool into len equal buckets
|
||||
assert pick_clip_id(s, 0.0) == "a"
|
||||
assert pick_clip_id(s, 0.24) == "a"
|
||||
assert pick_clip_id(s, 0.25) == "b"
|
||||
@@ -237,13 +236,84 @@ def test_pick_clip_id_selects_member_by_injected_r():
|
||||
|
||||
def test_pick_clip_id_clamps_out_of_range_r():
|
||||
s = Scale(id="coast", clip_id="a", pool=("a", "b", "c"))
|
||||
assert pick_clip_id(s, -5.0) == "a" # below 0 clamps to first
|
||||
assert pick_clip_id(s, 1.0) == "c" # 1.0 must not index past the end
|
||||
assert pick_clip_id(s, -5.0) == "a"
|
||||
assert pick_clip_id(s, 1.0) == "c"
|
||||
assert pick_clip_id(s, 999.0) == "c"
|
||||
|
||||
|
||||
def test_scale_carries_an_optional_audio_path():
|
||||
s = Scale(id="coast", clip_id="coast_birdrock", audio="coast/waves.loop.mp3")
|
||||
assert s.audio == "coast/waves.loop.mp3"
|
||||
# back-compat: audio defaults to empty
|
||||
assert Scale(id="x", clip_id="x").audio == ""
|
||||
|
||||
|
||||
# --- resolve_move: pick the destination clip THEN the matching morph, chained ---
|
||||
|
||||
|
||||
def _chain_ring():
|
||||
# 3 scales, pools of 2/1/2; descending edges cosmos->coast->abyss + wrap.
|
||||
scales = (Scale("cosmos", "c1", ("c1", "c2")),
|
||||
Scale("coast", "o1", ("o1",)),
|
||||
Scale("abyss", "a1", ("a1", "a2")))
|
||||
t = []
|
||||
for h in ("c1", "c2"):
|
||||
t += list(_pair(h, "o1")) # cosmos<->coast
|
||||
for l in ("a1", "a2"):
|
||||
t += list(_pair("o1", l)) # coast<->abyss
|
||||
for h in ("a1", "a2"):
|
||||
t += list(_pair(h, "c1")) # abyss<->cosmos wrap (to c1)
|
||||
t += list(_pair(h, "c2")) # abyss<->cosmos wrap (to c2)
|
||||
return ScaleRing(scales=scales, transitions=tuple(t))
|
||||
|
||||
|
||||
def test_resolve_single_step_zoom_in():
|
||||
r = _chain_ring()
|
||||
move = advance_ring(r, from_index=0, delta=1) # cosmos -> coast
|
||||
res = resolve_move(r, move, from_clip_id="c2", picks=(0.0,))
|
||||
assert len(res.steps) == 1
|
||||
s = res.steps[0]
|
||||
assert (s.from_clip, s.to_clip) == ("c2", "o1")
|
||||
assert s.file == "t/c2__o1.mp4"
|
||||
assert res.target_clip_id == "o1"
|
||||
|
||||
|
||||
def test_resolve_chains_through_intermediate_altitude():
|
||||
r = _chain_ring()
|
||||
move = advance_ring(r, from_index=0, delta=2) # cosmos -> coast -> abyss
|
||||
# coast member (only o1) then abyss member: 0.6*2 -> index 1 -> a2
|
||||
res = resolve_move(r, move, from_clip_id="c1", picks=(0.0, 0.6))
|
||||
files = [s.file for s in res.steps]
|
||||
assert files == ["t/c1__o1.mp4", "t/o1__a2.mp4"]
|
||||
assert res.target_clip_id == "a2"
|
||||
|
||||
|
||||
def test_resolve_zoom_out_uses_reverse_file():
|
||||
r = _chain_ring()
|
||||
move = advance_ring(r, from_index=2, delta=-1) # abyss -> coast (ascend)
|
||||
res = resolve_move(r, move, from_clip_id="a1", picks=(0.0,))
|
||||
assert res.steps[0].file == "t/o1__a1.rev.mp4"
|
||||
assert res.target_clip_id == "o1"
|
||||
|
||||
|
||||
def test_resolve_empty_move_keeps_source():
|
||||
r = _chain_ring()
|
||||
move = advance_ring(r, from_index=0, delta=0)
|
||||
res = resolve_move(r, move, from_clip_id="c2", picks=())
|
||||
assert res.steps == () and res.target_clip_id == "c2"
|
||||
|
||||
|
||||
def test_resolve_requires_one_pick_per_step():
|
||||
r = _chain_ring()
|
||||
move = advance_ring(r, from_index=0, delta=2)
|
||||
with pytest.raises(ValueError):
|
||||
resolve_move(r, move, from_clip_id="c1", picks=(0.0,))
|
||||
|
||||
|
||||
def test_resolve_missing_morph_yields_none_file():
|
||||
# A pair with no baked morph resolves to file=None (renderer plain-cuts).
|
||||
r = ScaleRing(scales=(Scale("a", "a1", ("a1",)), Scale("b", "b1", ("b1",))),
|
||||
transitions=())
|
||||
move = advance_ring(r, from_index=0, delta=1)
|
||||
res = resolve_move(r, move, from_clip_id="a1", picks=(0.0,))
|
||||
assert res.steps[0].file is None
|
||||
assert res.target_clip_id == "b1"
|
||||
|
||||
+35
-14
@@ -200,9 +200,12 @@ def ring_manifest_path(tmp_path):
|
||||
{"id": "abyss", "clip_id": "abyss", "audio": "abyss/whale.loop.mp3"},
|
||||
],
|
||||
"transitions": [
|
||||
{"file": "transitions/cosmos-forest.mp4", "model": "placeholder"},
|
||||
{"file": "transitions/forest-abyss.mp4", "model": "placeholder"},
|
||||
{"file": "transitions/abyss-cosmos.mp4", "model": "placeholder"},
|
||||
{"from": "cosmos", "to": "forest", "file": "transitions/cosmos__forest.mp4", "model": "placeholder"},
|
||||
{"from": "forest", "to": "cosmos", "file": "transitions/cosmos__forest.rev.mp4", "model": "placeholder"},
|
||||
{"from": "forest", "to": "abyss", "file": "transitions/forest__abyss.mp4", "model": "placeholder"},
|
||||
{"from": "abyss", "to": "forest", "file": "transitions/forest__abyss.rev.mp4", "model": "placeholder"},
|
||||
{"from": "abyss", "to": "cosmos", "file": "transitions/abyss__cosmos.mp4", "model": "placeholder"},
|
||||
{"from": "cosmos", "to": "abyss", "file": "transitions/abyss__cosmos.rev.mp4", "model": "placeholder"},
|
||||
],
|
||||
},
|
||||
}))
|
||||
@@ -220,7 +223,7 @@ def test_ring_returns_scales_and_transitions(ring_client):
|
||||
data = resp.json()
|
||||
assert [s["id"] for s in data["scales"]] == ["cosmos", "forest", "abyss"]
|
||||
assert data["scales"][0]["title"] == "NASA cosmos"
|
||||
assert len(data["transitions"]) == 3
|
||||
assert len(data["transitions"]) == 6 # 3 edges x 2 directions (clip-pair morphs)
|
||||
|
||||
|
||||
def test_ring_exposes_each_scales_audio(ring_client):
|
||||
@@ -233,14 +236,27 @@ def test_ring_exposes_each_scales_audio(ring_client):
|
||||
|
||||
|
||||
def test_ring_advance_inward_one_step(ring_client):
|
||||
resp = ring_client.post("/api/ring/advance", json={"from_index": 0, "delta": 1})
|
||||
resp = ring_client.post("/api/ring/advance",
|
||||
json={"from_index": 0, "delta": 1, "from_clip_id": "cosmos"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["to_index"] == 1
|
||||
assert data["target_clip_id"] == "forest"
|
||||
assert data["wrapped"] is False
|
||||
assert data["steps"][0]["file"] == "transitions/cosmos-forest.mp4"
|
||||
assert data["steps"][0]["reversed"] is False
|
||||
step = data["steps"][0]
|
||||
assert step["from_clip"] == "cosmos" # threaded current clip
|
||||
assert step["to_clip"] == "forest" # picked destination
|
||||
assert step["file"] == "transitions/cosmos__forest.mp4" # morph matches landing
|
||||
|
||||
|
||||
def test_ring_advance_picks_then_returns_matching_morph(ring_client):
|
||||
# The morph's `to_clip` always equals the locked target, and its file is the
|
||||
# directed morph for (from_clip -> to_clip).
|
||||
data = ring_client.post("/api/ring/advance",
|
||||
json={"from_index": 0, "delta": 1, "from_clip_id": "cosmos"}).json()
|
||||
step = data["steps"][-1]
|
||||
assert step["to_clip"] == data["target_clip_id"]
|
||||
assert step["file"] == f"transitions/cosmos__{data['target_clip_id']}.mp4"
|
||||
|
||||
|
||||
def test_ring_advance_wraps_smallest_to_largest(ring_client):
|
||||
@@ -251,11 +267,15 @@ def test_ring_advance_wraps_smallest_to_largest(ring_client):
|
||||
assert data["wrapped"] is True
|
||||
|
||||
|
||||
def test_ring_advance_outward_reverses_edge(ring_client):
|
||||
resp = ring_client.post("/api/ring/advance", json={"from_index": 1, "delta": -1})
|
||||
def test_ring_advance_outward_plays_reverse_morph(ring_client):
|
||||
resp = ring_client.post("/api/ring/advance",
|
||||
json={"from_index": 1, "delta": -1, "from_clip_id": "forest"})
|
||||
data = resp.json()
|
||||
assert data["to_index"] == 0
|
||||
assert data["steps"][0]["reversed"] is True
|
||||
# forest(1) -> cosmos(0) is a zoom-out: the directed (forest, cosmos) morph
|
||||
# resolves to the .rev companion.
|
||||
assert data["steps"][0]["file"] == "transitions/cosmos__forest.rev.mp4"
|
||||
assert data["target_clip_id"] == "cosmos"
|
||||
|
||||
|
||||
def test_ring_advance_small_delta_chains_not_fast(ring_client):
|
||||
@@ -266,13 +286,14 @@ def test_ring_advance_small_delta_chains_not_fast(ring_client):
|
||||
assert len(data["steps"]) == 2
|
||||
|
||||
|
||||
def test_ring_advance_fast_spin_collapses_to_one_blended_pass(ring_client):
|
||||
# A fast spin (4 detents batched) crosses the threshold -> one blended pass.
|
||||
def test_ring_advance_fast_spin_plays_whole_chain_blended(ring_client):
|
||||
# A fast spin (4 detents batched) crosses the threshold -> the whole chain is
|
||||
# kept and played fast (each step blended), not collapsed to one pass.
|
||||
resp = ring_client.post("/api/ring/advance", json={"from_index": 0, "delta": 4})
|
||||
data = resp.json()
|
||||
assert data["fast"] is True
|
||||
assert len(data["steps"]) == 1
|
||||
assert data["steps"][0]["blended"] is True
|
||||
assert len(data["steps"]) == 4
|
||||
assert all(s["blended"] for s in data["steps"])
|
||||
assert data["to_index"] == 1
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user