2026-02-09 17:43:35 +08:00
|
|
|
"""Tests for the identity parser module."""
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from vlm.parser import (
|
|
|
|
|
parse_movie,
|
|
|
|
|
parse_series,
|
|
|
|
|
normalize_title,
|
|
|
|
|
remove_quality_tags,
|
|
|
|
|
remove_release_groups,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestMovieParser:
|
|
|
|
|
"""Tests for movie identity parsing."""
|
|
|
|
|
|
|
|
|
|
def test_parse_movie_with_parentheses_year(self):
|
|
|
|
|
"""Test parsing movie with year in parentheses."""
|
|
|
|
|
result = parse_movie("The Matrix (1999).mkv")
|
|
|
|
|
assert result.title == "The Matrix"
|
|
|
|
|
assert result.year == 1999
|
|
|
|
|
assert result.confidence == 0.9
|
|
|
|
|
assert result.needs_review is False
|
|
|
|
|
assert result.original_filename == "The Matrix (1999).mkv"
|
|
|
|
|
|
|
|
|
|
def test_parse_movie_with_dot_year(self):
|
|
|
|
|
"""Test parsing movie with dot-separated year."""
|
|
|
|
|
result = parse_movie("Inception.2010.1080p.BluRay.mkv")
|
|
|
|
|
assert result.title == "Inception"
|
|
|
|
|
assert result.year == 2010
|
|
|
|
|
assert result.confidence == 0.9
|
|
|
|
|
assert result.needs_review is False
|
|
|
|
|
|
|
|
|
|
def test_parse_movie_with_dash_year(self):
|
|
|
|
|
"""Test parsing movie with dash-separated year."""
|
|
|
|
|
result = parse_movie("The Godfather - 1972.mp4")
|
|
|
|
|
assert result.title == "The Godfather"
|
|
|
|
|
assert result.year == 1972
|
|
|
|
|
assert result.confidence == 0.7
|
|
|
|
|
assert result.needs_review is False
|
|
|
|
|
|
|
|
|
|
def test_parse_movie_with_space_year(self):
|
|
|
|
|
"""Test parsing movie with space-separated year."""
|
|
|
|
|
result = parse_movie("Pulp Fiction 1994.avi")
|
|
|
|
|
assert result.title == "Pulp Fiction"
|
|
|
|
|
assert result.year == 1994
|
|
|
|
|
assert result.confidence == 0.7
|
|
|
|
|
assert result.needs_review is False
|
|
|
|
|
|
|
|
|
|
def test_parse_movie_with_quality_tags(self):
|
|
|
|
|
"""Test parsing movie with quality tags removed."""
|
|
|
|
|
result = parse_movie("Interstellar.2014.1080p.BluRay.x264.mkv")
|
|
|
|
|
assert result.title == "Interstellar"
|
|
|
|
|
assert result.year == 2014
|
|
|
|
|
assert "1080p" not in result.title
|
|
|
|
|
assert "BluRay" not in result.title
|
|
|
|
|
assert "x264" not in result.title
|
|
|
|
|
|
|
|
|
|
def test_parse_movie_with_release_group(self):
|
|
|
|
|
"""Test parsing movie with release group tags removed."""
|
|
|
|
|
result = parse_movie("The Shawshank Redemption (1994) [RARBG].mkv")
|
|
|
|
|
assert result.title == "The Shawshank Redemption"
|
|
|
|
|
assert result.year == 1994
|
|
|
|
|
assert "RARBG" not in result.title
|
|
|
|
|
|
|
|
|
|
def test_parse_movie_with_yts_release_group(self):
|
|
|
|
|
"""Test parsing movie with YTS release group."""
|
|
|
|
|
result = parse_movie("Fight Club (1999) (YTS).mp4")
|
|
|
|
|
assert result.title == "Fight Club"
|
|
|
|
|
assert result.year == 1999
|
|
|
|
|
# YTS should be removed
|
|
|
|
|
|
|
|
|
|
def test_parse_movie_without_year(self):
|
|
|
|
|
"""Test parsing movie without extractable year."""
|
|
|
|
|
result = parse_movie("Some Random Movie.mkv")
|
|
|
|
|
assert result.title == "Some Random Movie"
|
|
|
|
|
assert result.year is None
|
|
|
|
|
assert result.needs_review is True
|
|
|
|
|
assert result.confidence == 0.3
|
|
|
|
|
|
|
|
|
|
def test_parse_movie_with_multiple_quality_tags(self):
|
|
|
|
|
"""Test parsing movie with multiple quality indicators."""
|
|
|
|
|
result = parse_movie("Avatar.2009.2160p.4K.UHD.BluRay.x265.10bit.mkv")
|
|
|
|
|
assert result.title == "Avatar"
|
|
|
|
|
assert result.year == 2009
|
|
|
|
|
assert "2160p" not in result.title
|
|
|
|
|
assert "4K" not in result.title
|
|
|
|
|
assert "UHD" not in result.title
|
|
|
|
|
|
|
|
|
|
def test_parse_movie_with_web_dl(self):
|
|
|
|
|
"""Test parsing movie with WEB-DL tag."""
|
|
|
|
|
result = parse_movie("The Dark Knight (2008) WEB-DL 1080p.mkv")
|
|
|
|
|
assert result.title == "The Dark Knight"
|
|
|
|
|
assert result.year == 2008
|
|
|
|
|
assert "WEB-DL" not in result.title
|
|
|
|
|
|
|
|
|
|
def test_parse_movie_with_hdtv(self):
|
|
|
|
|
"""Test parsing movie with HDTV tag."""
|
|
|
|
|
result = parse_movie("Movie Name 2015 HDTV 720p.avi")
|
|
|
|
|
assert result.title == "Movie Name"
|
|
|
|
|
assert result.year == 2015
|
|
|
|
|
assert "HDTV" not in result.title
|
|
|
|
|
|
|
|
|
|
def test_parse_movie_with_dots_in_title(self):
|
|
|
|
|
"""Test parsing movie with dots in title."""
|
|
|
|
|
result = parse_movie("The.Lord.of.the.Rings.2001.mkv")
|
2026-02-13 13:36:39 +08:00
|
|
|
assert result.title == "The Lord of the Rings"
|
2026-02-09 17:43:35 +08:00
|
|
|
assert result.year == 2001
|
|
|
|
|
|
|
|
|
|
def test_parse_movie_with_underscores(self):
|
|
|
|
|
"""Test parsing movie with underscores in title."""
|
|
|
|
|
result = parse_movie("Star_Wars_Episode_IV (1977).mp4")
|
2026-02-13 13:36:39 +08:00
|
|
|
assert result.title == "Star Wars Episode IV"
|
2026-02-09 17:43:35 +08:00
|
|
|
assert result.year == 1977
|
|
|
|
|
|
|
|
|
|
def test_parse_movie_preserves_original_filename(self):
|
|
|
|
|
"""Test that original filename is preserved."""
|
|
|
|
|
original = "Complex.Movie.Name.2020.1080p.BluRay.x264.[RARBG].mkv"
|
|
|
|
|
result = parse_movie(original)
|
|
|
|
|
assert result.original_filename == original
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestNormalizeTitle:
|
|
|
|
|
"""Tests for title normalization."""
|
|
|
|
|
|
|
|
|
|
def test_normalize_removes_dots(self):
|
|
|
|
|
"""Test that dots are replaced with spaces."""
|
|
|
|
|
assert normalize_title("The.Matrix") == "The Matrix"
|
|
|
|
|
|
|
|
|
|
def test_normalize_removes_underscores(self):
|
|
|
|
|
"""Test that underscores are replaced with spaces."""
|
|
|
|
|
assert normalize_title("Star_Wars") == "Star Wars"
|
|
|
|
|
|
|
|
|
|
def test_normalize_removes_extra_whitespace(self):
|
|
|
|
|
"""Test that extra whitespace is removed."""
|
|
|
|
|
assert normalize_title("The Matrix Reloaded") == "The Matrix Reloaded"
|
|
|
|
|
|
2026-02-13 13:36:39 +08:00
|
|
|
def test_normalize_preserves_case(self):
|
|
|
|
|
"""Test that normalization preserves original letter case."""
|
|
|
|
|
assert normalize_title("the matrix") == "the matrix"
|
|
|
|
|
assert normalize_title("THE MATRIX") == "THE MATRIX"
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
def test_normalize_idempotence(self):
|
|
|
|
|
"""Test that normalizing multiple times produces same result."""
|
|
|
|
|
title = "The.Matrix.Reloaded"
|
|
|
|
|
normalized_once = normalize_title(title)
|
|
|
|
|
normalized_twice = normalize_title(normalized_once)
|
|
|
|
|
assert normalized_once == normalized_twice
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestRemoveQualityTags:
|
|
|
|
|
"""Tests for quality tag removal."""
|
|
|
|
|
|
|
|
|
|
def test_remove_resolution_tags(self):
|
|
|
|
|
"""Test removal of resolution tags."""
|
|
|
|
|
assert "1080p" not in remove_quality_tags("Movie 1080p")
|
|
|
|
|
assert "720p" not in remove_quality_tags("Movie 720p")
|
|
|
|
|
assert "4K" not in remove_quality_tags("Movie 4K")
|
|
|
|
|
|
|
|
|
|
def test_remove_source_tags(self):
|
|
|
|
|
"""Test removal of source tags."""
|
|
|
|
|
assert "BluRay" not in remove_quality_tags("Movie BluRay")
|
|
|
|
|
assert "WEB-DL" not in remove_quality_tags("Movie WEB-DL")
|
|
|
|
|
assert "HDTV" not in remove_quality_tags("Movie HDTV")
|
|
|
|
|
|
|
|
|
|
def test_remove_codec_tags(self):
|
|
|
|
|
"""Test removal of codec tags."""
|
|
|
|
|
assert "x264" not in remove_quality_tags("Movie x264")
|
|
|
|
|
assert "x265" not in remove_quality_tags("Movie x265")
|
|
|
|
|
assert "HEVC" not in remove_quality_tags("Movie HEVC")
|
|
|
|
|
|
|
|
|
|
def test_case_insensitive_removal(self):
|
|
|
|
|
"""Test that removal is case-insensitive."""
|
|
|
|
|
assert "bluray" not in remove_quality_tags("Movie bluray").lower()
|
|
|
|
|
assert "BLURAY" not in remove_quality_tags("Movie BLURAY").upper()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestRemoveReleaseGroups:
|
|
|
|
|
"""Tests for release group removal."""
|
|
|
|
|
|
|
|
|
|
def test_remove_bracketed_groups(self):
|
|
|
|
|
"""Test removal of bracketed release groups."""
|
|
|
|
|
assert "[RARBG]" not in remove_release_groups("Movie [RARBG]")
|
|
|
|
|
assert "[YTS]" not in remove_release_groups("Movie [YTS]")
|
|
|
|
|
|
|
|
|
|
def test_preserve_year_in_parentheses(self):
|
|
|
|
|
"""Test that years in parentheses are preserved."""
|
|
|
|
|
result = remove_release_groups("Movie (2020)")
|
|
|
|
|
# Years in parentheses should be preserved - they're handled by the parser
|
|
|
|
|
assert "(2020)" in result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSeriesParser:
|
|
|
|
|
"""Tests for series identity parsing."""
|
|
|
|
|
|
|
|
|
|
def test_parse_series_sxxeyy_format(self):
|
|
|
|
|
"""Test parsing series with SXXEYY format."""
|
|
|
|
|
result = parse_series("Breaking Bad S01E01.mkv")
|
|
|
|
|
assert result.title == "Breaking Bad"
|
|
|
|
|
assert result.season == 1
|
|
|
|
|
assert result.episodes == [1]
|
|
|
|
|
assert result.confidence == 0.9
|
|
|
|
|
assert result.needs_review is False
|
|
|
|
|
assert result.original_filename == "Breaking Bad S01E01.mkv"
|
|
|
|
|
|
|
|
|
|
def test_parse_series_sxxeyy_lowercase(self):
|
|
|
|
|
"""Test parsing series with lowercase sxxeyy format."""
|
|
|
|
|
result = parse_series("Game of Thrones s02e05.mkv")
|
2026-02-13 13:36:39 +08:00
|
|
|
assert result.title == "Game of Thrones"
|
2026-02-09 17:43:35 +08:00
|
|
|
assert result.season == 2
|
|
|
|
|
assert result.episodes == [5]
|
|
|
|
|
assert result.confidence == 0.9
|
|
|
|
|
assert result.needs_review is False
|
|
|
|
|
|
|
|
|
|
def test_parse_series_xxxyy_format(self):
|
|
|
|
|
"""Test parsing series with XXxYY format."""
|
|
|
|
|
result = parse_series("The Office 1x01.mp4")
|
|
|
|
|
assert result.title == "The Office"
|
|
|
|
|
assert result.season == 1
|
|
|
|
|
assert result.episodes == [1]
|
|
|
|
|
assert result.confidence == 0.9
|
|
|
|
|
assert result.needs_review is False
|
|
|
|
|
|
|
|
|
|
def test_parse_series_season_episode_format(self):
|
|
|
|
|
"""Test parsing series with Season X Episode Y format."""
|
|
|
|
|
result = parse_series("Friends Season 1 Episode 1.avi")
|
|
|
|
|
assert result.title == "Friends"
|
|
|
|
|
assert result.season == 1
|
|
|
|
|
assert result.episodes == [1]
|
|
|
|
|
assert result.confidence == 0.7
|
|
|
|
|
assert result.needs_review is False
|
|
|
|
|
|
|
|
|
|
def test_parse_series_multi_episode_dash(self):
|
|
|
|
|
"""Test parsing multi-episode file with dash separator."""
|
|
|
|
|
result = parse_series("The Wire S01E01-E02.mkv")
|
|
|
|
|
assert result.title == "The Wire"
|
|
|
|
|
assert result.season == 1
|
|
|
|
|
assert result.episodes == [1, 2]
|
|
|
|
|
assert result.confidence == 0.9
|
|
|
|
|
assert result.needs_review is False
|
|
|
|
|
|
|
|
|
|
def test_parse_series_multi_episode_no_dash(self):
|
|
|
|
|
"""Test parsing multi-episode file without dash."""
|
|
|
|
|
result = parse_series("Stranger Things S01E01E02.mkv")
|
|
|
|
|
assert result.title == "Stranger Things"
|
|
|
|
|
assert result.season == 1
|
|
|
|
|
assert result.episodes == [1, 2]
|
|
|
|
|
assert result.confidence == 0.9
|
|
|
|
|
assert result.needs_review is False
|
|
|
|
|
|
|
|
|
|
def test_parse_series_multi_episode_three_episodes(self):
|
|
|
|
|
"""Test parsing file with three episodes."""
|
|
|
|
|
result = parse_series("Show Name S02E01E02E03.mkv")
|
|
|
|
|
assert result.title == "Show Name"
|
|
|
|
|
assert result.season == 2
|
|
|
|
|
assert result.episodes == [1, 2, 3]
|
|
|
|
|
assert result.confidence == 0.9
|
|
|
|
|
assert result.needs_review is False
|
|
|
|
|
|
|
|
|
|
def test_parse_series_with_quality_tags(self):
|
|
|
|
|
"""Test parsing series with quality tags removed."""
|
|
|
|
|
result = parse_series("The Mandalorian S01E01 1080p WEB-DL x264.mkv")
|
|
|
|
|
assert result.title == "The Mandalorian"
|
|
|
|
|
assert result.season == 1
|
|
|
|
|
assert result.episodes == [1]
|
|
|
|
|
assert "1080p" not in result.title
|
|
|
|
|
assert "WEB-DL" not in result.title
|
|
|
|
|
assert "x264" not in result.title
|
|
|
|
|
|
|
|
|
|
def test_parse_series_with_release_group(self):
|
|
|
|
|
"""Test parsing series with release group removed."""
|
|
|
|
|
result = parse_series("Westworld S01E01 [RARBG].mkv")
|
|
|
|
|
assert result.title == "Westworld"
|
|
|
|
|
assert result.season == 1
|
|
|
|
|
assert result.episodes == [1]
|
|
|
|
|
assert "RARBG" not in result.title
|
|
|
|
|
|
|
|
|
|
def test_parse_series_with_dots_in_title(self):
|
|
|
|
|
"""Test parsing series with dots in title."""
|
|
|
|
|
result = parse_series("The.Walking.Dead.S05E10.mkv")
|
|
|
|
|
assert result.title == "The Walking Dead"
|
|
|
|
|
assert result.season == 5
|
|
|
|
|
assert result.episodes == [10]
|
|
|
|
|
|
|
|
|
|
def test_parse_series_with_underscores(self):
|
|
|
|
|
"""Test parsing series with underscores in title."""
|
|
|
|
|
result = parse_series("Better_Call_Saul_S02E03.mp4")
|
|
|
|
|
assert result.title == "Better Call Saul"
|
|
|
|
|
assert result.season == 2
|
|
|
|
|
assert result.episodes == [3]
|
|
|
|
|
|
|
|
|
|
def test_parse_series_without_season(self):
|
|
|
|
|
"""Test parsing series without extractable season."""
|
|
|
|
|
result = parse_series("Random Show Episode.mkv")
|
|
|
|
|
assert result.title == "Random Show Episode"
|
|
|
|
|
assert result.season is None
|
|
|
|
|
assert result.episodes == []
|
|
|
|
|
assert result.needs_review is True
|
|
|
|
|
assert result.confidence == 0.3
|
|
|
|
|
|
|
|
|
|
def test_parse_series_without_episode(self):
|
|
|
|
|
"""Test parsing series without extractable episode."""
|
|
|
|
|
result = parse_series("Some Series Name.mkv")
|
|
|
|
|
assert result.title == "Some Series Name"
|
|
|
|
|
assert result.season is None
|
|
|
|
|
assert result.episodes == []
|
|
|
|
|
assert result.needs_review is True
|
|
|
|
|
|
|
|
|
|
def test_parse_series_double_digit_season_episode(self):
|
|
|
|
|
"""Test parsing series with double-digit season and episode."""
|
|
|
|
|
result = parse_series("Doctor Who S12E10.mkv")
|
|
|
|
|
assert result.title == "Doctor Who"
|
|
|
|
|
assert result.season == 12
|
|
|
|
|
assert result.episodes == [10]
|
|
|
|
|
assert result.needs_review is False
|
|
|
|
|
|
|
|
|
|
def test_parse_series_complex_filename(self):
|
|
|
|
|
"""Test parsing series with complex filename."""
|
|
|
|
|
result = parse_series("The.Expanse.S03E05.1080p.BluRay.x264.[YTS].mkv")
|
|
|
|
|
assert result.title == "The Expanse"
|
|
|
|
|
assert result.season == 3
|
|
|
|
|
assert result.episodes == [5]
|
|
|
|
|
assert "1080p" not in result.title
|
|
|
|
|
assert "BluRay" not in result.title
|
|
|
|
|
assert "YTS" not in result.title
|
|
|
|
|
|
|
|
|
|
def test_parse_series_preserves_original_filename(self):
|
|
|
|
|
"""Test that original filename is preserved."""
|
|
|
|
|
original = "Complex.Series.Name.S01E01.1080p.WEB-DL.[RARBG].mkv"
|
|
|
|
|
result = parse_series(original)
|
|
|
|
|
assert result.original_filename == original
|
|
|
|
|
|
|
|
|
|
def test_parse_series_with_multiple_quality_tags(self):
|
|
|
|
|
"""Test parsing series with multiple quality indicators."""
|
|
|
|
|
result = parse_series("Series.Name.S01E01.2160p.4K.UHD.WEB-DL.x265.10bit.mkv")
|
|
|
|
|
assert result.title == "Series Name"
|
|
|
|
|
assert result.season == 1
|
|
|
|
|
assert result.episodes == [1]
|
|
|
|
|
assert "2160p" not in result.title
|
|
|
|
|
assert "4K" not in result.title
|
|
|
|
|
assert "UHD" not in result.title
|
|
|
|
|
|
|
|
|
|
def test_parse_series_xxxyy_double_digits(self):
|
|
|
|
|
"""Test parsing series with XXxYY format and double digits."""
|
|
|
|
|
result = parse_series("Show Name 10x15.mp4")
|
|
|
|
|
assert result.title == "Show Name"
|
|
|
|
|
assert result.season == 10
|
|
|
|
|
assert result.episodes == [15]
|
|
|
|
|
assert result.confidence == 0.9
|
|
|
|
|
|
2026-02-13 13:36:39 +08:00
|
|
|
def test_parse_series_resolution_not_treated_as_season_episode(self):
|
|
|
|
|
"""1920x1080 should not be parsed as 20x10."""
|
|
|
|
|
result = parse_series("Some.Show.1920x1080.BluRay.mkv")
|
|
|
|
|
assert result.season is None
|
|
|
|
|
assert result.episodes == []
|
|
|
|
|
assert result.needs_review is True
|
|
|
|
|
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
class TestEpisodeGrouping:
|
|
|
|
|
"""Tests for episode grouping functionality."""
|
|
|
|
|
|
|
|
|
|
def test_group_episodes_by_title_and_season(self):
|
|
|
|
|
"""Test grouping episodes by normalized title and season."""
|
|
|
|
|
from vlm.parser import group_episodes
|
|
|
|
|
|
|
|
|
|
episodes = [
|
|
|
|
|
parse_series("Breaking Bad S01E01.mkv"),
|
|
|
|
|
parse_series("Breaking Bad S01E02.mkv"),
|
|
|
|
|
parse_series("Breaking Bad S02E01.mkv"),
|
|
|
|
|
parse_series("Game of Thrones S01E01.mkv"),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
groups = group_episodes(episodes)
|
|
|
|
|
|
|
|
|
|
# Should have 3 groups: Breaking Bad S01, Breaking Bad S02, Game of Thrones S01
|
|
|
|
|
assert len(groups) == 3
|
|
|
|
|
assert ("Breaking Bad", 1) in groups
|
|
|
|
|
assert ("Breaking Bad", 2) in groups
|
2026-02-13 13:36:39 +08:00
|
|
|
assert ("Game of Thrones", 1) in groups
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
# Breaking Bad S01 should have 2 episodes
|
|
|
|
|
assert len(groups[("Breaking Bad", 1)]) == 2
|
|
|
|
|
# Breaking Bad S02 should have 1 episode
|
|
|
|
|
assert len(groups[("Breaking Bad", 2)]) == 1
|
|
|
|
|
# Game of Thrones S01 should have 1 episode
|
2026-02-13 13:36:39 +08:00
|
|
|
assert len(groups[("Game of Thrones", 1)]) == 1
|
2026-02-09 17:43:35 +08:00
|
|
|
|
|
|
|
|
def test_group_episodes_excludes_none_season(self):
|
|
|
|
|
"""Test that episodes with season=None are excluded from grouping."""
|
|
|
|
|
from vlm.parser import group_episodes
|
|
|
|
|
|
|
|
|
|
episodes = [
|
|
|
|
|
parse_series("Breaking Bad S01E01.mkv"),
|
|
|
|
|
parse_series("Random Show Episode.mkv"), # No season
|
|
|
|
|
parse_series("Breaking Bad S01E02.mkv"),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
groups = group_episodes(episodes)
|
|
|
|
|
|
|
|
|
|
# Should only have 1 group (Breaking Bad S01)
|
|
|
|
|
assert len(groups) == 1
|
|
|
|
|
assert ("Breaking Bad", 1) in groups
|
|
|
|
|
assert len(groups[("Breaking Bad", 1)]) == 2
|
|
|
|
|
|
|
|
|
|
def test_group_episodes_empty_list(self):
|
|
|
|
|
"""Test grouping with empty episode list."""
|
|
|
|
|
from vlm.parser import group_episodes
|
|
|
|
|
|
|
|
|
|
groups = group_episodes([])
|
|
|
|
|
|
|
|
|
|
assert len(groups) == 0
|
|
|
|
|
assert groups == {}
|
|
|
|
|
|
|
|
|
|
def test_group_episodes_single_episode(self):
|
|
|
|
|
"""Test grouping with single episode."""
|
|
|
|
|
from vlm.parser import group_episodes
|
|
|
|
|
|
|
|
|
|
episodes = [parse_series("The Office S01E01.mkv")]
|
|
|
|
|
|
|
|
|
|
groups = group_episodes(episodes)
|
|
|
|
|
|
|
|
|
|
assert len(groups) == 1
|
|
|
|
|
assert ("The Office", 1) in groups
|
|
|
|
|
assert len(groups[("The Office", 1)]) == 1
|
|
|
|
|
|
|
|
|
|
def test_group_episodes_same_title_different_seasons(self):
|
|
|
|
|
"""Test grouping episodes from same series but different seasons."""
|
|
|
|
|
from vlm.parser import group_episodes
|
|
|
|
|
|
|
|
|
|
episodes = [
|
|
|
|
|
parse_series("Friends S01E01.mkv"),
|
|
|
|
|
parse_series("Friends S01E02.mkv"),
|
|
|
|
|
parse_series("Friends S02E01.mkv"),
|
|
|
|
|
parse_series("Friends S02E02.mkv"),
|
|
|
|
|
parse_series("Friends S03E01.mkv"),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
groups = group_episodes(episodes)
|
|
|
|
|
|
|
|
|
|
# Should have 3 groups (one per season)
|
|
|
|
|
assert len(groups) == 3
|
|
|
|
|
assert ("Friends", 1) in groups
|
|
|
|
|
assert ("Friends", 2) in groups
|
|
|
|
|
assert ("Friends", 3) in groups
|
|
|
|
|
|
|
|
|
|
# Check episode counts per season
|
|
|
|
|
assert len(groups[("Friends", 1)]) == 2
|
|
|
|
|
assert len(groups[("Friends", 2)]) == 2
|
|
|
|
|
assert len(groups[("Friends", 3)]) == 1
|
|
|
|
|
|
|
|
|
|
def test_group_episodes_normalized_titles(self):
|
|
|
|
|
"""Test that grouping uses normalized titles."""
|
|
|
|
|
from vlm.parser import group_episodes
|
|
|
|
|
|
|
|
|
|
episodes = [
|
|
|
|
|
parse_series("The.Walking.Dead.S01E01.mkv"), # Dots
|
|
|
|
|
parse_series("The_Walking_Dead_S01E02.mkv"), # Underscores
|
|
|
|
|
parse_series("The Walking Dead S01E03.mkv"), # Spaces
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
groups = group_episodes(episodes)
|
|
|
|
|
|
|
|
|
|
# All should be grouped together under normalized title
|
|
|
|
|
assert len(groups) == 1
|
|
|
|
|
assert ("The Walking Dead", 1) in groups
|
|
|
|
|
assert len(groups[("The Walking Dead", 1)]) == 3
|
|
|
|
|
|
|
|
|
|
def test_group_episodes_multi_episode_files(self):
|
|
|
|
|
"""Test grouping with multi-episode files."""
|
|
|
|
|
from vlm.parser import group_episodes
|
|
|
|
|
|
|
|
|
|
episodes = [
|
|
|
|
|
parse_series("Show Name S01E01.mkv"),
|
|
|
|
|
parse_series("Show Name S01E02E03.mkv"), # Multi-episode
|
|
|
|
|
parse_series("Show Name S01E04.mkv"),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
groups = group_episodes(episodes)
|
|
|
|
|
|
|
|
|
|
# All should be in same group
|
|
|
|
|
assert len(groups) == 1
|
|
|
|
|
assert ("Show Name", 1) in groups
|
|
|
|
|
assert len(groups[("Show Name", 1)]) == 3
|
|
|
|
|
|
|
|
|
|
def test_group_episodes_preserves_original_objects(self):
|
|
|
|
|
"""Test that grouping preserves original SeriesIdentity objects."""
|
|
|
|
|
from vlm.parser import group_episodes
|
|
|
|
|
|
|
|
|
|
episodes = [
|
|
|
|
|
parse_series("Breaking Bad S01E01.mkv"),
|
|
|
|
|
parse_series("Breaking Bad S01E02.mkv"),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
groups = group_episodes(episodes)
|
|
|
|
|
|
|
|
|
|
# Check that original objects are preserved
|
|
|
|
|
grouped_episodes = groups[("Breaking Bad", 1)]
|
|
|
|
|
assert grouped_episodes[0].original_filename == "Breaking Bad S01E01.mkv"
|
|
|
|
|
assert grouped_episodes[1].original_filename == "Breaking Bad S01E02.mkv"
|
|
|
|
|
assert grouped_episodes[0].episodes == [1]
|
|
|
|
|
assert grouped_episodes[1].episodes == [2]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# Property-Based Tests
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
from hypothesis import given, strategies as st, assume, settings
|
|
|
|
|
from vlm.parser import group_episodes
|
|
|
|
|
import logging
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Custom strategies for generating test data
|
|
|
|
|
@st.composite
|
|
|
|
|
def movie_filename_strategy(draw):
|
|
|
|
|
"""Generate movie filenames matching common patterns."""
|
|
|
|
|
# Generate a title (1-5 words)
|
|
|
|
|
title_words = draw(st.lists(
|
|
|
|
|
st.text(
|
|
|
|
|
alphabet=st.characters(whitelist_categories=('Lu', 'Ll'), min_codepoint=65, max_codepoint=122),
|
|
|
|
|
min_size=3,
|
|
|
|
|
max_size=10
|
|
|
|
|
),
|
|
|
|
|
min_size=1,
|
|
|
|
|
max_size=5
|
|
|
|
|
))
|
|
|
|
|
title = ' '.join(title_words)
|
|
|
|
|
|
|
|
|
|
# Generate a year (1900-2030)
|
|
|
|
|
year = draw(st.integers(min_value=1900, max_value=2030))
|
|
|
|
|
|
|
|
|
|
# Choose a pattern
|
|
|
|
|
pattern = draw(st.sampled_from([
|
|
|
|
|
'parentheses', # Title (Year)
|
|
|
|
|
'dot', # Title.Year
|
|
|
|
|
'dash', # Title - Year
|
|
|
|
|
'space' # Title Year
|
|
|
|
|
]))
|
|
|
|
|
|
|
|
|
|
# Choose optional quality tags
|
|
|
|
|
quality_tags = draw(st.lists(
|
|
|
|
|
st.sampled_from(['1080p', '720p', '4K', 'BluRay', 'WEB-DL', 'HDTV', 'x264', 'x265']),
|
|
|
|
|
max_size=3
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
# Choose optional release group
|
|
|
|
|
release_group = draw(st.one_of(
|
|
|
|
|
st.none(),
|
|
|
|
|
st.sampled_from(['[RARBG]', '[YTS]', '[YIFY]'])
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
# Choose extension
|
|
|
|
|
ext = draw(st.sampled_from(['.mp4', '.mkv', '.avi', '.mov']))
|
|
|
|
|
|
|
|
|
|
# Build filename based on pattern
|
|
|
|
|
if pattern == 'parentheses':
|
|
|
|
|
filename = f"{title} ({year})"
|
|
|
|
|
elif pattern == 'dot':
|
|
|
|
|
filename = title.replace(' ', '.') + f".{year}"
|
|
|
|
|
elif pattern == 'dash':
|
|
|
|
|
filename = f"{title} - {year}"
|
|
|
|
|
else: # space
|
|
|
|
|
filename = f"{title} {year}"
|
|
|
|
|
|
|
|
|
|
# Add quality tags
|
|
|
|
|
if quality_tags:
|
|
|
|
|
filename += ' ' + ' '.join(quality_tags)
|
|
|
|
|
|
|
|
|
|
# Add release group
|
|
|
|
|
if release_group:
|
|
|
|
|
filename += ' ' + release_group
|
|
|
|
|
|
|
|
|
|
# Add extension
|
|
|
|
|
filename += ext
|
|
|
|
|
|
|
|
|
|
return filename, title, year
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@st.composite
|
|
|
|
|
def series_filename_strategy(draw):
|
|
|
|
|
"""Generate series filenames matching common patterns."""
|
|
|
|
|
# Generate a title (1-5 words)
|
|
|
|
|
title_words = draw(st.lists(
|
|
|
|
|
st.text(
|
|
|
|
|
alphabet=st.characters(whitelist_categories=('Lu', 'Ll'), min_codepoint=65, max_codepoint=122),
|
|
|
|
|
min_size=3,
|
|
|
|
|
max_size=10
|
|
|
|
|
),
|
|
|
|
|
min_size=1,
|
|
|
|
|
max_size=5
|
|
|
|
|
))
|
|
|
|
|
title = ' '.join(title_words)
|
|
|
|
|
|
|
|
|
|
# Generate season and episode
|
|
|
|
|
season = draw(st.integers(min_value=1, max_value=20))
|
|
|
|
|
episode = draw(st.integers(min_value=1, max_value=30))
|
|
|
|
|
|
|
|
|
|
# Choose a pattern
|
|
|
|
|
pattern = draw(st.sampled_from([
|
|
|
|
|
'SXXEYY', # S01E01
|
|
|
|
|
'sxxeyy', # s01e01
|
|
|
|
|
'XXxYY', # 1x01
|
|
|
|
|
'season_episode' # Season 1 Episode 1
|
|
|
|
|
]))
|
|
|
|
|
|
|
|
|
|
# Choose optional quality tags
|
|
|
|
|
quality_tags = draw(st.lists(
|
|
|
|
|
st.sampled_from(['1080p', '720p', '4K', 'WEB-DL', 'BluRay', 'x264']),
|
|
|
|
|
max_size=2
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
# Choose extension
|
|
|
|
|
ext = draw(st.sampled_from(['.mp4', '.mkv', '.avi']))
|
|
|
|
|
|
|
|
|
|
# Build filename based on pattern
|
|
|
|
|
if pattern == 'SXXEYY':
|
|
|
|
|
episode_part = f"S{season:02d}E{episode:02d}"
|
|
|
|
|
elif pattern == 'sxxeyy':
|
|
|
|
|
episode_part = f"s{season:02d}e{episode:02d}"
|
|
|
|
|
elif pattern == 'XXxYY':
|
|
|
|
|
episode_part = f"{season}x{episode:02d}"
|
|
|
|
|
else: # season_episode
|
|
|
|
|
episode_part = f"Season {season} Episode {episode}"
|
|
|
|
|
|
|
|
|
|
# Build filename
|
|
|
|
|
filename = f"{title} {episode_part}"
|
|
|
|
|
|
|
|
|
|
# Add quality tags
|
|
|
|
|
if quality_tags:
|
|
|
|
|
filename += ' ' + ' '.join(quality_tags)
|
|
|
|
|
|
|
|
|
|
# Add extension
|
|
|
|
|
filename += ext
|
|
|
|
|
|
|
|
|
|
return filename, title, season, episode
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@st.composite
|
|
|
|
|
def ambiguous_filename_strategy(draw):
|
|
|
|
|
"""Generate filenames without clear season/episode patterns."""
|
|
|
|
|
# Generate random text without season/episode patterns
|
|
|
|
|
words = draw(st.lists(
|
|
|
|
|
st.text(
|
|
|
|
|
alphabet=st.characters(whitelist_categories=('Lu', 'Ll'), min_codepoint=65, max_codepoint=122),
|
|
|
|
|
min_size=3,
|
|
|
|
|
max_size=10
|
|
|
|
|
),
|
|
|
|
|
min_size=1,
|
|
|
|
|
max_size=5
|
|
|
|
|
))
|
|
|
|
|
filename = ' '.join(words)
|
|
|
|
|
|
|
|
|
|
# Add extension
|
|
|
|
|
ext = draw(st.sampled_from(['.mp4', '.mkv', '.avi']))
|
|
|
|
|
filename += ext
|
|
|
|
|
|
|
|
|
|
return filename
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestParserProperties:
|
|
|
|
|
"""Property-based tests for the Identity Parser."""
|
|
|
|
|
|
|
|
|
|
# Feature: video-library-manager, Property 5: Movie parsing
|
|
|
|
|
@settings(max_examples=100)
|
|
|
|
|
@given(movie_filename_strategy())
|
|
|
|
|
def test_property_5_movie_parsing(self, movie_data):
|
|
|
|
|
"""For any filename matching common movie patterns, parser SHALL extract title and year.
|
|
|
|
|
|
|
|
|
|
Validates: Requirements 2.1, 2.2
|
|
|
|
|
"""
|
|
|
|
|
filename, expected_title, expected_year = movie_data
|
|
|
|
|
|
|
|
|
|
result = parse_movie(filename)
|
|
|
|
|
|
|
|
|
|
# Parser should extract a title
|
|
|
|
|
assert result.title is not None
|
|
|
|
|
assert len(result.title) > 0
|
|
|
|
|
|
|
|
|
|
# Parser should extract the year
|
|
|
|
|
assert result.year == expected_year
|
|
|
|
|
|
|
|
|
|
# Title should be normalized (no dots, underscores, proper case)
|
|
|
|
|
assert '.' not in result.title
|
|
|
|
|
assert '_' not in result.title
|
|
|
|
|
|
|
|
|
|
# Quality tags should be removed from title
|
|
|
|
|
quality_indicators = ['1080p', '720p', '4K', 'BluRay', 'WEB-DL', 'HDTV', 'x264', 'x265']
|
|
|
|
|
for tag in quality_indicators:
|
|
|
|
|
assert tag not in result.title
|
|
|
|
|
|
|
|
|
|
# Release groups should be removed from title
|
|
|
|
|
assert '[RARBG]' not in result.title
|
|
|
|
|
assert '[YTS]' not in result.title
|
|
|
|
|
assert '[YIFY]' not in result.title
|
|
|
|
|
|
|
|
|
|
# Original filename should be preserved
|
|
|
|
|
assert result.original_filename == filename
|
|
|
|
|
|
|
|
|
|
# Feature: video-library-manager, Property 6: Series parsing
|
|
|
|
|
@settings(max_examples=100)
|
|
|
|
|
@given(series_filename_strategy())
|
|
|
|
|
def test_property_6_series_parsing(self, series_data):
|
|
|
|
|
"""For any filename matching common series patterns, parser SHALL extract series title, season, and episodes.
|
|
|
|
|
|
|
|
|
|
Validates: Requirements 3.1, 3.2
|
|
|
|
|
"""
|
|
|
|
|
filename, expected_title, expected_season, expected_episode = series_data
|
|
|
|
|
|
|
|
|
|
result = parse_series(filename)
|
|
|
|
|
|
|
|
|
|
# Parser should extract a title
|
|
|
|
|
assert result.title is not None
|
|
|
|
|
assert len(result.title) > 0
|
|
|
|
|
|
|
|
|
|
# Parser should extract season and episode
|
|
|
|
|
assert result.season == expected_season
|
|
|
|
|
assert expected_episode in result.episodes
|
|
|
|
|
|
|
|
|
|
# Title should be normalized
|
|
|
|
|
assert '.' not in result.title
|
|
|
|
|
assert '_' not in result.title
|
|
|
|
|
|
|
|
|
|
# Quality tags should be removed from title
|
|
|
|
|
quality_indicators = ['1080p', '720p', '4K', 'WEB-DL', 'BluRay', 'x264']
|
|
|
|
|
for tag in quality_indicators:
|
|
|
|
|
assert tag not in result.title
|
|
|
|
|
|
|
|
|
|
# Original filename should be preserved
|
|
|
|
|
assert result.original_filename == filename
|
|
|
|
|
|
|
|
|
|
# Feature: video-library-manager, Property 7: Title normalization idempotence
|
|
|
|
|
@settings(max_examples=100)
|
|
|
|
|
@given(st.text(
|
|
|
|
|
alphabet=st.characters(whitelist_categories=('Lu', 'Ll', 'Nd', 'Zs'), min_codepoint=32, max_codepoint=126),
|
|
|
|
|
min_size=1,
|
|
|
|
|
max_size=100
|
|
|
|
|
))
|
|
|
|
|
def test_property_7_title_normalization_idempotence(self, title):
|
|
|
|
|
"""For any title string, normalizing multiple times SHALL produce same result.
|
|
|
|
|
|
|
|
|
|
Validates: Requirements 2.6, 3.6
|
|
|
|
|
"""
|
|
|
|
|
# Filter out empty strings after normalization
|
|
|
|
|
assume(len(title.strip()) > 0)
|
|
|
|
|
|
|
|
|
|
normalized_once = normalize_title(title)
|
|
|
|
|
normalized_twice = normalize_title(normalized_once)
|
|
|
|
|
normalized_thrice = normalize_title(normalized_twice)
|
|
|
|
|
|
|
|
|
|
# All normalizations should produce the same result
|
|
|
|
|
assert normalized_once == normalized_twice
|
|
|
|
|
assert normalized_twice == normalized_thrice
|
|
|
|
|
|
|
|
|
|
# Feature: video-library-manager, Property 8: Ambiguous filename flagging
|
|
|
|
|
@settings(max_examples=100)
|
|
|
|
|
@given(ambiguous_filename_strategy())
|
|
|
|
|
def test_property_8_ambiguous_filename_flagging(self, filename):
|
|
|
|
|
"""For any filename where season/episode cannot be extracted, parser SHALL mark as needs_review.
|
|
|
|
|
|
|
|
|
|
Validates: Requirements 2.5, 3.5
|
|
|
|
|
"""
|
|
|
|
|
# Ensure the filename doesn't accidentally match a pattern
|
|
|
|
|
# by checking it doesn't contain common episode markers
|
|
|
|
|
assume('S' not in filename.upper() or 'E' not in filename.upper())
|
|
|
|
|
assume('x' not in filename.lower())
|
|
|
|
|
assume('season' not in filename.lower())
|
|
|
|
|
assume('episode' not in filename.lower())
|
|
|
|
|
|
|
|
|
|
result = parse_series(filename)
|
|
|
|
|
|
|
|
|
|
# If season or episodes cannot be extracted, should be marked for review
|
|
|
|
|
if result.season is None or len(result.episodes) == 0:
|
|
|
|
|
assert result.needs_review is True
|
|
|
|
|
assert result.confidence <= 0.5
|
|
|
|
|
|
|
|
|
|
# Feature: video-library-manager, Property 9: Episode grouping
|
|
|
|
|
@settings(max_examples=100)
|
|
|
|
|
@given(st.lists(series_filename_strategy(), min_size=1, max_size=20))
|
|
|
|
|
def test_property_9_episode_grouping(self, series_data_list):
|
|
|
|
|
"""For any set of parsed episodes, grouping SHALL place episodes with identical normalized titles and seasons in same group.
|
|
|
|
|
|
|
|
|
|
Validates: Requirements 3.7
|
|
|
|
|
"""
|
|
|
|
|
# Parse all episodes
|
|
|
|
|
episodes = [parse_series(filename) for filename, _, _, _ in series_data_list]
|
|
|
|
|
|
|
|
|
|
# Group episodes
|
|
|
|
|
groups = group_episodes(episodes)
|
|
|
|
|
|
|
|
|
|
# Verify grouping correctness
|
|
|
|
|
for (title, season), group_episodes_list in groups.items():
|
|
|
|
|
# All episodes in a group should have the same normalized title and season
|
|
|
|
|
for episode in group_episodes_list:
|
|
|
|
|
assert episode.title == title
|
|
|
|
|
assert episode.season == season
|
|
|
|
|
|
|
|
|
|
# Verify no episode is in multiple groups
|
|
|
|
|
all_grouped_episodes = []
|
|
|
|
|
for group_episodes_list in groups.values():
|
|
|
|
|
all_grouped_episodes.extend(group_episodes_list)
|
|
|
|
|
|
|
|
|
|
# Count episodes with valid season (should match grouped count)
|
|
|
|
|
valid_episodes = [ep for ep in episodes if ep.season is not None]
|
|
|
|
|
assert len(all_grouped_episodes) == len(valid_episodes)
|
|
|
|
|
|
|
|
|
|
# Feature: video-library-manager, Property 51: Parsing error handling
|
|
|
|
|
@settings(max_examples=100)
|
|
|
|
|
@given(st.text(
|
|
|
|
|
alphabet=st.characters(blacklist_categories=('Cc', 'Cs'), min_codepoint=32, max_codepoint=126),
|
|
|
|
|
min_size=1,
|
|
|
|
|
max_size=200
|
|
|
|
|
))
|
|
|
|
|
def test_property_51_parsing_error_handling(self, filename):
|
|
|
|
|
"""For any filename that cannot be parsed, system SHALL log error and mark file for review.
|
|
|
|
|
|
|
|
|
|
Validates: Requirements 13.4
|
|
|
|
|
|
|
|
|
|
Note: This test verifies that the parser handles unparseable filenames gracefully
|
|
|
|
|
by marking them for review, rather than crashing or producing invalid results.
|
|
|
|
|
"""
|
|
|
|
|
# Ensure filename has an extension
|
|
|
|
|
if not any(filename.endswith(ext) for ext in ['.mp4', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.webm', '.m4v']):
|
|
|
|
|
filename += '.mkv'
|
|
|
|
|
|
|
|
|
|
# Try parsing as movie
|
|
|
|
|
movie_result = parse_movie(filename)
|
|
|
|
|
|
|
|
|
|
# Parser should always return a valid MovieIdentity object
|
|
|
|
|
assert movie_result is not None
|
|
|
|
|
assert movie_result.title is not None
|
|
|
|
|
assert movie_result.original_filename == filename
|
|
|
|
|
|
|
|
|
|
# If year cannot be extracted, should be marked for review
|
|
|
|
|
if movie_result.year is None:
|
|
|
|
|
assert movie_result.needs_review is True
|
|
|
|
|
|
|
|
|
|
# Try parsing as series
|
|
|
|
|
series_result = parse_series(filename)
|
|
|
|
|
|
|
|
|
|
# Parser should always return a valid SeriesIdentity object
|
|
|
|
|
assert series_result is not None
|
|
|
|
|
assert series_result.title is not None
|
|
|
|
|
assert series_result.original_filename == filename
|
|
|
|
|
|
|
|
|
|
# If season/episode cannot be extracted, should be marked for review
|
|
|
|
|
if series_result.season is None or len(series_result.episodes) == 0:
|
|
|
|
|
assert series_result.needs_review is True
|