test_muse_attributes.py
python
| 1 | """Tests for maestro/services/muse_attributes.py. |
| 2 | |
| 3 | Covers: |
| 4 | - parse_museattributes_file: valid rules, comments, blank lines, malformed lines, |
| 5 | unknown strategies. |
| 6 | - resolve_strategy: exact track+dimension match, fnmatch wildcard patterns, |
| 7 | fallback to MergeStrategy.AUTO when no rule matches. |
| 8 | - load_attributes: returns empty list when file not found; reads and parses |
| 9 | when the file exists. |
| 10 | """ |
| 11 | from __future__ import annotations |
| 12 | |
| 13 | import pathlib |
| 14 | |
| 15 | import pytest |
| 16 | |
| 17 | from maestro.services.muse_attributes import ( |
| 18 | MergeStrategy, |
| 19 | MuseAttribute, |
| 20 | load_attributes, |
| 21 | parse_museattributes_file, |
| 22 | resolve_strategy, |
| 23 | ) |
| 24 | |
| 25 | |
| 26 | # --------------------------------------------------------------------------- |
| 27 | # parse_museattributes_file |
| 28 | # --------------------------------------------------------------------------- |
| 29 | |
| 30 | |
| 31 | class TestParseMuseattributesFile: |
| 32 | def test_parses_basic_rule(self) -> None: |
| 33 | content = "drums/* * ours\n" |
| 34 | rules = parse_museattributes_file(content) |
| 35 | assert len(rules) == 1 |
| 36 | assert rules[0].track_pattern == "drums/*" |
| 37 | assert rules[0].dimension == "*" |
| 38 | assert rules[0].strategy == MergeStrategy.OURS |
| 39 | |
| 40 | def test_parses_multiple_rules(self) -> None: |
| 41 | content = ( |
| 42 | "drums/* * ours\n" |
| 43 | "bass/* harmonic theirs\n" |
| 44 | "* * auto\n" |
| 45 | ) |
| 46 | rules = parse_museattributes_file(content) |
| 47 | assert len(rules) == 3 |
| 48 | assert rules[0].strategy == MergeStrategy.OURS |
| 49 | assert rules[1].strategy == MergeStrategy.THEIRS |
| 50 | assert rules[2].strategy == MergeStrategy.AUTO |
| 51 | |
| 52 | def test_ignores_blank_lines(self) -> None: |
| 53 | content = "\n\ndrum/* * ours\n\n" |
| 54 | rules = parse_museattributes_file(content) |
| 55 | assert len(rules) == 1 |
| 56 | |
| 57 | def test_ignores_comment_lines(self) -> None: |
| 58 | content = "# This is a comment\ndrum/* * ours\n" |
| 59 | rules = parse_museattributes_file(content) |
| 60 | assert len(rules) == 1 |
| 61 | |
| 62 | def test_skips_malformed_lines(self) -> None: |
| 63 | content = "bad-line-only-two-tokens ours\n" |
| 64 | rules = parse_museattributes_file(content) |
| 65 | assert len(rules) == 0 |
| 66 | |
| 67 | def test_skips_unknown_strategy(self) -> None: |
| 68 | content = "drums/* * unknown_strategy\n" |
| 69 | rules = parse_museattributes_file(content) |
| 70 | assert len(rules) == 0 |
| 71 | |
| 72 | def test_all_valid_strategies(self) -> None: |
| 73 | content = ( |
| 74 | "a * ours\n" |
| 75 | "b * theirs\n" |
| 76 | "c * union\n" |
| 77 | "d * auto\n" |
| 78 | "e * manual\n" |
| 79 | ) |
| 80 | rules = parse_museattributes_file(content) |
| 81 | strategies = [r.strategy for r in rules] |
| 82 | assert MergeStrategy.OURS in strategies |
| 83 | assert MergeStrategy.THEIRS in strategies |
| 84 | assert MergeStrategy.UNION in strategies |
| 85 | assert MergeStrategy.AUTO in strategies |
| 86 | assert MergeStrategy.MANUAL in strategies |
| 87 | |
| 88 | def test_strategy_case_insensitive(self) -> None: |
| 89 | content = "drums/* * OURS\n" |
| 90 | rules = parse_museattributes_file(content) |
| 91 | assert len(rules) == 1 |
| 92 | assert rules[0].strategy == MergeStrategy.OURS |
| 93 | |
| 94 | def test_empty_content_returns_empty_list(self) -> None: |
| 95 | assert parse_museattributes_file("") == [] |
| 96 | |
| 97 | def test_only_comments_returns_empty_list(self) -> None: |
| 98 | content = "# only comments\n# nothing else\n" |
| 99 | assert parse_museattributes_file(content) == [] |
| 100 | |
| 101 | |
| 102 | # --------------------------------------------------------------------------- |
| 103 | # resolve_strategy |
| 104 | # --------------------------------------------------------------------------- |
| 105 | |
| 106 | |
| 107 | class TestResolveStrategy: |
| 108 | def _make_rule( |
| 109 | self, |
| 110 | track_pattern: str, |
| 111 | dimension: str, |
| 112 | strategy: MergeStrategy, |
| 113 | ) -> MuseAttribute: |
| 114 | return MuseAttribute( |
| 115 | track_pattern=track_pattern, |
| 116 | dimension=dimension, |
| 117 | strategy=strategy, |
| 118 | ) |
| 119 | |
| 120 | def test_exact_match_returns_configured_strategy(self) -> None: |
| 121 | attrs = [self._make_rule("drums/kick", "rhythmic", MergeStrategy.OURS)] |
| 122 | result = resolve_strategy(attrs, "drums/kick", "rhythmic") |
| 123 | assert result == MergeStrategy.OURS |
| 124 | |
| 125 | def test_fnmatch_wildcard_track_matches(self) -> None: |
| 126 | attrs = [self._make_rule("drums/*", "*", MergeStrategy.OURS)] |
| 127 | assert resolve_strategy(attrs, "drums/kick", "harmonic") == MergeStrategy.OURS |
| 128 | assert resolve_strategy(attrs, "drums/snare", "melodic") == MergeStrategy.OURS |
| 129 | |
| 130 | def test_star_track_matches_any(self) -> None: |
| 131 | attrs = [self._make_rule("*", "*", MergeStrategy.AUTO)] |
| 132 | assert resolve_strategy(attrs, "bass/electric", "harmonic") == MergeStrategy.AUTO |
| 133 | |
| 134 | def test_first_match_wins(self) -> None: |
| 135 | attrs = [ |
| 136 | self._make_rule("drums/*", "*", MergeStrategy.OURS), |
| 137 | self._make_rule("*", "*", MergeStrategy.AUTO), |
| 138 | ] |
| 139 | # drums/hi-hat should match the first rule |
| 140 | assert resolve_strategy(attrs, "drums/hi-hat", "rhythmic") == MergeStrategy.OURS |
| 141 | # keys/piano should fall through to the second rule |
| 142 | assert resolve_strategy(attrs, "keys/piano", "harmonic") == MergeStrategy.AUTO |
| 143 | |
| 144 | def test_no_match_returns_auto(self) -> None: |
| 145 | attrs = [self._make_rule("drums/*", "rhythmic", MergeStrategy.OURS)] |
| 146 | # Different track, no match |
| 147 | result = resolve_strategy(attrs, "bass/electric", "harmonic") |
| 148 | assert result == MergeStrategy.AUTO |
| 149 | |
| 150 | def test_empty_attributes_returns_auto(self) -> None: |
| 151 | result = resolve_strategy([], "drums/kick", "rhythmic") |
| 152 | assert result == MergeStrategy.AUTO |
| 153 | |
| 154 | def test_dimension_wildcard_matches_all_dimensions(self) -> None: |
| 155 | attrs = [self._make_rule("bass/*", "*", MergeStrategy.THEIRS)] |
| 156 | for dim in ("harmonic", "rhythmic", "melodic", "structural", "dynamic"): |
| 157 | assert resolve_strategy(attrs, "bass/electric", dim) == MergeStrategy.THEIRS |
| 158 | |
| 159 | def test_specific_dimension_does_not_match_other(self) -> None: |
| 160 | attrs = [self._make_rule("bass/*", "harmonic", MergeStrategy.THEIRS)] |
| 161 | assert resolve_strategy(attrs, "bass/electric", "harmonic") == MergeStrategy.THEIRS |
| 162 | assert resolve_strategy(attrs, "bass/electric", "rhythmic") == MergeStrategy.AUTO |
| 163 | |
| 164 | def test_theirs_strategy_resolved(self) -> None: |
| 165 | attrs = [self._make_rule("keys/*", "harmonic", MergeStrategy.THEIRS)] |
| 166 | assert resolve_strategy(attrs, "keys/piano", "harmonic") == MergeStrategy.THEIRS |
| 167 | |
| 168 | def test_union_strategy_resolved(self) -> None: |
| 169 | attrs = [self._make_rule("*", "structural", MergeStrategy.UNION)] |
| 170 | assert resolve_strategy(attrs, "any_track", "structural") == MergeStrategy.UNION |
| 171 | |
| 172 | def test_manual_strategy_resolved(self) -> None: |
| 173 | attrs = [self._make_rule("*", "*", MergeStrategy.MANUAL)] |
| 174 | assert resolve_strategy(attrs, "vocals/lead", "melodic") == MergeStrategy.MANUAL |
| 175 | |
| 176 | |
| 177 | # --------------------------------------------------------------------------- |
| 178 | # load_attributes |
| 179 | # --------------------------------------------------------------------------- |
| 180 | |
| 181 | |
| 182 | class TestLoadAttributes: |
| 183 | def test_returns_empty_list_when_file_not_found(self, tmp_path: pathlib.Path) -> None: |
| 184 | result = load_attributes(tmp_path) |
| 185 | assert result == [] |
| 186 | |
| 187 | def test_reads_and_parses_museattributes_file(self, tmp_path: pathlib.Path) -> None: |
| 188 | attr_file = tmp_path / ".museattributes" |
| 189 | attr_file.write_text("drums/* * ours\nbass/* harmonic theirs\n") |
| 190 | result = load_attributes(tmp_path) |
| 191 | assert len(result) == 2 |
| 192 | assert result[0].track_pattern == "drums/*" |
| 193 | assert result[0].strategy == MergeStrategy.OURS |
| 194 | assert result[1].strategy == MergeStrategy.THEIRS |
| 195 | |
| 196 | def test_returns_empty_list_for_empty_file(self, tmp_path: pathlib.Path) -> None: |
| 197 | attr_file = tmp_path / ".museattributes" |
| 198 | attr_file.write_text("") |
| 199 | result = load_attributes(tmp_path) |
| 200 | assert result == [] |
| 201 | |
| 202 | def test_returns_empty_list_for_comments_only_file(self, tmp_path: pathlib.Path) -> None: |
| 203 | attr_file = tmp_path / ".museattributes" |
| 204 | attr_file.write_text("# only comments here\n") |
| 205 | result = load_attributes(tmp_path) |
| 206 | assert result == [] |