feat(sim+player): scale-ring navigation (endless encoder) + cosmos/abyss scales
Add scale-ring navigation to the simulator per the scales-library design §3: an endless rotary-encoder control (relative, vs the absolute 0-4 experience knobs) walks a closed ring of neutral "scales of nature" clips, with placeholder AI zoom/warp transitions between adjacent scales and a small->large wrap (the infinite-zoom payoff). - player/ring.py (new, canonical pure logic): ScaleRing + advance_ring; one transition clip per edge, played forward zooming inward / reversed outward; modulo wrap both ways; chained steps for a multi-detent spin. Mirrors player/content.py conventions (frozen dataclasses, pure functions). - simulator: load_ring + ring_to_dict/ring_move_to_dict; GET /api/ring and POST /api/ring/advance (Python owns step/wrap/transition math; the browser only plays the returned transition(s) then settles on the target scale). - frontend: endless-encoder control (zoom in/out buttons + stage scroll), current-scale readout, multi-clip active-scale selection, transition playback. - media: two cheap true-PD scales so the ring is demonstrable -- cosmos (NASA/Hubble) + abyss (NOAA Ocean Exploration), provenance recorded in the manifest, bytes generated as labelled placeholders by setup_scales_media.py. The expensive multi-strength flow-stabilized Right re-bake is DEFERRED (new scales carry a raw base only) until the ring is liked; Pi renderer + serial/firmware remain deferred (simulator-first). - docs: ROADMAP slice-3 done; USER_GUIDE scale-ring gesture; plan doc. Verified: pytest 215 passed / 2 skipped (+22 new); sim boots, /api/ring + /api/ring/advance correct incl. wrap, media served, and a headless-Chrome CDP drive walked cosmos -> forest -> abyss -> (wrap) -> cosmos with the active clip reloading correctly. Spec: docs/superpowers/specs/2026-06-07-scales-library-and-right-axis-pipeline-design.md (§3) Plan: docs/superpowers/plans/2026-06-07-scale-ring-navigation.md Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+77
-1
@@ -2,7 +2,14 @@ import json
|
||||
|
||||
import pytest
|
||||
|
||||
from simulator.clips import Clip, load_manifest
|
||||
from player.ring import RingMove, ScaleRing, TransitionStep, advance_ring
|
||||
from simulator.clips import (
|
||||
Clip,
|
||||
load_manifest,
|
||||
load_ring,
|
||||
ring_move_to_dict,
|
||||
ring_to_dict,
|
||||
)
|
||||
|
||||
|
||||
def _manifest_dict():
|
||||
@@ -67,3 +74,72 @@ def test_clip_serializes_to_dict_for_the_api(tmp_path):
|
||||
def test_missing_manifest_raises(tmp_path):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
load_manifest(tmp_path / "nope.json")
|
||||
|
||||
|
||||
def _ring_manifest_dict():
|
||||
return {
|
||||
"clips": [],
|
||||
"ring": {
|
||||
"scales": [
|
||||
{"id": "cosmos", "clip_id": "cosmos"},
|
||||
{"id": "forest", "clip_id": "forest"},
|
||||
{"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"},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_load_ring_builds_a_scale_ring(tmp_path):
|
||||
p = tmp_path / "manifest.json"
|
||||
p.write_text(json.dumps(_ring_manifest_dict()))
|
||||
ring = load_ring(p)
|
||||
assert isinstance(ring, ScaleRing)
|
||||
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.transitions[0].model == "placeholder"
|
||||
|
||||
|
||||
def test_load_ring_is_none_when_absent(tmp_path):
|
||||
p = tmp_path / "manifest.json"
|
||||
p.write_text(json.dumps(_manifest_dict())) # no "ring" key
|
||||
assert load_ring(p) is None
|
||||
|
||||
|
||||
def test_ring_to_dict_carries_scale_titles_from_clips(tmp_path):
|
||||
p = tmp_path / "manifest.json"
|
||||
md = _ring_manifest_dict()
|
||||
md["clips"] = [
|
||||
{"id": "cosmos", "title": "NASA cosmos", "base_file": "cosmos/base.mp4"},
|
||||
{"id": "forest", "title": "Yosemite", "base_file": "forest/base.mp4"},
|
||||
{"id": "abyss", "title": "NOAA abyss", "base_file": "abyss/base.mp4"},
|
||||
]
|
||||
p.write_text(json.dumps(md))
|
||||
ring = load_ring(p)
|
||||
clips = load_manifest(p)
|
||||
d = ring_to_dict(ring, clips)
|
||||
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"
|
||||
|
||||
|
||||
def test_ring_move_to_dict_serializes_steps(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)
|
||||
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]["to_index"] == 0
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import pytest
|
||||
|
||||
from player.ring import (
|
||||
RingError,
|
||||
RingMove,
|
||||
Scale,
|
||||
ScaleRing,
|
||||
Transition,
|
||||
TransitionStep,
|
||||
advance_ring,
|
||||
scale_at,
|
||||
)
|
||||
|
||||
|
||||
def _ring():
|
||||
# cosmos (largest, idx 0) -> forest -> abyss (smallest) -> wraps to cosmos
|
||||
return ScaleRing(
|
||||
scales=(
|
||||
Scale(id="cosmos", clip_id="cosmos"),
|
||||
Scale(id="forest", clip_id="forest"),
|
||||
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)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
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_ring_rejects_empty():
|
||||
with pytest.raises(RingError):
|
||||
ScaleRing(scales=(), transitions=())
|
||||
|
||||
|
||||
def test_scale_at_wraps_modulo():
|
||||
r = _ring()
|
||||
assert scale_at(r, 0).id == "cosmos"
|
||||
assert scale_at(r, 3).id == "cosmos" # wraps
|
||||
assert scale_at(r, -1).id == "abyss" # negative wraps
|
||||
assert scale_at(r, 4).id == "forest"
|
||||
|
||||
|
||||
def test_advance_one_step_inward_plays_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),
|
||||
)
|
||||
|
||||
|
||||
def test_advance_one_step_outward_plays_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),
|
||||
)
|
||||
|
||||
|
||||
def test_advance_inward_past_smallest_wraps_to_largest():
|
||||
r = _ring()
|
||||
# at abyss (smallest, idx 2), zoom in -> wraps to cosmos (idx 0) via edge 2
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
def test_advance_outward_past_largest_wraps_to_smallest():
|
||||
r = _ring()
|
||||
# at cosmos (idx 0), zoom out -> wraps to abyss (idx 2) via edge 2 reversed
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
def test_advance_multi_detent_chains_transitions():
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
def test_advance_full_loop_returns_to_start_and_marks_wrap():
|
||||
r = _ring()
|
||||
move = advance_ring(r, from_index=0, delta=3)
|
||||
assert move.to_index == 0
|
||||
assert move.wrapped is True
|
||||
assert len(move.steps) == 3
|
||||
|
||||
|
||||
def test_advance_zero_is_a_noop():
|
||||
r = _ring()
|
||||
move = advance_ring(r, from_index=1, delta=0)
|
||||
assert move.from_index == 1
|
||||
assert move.to_index == 1
|
||||
assert move.steps == ()
|
||||
assert move.wrapped is False
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def test_degenerate_single_scale_ring_is_a_noop():
|
||||
r = ScaleRing(scales=(Scale("solo", "solo"),), transitions=())
|
||||
move = advance_ring(r, from_index=0, delta=5)
|
||||
assert move.to_index == 0
|
||||
assert move.steps == ()
|
||||
assert move.wrapped is False
|
||||
@@ -88,3 +88,85 @@ def test_index_is_served():
|
||||
assert resp.status_code == 200
|
||||
assert "text/html" in resp.headers["content-type"]
|
||||
assert "Alteration" in resp.text
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ring_manifest_path(tmp_path):
|
||||
p = tmp_path / "manifest.json"
|
||||
p.write_text(json.dumps({
|
||||
"clips": [
|
||||
{"id": "cosmos", "title": "NASA cosmos", "base_file": "cosmos/base.mp4",
|
||||
"license": "PD", "source": "NASA", "right_variants": {},
|
||||
"annotations": [], "strings": {}},
|
||||
{"id": "forest", "title": "Yosemite", "base_file": "forest/base.mp4",
|
||||
"license": "poc", "source": "hef-poc",
|
||||
"right_variants": {"4": {"file": "forest/right4.mp4"}},
|
||||
"annotations": [], "strings": {}},
|
||||
{"id": "abyss", "title": "NOAA abyss", "base_file": "abyss/base.mp4",
|
||||
"license": "PD", "source": "NOAA", "right_variants": {},
|
||||
"annotations": [], "strings": {}},
|
||||
],
|
||||
"ring": {
|
||||
"scales": [
|
||||
{"id": "cosmos", "clip_id": "cosmos"},
|
||||
{"id": "forest", "clip_id": "forest"},
|
||||
{"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"},
|
||||
],
|
||||
},
|
||||
}))
|
||||
return p
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ring_client(ring_manifest_path):
|
||||
return TestClient(create_app(manifest_path=ring_manifest_path))
|
||||
|
||||
|
||||
def test_ring_returns_scales_and_transitions(ring_client):
|
||||
resp = ring_client.get("/api/ring")
|
||||
assert resp.status_code == 200
|
||||
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
|
||||
|
||||
|
||||
def test_ring_advance_inward_one_step(ring_client):
|
||||
resp = ring_client.post("/api/ring/advance", json={"from_index": 0, "delta": 1})
|
||||
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
|
||||
|
||||
|
||||
def test_ring_advance_wraps_smallest_to_largest(ring_client):
|
||||
resp = ring_client.post("/api/ring/advance", json={"from_index": 2, "delta": 1})
|
||||
data = resp.json()
|
||||
assert data["to_index"] == 0
|
||||
assert data["target_clip_id"] == "cosmos"
|
||||
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})
|
||||
data = resp.json()
|
||||
assert data["to_index"] == 0
|
||||
assert data["steps"][0]["reversed"] is True
|
||||
|
||||
|
||||
def test_ring_404_when_no_ring_in_manifest(client):
|
||||
assert client.get("/api/ring").status_code == 404
|
||||
assert client.post("/api/ring/advance", json={"from_index": 0, "delta": 1}).status_code == 404
|
||||
|
||||
|
||||
def test_ring_advance_rejects_bad_delta(ring_client):
|
||||
resp = ring_client.post("/api/ring/advance", json={"from_index": 0, "delta": "x"})
|
||||
assert resp.status_code == 422
|
||||
|
||||
Reference in New Issue
Block a user