feat(ui): pattern modes, bundles, and zone content kind
Add profile/preset/sequence JSON import and export; map preset mode to wire n6 with a mode dropdown for multi-mode patterns; zone edit shows presets or sequences only with content_kind on save; update catalogue and tests for merged pattern names. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -25,18 +25,20 @@ def test_preset():
|
||||
print("\nTesting update preset")
|
||||
update_data = {
|
||||
"name": "test_preset",
|
||||
"pattern": "on",
|
||||
"pattern": "colour_cycle",
|
||||
"colors": ["#FF0000", "#00FF00"],
|
||||
"delay": 100,
|
||||
"brightness": 127,
|
||||
"n1": 10,
|
||||
"n2": 20
|
||||
"n2": 20,
|
||||
"mode": 1,
|
||||
}
|
||||
result = presets.update(preset_id, update_data)
|
||||
assert result is True
|
||||
updated = presets.read(preset_id)
|
||||
assert updated["name"] == "test_preset"
|
||||
assert updated["pattern"] == "on"
|
||||
assert updated["pattern"] == "colour_cycle"
|
||||
assert updated["mode"] == 1
|
||||
assert updated["delay"] == 100
|
||||
|
||||
print("\nTesting list presets")
|
||||
|
||||
@@ -29,6 +29,21 @@ def test_pack_parse_v2_brightness_only():
|
||||
assert data == {"v": "1", "b": 128}
|
||||
|
||||
|
||||
def test_pack_parse_v2_mode_maps_to_n6():
|
||||
raw = pack_binary_envelope_v2(
|
||||
presets={
|
||||
"m": {
|
||||
"p": "meteor",
|
||||
"c": ["#aabbcc"],
|
||||
"mode": 2,
|
||||
"n6": 0,
|
||||
}
|
||||
},
|
||||
)
|
||||
data = parse_binary_envelope_v2(raw)
|
||||
assert data["presets"]["m"]["n6"] == 2
|
||||
|
||||
|
||||
def test_pack_parse_v2_full():
|
||||
raw = pack_binary_envelope_v2(
|
||||
presets={
|
||||
|
||||
@@ -50,6 +50,17 @@ def _find_id_by_field(list_resp_json: Dict[str, Any], field: str, value: str) ->
|
||||
raise AssertionError(f"Could not find id for {field}={value!r}")
|
||||
|
||||
|
||||
def _create_and_apply_profile(c: requests.Session, base_url: str) -> str:
|
||||
"""Sequences/scenes/presets need an active profile in session."""
|
||||
unique_profile_name = f"pytest-profile-{uuid.uuid4().hex[:8]}"
|
||||
resp = c.post(f"{base_url}/profiles", json={"name": unique_profile_name})
|
||||
assert resp.status_code == 201
|
||||
profile_id = next(iter(resp.json().keys()))
|
||||
resp = c.post(f"{base_url}/profiles/{profile_id}/apply")
|
||||
assert resp.status_code == 200
|
||||
return str(profile_id)
|
||||
|
||||
|
||||
def _start_microdot_server(app: Microdot, host: str, port: int):
|
||||
"""
|
||||
Start Microdot server on a background thread.
|
||||
@@ -474,6 +485,36 @@ def test_profiles_presets_zones_endpoints(server, monkeypatch):
|
||||
resp = c.delete(f"{base_url}/zones/{zone_id}")
|
||||
assert resp.status_code == 200
|
||||
|
||||
resp = c.get(f"{base_url}/profiles/{profile_id}/export")
|
||||
assert resp.status_code == 200
|
||||
bundle = resp.json()
|
||||
assert bundle.get("kind") == "profile"
|
||||
assert isinstance(bundle.get("presets"), dict)
|
||||
|
||||
import_name = f"pytest-imported-{uuid.uuid4().hex[:8]}"
|
||||
resp = c.post(
|
||||
f"{base_url}/profiles/import",
|
||||
json={"bundle": bundle, "name": import_name, "apply": False},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
imported_profile_id = resp.json().get("id") or next(
|
||||
k for k in resp.json().keys() if k != "id"
|
||||
)
|
||||
resp = c.delete(f"{base_url}/profiles/{imported_profile_id}")
|
||||
assert resp.status_code == 200
|
||||
|
||||
resp = c.get(f"{base_url}/presets/{first_preset_id}/export")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json().get("kind") == "preset"
|
||||
resp = c.post(
|
||||
f"{base_url}/presets/import",
|
||||
json={"bundle": resp.json()},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
imported_preset_id = next(iter(resp.json().keys()))
|
||||
resp = c.delete(f"{base_url}/presets/{imported_preset_id}")
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Profile clone + update endpoints.
|
||||
clone_name = f"pytest-profile-clone-{uuid.uuid4().hex[:8]}"
|
||||
resp = c.post(f"{base_url}/profiles/{profile_id}/clone", json={"name": clone_name})
|
||||
@@ -508,6 +549,8 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
|
||||
base_url: str = server["base_url"]
|
||||
sender: DummySender = server["sender"]
|
||||
|
||||
_create_and_apply_profile(c, base_url)
|
||||
|
||||
# Groups.
|
||||
unique_group_name = f"pytest-group-{uuid.uuid4().hex[:8]}"
|
||||
resp = c.post(f"{base_url}/groups", json={"name": unique_group_name})
|
||||
@@ -715,6 +758,13 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
|
||||
assert resp.status_code == 200
|
||||
definitions = resp.json()
|
||||
assert isinstance(definitions, dict)
|
||||
assert "colour_cycle" in definitions
|
||||
cc_mode = definitions["colour_cycle"].get("mode")
|
||||
assert isinstance(cc_mode, dict)
|
||||
assert "0" in cc_mode and "1" in cc_mode
|
||||
assert "blink" in definitions
|
||||
blink_mode = definitions["blink"].get("mode")
|
||||
assert not isinstance(blink_mode, dict) or len(blink_mode) < 2
|
||||
|
||||
pattern_id = f"pytest_pattern_{uuid.uuid4().hex[:8]}"
|
||||
resp = c.post(
|
||||
|
||||
51
tests/test_preset_wire_mode.py
Normal file
51
tests/test_preset_wire_mode.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Preset style mode: ``mode`` field, wire ``n6``, and pattern.json metadata."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(PROJECT_ROOT / "src"))
|
||||
sys.path.insert(0, str(PROJECT_ROOT / "led-driver" / "src"))
|
||||
|
||||
from patterns.pattern_modes import style_mode # noqa: E402
|
||||
from preset import Preset # noqa: E402
|
||||
from util.espnow_message import build_preset_dict, wire_n6 # noqa: E402
|
||||
|
||||
|
||||
def test_wire_n6_prefers_mode_over_n6():
|
||||
assert wire_n6({"mode": 2, "n6": 0}) == 2
|
||||
assert wire_n6({"n6": 1}) == 1
|
||||
assert wire_n6({}) == 0
|
||||
|
||||
|
||||
def test_build_preset_dict_maps_mode_to_n6():
|
||||
wire = build_preset_dict({"pattern": "meteor", "mode": 2, "colors": ["#ffffff"]})
|
||||
assert wire["n6"] == 2
|
||||
assert wire["p"] == "meteor"
|
||||
|
||||
|
||||
def test_preset_edit_accepts_mode_alias():
|
||||
p = Preset({"p": "colour_cycle", "mode": 1, "d": 100, "c": [(255, 255, 255)]})
|
||||
assert p.n6 == 1
|
||||
|
||||
|
||||
def test_style_mode_reads_mode_and_legacy_pattern_id():
|
||||
p = Preset({"p": "colour_cycle", "mode": 0, "d": 100, "c": [(255, 0, 0)]})
|
||||
assert style_mode(p, 0, {"rainbow": 1}) == 0
|
||||
|
||||
legacy = Preset({"p": "rainbow", "d": 100, "c": [(255, 0, 0)]})
|
||||
assert style_mode(legacy, 0, {"rainbow": 1}) == 1
|
||||
|
||||
|
||||
def test_pattern_json_defines_mode_for_merged_patterns():
|
||||
path = PROJECT_ROOT / "db" / "pattern.json"
|
||||
definitions = json.loads(path.read_text(encoding="utf-8"))
|
||||
for name in ("colour_cycle", "chase", "aurora", "meteor", "particles", "sparkle"):
|
||||
assert name in definitions, name
|
||||
mode = definitions[name].get("mode")
|
||||
assert isinstance(mode, dict), name
|
||||
assert len(mode) >= 2, name
|
||||
|
||||
blink = definitions.get("blink", {})
|
||||
assert "mode" not in blink or not isinstance(blink.get("mode"), dict) or len(blink.get("mode", {})) < 2
|
||||
133
tests/test_profile_bundle.py
Normal file
133
tests/test_profile_bundle.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Unit tests for profile/preset/sequence bundle import/export."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(PROJECT_ROOT / "src"))
|
||||
|
||||
from models.pallet import Palette # noqa: E402
|
||||
from models.preset import Preset # noqa: E402
|
||||
from models.profile import Profile # noqa: E402
|
||||
from models.sequence import Sequence # noqa: E402
|
||||
from models.zone import Zone # noqa: E402
|
||||
from util.profile_bundle import ( # noqa: E402
|
||||
export_preset_bundle,
|
||||
export_profile_bundle,
|
||||
export_sequence_bundle,
|
||||
import_preset_bundle,
|
||||
import_profile_bundle,
|
||||
import_sequence_bundle,
|
||||
)
|
||||
|
||||
|
||||
def _fresh_models(tmp_path, monkeypatch):
|
||||
import models.model as model_mod
|
||||
|
||||
db = tmp_path / "db"
|
||||
db.mkdir()
|
||||
monkeypatch.setattr(model_mod, "_db_dir", lambda: str(db))
|
||||
|
||||
for cls in (Profile, Zone, Preset, Sequence, Palette):
|
||||
if hasattr(cls, "_instance"):
|
||||
delattr(cls, "_instance")
|
||||
|
||||
profiles = Profile()
|
||||
zones = Zone()
|
||||
presets = Preset()
|
||||
sequences = Sequence()
|
||||
palette = Palette()
|
||||
return profiles, zones, presets, sequences, palette
|
||||
|
||||
|
||||
def test_profile_export_import_round_trip(tmp_path, monkeypatch):
|
||||
profiles, zones, presets, sequences, palette = _fresh_models(tmp_path, monkeypatch)
|
||||
|
||||
pid = profiles.create("Source")
|
||||
zid = zones.create(name="main")
|
||||
preset_id = presets.create(pid)
|
||||
presets.update(
|
||||
preset_id,
|
||||
{
|
||||
"name": "Test preset",
|
||||
"pattern": "blink",
|
||||
"colors": ["#ff0000"],
|
||||
"brightness": 200,
|
||||
"delay": 50,
|
||||
},
|
||||
)
|
||||
zones.update(
|
||||
zid,
|
||||
{
|
||||
"presets_flat": [str(preset_id)],
|
||||
"default_preset": str(preset_id),
|
||||
},
|
||||
)
|
||||
seq_id = sequences.create(pid)
|
||||
sequences.update(
|
||||
seq_id,
|
||||
{
|
||||
"name": "Beat seq",
|
||||
"lanes": [[{"preset_id": str(preset_id), "group_ids": [], "beats": 2}]],
|
||||
"lanes_group_ids": [[]],
|
||||
},
|
||||
)
|
||||
zones.update(zid, {"sequence_ids": [str(seq_id)]})
|
||||
profiles.update(pid, {"zones": [str(zid)]})
|
||||
palette_id = profiles.read(pid)["palette_id"]
|
||||
palette.update(palette_id, {"colors": ["#112233", "#445566"]})
|
||||
|
||||
bundle = export_profile_bundle(
|
||||
str(pid), profiles, zones, presets, sequences, palette
|
||||
)
|
||||
assert bundle["kind"] == "profile"
|
||||
assert str(preset_id) in bundle["presets"]
|
||||
assert str(seq_id) in bundle["sequences"]
|
||||
assert bundle["palette"]["colors"] == ["#112233", "#445566"]
|
||||
|
||||
new_pid, _ = import_profile_bundle(
|
||||
bundle, profiles, zones, presets, sequences, palette, name="Imported"
|
||||
)
|
||||
assert new_pid != str(pid)
|
||||
found = [
|
||||
presets.read(k)
|
||||
for k in presets.list()
|
||||
if isinstance(presets.read(k), dict)
|
||||
and str(presets.read(k).get("profile_id")) == str(new_pid)
|
||||
and presets.read(k).get("name") == "Test preset"
|
||||
]
|
||||
assert found
|
||||
|
||||
|
||||
def test_preset_export_import(tmp_path, monkeypatch):
|
||||
profiles, zones, presets, sequences, palette = _fresh_models(tmp_path, monkeypatch)
|
||||
pid = profiles.create("P")
|
||||
preset_id = presets.create(pid)
|
||||
presets.update(preset_id, {"name": "Solo", "pattern": "on", "colors": ["#00ff00"]})
|
||||
|
||||
bundle = export_preset_bundle(str(preset_id), presets)
|
||||
assert bundle["kind"] == "preset"
|
||||
|
||||
new_id, data = import_preset_bundle(bundle, presets, str(pid))
|
||||
assert new_id != str(preset_id)
|
||||
assert data["name"] == "Solo"
|
||||
|
||||
|
||||
def test_sequence_export_import_with_presets(tmp_path, monkeypatch):
|
||||
profiles, zones, presets, sequences, palette = _fresh_models(tmp_path, monkeypatch)
|
||||
pid = profiles.create("P")
|
||||
preset_id = presets.create(pid)
|
||||
presets.update(preset_id, {"name": "Step", "pattern": "off"})
|
||||
seq_id = sequences.create(pid)
|
||||
sequences.update(
|
||||
seq_id,
|
||||
{"name": "S", "lanes": [[{"preset_id": str(preset_id), "beats": 1}]]},
|
||||
)
|
||||
|
||||
bundle = export_sequence_bundle(str(seq_id), sequences, presets, profile_id=str(pid))
|
||||
assert str(preset_id) in bundle["presets"]
|
||||
|
||||
new_seq_id, doc = import_sequence_bundle(bundle, sequences, presets, str(pid))
|
||||
assert new_seq_id != str(seq_id)
|
||||
assert doc["lanes"][0][0]["preset_id"] != str(preset_id)
|
||||
Reference in New Issue
Block a user