personal memory agent
0

Configure Feed

Select the types of activity you want to include in your feed.

solstone / tests / test_importer_audio_sync.py
27 kB 815 lines
1# SPDX-License-Identifier: AGPL-3.0-only 2# Copyright (c) 2026 sol pbc 3 4import importlib 5import sys 6from pathlib import Path 7from unittest.mock import MagicMock 8 9import pytest 10 11from solstone.think.importers.shared import ( 12 find_manifest_by_hash, 13 hash_source, 14 write_manifest, 15) 16from solstone.think.importers.sync import load_sync_state, save_sync_state 17 18 19def _write_audio(root: Path, rel_path: str = "clip.mp3", content: bytes = b"audio"): 20 path = root / rel_path 21 path.parent.mkdir(parents=True, exist_ok=True) 22 path.write_bytes(content) 23 return path 24 25 26def _patch_audio_tools(monkeypatch, duration: float | None = 60.0): 27 monkeypatch.setattr( 28 "solstone.think.importers.audio.shutil.which", 29 lambda name: f"/usr/bin/{name}", 30 ) 31 monkeypatch.setattr( 32 "solstone.think.importers.audio._get_audio_duration", 33 lambda _path: duration, 34 ) 35 36 37def _write_audio_manifest(journal_root: Path, audio_file: Path, import_id: str): 38 return write_manifest( 39 journal_root, 40 import_id=import_id, 41 source_type="audio", 42 source_hash=hash_source(audio_file), 43 entry_count=1, 44 files_created=[str(audio_file)], 45 days_affected=["20260303"], 46 ) 47 48 49def test_import_one_audio_manifest_is_findable_by_hash(tmp_path, monkeypatch): 50 mod = importlib.import_module("solstone.think.importers.cli") 51 52 audio_file = tmp_path / "test.mp3" 53 audio_file.write_bytes(b"fake audio") 54 callosum = MagicMock() 55 56 def fake_prepare_audio_segments(media_path, day_dir, base_dt, import_id, stream): 57 seg_dir = Path(day_dir) / stream / "120000_300" 58 seg_dir.mkdir(parents=True, exist_ok=True) 59 (seg_dir / "imported_audio.mp3").write_bytes(b"sliced audio") 60 return [("120000_300", seg_dir, ["imported_audio.mp3"])] 61 62 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 63 monkeypatch.setattr(mod, "CallosumConnection", lambda **kwargs: callosum) 64 monkeypatch.setattr(mod, "get_rev", lambda: "test-rev") 65 monkeypatch.setattr(mod, "_status_emitter", lambda: None) 66 monkeypatch.setattr(mod, "prepare_audio_segments", fake_prepare_audio_segments) 67 monkeypatch.setattr( 68 mod, 69 "update_stream", 70 lambda stream, day, seg, **kwargs: { 71 "prev_day": None, 72 "prev_segment": None, 73 "seq": 1, 74 }, 75 ) 76 monkeypatch.setattr(mod, "write_segment_stream", lambda *args, **kwargs: None) 77 78 result = mod.import_one( 79 audio_file, 80 timestamp="20260303_120000", 81 source="audio", 82 wait_for_processing=False, 83 ) 84 85 journal_root = tmp_path 86 source_hash = hash_source(audio_file) 87 manifest = find_manifest_by_hash(journal_root, source_hash) 88 89 assert result is not None 90 assert manifest is not None 91 assert manifest["source_hash"] == source_hash 92 93 94def test_audio_sync_protocol_conformance(): 95 from solstone.think.importers.audio import AudioFolderBackend 96 from solstone.think.importers.sync import SyncableBackend 97 98 assert isinstance(AudioFolderBackend(), SyncableBackend) 99 100 101def test_audio_sync_registry_discovery(): 102 from solstone.think.importers.sync import get_syncable_backends 103 104 backends = get_syncable_backends() 105 106 assert "audio" in [backend.name for backend in backends] 107 108 109def test_audio_sync_dry_run_catalogs_available_files(tmp_path, monkeypatch): 110 from solstone.think.importers.audio import AudioFolderBackend 111 112 _patch_audio_tools(monkeypatch) 113 source_dir = tmp_path / "source" 114 audio_file = _write_audio(source_dir, "nested/clip.mp3", b"available audio") 115 116 result = AudioFolderBackend().sync(tmp_path, source_path=source_dir, dry_run=True) 117 118 assert result == { 119 "total": 1, 120 "imported": 0, 121 "available": 1, 122 "skipped": 0, 123 "downloaded": 0, 124 "errors": [], 125 } 126 state = load_sync_state(tmp_path, "audio") 127 assert state is not None 128 assert state["backend"] == "audio" 129 assert state["source_path"] == str(source_dir.resolve()) 130 entry = state["files"]["nested/clip.mp3"] 131 assert entry["filename"] == "clip.mp3" 132 assert entry["filesize"] == len(b"available audio") 133 assert entry["hash"] == hash_source(audio_file) 134 assert entry["status"] == "available" 135 136 137def test_audio_sync_manifested_file_reports_imported(tmp_path, monkeypatch): 138 from solstone.think.importers.audio import AudioFolderBackend 139 140 monkeypatch.setattr( 141 "solstone.think.importers.audio.shutil.which", 142 lambda name: f"/usr/bin/{name}", 143 ) 144 duration = MagicMock(return_value=60.0) 145 monkeypatch.setattr("solstone.think.importers.audio._get_audio_duration", duration) 146 source_dir = tmp_path / "source" 147 audio_file = _write_audio(source_dir, "clip.mp3") 148 _write_audio_manifest(tmp_path, audio_file, "20260303_120000") 149 150 result = AudioFolderBackend().sync(tmp_path, source_path=source_dir, dry_run=True) 151 152 assert result["imported"] == 1 153 assert result["available"] == 0 154 duration.assert_not_called() 155 state = load_sync_state(tmp_path, "audio") 156 assert state is not None 157 assert state["files"]["clip.mp3"]["status"] == "imported" 158 159 160def test_audio_sync_second_sync_after_save_has_zero_available(tmp_path, monkeypatch): 161 from solstone.think.importers.audio import AudioFolderBackend 162 163 _patch_audio_tools(monkeypatch) 164 source_dir = tmp_path / "source" 165 audio_file = _write_audio(source_dir, "clip.mp3") 166 167 def fake_import_one(path, **kwargs): 168 _write_audio_manifest(tmp_path, Path(path), "20260303_120000") 169 return {"segments": ["120000_300"]} 170 171 monkeypatch.setattr("solstone.think.importers.cli.import_one", fake_import_one) 172 173 backend = AudioFolderBackend() 174 first = backend.sync(tmp_path, source_path=source_dir, dry_run=False) 175 second = backend.sync(tmp_path, source_path=source_dir, dry_run=True) 176 177 assert first["downloaded"] == 1 178 assert audio_file.exists() 179 assert second["imported"] == 1 180 assert second["available"] == 0 181 assert second["downloaded"] == 0 182 183 184def test_audio_sync_skips_too_short_files(tmp_path, monkeypatch): 185 from solstone.think.importers.audio import AudioFolderBackend 186 187 _patch_audio_tools(monkeypatch, duration=29.0) 188 source_dir = tmp_path / "source" 189 _write_audio(source_dir, "short.mp3") 190 191 result = AudioFolderBackend().sync(tmp_path, source_path=source_dir, dry_run=True) 192 193 assert result["skipped"] == 1 194 assert result["available"] == 0 195 state = load_sync_state(tmp_path, "audio") 196 assert state is not None 197 entry = state["files"]["short.mp3"] 198 assert entry["status"] == "skipped" 199 assert entry["skip_reason"] == "too_short" 200 201 202@pytest.mark.parametrize("dry_run", [True, False]) 203def test_audio_sync_probe_failure_prints_error_in_both_modes( 204 tmp_path, 205 monkeypatch, 206 capsys, 207 dry_run, 208): 209 mod = importlib.import_module("solstone.think.importers.cli") 210 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 211 monkeypatch.setattr( 212 "solstone.think.importers.audio.shutil.which", 213 lambda name: f"/usr/bin/{name}", 214 ) 215 monkeypatch.setattr( 216 "solstone.think.importers.audio._get_audio_duration", 217 lambda path: None if "broken" in path else 29.0, 218 ) 219 monkeypatch.setattr( 220 "solstone.think.importers.cli.import_one", 221 lambda path, **kwargs: {"segments": ["120000_300"]}, 222 ) 223 source_dir = tmp_path / "source" 224 _write_audio(source_dir, "short.mp3") 225 _write_audio(source_dir, "broken.mp3") 226 227 mod._run_sync("audio", dry_run=dry_run, source_path=source_dir) 228 229 output = capsys.readouterr().out 230 assert "Errors: 1" in output 231 assert "broken.mp3: could not read audio (probe failed)" in output 232 assert "too short" in output 233 assert "Skipped: 2" not in output 234 assert "Everything is up to date." not in output 235 236 237def test_audio_sync_partitions_available_skipped_and_unreadable(tmp_path, monkeypatch): 238 from solstone.think.importers.audio import AudioFolderBackend 239 240 monkeypatch.setattr( 241 "solstone.think.importers.audio.shutil.which", 242 lambda name: f"/usr/bin/{name}", 243 ) 244 245 def duration_for(path): 246 name = Path(path).name 247 if name == "broken.mp3": 248 return None 249 if name == "short.mp3": 250 return 29.0 251 return 60.0 252 253 monkeypatch.setattr( 254 "solstone.think.importers.audio._get_audio_duration", duration_for 255 ) 256 source_dir = tmp_path / "source" 257 _write_audio(source_dir, "long.mp3") 258 _write_audio(source_dir, "short.mp3") 259 _write_audio(source_dir, "broken.mp3") 260 261 result = AudioFolderBackend().sync(tmp_path, source_path=source_dir, dry_run=True) 262 263 assert result["available"] == 1 264 assert result["skipped"] == 1 265 assert len(result["errors"]) == 1 266 state = load_sync_state(tmp_path, "audio") 267 assert state is not None 268 assert state["files"]["broken.mp3"]["status"] == "unreadable" 269 270 271def test_audio_sync_probe_failure_recovery_is_not_sticky(tmp_path, monkeypatch): 272 from solstone.think.importers.audio import AudioFolderBackend 273 274 _patch_audio_tools(monkeypatch, duration=None) 275 source_dir = tmp_path / "source" 276 _write_audio(source_dir, "clip.mp3") 277 backend = AudioFolderBackend() 278 279 backend.sync(tmp_path, source_path=source_dir, dry_run=True) 280 first_state = load_sync_state(tmp_path, "audio") 281 assert first_state is not None 282 assert first_state["files"]["clip.mp3"]["status"] == "unreadable" 283 284 monkeypatch.setattr( 285 "solstone.think.importers.audio._get_audio_duration", 286 lambda _path: 60.0, 287 ) 288 backend.sync(tmp_path, source_path=source_dir, dry_run=True) 289 290 state = load_sync_state(tmp_path, "audio") 291 assert state is not None 292 entry = state["files"]["clip.mp3"] 293 assert entry["status"] == "available" 294 assert "last_error" not in entry 295 assert "skip_reason" not in entry 296 297 298def test_run_sync_skipped_reason_visible_without_verbose(tmp_path, monkeypatch, capsys): 299 mod = importlib.import_module("solstone.think.importers.cli") 300 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 301 _patch_audio_tools(monkeypatch, duration=29.0) 302 source_dir = tmp_path / "source" 303 _write_audio(source_dir, "short.mp3") 304 305 mod._run_sync("audio", dry_run=True, source_path=source_dir) 306 307 output = capsys.readouterr().out 308 assert "too short" in output 309 skipped_lines = [ 310 line.rstrip() for line in output.splitlines() if "Skipped:" in line 311 ] 312 assert " Skipped: 1" not in skipped_lines 313 314 315def test_run_sync_verbose_prints_file_dump(tmp_path, monkeypatch, capsys): 316 mod = importlib.import_module("solstone.think.importers.cli") 317 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 318 monkeypatch.setattr( 319 "solstone.think.importers.audio.shutil.which", 320 lambda name: f"/usr/bin/{name}", 321 ) 322 monkeypatch.setattr( 323 "solstone.think.importers.audio._get_audio_duration", 324 lambda path: 29.0 if Path(path).name == "short.mp3" else 60.0, 325 ) 326 source_dir = tmp_path / "source" 327 _write_audio(source_dir, "long.mp3") 328 _write_audio(source_dir, "short.mp3") 329 330 mod._run_sync("audio", dry_run=True, verbose=True, source_path=source_dir) 331 verbose_output = capsys.readouterr().out 332 assert "Files:" in verbose_output 333 assert "available" in verbose_output 334 assert "long.mp3" in verbose_output 335 assert "skipped" in verbose_output 336 assert "short.mp3" in verbose_output 337 338 mod._run_sync("audio", dry_run=True, verbose=False, source_path=source_dir) 339 plain_output = capsys.readouterr().out 340 assert "Files:" not in plain_output 341 342 343def test_importer_main_sync_verbose_wiring(tmp_path, monkeypatch, capsys): 344 mod = importlib.import_module("solstone.think.importers.cli") 345 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 346 monkeypatch.setattr(mod, "require_solstone", lambda: None) 347 monkeypatch.setattr( 348 sys, 349 "argv", 350 ["sol", "--sync", "audio", "--path", str(tmp_path / "source"), "-v"], 351 ) 352 _patch_audio_tools(monkeypatch) 353 _write_audio(tmp_path / "source", "clip.mp3") 354 355 mod.main() 356 357 output = capsys.readouterr().out 358 assert "Files:" in output 359 assert "clip.mp3" in output 360 361 362def test_run_sync_verbose_handles_obsidian_shaped_state( 363 tmp_path, 364 monkeypatch, 365 capsys, 366): 367 mod = importlib.import_module("solstone.think.importers.cli") 368 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 369 save_sync_state( 370 tmp_path, 371 "audio", 372 { 373 "backend": "audio", 374 "files": { 375 "clip.md": { 376 "filename": "clip.md", 377 "status": "available", 378 }, 379 "done.md": { 380 "filename": "done.md", 381 "status": "imported", 382 }, 383 }, 384 }, 385 ) 386 387 class FakeAudioBackend: 388 name = "audio" 389 390 def sync(self, journal_root, *, dry_run=True, source_path=None): 391 return { 392 "total": 2, 393 "imported": 1, 394 "available": 1, 395 "skipped": 0, 396 "downloaded": 0, 397 "errors": [], 398 } 399 400 monkeypatch.setattr( 401 "solstone.think.importers.sync.get_syncable_backends", 402 lambda: [FakeAudioBackend()], 403 ) 404 405 mod._run_sync("audio", dry_run=True, verbose=True, source_path=tmp_path / "source") 406 407 output = capsys.readouterr().out 408 assert "Files:" in output 409 assert "clip.md" in output 410 assert "done.md" in output 411 412 413def test_audio_sync_auto_passthrough_from_main(tmp_path, monkeypatch, capsys): 414 mod = importlib.import_module("solstone.think.importers.cli") 415 monkeypatch.setattr(mod, "require_solstone", lambda: None) 416 _patch_audio_tools(monkeypatch) 417 import_one = MagicMock(return_value={"segments": ["120000_300"]}) 418 monkeypatch.setattr("solstone.think.importers.cli.import_one", import_one) 419 420 guidance_journal = tmp_path / "guidance-journal" 421 guidance_source = tmp_path / "guidance-source" 422 _write_audio(guidance_source, "clip.mp3") 423 monkeypatch.setenv("SOLSTONE_JOURNAL", str(guidance_journal)) 424 monkeypatch.setattr( 425 sys, 426 "argv", 427 [ 428 "sol", 429 "--sync", 430 "audio", 431 "--path", 432 str(guidance_source), 433 "--save", 434 "--auto", 435 "guidance text", 436 ], 437 ) 438 mod.main() 439 assert import_one.call_args.kwargs["auto"] == "guidance text" 440 441 default_journal = tmp_path / "default-journal" 442 default_source = tmp_path / "default-source" 443 _write_audio(default_source, "clip.mp3", b"default audio") 444 monkeypatch.setenv("SOLSTONE_JOURNAL", str(default_journal)) 445 monkeypatch.setattr( 446 sys, 447 "argv", 448 ["sol", "--sync", "audio", "--path", str(default_source), "--save"], 449 ) 450 import_one.reset_mock() 451 capsys.readouterr() 452 mod.main() 453 assert import_one.call_args.kwargs["auto"] is True 454 455 dry_run_journal = tmp_path / "dry-run-journal" 456 dry_run_source = tmp_path / "dry-run-source" 457 _write_audio(dry_run_source, "clip.mp3", b"dry-run audio") 458 monkeypatch.setenv("SOLSTONE_JOURNAL", str(dry_run_journal)) 459 monkeypatch.setattr( 460 sys, 461 "argv", 462 [ 463 "sol", 464 "--sync", 465 "audio", 466 "--path", 467 str(dry_run_source), 468 "--auto", 469 "catalog guidance", 470 ], 471 ) 472 import_one.reset_mock() 473 capsys.readouterr() 474 mod.main() 475 import_one.assert_not_called() 476 477 478def test_audio_sync_save_imports_new_files_and_checkpoints(tmp_path, monkeypatch): 479 import solstone.think.importers.audio as audio_mod 480 from solstone.think.importers.audio import AudioFolderBackend 481 from solstone.think.importers.sync import save_sync_state as real_save_sync_state 482 483 _patch_audio_tools(monkeypatch) 484 source_dir = tmp_path / "source" 485 _write_audio(source_dir, "one.mp3") 486 _write_audio(source_dir, "two.mp3") 487 monkeypatch.setattr( 488 "solstone.think.importers.cli.import_one", 489 lambda path, **kwargs: {"segments": ["120000_300"]}, 490 ) 491 save_calls = [] 492 493 def spy_save_sync_state(journal_root, backend, state): 494 save_calls.append(backend) 495 real_save_sync_state(journal_root, backend, state) 496 497 monkeypatch.setattr(audio_mod, "save_sync_state", spy_save_sync_state) 498 499 result = AudioFolderBackend().sync(tmp_path, source_path=source_dir, dry_run=False) 500 501 assert result["downloaded"] == 2 502 assert result["imported"] == 2 503 assert len(save_calls) == 3 504 505 506@pytest.mark.parametrize( 507 ("mode", "expected_status", "expected_downloaded", "expects_error"), 508 [ 509 ("success", "imported", 1, False), 510 ("skipped", "available", 0, True), 511 ("raised", "available", 0, True), 512 ("none", "available", 0, True), 513 ], 514) 515def test_audio_sync_import_one_return_shapes( 516 tmp_path, 517 monkeypatch, 518 mode, 519 expected_status, 520 expected_downloaded, 521 expects_error, 522): 523 from solstone.think.importers.audio import AudioFolderBackend 524 525 _patch_audio_tools(monkeypatch) 526 source_dir = tmp_path / "source" 527 _write_audio(source_dir, "clip.mp3") 528 529 def fake_import_one(path, **kwargs): 530 if mode == "success": 531 return {"segments": ["120000_300"]} 532 if mode == "skipped": 533 return {"skipped": True, "reason": "already_imported"} 534 if mode == "raised": 535 raise RuntimeError("boom") 536 return None 537 538 monkeypatch.setattr("solstone.think.importers.cli.import_one", fake_import_one) 539 540 result = AudioFolderBackend().sync(tmp_path, source_path=source_dir, dry_run=False) 541 542 assert result["downloaded"] == expected_downloaded 543 assert bool(result["errors"]) is expects_error 544 state = load_sync_state(tmp_path, "audio") 545 assert state is not None 546 entry = state["files"]["clip.mp3"] 547 assert entry["status"] == expected_status 548 if expects_error: 549 assert entry["last_error"] 550 else: 551 assert "last_error" not in entry 552 553 554def test_audio_sync_removed_file_marks_removed(tmp_path, monkeypatch): 555 from solstone.think.importers.audio import AudioFolderBackend 556 557 _patch_audio_tools(monkeypatch) 558 source_dir = tmp_path / "source" 559 _write_audio(source_dir, "keep.mp3") 560 removed = _write_audio(source_dir, "removed.mp3") 561 backend = AudioFolderBackend() 562 backend.sync(tmp_path, source_path=source_dir, dry_run=True) 563 564 removed.unlink() 565 result = backend.sync(tmp_path, source_path=source_dir, dry_run=True) 566 567 assert result["total"] == 2 568 state = load_sync_state(tmp_path, "audio") 569 assert state is not None 570 assert state["files"]["keep.mp3"]["status"] == "available" 571 assert state["files"]["removed.mp3"]["status"] == "removed" 572 573 574def test_audio_sync_removed_skipped_file_marks_removed(tmp_path, monkeypatch): 575 from solstone.think.importers.audio import AudioFolderBackend 576 577 monkeypatch.setattr( 578 "solstone.think.importers.audio.shutil.which", 579 lambda name: f"/usr/bin/{name}", 580 ) 581 monkeypatch.setattr( 582 "solstone.think.importers.audio._get_audio_duration", 583 lambda path: 29.0 if Path(path).name == "short.mp3" else 60.0, 584 ) 585 source_dir = tmp_path / "source" 586 other_source_dir = tmp_path / "other-source" 587 _write_audio(source_dir, "short.mp3") 588 _write_audio(other_source_dir, "long.mp3") 589 backend = AudioFolderBackend() 590 first = backend.sync(tmp_path, source_path=source_dir, dry_run=True) 591 assert first["skipped"] == 1 592 593 second = backend.sync(tmp_path, source_path=other_source_dir, dry_run=True) 594 595 assert second["skipped"] == 0 596 assert second["available"] == 1 597 state = load_sync_state(tmp_path, "audio") 598 assert state is not None 599 assert state["files"]["short.mp3"]["status"] == "removed" 600 assert state["files"]["long.mp3"]["status"] == "available" 601 602 603def test_audio_sync_force_save_does_not_reimport_manifested_file(tmp_path, monkeypatch): 604 from solstone.think.importers.audio import AudioFolderBackend 605 606 _patch_audio_tools(monkeypatch) 607 source_dir = tmp_path / "source" 608 audio_file = _write_audio(source_dir, "clip.mp3") 609 _write_audio_manifest(tmp_path, audio_file, "20260303_120000") 610 import_one = MagicMock(return_value={"segments": ["120000_300"]}) 611 monkeypatch.setattr("solstone.think.importers.cli.import_one", import_one) 612 613 result = AudioFolderBackend().sync( 614 tmp_path, 615 source_path=source_dir, 616 dry_run=False, 617 force=True, 618 ) 619 620 assert result["imported"] == 1 621 assert result["available"] == 0 622 assert result["downloaded"] == 0 623 import_one.assert_not_called() 624 625 626def test_audio_sync_missing_path_raises_value_error(tmp_path, monkeypatch): 627 from solstone.think.importers.audio import AudioFolderBackend 628 629 _patch_audio_tools(monkeypatch) 630 631 with pytest.raises(ValueError, match="--path"): 632 AudioFolderBackend().sync(tmp_path) 633 634 635def test_audio_sync_non_directory_path_raises_value_error(tmp_path, monkeypatch): 636 from solstone.think.importers.audio import AudioFolderBackend 637 638 _patch_audio_tools(monkeypatch) 639 source_path = tmp_path / "not-a-dir.mp3" 640 source_path.write_bytes(b"audio") 641 642 with pytest.raises(ValueError, match="not a directory"): 643 AudioFolderBackend().sync(tmp_path, source_path=source_path) 644 645 646def test_audio_sync_no_audio_files_raises_value_error(tmp_path, monkeypatch): 647 from solstone.think.importers.audio import AudioFolderBackend 648 649 _patch_audio_tools(monkeypatch) 650 source_dir = tmp_path / "source" 651 source_dir.mkdir() 652 (source_dir / "notes.txt").write_text("not audio", encoding="utf-8") 653 654 with pytest.raises(ValueError, match="no audio"): 655 AudioFolderBackend().sync(tmp_path, source_path=source_dir) 656 657 658@pytest.mark.parametrize("missing_tool", ["ffmpeg", "ffprobe"]) 659def test_audio_sync_missing_tool_fails_fast(tmp_path, monkeypatch, missing_tool): 660 from solstone.think.importers.audio import AudioFolderBackend 661 662 source_dir = tmp_path / "source" 663 source_dir.mkdir() 664 665 def fake_which(name): 666 if name == missing_tool: 667 return None 668 return f"/usr/bin/{name}" 669 670 monkeypatch.setattr("solstone.think.importers.audio.shutil.which", fake_which) 671 672 with pytest.raises(ValueError, match=missing_tool): 673 AudioFolderBackend().sync(tmp_path, source_path=source_dir) 674 675 676def test_audio_sync_excludes_journal_imports_subtree(tmp_path, monkeypatch): 677 from solstone.think.importers.audio import AudioFolderBackend 678 679 _patch_audio_tools(monkeypatch) 680 _write_audio(tmp_path, "root.mp3") 681 _write_audio(tmp_path, "imports/20260303_120000/imported_audio.mp3") 682 683 result = AudioFolderBackend().sync(tmp_path, source_path=tmp_path, dry_run=True) 684 685 assert result["total"] == 1 686 state = load_sync_state(tmp_path, "audio") 687 assert state is not None 688 assert set(state["files"]) == {"root.mp3"} 689 690 691def test_run_sync_dry_run_hint_includes_path_for_audio(tmp_path, monkeypatch, capsys): 692 mod = importlib.import_module("solstone.think.importers.cli") 693 source_dir = tmp_path / "audio source" 694 source_dir.mkdir() 695 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 696 697 class FakeAudioBackend: 698 name = "audio" 699 700 def sync(self, journal_root, *, dry_run=True, source_path=None): 701 save_sync_state( 702 journal_root, 703 "audio", 704 { 705 "backend": "audio", 706 "source_path": str(source_path), 707 "files": { 708 "clip.mp3": { 709 "filename": "clip.mp3", 710 "filesize": 4, 711 "status": "available", 712 } 713 }, 714 }, 715 ) 716 return { 717 "total": 1, 718 "imported": 0, 719 "available": 1, 720 "skipped": 0, 721 "downloaded": 0, 722 "errors": [], 723 } 724 725 monkeypatch.setattr( 726 "solstone.think.importers.sync.get_syncable_backends", 727 lambda: [FakeAudioBackend()], 728 ) 729 730 mod._run_sync("audio", dry_run=True, source_path=source_dir) 731 732 output = capsys.readouterr().out 733 assert f"journal importer --sync audio --save --path {source_dir}" in output 734 735 736def test_run_sync_dry_run_hint_omits_path_for_pathless_backend( 737 tmp_path, 738 monkeypatch, 739 capsys, 740): 741 mod = importlib.import_module("solstone.think.importers.cli") 742 source_dir = tmp_path / "audio" 743 source_dir.mkdir() 744 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 745 746 class FakeAudioBackend: 747 name = "audio" 748 749 def sync(self, journal_root, *, dry_run=True): 750 save_sync_state( 751 journal_root, 752 "audio", 753 { 754 "backend": "audio", 755 "files": { 756 "clip.mp3": { 757 "filename": "clip.mp3", 758 "filesize": 4, 759 "status": "available", 760 } 761 }, 762 }, 763 ) 764 return { 765 "total": 1, 766 "imported": 0, 767 "available": 1, 768 "skipped": 0, 769 "downloaded": 0, 770 "errors": [], 771 } 772 773 monkeypatch.setattr( 774 "solstone.think.importers.sync.get_syncable_backends", 775 lambda: [FakeAudioBackend()], 776 ) 777 778 mod._run_sync("audio", dry_run=True, source_path=source_dir) 779 780 output = capsys.readouterr().out 781 assert "journal importer --sync audio --save" in output 782 assert "--path" not in output 783 784 785def test_run_sync_save_errors_suppress_up_to_date_message( 786 tmp_path, 787 monkeypatch, 788 capsys, 789): 790 mod = importlib.import_module("solstone.think.importers.cli") 791 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 792 793 class FakeAudioBackend: 794 name = "audio" 795 796 def sync(self, journal_root, *, dry_run=True): 797 return { 798 "total": 1, 799 "imported": 0, 800 "available": 0, 801 "skipped": 0, 802 "downloaded": 0, 803 "errors": ["clip.mp3: boom"], 804 } 805 806 monkeypatch.setattr( 807 "solstone.think.importers.sync.get_syncable_backends", 808 lambda: [FakeAudioBackend()], 809 ) 810 811 mod._run_sync("audio", dry_run=False) 812 813 output = capsys.readouterr().out 814 assert "Errors: 1" in output 815 assert "Everything is up to date." not in output