cgcardona / muse public
test_core_attributes.py python
235 lines 9.5 KB
45fd2148 fix: config and versioning audit — TOML attributes, v0.1.1, no Phase N labels Gabriel Cardona <cgcardona@gmail.com> 2d 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 def _write_attrs(tmp_path: pathlib.Path, content: str) -> None:
19 (tmp_path / ".museattributes").write_text(content, encoding="utf-8")
20
21
22 # ---------------------------------------------------------------------------
23 # load_attributes
24 # ---------------------------------------------------------------------------
25
26
27 class TestLoadAttributes:
28 def test_missing_file_returns_empty(self, tmp_path: pathlib.Path) -> None:
29 assert load_attributes(tmp_path) == []
30
31 def test_empty_file_returns_empty(self, tmp_path: pathlib.Path) -> None:
32 _write_attrs(tmp_path, "")
33 assert load_attributes(tmp_path) == []
34
35 def test_comment_only_returns_empty(self, tmp_path: pathlib.Path) -> None:
36 _write_attrs(tmp_path, "# just a comment\n\n")
37 assert load_attributes(tmp_path) == []
38
39 def test_meta_only_returns_empty_rules(self, tmp_path: pathlib.Path) -> None:
40 _write_attrs(tmp_path, '[meta]\ndomain = "music"\n')
41 assert load_attributes(tmp_path) == []
42
43 def test_parses_single_rule(self, tmp_path: pathlib.Path) -> None:
44 _write_attrs(
45 tmp_path,
46 '[meta]\ndomain = "music"\n\n[[rules]]\npath = "drums/*"\ndimension = "*"\nstrategy = "ours"\n',
47 )
48 rules = load_attributes(tmp_path)
49 assert len(rules) == 1
50 assert rules[0].path_pattern == "drums/*"
51 assert rules[0].dimension == "*"
52 assert rules[0].strategy == "ours"
53 assert rules[0].source_index == 0
54
55 def test_parses_multiple_rules(self, tmp_path: pathlib.Path) -> None:
56 content = (
57 '[[rules]]\npath = "drums/*"\ndimension = "*"\nstrategy = "ours"\n\n'
58 '[[rules]]\npath = "keys/*"\ndimension = "harmonic"\nstrategy = "theirs"\n'
59 )
60 _write_attrs(tmp_path, content)
61 rules = load_attributes(tmp_path)
62 assert len(rules) == 2
63 assert rules[0].path_pattern == "drums/*"
64 assert rules[1].path_pattern == "keys/*"
65
66 def test_preserves_source_index(self, tmp_path: pathlib.Path) -> None:
67 content = (
68 '[[rules]]\npath = "drums/*"\ndimension = "*"\nstrategy = "ours"\n\n'
69 '[[rules]]\npath = "keys/*"\ndimension = "harmonic"\nstrategy = "theirs"\n'
70 )
71 _write_attrs(tmp_path, content)
72 rules = load_attributes(tmp_path)
73 assert rules[0].source_index == 0
74 assert rules[1].source_index == 1
75
76 def test_all_valid_strategies_accepted(self, tmp_path: pathlib.Path) -> None:
77 lines = "\n".join(
78 f'[[rules]]\npath = "path{i}/*"\ndimension = "*"\nstrategy = "{s}"\n'
79 for i, s in enumerate(sorted(VALID_STRATEGIES))
80 )
81 _write_attrs(tmp_path, lines)
82 rules = load_attributes(tmp_path)
83 assert {r.strategy for r in rules} == VALID_STRATEGIES
84
85 def test_invalid_strategy_raises(self, tmp_path: pathlib.Path) -> None:
86 _write_attrs(
87 tmp_path,
88 '[[rules]]\npath = "drums/*"\ndimension = "*"\nstrategy = "badstrategy"\n',
89 )
90 with pytest.raises(ValueError, match="badstrategy"):
91 load_attributes(tmp_path)
92
93 def test_missing_required_field_raises(self, tmp_path: pathlib.Path) -> None:
94 # Rule missing "strategy"
95 _write_attrs(
96 tmp_path,
97 '[[rules]]\npath = "drums/*"\ndimension = "*"\n',
98 )
99 with pytest.raises(ValueError, match="strategy"):
100 load_attributes(tmp_path)
101
102 def test_all_dimension_names_accepted(self, tmp_path: pathlib.Path) -> None:
103 dims = ["melodic", "rhythmic", "harmonic", "dynamic", "structural", "*", "custom"]
104 lines = "\n".join(
105 f'[[rules]]\npath = "path/*"\ndimension = "{d}"\nstrategy = "auto"\n'
106 for d in dims
107 )
108 _write_attrs(tmp_path, lines)
109 rules = load_attributes(tmp_path)
110 assert [r.dimension for r in rules] == dims
111
112 def test_toml_parse_error_raises(self, tmp_path: pathlib.Path) -> None:
113 _write_attrs(tmp_path, "this is not valid toml [\n")
114 with pytest.raises(ValueError, match="TOML parse error"):
115 load_attributes(tmp_path)
116
117 def test_domain_kwarg_mismatch_warns(
118 self, tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture
119 ) -> None:
120 _write_attrs(tmp_path, '[meta]\ndomain = "music"\n')
121 with caplog.at_level(logging.WARNING, logger="muse.core.attributes"):
122 load_attributes(tmp_path, domain="genomics")
123 assert "genomics" in caplog.text
124
125 def test_domain_kwarg_match_no_warning(
126 self, tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture
127 ) -> None:
128 _write_attrs(tmp_path, '[meta]\ndomain = "music"\n')
129 with caplog.at_level(logging.WARNING, logger="muse.core.attributes"):
130 load_attributes(tmp_path, domain="music")
131 assert caplog.text == ""
132
133 def test_no_domain_kwarg_no_warning(
134 self, tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture
135 ) -> None:
136 _write_attrs(tmp_path, '[meta]\ndomain = "music"\n')
137 with caplog.at_level(logging.WARNING, logger="muse.core.attributes"):
138 load_attributes(tmp_path)
139 assert caplog.text == ""
140
141
142 # ---------------------------------------------------------------------------
143 # read_attributes_meta
144 # ---------------------------------------------------------------------------
145
146
147 class TestReadAttributesMeta:
148 def test_missing_file_returns_empty(self, tmp_path: pathlib.Path) -> None:
149 assert read_attributes_meta(tmp_path) == {}
150
151 def test_no_meta_section_returns_empty(self, tmp_path: pathlib.Path) -> None:
152 _write_attrs(
153 tmp_path,
154 '[[rules]]\npath = "*"\ndimension = "*"\nstrategy = "auto"\n',
155 )
156 assert read_attributes_meta(tmp_path) == {}
157
158 def test_meta_domain_returned(self, tmp_path: pathlib.Path) -> None:
159 _write_attrs(tmp_path, '[meta]\ndomain = "music"\n')
160 meta = read_attributes_meta(tmp_path)
161 assert meta.get("domain") == "music"
162
163 def test_invalid_toml_returns_empty(self, tmp_path: pathlib.Path) -> None:
164 _write_attrs(tmp_path, "not valid toml [\n")
165 assert read_attributes_meta(tmp_path) == {}
166
167
168 # ---------------------------------------------------------------------------
169 # resolve_strategy
170 # ---------------------------------------------------------------------------
171
172
173 class TestResolveStrategy:
174 def test_empty_rules_returns_auto(self) -> None:
175 assert resolve_strategy([], "drums/kick.mid") == "auto"
176
177 def test_wildcard_dimension_matches_any(self) -> None:
178 rules = [AttributeRule("drums/*", "*", "ours", 0)]
179 assert resolve_strategy(rules, "drums/kick.mid") == "ours"
180 assert resolve_strategy(rules, "drums/kick.mid", "melodic") == "ours"
181 assert resolve_strategy(rules, "drums/kick.mid", "harmonic") == "ours"
182
183 def test_specific_dimension_matches_exact(self) -> None:
184 rules = [AttributeRule("keys/*", "harmonic", "theirs", 0)]
185 assert resolve_strategy(rules, "keys/piano.mid", "harmonic") == "theirs"
186
187 def test_specific_dimension_no_match_on_other(self) -> None:
188 rules = [AttributeRule("keys/*", "harmonic", "theirs", 0)]
189 assert resolve_strategy(rules, "keys/piano.mid", "melodic") == "auto"
190
191 def test_first_match_wins(self) -> None:
192 rules = [
193 AttributeRule("*", "*", "ours", 0),
194 AttributeRule("*", "*", "theirs", 1),
195 ]
196 assert resolve_strategy(rules, "any/file.mid") == "ours"
197
198 def test_more_specific_rule_wins_when_first(self) -> None:
199 rules = [
200 AttributeRule("drums/*", "*", "ours", 0),
201 AttributeRule("*", "*", "auto", 1),
202 ]
203 assert resolve_strategy(rules, "drums/kick.mid") == "ours"
204 assert resolve_strategy(rules, "keys/piano.mid") == "auto"
205
206 def test_no_path_match_returns_auto(self) -> None:
207 rules = [AttributeRule("drums/*", "*", "ours", 0)]
208 assert resolve_strategy(rules, "keys/piano.mid") == "auto"
209
210 def test_glob_star_star(self) -> None:
211 rules = [AttributeRule("src/**/*.mid", "*", "manual", 0)]
212 assert resolve_strategy(rules, "src/tracks/beat.mid") == "manual"
213
214 def test_wildcard_dimension_in_query_matches_any_rule_dim(self) -> None:
215 """When caller passes dimension='*', any rule dimension matches."""
216 rules = [AttributeRule("drums/*", "structural", "manual", 0)]
217 assert resolve_strategy(rules, "drums/kick.mid", "*") == "manual"
218
219 def test_fallback_rule_order(self) -> None:
220 rules = [
221 AttributeRule("keys/*", "harmonic", "theirs", 0),
222 AttributeRule("*", "*", "manual", 1),
223 ]
224 assert resolve_strategy(rules, "keys/piano.mid", "harmonic") == "theirs"
225 assert resolve_strategy(rules, "keys/piano.mid", "dynamic") == "manual"
226 assert resolve_strategy(rules, "drums/kick.mid") == "manual"
227
228 def test_default_dimension_is_wildcard(self) -> None:
229 """Omitting dimension argument should match wildcard rules."""
230 rules = [AttributeRule("*", "*", "ours", 0)]
231 assert resolve_strategy(rules, "any.mid") == "ours"
232
233 def test_manual_strategy_returned(self) -> None:
234 rules = [AttributeRule("*", "structural", "manual", 0)]
235 assert resolve_strategy(rules, "song.mid", "structural") == "manual"