From df3e7968e831e0151f9aa6e8fadcda1e523d856a Mon Sep 17 00:00:00 2001 From: Ion Reguera Date: Thu, 8 Oct 2026 19:03:55 +0200 Subject: [PATCH 1/2] =?UTF-8?q?test(status):=20cue=20names=20on=20the=20:9?= =?UTF-8?q?190=20channel=20=E2=80=94=20red=20(869fedahu)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One /engine/status/cue_name/ per cue after the load status, replayed to late joiners, emptied on unload; a second load without unload replaces the map (duplicated projects share cue uuids). Pure unit tests, safe on a live rig. --- tests/test_controller_cue_names.py | 323 +++++++++++++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 tests/test_controller_cue_names.py diff --git a/tests/test_controller_cue_names.py b/tests/test_controller_cue_names.py new file mode 100644 index 0000000..cf4207e --- /dev/null +++ b/tests/test_controller_cue_names.py @@ -0,0 +1,323 @@ +# SPDX-FileCopyrightText: 2026 Stagelab Coop SCCL +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileContributor: Ion Reguera + +"""Cue names on the :9190 status channel (869fedahu). + +The UI names the engine's next cue from a uuid -> name table. The engine only +ever sent the uuid, so a tab that had not opened the engine's own project +showed a raw uuid. Now the controller broadcasts one +``/engine/status/cue_name/`` per cue at load and replays them to late +joiners, like ``cue/`` and ``cue_enabled/``. + +Pinned here, in order of importance: + +* names are sent AFTER ``/engine/status/load ``: duplicated projects + share cue uuids, so a client must be able to treat a change of ``load`` as + "new table" and refill it from the burst that follows; +* a second load with no unload in between replaces the map, and the shared + uuid ends up with the new project's name; +* the late-join dump carries them, after ``cue_enabled`` and before ``mixer``; +* unload empties the map; +* the existing ``cue/`` and ``cue_enabled/`` bursts are untouched. + +Pure unit tests: no MTC master, no NNG, no sockets, no subprocess. Safe to run +on a live rig. +""" + +import asyncio +import uuid +from os import environ +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest +from cuemsutils.cues import ActionCue, AudioCue, CueList, DmxCue, VideoCue + +from cuemsengine.osc.WebSocketOscHandler import build_osc_message + +CUE_NAME = "/engine/status/cue_name/" +CUE_STATUS = "/engine/status/cue/" +CUE_ENABLED = "/engine/status/cue_enabled/" +MIXER = "/engine/status/audio/mixer/" +LOAD = "/engine/status/load" + + +@pytest.fixture(autouse=True) +def set_config_path(): + """Point CUEMS_CONF_PATH at test XML files.""" + test_conf_path = Path(__file__).parent / ".." / "dev" / "test_xml_files" + environ["CUEMS_CONF_PATH"] = str(test_conf_path) + + +@pytest.fixture +def controller(): + """A minimal ControllerEngine with the heavy dependencies mocked out.""" + with ( + patch("cuemsengine.core.BaseEngine.ConfigManager") as MockCM, + patch( + "cuemsengine.core.BaseEngine.BaseEngine.get_controller_ip", + return_value="localhost", + ), + ): + mock_cm_instance = MockCM.return_value + mock_cm_instance.node_conf = { + "uuid": "test-controller-uuid", + "mtc_port": "MTC_MIDI_PORT", + } + mock_cm_instance.library_path = str( + Path(__file__).parent / ".." / "dev" / "test_xml_files" + ) + mock_cm_instance.tmp_path = "/tmp" + + from cuemsengine.ControllerEngine import ControllerEngine + + engine = ControllerEngine(with_mtc=False) + + engine.communications_thread = Mock() + engine.communications_thread.broadcast_osc = Mock() + engine.communications_thread.nng_hub = Mock() + + engine.set_status("running", "no") + engine.set_status("load", "") + + yield engine + + engine.stop() + + +def _uuid() -> str: + return str(uuid.uuid4()) + + +def _cue(cls, **fields): + """A cue built the way the XML reader builds them (init_dict path).""" + init = {"id": fields.pop("id", _uuid())} + init.update(fields) + return cls(init) + + +def _script(cuelist): + script = Mock() + script.cuelist = cuelist + script.id = _uuid() + return script + + +def _load(controller, project_name, cuelist): + """Drive load_project up to and past the status bursts, nothing further. + + Everything after set_status("load") that needs a cluster, MTC or the lock + file is stubbed; the bursts and the status messages are real. + """ + + def fake_read_script(name): + controller.script = _script(cuelist) + + with ( + patch.object(controller, "read_script", side_effect=fake_read_script), + patch.object(controller, "_resolve_cluster_state"), + patch.object(controller, "_forward_load_to_nodes"), + patch.object(controller, "start_timecode"), + patch.object(controller, "set_show_lock_file"), + ): + assert controller.load_project(project_name) is True + + +def _sent(controller): + """(address, value) pairs in broadcast order.""" + return [ + (c.args[0], c.args[1]) + for c in controller.communications_thread.broadcast_osc.call_args_list + ] + + +def _index_of_load(sent, project_name): + """Index of the LAST `load ` message.""" + idx = [i for i, (a, v) in enumerate(sent) if a == LOAD and v == project_name] + assert idx, f"no {LOAD} {project_name!r} in {sent}" + return idx[-1] + + +# ─── _collect_cue_names ────────────────────────────────────────────────── + + +class TestCollectCueNames: + def test_walks_nested_lists_and_keeps_every_id(self, controller): + inner = _cue(DmxCue, name="Luz 3") + nested = _cue(CueList, name="Bloque 2", contents=[inner]) + audio = _cue(AudioCue, name="Canción ñ · 2ª") + video = _cue(VideoCue) # no name given: init_dict fills 'empty' + action = _cue(ActionCue, name=None) + top = CueList({"id": _uuid(), "contents": [audio, video, action, nested]}) + + names = controller._collect_cue_names(top) + + assert {str(k): v for k, v in names.items()} == { + str(audio.id): "Canción ñ · 2ª", + str(video.id): "empty", + str(action.id): "", + str(nested.id): "Bloque 2", + str(inner.id): "Luz 3", + } + + def test_mirrors_collect_cue_ids(self, controller): + """Same walk as the status map: a cue with a name but no status, or + the reverse, would be a UI that cannot pair them.""" + inner = _cue(AudioCue, name="a") + nested = _cue(CueList, name="l", contents=[inner, None]) + top = CueList({"id": _uuid(), "contents": [None, nested, _cue(VideoCue)]}) + + ids = {str(i) for i in controller._collect_cue_ids(top)} + names = {str(k) for k in controller._collect_cue_names(top)} + assert names == ids + + +# ─── the load burst ────────────────────────────────────────────────────── + + +class TestLoadBurst: + def test_one_name_per_cue_after_the_load_status(self, controller): + a = _cue(AudioCue, name="Intro") + b = _cue(VideoCue, name="Vídeo 1") + _load(controller, "show_x", CueList({"id": _uuid(), "contents": [a, b]})) + + sent = _sent(controller) + load_at = _index_of_load(sent, "show_x") + names = [ + (i, a_, v) for i, (a_, v) in enumerate(sent) if a_.startswith(CUE_NAME) + ] + + assert {a_[len(CUE_NAME) :]: v for _, a_, v in names} == { + str(a.id): "Intro", + str(b.id): "Vídeo 1", + } + assert all( + i > load_at for i, _, _ in names + ), "names must follow `load ` so a client can clear on load change" + assert {str(k): v for k, v in controller.cue_names.items()} == { + str(a.id): "Intro", + str(b.id): "Vídeo 1", + } + + def test_status_and_enabled_bursts_are_untouched(self, controller): + """Regression guard: the two existing bursts keep their count and + their place before `load`; the new one is appended, not interleaved.""" + cues = [_cue(AudioCue, name=f"c{i}") for i in range(3)] + _load(controller, "show_x", CueList({"id": _uuid(), "contents": cues})) + + sent = _sent(controller) + load_at = _index_of_load(sent, "show_x") + status = [i for i, (a, _) in enumerate(sent) if a.startswith(CUE_STATUS)] + enabled = [i for i, (a, _) in enumerate(sent) if a.startswith(CUE_ENABLED)] + names = [i for i, (a, _) in enumerate(sent) if a.startswith(CUE_NAME)] + + assert len(status) == len(enabled) == len(names) == 3 + assert max(status) < min(enabled) < load_at < min(names) + + def test_second_load_without_unload_replaces_the_map(self, controller): + """Duplicated projects share cue uuids. After X then Y, the shared + uuid must carry Y's name and nothing of X must survive in the map.""" + shared = _uuid() + only_x = _cue(AudioCue, name="Sólo X") + _load( + controller, + "show_x", + CueList( + { + "id": _uuid(), + "contents": [_cue(AudioCue, id=shared, name="Intro"), only_x], + } + ), + ) + only_y = _cue(VideoCue, name="Sólo Y") + _load( + controller, + "show_y", + CueList( + { + "id": _uuid(), + "contents": [_cue(AudioCue, id=shared, name="Intro v2"), only_y], + } + ), + ) + + assert {str(k): v for k, v in controller.cue_names.items()} == { + shared: "Intro v2", + str(only_y.id): "Sólo Y", + } + sent = _sent(controller) + load_y = _index_of_load(sent, "show_y") + after = [(a, v) for a, v in sent[load_y + 1 :] if a.startswith(CUE_NAME)] + assert (CUE_NAME + shared, "Intro v2") in after + assert (CUE_NAME + shared, "Intro") not in after + + def test_a_utf8_name_survives_the_osc_round_trip(self): + from pythonosc.osc_message import OscMessage + + name = "Canción ñ · 2ª — «fin»" + data = build_osc_message(CUE_NAME + _uuid(), name) + assert data is not None + assert OscMessage(data).params == [name] + + +# ─── late join ─────────────────────────────────────────────────────────── + + +class TestLateJoin: + def _dump(self, controller): + ws = Mock() + sent = [] + + async def fake_send(data): + sent.append(data) + + ws.send = fake_send + asyncio.run(controller._on_ws_client_connect(ws)) + return sent + + def test_readable_before_any_load(self, controller): + """__init__ must define it: a browser connecting before the first load + of a freshly restarted engine reaches _on_ws_client_connect.""" + assert controller.cue_names == {} + sent = self._dump(controller) + assert not any(CUE_NAME.encode() in d for d in sent) + + def test_names_are_replayed_after_enabled_and_before_mixer(self, controller): + cid = _uuid() + controller.cue_status = {cid: 0} + controller.cue_enabled_status = {cid: True} + controller.cue_names = {cid: "Intro"} + controller.mixer_status = {"node/0/master": 0.5} + + sent = self._dump(controller) + + def first(prefix): + hits = [i for i, d in enumerate(sent) if prefix.encode() in d] + assert hits, f"{prefix} missing from the dump" + return hits[0] + + assert first(CUE_ENABLED) < first(CUE_NAME) < first(MIXER) + name_frames = [d for d in sent if (CUE_NAME + cid).encode() in d] + assert len(name_frames) == 1 + assert "Intro".encode() in name_frames[0] + + +# ─── unload ────────────────────────────────────────────────────────────── + + +class TestUnload: + def test_unload_empties_the_map(self, controller): + _load( + controller, + "show_x", + CueList({"id": _uuid(), "contents": [_cue(AudioCue, name="Intro")]}), + ) + assert controller.cue_names + + with patch.object(controller, "_forward_command_to_nodes"): + assert controller.unload_project(None) is True + + assert controller.cue_names == {} + assert controller.cue_status == {} + assert controller.cue_enabled_status == {} From 5ffb1b809d0743621aeef5bd9fddb86b3c70a237 Mon Sep 17 00:00:00 2001 From: Ion Reguera Date: Thu, 8 Oct 2026 19:03:55 +0200 Subject: [PATCH 2/2] fix(status): broadcast cue names after load on the :9190 channel (869fedahu) The UI named the engine's next cue from whatever project its own page had loaded, so a tab viewing X while the engine held Y showed a raw uuid. The controller already pushes cue status and enabled flags per cue; the name is the missing member of that family. - cue_names built at load from the script (same walk as _collect_cue_ids; item.get("name"), None -> "") - one /engine/status/cue_name/ per cue, sent AFTER set_status("load"): duplicated projects share cue uuids, so clients treat a change of load as "new table" and refill it from this burst - replayed in _on_ws_client_connect after cue_enabled, before mixer - emptied on unload; nothing is sent then (clients clear on load "") - "Cue names initialised for N cues" next to the cue status line Node engine untouched. Verified on test2 (rc6-2+nodeadopt1 base): wire capture order, late join, X->Y without unload, unload; full suite 1057 passed. --- src/cuemsengine/ControllerEngine.py | 58 +++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/cuemsengine/ControllerEngine.py b/src/cuemsengine/ControllerEngine.py index 5e8dd14..862058f 100644 --- a/src/cuemsengine/ControllerEngine.py +++ b/src/cuemsengine/ControllerEngine.py @@ -78,6 +78,12 @@ def __init__(self, **kwargs): # so no lock is needed — keep it that way. self.mixer_status: dict[str, float] = {} + # Per-cue names of the loaded script, {uuid: name}. Rebuilt at every + # load and broadcast AFTER the load status, replayed to late joiners, + # emptied on unload. The late-join dump reads it before the first load, + # so it must exist from __init__. + self.cue_names: dict[str, str] = {} + # Cluster-state tracking. Populated at load time by # _resolve_cluster_state # (chunk 3); used to gate the armed=yes flip on all required nodes @@ -806,6 +812,28 @@ def _collect_cue_ids(self, cuelist) -> list[str]: ids.extend(self._collect_cue_ids(item)) return ids + def _collect_cue_names(self, cuelist) -> dict[str, str]: + """Recursively collect cue names from a cuelist, same walk as + _collect_cue_ids (nested CueLists included, as items and recursed). + + Reads the name as a dict item, not through the Cue.name property: the + property raises KeyError on a cue that has no name key. None becomes + "" and anything else is str()-ed as is (never `or ""`, so a name an + old cuems-utils coerced to 0/False still prints). + """ + from cuemsutils.cues import CueList + + names = {} + if hasattr(cuelist, "contents") and cuelist.contents: + for item in cuelist.contents: + if item is None: + continue + name = item.get("name") + names[item.id] = "" if name is None else str(name) + if isinstance(item, CueList): + names.update(self._collect_cue_names(item)) + return names + def _collect_cue_enabled(self, cuelist) -> dict[str, bool]: """Recursively collect cue enabled states from a cuelist.""" from cuemsutils.cues import CueList @@ -820,6 +848,20 @@ def _collect_cue_enabled(self, cuelist) -> dict[str, bool]: result.update(self._collect_cue_enabled(item)) return result + def _broadcast_cue_name(self, cue_id: str, name: str) -> None: + """ + Broadcast a cue's name to UI at /engine/status/cue_name/{uuid}. + Sent once per load (no throttle), like cue_enabled. + """ + if ( + hasattr(self, "communications_thread") + and self.communications_thread + and hasattr(self.communications_thread, "broadcast_osc") + ): + self.communications_thread.broadcast_osc( + f"/engine/status/cue_name/{cue_id}", name + ) + def _broadcast_cue_enabled(self, cue_id: str, enabled: bool) -> None: """ Broadcast per-cue enabled status to UI at @@ -903,6 +945,12 @@ async def _on_ws_client_connect(self, websocket) -> None: if data: await websocket.send(data) + # Per-cue names (after `load` above, like the live burst) + for cid, name in self.cue_names.items(): + data = build_osc_message(f"/engine/status/cue_name/{cid}", name) + if data: + await websocket.send(data) + # Per-mixer-channel volume status. # Scale note: each entry is one ~80-byte WS message. Acceptable up to # ~500 entries (~40 KB / ~500 ms over LAN). If a deployment ever @@ -1032,6 +1080,15 @@ def load_project(self, project_name, context=None, deploy_only=False): # UI contract) self.set_status("load", project_name) + # Per-cue names, deliberately AFTER the load status (unlike cue_status + # and cue_enabled above): duplicated projects share cue uuids, so a + # client treats a change of `load` as "new table" and refills it from + # this burst. Keep this order. + self.cue_names = self._collect_cue_names(self.script.cuelist) + for cid, name in self.cue_names.items(): + self._broadcast_cue_name(cid, name) + Logger.info(f"Cue names initialised for {len(self.cue_names)} cues") + # Probe cluster, derive _required_nodes for GO gating, refresh # in network_map. Done BEFORE _forward_load_to_nodes so the gating set # is in place by the time nodes start sending armed_ready. @@ -1406,6 +1463,7 @@ def unload_project(self, value, context=None): self.reset_script() self.cue_status = {} self.cue_enabled_status = {} + self.cue_names = {} self.set_status("load", "") # No project loaded → no required/adopted snapshot. Without this, a # late armed_ready from a slow node could flip armed=yes on an