2026-02-09 17:43:35 +08:00
|
|
|
"""Unit tests for Configuration Manager."""
|
|
|
|
|
|
2026-06-01 15:32:03 +08:00
|
|
|
from pathlib import Path
|
|
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
import pytest
|
|
|
|
|
import yaml
|
2026-09-27 10:47:04 +08:00
|
|
|
from pydantic import ValidationError
|
2026-06-01 15:32:03 +08:00
|
|
|
|
|
|
|
|
from vlm.config import Config, create_default_config, load_config, validate_config
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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"
|
2026-02-16 13:23:01 +08:00
|
|
|
assert config.workspace_dir == Path("artifacts")
|
2026-02-10 08:29:31 +08:00
|
|
|
assert config.enrichment_providers == ["tmdb"]
|
2026-02-13 13:36:39 +08:00
|
|
|
assert config.plan_max_season == 15
|
|
|
|
|
assert config.plan_max_episode == 100
|
|
|
|
|
assert config.plan_include_sample_files is False
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
)
|
2026-02-09 22:40:48 +08:00
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
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"
|
|
|
|
|
|
2026-02-09 22:40:48 +08:00
|
|
|
def test_config_default_categories(self):
|
|
|
|
|
"""Test Config has default categories."""
|
|
|
|
|
config = Config(library_root=Path("/test"))
|
|
|
|
|
|
|
|
|
|
assert config.categories == {
|
2026-02-09 23:55:13 +08:00
|
|
|
"movie": ["movie", "movies"],
|
|
|
|
|
"series": ["series", "tv", "shows"],
|
2026-02-09 22:40:48 +08:00
|
|
|
"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"]
|
|
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
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',
|
2026-02-16 13:23:01 +08:00
|
|
|
'workspace_dir': 'artifacts',
|
2026-02-13 13:36:39 +08:00
|
|
|
'log_level': 'DEBUG',
|
|
|
|
|
'plan': {
|
|
|
|
|
'duplicate_keep': 'by_quality',
|
|
|
|
|
'max_season': 12,
|
|
|
|
|
'max_episode': 80,
|
|
|
|
|
'include_sample_files': True,
|
|
|
|
|
}
|
2026-02-09 17:43:35 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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'
|
2026-02-16 13:23:01 +08:00
|
|
|
assert config.workspace_dir == Path('artifacts')
|
2026-02-13 13:36:39 +08:00
|
|
|
assert config.duplicate_keep == 'by_quality'
|
|
|
|
|
assert config.plan_max_season == 12
|
|
|
|
|
assert config.plan_max_episode == 80
|
|
|
|
|
assert config.plan_include_sample_files is True
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
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"
|
2026-02-13 13:36:39 +08:00
|
|
|
|
|
|
|
|
def test_load_config_with_reputation_quality_time_strategy(self, tmp_path):
|
|
|
|
|
"""Test loading config with by_reputation_quality_time strategy."""
|
|
|
|
|
config_file = tmp_path / "config.yaml"
|
|
|
|
|
config_data = {
|
|
|
|
|
'library_root': '/mnt/nas/videos',
|
|
|
|
|
'plan': {
|
|
|
|
|
'duplicate_keep': 'by_reputation_quality_time'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
with open(config_file, 'w') as f:
|
|
|
|
|
yaml.dump(config_data, f)
|
|
|
|
|
|
|
|
|
|
config = load_config(config_file)
|
|
|
|
|
assert config.duplicate_keep == 'by_reputation_quality_time'
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
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'
|
|
|
|
|
}
|
2026-02-09 22:40:48 +08:00
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
with open(config_file, 'w') as f:
|
|
|
|
|
yaml.dump(config_data, f)
|
2026-02-09 22:40:48 +08:00
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
config = load_config(config_file)
|
2026-02-09 22:40:48 +08:00
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
# 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"
|
2026-02-16 13:23:01 +08:00
|
|
|
assert config.workspace_dir == Path("artifacts")
|
|
|
|
|
|
|
|
|
|
def test_load_config_with_workspace_dir(self, tmp_path):
|
|
|
|
|
"""Test loading config with explicit workspace_dir."""
|
|
|
|
|
config_file = tmp_path / "config.yaml"
|
|
|
|
|
config_data = {
|
|
|
|
|
'library_root': '/mnt/nas/videos',
|
|
|
|
|
'workspace_dir': 'work/artifacts'
|
|
|
|
|
}
|
|
|
|
|
with open(config_file, 'w') as f:
|
|
|
|
|
yaml.dump(config_data, f)
|
|
|
|
|
|
|
|
|
|
config = load_config(config_file)
|
|
|
|
|
assert config.workspace_dir == Path('work/artifacts')
|
2026-02-09 17:43:35 +08:00
|
|
|
|
2026-02-09 22:40:48 +08:00
|
|
|
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"]
|
|
|
|
|
|
2026-02-10 16:56:17 +08:00
|
|
|
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"
|
|
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
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
|
2026-02-16 13:23:01 +08:00
|
|
|
assert 'workspace_dir' in data
|
2026-02-10 16:56:17 +08:00
|
|
|
assert 'enrichment' in data
|
|
|
|
|
assert 'enrich' in data
|
2026-02-13 13:36:39 +08:00
|
|
|
assert 'plan' in data
|
|
|
|
|
assert 'max_season' in data['plan']
|
|
|
|
|
assert 'max_episode' in data['plan']
|
|
|
|
|
assert 'include_sample_files' in data['plan']
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
2026-06-01 15:32:03 +08:00
|
|
|
create_default_config(config_file)
|
|
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
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
|
2026-02-16 13:23:01 +08:00
|
|
|
assert loaded_config.workspace_dir == created_config.workspace_dir
|
2026-02-09 17:43:35 +08:00
|
|
|
assert loaded_config.movie_template == created_config.movie_template
|
|
|
|
|
assert loaded_config.log_level == created_config.log_level
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestValidateConfig:
|
2026-09-27 10:47:04 +08:00
|
|
|
"""Test validation — with Pydantic, invalid values raise ValidationError at construction."""
|
|
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
def test_validate_valid_config(self):
|
|
|
|
|
config = Config(library_root=Path("/mnt/nas/videos"))
|
2026-09-27 10:47:04 +08:00
|
|
|
assert validate_config(config) == []
|
|
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
def test_validate_empty_library_root(self):
|
2026-09-27 10:47:04 +08:00
|
|
|
with pytest.raises(ValidationError, match="library_root"):
|
|
|
|
|
Config(library_root=Path(""))
|
|
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
def test_validate_empty_video_extensions(self):
|
2026-09-27 10:47:04 +08:00
|
|
|
with pytest.raises(ValidationError, match="video_extensions"):
|
|
|
|
|
Config(library_root=Path("/mnt/nas/videos"), video_extensions=[])
|
|
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
def test_validate_invalid_video_extension_format(self):
|
2026-09-27 10:47:04 +08:00
|
|
|
with pytest.raises(ValidationError, match="must start with"):
|
|
|
|
|
Config(
|
2026-02-09 17:43:35 +08:00
|
|
|
library_root=Path("/mnt/nas/videos"),
|
2026-09-27 10:47:04 +08:00
|
|
|
video_extensions=["mp4", ".mkv"],
|
2026-02-09 17:43:35 +08:00
|
|
|
)
|
2026-09-27 10:47:04 +08:00
|
|
|
|
|
|
|
|
def test_validate_empty_templates(self):
|
|
|
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
|
|
|
Config(
|
|
|
|
|
library_root=Path("/mnt/nas/videos"),
|
|
|
|
|
movie_template="",
|
|
|
|
|
series_template="",
|
|
|
|
|
)
|
|
|
|
|
errors = exc_info.value.errors()
|
|
|
|
|
fields = {e["loc"][0] for e in errors}
|
|
|
|
|
assert "movie_template" in fields
|
|
|
|
|
assert "series_template" in fields
|
|
|
|
|
|
|
|
|
|
def test_validate_invalid_log_level(self):
|
|
|
|
|
with pytest.raises(ValidationError, match="log_level"):
|
|
|
|
|
Config(library_root=Path("/mnt/nas/videos"), log_level="INVALID")
|
|
|
|
|
|
|
|
|
|
def test_validate_valid_log_levels(self):
|
|
|
|
|
for level in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
|
|
|
|
|
config = Config(library_root=Path("/mnt/nas/videos"), log_level=level)
|
|
|
|
|
assert validate_config(config) == [], f"Log level {level} should be valid"
|
|
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
def test_validate_absolute_quarantine_dir(self):
|
2026-09-27 10:47:04 +08:00
|
|
|
with pytest.raises(ValidationError, match="must be relative"):
|
|
|
|
|
Config(
|
|
|
|
|
library_root=Path("/mnt/nas/videos"),
|
|
|
|
|
quarantine_dir="/absolute/path",
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
def test_validate_empty_quarantine_dir(self):
|
2026-09-27 10:47:04 +08:00
|
|
|
with pytest.raises(ValidationError, match="quarantine_dir"):
|
|
|
|
|
Config(library_root=Path("/mnt/nas/videos"), quarantine_dir="")
|
2026-02-16 13:23:01 +08:00
|
|
|
|
2026-09-27 10:47:04 +08:00
|
|
|
def test_workspace_dir_coerces_from_string(self):
|
2026-02-16 13:23:01 +08:00
|
|
|
config = Config(
|
|
|
|
|
library_root=Path("/mnt/nas/videos"),
|
2026-09-27 10:47:04 +08:00
|
|
|
workspace_dir="artifacts",
|
2026-02-16 13:23:01 +08:00
|
|
|
)
|
2026-09-27 10:47:04 +08:00
|
|
|
assert config.workspace_dir == Path("artifacts")
|
|
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
def test_validate_multiple_errors(self):
|
2026-09-27 10:47:04 +08:00
|
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
|
|
|
Config(
|
|
|
|
|
library_root=Path(""),
|
|
|
|
|
video_extensions=[],
|
|
|
|
|
movie_template="",
|
|
|
|
|
log_level="INVALID",
|
|
|
|
|
)
|
|
|
|
|
assert len(exc_info.value.errors()) >= 4
|
2026-02-09 17:43:35 +08:00
|
|
|
|
2026-02-13 13:36:39 +08:00
|
|
|
def test_validate_duplicate_keep_reputation_quality_time(self):
|
|
|
|
|
config = Config(
|
|
|
|
|
library_root=Path("/mnt/nas/videos"),
|
2026-09-27 10:47:04 +08:00
|
|
|
duplicate_keep="by_reputation_quality_time",
|
2026-02-13 13:36:39 +08:00
|
|
|
)
|
2026-09-27 10:47:04 +08:00
|
|
|
assert validate_config(config) == []
|
2026-02-13 13:36:39 +08:00
|
|
|
|
2026-02-09 22:40:48 +08:00
|
|
|
def test_validate_empty_categories(self):
|
2026-09-27 10:47:04 +08:00
|
|
|
with pytest.raises(ValidationError, match="categories"):
|
|
|
|
|
Config(library_root=Path("/test"), categories={})
|
2026-02-09 22:40:48 +08:00
|
|
|
|
|
|
|
|
def test_validate_missing_required_category(self):
|
2026-09-27 10:47:04 +08:00
|
|
|
with pytest.raises(ValidationError, match="categories"):
|
|
|
|
|
Config(
|
|
|
|
|
library_root=Path("/test"),
|
|
|
|
|
categories={"movie": ["movie"]},
|
|
|
|
|
)
|
2026-02-09 22:40:48 +08:00
|
|
|
|
|
|
|
|
def test_validate_duplicate_directory_names(self):
|
2026-09-27 10:47:04 +08:00
|
|
|
with pytest.raises(ValidationError, match="Duplicate.*videos"):
|
|
|
|
|
Config(
|
|
|
|
|
library_root=Path("/test"),
|
|
|
|
|
categories={
|
|
|
|
|
"movie": ["movie", "videos"],
|
|
|
|
|
"series": ["series", "videos"],
|
|
|
|
|
"anime": ["anime"],
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-02-09 22:40:48 +08:00
|
|
|
|
|
|
|
|
def test_validate_case_insensitive_duplicates(self):
|
2026-09-27 10:47:04 +08:00
|
|
|
with pytest.raises(ValidationError, match="Duplicate"):
|
|
|
|
|
Config(
|
|
|
|
|
library_root=Path("/test"),
|
|
|
|
|
categories={
|
|
|
|
|
"movie": ["Movie"],
|
|
|
|
|
"series": ["movie"],
|
|
|
|
|
"anime": ["anime"],
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-02-09 22:40:48 +08:00
|
|
|
|
|
|
|
|
def test_validate_valid_custom_categories(self):
|
|
|
|
|
config = Config(
|
|
|
|
|
library_root=Path("/test"),
|
|
|
|
|
categories={
|
|
|
|
|
"movie": ["movie", "movies"],
|
|
|
|
|
"series": ["series", "tv"],
|
2026-09-27 10:47:04 +08:00
|
|
|
"anime": ["anime"],
|
|
|
|
|
},
|
2026-02-09 22:40:48 +08:00
|
|
|
)
|
2026-09-27 10:47:04 +08:00
|
|
|
assert validate_config(config) == []
|
2026-02-09 22:40:48 +08:00
|
|
|
|
|
|
|
|
def test_validate_categories_not_dict(self):
|
2026-09-27 10:47:04 +08:00
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
Config(
|
|
|
|
|
library_root=Path("/test"),
|
|
|
|
|
categories=["movie", "series"],
|
|
|
|
|
)
|
2026-02-09 22:40:48 +08:00
|
|
|
|
|
|
|
|
def test_validate_category_list_not_list(self):
|
2026-09-27 10:47:04 +08:00
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
Config(
|
|
|
|
|
library_root=Path("/test"),
|
|
|
|
|
categories={
|
|
|
|
|
"movie": "movie",
|
|
|
|
|
"series": ["series"],
|
|
|
|
|
"anime": ["anime"],
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-02-09 22:40:48 +08:00
|
|
|
|
|
|
|
|
def test_validate_empty_category_list(self):
|
2026-09-27 10:47:04 +08:00
|
|
|
with pytest.raises(ValidationError, match="cannot be empty"):
|
|
|
|
|
Config(
|
|
|
|
|
library_root=Path("/test"),
|
|
|
|
|
categories={
|
|
|
|
|
"movie": [],
|
|
|
|
|
"series": ["series"],
|
|
|
|
|
"anime": ["anime"],
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-02-09 22:40:48 +08:00
|
|
|
|
2026-02-10 08:29:31 +08:00
|
|
|
def test_validate_rejects_unsupported_enrichment_provider(self):
|
2026-09-27 10:47:04 +08:00
|
|
|
with pytest.raises(ValidationError, match="unsupported providers"):
|
|
|
|
|
Config(
|
|
|
|
|
library_root=Path("/test"),
|
|
|
|
|
enrichment_providers=["tmdb", "douban"],
|
|
|
|
|
)
|
2026-02-10 08:29:31 +08:00
|
|
|
|
2026-02-09 22:40:48 +08:00
|
|
|
def test_validate_category_list_with_non_string(self):
|
2026-09-27 10:47:04 +08:00
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
Config(
|
|
|
|
|
library_root=Path("/test"),
|
|
|
|
|
categories={
|
|
|
|
|
"movie": ["movie", 123],
|
|
|
|
|
"series": ["series"],
|
|
|
|
|
"anime": ["anime"],
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-02-09 22:40:48 +08:00
|
|
|
|
|
|
|
|
def test_validate_category_list_with_empty_string(self):
|
2026-09-27 10:47:04 +08:00
|
|
|
with pytest.raises(ValidationError, match="empty directory name"):
|
|
|
|
|
Config(
|
|
|
|
|
library_root=Path("/test"),
|
|
|
|
|
categories={
|
|
|
|
|
"movie": ["movie", ""],
|
|
|
|
|
"series": ["series"],
|
|
|
|
|
"anime": ["anime"],
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-02-09 22:40:48 +08:00
|
|
|
|
2026-02-13 13:36:39 +08:00
|
|
|
def test_validate_invalid_plan_thresholds(self):
|
2026-09-27 10:47:04 +08:00
|
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
|
|
|
Config(
|
|
|
|
|
library_root=Path("/test"),
|
|
|
|
|
plan_max_season=0,
|
|
|
|
|
plan_max_episode=-1,
|
|
|
|
|
)
|
|
|
|
|
fields = {e["loc"][0] for e in exc_info.value.errors()}
|
|
|
|
|
assert "plan_max_season" in fields
|
|
|
|
|
assert "plan_max_episode" in fields
|
|
|
|
|
|
|
|
|
|
def test_validate_config_with_model_construct_bypass(self):
|
|
|
|
|
"""validate_config catches errors bypassed via model_construct."""
|
|
|
|
|
config = Config.model_construct(
|
2026-02-13 13:36:39 +08:00
|
|
|
library_root=Path("/test"),
|
2026-09-27 10:47:04 +08:00
|
|
|
video_extensions=[],
|
|
|
|
|
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",
|
|
|
|
|
workspace_dir=Path("artifacts"),
|
|
|
|
|
categories={"movie": ["movie"], "series": ["series"], "anime": ["anime"]},
|
|
|
|
|
enrichment_enabled=True,
|
|
|
|
|
enrichment_incremental=True,
|
|
|
|
|
enrichment_refresh_mode="manual",
|
|
|
|
|
enrichment_providers=["tmdb"],
|
|
|
|
|
enrichment_cache_db=Path.home() / ".vlm" / "enrichment_cache.db",
|
|
|
|
|
enrichment_max_concurrency=6,
|
|
|
|
|
enrichment_min_match_score=0.75,
|
|
|
|
|
translation_mode="bidirectional",
|
|
|
|
|
translation_fallback_machine=True,
|
|
|
|
|
tmdb_api_key=None,
|
|
|
|
|
tmdb_bearer_token=None,
|
|
|
|
|
tmdb_language="zh-CN",
|
|
|
|
|
tmdb_region=None,
|
|
|
|
|
tmdb_include_adult=False,
|
|
|
|
|
openai_api_key=None,
|
|
|
|
|
reputation_min_votes=50,
|
|
|
|
|
reputation_low_score_threshold=6.0,
|
|
|
|
|
reputation_policy="flag_for_review",
|
|
|
|
|
naming_title_format="{title_zh} {title_en}",
|
|
|
|
|
duplicate_keep="by_reputation",
|
|
|
|
|
plan_max_season=15,
|
|
|
|
|
plan_max_episode=100,
|
|
|
|
|
plan_include_sample_files=False,
|
2026-02-13 13:36:39 +08:00
|
|
|
)
|
|
|
|
|
errors = validate_config(config)
|
2026-09-27 10:47:04 +08:00
|
|
|
assert any("video_extensions" in e for e in errors)
|
2026-02-13 13:36:39 +08:00
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
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):
|
2026-09-27 10:47:04 +08:00
|
|
|
"""Test workflow: load config with invalid values raises ValidationError."""
|
2026-02-09 17:43:35 +08:00
|
|
|
config_file = tmp_path / "config.yaml"
|
2026-09-27 10:47:04 +08:00
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
config_data = {
|
|
|
|
|
'library_root': '/mnt/nas/videos',
|
|
|
|
|
'video_extensions': ['mp4', '.mkv'], # First one missing dot
|
|
|
|
|
'log_level': 'INVALID'
|
|
|
|
|
}
|
2026-09-27 10:47:04 +08:00
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
with open(config_file, 'w') as f:
|
|
|
|
|
yaml.dump(config_data, f)
|
2026-09-27 10:47:04 +08:00
|
|
|
|
|
|
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
|
|
|
load_config(config_file)
|
|
|
|
|
|
|
|
|
|
messages = [e["msg"] for e in exc_info.value.errors()]
|
|
|
|
|
assert any("must start with" in m for m in messages)
|
|
|
|
|
assert any("log_level" in m for m in messages)
|