cgcardona / muse public
test_core_attributes.py python
432 lines 17.6 KB
12559ad7 feat: supercharge .museattributes — base/union strategies, priority, co… Gabriel Cardona <cgcardona@gmail.com> 1d ago
1 """Tests for muse/core/attributes.py — .museattributes TOML parser and resolver."""
2 from __future__ import annotations
3
4 import logging
5 import pathlib
6
7 import pytest
8
9 from muse.core.attributes import (
10 VALID_STRATEGIES,
11 AttributeRule,
12 load_attributes,
13 read_attributes_meta,
14 resolve_strategy,
15 )
16
17
18 # ---------------------------------------------------------------------------
19 # Helpers
20 # ---------------------------------------------------------------------------
21
22
23 def _write_attrs(tmp_path: pathlib.Path, content: str) -> None:
24 (tmp_path / ".museattributes").write_text(content, encoding="utf-8")
25
26
27 # ---------------------------------------------------------------------------
28 # load_attributes
29 # ---------------------------------------------------------------------------
30
31
32 class TestLoadAttributes:
33 def test_missing_file_returns_empty(self, tmp_path: pathlib.Path) -> None:
34 assert load_attributes(tmp_path) == []
35
36 def test_empty_file_returns_empty(self, tmp_path: pathlib.Path) -> None:
37 _write_attrs(tmp_path, "")
38 assert load_attributes(tmp_path) == []
39
40 def test_comment_only_returns_empty(self, tmp_path: pathlib.Path) -> None:
41 _write_attrs(tmp_path, "# just a comment\n\n")
42 assert load_attributes(tmp_path) == []
43
44 def test_meta_only_returns_empty_rules(self, tmp_path: pathlib.Path) -> None:
45 _write_attrs(tmp_path, '[meta]\ndomain = "midi"\n')
46 assert load_attributes(tmp_path) == []
47
48 def test_parses_single_rule(self, tmp_path: pathlib.Path) -> None:
49 _write_attrs(
50 tmp_path,
51 '[meta]\ndomain = "midi"\n\n[[rules]]\npath = "drums/*"\ndimension = "*"\nstrategy = "ours"\n',
52 )
53 rules = load_attributes(tmp_path)
54 assert len(rules) == 1
55 assert rules[0].path_pattern == "drums/*"
56 assert rules[0].dimension == "*"
57 assert rules[0].strategy == "ours"
58 assert rules[0].source_index == 0
59
60 def test_parses_multiple_rules(self, tmp_path: pathlib.Path) -> None:
61 content = (
62 '[[rules]]\npath = "drums/*"\ndimension = "*"\nstrategy = "ours"\n\n'
63 '[[rules]]\npath = "keys/*"\ndimension = "pitch_bend"\nstrategy = "theirs"\n'
64 )
65 _write_attrs(tmp_path, content)
66 rules = load_attributes(tmp_path)
67 assert len(rules) == 2
68 assert rules[0].path_pattern == "drums/*"
69 assert rules[1].path_pattern == "keys/*"
70
71 def test_preserves_source_index(self, tmp_path: pathlib.Path) -> None:
72 content = (
73 '[[rules]]\npath = "drums/*"\ndimension = "*"\nstrategy = "ours"\n\n'
74 '[[rules]]\npath = "keys/*"\ndimension = "pitch_bend"\nstrategy = "theirs"\n'
75 )
76 _write_attrs(tmp_path, content)
77 rules = load_attributes(tmp_path)
78 assert rules[0].source_index == 0
79 assert rules[1].source_index == 1
80
81 def test_all_valid_strategies_accepted(self, tmp_path: pathlib.Path) -> None:
82 lines = "\n".join(
83 f'[[rules]]\npath = "path{i}/*"\ndimension = "*"\nstrategy = "{s}"\n'
84 for i, s in enumerate(sorted(VALID_STRATEGIES))
85 )
86 _write_attrs(tmp_path, lines)
87 rules = load_attributes(tmp_path)
88 assert {r.strategy for r in rules} == VALID_STRATEGIES
89
90 def test_invalid_strategy_raises(self, tmp_path: pathlib.Path) -> None:
91 _write_attrs(
92 tmp_path,
93 '[[rules]]\npath = "drums/*"\ndimension = "*"\nstrategy = "badstrategy"\n',
94 )
95 with pytest.raises(ValueError, match="badstrategy"):
96 load_attributes(tmp_path)
97
98 def test_missing_required_field_raises(self, tmp_path: pathlib.Path) -> None:
99 # Rule missing "strategy"
100 _write_attrs(
101 tmp_path,
102 '[[rules]]\npath = "drums/*"\ndimension = "*"\n',
103 )
104 with pytest.raises(ValueError, match="strategy"):
105 load_attributes(tmp_path)
106
107 def test_all_dimension_names_accepted(self, tmp_path: pathlib.Path) -> None:
108 dims = ["notes", "pitch_bend", "cc_volume", "cc_sustain", "track_structure", "*", "custom"]
109 lines = "\n".join(
110 f'[[rules]]\npath = "path/*"\ndimension = "{d}"\nstrategy = "auto"\n'
111 for d in dims
112 )
113 _write_attrs(tmp_path, lines)
114 rules = load_attributes(tmp_path)
115 assert [r.dimension for r in rules] == dims
116
117 def test_toml_parse_error_raises(self, tmp_path: pathlib.Path) -> None:
118 _write_attrs(tmp_path, "this is not valid toml [\n")
119 with pytest.raises(ValueError, match="TOML parse error"):
120 load_attributes(tmp_path)
121
122 def test_domain_kwarg_mismatch_warns(
123 self, tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture
124 ) -> None:
125 _write_attrs(tmp_path, '[meta]\ndomain = "midi"\n')
126 with caplog.at_level(logging.WARNING, logger="muse.core.attributes"):
127 load_attributes(tmp_path, domain="genomics")
128 assert "genomics" in caplog.text
129
130 def test_domain_kwarg_match_no_warning(
131 self, tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture
132 ) -> None:
133 _write_attrs(tmp_path, '[meta]\ndomain = "midi"\n')
134 with caplog.at_level(logging.WARNING, logger="muse.core.attributes"):
135 load_attributes(tmp_path, domain="midi")
136 assert caplog.text == ""
137
138 def test_no_domain_kwarg_no_warning(
139 self, tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture
140 ) -> None:
141 _write_attrs(tmp_path, '[meta]\ndomain = "midi"\n')
142 with caplog.at_level(logging.WARNING, logger="muse.core.attributes"):
143 load_attributes(tmp_path)
144 assert caplog.text == ""
145
146
147 # ---------------------------------------------------------------------------
148 # read_attributes_meta
149 # ---------------------------------------------------------------------------
150
151
152 class TestReadAttributesMeta:
153 def test_missing_file_returns_empty(self, tmp_path: pathlib.Path) -> None:
154 assert read_attributes_meta(tmp_path) == {}
155
156 def test_no_meta_section_returns_empty(self, tmp_path: pathlib.Path) -> None:
157 _write_attrs(
158 tmp_path,
159 '[[rules]]\npath = "*"\ndimension = "*"\nstrategy = "auto"\n',
160 )
161 assert read_attributes_meta(tmp_path) == {}
162
163 def test_meta_domain_returned(self, tmp_path: pathlib.Path) -> None:
164 _write_attrs(tmp_path, '[meta]\ndomain = "midi"\n')
165 meta = read_attributes_meta(tmp_path)
166 assert meta.get("domain") == "midi"
167
168 def test_invalid_toml_returns_empty(self, tmp_path: pathlib.Path) -> None:
169 _write_attrs(tmp_path, "not valid toml [\n")
170 assert read_attributes_meta(tmp_path) == {}
171
172
173 # ---------------------------------------------------------------------------
174 # resolve_strategy
175 # ---------------------------------------------------------------------------
176
177
178 class TestResolveStrategy:
179 def test_empty_rules_returns_auto(self) -> None:
180 assert resolve_strategy([], "drums/kick.mid") == "auto"
181
182 def test_wildcard_dimension_matches_any(self) -> None:
183 rules = [AttributeRule("drums/*", "*", "ours", 0)]
184 assert resolve_strategy(rules, "drums/kick.mid") == "ours"
185 assert resolve_strategy(rules, "drums/kick.mid", "notes") == "ours"
186 assert resolve_strategy(rules, "drums/kick.mid", "pitch_bend") == "ours"
187
188 def test_specific_dimension_matches_exact(self) -> None:
189 rules = [AttributeRule("keys/*", "pitch_bend", "theirs", 0)]
190 assert resolve_strategy(rules, "keys/piano.mid", "pitch_bend") == "theirs"
191
192 def test_specific_dimension_no_match_on_other(self) -> None:
193 rules = [AttributeRule("keys/*", "pitch_bend", "theirs", 0)]
194 assert resolve_strategy(rules, "keys/piano.mid", "notes") == "auto"
195
196 def test_first_match_wins(self) -> None:
197 rules = [
198 AttributeRule("*", "*", "ours", 0),
199 AttributeRule("*", "*", "theirs", 1),
200 ]
201 assert resolve_strategy(rules, "any/file.mid") == "ours"
202
203 def test_more_specific_rule_wins_when_first(self) -> None:
204 rules = [
205 AttributeRule("drums/*", "*", "ours", 0),
206 AttributeRule("*", "*", "auto", 1),
207 ]
208 assert resolve_strategy(rules, "drums/kick.mid") == "ours"
209 assert resolve_strategy(rules, "keys/piano.mid") == "auto"
210
211 def test_no_path_match_returns_auto(self) -> None:
212 rules = [AttributeRule("drums/*", "*", "ours", 0)]
213 assert resolve_strategy(rules, "keys/piano.mid") == "auto"
214
215 def test_glob_star_star(self) -> None:
216 rules = [AttributeRule("src/**/*.mid", "*", "manual", 0)]
217 assert resolve_strategy(rules, "src/tracks/beat.mid") == "manual"
218
219 def test_wildcard_dimension_in_query_matches_any_rule_dim(self) -> None:
220 """When caller passes dimension='*', any rule dimension matches."""
221 rules = [AttributeRule("drums/*", "track_structure", "manual", 0)]
222 assert resolve_strategy(rules, "drums/kick.mid", "*") == "manual"
223
224 def test_fallback_rule_order(self) -> None:
225 rules = [
226 AttributeRule("keys/*", "pitch_bend", "theirs", 0),
227 AttributeRule("*", "*", "manual", 1),
228 ]
229 assert resolve_strategy(rules, "keys/piano.mid", "pitch_bend") == "theirs"
230 assert resolve_strategy(rules, "keys/piano.mid", "cc_volume") == "manual"
231 assert resolve_strategy(rules, "drums/kick.mid") == "manual"
232
233 def test_default_dimension_is_wildcard(self) -> None:
234 """Omitting dimension argument should match wildcard rules."""
235 rules = [AttributeRule("*", "*", "ours", 0)]
236 assert resolve_strategy(rules, "any.mid") == "ours"
237
238 def test_manual_strategy_returned(self) -> None:
239 rules = [AttributeRule("*", "track_structure", "manual")]
240 assert resolve_strategy(rules, "song.mid", "track_structure") == "manual"
241
242
243 # ---------------------------------------------------------------------------
244 # New strategies: base and union
245 # ---------------------------------------------------------------------------
246
247
248 class TestNewStrategies:
249 def test_base_strategy_in_valid_set(self) -> None:
250 assert "base" in VALID_STRATEGIES
251
252 def test_union_strategy_in_valid_set(self) -> None:
253 assert "union" in VALID_STRATEGIES
254
255 def test_base_strategy_accepted_by_load(self, tmp_path: pathlib.Path) -> None:
256 (tmp_path / ".museattributes").write_text(
257 '[[rules]]\npath = "lock.json"\ndimension = "*"\nstrategy = "base"\n'
258 )
259 rules = load_attributes(tmp_path)
260 assert len(rules) == 1
261 assert rules[0].strategy == "base"
262
263 def test_union_strategy_accepted_by_load(self, tmp_path: pathlib.Path) -> None:
264 (tmp_path / ".museattributes").write_text(
265 '[[rules]]\npath = "docs/*"\ndimension = "*"\nstrategy = "union"\n'
266 )
267 rules = load_attributes(tmp_path)
268 assert rules[0].strategy == "union"
269
270 def test_base_strategy_resolved(self) -> None:
271 rules = [AttributeRule("lock.json", "*", "base")]
272 assert resolve_strategy(rules, "lock.json") == "base"
273
274 def test_union_strategy_resolved(self) -> None:
275 rules = [AttributeRule("docs/*", "*", "union")]
276 assert resolve_strategy(rules, "docs/api.md") == "union"
277
278
279 # ---------------------------------------------------------------------------
280 # comment field
281 # ---------------------------------------------------------------------------
282
283
284 class TestCommentField:
285 def test_comment_field_parsed(self, tmp_path: pathlib.Path) -> None:
286 (tmp_path / ".museattributes").write_text(
287 '[[rules]]\n'
288 'path = "drums/*"\n'
289 'dimension = "*"\n'
290 'strategy = "ours"\n'
291 'comment = "Drums are always authored on this branch."\n'
292 )
293 rules = load_attributes(tmp_path)
294 assert rules[0].comment == "Drums are always authored on this branch."
295
296 def test_comment_field_defaults_to_empty(self, tmp_path: pathlib.Path) -> None:
297 (tmp_path / ".museattributes").write_text(
298 '[[rules]]\npath = "*"\ndimension = "*"\nstrategy = "auto"\n'
299 )
300 rules = load_attributes(tmp_path)
301 assert rules[0].comment == ""
302
303 def test_comment_field_ignored_in_resolution(self) -> None:
304 rules = [AttributeRule("*", "*", "ours", comment="ignored at runtime")]
305 assert resolve_strategy(rules, "any/file.mid") == "ours"
306
307
308 # ---------------------------------------------------------------------------
309 # priority field
310 # ---------------------------------------------------------------------------
311
312
313 class TestPriorityField:
314 def test_priority_field_parsed(self, tmp_path: pathlib.Path) -> None:
315 (tmp_path / ".museattributes").write_text(
316 '[[rules]]\npath = "*"\ndimension = "*"\nstrategy = "ours"\npriority = 10\n'
317 )
318 rules = load_attributes(tmp_path)
319 assert rules[0].priority == 10
320
321 def test_priority_defaults_to_zero(self, tmp_path: pathlib.Path) -> None:
322 (tmp_path / ".museattributes").write_text(
323 '[[rules]]\npath = "*"\ndimension = "*"\nstrategy = "auto"\n'
324 )
325 rules = load_attributes(tmp_path)
326 assert rules[0].priority == 0
327
328 def test_higher_priority_overrides_lower_despite_file_order(
329 self, tmp_path: pathlib.Path
330 ) -> None:
331 """A low-priority catch-all declared first must not beat a high-priority rule."""
332 (tmp_path / ".museattributes").write_text(
333 '[[rules]]\n'
334 'path = "*"\ndimension = "*"\nstrategy = "theirs"\npriority = 0\n\n'
335 '[[rules]]\n'
336 'path = "drums/*"\ndimension = "*"\nstrategy = "ours"\npriority = 10\n'
337 )
338 rules = load_attributes(tmp_path)
339 # High-priority rule appears first after sort.
340 assert rules[0].path_pattern == "drums/*"
341 assert rules[0].strategy == "ours"
342 assert resolve_strategy(rules, "drums/kick.mid") == "ours"
343
344 def test_equal_priority_preserves_declaration_order(
345 self, tmp_path: pathlib.Path
346 ) -> None:
347 (tmp_path / ".museattributes").write_text(
348 '[[rules]]\n'
349 'path = "*"\ndimension = "*"\nstrategy = "ours"\npriority = 5\n\n'
350 '[[rules]]\n'
351 'path = "*"\ndimension = "*"\nstrategy = "theirs"\npriority = 5\n'
352 )
353 rules = load_attributes(tmp_path)
354 # Same priority → declaration order preserved; first match wins.
355 assert resolve_strategy(rules, "any/file.mid") == "ours"
356
357 def test_priority_negative_values_allowed(self, tmp_path: pathlib.Path) -> None:
358 (tmp_path / ".museattributes").write_text(
359 '[[rules]]\n'
360 'path = "*"\ndimension = "*"\nstrategy = "auto"\npriority = -5\n\n'
361 '[[rules]]\n'
362 'path = "src/*"\ndimension = "*"\nstrategy = "ours"\npriority = 0\n'
363 )
364 rules = load_attributes(tmp_path)
365 # priority=0 rule is higher than priority=-5, so it sorts first.
366 assert rules[0].path_pattern == "src/*"
367
368 def test_priority_affects_all_valid_strategies(
369 self, tmp_path: pathlib.Path
370 ) -> None:
371 (tmp_path / ".museattributes").write_text(
372 '[[rules]]\n'
373 'path = "a/*"\ndimension = "*"\nstrategy = "base"\npriority = 1\n\n'
374 '[[rules]]\n'
375 'path = "a/*"\ndimension = "*"\nstrategy = "union"\npriority = 100\n'
376 )
377 rules = load_attributes(tmp_path)
378 # "union" has higher priority, is resolved first for "a/*".
379 assert resolve_strategy(rules, "a/file.txt") == "union"
380
381
382 # ---------------------------------------------------------------------------
383 # Full-stack: comment + priority + new strategies together
384 # ---------------------------------------------------------------------------
385
386
387 class TestFullRuleComposition:
388 def test_all_new_fields_round_trip(self, tmp_path: pathlib.Path) -> None:
389 (tmp_path / ".museattributes").write_text(
390 '[meta]\ndomain = "code"\n\n'
391 '[[rules]]\n'
392 'path = "src/generated/**"\n'
393 'dimension = "*"\n'
394 'strategy = "base"\n'
395 'comment = "Generated — always revert to ancestor."\n'
396 'priority = 30\n\n'
397 '[[rules]]\n'
398 'path = "tests/**"\n'
399 'dimension = "symbols"\n'
400 'strategy = "union"\n'
401 'comment = "Test additions from both branches are safe."\n'
402 'priority = 10\n'
403 )
404 rules = load_attributes(tmp_path, domain="code")
405 assert len(rules) == 2
406 # Higher priority rule first.
407 assert rules[0].path_pattern == "src/generated/**"
408 assert rules[0].strategy == "base"
409 assert rules[0].comment == "Generated — always revert to ancestor."
410 assert rules[0].priority == 30
411 assert rules[1].path_pattern == "tests/**"
412 assert rules[1].strategy == "union"
413 assert rules[1].priority == 10
414
415 def test_priority_sorts_midi_rules(self, tmp_path: pathlib.Path) -> None:
416 (tmp_path / ".museattributes").write_text(
417 '[meta]\ndomain = "midi"\n\n'
418 '[[rules]]\n'
419 'path = "*"\ndimension = "*"\nstrategy = "auto"\npriority = 0\n\n'
420 '[[rules]]\n'
421 'path = "master.mid"\ndimension = "*"\nstrategy = "manual"\npriority = 100\n\n'
422 '[[rules]]\n'
423 'path = "stems/*"\ndimension = "notes"\nstrategy = "union"\npriority = 20\n'
424 )
425 rules = load_attributes(tmp_path, domain="midi")
426 assert rules[0].path_pattern == "master.mid" # priority 100
427 assert rules[1].path_pattern == "stems/*" # priority 20
428 assert rules[2].path_pattern == "*" # priority 0
429
430 assert resolve_strategy(rules, "master.mid") == "manual"
431 assert resolve_strategy(rules, "stems/bass.mid", "notes") == "union"
432 assert resolve_strategy(rules, "other/file.mid") == "auto"