From 265ce6011a4a292e0b959a8c4309b5698a3baf59 Mon Sep 17 00:00:00 2001 From: Antoni Ivanov <262547018+tony-xmelon@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:10:28 +0300 Subject: [PATCH 1/7] Match Cortex setlist mutation shapes --- pyquadcortex/protocol/client.py | 8 ++++++-- tests/test_client.py | 26 +++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/pyquadcortex/protocol/client.py b/pyquadcortex/protocol/client.py index 4e9e102d..e938a2a9 100644 --- a/pyquadcortex/protocol/client.py +++ b/pyquadcortex/protocol/client.py @@ -3286,6 +3286,7 @@ def create_setlist(self, name: str): watching the unit create one and then doing the same from the host: File{CREATE, type: 0, folder{key: "/media/p4/Presets/", + parent_key: "/media/p4/Presets", name: "", is_factory: false}} The new key works everywhere a setlist path does, so presets can be saved @@ -3294,6 +3295,7 @@ def create_setlist(self, name: str): """ msg = pa.FileMessage(type=0) msg.folder.key = f"{USER_SETLIST_ROOT}/{name}" + msg.folder.parent_key = USER_SETLIST_ROOT msg.folder.name = name msg.folder.is_factory = False self._file_operation(msg) @@ -3406,14 +3408,16 @@ def unpin_model(self, model): def delete_setlist(self, name: str): """Delete a setlist and whatever it holds. - ``File{DELETE, folder{key, name}}`` against the setlist's own key. + ``File{DELETE, folder{key, is_factory: false}, + delete_from_library: false}`` against the setlist's own key. Confirmed: the folder disappears from the listing. Like the other file operations this is eventually consistent, so re-enumerate rather than checking immediately. """ msg = pa.FileMessage(action=pa.MessageAction.DELETE, type=0) msg.folder.key = f"{USER_SETLIST_ROOT}/{name}" - msg.folder.name = name + msg.folder.is_factory = False + msg.delete_from_library = False return self._file_operation(msg) def copy_preset(self, from_setlist: str, position, to_setlist: str, diff --git a/tests/test_client.py b/tests/test_client.py index daa43778..49f23479 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -2829,11 +2829,23 @@ def test_create_setlist_uses_a_sibling_path_under_the_presets_root(): assert path == "/media/p4/Presets/probe" folder = qc._t.sent[-1].folder assert folder.key == "/media/p4/Presets/probe" + assert folder.parent_key == "/media/p4/Presets" assert folder.name == "probe" assert folder.is_factory is False assert "My Presets" not in folder.key, "a setlist is not nested inside My Presets" +def test_create_setlist_matches_cortex_control_builder_bytes(): + qc = client.QuadCortex(FakeTransport()) + qc.create_setlist("Tour") + msg = qc._t.sent[-1] + assert msg.SerializeToString() == bytes.fromhex( + "18 00 22 33 0a 16 2f 6d 65 64 69 61 2f 70 34 2f 50 72 65 73 65 74 " + "73 2f 54 6f 75 72 12 11 2f 6d 65 64 69 61 2f 70 34 2f 50 72 65 73 " + "65 74 73 1a 04 54 6f 75 72 20 00" + ) + + # -- master volume, pinning, setlist deletion ---------------------------------- @@ -2882,7 +2894,19 @@ def test_delete_setlist_addresses_the_folder_key(): msg = qc._t.sent[-1] assert msg.action == pa.MessageAction.DELETE assert msg.folder.key == "/media/p4/Presets/probe" - assert msg.folder.name == "probe" + assert msg.folder.is_factory is False + assert msg.delete_from_library is False + assert not msg.folder.HasField("name") + assert not msg.HasField("to_folder") + + +def test_delete_setlist_matches_cortex_control_builder_bytes(): + qc = client.QuadCortex(FakeTransport()) + qc.delete_setlist("Tour") + assert qc._t.sent[-1].SerializeToString() == bytes.fromhex( + "08 02 18 00 22 1a 0a 16 2f 6d 65 64 69 61 2f 70 34 2f 50 72 65 73 " + "65 74 73 2f 54 6f 75 72 20 00 50 00" + ) def test_looper_state_enum_omits_the_value_never_observed(): From bf4cd0a2efa2799dd9863b81556ad831512a14fc Mon Sep 17 00:00:00 2001 From: Antoni Ivanov <262547018+tony-xmelon@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:01:22 +0300 Subject: [PATCH 2/7] Scope setlist wire shapes to CorOS 4.1 --- docs/protocol.md | 2 ++ pyquadcortex/protocol/client.py | 14 +++++--------- pyquadcortex/protocol/profiles.py | 31 +++++++++++++++++++++++++++++-- tests/test_client.py | 18 ++++++++++-------- tests/test_profiles.py | 7 ++++--- 5 files changed, 50 insertions(+), 22 deletions(-) diff --git a/docs/protocol.md b/docs/protocol.md index cbe3b806..190b83a7 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -2828,7 +2828,9 @@ screen; **captured only** = seen on the wire, with no independent read-back. | `master_volume` / `set_master_volume` | `MasterVolume{READ}` / `{UPDATE, volume}` | read-back + on-unit + by ear | WRITABLE. The recorded "a write is ignored" was a STALE READ - a read straight after a write returns the previous value. Takes `Encoded`: the wire is 0..1 and the screen shows 0-100, but what that number IS has not been established, so no dB is offered. After a host write the physical knob soft-takes-over | | `pin_model` / `unpin_model` / `pinned_models` | `PinnedModels{models}` with NO action / `{DELETE, models}` | read-back + on-unit | pinning APPENDS and can duplicate; DELETE removes every entry for an id | | `delete_setlist` | `File{DELETE, folder{key, name}}` | read-back | removes the setlist and its contents | +| `delete_setlist` (CorOS 4.1.0) | `File{DELETE, folder{key, is_factory: false}, delete_from_library: false}` | read-back | Cortex Control 4.1.0 shape measured on a disposable setlist 2026-09-11; destructive, so confirmation came from folder-listing read-back | | `create_setlist` | `File{CREATE, folder{key: "/media/p4/Presets/", name}}` | read-back + on-unit | setlists are siblings under the presets root, not children of My Presets | +| `create_setlist` (CorOS 4.1.0) | `File{CREATE, folder{key, parent_key: "/media/p4/Presets", name, is_factory: false}}` | read-back | Cortex Control 4.1.0 shape measured on a disposable setlist 2026-09-11; the 4.0.1 shape above remains profile-local | | `set_split_mute` | `Grid{UPDATE, preset{chains{row, splitBypass{bypass}}}}` | read-back | the single splitter/mixer MUTE; reported back in `mixBypass`, and one write sets all eight scenes | | `set_stomp_assignment` | `Grid{DELETE, stomp_mode_assignments{row, column}}` then `Grid{UPDATE, ...{stomp_index}}` | read-back + on-unit | the unit's own two-message sequence; an UPDATE alone leaves the old assignment | | `set_stomp_momentary` | `Grid{UPDATE, preset{stomp_is_momentary{key, value}}}` | read-back + on-unit | keyed by footswitch, not column. **Only lands on a switch driving exactly one block** - the device refuses multi-block switches silently, as its own toggle does | diff --git a/pyquadcortex/protocol/client.py b/pyquadcortex/protocol/client.py index e938a2a9..847cabb4 100644 --- a/pyquadcortex/protocol/client.py +++ b/pyquadcortex/protocol/client.py @@ -3286,7 +3286,6 @@ def create_setlist(self, name: str): watching the unit create one and then doing the same from the host: File{CREATE, type: 0, folder{key: "/media/p4/Presets/", - parent_key: "/media/p4/Presets", name: "", is_factory: false}} The new key works everywhere a setlist path does, so presets can be saved @@ -3295,7 +3294,6 @@ def create_setlist(self, name: str): """ msg = pa.FileMessage(type=0) msg.folder.key = f"{USER_SETLIST_ROOT}/{name}" - msg.folder.parent_key = USER_SETLIST_ROOT msg.folder.name = name msg.folder.is_factory = False self._file_operation(msg) @@ -3408,16 +3406,14 @@ def unpin_model(self, model): def delete_setlist(self, name: str): """Delete a setlist and whatever it holds. - ``File{DELETE, folder{key, is_factory: false}, - delete_from_library: false}`` against the setlist's own key. - Confirmed: the folder disappears from the listing. Like the other file - operations this is eventually consistent, so re-enumerate rather than - checking immediately. + ``File{DELETE, folder{key, name}}`` against the setlist's own key. + Confirmed on CorOS 4.0.1: the folder disappears from the listing. Like + the other file operations this is eventually consistent, so + re-enumerate rather than checking immediately. """ msg = pa.FileMessage(action=pa.MessageAction.DELETE, type=0) msg.folder.key = f"{USER_SETLIST_ROOT}/{name}" - msg.folder.is_factory = False - msg.delete_from_library = False + msg.folder.name = name return self._file_operation(msg) def copy_preset(self, from_setlist: str, position, to_setlist: str, diff --git a/pyquadcortex/protocol/profiles.py b/pyquadcortex/protocol/profiles.py index 18cd41e0..3c9aa9e0 100644 --- a/pyquadcortex/protocol/profiles.py +++ b/pyquadcortex/protocol/profiles.py @@ -8,7 +8,7 @@ """ from __future__ import annotations -from pyquadcortex.protocol.client import QuadCortex +from pyquadcortex.protocol.client import QuadCortex, USER_SETLIST_ROOT from pyquadcortex.protocol.proto import ProductionAutomation_pb2 as pa from pyquadcortex.protocol.support import Evidence, Hardware, NoSnapshot @@ -43,11 +43,38 @@ class QuadCortex41(QuadCortex): MEASURED_ON = ("4.1.0",) EVIDENCE = Evidence.CONTRIBUTED - VERIFIED = frozenset() + VERIFIED = frozenset({"create_setlist", "delete_setlist"}) models = NoSnapshot("coros_4_1_0") params = NoSnapshot("coros_4_1_0") options = NoSnapshot("coros_4_1_0") + def create_setlist(self, name: str): + """Create a user setlist using the CorOS 4.1 Cortex Control shape. + + Measured on CorOS 4.1.0 on 2026-09-11 against a disposable setlist: + ``parent_key`` is present and listing read-back confirms creation. + """ + msg = pa.FileMessage(type=0) + msg.folder.key = f"{USER_SETLIST_ROOT}/{name}" + msg.folder.parent_key = USER_SETLIST_ROOT + msg.folder.name = name + msg.folder.is_factory = False + self._file_operation(msg) + return msg.folder.key + + def delete_setlist(self, name: str): + """Delete a user setlist using the measured CorOS 4.1 shape. + + Measured on CorOS 4.1.0 on 2026-09-11 against a disposable setlist: + omitting ``name`` and sending the explicit false flags removed it, as + confirmed by listing read-back. + """ + msg = pa.FileMessage(action=pa.MessageAction.DELETE, type=0) + msg.folder.key = f"{USER_SETLIST_ROOT}/{name}" + msg.folder.is_factory = False + msg.delete_from_library = False + return self._file_operation(msg) + class QuadCortexMini(QuadCortex): """Quad Cortex Mini - recognised, not supported, and here to be finished. diff --git a/tests/test_client.py b/tests/test_client.py index 49f23479..fb8173b2 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -2829,14 +2829,15 @@ def test_create_setlist_uses_a_sibling_path_under_the_presets_root(): assert path == "/media/p4/Presets/probe" folder = qc._t.sent[-1].folder assert folder.key == "/media/p4/Presets/probe" - assert folder.parent_key == "/media/p4/Presets" + assert not folder.HasField("parent_key") assert folder.name == "probe" assert folder.is_factory is False assert "My Presets" not in folder.key, "a setlist is not nested inside My Presets" -def test_create_setlist_matches_cortex_control_builder_bytes(): - qc = client.QuadCortex(FakeTransport()) +def test_coros_4_1_create_setlist_serialization_pin(): + from pyquadcortex.protocol.profiles import QuadCortex41 + qc = QuadCortex41(FakeTransport()) qc.create_setlist("Tour") msg = qc._t.sent[-1] assert msg.SerializeToString() == bytes.fromhex( @@ -2894,14 +2895,15 @@ def test_delete_setlist_addresses_the_folder_key(): msg = qc._t.sent[-1] assert msg.action == pa.MessageAction.DELETE assert msg.folder.key == "/media/p4/Presets/probe" - assert msg.folder.is_factory is False - assert msg.delete_from_library is False - assert not msg.folder.HasField("name") + assert msg.folder.name == "probe" + assert not msg.folder.HasField("is_factory") + assert not msg.HasField("delete_from_library") assert not msg.HasField("to_folder") -def test_delete_setlist_matches_cortex_control_builder_bytes(): - qc = client.QuadCortex(FakeTransport()) +def test_coros_4_1_delete_setlist_serialization_pin(): + from pyquadcortex.protocol.profiles import QuadCortex41 + qc = QuadCortex41(FakeTransport()) qc.delete_setlist("Tour") assert qc._t.sent[-1].SerializeToString() == bytes.fromhex( "08 02 18 00 22 1a 0a 16 2f 6d 65 64 69 61 2f 70 34 2f 50 72 65 73 " diff --git a/tests/test_profiles.py b/tests/test_profiles.py index 4fd3c8f0..e651cb5a 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -248,17 +248,18 @@ def test_support_defaults_to_verified_and_is_readable(): assert qc.support is support.Support.EXPERIMENTAL -def test_the_4_1_stub_connects_but_verifies_nothing_and_has_no_snapshot(): +def test_the_4_1_profile_verifies_the_two_measured_setlist_shapes(): cls = profiles.QuadCortex41 assert issubclass(cls, client.QuadCortex) assert cls.MEASURED_ON == ("4.1.0",) assert cls.EVIDENCE is support.Evidence.CONTRIBUTED - assert cls.VERIFIED == frozenset() + assert cls.VERIFIED == frozenset({"create_setlist", "delete_setlist"}) assert cls.CC_VERSION == "4.0.1", "inherited: the contributor's runs announced 4.0.1" assert isinstance(cls.models, support.NoSnapshot) with pytest.raises(AttributeError, match="coros_4_1_0"): cls.models.Delay - assert len(cls(FakeTransport()).unverified_operations) == len(client.QuadCortex.operations()) + assert len(cls(FakeTransport()).unverified_operations) == \ + len(client.QuadCortex.operations()) - 2 def test_the_mini_stub_is_recognised_but_cannot_connect(): From a16cba6e8aec894d6797da68a32a38e88cf8efbb Mon Sep 17 00:00:00 2001 From: Antoni Ivanov <262547018+tony-xmelon@users.noreply.github.com> Date: Sat, 19 Sep 2026 22:44:06 +0300 Subject: [PATCH 3/7] Fix profile-aware session expectation --- tests/test_session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_session.py b/tests/test_session.py index 9c4bee4a..7f947652 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -506,7 +506,7 @@ def test_connect_hands_back_the_4_1_class_for_a_4_1_unit(fake_stack): qc = session.connect() assert type(qc) is profiles.QuadCortex41 assert qc.unverified_operations == ( - client.QuadCortex.operations() - {"create_local_backup"} + client.QuadCortex.operations() - profiles.QuadCortex41.VERIFIED ) with pytest.raises(errors.ControlNotDrivable): qc.switch_scene(1) From a706f3fba2bde1baf4f769302a516e497c1e53e1 Mon Sep 17 00:00:00 2001 From: Antoni Ivanov <262547018+tony-xmelon@users.noreply.github.com> Date: Sun, 27 Sep 2026 01:02:27 +0300 Subject: [PATCH 4/7] Record setlist profile measurements --- docs/protocol.md | 46 ++++++++++++++++++++++++------- pyquadcortex/protocol/profiles.py | 9 +++--- tests/test_client.py | 4 +-- 3 files changed, 43 insertions(+), 16 deletions(-) diff --git a/docs/protocol.md b/docs/protocol.md index a909d250..18ddf15f 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -1384,18 +1384,44 @@ sessions hold one `MOVE` (session 02, frame 25825) and one `DELETE` (session ### 10.3 Setlists -**Creating a setlist** is captured from the unit's own "New Setlist" and -confirmed host to unit. Setlists sit side by side under `/media/p4/Presets`: +Setlists sit side by side under `/media/p4/Presets`. The create shape is +profile-specific: ``` -File{CREATE, type: 0, folder{key: "/media/p4/Presets/", name: "", - is_factory: false}} +CorOS 4.0.1: +File{CREATE, type: 0, folder{key: "/media/p4/Presets/", name: ""}} + +CorOS 4.1.0: +File{CREATE, type: 0, folder{key: "/media/p4/Presets/", + parent_key: "/media/p4/Presets", + name: "", is_factory: false}} +``` + +The 4.0.1 create was captured from the unit's own "New Setlist" action and +confirmed host to unit. The 4.1.0 create shape was sent by the library on a +CorOS 4.1.0 unit on 2026-09-27; a fresh folder listing confirmed the disposable +setlist appeared. On both profiles the new key works anywhere a setlist path +does. So the MIDI documentation's "User folders" at bank-select LSB 2 to 12 +are folders a player creates. + +Delete is also profile-specific. CorOS 4.0.1 uses the shape captured on that +firmware: + +``` +File{DELETE, folder{key, name}} +``` + +CorOS 4.1.0's Cortex Control shape omits `name` and sends explicit false flags: + +``` +File{DELETE, type: 0, folder{key, is_factory: false}, + delete_from_library: false} ``` -The new key appears in the folder listing and works anywhere a setlist path -does. So the MIDI documentation's "User folders" at bank-select LSB 2 to 12 are -folders a player creates. **Deleting a setlist** is `File{DELETE, folder{key, -name}}` against the setlist's own key. +Both delete shapes were sent on CorOS 4.1.0 on 2026-09-27 against separate +empty disposable setlists; a fresh listing confirmed each folder was removed. +The 4.0.1 shape remains the base profile's behavior, while `QuadCortex41` +uses the 4.1.0 shape. **There is no host-drivable copy.** The unit's duplicate action sends a `File` `CREATE` for the destination and then narrates itself through `BulkOperation` @@ -2147,9 +2173,9 @@ wire, with no independent read-back. | `master_volume` / `set_master_volume` | `MasterVolume{READ}` / `{UPDATE, volume}` | read-back + on-unit + by ear | takes `Encoded`; the screen shows `round(v * 100)`. Never add `calibrate` | | `pin_model` / `unpin_model` / `pinned_models` | `PinnedModels{models}` with no action / `{DELETE, models}` | read-back + on-unit | pinning appends and can duplicate; `DELETE` removes every entry for an id | | `delete_setlist` | `File{DELETE, folder{key, name}}` | read-back | removes the setlist and its contents | -| `delete_setlist` on CorOS 4.1.0 | `File{DELETE, folder{key, is_factory: false}, delete_from_library: false}` | read-back | measured 2026-09-11 on a disposable setlist; the 4.0.1 shape above remains profile-local | +| `delete_setlist` on CorOS 4.1.0 | `File{DELETE, folder{key, is_factory: false}, delete_from_library: false}` | read-back | measured 2026-09-27 on an empty disposable setlist; the legacy shape also removed a separate disposable setlist on 4.1.0; the 4.0.1 base shape remains profile-local | | `create_setlist` | `File{CREATE, folder{key: "/media/p4/Presets/", name}}` | read-back + on-unit | setlists are siblings under the presets root | -| `create_setlist` on CorOS 4.1.0 | `File{CREATE, folder{key, parent_key: "/media/p4/Presets", name, is_factory: false}}` | read-back | measured 2026-09-11 on a disposable setlist; the 4.0.1 shape above remains profile-local | +| `create_setlist` on CorOS 4.1.0 | `File{CREATE, folder{key, parent_key: "/media/p4/Presets", name, is_factory: false}}` | read-back | measured 2026-09-27 on an empty disposable setlist; the 4.0.1 shape above remains profile-local | | `copy_preset` / `duplicate_setlist` | recall then `File{CREATE}` per preset | read-back | compositions; each recalls the source on the unit | | `set_split_mute` | `Grid{UPDATE, preset{chains{row, splitBypass{bypass}}}}` | read-back | reported back in `mixBypass`; one write sets all eight scenes | | `set_stomp_assignment` / `clear_stomp_assignment` | `Grid{DELETE, stomp_mode_assignments{row, column}}` then `Grid{UPDATE, ...{stomp_index}}` | read-back + on-unit | the unit's own two-message sequence | diff --git a/pyquadcortex/protocol/profiles.py b/pyquadcortex/protocol/profiles.py index 995cad2a..bd4edce6 100644 --- a/pyquadcortex/protocol/profiles.py +++ b/pyquadcortex/protocol/profiles.py @@ -53,7 +53,7 @@ class QuadCortex41(QuadCortex): def create_setlist(self, name: str): """Create a user setlist using the CorOS 4.1 Cortex Control shape. - Measured on CorOS 4.1.0 on 2026-09-11 against a disposable setlist: + Measured on CorOS 4.1.0 on 2026-09-27 against a disposable setlist: ``parent_key`` is present and listing read-back confirms creation. """ msg = pa.FileMessage(type=0) @@ -67,9 +67,10 @@ def create_setlist(self, name: str): def delete_setlist(self, name: str): """Delete a user setlist using the measured CorOS 4.1 shape. - Measured on CorOS 4.1.0 on 2026-09-11 against a disposable setlist: - omitting ``name`` and sending the explicit false flags removed it, as - confirmed by listing read-back. + Measured on CorOS 4.1.0 on 2026-09-27 against an empty disposable + setlist: omitting ``name`` and sending the explicit false flags removed + it, as confirmed by listing read-back. The legacy 4.0.1 shape also + removed a separate disposable setlist on this firmware. """ msg = pa.FileMessage(action=pa.MessageAction.DELETE, type=0) msg.folder.key = f"{USER_SETLIST_ROOT}/{name}" diff --git a/tests/test_client.py b/tests/test_client.py index 8d1ffacb..780ec4dd 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -3156,7 +3156,7 @@ def test_create_setlist_uses_a_sibling_path_under_the_presets_root(): assert "My Presets" not in folder.key, "a setlist is not nested inside My Presets" -def test_coros_4_1_create_setlist_serialization_pin(): +def test_coros_4_1_create_setlist_builder_serialization_pin(): from pyquadcortex.protocol.profiles import QuadCortex41 qc = QuadCortex41(FakeTransport()) qc.create_setlist("Tour") @@ -3222,7 +3222,7 @@ def test_delete_setlist_addresses_the_folder_key(): assert not msg.HasField("to_folder") -def test_coros_4_1_delete_setlist_serialization_pin(): +def test_coros_4_1_delete_setlist_builder_serialization_pin(): from pyquadcortex.protocol.profiles import QuadCortex41 qc = QuadCortex41(FakeTransport()) qc.delete_setlist("Tour") From 5b3f7a3c366d5d930dfaf440a67fabe1c5d697f8 Mon Sep 17 00:00:00 2001 From: Antoni Ivanov <262547018+tony-xmelon@users.noreply.github.com> Date: Sun, 27 Sep 2026 03:13:02 +0300 Subject: [PATCH 5/7] Verify disposable setlist profile operations --- tests/hardware/test_setlist_operations.py | 90 +++++++++++++++++++++++ tests/test_hardware_markers.py | 2 - 2 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 tests/hardware/test_setlist_operations.py diff --git a/tests/hardware/test_setlist_operations.py b/tests/hardware/test_setlist_operations.py new file mode 100644 index 00000000..461296e9 --- /dev/null +++ b/tests/hardware/test_setlist_operations.py @@ -0,0 +1,90 @@ +"""Disposable-folder hardware checks for the profile-owned setlist shapes.""" + +import time +import uuid + +import pytest + + +def _folder_named(qc, key, *, seconds=10.0): + return next((folder for folder in qc.list_folders(seconds=seconds) + if folder.key.rstrip("/") == key.rstrip("/")), None) + + +def _wait_for_folder(qc, key, *, present, timeout=60.0): + deadline = time.monotonic() + timeout + while True: + folder = _folder_named(qc, key, seconds=min(10.0, max( + 1.0, deadline - time.monotonic()))) + if (folder is not None) is present: + return folder + remaining = deadline - time.monotonic() + if remaining <= 0: + state = "appear" if present else "disappear" + raise AssertionError( + f"disposable setlist {key!r} did not {state} within {timeout}s") + time.sleep(min(2.0, remaining)) + + +@pytest.mark.verifies("create_setlist", "delete_setlist") +def test_create_and_delete_an_empty_disposable_setlist(qc, profile): + """Exercise both exact profile builders without touching user content. + + The only folder this test may delete is a unique, preflight-absent name + that it just created and read back as empty. It never saves, recalls, moves, + or deletes a preset. If DELETE has an uncertain result, it reads the folder + state and does not replay the persistent write. + """ + operations = ("create_setlist", "delete_setlist") + if any(name not in profile.VERIFIED for name in operations): + pytest.skip(f"setlist writes are not VERIFIED on {profile.__name__}") + + from pyquadcortex.protocol.client import USER_SETLIST_ROOT + + name = f"QC disposable {uuid.uuid4().hex[:12]}" + key = f"{USER_SETLIST_ROOT}/{name}" + assert _folder_named(qc, key) is None, ( + f"random disposable name already exists; refusing to touch {key!r}") + + create_attempted = False + delete_attempted = False + deleted = False + try: + # Set before invoking CREATE because a transport timeout can happen + # after the device has accepted the persistent write. + create_attempted = True + assert qc.create_setlist(name) == key + folder = _wait_for_folder(qc, key, present=True) + assert folder.name == name + assert folder.occupied == 0, ( + f"new disposable folder unexpectedly contains presets: {folder!r}; " + "refusing to delete its contents") + + # Do not retry this write if it errors or times out. The test makes a + # fresh listing check below and reports the exact folder if it remains. + delete_attempted = True + qc.delete_setlist(name) + _wait_for_folder(qc, key, present=False) + deleted = True + finally: + if create_attempted and not delete_attempted: + # CREATE may have committed before an error. Cleanup is allowed only + # after a fresh listing confirms this exact unique folder is empty. + folder = _folder_named(qc, key, seconds=20.0) + if folder is not None: + if folder.occupied != 0: + pytest.fail( + f"created disposable folder {key!r} now contains " + f"{folder.occupied} presets; left untouched for manual review") + qc.delete_setlist(name) + _wait_for_folder(qc, key, present=False) + deleted = True + if delete_attempted and not deleted: + # An uncertain DELETE is never replayed: tell the operator exactly + # which empty test folder may need manual cleanup. + folder = _folder_named(qc, key, seconds=10.0) + if folder is not None: + pytest.fail( + f"DELETE result was not confirmed; disposable folder " + f"{key!r} still appears in the fresh listing and was not " + "deleted a second time") diff --git a/tests/test_hardware_markers.py b/tests/test_hardware_markers.py index c8b4db51..89ab709c 100644 --- a/tests/test_hardware_markers.py +++ b/tests/test_hardware_markers.py @@ -35,8 +35,6 @@ "read_preset": "the undo/redo test reads its scratch copy to choose an edit, but verifies the history result rather than the read operation itself", "find_preset": "no hardware test yet; it needs a name that exists on the unit, which the loaded preset supplies - just not written", "wait_for_listing": "no hardware test yet; it is the polling wrapper round list_presets and says nothing until a write changes a listing", - "create_setlist": "no hardware test yet; it adds a folder to the owner's Directory that a failed run would leave behind", - "delete_setlist": "no hardware test yet; it deletes a folder and everything in it, which is the one preset-library mistake nothing can undo", "duplicate_setlist": "no hardware test yet; it copies a whole setlist preset by preset, so a failed run leaves a partial folder behind", # -- library listings whose CONTENT belongs to the owner ------------------- From 9ed2d4ebda818f8f64c903c536611c13ce549fd5 Mon Sep 17 00:00:00 2001 From: Antoni Ivanov <262547018+tony-xmelon@users.noreply.github.com> Date: Sun, 27 Sep 2026 03:32:30 +0300 Subject: [PATCH 6/7] Allow complete setlist listing windows in hardware test --- tests/hardware/test_setlist_operations.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/hardware/test_setlist_operations.py b/tests/hardware/test_setlist_operations.py index 461296e9..dc8f0752 100644 --- a/tests/hardware/test_setlist_operations.py +++ b/tests/hardware/test_setlist_operations.py @@ -5,17 +5,20 @@ import pytest +FOLDER_LIST_TIMEOUT = 20.0 -def _folder_named(qc, key, *, seconds=10.0): + +def _folder_named(qc, key, *, seconds=FOLDER_LIST_TIMEOUT): return next((folder for folder in qc.list_folders(seconds=seconds) if folder.key.rstrip("/") == key.rstrip("/")), None) -def _wait_for_folder(qc, key, *, present, timeout=60.0): +def _wait_for_folder(qc, key, *, present, timeout=90.0): deadline = time.monotonic() + timeout while True: - folder = _folder_named(qc, key, seconds=min(10.0, max( - 1.0, deadline - time.monotonic()))) + # A complete device listing can take about fifteen seconds; do not + # cut it off at ten and mistake a slow generation for a missing folder. + folder = _folder_named(qc, key) if (folder is not None) is present: return folder remaining = deadline - time.monotonic() @@ -70,7 +73,7 @@ def test_create_and_delete_an_empty_disposable_setlist(qc, profile): if create_attempted and not delete_attempted: # CREATE may have committed before an error. Cleanup is allowed only # after a fresh listing confirms this exact unique folder is empty. - folder = _folder_named(qc, key, seconds=20.0) + folder = _folder_named(qc, key) if folder is not None: if folder.occupied != 0: pytest.fail( @@ -82,7 +85,7 @@ def test_create_and_delete_an_empty_disposable_setlist(qc, profile): if delete_attempted and not deleted: # An uncertain DELETE is never replayed: tell the operator exactly # which empty test folder may need manual cleanup. - folder = _folder_named(qc, key, seconds=10.0) + folder = _folder_named(qc, key) if folder is not None: pytest.fail( f"DELETE result was not confirmed; disposable folder " From 544506d77868b513ec0b3dde12af562fc97cfa8f Mon Sep 17 00:00:00 2001 From: Antoni Ivanov <262547018+tony-xmelon@users.noreply.github.com> Date: Sun, 27 Sep 2026 07:24:10 +0300 Subject: [PATCH 7/7] Skip 4.1 setlist hardware test on unmeasured firmware --- tests/hardware/test_setlist_operations.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/hardware/test_setlist_operations.py b/tests/hardware/test_setlist_operations.py index dc8f0752..8792cc1e 100644 --- a/tests/hardware/test_setlist_operations.py +++ b/tests/hardware/test_setlist_operations.py @@ -39,7 +39,8 @@ def test_create_and_delete_an_empty_disposable_setlist(qc, profile): state and does not replay the persistent write. """ operations = ("create_setlist", "delete_setlist") - if any(name not in profile.VERIFIED for name in operations): + if ("4.1.0" not in profile.MEASURED_ON + or any(name not in profile.VERIFIED for name in operations)): pytest.skip(f"setlist writes are not VERIFIED on {profile.__name__}") from pyquadcortex.protocol.client import USER_SETLIST_ROOT