diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 000000000..17cd2481d --- /dev/null +++ b/tests/README.md @@ -0,0 +1,143 @@ +# Test Suite Documentation + +## Skip Status Overview + +This document tracks all skipped tests in the pytest test suite. Every skipped test is documented with a tracking issue explaining why it's skipped and when it can be unskipped. + +### Final Skip Count + +As of the audit completed on 2026-03-30: + +- **@pytest.mark.skip**: 15 decorators (17 test methods; one class-level decorator covers 3 tests in `TestExecutorInterface`) +- **@pytest.mark.skipif**: 18 decorators (30 test methods; one class-level decorator covers 12 tests in `test_context_menu_macos.py`) +- **pytest.importorskip**: ~8+ additional skips (optional dependency checks) +- **Total documented skips**: ~55+ tests with issue references + +**Note**: `TestExecutorInterface` in `tests/plugins/test_sandbox_isolation.py` was added as a new +class-level skip by this PR. The class existed previously and its tests were running; the skip +decorator was added during this audit to match the documented intent in issue #338. + +### Skip Categories + +#### 1. Deferred Features (Phase 3 Development) + +Tests skipped because features are not yet implemented: + +| Issue | Count | Description | Files Affected | +|-------|-------|-------------|----------------| +| [#611](https://github.com/curdriceaurora/Local-File-Organizer/issues/611) | 3 | Audio metadata extraction needs real fixtures | `tests/utils/test_audio_metadata.py` | +| [#1071](https://github.com/curdriceaurora/Local-File-Organizer/issues/1071) | 3 | Audio transcription feature (Phase 3) | `tests/services/test_audio_transcription.py` | +| [#1073](https://github.com/curdriceaurora/Local-File-Organizer/issues/1073) | 6 | Video processing features (Phase 3) | `tests/services/test_video_processing.py`
`tests/utils/test_video_metadata.py` | +| [#1076](https://github.com/curdriceaurora/Local-File-Organizer/issues/1076) | 1 | SSE routes for file browser | `tests/test_web_files_routes.py` | +| [#1077](https://github.com/curdriceaurora/Local-File-Organizer/issues/1077) | 3 | SuggestionEngine API implementation | `tests/integration/test_image_quality_para_suggestion.py` | +| [#1080](https://github.com/curdriceaurora/Local-File-Organizer/issues/1080) | 1 | SSE streaming for organize route | `tests/test_web_organize_routes.py` | +| [#338](https://github.com/curdriceaurora/Local-File-Organizer/issues/338) | 3 | Stream A executor not yet delivered | `tests/plugins/test_sandbox_isolation.py` | + +**Subtotal: 20 tests** (deferred feature skips; #1077 uses `@pytest.mark.skipif`, others use `@pytest.mark.skip`) + +#### 2. Platform-Specific Limitations + +Tests skipped on specific operating systems due to platform limitations: + +| Issue | Platform | Count | Description | Files Affected | +|-------|----------|-------|-------------|----------------| +| [#1072](https://github.com/curdriceaurora/Local-File-Organizer/issues/1072) | Cross-platform | 3 | Platform-specific path validation (macOS, Linux, Windows) | `tests/config/test_config_paths.py` | +| [#1074](https://github.com/curdriceaurora/Local-File-Organizer/issues/1074) | Windows | 3 | Signal pipe not available on Windows | `tests/daemon/test_service_signal_safety.py` | +| [#1075](https://github.com/curdriceaurora/Local-File-Organizer/issues/1075) | Windows | 2 | `/dev/null` is writable on Windows | `tests/undo/test_rollback_extended.py` | +| [#1078](https://github.com/curdriceaurora/Local-File-Organizer/issues/1078) | Windows | 2 | `chmod` does not restrict reads on Windows | `tests/integration/test_error_propagation.py` | +| [#1078](https://github.com/curdriceaurora/Local-File-Organizer/issues/1078) | Windows | 1 | `chmod` does not restrict reads on Windows | `tests/plugins/test_base_coverage.py` | +| [#1081](https://github.com/curdriceaurora/Local-File-Organizer/issues/1081) | Windows | 1 | Directory fsync is a no-op on Windows | `tests/parallel/test_checkpoint.py` | +| [#1082](https://github.com/curdriceaurora/Local-File-Organizer/issues/1082) | Windows | 1 | Hardlinks require admin privileges on Windows | `tests/integration/test_organize_text_workflow.py` | +| [#1083](https://github.com/curdriceaurora/Local-File-Organizer/issues/1083) | macOS | 12 | macOS-only Quick Action feature | `tests/integration/test_context_menu_macos.py` | +| [#1084](https://github.com/curdriceaurora/Local-File-Organizer/issues/1084) | Any | 1 | pytest-benchmark not installed | `tests/e2e/test_full_pipeline.py` | +| [#1085](https://github.com/curdriceaurora/Local-File-Organizer/issues/1085) | Windows, macOS | 1 | Creation time sorting is flaky on Windows/macOS | `tests/test_web_files_routes.py` | + +**Subtotal: 27 tests** (platform/environment skipif; uses `@pytest.mark.skipif`) + +#### 3. Optional Dependencies + +Tests skipped when optional dependencies are not installed. These use `pytest.importorskip()` pattern. + +**Policy exception**: Tests using `pytest.importorskip()` for `rank_bm25` and `sklearn` do not require tracking issues, as these are standard optional dependency checks that skip automatically when the package is not installed. + +| Issue | Dependency | Description | Files Affected | +|-------|------------|-------------|----------------| +| [#1079](https://github.com/curdriceaurora/Local-File-Organizer/issues/1079) | `ebooklib` | EPUB file processing | `tests/utils/test_file_readers.py`
`tests/unit/utils/test_file_readers.py` | +| [#1079](https://github.com/curdriceaurora/Local-File-Organizer/issues/1079) | `Pillow` | Image processing (EPUB thumbnails) | `tests/utils/test_epub_enhanced.py` | +| N/A | `openpyxl` | Excel file processing | Multiple spreadsheet test files | +| Exception applies | `rank_bm25` | BM25 search indexing | Multiple search/copilot test files | +| Exception applies | `sklearn` | Machine learning features | Analytics and vector search tests | + +**Subtotal: 8+ tests** (optional dependency skips) + +### Skip Pattern Reference + +#### Pattern 1: Unconditional Skip with Issue Reference + +```python +@pytest.mark.skip(reason="See #1073 - Phase 3 feature not yet implemented") +def test_future_feature(): + pass +``` + +#### Pattern 2: Platform-Specific Skip + +```python +@pytest.mark.skipif(sys.platform == "win32", reason="See #1074 - signal pipe not available on Windows") +def test_unix_only_feature(): + pass +``` + +#### Pattern 3: Optional Dependency Skip + +Use `pytest.importorskip()` inside an autouse fixture scoped to the class that needs the dependency: + +```python +class TestEpubCoverExtraction: + @pytest.fixture(autouse=True) + def _require_pillow(self) -> None: + pytest.importorskip("PIL.Image") # See #1079 - Optional image processing dependency + + def test_extract_cover(self, tmp_path): + # Test continues if import succeeds, skips if Pillow not installed + ... +``` + +### Verification Commands + +```bash +# Show all skips with reasons +pytest tests/ -v -rs + +# Count skip decorators by type +rg '@pytest.mark.skip\(' tests/ --type py -c +rg '@pytest.mark.skipif' tests/ --type py -c +rg 'pytest.importorskip' tests/ --type py -c + +# Verify all skips have issue references (grep -v filters out lines with reason=) +rg '@pytest.mark.skip' tests/ --type py | grep -v 'reason=' + +# List all tracking issues +rg 'reason="See #(\d+)' tests/ --type py -o -r '$1' | sort | uniq -c | sort -rn +``` + +### Maintenance Guidelines + +1. **Never leave a skip without a tracking issue** - Every `@pytest.mark.skip` and `@pytest.mark.skipif` must have `reason="See #NNN"` +2. **Use pytest.importorskip for optional dependencies** - Decorator-based skips should only be used for platform or environment conditions +3. **Delete irrelevant tests** - If a feature is permanently removed, delete its tests rather than leaving them skipped +4. **Document in tracking issues** - Each issue should explain: + - Why the test is skipped + - What needs to happen before it can be unskipped + - Whether this is temporary (bug fix, feature implementation) or permanent (platform limitation) + +### Related Documentation + +- [GitHub Issue #1028](https://github.com/curdriceaurora/Local-File-Organizer/issues/1028) - Original audit task +- [pytest skip/xfail documentation](https://docs.pytest.org/en/stable/how-to/skipping.html) +- [pytest.importorskip API](https://docs.pytest.org/en/stable/reference/reference.html#pytest.importorskip) + +--- + +**Last Updated:** 2026-03-30 +**Audit Completed By:** auto-claude task #038 diff --git a/tests/config/test_config_paths.py b/tests/config/test_config_paths.py index 6338156a7..a9bf28f7d 100644 --- a/tests/config/test_config_paths.py +++ b/tests/config/test_config_paths.py @@ -49,7 +49,9 @@ def test_empty_xdg_config_home_falls_back_to_platformdirs(self) -> None: assert isinstance(result, Path) assert APP_NAME in str(result) - @pytest.mark.skipif(sys.platform != "darwin", reason="macOS-specific path") + @pytest.mark.skipif( + sys.platform != "darwin", reason="See #1072 - macOS-specific path validation" + ) def test_macos_path_format(self) -> None: """On macOS, config dir should be under ~/Library/Application Support.""" with mock.patch.dict(os.environ, {}, clear=False): @@ -60,7 +62,9 @@ def test_macos_path_format(self) -> None: home = Path.home() assert str(result).startswith(str(home / "Library" / "Application Support")) - @pytest.mark.skipif(sys.platform != "linux", reason="Linux-specific path") + @pytest.mark.skipif( + sys.platform != "linux", reason="See #1072 - Linux-specific path validation" + ) def test_linux_path_format(self) -> None: """On Linux without XDG override, config dir should be under ~/.config.""" env = {k: v for k, v in os.environ.items() if k != "XDG_CONFIG_HOME"} @@ -69,7 +73,9 @@ def test_linux_path_format(self) -> None: home = Path.home() assert str(result).startswith(str(home / ".config")) - @pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific path") + @pytest.mark.skipif( + sys.platform != "win32", reason="See #1072 - Windows-specific path validation" + ) def test_windows_path_format(self) -> None: """On Windows, config dir should be under %APPDATA%.""" result = get_config_dir() diff --git a/tests/daemon/test_service_signal_safety.py b/tests/daemon/test_service_signal_safety.py index 322bc5726..45093965a 100644 --- a/tests/daemon/test_service_signal_safety.py +++ b/tests/daemon/test_service_signal_safety.py @@ -87,7 +87,9 @@ def test_signal_handler_tolerates_none_pipe(self) -> None: class TestRunLoopExitsOnPipeSignal: """TestRunLoopExitsOnPipeSignal test suite.""" - @pytest.mark.skipif(sys.platform == "win32", reason="signal pipe not available on Windows") + @pytest.mark.skipif( + sys.platform == "win32", reason="See #1074 - signal pipe not available on Windows" + ) def test_run_loop_exits_on_pipe_signal(self) -> None: """Verify run loop exits when signal is written to pipe.""" daemon = DaemonService(_make_config()) @@ -129,7 +131,9 @@ def stop_later() -> None: assert daemon._stop_event.is_set(), "Stop event should be set after event.wait path" -@pytest.mark.skipif(sys.platform == "win32", reason="signal pipe not available on Windows") +@pytest.mark.skipif( + sys.platform == "win32", reason="See #1074 - signal pipe not available on Windows" +) class TestPipeClosedOnRestore: """TestPipeClosedOnRestore test suite.""" @@ -168,7 +172,9 @@ def test_pipe_closed_on_restore(self) -> None: # --------------------------------------------------------------------------- -@pytest.mark.skipif(sys.platform == "win32", reason="signal pipe not available on Windows") +@pytest.mark.skipif( + sys.platform == "win32", reason="See #1074 - signal pipe not available on Windows" +) class TestInstallSignalHandlersMainThread: """TestInstallSignalHandlersMainThread test suite.""" diff --git a/tests/e2e/test_full_pipeline.py b/tests/e2e/test_full_pipeline.py index cfb42d65a..553c9dade 100644 --- a/tests/e2e/test_full_pipeline.py +++ b/tests/e2e/test_full_pipeline.py @@ -309,7 +309,9 @@ def test_collect_files_timing( assert elapsed < 1.0, f"_collect_files too slow: {elapsed:.3f}s" @pytest.mark.benchmark - @pytest.mark.skipif(not HAS_PYTEST_BENCHMARK, reason="pytest-benchmark not installed") + @pytest.mark.skipif( + not HAS_PYTEST_BENCHMARK, reason="See #1084 - pytest-benchmark optional dependency" + ) def test_benchmark_organize( self, benchmark: Any, diff --git a/tests/integration/test_context_menu_macos.py b/tests/integration/test_context_menu_macos.py index 5e889e7f4..626443760 100644 --- a/tests/integration/test_context_menu_macos.py +++ b/tests/integration/test_context_menu_macos.py @@ -8,7 +8,7 @@ import pytest -@pytest.mark.skipif(sys.platform != "darwin", reason="macOS-only test") +@pytest.mark.skipif(sys.platform != "darwin", reason="See #1083 - macOS-only Quick Action feature") class TestMacOSQuickAction(unittest.TestCase): def setUp(self): self.macos_dir = Path("desktop/context-menus/macos") diff --git a/tests/integration/test_error_propagation.py b/tests/integration/test_error_propagation.py index 5286358e9..82f79b22d 100644 --- a/tests/integration/test_error_propagation.py +++ b/tests/integration/test_error_propagation.py @@ -27,7 +27,9 @@ class TestFileReadErrors: """File read errors surface in ProcessedFile results, not exceptions.""" - @pytest.mark.skipif(sys.platform == "win32", reason="chmod does not restrict reads on Windows") + @pytest.mark.skipif( + sys.platform == "win32", reason="See #1078 - chmod does not restrict reads on Windows" + ) def test_unreadable_file_returns_error_in_result( self, stub_text_model_init: None, @@ -120,7 +122,9 @@ def test_missing_input_dir_raises_valueerror( output_path=str(tmp_path / "output"), ) - @pytest.mark.skipif(sys.platform == "win32", reason="chmod does not restrict reads on Windows") + @pytest.mark.skipif( + sys.platform == "win32", reason="See #1078 - chmod does not restrict reads on Windows" + ) def test_mixed_good_and_bad_files_in_batch( self, stub_all_models: None, diff --git a/tests/integration/test_image_quality_para_suggestion.py b/tests/integration/test_image_quality_para_suggestion.py index e76538285..687e6d970 100644 --- a/tests/integration/test_image_quality_para_suggestion.py +++ b/tests/integration/test_image_quality_para_suggestion.py @@ -445,23 +445,35 @@ def test_candidate_larger_returns_false( _suggestion_engine_available = False -@pytest.mark.skipif(not _suggestion_engine_available, reason="SuggestionEngine not importable") +@pytest.mark.skipif( + not _suggestion_engine_available, + reason="See #1077 - SuggestionEngine API not yet implemented (tests expect SuggestionEngine, but PARASuggestionEngine exists)", +) class TestSuggestionEngineInit: def test_creates(self) -> None: se = SuggestionEngine() assert se is not None -@pytest.mark.skipif(not _suggestion_engine_available, reason="SuggestionEngine not importable") +@pytest.mark.skipif( + not _suggestion_engine_available, + reason="See #1077 - SuggestionEngine API not yet implemented (tests expect SuggestionEngine, but PARASuggestionEngine exists)", +) class TestSuggestionEngineAPI: @pytest.fixture() def engine(self) -> SuggestionEngine: return SuggestionEngine() def test_has_suggest_method(self, engine: SuggestionEngine) -> None: - # Verify the engine has suggest-type methods - methods = [m for m in dir(engine) if not m.startswith("_") and callable(getattr(engine, m))] - assert len(methods) > 0 + # Verify the engine exposes at least one of the expected suggest API methods + expected_methods = {"suggest_category", "suggest"} + public_methods = [ + m for m in dir(engine) if not m.startswith("_") and callable(getattr(engine, m)) + ] + suggest_methods = {m for m in public_methods if "suggest" in m.lower()} + assert suggest_methods & expected_methods, ( + f"SuggestionEngine must have suggest_category or suggest; found public methods: {public_methods}" + ) def test_suggest_category_returns_something( self, engine: SuggestionEngine, tmp_path: Path @@ -476,5 +488,8 @@ def test_suggest_category_returns_something( result = engine.suggest(f) assert result is not None else: - # No matching method found — skip silently - pytest.skip("No suggest method found on SuggestionEngine") + # No matching method found — fail so a SuggestionEngine with no suggest API is caught + pytest.fail( + "SuggestionEngine has neither suggest_category() nor suggest() — " + "at least one must exist for this class to be useful" + ) diff --git a/tests/integration/test_organize_text_workflow.py b/tests/integration/test_organize_text_workflow.py index fdcd84f31..b9b2057d2 100644 --- a/tests/integration/test_organize_text_workflow.py +++ b/tests/integration/test_organize_text_workflow.py @@ -97,7 +97,7 @@ def mock_process_file(file_path: Path) -> ProcessedFile: assert (source_dir / "report.txt").exists() @pytest.mark.skipif( - sys.platform == "win32", reason="Hardlinks require admin privileges on Windows" + sys.platform == "win32", reason="See #1082 - Hardlinks require admin privileges on Windows" ) @patch("file_organizer.core.organizer.TextProcessor") @patch("file_organizer.core.organizer.VisionProcessor") diff --git a/tests/parallel/test_checkpoint.py b/tests/parallel/test_checkpoint.py index 9058af998..5195e1a07 100644 --- a/tests/parallel/test_checkpoint.py +++ b/tests/parallel/test_checkpoint.py @@ -443,7 +443,9 @@ def tearDown(self) -> None: shutil.rmtree(self._tmpdir, ignore_errors=True) - @pytest.mark.skipif(sys.platform == "win32", reason="directory fsync is a no-op on Windows") + @pytest.mark.skipif( + sys.platform == "win32", reason="See #1081 - directory fsync is a no-op on Windows" + ) def test_save_checkpoint_calls_fsync(self) -> None: """Verify os.fsync is called during save_checkpoint (file + directory).""" ckpt = Checkpoint(job_id="fsync-test", file_hashes={}) diff --git a/tests/plugins/test_base_coverage.py b/tests/plugins/test_base_coverage.py index 26da9952e..33c3f6a41 100644 --- a/tests/plugins/test_base_coverage.py +++ b/tests/plugins/test_base_coverage.py @@ -76,7 +76,9 @@ def test_manifest_preserves_existing_optional_fields(self, tmp_path): assert result["license"] == "Apache-2.0" assert result["homepage"] == "https://example.com" - @pytest.mark.skipif(sys.platform == "win32", reason="chmod does not restrict reads on Windows") + @pytest.mark.skipif( + sys.platform == "win32", reason="See #1078 - chmod does not restrict reads on Windows" + ) def test_unreadable_manifest_raises(self, tmp_path): plugin_dir = tmp_path / "plugin" plugin_dir.mkdir() diff --git a/tests/plugins/test_sandbox_isolation.py b/tests/plugins/test_sandbox_isolation.py index 9d02ce87f..c94abce29 100644 --- a/tests/plugins/test_sandbox_isolation.py +++ b/tests/plugins/test_sandbox_isolation.py @@ -14,9 +14,8 @@ executor_interface White-box tests for the ``PluginExecutor`` lifecycle and RPC. - Marked ``@pytest.mark.skip`` until Stream A delivers the real - implementation. The full assertion bodies are written here so they are - ready to un-skip. + Skipped via issue #338 until Stream A delivers the real implementation. + The full assertion bodies are written here so they are ready to un-skip. ipc_protocol Unit tests for the IPC dataclasses and encoding/decoding helpers in @@ -267,12 +266,13 @@ def _guarded_open(path: Any, *args: Any, **kwargs: Any) -> Any: @pytest.mark.unit +@pytest.mark.skip(reason="See #338 - Stream A executor not yet delivered") class TestExecutorInterface: """Tests for PluginExecutor lifecycle and RPC. - All tests in this class are skipped until Stream A delivers a working - ``PluginExecutor``. The test bodies are complete — only remove the - ``@pytest.mark.skip`` decorator when the executor is ready. + All tests in this class are skipped (see #338) until Stream A delivers a working + ``PluginExecutor``. The test bodies are complete — only remove the skip decorator + when the executor is ready. """ def test_executor_starts_and_stops(self, tmp_path: Path) -> None: diff --git a/tests/services/test_audio_transcription.py b/tests/services/test_audio_transcription.py index bb5fb453a..f588533cf 100644 --- a/tests/services/test_audio_transcription.py +++ b/tests/services/test_audio_transcription.py @@ -30,7 +30,7 @@ def test_transcriber_initialization(self): except (ImportError, NotImplementedError): pytest.skip("AudioTranscriber not yet fully implemented (Phase 3)") - @pytest.mark.skip(reason="Phase 3 - Audio transcription not yet implemented") + @pytest.mark.skip(reason="See #1071 - Phase 3 audio transcription feature not yet implemented") def test_transcribe_mp3_file(self, tmp_path): """Test transcribing MP3 file.""" from file_organizer.services.audio.transcriber import AudioTranscriber @@ -45,7 +45,7 @@ def test_transcribe_mp3_file(self, tmp_path): assert result is not None assert "text" in result - @pytest.mark.skip(reason="Phase 3 - Audio transcription not yet implemented") + @pytest.mark.skip(reason="See #1071 - Phase 3 audio transcription feature not yet implemented") def test_transcribe_wav_file(self, tmp_path): """Test transcribing WAV file.""" from file_organizer.services.audio.transcriber import AudioTranscriber @@ -58,7 +58,7 @@ def test_transcribe_wav_file(self, tmp_path): assert result is not None - @pytest.mark.skip(reason="Phase 3 - Audio transcription not yet implemented") + @pytest.mark.skip(reason="See #1071 - Phase 3 audio transcription feature not yet implemented") def test_language_detection(self, tmp_path): """Test language detection in transcription.""" from file_organizer.services.audio.transcriber import AudioTranscriber diff --git a/tests/services/test_video_processing.py b/tests/services/test_video_processing.py index b3c142fc3..45c229296 100644 --- a/tests/services/test_video_processing.py +++ b/tests/services/test_video_processing.py @@ -30,7 +30,7 @@ def test_vision_processor_initialization(self): except (ImportError, Exception): pytest.skip("VisionProcessor not yet fully implemented") - @pytest.mark.skip(reason="Phase 3 - Advanced video processing not yet implemented") + @pytest.mark.skip(reason="See #1073 - Phase 3 advanced video processing not yet implemented") def test_process_mp4_video(self, tmp_path): """Test processing MP4 video file.""" from file_organizer.services.vision_processor import VisionProcessor @@ -43,20 +43,24 @@ def test_process_mp4_video(self, tmp_path): assert result is not None - @pytest.mark.skip(reason="Phase 3 - Scene detection not yet implemented") + @pytest.mark.skip( + reason="See #1073 - Requires real video file; fake bytes cause decode failure" + ) def test_scene_detection(self, tmp_path): """Test scene detection in video.""" - from file_organizer.services.video.scene_detector import SceneDetector + from file_organizer.services.video.scene_detector import SceneDetectionResult, SceneDetector video_file = tmp_path / "test.mp4" video_file.write_bytes(b"fake video") detector = SceneDetector() - scenes = detector.detect_scenes(video_file) + result = detector.detect_scenes(video_file) - assert isinstance(scenes, list) and all(hasattr(s, "start_time") for s in scenes) + assert isinstance(result, SceneDetectionResult) + assert isinstance(result.scenes, list) + assert all(hasattr(s, "start_time") for s in result.scenes) - @pytest.mark.skip(reason="Phase 3 - Frame extraction not yet implemented") + @pytest.mark.skip(reason="See #1073 - Phase 3 frame extraction not yet implemented") def test_frame_extraction(self, tmp_path): """Test extracting frames from video.""" from file_organizer.services.vision_processor import VisionProcessor @@ -65,6 +69,7 @@ def test_frame_extraction(self, tmp_path): video_file.write_bytes(b"fake avi") processor = VisionProcessor() + # TODO: replace fake_avi with a real fixture when unskipping — fake bytes cause decode failure frames = processor.extract_frames(video_file, interval=1.0) - assert frames == [] + assert len(frames) >= 1, "extract_frames should return at least one frame per second" diff --git a/tests/test_web_files_routes.py b/tests/test_web_files_routes.py index 1eebeb4e0..692900cec 100644 --- a/tests/test_web_files_routes.py +++ b/tests/test_web_files_routes.py @@ -86,7 +86,7 @@ def test_files_sort_by_modified(self, tmp_path: Path, web_client_builder) -> Non @pytest.mark.skipif( platform.system() in ("Windows", "Darwin"), - reason="Creation time sorting is flaky on Windows/macOS: st_birthtime and st_ctime " + reason="See #1085 - Creation time sorting is flaky on Windows/macOS: st_birthtime and st_ctime " "don't reliably match st_mtime (used by os.utime). Skip on these platforms.", ) def test_files_sort_by_created(self, tmp_path: Path, web_client_builder) -> None: @@ -243,7 +243,7 @@ class TestFilesSSEHandling: # # Endpoint should exist or be explicitly not implemented # assert response.status_code in (200, 404) - @pytest.mark.skip(reason="SSE routes not yet implemented") + @pytest.mark.skip(reason="See #1076 - SSE routes for file browser not yet implemented") def test_files_sse_placeholder(self) -> None: """Placeholder test for SSE handling until SSE routes are implemented.""" diff --git a/tests/test_web_organize_routes.py b/tests/test_web_organize_routes.py index 1e7e44453..65a191db8 100644 --- a/tests/test_web_organize_routes.py +++ b/tests/test_web_organize_routes.py @@ -327,7 +327,7 @@ def test_organize_scan_with_progress_updates( # Verify response includes progress indication assert "plan" in response.text.lower() or "organize" in response.text.lower() - @pytest.mark.skip(reason="SSE streaming not yet implemented") + @pytest.mark.skip(reason="See #1080 - SSE streaming not yet implemented") def test_organize_stream_cancellation(self) -> None: """Stream should handle cancellation/timeout gracefully.""" # Progress stream endpoint not yet implemented diff --git a/tests/undo/test_rollback_extended.py b/tests/undo/test_rollback_extended.py index aea04625b..4d7df0c21 100644 --- a/tests/undo/test_rollback_extended.py +++ b/tests/undo/test_rollback_extended.py @@ -151,7 +151,9 @@ def test_redo_create_directory(self, env): assert executor.redo_create(op) is True assert target.is_dir() - @pytest.mark.skipif(sys.platform == "win32", reason="/dev/null is writable on Windows") + @pytest.mark.skipif( + sys.platform == "win32", reason="See #1075 - /dev/null is writable on Windows" + ) def test_redo_create_exception(self, env): _, _, executor = env # Provide a path whose parent can't be created @@ -359,7 +361,9 @@ def test_rollback_copy_exception(self, env): result = executor.rollback_copy(op) assert result is False - @pytest.mark.skipif(sys.platform == "win32", reason="/dev/null is writable on Windows") + @pytest.mark.skipif( + sys.platform == "win32", reason="See #1075 - /dev/null is writable on Windows" + ) def test_rollback_create_exception(self, env): _, _, executor = env op = _op(OperationType.CREATE, Path("/dev/null/impossible")) diff --git a/tests/unit/utils/test_file_readers.py b/tests/unit/utils/test_file_readers.py index 342ce06d9..86f059965 100644 --- a/tests/unit/utils/test_file_readers.py +++ b/tests/unit/utils/test_file_readers.py @@ -31,7 +31,6 @@ read_zip_file, ) from file_organizer.utils.readers._base import _check_file_size -from file_organizer.utils.readers.ebook import EBOOKLIB_AVAILABLE pytestmark = [pytest.mark.unit] @@ -219,14 +218,15 @@ def test_read_presentation_not_installed(self) -> None: with pytest.raises(ImportError, match="python-pptx is not installed"): read_presentation_file("test.pptx") - @pytest.mark.skipif(not EBOOKLIB_AVAILABLE, reason="ebooklib not installed") @patch("file_organizer.utils.readers.ebook.EBOOKLIB_AVAILABLE", True) @patch("file_organizer.utils.readers.ebook.epub", create=True) def test_read_ebook_file(self, mock_epub: MagicMock, tmp_path: Path) -> None: """Test reading EPUB.""" + # Skip if ebooklib not available - See #1079 + ebooklib = pytest.importorskip("ebooklib") mock_book = MagicMock() mock_item = MagicMock() - mock_item.get_type.return_value = ebooklib.ITEM_DOCUMENT if ebooklib is not None else 9 + mock_item.get_type.return_value = ebooklib.ITEM_DOCUMENT mock_item.get_content.return_value = b"Ebook Content" mock_book.get_items.return_value = [mock_item] mock_epub.read_epub.return_value = mock_book diff --git a/tests/utils/test_audio_metadata.py b/tests/utils/test_audio_metadata.py index 94391c188..8d671acc5 100644 --- a/tests/utils/test_audio_metadata.py +++ b/tests/utils/test_audio_metadata.py @@ -20,7 +20,7 @@ def test_audio_metadata_module_exists(self): except ImportError: pytest.skip("Audio metadata extraction not yet implemented (Phase 3)") - @pytest.mark.skip(reason="Phase 3 - Audio metadata not yet implemented") + @pytest.mark.skip(reason="See #611 - Needs real audio fixtures with metadata, not fake bytes") def test_extract_mp3_metadata(self, tmp_path): """Test extracting metadata from MP3 file.""" from file_organizer.services.audio.metadata_extractor import ( @@ -36,7 +36,7 @@ def test_extract_mp3_metadata(self, tmp_path): assert "duration" in metadata assert "format" in metadata - @pytest.mark.skip(reason="Phase 3 - Audio metadata not yet implemented") + @pytest.mark.skip(reason="See #611 - Needs real WAV fixtures with proper headers") def test_extract_wav_metadata(self, tmp_path): """Test extracting metadata from WAV file.""" from file_organizer.services.audio.metadata_extractor import ( @@ -49,9 +49,10 @@ def test_extract_wav_metadata(self, tmp_path): extractor = AudioMetadataExtractor() metadata = extractor.extract(audio_file) - assert metadata is not None + assert "duration" in metadata + assert "format" in metadata - @pytest.mark.skip(reason="Phase 3 - Music metadata not yet implemented") + @pytest.mark.skip(reason="See #611 - Needs MP3 fixtures with ID3 tags for testing") def test_extract_music_tags(self, tmp_path): """Test extracting music tags (artist, album, etc.).""" from file_organizer.services.audio.metadata_extractor import ( @@ -65,4 +66,4 @@ def test_extract_music_tags(self, tmp_path): metadata = extractor.extract(audio_file) # Should extract ID3 tags - assert "title" in metadata or metadata is not None + assert "title" in metadata diff --git a/tests/utils/test_epub_enhanced.py b/tests/utils/test_epub_enhanced.py index 52e4a39ce..1229a19b1 100644 --- a/tests/utils/test_epub_enhanced.py +++ b/tests/utils/test_epub_enhanced.py @@ -16,13 +16,6 @@ import pytest -try: - from PIL import Image - - PILLOW_AVAILABLE = True -except ImportError: - PILLOW_AVAILABLE = False - try: import ebooklib from ebooklib import epub @@ -347,10 +340,11 @@ def test_has_cover(self): mock_book.get_items = Mock(return_value=[]) assert reader._has_cover(mock_book) is False - @pytest.mark.skipif(not PILLOW_AVAILABLE, reason="Pillow not installed") @patch("file_organizer.utils.epub_enhanced.epub.read_epub") def test_extract_cover(self, mock_read, tmp_path): """Test extracting cover image.""" + # Skip if Pillow not available - See #1079 + PIL_Image = pytest.importorskip("PIL.Image") reader = EnhancedEPUBReader() # Create mock cover item @@ -358,7 +352,7 @@ def test_extract_cover(self, mock_read, tmp_path): mock_cover.get_type = Mock(return_value=ebooklib.ITEM_COVER) # Create a minimal PNG image - img = Image.new("RGB", (100, 150), color="red") + img = PIL_Image.new("RGB", (100, 150), color="red") img_bytes = io.BytesIO() img.save(img_bytes, format="PNG") img_bytes.seek(0) diff --git a/tests/utils/test_file_readers.py b/tests/utils/test_file_readers.py index 342ce06d9..86f059965 100644 --- a/tests/utils/test_file_readers.py +++ b/tests/utils/test_file_readers.py @@ -31,7 +31,6 @@ read_zip_file, ) from file_organizer.utils.readers._base import _check_file_size -from file_organizer.utils.readers.ebook import EBOOKLIB_AVAILABLE pytestmark = [pytest.mark.unit] @@ -219,14 +218,15 @@ def test_read_presentation_not_installed(self) -> None: with pytest.raises(ImportError, match="python-pptx is not installed"): read_presentation_file("test.pptx") - @pytest.mark.skipif(not EBOOKLIB_AVAILABLE, reason="ebooklib not installed") @patch("file_organizer.utils.readers.ebook.EBOOKLIB_AVAILABLE", True) @patch("file_organizer.utils.readers.ebook.epub", create=True) def test_read_ebook_file(self, mock_epub: MagicMock, tmp_path: Path) -> None: """Test reading EPUB.""" + # Skip if ebooklib not available - See #1079 + ebooklib = pytest.importorskip("ebooklib") mock_book = MagicMock() mock_item = MagicMock() - mock_item.get_type.return_value = ebooklib.ITEM_DOCUMENT if ebooklib is not None else 9 + mock_item.get_type.return_value = ebooklib.ITEM_DOCUMENT mock_item.get_content.return_value = b"Ebook Content" mock_book.get_items.return_value = [mock_item] mock_epub.read_epub.return_value = mock_book diff --git a/tests/utils/test_video_metadata.py b/tests/utils/test_video_metadata.py index 141325867..bda8af237 100644 --- a/tests/utils/test_video_metadata.py +++ b/tests/utils/test_video_metadata.py @@ -20,43 +20,49 @@ def test_video_metadata_module_exists(self): except ImportError: pytest.skip("Video metadata extraction not yet implemented (Phase 3)") - @pytest.mark.skip(reason="Phase 3 - Video metadata not yet implemented") + @pytest.mark.skip( + reason="See #1073 - Requires real video file; fake bytes cause decode failure" + ) def test_extract_mp4_metadata(self, tmp_path): """Test extracting metadata from MP4 file.""" - from file_organizer.services.vision_processor import VisionProcessor + from file_organizer.services.video.metadata_extractor import VideoMetadataExtractor video_file = tmp_path / "test.mp4" video_file.write_bytes(b"fake mp4") - processor = VisionProcessor() - metadata = processor.extract_metadata(video_file) + extractor = VideoMetadataExtractor() + metadata = extractor.extract(video_file) - assert "duration" in metadata - assert "resolution" in metadata + assert metadata.duration is not None + assert metadata.width is not None - @pytest.mark.skip(reason="Phase 3 - Video metadata not yet implemented") + @pytest.mark.skip( + reason="See #1073 - Requires real video file; fake bytes cause decode failure" + ) def test_extract_resolution(self, tmp_path): """Test extracting video resolution.""" - from file_organizer.services.vision_processor import VisionProcessor + from file_organizer.services.video.metadata_extractor import VideoMetadataExtractor video_file = tmp_path / "test.avi" video_file.write_bytes(b"fake avi") - processor = VisionProcessor() - metadata = processor.extract_metadata(video_file) + extractor = VideoMetadataExtractor() + metadata = extractor.extract(video_file) - assert "width" in metadata - assert "height" in metadata + assert metadata.width is not None + assert metadata.height is not None - @pytest.mark.skip(reason="Phase 3 - Video codec detection not yet implemented") + @pytest.mark.skip( + reason="See #1073 - Requires real video file; fake bytes cause decode failure" + ) def test_detect_codec(self, tmp_path): """Test detecting video codec.""" - from file_organizer.services.vision_processor import VisionProcessor + from file_organizer.services.video.metadata_extractor import VideoMetadataExtractor video_file = tmp_path / "test.mkv" video_file.write_bytes(b"fake mkv") - processor = VisionProcessor() - metadata = processor.extract_metadata(video_file) + extractor = VideoMetadataExtractor() + metadata = extractor.extract(video_file) - assert "codec" in metadata or metadata is not None + assert metadata.codec is not None