personal memory agent
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