973 lines
30 KiB
Python
973 lines
30 KiB
Python
"""Unit tests for plan generator."""
|
|
|
|
import pytest
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from vlm.config import Config
|
|
from vlm.models import (
|
|
ExecutionPlan,
|
|
FileOperation,
|
|
MovieIdentity,
|
|
SeriesIdentity,
|
|
VideoFile,
|
|
)
|
|
from vlm.planner import generate_plan
|
|
|
|
|
|
@pytest.fixture
|
|
def config():
|
|
"""Create a test configuration."""
|
|
return Config(
|
|
library_root=Path("/mnt/nas/videos"),
|
|
video_extensions=[".mp4", ".mkv", ".avi"],
|
|
movie_template="movie/{title} ({year})/",
|
|
series_template="series/{title}/Season {season:02d}/",
|
|
movie_filename_template="{title} ({year}){ext}",
|
|
series_filename_template="S{season:02d}E{episode:02d}{ext}",
|
|
log_level="INFO",
|
|
quarantine_dir=".quarantine"
|
|
)
|
|
|
|
|
|
def test_generate_plan_for_movie_with_year(config):
|
|
"""Test plan generation for a movie with year."""
|
|
video_file = VideoFile(
|
|
path=Path("/mnt/nas/videos/movie/Some.Movie.2020.1080p.mkv"),
|
|
filename="Some.Movie.2020.1080p.mkv",
|
|
size_bytes=1000000,
|
|
modified_timestamp=datetime.now(),
|
|
category="movie"
|
|
)
|
|
|
|
identity = MovieIdentity(
|
|
title="Some Movie",
|
|
year=2020,
|
|
confidence=0.9,
|
|
needs_review=False,
|
|
original_filename="Some.Movie.2020.1080p.mkv"
|
|
)
|
|
|
|
plan = generate_plan([(video_file, identity)], config)
|
|
|
|
assert isinstance(plan, ExecutionPlan)
|
|
assert len(plan.operations) == 1
|
|
|
|
operation = plan.operations[0]
|
|
assert operation.operation_type == "move"
|
|
assert operation.source_path == video_file.path
|
|
assert operation.destination_path == Path("/mnt/nas/videos/movie/Some Movie (2020)/Some Movie (2020).mkv")
|
|
assert not operation.has_conflict
|
|
assert "Some Movie (2020)" in operation.reason
|
|
|
|
|
|
def test_generate_plan_for_movie_without_year(config):
|
|
"""Test plan generation for a movie without year (needs review)."""
|
|
video_file = VideoFile(
|
|
path=Path("/mnt/nas/videos/movie/random_movie.mkv"),
|
|
filename="random_movie.mkv",
|
|
size_bytes=1000000,
|
|
modified_timestamp=datetime.now(),
|
|
category="movie"
|
|
)
|
|
|
|
identity = MovieIdentity(
|
|
title="Random Movie",
|
|
year=None,
|
|
confidence=0.3,
|
|
needs_review=True,
|
|
original_filename="random_movie.mkv"
|
|
)
|
|
|
|
plan = generate_plan([(video_file, identity)], config)
|
|
|
|
assert len(plan.operations) == 1
|
|
operation = plan.operations[0]
|
|
assert operation.operation_type == "no-op"
|
|
assert operation.destination_path is None
|
|
assert "manual review" in operation.reason.lower()
|
|
|
|
|
|
def test_generate_plan_for_series_with_season_and_episode(config):
|
|
"""Test plan generation for a series with season and episode."""
|
|
video_file = VideoFile(
|
|
path=Path("/mnt/nas/videos/series/Show.Name.S01E05.mkv"),
|
|
filename="Show.Name.S01E05.mkv",
|
|
size_bytes=500000,
|
|
modified_timestamp=datetime.now(),
|
|
category="series"
|
|
)
|
|
|
|
identity = SeriesIdentity(
|
|
title="Show Name",
|
|
season=1,
|
|
episodes=[5],
|
|
confidence=0.9,
|
|
needs_review=False,
|
|
original_filename="Show.Name.S01E05.mkv"
|
|
)
|
|
|
|
plan = generate_plan([(video_file, identity)], config)
|
|
|
|
assert len(plan.operations) == 1
|
|
operation = plan.operations[0]
|
|
assert operation.operation_type == "move"
|
|
assert operation.source_path == video_file.path
|
|
assert operation.destination_path == Path("/mnt/nas/videos/series/Show Name/Season 01/S01E05.mkv")
|
|
assert not operation.has_conflict
|
|
|
|
|
|
def test_generate_plan_for_series_without_season(config):
|
|
"""Test plan generation for a series without season (needs review)."""
|
|
video_file = VideoFile(
|
|
path=Path("/mnt/nas/videos/series/ambiguous_show.mkv"),
|
|
filename="ambiguous_show.mkv",
|
|
size_bytes=500000,
|
|
modified_timestamp=datetime.now(),
|
|
category="series"
|
|
)
|
|
|
|
identity = SeriesIdentity(
|
|
title="Ambiguous Show",
|
|
season=None,
|
|
episodes=[],
|
|
confidence=0.3,
|
|
needs_review=True,
|
|
original_filename="ambiguous_show.mkv"
|
|
)
|
|
|
|
plan = generate_plan([(video_file, identity)], config)
|
|
|
|
assert len(plan.operations) == 1
|
|
operation = plan.operations[0]
|
|
assert operation.operation_type == "no-op"
|
|
assert operation.destination_path is None
|
|
assert "manual review" in operation.reason.lower()
|
|
|
|
|
|
def test_generate_plan_for_anime_category(config):
|
|
"""Test plan generation for anime files (no-op in v1)."""
|
|
video_file = VideoFile(
|
|
path=Path("/mnt/nas/videos/anime/Some.Anime.01.mkv"),
|
|
filename="Some.Anime.01.mkv",
|
|
size_bytes=500000,
|
|
modified_timestamp=datetime.now(),
|
|
category="anime"
|
|
)
|
|
|
|
# Anime files don't get parsed in v1, so identity is None
|
|
plan = generate_plan([(video_file, None)], config)
|
|
|
|
assert len(plan.operations) == 1
|
|
operation = plan.operations[0]
|
|
assert operation.operation_type == "no-op"
|
|
assert operation.destination_path is None
|
|
assert "anime" in operation.reason.lower() or "not organized" in operation.reason.lower()
|
|
|
|
|
|
def test_generate_plan_for_other_category(config):
|
|
"""Test plan generation for other category files (no-op in v1)."""
|
|
video_file = VideoFile(
|
|
path=Path("/mnt/nas/videos/other/random.mkv"),
|
|
filename="random.mkv",
|
|
size_bytes=500000,
|
|
modified_timestamp=datetime.now(),
|
|
category="other"
|
|
)
|
|
|
|
plan = generate_plan([(video_file, None)], config)
|
|
|
|
assert len(plan.operations) == 1
|
|
operation = plan.operations[0]
|
|
assert operation.operation_type == "no-op"
|
|
assert operation.destination_path is None
|
|
assert "other" in operation.reason.lower() or "not organized" in operation.reason.lower()
|
|
|
|
|
|
def test_generate_plan_preserves_category_boundaries(config):
|
|
"""Test that plan generation preserves category boundaries."""
|
|
movie_file = VideoFile(
|
|
path=Path("/mnt/nas/videos/movie/Movie.2020.mkv"),
|
|
filename="Movie.2020.mkv",
|
|
size_bytes=1000000,
|
|
modified_timestamp=datetime.now(),
|
|
category="movie"
|
|
)
|
|
|
|
movie_identity = MovieIdentity(
|
|
title="Movie",
|
|
year=2020,
|
|
confidence=0.9,
|
|
needs_review=False,
|
|
original_filename="Movie.2020.mkv"
|
|
)
|
|
|
|
plan = generate_plan([(movie_file, movie_identity)], config)
|
|
|
|
operation = plan.operations[0]
|
|
# Destination should still be in movie category
|
|
assert str(operation.destination_path).startswith(str(config.library_root / "movie"))
|
|
|
|
|
|
def test_generate_plan_for_multi_episode_file(config):
|
|
"""Test plan generation for multi-episode files."""
|
|
video_file = VideoFile(
|
|
path=Path("/mnt/nas/videos/series/Show.S01E01E02.mkv"),
|
|
filename="Show.S01E01E02.mkv",
|
|
size_bytes=1000000,
|
|
modified_timestamp=datetime.now(),
|
|
category="series"
|
|
)
|
|
|
|
identity = SeriesIdentity(
|
|
title="Show",
|
|
season=1,
|
|
episodes=[1, 2], # Multi-episode
|
|
confidence=0.9,
|
|
needs_review=False,
|
|
original_filename="Show.S01E01E02.mkv"
|
|
)
|
|
|
|
plan = generate_plan([(video_file, identity)], config)
|
|
|
|
operation = plan.operations[0]
|
|
# Should use first episode number for filename
|
|
assert operation.destination_path == Path("/mnt/nas/videos/series/Show/Season 01/S01E01.mkv")
|
|
|
|
|
|
def test_generate_plan_file_already_at_target(config):
|
|
"""Test plan generation when file is already at target location."""
|
|
# File already in correct location
|
|
video_file = VideoFile(
|
|
path=Path("/mnt/nas/videos/movie/Some Movie (2020)/Some Movie (2020).mkv"),
|
|
filename="Some Movie (2020).mkv",
|
|
size_bytes=1000000,
|
|
modified_timestamp=datetime.now(),
|
|
category="movie"
|
|
)
|
|
|
|
identity = MovieIdentity(
|
|
title="Some Movie",
|
|
year=2020,
|
|
confidence=0.9,
|
|
needs_review=False,
|
|
original_filename="Some Movie (2020).mkv"
|
|
)
|
|
|
|
plan = generate_plan([(video_file, identity)], config)
|
|
|
|
operation = plan.operations[0]
|
|
assert operation.operation_type == "no-op"
|
|
assert "already at target" in operation.reason.lower()
|
|
|
|
|
|
def test_generate_plan_rename_vs_move(config):
|
|
"""Test that plan distinguishes between rename and move operations."""
|
|
# File in correct directory but wrong name (rename)
|
|
video_file_rename = VideoFile(
|
|
path=Path("/mnt/nas/videos/movie/Some Movie (2020)/old_name.mkv"),
|
|
filename="old_name.mkv",
|
|
size_bytes=1000000,
|
|
modified_timestamp=datetime.now(),
|
|
category="movie"
|
|
)
|
|
|
|
identity = MovieIdentity(
|
|
title="Some Movie",
|
|
year=2020,
|
|
confidence=0.9,
|
|
needs_review=False,
|
|
original_filename="old_name.mkv"
|
|
)
|
|
|
|
plan = generate_plan([(video_file_rename, identity)], config)
|
|
operation = plan.operations[0]
|
|
assert operation.operation_type == "rename"
|
|
|
|
# File in wrong directory (move)
|
|
video_file_move = VideoFile(
|
|
path=Path("/mnt/nas/videos/movie/wrong_dir/Some Movie (2020).mkv"),
|
|
filename="Some Movie (2020).mkv",
|
|
size_bytes=1000000,
|
|
modified_timestamp=datetime.now(),
|
|
category="movie"
|
|
)
|
|
|
|
plan = generate_plan([(video_file_move, identity)], config)
|
|
operation = plan.operations[0]
|
|
assert operation.operation_type == "move"
|
|
|
|
|
|
def test_generate_plan_summary(config):
|
|
"""Test that plan summary contains accurate counts."""
|
|
files_and_identities = [
|
|
# Movie with year (move)
|
|
(
|
|
VideoFile(
|
|
path=Path("/mnt/nas/videos/movie/Movie1.2020.mkv"),
|
|
filename="Movie1.2020.mkv",
|
|
size_bytes=1000000,
|
|
modified_timestamp=datetime.now(),
|
|
category="movie"
|
|
),
|
|
MovieIdentity(
|
|
title="Movie1",
|
|
year=2020,
|
|
confidence=0.9,
|
|
needs_review=False,
|
|
original_filename="Movie1.2020.mkv"
|
|
)
|
|
),
|
|
# Movie without year (no-op)
|
|
(
|
|
VideoFile(
|
|
path=Path("/mnt/nas/videos/movie/Movie2.mkv"),
|
|
filename="Movie2.mkv",
|
|
size_bytes=1000000,
|
|
modified_timestamp=datetime.now(),
|
|
category="movie"
|
|
),
|
|
MovieIdentity(
|
|
title="Movie2",
|
|
year=None,
|
|
confidence=0.3,
|
|
needs_review=True,
|
|
original_filename="Movie2.mkv"
|
|
)
|
|
),
|
|
# Anime (no-op)
|
|
(
|
|
VideoFile(
|
|
path=Path("/mnt/nas/videos/anime/Anime.01.mkv"),
|
|
filename="Anime.01.mkv",
|
|
size_bytes=500000,
|
|
modified_timestamp=datetime.now(),
|
|
category="anime"
|
|
),
|
|
None
|
|
),
|
|
# Series with season/episode (move)
|
|
(
|
|
VideoFile(
|
|
path=Path("/mnt/nas/videos/series/Show.S01E01.mkv"),
|
|
filename="Show.S01E01.mkv",
|
|
size_bytes=500000,
|
|
modified_timestamp=datetime.now(),
|
|
category="series"
|
|
),
|
|
SeriesIdentity(
|
|
title="Show",
|
|
season=1,
|
|
episodes=[1],
|
|
confidence=0.9,
|
|
needs_review=False,
|
|
original_filename="Show.S01E01.mkv"
|
|
)
|
|
),
|
|
]
|
|
|
|
plan = generate_plan(files_and_identities, config)
|
|
|
|
assert plan.summary["total"] == 4
|
|
assert plan.summary["move"] == 2
|
|
assert plan.summary["no-op"] == 2
|
|
assert plan.summary["rename"] == 0
|
|
assert plan.summary["quarantine"] == 0
|
|
|
|
|
|
def test_generate_plan_with_different_extensions(config):
|
|
"""Test plan generation preserves file extensions."""
|
|
extensions = [".mp4", ".mkv", ".avi"]
|
|
|
|
for ext in extensions:
|
|
video_file = VideoFile(
|
|
path=Path(f"/mnt/nas/videos/movie/Movie.2020{ext}"),
|
|
filename=f"Movie.2020{ext}",
|
|
size_bytes=1000000,
|
|
modified_timestamp=datetime.now(),
|
|
category="movie"
|
|
)
|
|
|
|
identity = MovieIdentity(
|
|
title="Movie",
|
|
year=2020,
|
|
confidence=0.9,
|
|
needs_review=False,
|
|
original_filename=f"Movie.2020{ext}"
|
|
)
|
|
|
|
plan = generate_plan([(video_file, identity)], config)
|
|
operation = plan.operations[0]
|
|
|
|
# Check that extension is preserved
|
|
assert operation.destination_path.suffix == ext
|
|
|
|
|
|
def test_conflict_detection_for_movie(config, tmp_path):
|
|
"""Test conflict detection when destination movie file already exists."""
|
|
# Set up config with tmp_path as library root
|
|
config.library_root = tmp_path
|
|
|
|
# Create destination directory and file
|
|
dest_dir = tmp_path / "movie" / "Some Movie (2020)"
|
|
dest_dir.mkdir(parents=True)
|
|
dest_file = dest_dir / "Some Movie (2020).mkv"
|
|
dest_file.touch() # Create the file
|
|
|
|
# Source file in different location
|
|
video_file = VideoFile(
|
|
path=tmp_path / "movie" / "Some.Movie.2020.1080p.mkv",
|
|
filename="Some.Movie.2020.1080p.mkv",
|
|
size_bytes=1000000,
|
|
modified_timestamp=datetime.now(),
|
|
category="movie"
|
|
)
|
|
|
|
identity = MovieIdentity(
|
|
title="Some Movie",
|
|
year=2020,
|
|
confidence=0.9,
|
|
needs_review=False,
|
|
original_filename="Some.Movie.2020.1080p.mkv"
|
|
)
|
|
|
|
plan = generate_plan([(video_file, identity)], config)
|
|
|
|
operation = plan.operations[0]
|
|
assert operation.has_conflict is True
|
|
assert operation.conflict_reason is not None
|
|
assert "already exists" in operation.conflict_reason.lower()
|
|
assert str(dest_file) in operation.conflict_reason
|
|
|
|
|
|
def test_conflict_detection_for_series(config, tmp_path):
|
|
"""Test conflict detection when destination series file already exists."""
|
|
# Set up config with tmp_path as library root
|
|
config.library_root = tmp_path
|
|
|
|
# Create destination directory and file
|
|
dest_dir = tmp_path / "series" / "Show Name" / "Season 01"
|
|
dest_dir.mkdir(parents=True)
|
|
dest_file = dest_dir / "S01E05.mkv"
|
|
dest_file.touch() # Create the file
|
|
|
|
# Source file in different location
|
|
video_file = VideoFile(
|
|
path=tmp_path / "series" / "Show.Name.S01E05.1080p.mkv",
|
|
filename="Show.Name.S01E05.1080p.mkv",
|
|
size_bytes=500000,
|
|
modified_timestamp=datetime.now(),
|
|
category="series"
|
|
)
|
|
|
|
identity = SeriesIdentity(
|
|
title="Show Name",
|
|
season=1,
|
|
episodes=[5],
|
|
confidence=0.9,
|
|
needs_review=False,
|
|
original_filename="Show.Name.S01E05.1080p.mkv"
|
|
)
|
|
|
|
plan = generate_plan([(video_file, identity)], config)
|
|
|
|
operation = plan.operations[0]
|
|
assert operation.has_conflict is True
|
|
assert operation.conflict_reason is not None
|
|
assert "already exists" in operation.conflict_reason.lower()
|
|
assert str(dest_file) in operation.conflict_reason
|
|
|
|
|
|
def test_no_conflict_when_destination_does_not_exist(config, tmp_path):
|
|
"""Test that no conflict is detected when destination file does not exist."""
|
|
# Set up config with tmp_path as library root
|
|
config.library_root = tmp_path
|
|
|
|
# Create source directory but NOT destination
|
|
source_dir = tmp_path / "movie"
|
|
source_dir.mkdir(parents=True)
|
|
|
|
video_file = VideoFile(
|
|
path=source_dir / "Some.Movie.2020.mkv",
|
|
filename="Some.Movie.2020.mkv",
|
|
size_bytes=1000000,
|
|
modified_timestamp=datetime.now(),
|
|
category="movie"
|
|
)
|
|
|
|
identity = MovieIdentity(
|
|
title="Some Movie",
|
|
year=2020,
|
|
confidence=0.9,
|
|
needs_review=False,
|
|
original_filename="Some.Movie.2020.mkv"
|
|
)
|
|
|
|
plan = generate_plan([(video_file, identity)], config)
|
|
|
|
operation = plan.operations[0]
|
|
assert operation.has_conflict is False
|
|
assert operation.conflict_reason is None
|
|
|
|
|
|
def test_conflict_detection_with_multiple_files(config, tmp_path):
|
|
"""Test conflict detection with multiple files, some with conflicts."""
|
|
# Set up config with tmp_path as library root
|
|
config.library_root = tmp_path
|
|
|
|
# Create destination for first movie (conflict)
|
|
dest_dir1 = tmp_path / "movie" / "Movie1 (2020)"
|
|
dest_dir1.mkdir(parents=True)
|
|
(dest_dir1 / "Movie1 (2020).mkv").touch()
|
|
|
|
# Don't create destination for second movie (no conflict)
|
|
|
|
files_and_identities = [
|
|
# Movie 1 - has conflict
|
|
(
|
|
VideoFile(
|
|
path=tmp_path / "movie" / "Movie1.2020.mkv",
|
|
filename="Movie1.2020.mkv",
|
|
size_bytes=1000000,
|
|
modified_timestamp=datetime.now(),
|
|
category="movie"
|
|
),
|
|
MovieIdentity(
|
|
title="Movie1",
|
|
year=2020,
|
|
confidence=0.9,
|
|
needs_review=False,
|
|
original_filename="Movie1.2020.mkv"
|
|
)
|
|
),
|
|
# Movie 2 - no conflict
|
|
(
|
|
VideoFile(
|
|
path=tmp_path / "movie" / "Movie2.2021.mkv",
|
|
filename="Movie2.2021.mkv",
|
|
size_bytes=1000000,
|
|
modified_timestamp=datetime.now(),
|
|
category="movie"
|
|
),
|
|
MovieIdentity(
|
|
title="Movie2",
|
|
year=2021,
|
|
confidence=0.9,
|
|
needs_review=False,
|
|
original_filename="Movie2.2021.mkv"
|
|
)
|
|
),
|
|
]
|
|
|
|
plan = generate_plan(files_and_identities, config)
|
|
|
|
# First operation should have conflict
|
|
assert plan.operations[0].has_conflict is True
|
|
assert plan.operations[0].conflict_reason is not None
|
|
|
|
# Second operation should not have conflict
|
|
assert plan.operations[1].has_conflict is False
|
|
assert plan.operations[1].conflict_reason is None
|
|
|
|
|
|
def test_save_plan_to_json(config, tmp_path):
|
|
"""Test saving execution plan to JSON file."""
|
|
from vlm.planner import save_plan
|
|
|
|
# Create a simple plan
|
|
video_file = VideoFile(
|
|
path=Path("/mnt/nas/videos/movie/Movie.2020.mkv"),
|
|
filename="Movie.2020.mkv",
|
|
size_bytes=1000000,
|
|
modified_timestamp=datetime.now(),
|
|
category="movie"
|
|
)
|
|
|
|
identity = MovieIdentity(
|
|
title="Movie",
|
|
year=2020,
|
|
confidence=0.9,
|
|
needs_review=False,
|
|
original_filename="Movie.2020.mkv"
|
|
)
|
|
|
|
plan = generate_plan([(video_file, identity)], config)
|
|
|
|
# Save to JSON
|
|
output_path = tmp_path / "plan.json"
|
|
save_plan(plan, output_path)
|
|
|
|
# Verify file was created
|
|
assert output_path.exists()
|
|
|
|
# Verify JSON is valid and contains expected fields
|
|
import json
|
|
with open(output_path, 'r') as f:
|
|
plan_dict = json.load(f)
|
|
|
|
assert "plan_id" in plan_dict
|
|
assert "created_at" in plan_dict
|
|
assert "operations" in plan_dict
|
|
assert "summary" in plan_dict
|
|
|
|
assert plan_dict["plan_id"] == plan.plan_id
|
|
assert len(plan_dict["operations"]) == 1
|
|
assert plan_dict["summary"]["total"] == 1
|
|
|
|
|
|
def test_load_plan_from_json(config, tmp_path):
|
|
"""Test loading execution plan from JSON file."""
|
|
from vlm.planner import save_plan, load_plan
|
|
|
|
# Create and save a plan
|
|
video_file = VideoFile(
|
|
path=Path("/mnt/nas/videos/movie/Movie.2020.mkv"),
|
|
filename="Movie.2020.mkv",
|
|
size_bytes=1000000,
|
|
modified_timestamp=datetime.now(),
|
|
category="movie"
|
|
)
|
|
|
|
identity = MovieIdentity(
|
|
title="Movie",
|
|
year=2020,
|
|
confidence=0.9,
|
|
needs_review=False,
|
|
original_filename="Movie.2020.mkv"
|
|
)
|
|
|
|
original_plan = generate_plan([(video_file, identity)], config)
|
|
|
|
output_path = tmp_path / "plan.json"
|
|
save_plan(original_plan, output_path)
|
|
|
|
# Load the plan
|
|
loaded_plan = load_plan(output_path)
|
|
|
|
# Verify loaded plan matches original
|
|
assert loaded_plan.plan_id == original_plan.plan_id
|
|
assert loaded_plan.created_at == original_plan.created_at
|
|
assert len(loaded_plan.operations) == len(original_plan.operations)
|
|
assert loaded_plan.summary == original_plan.summary
|
|
|
|
# Verify operation details
|
|
loaded_op = loaded_plan.operations[0]
|
|
original_op = original_plan.operations[0]
|
|
|
|
assert loaded_op.operation_type == original_op.operation_type
|
|
assert loaded_op.source_path == original_op.source_path
|
|
assert loaded_op.destination_path == original_op.destination_path
|
|
assert loaded_op.reason == original_op.reason
|
|
assert loaded_op.has_conflict == original_op.has_conflict
|
|
assert loaded_op.conflict_reason == original_op.conflict_reason
|
|
|
|
|
|
def test_save_and_load_plan_with_conflicts(config, tmp_path):
|
|
"""Test saving and loading plan with conflict information."""
|
|
from vlm.planner import save_plan, load_plan
|
|
|
|
# Set up config with tmp_path as library root
|
|
config.library_root = tmp_path
|
|
|
|
# Create destination file to trigger conflict
|
|
dest_dir = tmp_path / "movie" / "Movie (2020)"
|
|
dest_dir.mkdir(parents=True)
|
|
(dest_dir / "Movie (2020).mkv").touch()
|
|
|
|
video_file = VideoFile(
|
|
path=tmp_path / "movie" / "Movie.2020.mkv",
|
|
filename="Movie.2020.mkv",
|
|
size_bytes=1000000,
|
|
modified_timestamp=datetime.now(),
|
|
category="movie"
|
|
)
|
|
|
|
identity = MovieIdentity(
|
|
title="Movie",
|
|
year=2020,
|
|
confidence=0.9,
|
|
needs_review=False,
|
|
original_filename="Movie.2020.mkv"
|
|
)
|
|
|
|
original_plan = generate_plan([(video_file, identity)], config)
|
|
|
|
# Verify conflict was detected
|
|
assert original_plan.operations[0].has_conflict is True
|
|
|
|
# Save and load
|
|
output_path = tmp_path / "plan_with_conflict.json"
|
|
save_plan(original_plan, output_path)
|
|
loaded_plan = load_plan(output_path)
|
|
|
|
# Verify conflict information is preserved
|
|
assert loaded_plan.operations[0].has_conflict is True
|
|
assert loaded_plan.operations[0].conflict_reason is not None
|
|
assert "already exists" in loaded_plan.operations[0].conflict_reason.lower()
|
|
|
|
|
|
def test_save_and_load_plan_with_no_op_operations(config, tmp_path):
|
|
"""Test saving and loading plan with no-op operations."""
|
|
from vlm.planner import save_plan, load_plan
|
|
|
|
# Create files that will generate no-op operations
|
|
files_and_identities = [
|
|
# Anime (no-op)
|
|
(
|
|
VideoFile(
|
|
path=Path("/mnt/nas/videos/anime/Anime.01.mkv"),
|
|
filename="Anime.01.mkv",
|
|
size_bytes=500000,
|
|
modified_timestamp=datetime.now(),
|
|
category="anime"
|
|
),
|
|
None
|
|
),
|
|
# Movie without year (no-op)
|
|
(
|
|
VideoFile(
|
|
path=Path("/mnt/nas/videos/movie/Movie.mkv"),
|
|
filename="Movie.mkv",
|
|
size_bytes=1000000,
|
|
modified_timestamp=datetime.now(),
|
|
category="movie"
|
|
),
|
|
MovieIdentity(
|
|
title="Movie",
|
|
year=None,
|
|
confidence=0.3,
|
|
needs_review=True,
|
|
original_filename="Movie.mkv"
|
|
)
|
|
),
|
|
]
|
|
|
|
original_plan = generate_plan(files_and_identities, config)
|
|
|
|
# Save and load
|
|
output_path = tmp_path / "plan_with_noops.json"
|
|
save_plan(original_plan, output_path)
|
|
loaded_plan = load_plan(output_path)
|
|
|
|
# Verify no-op operations are preserved
|
|
assert len(loaded_plan.operations) == 2
|
|
assert all(op.operation_type == "no-op" for op in loaded_plan.operations)
|
|
assert all(op.destination_path is None for op in loaded_plan.operations)
|
|
|
|
|
|
def test_save_plan_json_is_human_readable(config, tmp_path):
|
|
"""Test that saved JSON is human-readable with proper formatting."""
|
|
from vlm.planner import save_plan
|
|
|
|
video_file = VideoFile(
|
|
path=Path("/mnt/nas/videos/movie/Movie.2020.mkv"),
|
|
filename="Movie.2020.mkv",
|
|
size_bytes=1000000,
|
|
modified_timestamp=datetime.now(),
|
|
category="movie"
|
|
)
|
|
|
|
identity = MovieIdentity(
|
|
title="Movie",
|
|
year=2020,
|
|
confidence=0.9,
|
|
needs_review=False,
|
|
original_filename="Movie.2020.mkv"
|
|
)
|
|
|
|
plan = generate_plan([(video_file, identity)], config)
|
|
|
|
output_path = tmp_path / "plan.json"
|
|
save_plan(plan, output_path)
|
|
|
|
# Read the raw JSON content
|
|
with open(output_path, 'r') as f:
|
|
content = f.read()
|
|
|
|
# Verify it's formatted with indentation (human-readable)
|
|
assert "\n" in content # Has newlines
|
|
assert " " in content # Has indentation
|
|
|
|
# Verify it's valid JSON
|
|
import json
|
|
json.loads(content)
|
|
|
|
|
|
def test_save_plan_with_multiple_operations(config, tmp_path):
|
|
"""Test saving plan with multiple operations of different types."""
|
|
from vlm.planner import save_plan, load_plan
|
|
|
|
files_and_identities = [
|
|
# Movie (move)
|
|
(
|
|
VideoFile(
|
|
path=Path("/mnt/nas/videos/movie/Movie1.2020.mkv"),
|
|
filename="Movie1.2020.mkv",
|
|
size_bytes=1000000,
|
|
modified_timestamp=datetime.now(),
|
|
category="movie"
|
|
),
|
|
MovieIdentity(
|
|
title="Movie1",
|
|
year=2020,
|
|
confidence=0.9,
|
|
needs_review=False,
|
|
original_filename="Movie1.2020.mkv"
|
|
)
|
|
),
|
|
# Series (move)
|
|
(
|
|
VideoFile(
|
|
path=Path("/mnt/nas/videos/series/Show.S01E01.mkv"),
|
|
filename="Show.S01E01.mkv",
|
|
size_bytes=500000,
|
|
modified_timestamp=datetime.now(),
|
|
category="series"
|
|
),
|
|
SeriesIdentity(
|
|
title="Show",
|
|
season=1,
|
|
episodes=[1],
|
|
confidence=0.9,
|
|
needs_review=False,
|
|
original_filename="Show.S01E01.mkv"
|
|
)
|
|
),
|
|
# Anime (no-op)
|
|
(
|
|
VideoFile(
|
|
path=Path("/mnt/nas/videos/anime/Anime.01.mkv"),
|
|
filename="Anime.01.mkv",
|
|
size_bytes=500000,
|
|
modified_timestamp=datetime.now(),
|
|
category="anime"
|
|
),
|
|
None
|
|
),
|
|
]
|
|
|
|
original_plan = generate_plan(files_and_identities, config)
|
|
|
|
output_path = tmp_path / "multi_op_plan.json"
|
|
save_plan(original_plan, output_path)
|
|
loaded_plan = load_plan(output_path)
|
|
|
|
# Verify all operations are preserved
|
|
assert len(loaded_plan.operations) == 3
|
|
assert loaded_plan.summary["total"] == 3
|
|
assert loaded_plan.summary["move"] == 2
|
|
assert loaded_plan.summary["no-op"] == 1
|
|
|
|
|
|
def test_load_plan_file_not_found(tmp_path):
|
|
"""Test loading plan from non-existent file raises FileNotFoundError."""
|
|
from vlm.planner import load_plan
|
|
|
|
non_existent_path = tmp_path / "does_not_exist.json"
|
|
|
|
with pytest.raises(FileNotFoundError):
|
|
load_plan(non_existent_path)
|
|
|
|
|
|
def test_load_plan_invalid_json(tmp_path):
|
|
"""Test loading plan from invalid JSON raises JSONDecodeError."""
|
|
from vlm.planner import load_plan
|
|
import json
|
|
|
|
invalid_json_path = tmp_path / "invalid.json"
|
|
with open(invalid_json_path, 'w') as f:
|
|
f.write("{ this is not valid json }")
|
|
|
|
with pytest.raises(json.JSONDecodeError):
|
|
load_plan(invalid_json_path)
|
|
|
|
|
|
def test_plan_json_includes_all_required_fields(config, tmp_path):
|
|
"""Test that saved JSON includes plan_id, created_at, operations, and summary."""
|
|
from vlm.planner import save_plan
|
|
import json
|
|
|
|
video_file = VideoFile(
|
|
path=Path("/mnt/nas/videos/movie/Movie.2020.mkv"),
|
|
filename="Movie.2020.mkv",
|
|
size_bytes=1000000,
|
|
modified_timestamp=datetime.now(),
|
|
category="movie"
|
|
)
|
|
|
|
identity = MovieIdentity(
|
|
title="Movie",
|
|
year=2020,
|
|
confidence=0.9,
|
|
needs_review=False,
|
|
original_filename="Movie.2020.mkv"
|
|
)
|
|
|
|
plan = generate_plan([(video_file, identity)], config)
|
|
|
|
output_path = tmp_path / "plan.json"
|
|
save_plan(plan, output_path)
|
|
|
|
with open(output_path, 'r') as f:
|
|
plan_dict = json.load(f)
|
|
|
|
# Verify all required fields are present
|
|
required_fields = ["plan_id", "created_at", "operations", "summary"]
|
|
for field in required_fields:
|
|
assert field in plan_dict, f"Missing required field: {field}"
|
|
|
|
# Verify operations have required fields
|
|
operation = plan_dict["operations"][0]
|
|
required_op_fields = ["operation_type", "source_path", "destination_path", "reason", "has_conflict", "conflict_reason"]
|
|
for field in required_op_fields:
|
|
assert field in operation, f"Missing required operation field: {field}"
|
|
|
|
|
|
def test_movie_rejected_by_review_generates_noop(config):
|
|
"""Rejected movie should not generate move/rename operation."""
|
|
video_file = VideoFile(
|
|
path=Path("/mnt/nas/videos/movie/Movie.2020.mkv"),
|
|
filename="Movie.2020.mkv",
|
|
size_bytes=1000000,
|
|
modified_timestamp=datetime.now(),
|
|
category="movie"
|
|
)
|
|
|
|
identity = MovieIdentity(
|
|
title="Movie",
|
|
year=2020,
|
|
confidence=0.9,
|
|
needs_review=False,
|
|
original_filename="Movie.2020.mkv",
|
|
review_status="rejected"
|
|
)
|
|
|
|
plan = generate_plan([(video_file, identity)], config)
|
|
assert plan.operations[0].operation_type == "no-op"
|
|
assert "rejected" in plan.operations[0].reason.lower()
|
|
|
|
|
|
def test_series_rejected_by_review_generates_noop(config):
|
|
"""Rejected series should not generate move/rename operation."""
|
|
video_file = VideoFile(
|
|
path=Path("/mnt/nas/videos/series/Show.S01E01.mkv"),
|
|
filename="Show.S01E01.mkv",
|
|
size_bytes=1000000,
|
|
modified_timestamp=datetime.now(),
|
|
category="series"
|
|
)
|
|
|
|
identity = SeriesIdentity(
|
|
title="Show",
|
|
season=1,
|
|
episodes=[1],
|
|
confidence=0.9,
|
|
needs_review=False,
|
|
original_filename="Show.S01E01.mkv",
|
|
review_status="rejected"
|
|
)
|
|
|
|
plan = generate_plan([(video_file, identity)], config)
|
|
assert plan.operations[0].operation_type == "no-op"
|
|
assert "rejected" in plan.operations[0].reason.lower()
|