cgcardona / muse public
test_core_ignore.py python
305 lines 11.9 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/ignore.py — .museignore parser and path filter."""
2
3 import pathlib
4
5 import pytest
6
7 from muse.core.ignore import _matches, is_ignored, load_patterns
8
9
10 # ---------------------------------------------------------------------------
11 # load_patterns
12 # ---------------------------------------------------------------------------
13
14
15 class TestLoadPatterns:
16 def test_returns_empty_when_no_file(self, tmp_path: pathlib.Path) -> None:
17 assert load_patterns(tmp_path) == []
18
19 def test_reads_patterns(self, tmp_path: pathlib.Path) -> None:
20 (tmp_path / ".museignore").write_text("*.tmp\n*.bak\n")
21 assert load_patterns(tmp_path) == ["*.tmp", "*.bak"]
22
23 def test_strips_blank_lines(self, tmp_path: pathlib.Path) -> None:
24 (tmp_path / ".museignore").write_text("\n*.tmp\n\n*.bak\n\n")
25 assert load_patterns(tmp_path) == ["*.tmp", "*.bak"]
26
27 def test_strips_comments(self, tmp_path: pathlib.Path) -> None:
28 (tmp_path / ".museignore").write_text(
29 "# ignore backups\n*.bak\n# and temps\n*.tmp\n"
30 )
31 assert load_patterns(tmp_path) == ["*.bak", "*.tmp"]
32
33 def test_strips_whitespace_around_patterns(self, tmp_path: pathlib.Path) -> None:
34 (tmp_path / ".museignore").write_text(" *.tmp \n *.bak \n")
35 assert load_patterns(tmp_path) == ["*.tmp", "*.bak"]
36
37 def test_inline_comment_not_stripped(self, tmp_path: pathlib.Path) -> None:
38 # Only leading-# lines are comments; inline # is part of the pattern.
39 (tmp_path / ".museignore").write_text("*.tmp # not a comment\n")
40 assert load_patterns(tmp_path) == ["*.tmp # not a comment"]
41
42 def test_negation_pattern_preserved(self, tmp_path: pathlib.Path) -> None:
43 (tmp_path / ".museignore").write_text("*.bak\n!keep.bak\n")
44 assert load_patterns(tmp_path) == ["*.bak", "!keep.bak"]
45
46 def test_empty_file(self, tmp_path: pathlib.Path) -> None:
47 (tmp_path / ".museignore").write_text("")
48 assert load_patterns(tmp_path) == []
49
50
51 # ---------------------------------------------------------------------------
52 # _matches (internal — gitignore path semantics)
53 # ---------------------------------------------------------------------------
54
55
56 class TestMatchesInternal:
57 """Verify the core matching logic in isolation."""
58
59 # ---- Patterns without slash: match any component ----
60
61 def test_ext_pattern_matches_top_level(self) -> None:
62 import pathlib as pl
63 assert _matches(pl.PurePosixPath("drums.tmp"), "*.tmp")
64
65 def test_ext_pattern_matches_nested(self) -> None:
66 import pathlib as pl
67 assert _matches(pl.PurePosixPath("tracks/drums.tmp"), "*.tmp")
68
69 def test_ext_pattern_matches_deep_nested(self) -> None:
70 import pathlib as pl
71 assert _matches(pl.PurePosixPath("a/b/c/drums.tmp"), "*.tmp")
72
73 def test_ext_pattern_no_false_positive(self) -> None:
74 import pathlib as pl
75 assert not _matches(pl.PurePosixPath("tracks/drums.mid"), "*.tmp")
76
77 def test_exact_name_matches_any_depth(self) -> None:
78 import pathlib as pl
79 assert _matches(pl.PurePosixPath("a/b/.DS_Store"), ".DS_Store")
80
81 def test_exact_name_top_level(self) -> None:
82 import pathlib as pl
83 assert _matches(pl.PurePosixPath(".DS_Store"), ".DS_Store")
84
85 # ---- Patterns with slash: match full path from right ----
86
87 def test_dir_ext_matches_direct_child(self) -> None:
88 import pathlib as pl
89 assert _matches(pl.PurePosixPath("tracks/drums.bak"), "tracks/*.bak")
90
91 def test_dir_ext_no_match_different_dir(self) -> None:
92 import pathlib as pl
93 assert not _matches(pl.PurePosixPath("exports/drums.bak"), "tracks/*.bak")
94
95 def test_double_star_matches_nested(self) -> None:
96 import pathlib as pl
97 assert _matches(pl.PurePosixPath("a/b/cache/index.dat"), "**/cache/*.dat")
98
99 def test_double_star_matches_shallow(self) -> None:
100 import pathlib as pl
101 # **/cache/*.dat should match cache/index.dat (** = zero components)
102 assert _matches(pl.PurePosixPath("cache/index.dat"), "**/cache/*.dat")
103
104 # ---- Anchored patterns (leading /) ----
105
106 def test_anchored_matches_root_level(self) -> None:
107 import pathlib as pl
108 assert _matches(pl.PurePosixPath("scratch.mid"), "/scratch.mid")
109
110 def test_anchored_no_match_nested(self) -> None:
111 import pathlib as pl
112 assert not _matches(pl.PurePosixPath("tracks/scratch.mid"), "/scratch.mid")
113
114 def test_anchored_dir_pattern_no_match_file(self) -> None:
115 import pathlib as pl
116 # /renders/*.wav anchored to root
117 assert _matches(pl.PurePosixPath("renders/mix.wav"), "/renders/*.wav")
118 assert not _matches(pl.PurePosixPath("exports/renders/mix.wav"), "/renders/*.wav")
119
120
121 # ---------------------------------------------------------------------------
122 # is_ignored — full rule evaluation with negation
123 # ---------------------------------------------------------------------------
124
125
126 class TestIsIgnored:
127 def test_empty_patterns_ignores_nothing(self) -> None:
128 assert not is_ignored("tracks/drums.mid", [])
129
130 def test_simple_ext_ignored(self) -> None:
131 assert is_ignored("drums.tmp", ["*.tmp"])
132
133 def test_simple_ext_nested_ignored(self) -> None:
134 assert is_ignored("tracks/drums.tmp", ["*.tmp"])
135
136 def test_non_matching_not_ignored(self) -> None:
137 assert not is_ignored("drums.mid", ["*.tmp"])
138
139 def test_directory_pattern_not_applied_to_file(self) -> None:
140 # Trailing / means directory-only; must not ignore a file.
141 assert not is_ignored("renders/mix.wav", ["renders/"])
142
143 def test_negation_un_ignores(self) -> None:
144 patterns = ["*.bak", "!keep.bak"]
145 assert is_ignored("session.bak", patterns)
146 assert not is_ignored("keep.bak", patterns)
147
148 def test_negation_nested_un_ignores(self) -> None:
149 patterns = ["*.bak", "!tracks/keeper.bak"]
150 assert is_ignored("tracks/session.bak", patterns)
151 assert not is_ignored("tracks/keeper.bak", patterns)
152
153 def test_last_rule_wins(self) -> None:
154 # First rule ignores, second negates, third re-ignores.
155 patterns = ["*.bak", "!session.bak", "*.bak"]
156 assert is_ignored("session.bak", patterns)
157
158 def test_anchored_pattern_root_only(self) -> None:
159 patterns = ["/scratch.mid"]
160 assert is_ignored("scratch.mid", patterns)
161 assert not is_ignored("tracks/scratch.mid", patterns)
162
163 def test_ds_store_at_any_depth(self) -> None:
164 patterns = [".DS_Store"]
165 assert is_ignored(".DS_Store", patterns)
166 assert is_ignored("tracks/.DS_Store", patterns)
167 assert is_ignored("a/b/c/.DS_Store", patterns)
168
169 def test_double_star_glob(self) -> None:
170 # Match *.pyc at any depth using a no-slash pattern.
171 assert is_ignored("__pycache__/foo.pyc", ["*.pyc"])
172 assert is_ignored("tracks/__pycache__/foo.pyc", ["*.pyc"])
173 # Pattern with embedded slash + ** at start.
174 assert is_ignored("cache/index.dat", ["**/cache/*.dat"])
175 assert is_ignored("a/b/cache/index.dat", ["**/cache/*.dat"])
176
177 def test_multiple_patterns_first_matches(self) -> None:
178 patterns = ["*.tmp", "*.bak"]
179 assert is_ignored("drums.tmp", patterns)
180 assert is_ignored("drums.bak", patterns)
181 assert not is_ignored("drums.mid", patterns)
182
183 def test_negation_before_rule_has_no_effect(self) -> None:
184 # Negation appears before the rule it would override — last rule wins,
185 # so the file ends up ignored.
186 patterns = ["!session.bak", "*.bak"]
187 assert is_ignored("session.bak", patterns)
188
189
190 # ---------------------------------------------------------------------------
191 # Integration: MidiPlugin.snapshot() honours .museignore
192 # ---------------------------------------------------------------------------
193
194
195 class TestMidiPluginSnapshotIgnore:
196 """End-to-end: .museignore filters out paths during snapshot()."""
197
198 def _make_repo(self, tmp_path: pathlib.Path) -> pathlib.Path:
199 """Create a minimal repo structure with a muse-work/ directory."""
200 workdir = tmp_path / "muse-work"
201 workdir.mkdir()
202 return tmp_path
203
204 def test_snapshot_without_museignore_includes_all(
205 self, tmp_path: pathlib.Path
206 ) -> None:
207 from muse.plugins.midi.plugin import MidiPlugin
208
209 root = self._make_repo(tmp_path)
210 workdir = root / "muse-work"
211 (workdir / "beat.mid").write_text("data")
212 (workdir / "session.tmp").write_text("temp")
213
214 plugin = MidiPlugin()
215 snap = plugin.snapshot(workdir)
216 assert "beat.mid" in snap["files"]
217 assert "session.tmp" in snap["files"]
218
219 def test_snapshot_excludes_ignored_files(self, tmp_path: pathlib.Path) -> None:
220 from muse.plugins.midi.plugin import MidiPlugin
221
222 root = self._make_repo(tmp_path)
223 workdir = root / "muse-work"
224 (workdir / "beat.mid").write_text("data")
225 (workdir / "session.tmp").write_text("temp")
226 (root / ".museignore").write_text("*.tmp\n")
227
228 plugin = MidiPlugin()
229 snap = plugin.snapshot(workdir)
230 assert "beat.mid" in snap["files"]
231 assert "session.tmp" not in snap["files"]
232
233 def test_snapshot_negation_keeps_file(self, tmp_path: pathlib.Path) -> None:
234 from muse.plugins.midi.plugin import MidiPlugin
235
236 root = self._make_repo(tmp_path)
237 workdir = root / "muse-work"
238 (workdir / "session.tmp").write_text("temp")
239 (workdir / "important.tmp").write_text("keep me")
240 (root / ".museignore").write_text("*.tmp\n!important.tmp\n")
241
242 plugin = MidiPlugin()
243 snap = plugin.snapshot(workdir)
244 assert "session.tmp" not in snap["files"]
245 assert "important.tmp" in snap["files"]
246
247 def test_snapshot_nested_pattern(self, tmp_path: pathlib.Path) -> None:
248 from muse.plugins.midi.plugin import MidiPlugin
249
250 root = self._make_repo(tmp_path)
251 workdir = root / "muse-work"
252 renders = workdir / "renders"
253 renders.mkdir()
254 (workdir / "beat.mid").write_text("data")
255 (renders / "preview.wav").write_text("audio")
256 (root / ".museignore").write_text("renders/*.wav\n")
257
258 plugin = MidiPlugin()
259 snap = plugin.snapshot(workdir)
260 assert "beat.mid" in snap["files"]
261 assert "renders/preview.wav" not in snap["files"]
262
263 def test_snapshot_dotfiles_always_excluded(self, tmp_path: pathlib.Path) -> None:
264 from muse.plugins.midi.plugin import MidiPlugin
265
266 root = self._make_repo(tmp_path)
267 workdir = root / "muse-work"
268 (workdir / "beat.mid").write_text("data")
269 (workdir / ".DS_Store").write_bytes(b"\x00" * 16)
270 # No .museignore — dotfiles excluded by the built-in rule.
271
272 plugin = MidiPlugin()
273 snap = plugin.snapshot(workdir)
274 assert "beat.mid" in snap["files"]
275 assert ".DS_Store" not in snap["files"]
276
277 def test_snapshot_with_empty_museignore(self, tmp_path: pathlib.Path) -> None:
278 from muse.plugins.midi.plugin import MidiPlugin
279
280 root = self._make_repo(tmp_path)
281 workdir = root / "muse-work"
282 (workdir / "beat.mid").write_text("data")
283 (root / ".museignore").write_text("# just a comment\n\n")
284
285 plugin = MidiPlugin()
286 snap = plugin.snapshot(workdir)
287 assert "beat.mid" in snap["files"]
288
289 def test_snapshot_directory_pattern_does_not_exclude_file(
290 self, tmp_path: pathlib.Path
291 ) -> None:
292 from muse.plugins.midi.plugin import MidiPlugin
293
294 root = self._make_repo(tmp_path)
295 workdir = root / "muse-work"
296 renders = workdir / "renders"
297 renders.mkdir()
298 (renders / "mix.wav").write_text("audio")
299 # Directory-only pattern — should not exclude files.
300 (root / ".museignore").write_text("renders/\n")
301
302 plugin = MidiPlugin()
303 snap = plugin.snapshot(workdir)
304 # The file is NOT excluded because trailing-/ patterns are directory-only.
305 assert "renders/mix.wav" in snap["files"]