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