"""Unit tests for Configuration Manager.""" from pathlib import Path import pytest import yaml from pydantic import ValidationError from vlm.config import Config, create_default_config, load_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.workspace_dir == Path("artifacts") assert config.enrichment_providers == ["tmdb"] assert config.plan_max_season == 15 assert config.plan_max_episode == 100 assert config.plan_include_sample_files is False 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', 'workspace_dir': 'artifacts', 'log_level': 'DEBUG', 'plan': { 'duplicate_keep': 'by_quality', 'max_season': 12, 'max_episode': 80, 'include_sample_files': True, } } 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' assert config.workspace_dir == Path('artifacts') 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 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_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' 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" 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') 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 'workspace_dir' in data assert 'enrichment' in data assert 'enrich' in data assert 'plan' in data assert 'max_season' in data['plan'] assert 'max_episode' in data['plan'] assert 'include_sample_files' in data['plan'] 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" 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.workspace_dir == created_config.workspace_dir assert loaded_config.movie_template == created_config.movie_template assert loaded_config.log_level == created_config.log_level class TestValidateConfig: """Test validation — with Pydantic, invalid values raise ValidationError at construction.""" def test_validate_valid_config(self): config = Config(library_root=Path("/mnt/nas/videos")) assert validate_config(config) == [] def test_validate_empty_library_root(self): with pytest.raises(ValidationError, match="library_root"): Config(library_root=Path("")) def test_validate_empty_video_extensions(self): with pytest.raises(ValidationError, match="video_extensions"): Config(library_root=Path("/mnt/nas/videos"), video_extensions=[]) def test_validate_invalid_video_extension_format(self): with pytest.raises(ValidationError, match="must start with"): Config( library_root=Path("/mnt/nas/videos"), video_extensions=["mp4", ".mkv"], ) 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" def test_validate_absolute_quarantine_dir(self): with pytest.raises(ValidationError, match="must be relative"): Config( library_root=Path("/mnt/nas/videos"), quarantine_dir="/absolute/path", ) def test_validate_empty_quarantine_dir(self): with pytest.raises(ValidationError, match="quarantine_dir"): Config(library_root=Path("/mnt/nas/videos"), quarantine_dir="") def test_workspace_dir_coerces_from_string(self): config = Config( library_root=Path("/mnt/nas/videos"), workspace_dir="artifacts", ) assert config.workspace_dir == Path("artifacts") def test_validate_multiple_errors(self): 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 def test_validate_duplicate_keep_reputation_quality_time(self): config = Config( library_root=Path("/mnt/nas/videos"), duplicate_keep="by_reputation_quality_time", ) assert validate_config(config) == [] def test_validate_empty_categories(self): with pytest.raises(ValidationError, match="categories"): Config(library_root=Path("/test"), categories={}) def test_validate_missing_required_category(self): with pytest.raises(ValidationError, match="categories"): Config( library_root=Path("/test"), categories={"movie": ["movie"]}, ) def test_validate_duplicate_directory_names(self): with pytest.raises(ValidationError, match="Duplicate.*videos"): Config( library_root=Path("/test"), categories={ "movie": ["movie", "videos"], "series": ["series", "videos"], "anime": ["anime"], }, ) def test_validate_case_insensitive_duplicates(self): with pytest.raises(ValidationError, match="Duplicate"): Config( library_root=Path("/test"), categories={ "movie": ["Movie"], "series": ["movie"], "anime": ["anime"], }, ) def test_validate_valid_custom_categories(self): config = Config( library_root=Path("/test"), categories={ "movie": ["movie", "movies"], "series": ["series", "tv"], "anime": ["anime"], }, ) assert validate_config(config) == [] def test_validate_categories_not_dict(self): with pytest.raises(ValidationError): Config( library_root=Path("/test"), categories=["movie", "series"], ) def test_validate_category_list_not_list(self): with pytest.raises(ValidationError): Config( library_root=Path("/test"), categories={ "movie": "movie", "series": ["series"], "anime": ["anime"], }, ) def test_validate_empty_category_list(self): with pytest.raises(ValidationError, match="cannot be empty"): Config( library_root=Path("/test"), categories={ "movie": [], "series": ["series"], "anime": ["anime"], }, ) def test_validate_rejects_unsupported_enrichment_provider(self): with pytest.raises(ValidationError, match="unsupported providers"): Config( library_root=Path("/test"), enrichment_providers=["tmdb", "douban"], ) def test_validate_category_list_with_non_string(self): with pytest.raises(ValidationError): Config( library_root=Path("/test"), categories={ "movie": ["movie", 123], "series": ["series"], "anime": ["anime"], }, ) def test_validate_category_list_with_empty_string(self): with pytest.raises(ValidationError, match="empty directory name"): Config( library_root=Path("/test"), categories={ "movie": ["movie", ""], "series": ["series"], "anime": ["anime"], }, ) def test_validate_invalid_plan_thresholds(self): 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( library_root=Path("/test"), 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, ) errors = validate_config(config) assert any("video_extensions" 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 with invalid values raises ValidationError.""" config_file = tmp_path / "config.yaml" 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) 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)