refactor(release): freeze ParsedRelease + enrich_from_probe returns new instance
ParsedRelease is now @dataclass(frozen=True). The enrichment passes that used to patch fields in place now produce new instances: - enrich_from_probe(parsed, info, kb) returns a new ParsedRelease via dataclasses.replace (no allocation when no field changed). - inspect_release rebinds 'parsed' after detect_media_type (wrapped in MediaTypeToken — the strict isinstance check now also runs on replace) and after enrich_from_probe. languages becomes a tuple[str, ...] so the VO is properly immutable. Parser pipeline packs languages as a tuple in the assemble dict. Callers updated: inspect_release, testing/recognize_folders_in_downloads.py. Tests updated: 22 enrich_from_probe call sites rebound, language assertions switched to tuple literals, test_release_fixtures normalizes result['languages'] back to list for YAML-fixture comparison. Suite: 1077 passed.
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
"""Tests for ``alfred.application.release.enrich_from_probe``.
|
||||
|
||||
The function mutates a ``ParsedRelease`` in place using ffprobe ``MediaInfo``.
|
||||
Token-level values from the release name always win — only ``None`` fields
|
||||
are filled.
|
||||
The function returns a new ``ParsedRelease`` with ``None`` fields filled
|
||||
from ffprobe ``MediaInfo``. Token-level values from the release name
|
||||
always win — only ``None`` fields are filled.
|
||||
|
||||
Coverage:
|
||||
|
||||
@@ -62,17 +62,17 @@ def _bare(**overrides) -> ParsedRelease:
|
||||
class TestQuality:
|
||||
def test_fills_when_none(self):
|
||||
p = _bare()
|
||||
enrich_from_probe(p, _info_with_video(width=1920, height=1080), _KB)
|
||||
p = enrich_from_probe(p, _info_with_video(width=1920, height=1080), _KB)
|
||||
assert p.quality == "1080p"
|
||||
|
||||
def test_does_not_overwrite_existing(self):
|
||||
p = _bare(quality="2160p")
|
||||
enrich_from_probe(p, _info_with_video(width=1920, height=1080), _KB)
|
||||
p = enrich_from_probe(p, _info_with_video(width=1920, height=1080), _KB)
|
||||
assert p.quality == "2160p"
|
||||
|
||||
def test_no_dims_leaves_none(self):
|
||||
p = _bare()
|
||||
enrich_from_probe(p, MediaInfo(), _KB)
|
||||
p = enrich_from_probe(p, MediaInfo(), _KB)
|
||||
assert p.quality is None
|
||||
|
||||
|
||||
@@ -84,27 +84,27 @@ class TestQuality:
|
||||
class TestVideoCodec:
|
||||
def test_hevc_to_x265(self):
|
||||
p = _bare()
|
||||
enrich_from_probe(p, _info_with_video(codec="hevc"), _KB)
|
||||
p = enrich_from_probe(p, _info_with_video(codec="hevc"), _KB)
|
||||
assert p.codec == "x265"
|
||||
|
||||
def test_h264_to_x264(self):
|
||||
p = _bare()
|
||||
enrich_from_probe(p, _info_with_video(codec="h264"), _KB)
|
||||
p = enrich_from_probe(p, _info_with_video(codec="h264"), _KB)
|
||||
assert p.codec == "x264"
|
||||
|
||||
def test_unknown_codec_uppercased(self):
|
||||
p = _bare()
|
||||
enrich_from_probe(p, _info_with_video(codec="weird"), _KB)
|
||||
p = enrich_from_probe(p, _info_with_video(codec="weird"), _KB)
|
||||
assert p.codec == "WEIRD"
|
||||
|
||||
def test_does_not_overwrite_existing(self):
|
||||
p = _bare(codec="HEVC")
|
||||
enrich_from_probe(p, _info_with_video(codec="h264"), _KB)
|
||||
p = enrich_from_probe(p, _info_with_video(codec="h264"), _KB)
|
||||
assert p.codec == "HEVC"
|
||||
|
||||
def test_no_codec_leaves_none(self):
|
||||
p = _bare()
|
||||
enrich_from_probe(p, MediaInfo(), _KB)
|
||||
p = enrich_from_probe(p, MediaInfo(), _KB)
|
||||
assert p.codec is None
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ class TestAudio:
|
||||
]
|
||||
)
|
||||
p = _bare()
|
||||
enrich_from_probe(p, info, _KB)
|
||||
p = enrich_from_probe(p, info, _KB)
|
||||
assert p.audio_codec == "EAC3"
|
||||
assert p.audio_channels == "5.1"
|
||||
|
||||
@@ -134,32 +134,32 @@ class TestAudio:
|
||||
]
|
||||
)
|
||||
p = _bare()
|
||||
enrich_from_probe(p, info, _KB)
|
||||
p = enrich_from_probe(p, info, _KB)
|
||||
assert p.audio_codec == "AC3"
|
||||
assert p.audio_channels == "5.1"
|
||||
|
||||
def test_channel_count_unknown_falls_back(self):
|
||||
info = MediaInfo(audio_tracks=[AudioTrack(0, "aac", 4, "quad", "eng")])
|
||||
p = _bare()
|
||||
enrich_from_probe(p, info, _KB)
|
||||
p = enrich_from_probe(p, info, _KB)
|
||||
assert p.audio_channels == "4ch"
|
||||
|
||||
def test_unknown_audio_codec_uppercased(self):
|
||||
info = MediaInfo(audio_tracks=[AudioTrack(0, "newcodec", 2, "stereo", "eng")])
|
||||
p = _bare()
|
||||
enrich_from_probe(p, info, _KB)
|
||||
p = enrich_from_probe(p, info, _KB)
|
||||
assert p.audio_codec == "NEWCODEC"
|
||||
|
||||
def test_no_audio_tracks(self):
|
||||
p = _bare()
|
||||
enrich_from_probe(p, MediaInfo(), _KB)
|
||||
p = enrich_from_probe(p, MediaInfo(), _KB)
|
||||
assert p.audio_codec is None
|
||||
assert p.audio_channels is None
|
||||
|
||||
def test_does_not_overwrite_existing_audio_fields(self):
|
||||
info = MediaInfo(audio_tracks=[AudioTrack(0, "ac3", 6, "5.1", "eng")])
|
||||
p = _bare(audio_codec="DTS-HD.MA", audio_channels="7.1")
|
||||
enrich_from_probe(p, info, _KB)
|
||||
p = enrich_from_probe(p, info, _KB)
|
||||
assert p.audio_codec == "DTS-HD.MA"
|
||||
assert p.audio_channels == "7.1"
|
||||
|
||||
@@ -178,8 +178,8 @@ class TestLanguages:
|
||||
]
|
||||
)
|
||||
p = _bare()
|
||||
enrich_from_probe(p, info, _KB)
|
||||
assert p.languages == ["eng", "fre"]
|
||||
p = enrich_from_probe(p, info, _KB)
|
||||
assert p.languages == ("eng", "fre")
|
||||
|
||||
def test_skips_und(self):
|
||||
info = MediaInfo(
|
||||
@@ -189,8 +189,8 @@ class TestLanguages:
|
||||
]
|
||||
)
|
||||
p = _bare()
|
||||
enrich_from_probe(p, info, _KB)
|
||||
assert p.languages == ["eng"]
|
||||
p = enrich_from_probe(p, info, _KB)
|
||||
assert p.languages == ("eng",)
|
||||
|
||||
def test_dedup_against_existing_case_insensitive(self):
|
||||
# existing token-level languages are typically upper-case ("FRENCH", "ENG")
|
||||
@@ -202,16 +202,15 @@ class TestLanguages:
|
||||
AudioTrack(1, "aac", 2, "stereo", "fre"),
|
||||
]
|
||||
)
|
||||
p = _bare()
|
||||
p.languages = ["ENG"]
|
||||
enrich_from_probe(p, info, _KB)
|
||||
p = _bare(languages=("ENG",))
|
||||
p = enrich_from_probe(p, info, _KB)
|
||||
# "eng" → upper "ENG" already present → skipped. "fre" → "FRE" new → kept.
|
||||
assert p.languages == ["ENG", "fre"]
|
||||
assert p.languages == ("ENG", "fre")
|
||||
|
||||
def test_no_audio_tracks_leaves_languages_empty(self):
|
||||
p = _bare()
|
||||
enrich_from_probe(p, MediaInfo(), _KB)
|
||||
assert p.languages == []
|
||||
p = enrich_from_probe(p, MediaInfo(), _KB)
|
||||
assert p.languages == ()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -226,7 +225,7 @@ class TestTechString:
|
||||
|
||||
def test_rebuilt_from_filled_quality_and_codec(self):
|
||||
p = _bare()
|
||||
enrich_from_probe(
|
||||
p = enrich_from_probe(
|
||||
p, _info_with_video(width=1920, height=1080, codec="hevc"), _KB
|
||||
)
|
||||
assert p.quality == "1080p"
|
||||
@@ -236,7 +235,7 @@ class TestTechString:
|
||||
def test_keeps_existing_source_when_enriching(self):
|
||||
# Token-level source must stay; probe fills only None fields.
|
||||
p = _bare(source="BluRay")
|
||||
enrich_from_probe(
|
||||
p = enrich_from_probe(
|
||||
p, _info_with_video(width=1920, height=1080, codec="hevc"), _KB
|
||||
)
|
||||
assert p.tech_string == "1080p.BluRay.x265"
|
||||
@@ -245,10 +244,10 @@ class TestTechString:
|
||||
# No video info → nothing to fill → derived tech_string stays as it was.
|
||||
p = _bare(quality="2160p", source="WEB-DL", codec="x265")
|
||||
assert p.tech_string == "2160p.WEB-DL.x265"
|
||||
enrich_from_probe(p, MediaInfo(), _KB)
|
||||
p = enrich_from_probe(p, MediaInfo(), _KB)
|
||||
assert p.tech_string == "2160p.WEB-DL.x265"
|
||||
|
||||
def test_empty_when_nothing_known(self):
|
||||
p = _bare()
|
||||
enrich_from_probe(p, MediaInfo(), _KB)
|
||||
p = enrich_from_probe(p, MediaInfo(), _KB)
|
||||
assert p.tech_string == ""
|
||||
|
||||
@@ -198,7 +198,7 @@ class TestEnrichers:
|
||||
assert annotated is not None
|
||||
fields = assemble(annotated, tag, name, _KB)
|
||||
|
||||
assert fields["languages"] == ["FRENCH", "MULTI"]
|
||||
assert fields["languages"] == ("FRENCH", "MULTI")
|
||||
assert fields["audio_codec"] == "DTS-HD.MA"
|
||||
assert fields["audio_channels"] == "5.1"
|
||||
|
||||
@@ -212,5 +212,5 @@ class TestEnrichers:
|
||||
assert fields["title"] == "Show"
|
||||
assert fields["season"] == 1
|
||||
assert fields["episode"] == 5
|
||||
assert fields["languages"] == ["FRENCH"]
|
||||
assert fields["languages"] == ("FRENCH",)
|
||||
assert fields["media_type"] == "tv_show"
|
||||
|
||||
@@ -264,10 +264,10 @@ class TestParsedReleaseInvariants:
|
||||
r = _parse(raw)
|
||||
assert r.raw == raw
|
||||
|
||||
def test_languages_defaults_to_empty_list_not_none(self):
|
||||
def test_languages_defaults_to_empty_tuple_not_none(self):
|
||||
r = _parse("Movie.2020.1080p.BluRay.x264-GRP")
|
||||
# __post_init__ ensures languages is a list, never None
|
||||
assert r.languages == []
|
||||
# ``languages`` defaults to an empty tuple (frozen VO).
|
||||
assert r.languages == ()
|
||||
|
||||
def test_tech_string_joined(self):
|
||||
r = _parse("Movie.2020.1080p.BluRay.x264-GRP")
|
||||
|
||||
@@ -48,6 +48,9 @@ def test_parse_matches_fixture(fixture: ReleaseFixture, tmp_path) -> None:
|
||||
# ``asdict()`` does not include them.
|
||||
result["is_season_pack"] = parsed.is_season_pack
|
||||
result["tech_string"] = parsed.tech_string
|
||||
# ``languages`` is a tuple on the VO; fixtures encode it as a YAML list.
|
||||
# Compare list-to-list so the equality is unambiguous.
|
||||
result["languages"] = list(result.get("languages", ()))
|
||||
|
||||
for field, expected in fixture.expected_parsed.items():
|
||||
assert field in result, (
|
||||
|
||||
Reference in New Issue
Block a user