- Updated AGENTS.md to reflect changes in CLI commands and module organization, including the addition of an enrichment step and new functional modules. - Introduced analysis.json, identities.json, inventory.csv, and plan.json to support enriched metadata and execution planning. - Added CODE_IMPROVEMENTS.md to document identified code issues and proposed solutions for future enhancements. - Updated README.md to include new enrichment features and configuration options. - Removed unused dependency on ffmpeg-python from pyproject.toml. These changes improve the overall functionality and maintainability of the Video Library Manager project.
597 lines
21 KiB
Python
597 lines
21 KiB
Python
"""Unit tests for Configuration Manager."""
|
|
|
|
import pytest
|
|
import yaml
|
|
from pathlib import Path
|
|
from vlm.config import Config, load_config, create_default_config, validate_config
|
|
|
|
|
|
class TestConfig:
|
|
"""Test Config dataclass."""
|
|
|
|
def test_config_creation_with_defaults(self):
|
|
"""Test creating Config with default values."""
|
|
config = Config(library_root=Path("/mnt/nas/videos"))
|
|
|
|
assert config.library_root == Path("/mnt/nas/videos")
|
|
assert ".mp4" in config.video_extensions
|
|
assert ".mkv" in config.video_extensions
|
|
assert config.movie_template == "movie/{title} ({year})/"
|
|
assert config.series_template == "series/{title}/Season {season:02d}/"
|
|
assert config.log_level == "INFO"
|
|
assert config.quarantine_dir == ".quarantine"
|
|
assert config.enrichment_providers == ["tmdb"]
|
|
|
|
def test_config_creation_with_custom_values(self):
|
|
"""Test creating Config with custom values."""
|
|
config = Config(
|
|
library_root=Path("/custom/path"),
|
|
video_extensions=[".mp4", ".avi"],
|
|
movie_template="movies/{title}-{year}/",
|
|
log_level="DEBUG"
|
|
)
|
|
|
|
assert config.library_root == Path("/custom/path")
|
|
assert config.video_extensions == [".mp4", ".avi"]
|
|
assert config.movie_template == "movies/{title}-{year}/"
|
|
assert config.log_level == "DEBUG"
|
|
|
|
def test_config_default_categories(self):
|
|
"""Test Config has default categories."""
|
|
config = Config(library_root=Path("/test"))
|
|
|
|
assert config.categories == {
|
|
"movie": ["movie", "movies"],
|
|
"series": ["series", "tv", "shows"],
|
|
"anime": ["anime"]
|
|
}
|
|
|
|
def test_config_custom_categories(self):
|
|
"""Test Config with custom category mappings."""
|
|
config = Config(
|
|
library_root=Path("/test"),
|
|
categories={
|
|
"movie": ["movie", "movies", "films"],
|
|
"series": ["series", "tv"],
|
|
"anime": ["anime"]
|
|
}
|
|
)
|
|
|
|
assert "movies" in config.categories["movie"]
|
|
assert "tv" in config.categories["series"]
|
|
|
|
|
|
class TestLoadConfig:
|
|
"""Test load_config function."""
|
|
|
|
def test_load_config_success(self, tmp_path):
|
|
"""Test loading valid configuration file."""
|
|
config_file = tmp_path / "config.yaml"
|
|
config_data = {
|
|
'library_root': '/mnt/nas/videos',
|
|
'video_extensions': ['.mp4', '.mkv', '.avi'],
|
|
'templates': {
|
|
'movie_dir': 'movie/{title} ({year})/',
|
|
'series_dir': 'series/{title}/Season {season:02d}/',
|
|
'movie_filename': '{title} ({year}){ext}',
|
|
'series_filename': 'S{season:02d}E{episode:02d}{ext}'
|
|
},
|
|
'quarantine_dir': '.quarantine',
|
|
'log_level': 'DEBUG'
|
|
}
|
|
|
|
with open(config_file, 'w') as f:
|
|
yaml.dump(config_data, f)
|
|
|
|
config = load_config(config_file)
|
|
|
|
assert config.library_root == Path('/mnt/nas/videos')
|
|
assert config.video_extensions == ['.mp4', '.mkv', '.avi']
|
|
assert config.movie_template == 'movie/{title} ({year})/'
|
|
assert config.series_template == 'series/{title}/Season {season:02d}/'
|
|
assert config.log_level == 'DEBUG'
|
|
assert config.quarantine_dir == '.quarantine'
|
|
|
|
def test_load_config_with_home_directory(self, tmp_path):
|
|
"""Test loading config with ~ in library_root."""
|
|
config_file = tmp_path / "config.yaml"
|
|
config_data = {
|
|
'library_root': '~/Videos',
|
|
'video_extensions': ['.mp4']
|
|
}
|
|
|
|
with open(config_file, 'w') as f:
|
|
yaml.dump(config_data, f)
|
|
|
|
config = load_config(config_file)
|
|
|
|
# Should expand ~ to home directory
|
|
assert config.library_root == Path.home() / "Videos"
|
|
|
|
def test_load_config_missing_file(self, tmp_path):
|
|
"""Test loading non-existent configuration file."""
|
|
config_file = tmp_path / "nonexistent.yaml"
|
|
|
|
with pytest.raises(FileNotFoundError):
|
|
load_config(config_file)
|
|
|
|
def test_load_config_invalid_yaml(self, tmp_path):
|
|
"""Test loading configuration with invalid YAML syntax."""
|
|
config_file = tmp_path / "config.yaml"
|
|
|
|
with open(config_file, 'w') as f:
|
|
f.write("invalid: yaml: syntax: [unclosed")
|
|
|
|
with pytest.raises(yaml.YAMLError):
|
|
load_config(config_file)
|
|
|
|
def test_load_config_missing_library_root(self, tmp_path):
|
|
"""Test loading configuration without library_root."""
|
|
config_file = tmp_path / "config.yaml"
|
|
config_data = {
|
|
'video_extensions': ['.mp4']
|
|
}
|
|
|
|
with open(config_file, 'w') as f:
|
|
yaml.dump(config_data, f)
|
|
|
|
with pytest.raises(ValueError, match="library_root"):
|
|
load_config(config_file)
|
|
|
|
def test_load_config_empty_file(self, tmp_path):
|
|
"""Test loading empty configuration file."""
|
|
config_file = tmp_path / "config.yaml"
|
|
config_file.write_text("")
|
|
|
|
with pytest.raises(ValueError, match="library_root"):
|
|
load_config(config_file)
|
|
|
|
def test_load_config_with_defaults(self, tmp_path):
|
|
"""Test loading config with minimal settings uses defaults."""
|
|
config_file = tmp_path / "config.yaml"
|
|
config_data = {
|
|
'library_root': '/mnt/nas/videos'
|
|
}
|
|
|
|
with open(config_file, 'w') as f:
|
|
yaml.dump(config_data, f)
|
|
|
|
config = load_config(config_file)
|
|
|
|
# Should use default values for missing fields
|
|
assert config.library_root == Path('/mnt/nas/videos')
|
|
assert ".mp4" in config.video_extensions
|
|
assert config.movie_template == "movie/{title} ({year})/"
|
|
assert config.log_level == "INFO"
|
|
|
|
def test_load_config_with_custom_categories(self, tmp_path):
|
|
"""Test loading config with custom categories."""
|
|
config_file = tmp_path / "config.yaml"
|
|
config_file.write_text("""
|
|
library_root: /test/library
|
|
categories:
|
|
movie: [movie, movies, films]
|
|
series: [series, tv, shows]
|
|
anime: [anime]
|
|
""")
|
|
|
|
config = load_config(config_file)
|
|
|
|
assert config.categories["movie"] == ["movie", "movies", "films"]
|
|
assert config.categories["series"] == ["series", "tv", "shows"]
|
|
|
|
def test_load_config_with_tmdb_settings(self, tmp_path):
|
|
"""Test loading TMDB auth and query preferences from config."""
|
|
config_file = tmp_path / "config.yaml"
|
|
config_file.write_text("""
|
|
library_root: /test/library
|
|
enrichment:
|
|
api_keys:
|
|
tmdb_bearer: bearer-token
|
|
tmdb:
|
|
language: zh-TW
|
|
region: TW
|
|
include_adult: false
|
|
""")
|
|
|
|
config = load_config(config_file)
|
|
|
|
assert config.tmdb_bearer_token == "bearer-token"
|
|
assert config.tmdb_language == "zh-TW"
|
|
assert config.tmdb_region == "TW"
|
|
assert config.tmdb_include_adult is False
|
|
|
|
def test_load_config_with_enrich_alias(self, tmp_path):
|
|
"""Test loading enrichment settings from `enrich` alias."""
|
|
config_file = tmp_path / "config.yaml"
|
|
config_file.write_text("""
|
|
library_root: /test/library
|
|
enrich:
|
|
enabled: false
|
|
providers: [tmdb]
|
|
api_keys:
|
|
tmdb_bearer: alias-bearer-token
|
|
tmdb:
|
|
language: en-US
|
|
""")
|
|
|
|
config = load_config(config_file)
|
|
|
|
assert config.enrichment_enabled is False
|
|
assert config.enrichment_providers == ["tmdb"]
|
|
assert config.tmdb_bearer_token == "alias-bearer-token"
|
|
assert config.tmdb_language == "en-US"
|
|
|
|
|
|
class TestCreateDefaultConfig:
|
|
"""Test create_default_config function."""
|
|
|
|
def test_create_default_config(self, tmp_path):
|
|
"""Test creating default configuration file."""
|
|
config_file = tmp_path / "config.yaml"
|
|
|
|
config = create_default_config(config_file)
|
|
|
|
# Check returned config object
|
|
assert config.library_root == Path.home() / "Videos"
|
|
assert ".mp4" in config.video_extensions
|
|
assert ".mkv" in config.video_extensions
|
|
assert config.movie_template == "movie/{title} ({year})/"
|
|
assert config.series_template == "series/{title}/Season {season:02d}/"
|
|
assert config.log_level == "INFO"
|
|
assert config.quarantine_dir == ".quarantine"
|
|
|
|
# Check file was created
|
|
assert config_file.exists()
|
|
|
|
# Check file content
|
|
with open(config_file, 'r') as f:
|
|
data = yaml.safe_load(f)
|
|
|
|
assert 'library_root' in data
|
|
assert 'video_extensions' in data
|
|
assert 'templates' in data
|
|
assert 'log_level' in data
|
|
assert 'quarantine_dir' in data
|
|
assert 'enrichment' in data
|
|
assert 'enrich' in data
|
|
|
|
def test_create_default_config_creates_parent_dirs(self, tmp_path):
|
|
"""Test that create_default_config creates parent directories."""
|
|
config_file = tmp_path / "subdir" / "config.yaml"
|
|
|
|
config = create_default_config(config_file)
|
|
|
|
assert config_file.exists()
|
|
assert config_file.parent.exists()
|
|
|
|
def test_create_default_config_is_loadable(self, tmp_path):
|
|
"""Test that created default config can be loaded."""
|
|
config_file = tmp_path / "config.yaml"
|
|
|
|
created_config = create_default_config(config_file)
|
|
loaded_config = load_config(config_file)
|
|
|
|
# Configs should be equivalent
|
|
assert loaded_config.library_root == created_config.library_root
|
|
assert loaded_config.video_extensions == created_config.video_extensions
|
|
assert loaded_config.movie_template == created_config.movie_template
|
|
assert loaded_config.log_level == created_config.log_level
|
|
|
|
|
|
class TestValidateConfig:
|
|
"""Test validate_config function."""
|
|
|
|
def test_validate_valid_config(self):
|
|
"""Test validating a valid configuration."""
|
|
config = Config(library_root=Path("/mnt/nas/videos"))
|
|
|
|
errors = validate_config(config)
|
|
|
|
assert errors == []
|
|
|
|
def test_validate_empty_library_root(self):
|
|
"""Test validating config with empty library_root."""
|
|
config = Config(library_root=Path(""))
|
|
|
|
errors = validate_config(config)
|
|
|
|
assert len(errors) > 0
|
|
assert any("library_root" in err for err in errors)
|
|
|
|
def test_validate_empty_video_extensions(self):
|
|
"""Test validating config with empty video_extensions."""
|
|
config = Config(
|
|
library_root=Path("/mnt/nas/videos"),
|
|
video_extensions=[]
|
|
)
|
|
|
|
errors = validate_config(config)
|
|
|
|
assert len(errors) > 0
|
|
assert any("video_extensions" in err for err in errors)
|
|
|
|
def test_validate_invalid_video_extension_format(self):
|
|
"""Test validating config with invalid video extension format."""
|
|
config = Config(
|
|
library_root=Path("/mnt/nas/videos"),
|
|
video_extensions=["mp4", ".mkv"] # Missing dot on first one
|
|
)
|
|
|
|
errors = validate_config(config)
|
|
|
|
assert len(errors) > 0
|
|
assert any("must start with '.'" in err for err in errors)
|
|
|
|
def test_validate_empty_templates(self):
|
|
"""Test validating config with empty templates."""
|
|
config = Config(
|
|
library_root=Path("/mnt/nas/videos"),
|
|
movie_template="",
|
|
series_template=""
|
|
)
|
|
|
|
errors = validate_config(config)
|
|
|
|
assert len(errors) >= 2
|
|
assert any("movie_template" in err for err in errors)
|
|
assert any("series_template" in err for err in errors)
|
|
|
|
def test_validate_invalid_log_level(self):
|
|
"""Test validating config with invalid log level."""
|
|
config = Config(
|
|
library_root=Path("/mnt/nas/videos"),
|
|
log_level="INVALID"
|
|
)
|
|
|
|
errors = validate_config(config)
|
|
|
|
assert len(errors) > 0
|
|
assert any("log_level" in err for err in errors)
|
|
|
|
def test_validate_valid_log_levels(self):
|
|
"""Test validating config with all valid log levels."""
|
|
valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
|
|
|
for level in valid_levels:
|
|
config = Config(
|
|
library_root=Path("/mnt/nas/videos"),
|
|
log_level=level
|
|
)
|
|
errors = validate_config(config)
|
|
assert errors == [], f"Log level {level} should be valid"
|
|
|
|
def test_validate_absolute_quarantine_dir(self):
|
|
"""Test validating config with absolute quarantine_dir."""
|
|
config = Config(
|
|
library_root=Path("/mnt/nas/videos"),
|
|
quarantine_dir="/absolute/path"
|
|
)
|
|
|
|
errors = validate_config(config)
|
|
|
|
assert len(errors) > 0
|
|
assert any("must be relative" in err for err in errors)
|
|
|
|
def test_validate_empty_quarantine_dir(self):
|
|
"""Test validating config with empty quarantine_dir."""
|
|
config = Config(
|
|
library_root=Path("/mnt/nas/videos"),
|
|
quarantine_dir=""
|
|
)
|
|
|
|
errors = validate_config(config)
|
|
|
|
assert len(errors) > 0
|
|
assert any("quarantine_dir" in err for err in errors)
|
|
|
|
def test_validate_multiple_errors(self):
|
|
"""Test validating config with multiple errors."""
|
|
config = Config(
|
|
library_root=Path(""),
|
|
video_extensions=[],
|
|
movie_template="",
|
|
log_level="INVALID"
|
|
)
|
|
|
|
errors = validate_config(config)
|
|
|
|
# Should have multiple errors
|
|
assert len(errors) >= 4
|
|
|
|
def test_validate_empty_categories(self):
|
|
"""Test validating config with empty categories."""
|
|
config = Config(library_root=Path("/test"), categories={})
|
|
errors = validate_config(config)
|
|
assert any("categories" in e and "empty" in e for e in errors)
|
|
|
|
def test_validate_missing_required_category(self):
|
|
"""Test validating config with missing required categories."""
|
|
config = Config(
|
|
library_root=Path("/test"),
|
|
categories={"movie": ["movie"]} # Missing series, anime
|
|
)
|
|
errors = validate_config(config)
|
|
assert any("series" in e or "anime" in e for e in errors)
|
|
|
|
def test_validate_duplicate_directory_names(self):
|
|
"""Test validating config with duplicate directory names."""
|
|
config = Config(
|
|
library_root=Path("/test"),
|
|
categories={
|
|
"movie": ["movie", "videos"],
|
|
"series": ["series", "videos"], # Duplicate
|
|
"anime": ["anime"]
|
|
}
|
|
)
|
|
errors = validate_config(config)
|
|
assert any("Duplicate" in e and "videos" in e for e in errors)
|
|
|
|
def test_validate_case_insensitive_duplicates(self):
|
|
"""Test validating config with case-insensitive duplicates."""
|
|
config = Config(
|
|
library_root=Path("/test"),
|
|
categories={
|
|
"movie": ["Movie"],
|
|
"series": ["movie"], # Case-insensitive duplicate
|
|
"anime": ["anime"]
|
|
}
|
|
)
|
|
errors = validate_config(config)
|
|
assert any("Duplicate" in e for e in errors)
|
|
|
|
def test_validate_valid_custom_categories(self):
|
|
"""Test validating config with valid custom categories."""
|
|
config = Config(
|
|
library_root=Path("/test"),
|
|
categories={
|
|
"movie": ["movie", "movies"],
|
|
"series": ["series", "tv"],
|
|
"anime": ["anime"]
|
|
}
|
|
)
|
|
errors = validate_config(config)
|
|
assert errors == []
|
|
|
|
def test_validate_categories_not_dict(self):
|
|
"""Test validating config with categories not a dict."""
|
|
config = Config(
|
|
library_root=Path("/test"),
|
|
categories=["movie", "series"] # Wrong type
|
|
)
|
|
errors = validate_config(config)
|
|
assert any("must be a dictionary" in e for e in errors)
|
|
|
|
def test_validate_category_list_not_list(self):
|
|
"""Test validating config with category value not a list."""
|
|
config = Config(
|
|
library_root=Path("/test"),
|
|
categories={
|
|
"movie": "movie", # Should be a list
|
|
"series": ["series"],
|
|
"anime": ["anime"]
|
|
}
|
|
)
|
|
errors = validate_config(config)
|
|
assert any("must be a list" in e for e in errors)
|
|
|
|
def test_validate_empty_category_list(self):
|
|
"""Test validating config with empty category list."""
|
|
config = Config(
|
|
library_root=Path("/test"),
|
|
categories={
|
|
"movie": [], # Empty list
|
|
"series": ["series"],
|
|
"anime": ["anime"]
|
|
}
|
|
)
|
|
errors = validate_config(config)
|
|
assert any("cannot be empty" in e for e in errors)
|
|
|
|
def test_validate_rejects_unsupported_enrichment_provider(self):
|
|
"""Test validating config with unsupported enrichment provider."""
|
|
config = Config(
|
|
library_root=Path("/test"),
|
|
enrichment_providers=["tmdb", "douban"],
|
|
)
|
|
errors = validate_config(config)
|
|
assert any("unsupported providers" in e for e in errors)
|
|
|
|
def test_validate_category_list_with_non_string(self):
|
|
"""Test validating config with non-string in category list."""
|
|
config = Config(
|
|
library_root=Path("/test"),
|
|
categories={
|
|
"movie": ["movie", 123], # Non-string
|
|
"series": ["series"],
|
|
"anime": ["anime"]
|
|
}
|
|
)
|
|
errors = validate_config(config)
|
|
assert any("must contain strings" in e for e in errors)
|
|
|
|
def test_validate_category_list_with_empty_string(self):
|
|
"""Test validating config with empty string in category list."""
|
|
config = Config(
|
|
library_root=Path("/test"),
|
|
categories={
|
|
"movie": ["movie", ""], # Empty string
|
|
"series": ["series"],
|
|
"anime": ["anime"]
|
|
}
|
|
)
|
|
errors = validate_config(config)
|
|
assert any("empty directory name" in e for e in errors)
|
|
|
|
|
|
class TestConfigIntegration:
|
|
"""Integration tests for configuration workflow."""
|
|
|
|
def test_missing_config_workflow(self, tmp_path):
|
|
"""Test workflow: missing config -> create default -> load."""
|
|
config_file = tmp_path / "config.yaml"
|
|
|
|
# Config doesn't exist
|
|
assert not config_file.exists()
|
|
|
|
# Try to load, should raise FileNotFoundError
|
|
with pytest.raises(FileNotFoundError):
|
|
load_config(config_file)
|
|
|
|
# Create default config
|
|
default_config = create_default_config(config_file)
|
|
|
|
# Now file exists
|
|
assert config_file.exists()
|
|
|
|
# Load the created config
|
|
loaded_config = load_config(config_file)
|
|
|
|
# Should match default
|
|
assert loaded_config.library_root == default_config.library_root
|
|
assert loaded_config.video_extensions == default_config.video_extensions
|
|
|
|
def test_invalid_yaml_workflow(self, tmp_path):
|
|
"""Test workflow: invalid YAML -> report error -> use defaults."""
|
|
config_file = tmp_path / "config.yaml"
|
|
|
|
# Create invalid YAML
|
|
with open(config_file, 'w') as f:
|
|
f.write("invalid: yaml: [unclosed")
|
|
|
|
# Try to load, should raise YAMLError
|
|
with pytest.raises(yaml.YAMLError):
|
|
load_config(config_file)
|
|
|
|
# In real usage, caller would catch this and create default
|
|
default_config = create_default_config(config_file)
|
|
|
|
# Now should be loadable
|
|
loaded_config = load_config(config_file)
|
|
assert loaded_config.library_root == default_config.library_root
|
|
|
|
def test_validation_workflow(self, tmp_path):
|
|
"""Test workflow: load config -> validate -> report errors."""
|
|
config_file = tmp_path / "config.yaml"
|
|
|
|
# Create config with some invalid values
|
|
config_data = {
|
|
'library_root': '/mnt/nas/videos',
|
|
'video_extensions': ['mp4', '.mkv'], # First one missing dot
|
|
'log_level': 'INVALID'
|
|
}
|
|
|
|
with open(config_file, 'w') as f:
|
|
yaml.dump(config_data, f)
|
|
|
|
# Load config
|
|
config = load_config(config_file)
|
|
|
|
# Validate
|
|
errors = validate_config(config)
|
|
|
|
# Should have errors
|
|
assert len(errors) > 0
|
|
assert any("must start with '.'" in err for err in errors)
|
|
assert any("log_level" in err for err in errors)
|