cgcardona / muse public
test_muse_validate.py python
575 lines 22.7 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """Tests for ``muse validate`` — CLI interface, exit codes, and per-check logic.
2
3 Coverage strategy
4 -----------------
5 - Regression: ``test_validate_exits_nonzero_on_errors`` — would have caught the
6 absence of the ``muse validate`` command.
7 - Unit: each check function in ``maestro.services.muse_validate`` in isolation.
8 - Integration: the ``run_validate`` orchestrator with real temporary directories.
9 - CLI layer: ``typer.testing.CliRunner`` against the full ``muse`` app so that
10 flag parsing, exit codes, and output format are exercised end-to-end.
11 - Edge cases: missing workdir, no commits yet, ``--strict`` mode, ``--json`` output.
12 """
13 from __future__ import annotations
14
15 import json
16 import pathlib
17 import struct
18 import uuid
19
20 import pytest
21 from typer.testing import CliRunner
22
23 from maestro.muse_cli.app import cli
24 from maestro.muse_cli.errors import ExitCode
25 from maestro.services.muse_validate import (
26 ALLOWED_EMOTION_TAGS,
27 MuseValidateResult,
28 ValidationSeverity,
29 apply_fixes,
30 check_emotion_tags,
31 check_manifest_consistency,
32 check_midi_integrity,
33 check_no_duplicate_tracks,
34 check_section_naming,
35 run_validate,
36 )
37
38 runner = CliRunner()
39
40
41 # ---------------------------------------------------------------------------
42 # Helpers
43 # ---------------------------------------------------------------------------
44
45
46 def _make_valid_midi(path: pathlib.Path) -> None:
47 """Write a minimal, structurally valid Standard MIDI File to *path*."""
48 path.parent.mkdir(parents=True, exist_ok=True)
49 with path.open("wb") as fh:
50 # MThd header: magic + chunk length (always 6) + format (1) + ntracks (1) + division (96)
51 fh.write(b"MThd")
52 fh.write(struct.pack(">I", 6)) # chunk length
53 fh.write(struct.pack(">H", 1)) # format 1
54 fh.write(struct.pack(">H", 1)) # 1 track
55 fh.write(struct.pack(">H", 96)) # 96 ticks/beat
56 # MTrk header with end-of-track event
57 fh.write(b"MTrk")
58 fh.write(struct.pack(">I", 4)) # chunk length
59 fh.write(b"\x00\xff\x2f\x00") # delta=0, Meta=EOT
60
61
62 def _make_invalid_midi(path: pathlib.Path) -> None:
63 """Write a file that looks like MIDI but has a wrong header."""
64 path.parent.mkdir(parents=True, exist_ok=True)
65 path.write_bytes(b"JUNK" + b"\x00" * 20)
66
67
68 def _init_muse_repo(root: pathlib.Path, branch: str = "main") -> str:
69 """Create a minimal ``.muse/`` layout with no commits."""
70 rid = str(uuid.uuid4())
71 muse = root / ".muse"
72 (muse / "refs" / "heads").mkdir(parents=True)
73 (muse / "repo.json").write_text(json.dumps({"repo_id": rid, "schema_version": "1"}))
74 (muse / "HEAD").write_text(f"refs/heads/{branch}")
75 (muse / "refs" / "heads" / branch).write_text("")
76 return rid
77
78
79 def _commit_ref(root: pathlib.Path, branch: str = "main") -> None:
80 """Write a fake commit ID into the branch ref so HEAD is non-empty."""
81 muse = root / ".muse"
82 (muse / "refs" / "heads" / branch).write_text("a1b2c3d4" * 8)
83
84
85 # ---------------------------------------------------------------------------
86 # Regression test — the single test that would have caught the missing command
87 # ---------------------------------------------------------------------------
88
89
90 def test_validate_exits_nonzero_on_errors(tmp_path: pathlib.Path) -> None:
91 """muse validate must exit non-zero when MIDI integrity errors are found.
92
93 This is the regression test: if ``muse validate`` does not
94 exist or silently exits 0 on errors, this test fails.
95 """
96 _init_muse_repo(tmp_path)
97 workdir = tmp_path / "muse-work"
98 workdir.mkdir()
99 _make_invalid_midi(workdir / "bass.mid")
100
101 result = runner.invoke(cli, ["validate"], env={"MUSE_REPO_ROOT": str(tmp_path)})
102 assert result.exit_code != 0, (
103 "muse validate should exit non-zero when MIDI integrity errors are found"
104 )
105
106
107 # ---------------------------------------------------------------------------
108 # check_midi_integrity
109 # ---------------------------------------------------------------------------
110
111
112 class TestCheckMidiIntegrity:
113 def test_valid_midi_passes(self, tmp_path: pathlib.Path) -> None:
114 workdir = tmp_path / "muse-work"
115 _make_valid_midi(workdir / "bass.mid")
116 result = check_midi_integrity(workdir)
117 assert result.passed
118 assert result.issues == []
119
120 def test_invalid_midi_produces_error(self, tmp_path: pathlib.Path) -> None:
121 workdir = tmp_path / "muse-work"
122 _make_invalid_midi(workdir / "corrupted.mid")
123 result = check_midi_integrity(workdir)
124 assert not result.passed
125 assert len(result.issues) == 1
126 assert result.issues[0].severity == ValidationSeverity.ERROR
127 assert "corrupted.mid" in result.issues[0].path
128
129 def test_missing_workdir_is_clean(self, tmp_path: pathlib.Path) -> None:
130 workdir = tmp_path / "muse-work"
131 result = check_midi_integrity(workdir)
132 assert result.passed
133
134 def test_track_filter_excludes_unmatched_files(self, tmp_path: pathlib.Path) -> None:
135 workdir = tmp_path / "muse-work"
136 _make_invalid_midi(workdir / "drums.mid")
137 _make_valid_midi(workdir / "bass.mid")
138 result = check_midi_integrity(workdir, track_filter="bass")
139 # Only bass.mid is checked and it's valid
140 assert result.passed
141
142 def test_track_filter_includes_invalid_match(self, tmp_path: pathlib.Path) -> None:
143 workdir = tmp_path / "muse-work"
144 _make_invalid_midi(workdir / "drums.mid")
145 _make_valid_midi(workdir / "bass.mid")
146 result = check_midi_integrity(workdir, track_filter="drums")
147 assert not result.passed
148 assert any("drums.mid" in i.path for i in result.issues)
149
150 def test_empty_workdir_is_clean(self, tmp_path: pathlib.Path) -> None:
151 workdir = tmp_path / "muse-work"
152 workdir.mkdir()
153 result = check_midi_integrity(workdir)
154 assert result.passed
155
156 def test_multiple_invalid_files(self, tmp_path: pathlib.Path) -> None:
157 workdir = tmp_path / "muse-work"
158 _make_invalid_midi(workdir / "a.mid")
159 _make_invalid_midi(workdir / "b.midi")
160 result = check_midi_integrity(workdir)
161 assert not result.passed
162 assert len(result.issues) == 2
163
164 def test_truncated_midi_header(self, tmp_path: pathlib.Path) -> None:
165 workdir = tmp_path / "muse-work"
166 path = workdir / "short.mid"
167 path.parent.mkdir(parents=True, exist_ok=True)
168 path.write_bytes(b"MThd\x00") # truncated after magic
169 result = check_midi_integrity(workdir)
170 assert not result.passed
171
172 def test_wrong_chunk_length(self, tmp_path: pathlib.Path) -> None:
173 workdir = tmp_path / "muse-work"
174 path = workdir / "badlen.mid"
175 path.parent.mkdir(parents=True, exist_ok=True)
176 with path.open("wb") as fh:
177 fh.write(b"MThd")
178 fh.write(struct.pack(">I", 10)) # wrong: must be 6
179 fh.write(b"\x00" * 10)
180 result = check_midi_integrity(workdir)
181 assert not result.passed
182
183
184 # ---------------------------------------------------------------------------
185 # check_no_duplicate_tracks
186 # ---------------------------------------------------------------------------
187
188
189 class TestCheckNoDuplicateTracks:
190 def test_no_duplicates_passes(self, tmp_path: pathlib.Path) -> None:
191 workdir = tmp_path / "muse-work"
192 _make_valid_midi(workdir / "bass.mid")
193 _make_valid_midi(workdir / "drums.mid")
194 result = check_no_duplicate_tracks(workdir)
195 assert result.passed
196
197 def test_duplicate_role_produces_warning(self, tmp_path: pathlib.Path) -> None:
198 workdir = tmp_path / "muse-work"
199 _make_valid_midi(workdir / "bass.mid")
200 _make_valid_midi(workdir / "bass2.mid") # same role "bass" after stripping trailing digit
201 result = check_no_duplicate_tracks(workdir)
202 assert not result.passed
203 assert len(result.issues) == 1
204 assert result.issues[0].severity == ValidationSeverity.WARN
205 assert "bass" in result.issues[0].message
206
207 def test_numbered_variants_flagged(self, tmp_path: pathlib.Path) -> None:
208 workdir = tmp_path / "muse-work"
209 _make_valid_midi(workdir / "lead.mid")
210 _make_valid_midi(workdir / "lead01.mid")
211 _make_valid_midi(workdir / "lead-02.mid")
212 result = check_no_duplicate_tracks(workdir)
213 assert not result.passed
214
215 def test_missing_workdir_is_clean(self, tmp_path: pathlib.Path) -> None:
216 workdir = tmp_path / "muse-work"
217 result = check_no_duplicate_tracks(workdir)
218 assert result.passed
219
220 def test_track_filter_scopes_check(self, tmp_path: pathlib.Path) -> None:
221 workdir = tmp_path / "muse-work"
222 _make_valid_midi(workdir / "bass.mid")
223 _make_valid_midi(workdir / "bass_alt.mid")
224 _make_valid_midi(workdir / "drums.mid")
225 result = check_no_duplicate_tracks(workdir, track_filter="drums")
226 # Only drums is in scope — no duplicates there
227 assert result.passed
228
229
230 # ---------------------------------------------------------------------------
231 # check_section_naming
232 # ---------------------------------------------------------------------------
233
234
235 class TestCheckSectionNaming:
236 def test_valid_names_pass(self, tmp_path: pathlib.Path) -> None:
237 workdir = tmp_path / "muse-work"
238 (workdir / "verse").mkdir(parents=True)
239 (workdir / "chorus-01").mkdir()
240 (workdir / "bridge_02").mkdir()
241 result = check_section_naming(workdir)
242 assert result.passed
243
244 def test_uppercase_name_fails(self, tmp_path: pathlib.Path) -> None:
245 workdir = tmp_path / "muse-work"
246 (workdir / "Verse").mkdir(parents=True)
247 result = check_section_naming(workdir)
248 assert not result.passed
249 assert any("Verse" in i.path for i in result.issues)
250 assert result.issues[0].severity == ValidationSeverity.WARN
251
252 def test_name_with_spaces_fails(self, tmp_path: pathlib.Path) -> None:
253 workdir = tmp_path / "muse-work"
254 (workdir / "my section").mkdir(parents=True)
255 result = check_section_naming(workdir)
256 assert not result.passed
257
258 def test_name_starting_with_digit_fails(self, tmp_path: pathlib.Path) -> None:
259 workdir = tmp_path / "muse-work"
260 (workdir / "1verse").mkdir(parents=True)
261 result = check_section_naming(workdir)
262 assert not result.passed
263
264 def test_missing_workdir_is_clean(self, tmp_path: pathlib.Path) -> None:
265 workdir = tmp_path / "muse-work"
266 result = check_section_naming(workdir)
267 assert result.passed
268
269 def test_section_filter_excludes_bad_name(self, tmp_path: pathlib.Path) -> None:
270 workdir = tmp_path / "muse-work"
271 (workdir / "Verse").mkdir(parents=True)
272 (workdir / "chorus").mkdir()
273 result = check_section_naming(workdir, section_filter="chorus")
274 # Verse is out of scope for the filter
275 assert result.passed
276
277
278 # ---------------------------------------------------------------------------
279 # check_emotion_tags
280 # ---------------------------------------------------------------------------
281
282
283 class TestCheckEmotionTags:
284 def test_no_tag_cache_is_clean(self, tmp_path: pathlib.Path) -> None:
285 _init_muse_repo(tmp_path)
286 result = check_emotion_tags(tmp_path)
287 assert result.passed
288
289 def test_valid_tags_pass(self, tmp_path: pathlib.Path) -> None:
290 _init_muse_repo(tmp_path)
291 muse = tmp_path / ".muse"
292 tags = [{"tag": "happy"}, {"tag": "calm"}]
293 (muse / "tags.json").write_text(json.dumps(tags))
294 result = check_emotion_tags(tmp_path)
295 assert result.passed
296
297 def test_unknown_tag_produces_warning(self, tmp_path: pathlib.Path) -> None:
298 _init_muse_repo(tmp_path)
299 muse = tmp_path / ".muse"
300 tags = [{"tag": "happy"}, {"tag": "funky-fresh"}]
301 (muse / "tags.json").write_text(json.dumps(tags))
302 result = check_emotion_tags(tmp_path)
303 assert not result.passed
304 assert any("funky-fresh" in i.message for i in result.issues)
305 assert result.issues[0].severity == ValidationSeverity.WARN
306
307 def test_malformed_tag_cache_produces_warning(self, tmp_path: pathlib.Path) -> None:
308 _init_muse_repo(tmp_path)
309 muse = tmp_path / ".muse"
310 (muse / "tags.json").write_text("{not valid json")
311 result = check_emotion_tags(tmp_path)
312 assert not result.passed
313
314 def test_non_list_tag_cache_is_skipped(self, tmp_path: pathlib.Path) -> None:
315 _init_muse_repo(tmp_path)
316 muse = tmp_path / ".muse"
317 (muse / "tags.json").write_text(json.dumps({"tag": "happy"})) # dict, not list
318 result = check_emotion_tags(tmp_path)
319 assert result.passed
320
321 def test_all_allowed_tags_pass(self, tmp_path: pathlib.Path) -> None:
322 _init_muse_repo(tmp_path)
323 muse = tmp_path / ".muse"
324 tags = [{"tag": t} for t in sorted(ALLOWED_EMOTION_TAGS)]
325 (muse / "tags.json").write_text(json.dumps(tags))
326 result = check_emotion_tags(tmp_path)
327 assert result.passed
328
329
330 # ---------------------------------------------------------------------------
331 # check_manifest_consistency
332 # ---------------------------------------------------------------------------
333
334
335 class TestCheckManifestConsistency:
336 def test_no_commits_is_clean(self, tmp_path: pathlib.Path) -> None:
337 _init_muse_repo(tmp_path)
338 result = check_manifest_consistency(tmp_path)
339 assert result.passed
340
341 def test_no_snapshot_cache_is_clean(self, tmp_path: pathlib.Path) -> None:
342 _init_muse_repo(tmp_path)
343 _commit_ref(tmp_path)
344 # No .muse/snapshot_manifest.json — check skips gracefully
345 result = check_manifest_consistency(tmp_path)
346 assert result.passed
347
348 def test_matching_manifest_passes(self, tmp_path: pathlib.Path) -> None:
349 _init_muse_repo(tmp_path)
350 _commit_ref(tmp_path)
351 workdir = tmp_path / "muse-work"
352 _make_valid_midi(workdir / "bass.mid")
353 # Build a manifest matching the current working tree
354 from maestro.muse_cli.snapshot import hash_file
355 manifest = {"bass.mid": hash_file(workdir / "bass.mid")}
356 (tmp_path / ".muse" / "snapshot_manifest.json").write_text(json.dumps(manifest))
357 result = check_manifest_consistency(tmp_path)
358 assert result.passed
359
360 def test_orphaned_file_produces_error(self, tmp_path: pathlib.Path) -> None:
361 _init_muse_repo(tmp_path)
362 _commit_ref(tmp_path)
363 workdir = tmp_path / "muse-work"
364 workdir.mkdir()
365 # Manifest claims bass.mid exists, but it's not on disk
366 manifest = {"bass.mid": "abc123"}
367 (tmp_path / ".muse" / "snapshot_manifest.json").write_text(json.dumps(manifest))
368 result = check_manifest_consistency(tmp_path)
369 assert not result.passed
370 assert any(i.severity == ValidationSeverity.ERROR for i in result.issues)
371 assert any("bass.mid" in i.path for i in result.issues)
372
373 def test_unregistered_file_produces_warning(self, tmp_path: pathlib.Path) -> None:
374 _init_muse_repo(tmp_path)
375 _commit_ref(tmp_path)
376 workdir = tmp_path / "muse-work"
377 _make_valid_midi(workdir / "lead.mid")
378 # Empty committed manifest — lead.mid is unregistered
379 (tmp_path / ".muse" / "snapshot_manifest.json").write_text(json.dumps({}))
380 result = check_manifest_consistency(tmp_path)
381 assert not result.passed
382 assert any(i.severity == ValidationSeverity.WARN for i in result.issues)
383 assert any("lead.mid" in i.path for i in result.issues)
384
385 def test_malformed_snapshot_cache_produces_error(self, tmp_path: pathlib.Path) -> None:
386 _init_muse_repo(tmp_path)
387 _commit_ref(tmp_path)
388 (tmp_path / ".muse" / "snapshot_manifest.json").write_text("{broken json")
389 result = check_manifest_consistency(tmp_path)
390 assert not result.passed
391 assert any(i.severity == ValidationSeverity.ERROR for i in result.issues)
392
393
394 # ---------------------------------------------------------------------------
395 # run_validate orchestrator
396 # ---------------------------------------------------------------------------
397
398
399 class TestRunValidate:
400 def test_clean_repo_returns_clean(self, tmp_path: pathlib.Path) -> None:
401 _init_muse_repo(tmp_path)
402 workdir = tmp_path / "muse-work"
403 workdir.mkdir()
404 result = run_validate(tmp_path)
405 assert result.clean
406 assert not result.has_errors
407 assert not result.has_warnings
408
409 def test_invalid_midi_makes_has_errors_true(self, tmp_path: pathlib.Path) -> None:
410 _init_muse_repo(tmp_path)
411 workdir = tmp_path / "muse-work"
412 _make_invalid_midi(workdir / "corrupt.mid")
413 result = run_validate(tmp_path)
414 assert not result.clean
415 assert result.has_errors
416
417 def test_bad_section_name_makes_has_warnings_true(self, tmp_path: pathlib.Path) -> None:
418 _init_muse_repo(tmp_path)
419 workdir = tmp_path / "muse-work"
420 workdir.mkdir()
421 (workdir / "BadSection").mkdir()
422 result = run_validate(tmp_path)
423 assert not result.clean
424 assert result.has_warnings
425 assert not result.has_errors
426
427 def test_to_dict_is_serialisable(self, tmp_path: pathlib.Path) -> None:
428 _init_muse_repo(tmp_path)
429 result = run_validate(tmp_path)
430 data = result.to_dict()
431 # Must be JSON-serialisable without error
432 serialised = json.dumps(data)
433 assert "checks" in json.loads(serialised)
434
435 def test_track_filter_is_forwarded(self, tmp_path: pathlib.Path) -> None:
436 _init_muse_repo(tmp_path)
437 workdir = tmp_path / "muse-work"
438 _make_invalid_midi(workdir / "drums.mid")
439 _make_valid_midi(workdir / "bass.mid")
440 result = run_validate(tmp_path, track_filter="bass")
441 # drums.mid error should be excluded by filter
442 assert result.clean
443
444 def test_all_checks_are_present(self, tmp_path: pathlib.Path) -> None:
445 _init_muse_repo(tmp_path)
446 result = run_validate(tmp_path)
447 check_names = {c.name for c in result.checks}
448 assert "midi_integrity" in check_names
449 assert "manifest_consistency" in check_names
450 assert "no_duplicate_tracks" in check_names
451 assert "section_naming" in check_names
452 assert "emotion_tags" in check_names
453
454 def test_fixes_applied_empty_when_no_auto_fix(self, tmp_path: pathlib.Path) -> None:
455 _init_muse_repo(tmp_path)
456 result = run_validate(tmp_path, auto_fix=False)
457 assert result.fixes_applied == []
458
459
460 # ---------------------------------------------------------------------------
461 # apply_fixes
462 # ---------------------------------------------------------------------------
463
464
465 class TestApplyFixes:
466 def test_apply_fixes_returns_list(self, tmp_path: pathlib.Path) -> None:
467 from maestro.services.muse_validate import ValidationIssue
468 issues = [
469 ValidationIssue(
470 severity=ValidationSeverity.ERROR,
471 check="midi_integrity",
472 path="bass.mid",
473 message="Corrupted",
474 )
475 ]
476 workdir = tmp_path / "muse-work"
477 workdir.mkdir()
478 result = apply_fixes(workdir, issues)
479 assert isinstance(result, list)
480
481
482 # ---------------------------------------------------------------------------
483 # CLI integration tests
484 # ---------------------------------------------------------------------------
485
486
487 class TestValidateCli:
488 def test_clean_repo_exits_0(self, tmp_path: pathlib.Path) -> None:
489 _init_muse_repo(tmp_path)
490 (tmp_path / "muse-work").mkdir()
491 result = runner.invoke(
492 cli, ["validate"], env={"MUSE_REPO_ROOT": str(tmp_path)}
493 )
494 assert result.exit_code == 0
495 assert "clean" in result.output.lower() or "pass" in result.output.lower()
496
497 def test_invalid_midi_exits_1(self, tmp_path: pathlib.Path) -> None:
498 _init_muse_repo(tmp_path)
499 workdir = tmp_path / "muse-work"
500 _make_invalid_midi(workdir / "corrupt.mid")
501 result = runner.invoke(
502 cli, ["validate"], env={"MUSE_REPO_ROOT": str(tmp_path)}
503 )
504 assert result.exit_code == ExitCode.USER_ERROR
505
506 def test_strict_mode_exits_2_on_warnings(self, tmp_path: pathlib.Path) -> None:
507 _init_muse_repo(tmp_path)
508 workdir = tmp_path / "muse-work"
509 workdir.mkdir()
510 (workdir / "BadSection").mkdir()
511 result = runner.invoke(
512 cli, ["validate", "--strict"], env={"MUSE_REPO_ROOT": str(tmp_path)}
513 )
514 assert result.exit_code == 2
515
516 def test_clean_repo_strict_still_exits_0(self, tmp_path: pathlib.Path) -> None:
517 _init_muse_repo(tmp_path)
518 (tmp_path / "muse-work").mkdir()
519 result = runner.invoke(
520 cli, ["validate", "--strict"], env={"MUSE_REPO_ROOT": str(tmp_path)}
521 )
522 assert result.exit_code == 0
523
524 def test_json_output_is_parseable(self, tmp_path: pathlib.Path) -> None:
525 _init_muse_repo(tmp_path)
526 (tmp_path / "muse-work").mkdir()
527 result = runner.invoke(
528 cli, ["validate", "--json"], env={"MUSE_REPO_ROOT": str(tmp_path)}
529 )
530 assert result.exit_code == 0
531 data = json.loads(result.output)
532 assert "clean" in data
533 assert "checks" in data
534 assert isinstance(data["checks"], list)
535
536 def test_json_output_has_issues_on_error(self, tmp_path: pathlib.Path) -> None:
537 _init_muse_repo(tmp_path)
538 workdir = tmp_path / "muse-work"
539 _make_invalid_midi(workdir / "bad.mid")
540 result = runner.invoke(
541 cli, ["validate", "--json"], env={"MUSE_REPO_ROOT": str(tmp_path)}
542 )
543 assert result.exit_code != 0
544 data = json.loads(result.output)
545 assert data["has_errors"] is True
546
547 def test_not_a_repo_exits_2(self, tmp_path: pathlib.Path) -> None:
548 result = runner.invoke(
549 cli, ["validate"], env={"MUSE_REPO_ROOT": str(tmp_path / "nonexistent")}
550 )
551 assert result.exit_code == ExitCode.REPO_NOT_FOUND
552
553 def test_track_flag_scopes_checks(self, tmp_path: pathlib.Path) -> None:
554 _init_muse_repo(tmp_path)
555 workdir = tmp_path / "muse-work"
556 _make_invalid_midi(workdir / "drums.mid")
557 _make_valid_midi(workdir / "bass.mid")
558 result = runner.invoke(
559 cli, ["validate", "--track", "bass"],
560 env={"MUSE_REPO_ROOT": str(tmp_path)},
561 )
562 assert result.exit_code == 0
563
564 def test_fix_flag_runs_without_error(self, tmp_path: pathlib.Path) -> None:
565 _init_muse_repo(tmp_path)
566 (tmp_path / "muse-work").mkdir()
567 result = runner.invoke(
568 cli, ["validate", "--fix"], env={"MUSE_REPO_ROOT": str(tmp_path)}
569 )
570 assert result.exit_code == 0
571
572 def test_help_text_is_accessible(self) -> None:
573 result = runner.invoke(cli, ["validate", "--help"])
574 assert result.exit_code == 0
575 assert "integrity" in result.output.lower() or "validate" in result.output.lower()