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:
2026-05-16 21:12:42 +12:00
parent 6286297646
commit 96d1e1b5fd
28 changed files with 1715 additions and 458 deletions

View File

@@ -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")

View File

@@ -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={

View File

@@ -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(

View 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

View 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)