cgcardona / muse public
test_core_attributes.py python
171 lines 7.4 KB
0e0cbf44 feat: .museattributes + multidimensional MIDI merge (#11) Gabriel Cardona <cgcardona@gmail.com> 3d ago
1 """Tests for muse/core/attributes.py — .museattributes parser and resolver."""
2 from __future__ import annotations
3
4 import pathlib
5
6 import pytest
7
8 from muse.core.attributes import (
9 VALID_STRATEGIES,
10 AttributeRule,
11 load_attributes,
12 resolve_strategy,
13 )
14
15
16 # ---------------------------------------------------------------------------
17 # load_attributes
18 # ---------------------------------------------------------------------------
19
20
21 class TestLoadAttributes:
22 def test_missing_file_returns_empty(self, tmp_path: pathlib.Path) -> None:
23 assert load_attributes(tmp_path) == []
24
25 def test_empty_file_returns_empty(self, tmp_path: pathlib.Path) -> None:
26 (tmp_path / ".museattributes").write_text("")
27 assert load_attributes(tmp_path) == []
28
29 def test_comment_only_returns_empty(self, tmp_path: pathlib.Path) -> None:
30 (tmp_path / ".museattributes").write_text("# just a comment\n\n")
31 assert load_attributes(tmp_path) == []
32
33 def test_parses_single_rule(self, tmp_path: pathlib.Path) -> None:
34 (tmp_path / ".museattributes").write_text("drums/* * ours\n")
35 rules = load_attributes(tmp_path)
36 assert len(rules) == 1
37 assert rules[0].path_pattern == "drums/*"
38 assert rules[0].dimension == "*"
39 assert rules[0].strategy == "ours"
40 assert rules[0].source_line == 1
41
42 def test_parses_multiple_rules(self, tmp_path: pathlib.Path) -> None:
43 content = "drums/* * ours\nkeys/* harmonic theirs\n"
44 (tmp_path / ".museattributes").write_text(content)
45 rules = load_attributes(tmp_path)
46 assert len(rules) == 2
47 assert rules[0].path_pattern == "drums/*"
48 assert rules[1].path_pattern == "keys/*"
49
50 def test_skips_blank_lines(self, tmp_path: pathlib.Path) -> None:
51 content = "\ndrum/* * ours\n\nkeys/* * theirs\n\n"
52 (tmp_path / ".museattributes").write_text(content)
53 rules = load_attributes(tmp_path)
54 assert len(rules) == 2
55
56 def test_skips_comment_lines(self, tmp_path: pathlib.Path) -> None:
57 content = "# drums\ndrums/* * ours\n# keys\nkeys/* * theirs\n"
58 (tmp_path / ".museattributes").write_text(content)
59 rules = load_attributes(tmp_path)
60 assert len(rules) == 2
61
62 def test_preserves_source_line_numbers(self, tmp_path: pathlib.Path) -> None:
63 content = "# comment\n\ndrums/* * ours\n\nkeys/* harmonic theirs\n"
64 (tmp_path / ".museattributes").write_text(content)
65 rules = load_attributes(tmp_path)
66 assert rules[0].source_line == 3
67 assert rules[1].source_line == 5
68
69 def test_all_valid_strategies_accepted(self, tmp_path: pathlib.Path) -> None:
70 lines = "\n".join(
71 f"path{i}/* * {s}" for i, s in enumerate(sorted(VALID_STRATEGIES))
72 )
73 (tmp_path / ".museattributes").write_text(lines)
74 rules = load_attributes(tmp_path)
75 assert {r.strategy for r in rules} == VALID_STRATEGIES
76
77 def test_invalid_strategy_raises(self, tmp_path: pathlib.Path) -> None:
78 (tmp_path / ".museattributes").write_text("drums/* * badstrategy\n")
79 with pytest.raises(ValueError, match="badstrategy"):
80 load_attributes(tmp_path)
81
82 def test_too_few_fields_raises(self, tmp_path: pathlib.Path) -> None:
83 (tmp_path / ".museattributes").write_text("drums/* ours\n")
84 with pytest.raises(ValueError, match="3 fields"):
85 load_attributes(tmp_path)
86
87 def test_too_many_fields_raises(self, tmp_path: pathlib.Path) -> None:
88 (tmp_path / ".museattributes").write_text("drums/* * ours extra\n")
89 with pytest.raises(ValueError, match="3 fields"):
90 load_attributes(tmp_path)
91
92 def test_all_dimension_names_accepted(self, tmp_path: pathlib.Path) -> None:
93 dims = ["melodic", "rhythmic", "harmonic", "dynamic", "structural", "*", "custom"]
94 lines = "\n".join(f"path/* {d} auto" for d in dims)
95 (tmp_path / ".museattributes").write_text(lines)
96 rules = load_attributes(tmp_path)
97 assert [r.dimension for r in rules] == dims
98
99
100 # ---------------------------------------------------------------------------
101 # resolve_strategy
102 # ---------------------------------------------------------------------------
103
104
105 class TestResolveStrategy:
106 def test_empty_rules_returns_auto(self) -> None:
107 assert resolve_strategy([], "drums/kick.mid") == "auto"
108
109 def test_wildcard_dimension_matches_any(self) -> None:
110 rules = [AttributeRule("drums/*", "*", "ours", 1)]
111 assert resolve_strategy(rules, "drums/kick.mid") == "ours"
112 assert resolve_strategy(rules, "drums/kick.mid", "melodic") == "ours"
113 assert resolve_strategy(rules, "drums/kick.mid", "harmonic") == "ours"
114
115 def test_specific_dimension_matches_exact(self) -> None:
116 rules = [AttributeRule("keys/*", "harmonic", "theirs", 1)]
117 assert resolve_strategy(rules, "keys/piano.mid", "harmonic") == "theirs"
118
119 def test_specific_dimension_no_match_on_other(self) -> None:
120 rules = [AttributeRule("keys/*", "harmonic", "theirs", 1)]
121 assert resolve_strategy(rules, "keys/piano.mid", "melodic") == "auto"
122
123 def test_first_match_wins(self) -> None:
124 rules = [
125 AttributeRule("*", "*", "ours", 1),
126 AttributeRule("*", "*", "theirs", 2),
127 ]
128 assert resolve_strategy(rules, "any/file.mid") == "ours"
129
130 def test_more_specific_rule_wins_when_first(self) -> None:
131 rules = [
132 AttributeRule("drums/*", "*", "ours", 1),
133 AttributeRule("*", "*", "auto", 2),
134 ]
135 assert resolve_strategy(rules, "drums/kick.mid") == "ours"
136 assert resolve_strategy(rules, "keys/piano.mid") == "auto"
137
138 def test_no_path_match_returns_auto(self) -> None:
139 rules = [AttributeRule("drums/*", "*", "ours", 1)]
140 assert resolve_strategy(rules, "keys/piano.mid") == "auto"
141
142 def test_glob_star_star(self) -> None:
143 rules = [AttributeRule("src/**/*.mid", "*", "manual", 1)]
144 assert resolve_strategy(rules, "src/tracks/beat.mid") == "manual"
145
146 def test_wildcard_dimension_in_query_matches_any_rule_dim(self) -> None:
147 """When caller passes dimension='*', any rule dimension matches."""
148 rules = [AttributeRule("drums/*", "structural", "manual", 1)]
149 # Calling with dimension="*" means "give me any matching rule"
150 assert resolve_strategy(rules, "drums/kick.mid", "*") == "manual"
151
152 def test_fallback_rule_order(self) -> None:
153 rules = [
154 AttributeRule("keys/*", "harmonic", "theirs", 1),
155 AttributeRule("*", "*", "manual", 2),
156 ]
157 # keys/ file, harmonic → matched by first rule
158 assert resolve_strategy(rules, "keys/piano.mid", "harmonic") == "theirs"
159 # keys/ file, other dim → falls through to catch-all
160 assert resolve_strategy(rules, "keys/piano.mid", "dynamic") == "manual"
161 # unrelated path → catch-all
162 assert resolve_strategy(rules, "drums/kick.mid") == "manual"
163
164 def test_default_dimension_is_wildcard(self) -> None:
165 """Omitting dimension argument should match wildcard rules."""
166 rules = [AttributeRule("*", "*", "ours", 1)]
167 assert resolve_strategy(rules, "any.mid") == "ours"
168
169 def test_manual_strategy_returned(self) -> None:
170 rules = [AttributeRule("*", "structural", "manual", 1)]
171 assert resolve_strategy(rules, "song.mid", "structural") == "manual"