From bfdeb3cbb71a240c4157c1834427e4a3c8d6c803 Mon Sep 17 00:00:00 2001 From: Sheersh Jaiswal Date: Thu, 5 Nov 2026 15:04:17 +0530 Subject: [PATCH 1/3] test: add unit and integration suites; fix bugs they uncovered Tests: ~300 new Python tests (mocked units, CLI handlers, real-ffmpeg integration) and Flutter tests for models, app state, scan and transcode. Fixes: - compress --report x.json wrote HTML instead of JSON - --quality-check compared the output to itself - ContentType.UNKNOWN aliased LIVE_ACTION; add preset_key mapping - duplicate finder missed matches across 4s duration buckets - report size formatting truncated fractions; escape HTML in reports - content_classifier failed to import on Python 3.9 --- desktop/test/app_state_test.dart | 167 ++++++++++++++++++++ desktop/test/compression_job_test.dart | 43 +++++ desktop/test/ffmpeg_and_scan_test.dart | 92 +++++++++++ desktop/test/models_extended_test.dart | 109 +++++++++++++ mediacrush/cli/commands.py | 20 ++- mediacrush/core/content_classifier.py | 9 +- mediacrush/core/deduplicator.py | 25 +-- mediacrush/core/reporter.py | 9 +- tests/conftest.py | 100 ++++++++++++ tests/test_classifier_ppm.py | 115 ++++++++++++++ tests/test_cli_parser.py | 67 ++++++++ tests/test_commands.py | 210 +++++++++++++++++++++++++ tests/test_content_classifier.py | 3 +- tests/test_dedupe_logic.py | 94 +++++++++++ tests/test_formatters.py | 51 ++++++ tests/test_hardware_mocked.py | 93 +++++++++++ tests/test_integration_ffmpeg.py | 183 +++++++++++++++++++++ tests/test_presets_args.py | 113 +++++++++++++ tests/test_probe_parsing.py | 141 +++++++++++++++++ tests/test_quality_mocked.py | 85 ++++++++++ tests/test_queue_extra.py | 77 +++++++++ tests/test_reporter_extra.py | 56 +++++++ tests/test_safety_finalize.py | 94 +++++++++++ tests/test_scanner_scan.py | 143 +++++++++++++++++ tests/test_transcoder.py | 165 +++++++++++++++++++ tests/test_watcher.py | 117 ++++++++++++++ tests/test_worker.py | 78 +++++++++ 27 files changed, 2433 insertions(+), 26 deletions(-) create mode 100644 desktop/test/app_state_test.dart create mode 100644 desktop/test/compression_job_test.dart create mode 100644 desktop/test/ffmpeg_and_scan_test.dart create mode 100644 desktop/test/models_extended_test.dart create mode 100644 tests/conftest.py create mode 100644 tests/test_classifier_ppm.py create mode 100644 tests/test_cli_parser.py create mode 100644 tests/test_commands.py create mode 100644 tests/test_dedupe_logic.py create mode 100644 tests/test_formatters.py create mode 100644 tests/test_hardware_mocked.py create mode 100644 tests/test_integration_ffmpeg.py create mode 100644 tests/test_presets_args.py create mode 100644 tests/test_probe_parsing.py create mode 100644 tests/test_quality_mocked.py create mode 100644 tests/test_queue_extra.py create mode 100644 tests/test_reporter_extra.py create mode 100644 tests/test_safety_finalize.py create mode 100644 tests/test_scanner_scan.py create mode 100644 tests/test_transcoder.py create mode 100644 tests/test_watcher.py create mode 100644 tests/test_worker.py diff --git a/desktop/test/app_state_test.dart b/desktop/test/app_state_test.dart new file mode 100644 index 0000000..ac3133b --- /dev/null +++ b/desktop/test/app_state_test.dart @@ -0,0 +1,167 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:desktop/models/hardware_engine.dart'; +import 'package:desktop/state/app_state.dart'; + +void main() { + late Directory tmp; + late AppState state; + int notifications = 0; + + setUp(() { + tmp = Directory.systemTemp.createTempSync('mc_state_'); + state = AppState(); + notifications = 0; + state.addListener(() => notifications++); + }); + + tearDown(() { + state.dispose(); + if (tmp.existsSync()) tmp.deleteSync(recursive: true); + }); + + File make(String name, int bytes) { + final f = File(p.join(tmp.path, name))..createSync(recursive: true); + f.writeAsBytesSync(List.filled(bytes, 0)); + return f; + } + + group('initial state', () { + test('is empty and idle', () { + expect(state.items, isEmpty); + expect(state.isScanning, isFalse); + expect(state.isCompressing, isFalse); + expect(state.job, isNull); + expect(state.preset, 'anime'); + expect(state.outputFolder, isNull); + expect(state.totalCandidates, 0); + expect(state.savingsPercentage, 0.0); + expect(state.hardware.name, 'libx265'); + }); + }); + + group('setters notify listeners', () { + test('preset', () { + state.setPreset('saver'); + expect(state.preset, 'saver'); + expect(notifications, 1); + }); + + test('output folder', () { + state.setOutputFolder('/out'); + expect(state.outputFolder, '/out'); + expect(notifications, 1); + }); + + test('hardware', () { + state.setHardware(HardwareEngine.fromCliName('nvenc')); + expect(state.hardware.vendor, 'NVIDIA'); + expect(notifications, 1); + }); + }); + + group('addFiles', () { + test('adds existing files with estimate and default output folder', () { + final f = make('a.mkv', 1000); + state.addFiles([f.path]); + expect(state.items, hasLength(1)); + expect(state.items.first.fileName, 'a.mkv'); + expect(state.items.first.originalBytes, 1000); + expect(state.items.first.estimatedBytes, 450); + expect(state.outputFolder, p.join(tmp.path, 'crushed')); + expect(notifications, 1); + }); + + test('ignores missing files and duplicates', () { + final f = make('a.mkv', 100); + state.addFiles([f.path, f.path, p.join(tmp.path, 'ghost.mkv')]); + expect(state.items, hasLength(1)); + }); + + test('empty list is a no-op and does not notify', () { + state.addFiles([]); + expect(notifications, 0); + }); + + test('keeps an explicitly chosen output folder', () { + state.setOutputFolder('/custom'); + state.addFiles([make('a.mkv', 10).path]); + expect(state.outputFolder, '/custom'); + }); + + test('aggregates totals and savings', () { + state.addFiles([make('a.mkv', 1000).path, make('b.mkv', 3000).path]); + expect(state.totalCandidates, 2); + expect(state.totalOriginalBytes, 4000); + expect(state.totalEstimatedBytes, 1800); + expect(state.totalSavedBytes, 2200); + expect(state.savingsPercentage, closeTo(55.0, 1e-9)); + }); + }); + + group('scanFolder', () { + test('finds only video files recursively and sets folders', () async { + make('a.mkv', 10); + make('sub/b.MP4', 10); + make('notes.txt', 10); + await state.scanFolder(tmp.path); + expect(state.items.map((i) => i.fileName).toSet(), {'a.mkv', 'b.MP4'}); + expect(state.selectedFolder, tmp.path); + expect(state.outputFolder, p.join(tmp.path, 'crushed')); + expect(state.isScanning, isFalse); + expect(notifications, greaterThanOrEqualTo(2)); // scanning on, scanning off + }); + + test('missing folder yields no items', () async { + await state.scanFolder(p.join(tmp.path, 'nope')); + expect(state.items, isEmpty); + expect(state.isScanning, isFalse); + }); + + test('rescan replaces previous items', () async { + make('a.mkv', 10); + await state.scanFolder(tmp.path); + File(p.join(tmp.path, 'a.mkv')).deleteSync(); + await state.scanFolder(tmp.path); + expect(state.items, isEmpty); + }); + }); + + group('clearItems', () { + test('empties list and selection', () async { + make('a.mkv', 10); + await state.scanFolder(tmp.path); + state.clearItems(); + expect(state.items, isEmpty); + expect(state.selectedFolder, isNull); + }); + }); + + group('compression control', () { + test('startCompression with nothing queued does nothing', () async { + await state.startCompression(); + expect(state.job, isNull); + expect(state.isCompressing, isFalse); + }); + + test('pauseCompression is safe when idle and notifies', () { + state.pauseCompression(); + expect(state.isCompressing, isFalse); + expect(notifications, 1); + }); + + test('a bogus input fails the item without crashing and finishes the job', () async { + // Input exists on disk but is not a real video, so ffmpeg (or its absence) must fail cleanly. + final f = make('fake.mkv', 2048); + state.addFiles([f.path]); + state.setOutputFolder(p.join(tmp.path, 'out')); + await state.startCompression(); + expect(state.isCompressing, isFalse); + expect(state.job!.isRunning, isFalse); + expect(state.job!.completedFiles, 0); + expect(state.items.first.status.name, 'failed'); + }, timeout: const Timeout(Duration(seconds: 60))); + }); +} diff --git a/desktop/test/compression_job_test.dart b/desktop/test/compression_job_test.dart new file mode 100644 index 0000000..5b11df2 --- /dev/null +++ b/desktop/test/compression_job_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:desktop/models/compression_job.dart'; + +void main() { + group('CompressionJob', () { + test('defaults', () { + final j = CompressionJob(totalFiles: 3); + expect(j.completedFiles, 0); + expect(j.overallProgress, 0.0); + expect(j.isRunning, isFalse); + expect(j.isPaused, isFalse); + expect(j.currentSpeedMultiplier, 1.0); + expect(j.totalSavedBytes, 0); + }); + + test('totalSavedBytes is the difference', () { + final j = CompressionJob(totalFiles: 1, originalTotalBytes: 1000, finalTotalBytes: 400); + expect(j.totalSavedBytes, 600); + expect(j.overallSavingsPercentage, closeTo(60.0, 1e-9)); + }); + + test('totalSavedBytes never negative when output grows', () { + final j = CompressionJob(totalFiles: 1, originalTotalBytes: 100, finalTotalBytes: 500); + expect(j.totalSavedBytes, 0); + expect(j.overallSavingsPercentage, 0.0); + }); + + test('percentage is zero when nothing to compress', () { + expect(CompressionJob(totalFiles: 0).overallSavingsPercentage, 0.0); + expect(CompressionJob(totalFiles: 1, originalTotalBytes: -5).overallSavingsPercentage, 0.0); + }); + + test('fields are mutable for live progress', () { + final j = CompressionJob(totalFiles: 2) + ..completedFiles = 1 + ..overallProgress = 0.5 + ..isRunning = true; + expect(j.completedFiles, 1); + expect(j.overallProgress, 0.5); + expect(j.isRunning, isTrue); + }); + }); +} diff --git a/desktop/test/ffmpeg_and_scan_test.dart b/desktop/test/ffmpeg_and_scan_test.dart new file mode 100644 index 0000000..2727ee0 --- /dev/null +++ b/desktop/test/ffmpeg_and_scan_test.dart @@ -0,0 +1,92 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:desktop/models/hardware_engine.dart'; +import 'package:desktop/services/mediacrush_cli.dart'; +import 'package:desktop/services/transcode_service.dart'; +import 'package:desktop/utils/ffmpeg_locator.dart'; + +void main() { + group('FfmpegLocator', () { + test('always returns a non-empty binary name/path', () async { + expect(await FfmpegLocator.getFfmpegPath(), isNotEmpty); + expect(await FfmpegLocator.getFfprobePath(), isNotEmpty); + }); + + test('path lookups are stable (cached)', () async { + expect(await FfmpegLocator.getFfmpegPath(), await FfmpegLocator.getFfmpegPath()); + }); + + test('availability agrees with the resolved binary', () async { + final available = await FfmpegLocator.isFfmpegAvailable(); + final path = await FfmpegLocator.getFfmpegPath(); + final res = await Process.run(path, ['-version']).then((r) => r.exitCode == 0, onError: (_) => false); + expect(available, res); + }); + }); + + group('MediaCrushCli.scanDirectory', () { + late Directory tmp; + final cli = MediaCrushCli(); + + setUp(() => tmp = Directory.systemTemp.createTempSync('mc_scan_')); + tearDown(() => tmp.deleteSync(recursive: true)); + + void touch(String rel, [int bytes = 4]) { + File(p.join(tmp.path, rel)) + ..createSync(recursive: true) + ..writeAsBytesSync(List.filled(bytes, 1)); + } + + test('every supported extension is picked up (case-insensitive)', () async { + for (final ext in MediaCrushCli.videoExtensions) { + touch('v$ext'); + touch('UP${ext.toUpperCase()}'); + } + final items = await cli.scanDirectory(tmp.path); + expect(items, hasLength(MediaCrushCli.videoExtensions.length * 2)); + }); + + test('ignores non-video files', () async { + touch('a.txt'); + touch('b.jpg'); + touch('c.srt'); + expect(await cli.scanDirectory(tmp.path), isEmpty); + }); + + test('recurses and records size + 45% estimate', () async { + touch('deep/er/clip.mkv', 2000); + final items = await cli.scanDirectory(tmp.path); + expect(items.single.originalBytes, 2000); + expect(items.single.estimatedBytes, 900); + expect(items.single.fileName, 'clip.mkv'); + }); + + test('empty directory', () async { + expect(await cli.scanDirectory(tmp.path), isEmpty); + }); + }); + + group('TranscodeService', () { + test('returns false and leaves no output for a missing input', () async { + final tmp = Directory.systemTemp.createTempSync('mc_tc_'); + addTearDown(() => tmp.deleteSync(recursive: true)); + final out = p.join(tmp.path, 'out.mp4'); + final progress = []; + final ok = await TranscodeService().transcode( + inputPath: p.join(tmp.path, 'missing.mkv'), + outputPath: out, + engine: HardwareEngine.fallback(), + preset: 'film', + onProgress: progress.add, + ); + expect(ok, isFalse); + expect(File(out).existsSync(), isFalse); + }, timeout: const Timeout(Duration(seconds: 60))); + + test('cancel is safe with no active process', () { + TranscodeService().cancel(); + }); + }); +} diff --git a/desktop/test/models_extended_test.dart b/desktop/test/models_extended_test.dart new file mode 100644 index 0000000..8cdd51a --- /dev/null +++ b/desktop/test/models_extended_test.dart @@ -0,0 +1,109 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:desktop/models/hardware_engine.dart'; +import 'package:desktop/models/media_item.dart'; + +MediaItem item({int original = 1000, int estimated = 450, int fin = 0, MediaStatus status = MediaStatus.pending}) => + MediaItem( + path: '/a.mkv', + fileName: 'a.mkv', + codec: 'H.264', + resolution: '1080p', + originalBytes: original, + estimatedBytes: estimated, + finalBytes: fin, + status: status, + ); + +void main() { + group('HardwareEngine.fromCliName', () { + const table = { + 'hevc_nvenc': ['hevc_nvenc', 'NVIDIA', 'HEVC'], + 'nvenc': ['hevc_nvenc', 'NVIDIA', 'HEVC'], + 'av1_nvenc': ['av1_nvenc', 'NVIDIA', 'AV1'], + 'hevc_videotoolbox': ['hevc_videotoolbox', 'Apple', 'HEVC'], + 'videotoolbox': ['hevc_videotoolbox', 'Apple', 'HEVC'], + 'hevc_amf': ['hevc_amf', 'AMD', 'HEVC'], + 'amf': ['hevc_amf', 'AMD', 'HEVC'], + 'hevc_qsv': ['hevc_qsv', 'Intel', 'HEVC'], + 'qsv': ['hevc_qsv', 'Intel', 'HEVC'], + 'hevc_vaapi': ['hevc_vaapi', 'Linux/Mesa', 'HEVC'], + 'vaapi': ['hevc_vaapi', 'Linux/Mesa', 'HEVC'], + }; + + table.forEach((input, expected) { + test('maps "$input"', () { + final e = HardwareEngine.fromCliName(input); + expect([e.name, e.vendor, e.codec], expected); + expect(e.isHardware, isTrue); + expect(e.typicalFps, greaterThan(0)); + }); + }); + + test('is case-insensitive', () { + expect(HardwareEngine.fromCliName('HEVC_NVENC').vendor, 'NVIDIA'); + }); + + test('unknown and empty names fall back to CPU', () { + for (final n in ['', 'libx264', 'garbage']) { + final e = HardwareEngine.fromCliName(n); + expect(e.name, 'libx265'); + expect(e.isHardware, isFalse); + expect(e.vendor, 'CPU'); + } + }); + + test('fallback is software HEVC', () { + final f = HardwareEngine.fallback(); + expect(f.codec, 'HEVC'); + expect(f.isHardware, isFalse); + }); + }); + + group('MediaItem.formatBytes', () { + test('zero and negative', () { + expect(MediaItem.formatBytes(0), '0 B'); + expect(MediaItem.formatBytes(-10), '0 B'); + }); + + test('unit boundaries', () { + expect(MediaItem.formatBytes(1), '1.0 B'); + expect(MediaItem.formatBytes(1023), '1023.0 B'); + expect(MediaItem.formatBytes(1536), '1.5 KB'); + expect(MediaItem.formatBytes(1024 * 1024 * 1024 * 1024), '1.0 TB'); + }); + + test('caps at TB', () { + expect(MediaItem.formatBytes(1024 * 1024 * 1024 * 1024 * 2048), endsWith('TB')); + }); + }); + + group('MediaItem.savingsRatio', () { + test('uses estimate while pending', () { + expect(item().savingsRatio, closeTo(0.55, 1e-9)); + }); + + test('uses actual size once completed', () { + expect(item(fin: 200, status: MediaStatus.completed).savingsRatio, closeTo(0.8, 1e-9)); + }); + + test('falls back to estimate when completed with unknown final size', () { + expect(item(fin: 0, status: MediaStatus.completed).savingsRatio, closeTo(0.55, 1e-9)); + }); + + test('never negative if estimate exceeds original', () { + expect(item(estimated: 2000).savingsRatio, 0.0); + }); + + test('zero for empty files', () { + expect(item(original: 0, estimated: 0).savingsRatio, 0.0); + }); + + test('defaults', () { + final i = item(); + expect(i.status, MediaStatus.pending); + expect(i.progress, 0.0); + expect(i.outputPath, isNull); + expect(i.error, isNull); + }); + }); +} diff --git a/mediacrush/cli/commands.py b/mediacrush/cli/commands.py index 60ac867..8e3696b 100644 --- a/mediacrush/cli/commands.py +++ b/mediacrush/cli/commands.py @@ -14,6 +14,7 @@ from mediacrush.core.quality import QualityChecker from mediacrush.core.queue import JobQueue, QueueEntry from mediacrush.core.reporter import CompressionReporter +from mediacrush.core.safety import SafetyManager from mediacrush.core.scanner import LibraryScanner from mediacrush.core.transcoder import TranscodeEngine from mediacrush.core.watcher import FolderWatcher @@ -71,7 +72,7 @@ def _execute_compression_batch(candidates, active_enc, args) -> None: for idx, cand in enumerate(candidates, 1): f_path = cand.metadata.path preset = cand.local_preset or ( - ContentClassifier.detect(f_path).value if args.preset == "auto" else args.preset + ContentClassifier.detect(f_path).preset_key if args.preset == "auto" else args.preset ) console.print(f"\n[bold cyan][{idx}/{len(candidates)}][/bold cyan] Compressing: [white]{f_path.name}[/white] [dim]({preset})[/dim]") @@ -105,9 +106,19 @@ def _execute_compression_batch(candidates, active_enc, args) -> None: console.print(f"[bold green][OK] Completed![/bold green] Saved ~{format_bytes(saved)}") if args.quality_check: - score = QualityChecker.score(f_path, f_path) + # The source is replaced in place, so compare against the .crushed copy + # (side-by-side) or the .bak backup; otherwise there is nothing to compare. + if in_place: + original = f_path.with_name(f"{f_path.name}.bak") if args.keep_backup else None + encoded = f_path + else: + original = f_path + encoded = SafetyManager.get_output_path(f_path, False) + score = QualityChecker.score(original, encoded) if original else None if score: console.print(f"[magenta]Quality:[/magenta] VMAF: {score.vmaf} | SSIM: {score.ssim} ({score.verdict})") + elif original is None: + console.print("[dim]Quality check skipped: use --side-by-side or --keep-backup to keep the original.[/dim]") else: console.print(f"[bold red][FAIL] Failed to optimize {f_path.name}[/bold red]") @@ -119,7 +130,10 @@ def _execute_compression_batch(candidates, active_enc, args) -> None: if reporter and args.report: rep_p = Path(args.report) - rep_p.suffix.lower() == ".json" and reporter.write_json(rep_p) or reporter.write_html(rep_p) + if rep_p.suffix.lower() == ".json": + reporter.write_json(rep_p) + else: + reporter.write_html(rep_p) console.print(f"[cyan]Report written to:[/cyan] {rep_p}") console.print(f"\n[bold green]Batch complete![/bold green] Total reclaimed space: [bold green]{format_bytes(total_saved)}[/bold green]") diff --git a/mediacrush/core/content_classifier.py b/mediacrush/core/content_classifier.py index 5f7d866..85f14db 100644 --- a/mediacrush/core/content_classifier.py +++ b/mediacrush/core/content_classifier.py @@ -1,5 +1,7 @@ """Automatic media content classification (Animation vs Live-Action).""" +from __future__ import annotations + from enum import Enum from pathlib import Path import subprocess @@ -11,7 +13,12 @@ class ContentType(str, Enum): """Categorized content types for preset auto-selection.""" ANIME = "anime" LIVE_ACTION = "film" - UNKNOWN = "film" + UNKNOWN = "unknown" + + @property + def preset_key(self) -> str: + """Preset to use for this content type; unknown content is treated as live action.""" + return "anime" if self is ContentType.ANIME else "film" class ContentClassifier: diff --git a/mediacrush/core/deduplicator.py b/mediacrush/core/deduplicator.py index c7f60fe..f3e70bb 100644 --- a/mediacrush/core/deduplicator.py +++ b/mediacrush/core/deduplicator.py @@ -45,28 +45,19 @@ def find(cls, target_path: Path) -> List[DuplicateGroup]: if meta and meta.duration_seconds > 10: probed.append(meta) - # Bucket by duration bucket (rounded to nearest 4 seconds) - duration_buckets: Dict[int, List[MediaMetadata]] = {} + # Cluster by duration: sorted sweep, a file joins the open cluster if within 3s of its first member + probed.sort(key=lambda m: m.duration_seconds) + clusters: List[List[MediaMetadata]] = [] for m in probed: - bucket_key = int(round(m.duration_seconds / 4.0)) * 4 - duration_buckets.setdefault(bucket_key, []).append(m) + if clusters and abs(clusters[-1][0].duration_seconds - m.duration_seconds) <= 3.0: + clusters[-1].append(m) + else: + clusters.append([m]) groups: List[DuplicateGroup] = [] res_rank = {"4K": 3, "1080p": 2, "720p": 1} - for _, bucket in duration_buckets.items(): - if len(bucket) < 2: - continue - - # Within duration bucket, match if durations are within 3s - cluster: List[MediaMetadata] = [] - for item in bucket: - if not cluster: - cluster.append(item) - else: - if abs(cluster[0].duration_seconds - item.duration_seconds) <= 3.0: - cluster.append(item) - + for cluster in clusters: if len(cluster) >= 2: # Pick best: highest resolution rank, then largest file sorted_files = sorted( diff --git a/mediacrush/core/reporter.py b/mediacrush/core/reporter.py index 213a279..9d642fc 100644 --- a/mediacrush/core/reporter.py +++ b/mediacrush/core/reporter.py @@ -2,6 +2,7 @@ from __future__ import annotations +from html import escape import json from dataclasses import asdict, dataclass from pathlib import Path @@ -79,11 +80,11 @@ def write_html(self, output_path: Path) -> None: output_path.write_text(html, encoding="utf-8") @staticmethod - def _fmt(b: int) -> str: + def _fmt(b: float) -> str: for unit in ("B", "KB", "MB", "GB"): if b < 1024: return f"{b:.1f} {unit}" - b //= 1024 + b /= 1024 return f"{b:.1f} TB" @staticmethod @@ -92,7 +93,7 @@ def _build_row(e: ReportEntry) -> str: orig = CompressionReporter._fmt(e.original_bytes) final = CompressionReporter._fmt(e.final_bytes) return ( - f"{e.file}{orig}{final}" - f"{e.savings_pct:.1f}%{e.encoder}" + f"{escape(e.file)}{orig}{final}" + f"{e.savings_pct:.1f}%{escape(e.encoder)}" f"{e.duration_s:.1f}{status}\n" ) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1bd108d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,100 @@ +"""Shared fixtures and helpers for the MediaCrush test suite.""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path +from typing import List, Optional + +import pytest + +from mediacrush.core.probe import MediaMetadata, StreamInfo + +HAS_FFMPEG = bool(shutil.which("ffmpeg") and shutil.which("ffprobe")) +requires_ffmpeg = pytest.mark.skipif(not HAS_FFMPEG, reason="ffmpeg/ffprobe not installed") + + +def make_meta( + path: str = "/fake/video.mkv", + codec: str = "h264", + width: int = 1920, + height: int = 1080, + duration: float = 1400.0, + size_mb: float = 500, + video_bitrate: int = 8_000_000, + overall_bitrate: Optional[int] = None, + audio: int = 1, + subs: int = 0, + hdr: Optional[str] = None, + primaries: str = "unknown", + transfer: str = "unknown", +) -> MediaMetadata: + """Builds MediaMetadata without touching disk or ffprobe.""" + audios: List[StreamInfo] = [StreamInfo(i + 1, "audio", "aac", "en", None, 2) for i in range(audio)] + subtitles: List[StreamInfo] = [StreamInfo(i + 10, "subtitle", "ass", "en") for i in range(subs)] + return MediaMetadata( + path=Path(path), + duration_seconds=duration, + file_size_bytes=int(size_mb * 1024 * 1024), + video_codec=codec, + width=width, + height=height, + fps=24.0, + video_bitrate=video_bitrate, + overall_bitrate=overall_bitrate if overall_bitrate is not None else video_bitrate + 192_000, + audio_streams=audios, + subtitle_streams=subtitles, + hdr_format=hdr, + color_primaries=primaries, + color_transfer=transfer, + ) + + +@pytest.fixture +def meta_factory(): + return make_meta + + +def generate_video( + path: Path, + seconds: float = 12, + size: str = "320x240", + audio: bool = True, + codec: str = "libx264", + pattern: str = "testsrc2", + extra: Optional[List[str]] = None, +) -> Path: + """Creates a real, tiny test video with ffmpeg (requires ffmpeg).""" + cmd = ["ffmpeg", "-y", "-v", "error", "-f", "lavfi", "-i", f"{pattern}{':' if '=' in pattern else '='}s={size}:r=24:d={seconds}"] + if audio: + cmd += ["-f", "lavfi", "-i", f"sine=frequency=440:duration={seconds}"] + cmd += ["-c:v", codec, "-pix_fmt", "yuv420p"] + if audio: + cmd += ["-c:a", "aac", "-shortest"] + cmd += (extra or []) + [str(path)] + subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=120) + return path + + +@pytest.fixture +def video_factory(tmp_path): + """Returns a callable that writes real videos into tmp_path.""" + def _make(name: str = "clip.mp4", **kwargs) -> Path: + return generate_video(tmp_path / name, **kwargs) + return _make + + +@pytest.fixture +def cli_args(): + """Builds an argparse.Namespace mirroring `mediacrush compress` flags.""" + import argparse + + def _make(**overrides): + base = dict( + path=".", preset="auto", side_by_side=False, keep_backup=False, + dry_run=False, report=None, pause=False, quality_check=False, + ) + base.update(overrides) + return argparse.Namespace(**base) + return _make diff --git a/tests/test_classifier_ppm.py b/tests/test_classifier_ppm.py new file mode 100644 index 0000000..c303de6 --- /dev/null +++ b/tests/test_classifier_ppm.py @@ -0,0 +1,115 @@ +"""Unit tests for ContentClassifier PPM analysis and detect() control flow.""" + +import random +from unittest.mock import patch + +import pytest + +from mediacrush.core.content_classifier import ContentClassifier, ContentType + + +def write_ppm(path, w, h, pixel_fn, maxval=255, magic=b"P6", comment=False): + body = bytearray() + for i in range(w * h): + body += bytes(pixel_fn(i)) + hdr = magic + b"\n" + (b"# c\n" if comment else b"") + f"{w} {h}\n{maxval}\n".encode() + path.write_bytes(hdr + bytes(body)) + return path + + +class TestAnalyzePpm: + def test_flat_color_is_one_color(self, tmp_path): + p = write_ppm(tmp_path / "a.ppm", 64, 64, lambda i: (10, 20, 30)) + assert ContentClassifier._analyze_frame_ppm(p) == pytest.approx(1 / 4096) + + def test_noise_has_high_ratio(self, tmp_path): + rnd = random.Random(1) + p = write_ppm(tmp_path / "n.ppm", 320, 180, lambda i: (rnd.randrange(256),) * 3 if False else (rnd.randrange(256), rnd.randrange(256), rnd.randrange(256))) + assert ContentClassifier._analyze_frame_ppm(p) > 0.5 + + def test_quantization_merges_close_colors(self, tmp_path): + p = write_ppm(tmp_path / "q.ppm", 4, 4, lambda i: (16 + (i % 15), 0, 0)) # all share top nibble 1 + assert ContentClassifier._analyze_frame_ppm(p) == pytest.approx(1 / 4096) + + def test_comment_line_skipped(self, tmp_path): + p = write_ppm(tmp_path / "c.ppm", 2, 2, lambda i: (0, 0, 0), comment=True) + assert ContentClassifier._analyze_frame_ppm(p) is not None + + def test_wrong_magic(self, tmp_path): + p = write_ppm(tmp_path / "b.ppm", 2, 2, lambda i: (0, 0, 0), magic=b"P3") + assert ContentClassifier._analyze_frame_ppm(p) is None + + def test_wrong_maxval(self, tmp_path): + p = write_ppm(tmp_path / "m.ppm", 2, 2, lambda i: (0, 0, 0), maxval=65535) + assert ContentClassifier._analyze_frame_ppm(p) is None + + def test_truncated_data(self, tmp_path): + p = tmp_path / "t.ppm" + p.write_bytes(b"P6\n10 10\n255\n" + b"\0" * 5) + assert ContentClassifier._analyze_frame_ppm(p) is None + + def test_missing_file(self, tmp_path): + assert ContentClassifier._analyze_frame_ppm(tmp_path / "none.ppm") is None + + def test_garbage_header(self, tmp_path): + p = tmp_path / "g.ppm" + p.write_bytes(b"P6\nabc def\n255\n") + assert ContentClassifier._analyze_frame_ppm(p) is None + + +class TestDetect: + def test_missing_file_unknown(self, tmp_path): + assert ContentClassifier.detect(tmp_path / "x.mkv") == ContentType.UNKNOWN + + def test_exception_returns_unknown(self, tmp_path): + f = tmp_path / "x.mkv" + f.write_bytes(b"1") + with patch("mediacrush.core.content_classifier.subprocess.run", side_effect=OSError): + assert ContentClassifier.detect(f) == ContentType.UNKNOWN + + def test_no_frames_unknown(self, tmp_path): + f = tmp_path / "x.mkv" + f.write_bytes(b"1") + with patch("mediacrush.core.content_classifier.subprocess.run"): + assert ContentClassifier.detect(f) == ContentType.UNKNOWN + + @pytest.mark.parametrize("ratio,expected", [(0.05, ContentType.ANIME), (0.17, ContentType.ANIME), + (0.18, ContentType.LIVE_ACTION), (0.6, ContentType.LIVE_ACTION)]) + def test_threshold(self, tmp_path, ratio, expected): + f = tmp_path / "x.mkv" + f.write_bytes(b"1") + + def fake_run(cmd, **kw): + from pathlib import Path + Path(cmd[-1].replace("%02d", "01")).write_bytes(b"") + + with patch("mediacrush.core.content_classifier.subprocess.run", side_effect=fake_run), \ + patch.object(ContentClassifier, "_analyze_frame_ppm", return_value=ratio): + assert ContentClassifier.detect(f) == expected + + def test_all_frames_unparsable_unknown(self, tmp_path): + f = tmp_path / "x.mkv" + f.write_bytes(b"1") + + def fake_run(cmd, **kw): + from pathlib import Path + Path(cmd[-1].replace("%02d", "01")).write_bytes(b"") + + with patch("mediacrush.core.content_classifier.subprocess.run", side_effect=fake_run), \ + patch.object(ContentClassifier, "_analyze_frame_ppm", return_value=None): + assert ContentClassifier.detect(f) == ContentType.UNKNOWN + + +class TestContentTypeEnum: + def test_values_are_valid_preset_keys(self): + from mediacrush.core.presets import PresetManager + for t in ContentType: + assert t.preset_key in PresetManager.PRESETS + + def test_unknown_maps_to_film_preset(self): + assert ContentType.UNKNOWN.preset_key == "film" + assert ContentType.LIVE_ACTION.preset_key == "film" + assert ContentType.ANIME.preset_key == "anime" + + def test_unknown_is_distinct_member(self): + assert ContentType.UNKNOWN is not ContentType.LIVE_ACTION diff --git a/tests/test_cli_parser.py b/tests/test_cli_parser.py new file mode 100644 index 0000000..e10e0c5 --- /dev/null +++ b/tests/test_cli_parser.py @@ -0,0 +1,67 @@ +"""Unit tests for the argparse CLI surface and main() routing.""" + +import pytest + +from mediacrush.cli import main as cli_main +from mediacrush.cli.main import build_parser + + +@pytest.fixture +def parser(): + return build_parser() + + +class TestParser: + def test_command_required(self, parser): + with pytest.raises(SystemExit): + parser.parse_args([]) + + def test_unknown_command(self, parser): + with pytest.raises(SystemExit): + parser.parse_args(["explode"]) + + def test_compress_defaults(self, parser): + a = parser.parse_args(["compress", "/x"]) + assert (a.preset, a.side_by_side, a.keep_backup, a.dry_run, a.report, a.pause, a.quality_check) == \ + ("auto", False, False, False, None, False, False) + + def test_compress_all_flags(self, parser): + a = parser.parse_args(["compress", "/x", "--preset", "saver", "--side-by-side", "--keep-backup", + "--dry-run", "--report", "r.json", "--pause", "--quality-check"]) + assert a.preset == "saver" and a.side_by_side and a.keep_backup and a.dry_run + assert a.report == "r.json" and a.pause and a.quality_check + + def test_compress_rejects_bad_preset(self, parser): + with pytest.raises(SystemExit): + parser.parse_args(["compress", "/x", "--preset", "ultra"]) + + def test_compress_requires_path(self, parser): + with pytest.raises(SystemExit): + parser.parse_args(["compress"]) + + def test_watch_default_and_no_auto(self, parser): + assert parser.parse_args(["watch", "/x"]).preset == "anime" + with pytest.raises(SystemExit): + parser.parse_args(["watch", "/x", "--preset", "auto"]) + + @pytest.mark.parametrize("cmd", ["detect", "resume", "gui"]) + def test_no_arg_commands(self, parser, cmd): + assert parser.parse_args([cmd]).command == cmd + + def test_find_dupes(self, parser): + a = parser.parse_args(["find-dupes", "/lib", "--report", "o.html"]) + assert a.path == "/lib" and a.report == "o.html" + + +class TestMainRouting: + @pytest.mark.parametrize("argv,name", [ + (["detect"], "cmd_detect"), (["scan", "/x"], "cmd_scan"), (["compress", "/x"], "cmd_compress"), + (["watch", "/x"], "cmd_watch"), (["resume"], "cmd_resume"), + (["find-dupes", "/x"], "cmd_find_dupes"), (["gui"], "cmd_gui"), + ]) + def test_dispatch(self, monkeypatch, argv, name): + called = [] + monkeypatch.setattr(cli_main, name, lambda args: called.append(args.command)) + monkeypatch.setattr("sys.argv", ["mediacrush"] + argv) + cli_main.main() + assert called == [argv[0]] diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..2986a55 --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,210 @@ +"""Integration-style tests for CLI command handlers with core services mocked.""" + +import argparse +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from rich.console import Console + +from conftest import make_meta +from mediacrush.cli import commands, formatters +from mediacrush.core.deduplicator import DuplicateGroup +from mediacrush.core.hardware import EncoderInfo +from mediacrush.core.scanner import CandidateFile, ScanReport + +ENC = EncoderInfo("hevc", "libx265", "CPU", None, "CPU x265") + + +@pytest.fixture(autouse=True) +def quiet_console(monkeypatch): + c = Console(record=True, width=200) + monkeypatch.setattr(commands, "console", c) + monkeypatch.setattr(formatters, "console", c) + return c + + +def cand(path, size_mb=1000, preset=None): + m = make_meta(str(path), size_mb=size_mb) + return CandidateFile(m, "r", m.file_size_bytes // 3, m.file_size_bytes * 2 // 3, preset) + + +def report(tmp_path, cands): + return ScanReport(tmp_path, cands, 0, sum(c.metadata.file_size_bytes for c in cands), + sum(c.estimated_size_bytes for c in cands)) + + +class TestDetect: + def test_prints_table(self, quiet_console): + with patch.object(commands.HardwareDetector, "discover_encoders", return_value=[ENC]), \ + patch.object(commands.HardwareDetector, "get_best_encoder", return_value=ENC): + commands.cmd_detect(argparse.Namespace()) + assert "libx265" in quiet_console.export_text() + + +class TestScan: + def test_missing_path_exits_1(self, tmp_path): + with pytest.raises(SystemExit) as e: + commands.cmd_scan(argparse.Namespace(path=str(tmp_path / "nope"))) + assert e.value.code == 1 + + def test_scan_prints_report(self, tmp_path, quiet_console): + with patch.object(commands.LibraryScanner, "scan_path", return_value=report(tmp_path, [cand(tmp_path / "a.mkv")])): + commands.cmd_scan(argparse.Namespace(path=str(tmp_path))) + assert "a.mkv" in quiet_console.export_text() + + +class TestCompress: + def _run(self, tmp_path, args, rep, transcode=True): + with patch.object(commands.HardwareDetector, "get_best_encoder", return_value=ENC), \ + patch.object(commands.LibraryScanner, "scan_path", return_value=rep), \ + patch.object(commands.TranscodeEngine, "transcode", return_value=transcode) as tc, \ + patch.object(commands.ContentClassifier, "detect") as det: + det.return_value = MagicMock(preset_key="anime") + commands.cmd_compress(args) + return tc, det + + def test_missing_path_exits(self, cli_args, tmp_path): + with pytest.raises(SystemExit): + commands.cmd_compress(cli_args(path=str(tmp_path / "nope"))) + + def test_nothing_to_do(self, cli_args, tmp_path, quiet_console): + tc, _ = self._run(tmp_path, cli_args(path=str(tmp_path)), report(tmp_path, [])) + tc.assert_not_called() + assert "already optimized" in quiet_console.export_text() + + def test_dry_run_never_transcodes(self, cli_args, tmp_path): + tc, _ = self._run(tmp_path, cli_args(path=str(tmp_path), dry_run=True), report(tmp_path, [cand(tmp_path / "a.mkv")])) + tc.assert_not_called() + + def test_auto_preset_uses_classifier(self, cli_args, tmp_path): + tc, det = self._run(tmp_path, cli_args(path=str(tmp_path)), report(tmp_path, [cand(tmp_path / "a.mkv")])) + det.assert_called_once() + assert tc.call_args.kwargs["preset_key"] == "anime" + + def test_explicit_preset_skips_classifier(self, cli_args, tmp_path): + tc, det = self._run(tmp_path, cli_args(path=str(tmp_path), preset="saver"), report(tmp_path, [cand(tmp_path / "a.mkv")])) + det.assert_not_called() + assert tc.call_args.kwargs["preset_key"] == "saver" + + def test_folder_override_wins(self, cli_args, tmp_path): + tc, det = self._run(tmp_path, cli_args(path=str(tmp_path), preset="saver"), + report(tmp_path, [cand(tmp_path / "a.mkv", preset="film")])) + assert tc.call_args.kwargs["preset_key"] == "film" + det.assert_not_called() + + def test_side_by_side_flag(self, cli_args, tmp_path): + tc, _ = self._run(tmp_path, cli_args(path=str(tmp_path), preset="film", side_by_side=True), + report(tmp_path, [cand(tmp_path / "a.mkv")])) + assert tc.call_args.kwargs["in_place"] is False + + def test_failure_is_reported_not_raised(self, cli_args, tmp_path, quiet_console): + self._run(tmp_path, cli_args(path=str(tmp_path), preset="film"), report(tmp_path, [cand(tmp_path / "a.mkv")]), transcode=False) + assert "Failed" in quiet_console.export_text() + + def test_processes_every_candidate(self, cli_args, tmp_path): + cs = [cand(tmp_path / f"{i}.mkv") for i in range(3)] + tc, _ = self._run(tmp_path, cli_args(path=str(tmp_path), preset="film"), report(tmp_path, cs)) + assert tc.call_count == 3 + + +class TestPauseQueue: + def test_pause_queues_remaining(self, cli_args, tmp_path): + qfile = tmp_path / "q.json" + cs = [cand(tmp_path / f"{i}.mkv") for i in range(3)] + from mediacrush.core.queue import JobQueue + with patch.object(commands, "JobQueue", side_effect=lambda: JobQueue(qfile)), \ + patch.object(commands.TranscodeEngine, "transcode", return_value=True) as tc: + commands._execute_compression_batch(cs, ENC, cli_args(preset="film", pause=True)) + assert tc.call_count == 1 + saved = json.loads(qfile.read_text()) + assert [Path(e["path"]).name for e in saved] == ["1.mkv", "2.mkv"] + assert all(e["status"] == "pending" and e["preset"] == "film" for e in saved) + + +class TestReports: + def _batch(self, tmp_path, cli_args, name): + rp = tmp_path / name + with patch.object(commands.TranscodeEngine, "transcode", return_value=True): + commands._execute_compression_batch([cand(tmp_path / "a.mkv")], ENC, cli_args(preset="film", report=str(rp))) + return rp + + def test_html_report(self, tmp_path, cli_args): + rp = self._batch(tmp_path, cli_args, "r.html") + assert " preset args -> ffmpeg -> verify -> finalize.""" + + @pytest.fixture(autouse=True) + def _relax_min_size(self, monkeypatch): + # Tiny generated clips are far below the production 1 MB sanity floor, so swap in a + # verifier that keeps the duration/audio/readability checks but drops the size floor. + monkeypatch.setattr(SafetyManager, "verify_transcode", + classmethod(lambda cls, meta, staging: self._verify_ignoring_size(meta, staging))) + + @staticmethod + def _verify_ignoring_size(source_meta, staging): + out = MediaProbe.inspect(staging) + if not out: + return False, "unreadable" + if abs(source_meta.duration_seconds - out.duration_seconds) > 1.5: + return False, "duration" + if source_meta.audio_streams and not out.audio_streams: + return False, "audio" + return True, "ok" + + def test_in_place_transcode(self, clip): + meta = MediaProbe.inspect(clip) + progress = [] + ok = TranscodeEngine().transcode(meta, X264, "film", in_place=True, progress_callback=progress.append) + assert ok is True + assert clip.exists() and not SafetyManager.get_staging_path(clip).exists() + out = MediaProbe.inspect(clip) + assert out.duration_seconds == pytest.approx(meta.duration_seconds, abs=1.5) + assert len(out.audio_streams) == 1 + if progress: + assert progress[-1].percent <= 100.0 + + def test_side_by_side_keeps_original(self, clip): + original = clip.read_bytes() + meta = MediaProbe.inspect(clip) + assert TranscodeEngine().transcode(meta, X264, "saver", in_place=False) is True + assert clip.read_bytes() == original + assert (clip.parent / "clip.crushed.mkv").exists() + + def test_keep_backup(self, clip): + original = clip.read_bytes() + meta = MediaProbe.inspect(clip) + assert TranscodeEngine().transcode(meta, X264, "film", in_place=True, keep_backup=True) is True + assert (clip.parent / "clip.mp4.bak").read_bytes() == original + + def test_bad_encoder_fails_and_preserves_source(self, clip): + original = clip.read_bytes() + meta = MediaProbe.inspect(clip) + bogus = EncoderInfo("hevc", "no_such_encoder", "X", None, "") + assert TranscodeEngine().transcode(meta, bogus, "film") is False + assert clip.read_bytes() == original + assert not SafetyManager.get_staging_path(clip).exists() + + def test_hevc_cpu_when_available(self, clip): + enc = cpu_hevc() + if enc is None: + pytest.skip("no working CPU HEVC encoder") + meta = MediaProbe.inspect(clip) + assert TranscodeEngine().transcode(meta, enc, "anime", in_place=False) is True + assert MediaProbe.inspect(clip.parent / "clip.crushed.mkv").video_codec in {"hevc", "av1"} + + +class TestCliEndToEnd: + def test_scan_command_on_real_dir(self, src_dir, capsys, monkeypatch): + import argparse + from rich.console import Console + from mediacrush.cli import commands, formatters + c = Console(record=True, width=200) + monkeypatch.setattr(commands, "console", c) + monkeypatch.setattr(formatters, "console", c) + commands.cmd_scan(argparse.Namespace(path=str(src_dir))) + assert "Skipped 1" in c.export_text() + + def test_main_entry_scan(self, src_dir, monkeypatch): + from mediacrush.cli import main as m + monkeypatch.setattr("sys.argv", ["mediacrush", "scan", str(src_dir)]) + m.main() # must not raise diff --git a/tests/test_presets_args.py b/tests/test_presets_args.py new file mode 100644 index 0000000..97ad254 --- /dev/null +++ b/tests/test_presets_args.py @@ -0,0 +1,113 @@ +"""Unit tests for PresetManager.build_ffmpeg_args per encoder / preset / HDR.""" + +import pytest + +from conftest import make_meta +from mediacrush.core.hardware import EncoderInfo +from mediacrush.core.presets import PresetManager + + +def enc(name, vendor="X", hw=None): + return EncoderInfo("hevc", name, vendor, hw, name) + + +def val(args, flag): + return args[args.index(flag) + 1] + + +class TestCommonArgs: + @pytest.mark.parametrize("name", [ + "hevc_nvenc", "hevc_amf", "hevc_qsv", "hevc_mf", "hevc_videotoolbox", + "h264_videotoolbox", "hevc_vaapi", "libx265", "libsvtav1", + ]) + def test_every_encoder_maps_streams_and_copies_audio_subs(self, name): + args = PresetManager.build_ffmpeg_args(enc(name), "film") + assert args[:2] == ["-map", "0"] + assert val(args, "-c:v") == name + assert val(args, "-c:a") == "copy" + assert val(args, "-c:s") == "copy" + assert val(args, "-map_metadata") == "0" + + def test_unknown_preset_falls_back_to_anime(self): + a = PresetManager.build_ffmpeg_args(enc("libx265"), "bogus") + b = PresetManager.build_ffmpeg_args(enc("libx265"), "anime") + assert a == b + + def test_all_args_are_strings(self): + for name in ("hevc_nvenc", "libx265", "hevc_videotoolbox"): + assert all(isinstance(x, str) for x in PresetManager.build_ffmpeg_args(enc(name), "saver")) + + def test_saver_is_more_aggressive_than_film(self): + assert PresetManager.PRESETS["saver"].cpu_crf > PresetManager.PRESETS["film"].cpu_crf + assert PresetManager.PRESETS["saver"].nvenc_cq > PresetManager.PRESETS["film"].nvenc_cq + + +class TestEncoderSpecific: + def test_nvenc_uses_cq_and_vbr(self): + a = PresetManager.build_ffmpeg_args(enc("hevc_nvenc"), "saver") + assert val(a, "-cq") == "24" and val(a, "-rc") == "vbr" and val(a, "-b:v") == "0" + + def test_amf_uses_cqp(self): + a = PresetManager.build_ffmpeg_args(enc("hevc_amf"), "film") + assert val(a, "-rc") == "cqp" and val(a, "-qp_i") == "20" + + def test_qsv_global_quality(self): + assert val(PresetManager.build_ffmpeg_args(enc("hevc_qsv"), "anime"), "-global_quality") == "20" + + def test_vaapi_includes_device_and_hwupload(self): + a = PresetManager.build_ffmpeg_args(enc("hevc_vaapi"), "anime") + assert "-vaapi_device" in a and "hwupload" in val(a, "-vf") + + @pytest.mark.parametrize("preset,q", [("anime", "70"), ("film", "65"), ("saver", "55")]) + def test_videotoolbox_quality_per_preset(self, preset, q): + a = PresetManager.build_ffmpeg_args(enc("hevc_videotoolbox"), preset) + assert val(a, "-q:v") == q and val(a, "-tag:v") == "hvc1" + + def test_h264_videotoolbox_has_no_hvc1_tag(self): + a = PresetManager.build_ffmpeg_args(enc("h264_videotoolbox"), "film") + assert "-tag:v" not in a + + def test_x265_animation_tune_only_for_anime(self): + assert "-tune" in PresetManager.build_ffmpeg_args(enc("libx265"), "anime") + assert "-tune" not in PresetManager.build_ffmpeg_args(enc("libx265"), "film") + assert val(PresetManager.build_ffmpeg_args(enc("libx265"), "film"), "-crf") == "20" + + def test_svtav1_crf_offset(self): + a = PresetManager.build_ffmpeg_args(enc("libsvtav1"), "film") + assert val(a, "-crf") == "22" + + def test_unrecognized_encoder_still_valid(self): + a = PresetManager.build_ffmpeg_args(enc("mystery"), "film") + assert val(a, "-c:v") == "mystery" + + +class TestHdr: + def test_hdr_flags_added(self): + m = make_meta(hdr="HDR10", primaries="bt2020", transfer="smpte2084") + a = PresetManager.build_ffmpeg_args(enc("hevc_nvenc"), "film", meta=m) + assert val(a, "-color_primaries") == "bt2020" + assert val(a, "-color_trc") == "smpte2084" + assert val(a, "-colorspace") == "bt2020nc" + assert "-x265-params" not in a + + def test_x265_hdr_params(self): + m = make_meta(hdr="HDR10", primaries="bt2020", transfer="smpte2084") + a = PresetManager.build_ffmpeg_args(enc("libx265"), "film", meta=m) + assert "hdr-opt=1" in val(a, "-x265-params") + + def test_sdr_no_hdr_flags(self): + a = PresetManager.build_ffmpeg_args(enc("libx265"), "film", meta=make_meta()) + assert "-color_primaries" not in a + + def test_videotoolbox_main10_for_hdr(self): + m = make_meta(hdr="HDR10", primaries="bt2020", transfer="smpte2084") + a = PresetManager.build_ffmpeg_args(enc("hevc_videotoolbox"), "film", meta=m) + assert val(a, "-profile:v") == "main10" + + def test_videotoolbox_main10_for_anime_even_sdr(self): + a = PresetManager.build_ffmpeg_args(enc("hevc_videotoolbox"), "anime", meta=make_meta()) + assert val(a, "-pix_fmt") == "p010le" + + def test_videotoolbox_film_sdr_stays_8bit(self): + a = PresetManager.build_ffmpeg_args(enc("hevc_videotoolbox"), "film", meta=make_meta()) + assert "-pix_fmt" not in a diff --git a/tests/test_probe_parsing.py b/tests/test_probe_parsing.py new file mode 100644 index 0000000..5c8654a --- /dev/null +++ b/tests/test_probe_parsing.py @@ -0,0 +1,141 @@ +"""Unit tests for MediaProbe ffprobe JSON parsing (subprocess mocked).""" + +import json +import subprocess +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from mediacrush.core.probe import MediaMetadata, MediaProbe + + +def _probe(tmp_path, payload=None, side_effect=None): + f = tmp_path / "a.mkv" + f.write_bytes(b"x" * 100) + with patch("mediacrush.core.probe.subprocess.run") as run: + if side_effect is not None: + run.side_effect = side_effect + else: + run.return_value = MagicMock(stdout=json.dumps(payload)) + return MediaProbe.inspect(f), run + + +BASE = { + "format": {"duration": "100.5", "size": "5000", "bit_rate": "400000"}, + "streams": [ + {"index": 0, "codec_type": "video", "codec_name": "h264", "width": 1920, "height": 1080, + "r_frame_rate": "24000/1001", "bit_rate": "350000"}, + {"index": 1, "codec_type": "audio", "codec_name": "aac", "channels": 6, "tags": {"language": "jpn", "title": "Main"}}, + {"index": 2, "codec_type": "subtitle", "codec_name": "ass", "tags": {"language": "eng"}}, + ], +} + + +class TestResolutionLabel: + @pytest.mark.parametrize("w,h,label", [ + (3840, 2160, "4K"), (3840, 1600, "4K"), (1920, 1080, "1080p"), (1920, 800, "1080p"), + (1280, 720, "720p"), (1280, 534, "720p"), (640, 480, "640x480"), (0, 0, "0x0"), + ]) + def test_labels(self, w, h, label): + m = MediaMetadata(Path("x"), 1, 1, "h264", w, h, 24, 0, 0) + assert m.resolution_label == label + + +class TestInspectParsing: + def test_full_parse(self, tmp_path): + meta, run = _probe(tmp_path, BASE) + assert meta.duration_seconds == 100.5 + assert meta.file_size_bytes == 5000 + assert meta.overall_bitrate == 400000 + assert (meta.video_codec, meta.width, meta.height) == ("h264", 1920, 1080) + assert meta.video_bitrate == 350000 + assert meta.fps == pytest.approx(23.976, abs=1e-3) + assert len(meta.audio_streams) == 1 and meta.audio_streams[0].channels == 6 + assert meta.audio_streams[0].language == "jpn" and meta.audio_streams[0].title == "Main" + assert len(meta.subtitle_streams) == 1 and meta.subtitle_streams[0].codec_name == "ass" + assert meta.hdr_format is None + + def test_missing_file_returns_none(self, tmp_path): + assert MediaProbe.inspect(tmp_path / "nope.mkv") is None + + def test_empty_file_returns_none(self, tmp_path): + f = tmp_path / "empty.mkv" + f.touch() + assert MediaProbe.inspect(f) is None + + def test_ffprobe_failure_returns_none(self, tmp_path): + meta, _ = _probe(tmp_path, side_effect=subprocess.CalledProcessError(1, "ffprobe")) + assert meta is None + + def test_ffprobe_missing_binary_returns_none(self, tmp_path): + meta, _ = _probe(tmp_path, side_effect=FileNotFoundError()) + assert meta is None + + def test_ffprobe_timeout_returns_none(self, tmp_path): + meta, _ = _probe(tmp_path, side_effect=subprocess.TimeoutExpired("ffprobe", 15)) + assert meta is None + + def test_invalid_json_returns_none(self, tmp_path): + f = tmp_path / "a.mkv" + f.write_bytes(b"x") + with patch("mediacrush.core.probe.subprocess.run", return_value=MagicMock(stdout="not json")): + assert MediaProbe.inspect(f) is None + + def test_no_video_stream_defaults(self, tmp_path): + meta, _ = _probe(tmp_path, {"format": {"duration": "5"}, "streams": []}) + assert meta.video_codec == "unknown" + assert meta.width == 0 and meta.height == 0 + assert meta.fps == 24.0 + + def test_size_falls_back_to_stat(self, tmp_path): + meta, _ = _probe(tmp_path, {"format": {"duration": "5"}, "streams": []}) + assert meta.file_size_bytes == 100 + + @pytest.mark.parametrize("rate,expected", [("30/1", 30.0), ("0/0", 24.0), ("garbage", 24.0), ("60000/1001", 59.94)]) + def test_fps_parsing(self, tmp_path, rate, expected): + payload = {"format": {}, "streams": [{"index": 0, "codec_type": "video", "r_frame_rate": rate}]} + meta, _ = _probe(tmp_path, payload) + assert meta.fps == pytest.approx(expected, abs=0.01) + + def test_bitrate_from_tags_bps(self, tmp_path): + payload = {"format": {}, "streams": [{"index": 0, "codec_type": "video", "tags": {"BPS": "1234567"}}]} + meta, _ = _probe(tmp_path, payload) + assert meta.video_bitrate == 1234567 + + def test_non_numeric_bitrate_is_zero(self, tmp_path): + payload = {"format": {}, "streams": [{"index": 0, "codec_type": "video", "bit_rate": "N/A"}]} + meta, _ = _probe(tmp_path, payload) + assert meta.video_bitrate == 0 + + +class TestHdrDetection: + def _video(self, **kw): + s = {"index": 0, "codec_type": "video", "codec_name": "hevc", "width": 3840, "height": 2160} + s.update(kw) + return {"format": {"duration": "1"}, "streams": [s]} + + def test_hdr10_from_color_tags(self, tmp_path): + meta, _ = _probe(tmp_path, self._video(color_primaries="bt2020", color_trc="smpte2084")) + assert meta.hdr_format == "HDR10" + assert meta.color_primaries == "bt2020" + + def test_hlg_detected_as_hdr10(self, tmp_path): + meta, _ = _probe(tmp_path, self._video(color_primaries="bt2020", color_trc="arib-std-b67")) + assert meta.hdr_format == "HDR10" + + def test_dolby_vision(self, tmp_path): + meta, _ = _probe(tmp_path, self._video(side_data_list=[{"side_data_type": "DOVI configuration record"}])) + assert meta.hdr_format == "DolbyVision" + + def test_hdr10_plus(self, tmp_path): + meta, _ = _probe(tmp_path, self._video(side_data_list=[{"side_data_type": "HDR Dynamic Metadata SMPTE2094-40 (HDR10+)"}])) + assert meta.hdr_format == "HDR10+" + + def test_sdr_bt709(self, tmp_path): + meta, _ = _probe(tmp_path, self._video(color_primaries="bt709", color_trc="bt709")) + assert meta.hdr_format is None + + def test_bt2020_sdr_transfer_not_hdr(self, tmp_path): + meta, _ = _probe(tmp_path, self._video(color_primaries="bt2020", color_trc="bt709")) + assert meta.hdr_format is None diff --git a/tests/test_quality_mocked.py b/tests/test_quality_mocked.py new file mode 100644 index 0000000..be9113b --- /dev/null +++ b/tests/test_quality_mocked.py @@ -0,0 +1,85 @@ +"""Unit tests for QualityChecker verdicts and log parsing (ffmpeg mocked).""" + +import json +from unittest.mock import patch + +import pytest + +from mediacrush.core.quality import QualityChecker, QualityScore + + +@pytest.mark.parametrize("vmaf,verdict", [ + (100, "Excellent"), (92.0, "Excellent"), (91.99, "Good"), (80.0, "Good"), + (79.9, "Acceptable"), (65.0, "Acceptable"), (64.9, "Poor"), (0, "Poor"), +]) +def test_verdict_boundaries(vmaf, verdict): + assert QualityChecker._calculate_verdict(vmaf) == verdict + + +class TestScore: + def test_missing_inputs(self, tmp_path): + f = tmp_path / "a.mkv" + f.write_bytes(b"x") + assert QualityChecker.score(f, tmp_path / "none.mkv") is None + assert QualityChecker.score(tmp_path / "none.mkv", f) is None + + def test_combines_vmaf_ssim(self, tmp_path): + f = tmp_path / "a.mkv" + f.write_bytes(b"x") + with patch.object(QualityChecker, "_run_vmaf", return_value=93.456), \ + patch.object(QualityChecker, "_run_ssim", return_value=0.98765): + s = QualityChecker.score(f, f) + assert s == QualityScore(93.46, 0.9877, "Excellent") + + def test_vmaf_failure_returns_none(self, tmp_path): + f = tmp_path / "a.mkv" + f.write_bytes(b"x") + with patch.object(QualityChecker, "_run_vmaf", return_value=None): + assert QualityChecker.score(f, f) is None + + def test_ssim_failure_uses_default(self, tmp_path): + f = tmp_path / "a.mkv" + f.write_bytes(b"x") + with patch.object(QualityChecker, "_run_vmaf", return_value=70.0), \ + patch.object(QualityChecker, "_run_ssim", return_value=None): + assert QualityChecker.score(f, f).ssim == 0.95 + + +class TestLogParsing: + def test_vmaf_json(self, tmp_path): + log = tmp_path / "v.json" + log.write_text(json.dumps({"pooled_metrics": {"vmaf": {"mean": 88.5}}})) + with patch("mediacrush.core.quality.subprocess.run"): + assert QualityChecker._run_vmaf(tmp_path / "s", tmp_path / "e", log) == 88.5 + + def test_vmaf_no_log(self, tmp_path): + with patch("mediacrush.core.quality.subprocess.run"): + assert QualityChecker._run_vmaf(tmp_path / "s", tmp_path / "e", tmp_path / "none.json") is None + + def test_vmaf_corrupt_log(self, tmp_path): + log = tmp_path / "v.json" + log.write_text("{{") + with patch("mediacrush.core.quality.subprocess.run"): + assert QualityChecker._run_vmaf(tmp_path / "s", tmp_path / "e", log) is None + + def test_vmaf_ffmpeg_missing(self, tmp_path): + with patch("mediacrush.core.quality.subprocess.run", side_effect=FileNotFoundError): + assert QualityChecker._run_vmaf(tmp_path / "s", tmp_path / "e", tmp_path / "l") is None + + def test_ssim_average(self, tmp_path): + log = tmp_path / "s.txt" + log.write_text("n:1 Y:1 U:1 V:1 All:0.90 (10)\nn:2 Y:1 U:1 V:1 All:1.00 (20)\n") + with patch("mediacrush.core.quality.subprocess.run"): + assert QualityChecker._run_ssim(tmp_path / "s", tmp_path / "e", log) == pytest.approx(0.95) + + def test_ssim_empty_log(self, tmp_path): + log = tmp_path / "s.txt" + log.write_text("") + with patch("mediacrush.core.quality.subprocess.run"): + assert QualityChecker._run_ssim(tmp_path / "s", tmp_path / "e", log) is None + + def test_windows_paths_normalized_in_filter(self, tmp_path): + with patch("mediacrush.core.quality.subprocess.run") as run: + QualityChecker._run_vmaf(tmp_path / "s", tmp_path / "e", tmp_path / "v.json") + filt = run.call_args[0][0][run.call_args[0][0].index("-lavfi") + 1] + assert "\\" not in filt diff --git a/tests/test_queue_extra.py b/tests/test_queue_extra.py new file mode 100644 index 0000000..c849cfb --- /dev/null +++ b/tests/test_queue_extra.py @@ -0,0 +1,77 @@ +"""Additional JobQueue tests: persistence across instances and corrupt-file recovery.""" + +import json + +import pytest + +from mediacrush.core.queue import JobQueue, QueueEntry + + +def e(path, status="pending"): + return QueueEntry(path, "anime", True, status) + + +@pytest.fixture +def qfile(tmp_path): + return tmp_path / "nested" / "queue.json" + + +def test_missing_file_loads_empty(qfile): + assert JobQueue(qfile).load() == [] + + +def test_push_creates_parent_dirs(qfile): + JobQueue(qfile).push(e("/a")) + assert qfile.exists() + + +def test_persists_across_instances(qfile): + JobQueue(qfile).push(e("/a")) + q2 = JobQueue(qfile) + assert [x.path for x in q2.load()] == ["/a"] + + +def test_corrupt_json_recovers_empty(qfile): + qfile.parent.mkdir(parents=True) + qfile.write_text("{{{") + assert JobQueue(qfile).load() == [] + + +def test_unexpected_schema_recovers_empty(qfile): + qfile.parent.mkdir(parents=True) + qfile.write_text(json.dumps([{"unknown": 1}])) + assert JobQueue(qfile).load() == [] + + +def test_status_transitions_persist(qfile): + q = JobQueue(qfile) + for p in ("/a", "/b", "/c"): + q.push(e(p)) + q.mark_done("/a") + q.mark_failed("/b") + q2 = JobQueue(qfile) + q2.load() + assert [x.path for x in q2.pending()] == ["/c"] + assert {x.path: x.status for x in q2.load()} == {"/a": "done", "/b": "failed", "/c": "pending"} + + +def test_mark_unknown_path_is_noop(qfile): + q = JobQueue(qfile) + q.push(e("/a")) + q.mark_done("/zzz") + assert q.pending()[0].path == "/a" + + +def test_clear_done_keeps_failed_and_pending(qfile): + q = JobQueue(qfile) + q.push(e("/a", "done")) + q.push(e("/b", "failed")) + q.push(e("/c")) + q.clear_done() + q2 = JobQueue(qfile) + assert sorted(x.path for x in q2.load()) == ["/b", "/c"] + + +def test_default_queue_file_under_home(): + from mediacrush.core import queue as qm + assert qm._QUEUE_FILE.name == "queue.json" and qm._QUEUE_FILE.parent.name == ".mediacrush" diff --git a/tests/test_reporter_extra.py b/tests/test_reporter_extra.py new file mode 100644 index 0000000..28b47d9 --- /dev/null +++ b/tests/test_reporter_extra.py @@ -0,0 +1,56 @@ +"""Additional reporter tests: JSON round-trip, HTML structure, formatting, known defects.""" + +import json + +import pytest + +from mediacrush.core.reporter import CompressionReporter, ReportEntry + + +def filled(): + r = CompressionReporter() + r.add_result("a.mkv", 1000, 400, "libx265", 12.34, True) + r.add_result("b.mkv", 2000, 2000, "libx265", 1.0, False) + return r + + +class TestEntry: + @pytest.mark.parametrize("o,f,pct", [(1000, 400, 60.0), (1000, 1000, 0.0), (1000, 1500, 0.0), (0, 0, 0.0)]) + def test_savings_pct(self, o, f, pct): + assert ReportEntry("x", o, f, "e", 1, True).savings_pct == pytest.approx(pct) + + +class TestOutputs: + def test_json_round_trip(self, tmp_path): + p = tmp_path / "r.json" + filled().write_json(p) + data = json.loads(p.read_text()) + assert [d["file"] for d in data] == ["a.mkv", "b.mkv"] + assert data[0]["success"] is True and data[1]["success"] is False + + def test_html_contains_rows_and_totals(self, tmp_path): + p = tmp_path / "r.html" + filled().write_html(p) + html = p.read_text(encoding="utf-8") + assert html.startswith("") and "a.mkv" in html and "b.mkv" in html + assert "✓" in html and "✗" in html + assert "Total saved" in html and "20.0%" in html + + def test_empty_report_no_division_error(self, tmp_path): + p = tmp_path / "e.html" + CompressionReporter().write_html(p) + assert "0.0%" in p.read_text(encoding="utf-8") + + @pytest.mark.parametrize("b,expected", [(0, "0.0 B"), (1023, "1023.0 B"), (1024, "1.0 KB"), (1024**2, "1.0 MB"), (1024**3, "1.0 GB")]) + def test_fmt_exact_units(self, b, expected): + assert CompressionReporter._fmt(b) == expected + + def test_fmt_keeps_fraction(self): + assert CompressionReporter._fmt(1536) == "1.5 KB" + + def test_html_escapes_filenames(self, tmp_path): + r = CompressionReporter() + r.add_result(".mkv", 10, 5, "e", 1, True) + p = tmp_path / "x.html" + r.write_html(p) + assert "