test_domain_schema.py
python
| 1 | """Tests for domain schema declaration and plugin registry lookup. |
| 2 | |
| 3 | Verifies that: |
| 4 | - ``MidiPlugin.schema()`` returns a fully-typed ``DomainSchema``. |
| 5 | - All 21 MIDI dimensions are declared with the correct schema types. |
| 6 | - Independence flags match the semantic MIDI merge model. |
| 7 | - The schema is JSON round-trippable (all values are JSON-serialisable). |
| 8 | - ``schema_for()`` in the plugin registry performs the correct lookup. |
| 9 | - The protocol assertion still holds after adding ``schema()``. |
| 10 | """ |
| 11 | |
| 12 | import json |
| 13 | |
| 14 | import pytest |
| 15 | |
| 16 | from muse.core.schema import DomainSchema |
| 17 | from muse.domain import MuseDomainPlugin |
| 18 | from muse.plugins.midi.plugin import MidiPlugin |
| 19 | from muse.plugins.registry import registered_domains, schema_for |
| 20 | |
| 21 | # --------------------------------------------------------------------------- |
| 22 | # Expected dimension layout for the 21-dimension MIDI schema |
| 23 | # --------------------------------------------------------------------------- |
| 24 | |
| 25 | # (name, independent_merge, schema_kind) |
| 26 | _EXPECTED_DIMS: list[tuple[str, bool, str]] = [ |
| 27 | # Expressive note content |
| 28 | ("notes", True, "sequence"), |
| 29 | ("pitch_bend", True, "tensor"), |
| 30 | ("channel_pressure", True, "tensor"), |
| 31 | ("poly_pressure", True, "tensor"), |
| 32 | # Named CC controllers |
| 33 | ("cc_modulation", True, "tensor"), |
| 34 | ("cc_volume", True, "tensor"), |
| 35 | ("cc_pan", True, "tensor"), |
| 36 | ("cc_expression", True, "tensor"), |
| 37 | ("cc_sustain", True, "tensor"), |
| 38 | ("cc_portamento", True, "tensor"), |
| 39 | ("cc_sostenuto", True, "tensor"), |
| 40 | ("cc_soft_pedal", True, "tensor"), |
| 41 | ("cc_reverb", True, "tensor"), |
| 42 | ("cc_chorus", True, "tensor"), |
| 43 | ("cc_other", True, "tensor"), |
| 44 | # Patch selection |
| 45 | ("program_change", True, "sequence"), |
| 46 | # Non-independent timeline metadata |
| 47 | ("tempo_map", False, "sequence"), |
| 48 | ("time_signatures", False, "sequence"), |
| 49 | # Tonal / annotation metadata |
| 50 | ("key_signatures", True, "sequence"), |
| 51 | ("markers", True, "sequence"), |
| 52 | # Track structure (non-independent) |
| 53 | ("track_structure", False, "tree"), |
| 54 | ] |
| 55 | |
| 56 | _NON_INDEPENDENT = frozenset( |
| 57 | name for name, independent, _ in _EXPECTED_DIMS if not independent |
| 58 | ) |
| 59 | |
| 60 | |
| 61 | # --------------------------------------------------------------------------- |
| 62 | # Fixtures |
| 63 | # --------------------------------------------------------------------------- |
| 64 | |
| 65 | |
| 66 | @pytest.fixture() |
| 67 | def midi_plugin() -> MidiPlugin: |
| 68 | return MidiPlugin() |
| 69 | |
| 70 | |
| 71 | @pytest.fixture() |
| 72 | def midi_schema(midi_plugin: MidiPlugin) -> DomainSchema: |
| 73 | return midi_plugin.schema() |
| 74 | |
| 75 | |
| 76 | # =========================================================================== |
| 77 | # MidiPlugin.schema() — top-level structure |
| 78 | # =========================================================================== |
| 79 | |
| 80 | |
| 81 | class TestMidiPluginSchema: |
| 82 | def test_schema_returns_dict(self, midi_schema: DomainSchema) -> None: |
| 83 | assert isinstance(midi_schema, dict) |
| 84 | |
| 85 | def test_domain_is_midi(self, midi_schema: DomainSchema) -> None: |
| 86 | assert midi_schema["domain"] == "midi" |
| 87 | |
| 88 | def test_schema_version_is_1(self, midi_schema: DomainSchema) -> None: |
| 89 | assert midi_schema["schema_version"] == 1 |
| 90 | |
| 91 | def test_merge_mode_is_three_way(self, midi_schema: DomainSchema) -> None: |
| 92 | assert midi_schema["merge_mode"] == "three_way" |
| 93 | |
| 94 | def test_description_is_non_empty(self, midi_schema: DomainSchema) -> None: |
| 95 | assert isinstance(midi_schema["description"], str) |
| 96 | assert len(midi_schema["description"]) > 0 |
| 97 | |
| 98 | def test_top_level_is_set_schema(self, midi_schema: DomainSchema) -> None: |
| 99 | top = midi_schema["top_level"] |
| 100 | assert top["kind"] == "set" |
| 101 | |
| 102 | def test_top_level_element_type(self, midi_schema: DomainSchema) -> None: |
| 103 | top = midi_schema["top_level"] |
| 104 | assert top["kind"] == "set" |
| 105 | assert top["element_type"] == "audio_file" |
| 106 | assert top["identity"] == "by_content" |
| 107 | |
| 108 | |
| 109 | # =========================================================================== |
| 110 | # 21-dimension layout |
| 111 | # =========================================================================== |
| 112 | |
| 113 | |
| 114 | class TestMidiDimensions: |
| 115 | def test_exactly_21_dimensions(self, midi_schema: DomainSchema) -> None: |
| 116 | assert len(midi_schema["dimensions"]) == 21 |
| 117 | |
| 118 | def test_all_expected_dimension_names_present(self, midi_schema: DomainSchema) -> None: |
| 119 | names = {d["name"] for d in midi_schema["dimensions"]} |
| 120 | expected = {name for name, _, _ in _EXPECTED_DIMS} |
| 121 | assert names == expected |
| 122 | |
| 123 | def test_dimension_order_matches_spec(self, midi_schema: DomainSchema) -> None: |
| 124 | names = [d["name"] for d in midi_schema["dimensions"]] |
| 125 | expected = [name for name, _, _ in _EXPECTED_DIMS] |
| 126 | assert names == expected |
| 127 | |
| 128 | @pytest.mark.parametrize("name,independent,kind", _EXPECTED_DIMS) |
| 129 | def test_dimension_independence( |
| 130 | self, midi_schema: DomainSchema, name: str, independent: bool, kind: str |
| 131 | ) -> None: |
| 132 | dim = next(d for d in midi_schema["dimensions"] if d["name"] == name) |
| 133 | assert dim["independent_merge"] is independent, ( |
| 134 | f"Dimension '{name}': expected independent_merge={independent}" |
| 135 | ) |
| 136 | |
| 137 | @pytest.mark.parametrize("name,independent,kind", _EXPECTED_DIMS) |
| 138 | def test_dimension_schema_kind( |
| 139 | self, midi_schema: DomainSchema, name: str, independent: bool, kind: str |
| 140 | ) -> None: |
| 141 | dim = next(d for d in midi_schema["dimensions"] if d["name"] == name) |
| 142 | assert dim["schema"]["kind"] == kind, ( |
| 143 | f"Dimension '{name}': expected schema kind '{kind}', got '{dim['schema']['kind']}'" |
| 144 | ) |
| 145 | |
| 146 | def test_all_dimensions_have_description(self, midi_schema: DomainSchema) -> None: |
| 147 | for dim in midi_schema["dimensions"]: |
| 148 | assert isinstance(dim.get("description"), str), ( |
| 149 | f"Dimension '{dim['name']}' missing description" |
| 150 | ) |
| 151 | assert len(dim["description"]) > 0 |
| 152 | |
| 153 | def test_non_independent_set(self, midi_schema: DomainSchema) -> None: |
| 154 | non_indep = { |
| 155 | d["name"] for d in midi_schema["dimensions"] if not d["independent_merge"] |
| 156 | } |
| 157 | assert non_indep == _NON_INDEPENDENT |
| 158 | |
| 159 | def test_notes_dimension_sequence_fields(self, midi_schema: DomainSchema) -> None: |
| 160 | notes = next(d for d in midi_schema["dimensions"] if d["name"] == "notes") |
| 161 | schema = notes["schema"] |
| 162 | assert schema["kind"] == "sequence" |
| 163 | assert schema["element_type"] == "note_event" |
| 164 | assert schema["diff_algorithm"] == "lcs" |
| 165 | |
| 166 | def test_cc_dimensions_are_tensor_float32(self, midi_schema: DomainSchema) -> None: |
| 167 | cc_names = {name for name, _, kind in _EXPECTED_DIMS if kind == "tensor"} |
| 168 | for dim in midi_schema["dimensions"]: |
| 169 | if dim["name"] in cc_names: |
| 170 | s = dim["schema"] |
| 171 | assert s["kind"] == "tensor" |
| 172 | assert s["dtype"] == "float32" |
| 173 | assert s["diff_mode"] == "sparse" |
| 174 | |
| 175 | def test_track_structure_is_tree(self, midi_schema: DomainSchema) -> None: |
| 176 | ts = next(d for d in midi_schema["dimensions"] if d["name"] == "track_structure") |
| 177 | schema = ts["schema"] |
| 178 | assert schema["kind"] == "tree" |
| 179 | assert schema["node_type"] == "track_node" |
| 180 | assert schema["diff_algorithm"] == "zhang_shasha" |
| 181 | |
| 182 | |
| 183 | # =========================================================================== |
| 184 | # JSON round-trip |
| 185 | # =========================================================================== |
| 186 | |
| 187 | |
| 188 | class TestSchemaJsonRoundtrip: |
| 189 | def test_schema_is_json_serialisable(self, midi_schema: DomainSchema) -> None: |
| 190 | serialised = json.dumps(midi_schema) |
| 191 | restored = json.loads(serialised) |
| 192 | assert restored["domain"] == midi_schema["domain"] |
| 193 | assert restored["schema_version"] == midi_schema["schema_version"] |
| 194 | assert len(restored["dimensions"]) == len(midi_schema["dimensions"]) |
| 195 | assert restored["top_level"]["kind"] == midi_schema["top_level"]["kind"] |
| 196 | |
| 197 | def test_all_dimension_schemas_survive_roundtrip(self, midi_schema: DomainSchema) -> None: |
| 198 | serialised = json.dumps(midi_schema) |
| 199 | restored = json.loads(serialised) |
| 200 | original_kinds = {d["name"]: d["schema"]["kind"] for d in midi_schema["dimensions"]} |
| 201 | restored_kinds = {d["name"]: d["schema"]["kind"] for d in restored["dimensions"]} |
| 202 | assert original_kinds == restored_kinds |
| 203 | |
| 204 | |
| 205 | # =========================================================================== |
| 206 | # Plugin registry schema lookup |
| 207 | # =========================================================================== |
| 208 | |
| 209 | |
| 210 | class TestPluginRegistrySchemaLookup: |
| 211 | def test_schema_for_midi_returns_domain_schema(self) -> None: |
| 212 | result = schema_for("midi") |
| 213 | assert result is not None |
| 214 | assert result["domain"] == "midi" |
| 215 | |
| 216 | def test_schema_for_unknown_domain_returns_none(self) -> None: |
| 217 | result = schema_for("nonexistent_domain_xyz") |
| 218 | assert result is None |
| 219 | |
| 220 | def test_schema_for_matches_direct_plugin_call(self) -> None: |
| 221 | plugin = MidiPlugin() |
| 222 | direct = plugin.schema() |
| 223 | via_registry = schema_for("midi") |
| 224 | assert via_registry is not None |
| 225 | assert via_registry["domain"] == direct["domain"] |
| 226 | assert via_registry["schema_version"] == direct["schema_version"] |
| 227 | assert len(via_registry["dimensions"]) == len(direct["dimensions"]) |
| 228 | |
| 229 | def test_registered_domains_contains_midi(self) -> None: |
| 230 | assert "midi" in registered_domains() |
| 231 | |
| 232 | def test_music_key_not_in_registry(self) -> None: |
| 233 | """Ensure the old 'music' key was fully removed.""" |
| 234 | assert "music" not in registered_domains() |
| 235 | |
| 236 | def test_schema_for_all_registered_domains_returns_non_none(self) -> None: |
| 237 | for domain in registered_domains(): |
| 238 | result = schema_for(domain) |
| 239 | assert result is not None, f"schema_for({domain!r}) returned None" |
| 240 | |
| 241 | |
| 242 | # =========================================================================== |
| 243 | # Protocol conformance |
| 244 | # =========================================================================== |
| 245 | |
| 246 | |
| 247 | class TestProtocolConformance: |
| 248 | def test_midi_plugin_satisfies_protocol(self) -> None: |
| 249 | plugin = MidiPlugin() |
| 250 | assert isinstance(plugin, MuseDomainPlugin) |
| 251 | |
| 252 | def test_schema_method_is_callable(self) -> None: |
| 253 | plugin = MidiPlugin() |
| 254 | assert callable(plugin.schema) |
| 255 | |
| 256 | def test_schema_returns_domain_schema(self) -> None: |
| 257 | plugin = MidiPlugin() |
| 258 | result = plugin.schema() |
| 259 | assert isinstance(result, dict) |
| 260 | assert "domain" in result |
| 261 | assert "dimensions" in result |