backend/tests/test_health_checker.py
Rama master · réplica de lectura del repositorio original.
"""Tests for health_checker.py — subtitle health-check engine.
Covers all 10 check functions, the score calculator, the orchestrator
(run_health_checks), and all 6 auto-fix functions plus apply_fixes.
"""
import os
import shutil
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pysubs2
import pytest
from health_checker import (
ALL_CHECKS,
FIX_MAP,
FIXABLE_CHECKS,
_calculate_score,
apply_fixes,
check_duplicate_lines,
check_empty_events,
check_encoding_issues,
check_excessive_duration,
check_line_too_long,
check_missing_newlines,
check_missing_styles,
check_negative_timing,
check_timing_overlaps,
check_zero_duration,
fix_duplicate_lines,
fix_empty_events,
fix_missing_styles,
fix_negative_timing,
fix_timing_overlaps,
fix_zero_duration,
run_health_checks,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_event(start=0, end=1000, text="Hello", style="Default", is_comment=False, layer=0):
"""Create a mock subtitle event."""
ev = SimpleNamespace(
start=start,
end=end,
text=text,
plaintext=text,
style=style,
is_comment=is_comment,
layer=layer,
)
return ev
def _make_subs(events, styles=None):
"""Create a mock SSAFile with given events and optional styles dict."""
subs = SimpleNamespace(events=list(events))
if styles is not None:
subs.styles = styles
return subs
def _make_ass(tmp_path, lines=None, extra_styles=None, extra_events=None):
"""Write a real .ass file and return its path."""
content = (
"[Script Info]\nTitle: Test\nScriptType: v4.00+\n\n"
"[V4+ Styles]\n"
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, "
"OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, "
"ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, "
"Alignment, MarginL, MarginR, MarginV, Encoding\n"
"Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,"
"&H80000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1\n"
)
if extra_styles:
content += extra_styles + "\n"
content += (
"\n[Events]\n"
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"
)
if lines is None:
lines = ["Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Hello World"]
if extra_events:
lines.extend(extra_events)
content += "\n".join(lines) + "\n"
path = tmp_path / "test.ass"
path.write_text(content, encoding="utf-8")
return str(path)
def _make_srt(tmp_path, content):
"""Write a .srt file and return its path."""
path = tmp_path / "test.srt"
path.write_text(content, encoding="utf-8")
return str(path)
# ===========================================================================
# Constants
# ===========================================================================
class TestConstants:
"""Verify module-level constants are defined correctly."""
def test_fixable_checks_contains_expected_names(self):
assert "duplicate_lines" in FIXABLE_CHECKS
assert "timing_overlaps" in FIXABLE_CHECKS
assert "missing_styles" in FIXABLE_CHECKS
assert "empty_events" in FIXABLE_CHECKS
assert "negative_timing" in FIXABLE_CHECKS
assert "zero_duration" in FIXABLE_CHECKS
assert len(FIXABLE_CHECKS) == 6
def test_fix_map_keys_match_fixable_checks(self):
assert set(FIX_MAP.keys()) == FIXABLE_CHECKS
def test_all_checks_count(self):
# 9 checks in ALL_CHECKS (encoding is handled separately)
assert len(ALL_CHECKS) == 9
# ===========================================================================
# check_duplicate_lines
# ===========================================================================
class TestCheckDuplicateLines:
def test_no_duplicates(self):
subs = _make_subs(
[
_make_event(start=0, end=1000, text="A"),
_make_event(start=1000, end=2000, text="B"),
]
)
issues = check_duplicate_lines(subs)
assert issues == []
def test_detects_exact_duplicate(self):
subs = _make_subs(
[
_make_event(start=0, end=1000, text="A", style="Default"),
_make_event(start=0, end=1000, text="A", style="Default"),
]
)
issues = check_duplicate_lines(subs)
assert len(issues) == 1
assert issues[0]["check"] == "duplicate_lines"
assert issues[0]["severity"] == "warning"
assert issues[0]["auto_fixable"] is True
assert issues[0]["line"] == 2
def test_different_style_not_duplicate(self):
subs = _make_subs(
[
_make_event(start=0, end=1000, text="A", style="Default"),
_make_event(start=0, end=1000, text="A", style="Italic"),
]
)
assert check_duplicate_lines(subs) == []
def test_different_timing_not_duplicate(self):
subs = _make_subs(
[
_make_event(start=0, end=1000, text="A"),
_make_event(start=500, end=1000, text="A"),
]
)
assert check_duplicate_lines(subs) == []
def test_comments_skipped(self):
subs = _make_subs(
[
_make_event(start=0, end=1000, text="A", is_comment=True),
_make_event(start=0, end=1000, text="A", is_comment=True),
]
)
assert check_duplicate_lines(subs) == []
def test_multiple_duplicates(self):
ev = _make_event(start=0, end=1000, text="A")
subs = _make_subs([ev, ev, ev])
# Second and third are duplicates
issues = check_duplicate_lines(subs)
assert len(issues) == 2
def test_empty_events_list(self):
subs = _make_subs([])
assert check_duplicate_lines(subs) == []
# ===========================================================================
# check_timing_overlaps
# ===========================================================================
class TestCheckTimingOverlaps:
def test_no_overlap(self):
subs = _make_subs(
[
_make_event(start=0, end=1000, text="A"),
_make_event(start=1000, end=2000, text="B"),
]
)
assert check_timing_overlaps(subs) == []
def test_detects_overlap_warning(self):
subs = _make_subs(
[
_make_event(start=0, end=1500, text="A"),
_make_event(start=1000, end=2000, text="B"),
]
)
issues = check_timing_overlaps(subs)
assert len(issues) == 1
assert issues[0]["check"] == "timing_overlaps"
assert issues[0]["severity"] == "error" # 500ms overlap -> error
assert issues[0]["auto_fixable"] is True
def test_small_overlap_is_warning(self):
subs = _make_subs(
[
_make_event(start=0, end=1200, text="A"),
_make_event(start=1000, end=2000, text="B"),
]
)
issues = check_timing_overlaps(subs)
assert len(issues) == 1
assert issues[0]["severity"] == "warning" # 200ms < 500ms
def test_large_overlap_is_error(self):
subs = _make_subs(
[
_make_event(start=0, end=2000, text="A"),
_make_event(start=1000, end=3000, text="B"),
]
)
issues = check_timing_overlaps(subs)
assert len(issues) == 1
assert issues[0]["severity"] == "error" # 1000ms >= 500ms
def test_different_styles_no_overlap(self):
subs = _make_subs(
[
_make_event(start=0, end=2000, text="A", style="Default"),
_make_event(start=500, end=3000, text="B", style="Italic"),
]
)
assert check_timing_overlaps(subs) == []
def test_comments_skipped(self):
subs = _make_subs(
[
_make_event(start=0, end=2000, text="A", is_comment=True),
_make_event(start=500, end=3000, text="B", is_comment=True),
]
)
assert check_timing_overlaps(subs) == []
def test_empty_events(self):
subs = _make_subs([])
assert check_timing_overlaps(subs) == []
# ===========================================================================
# check_encoding_issues
# ===========================================================================
class TestCheckEncodingIssues:
def test_no_raw_bytes_returns_empty(self):
subs = _make_subs([])
assert check_encoding_issues(subs, raw_bytes=None) == []
def test_utf8_bom_detected(self):
subs = _make_subs([])
raw = b"\xef\xbb\xbfHello"
issues = check_encoding_issues(subs, raw_bytes=raw)
assert len(issues) >= 1
bom_issues = [i for i in issues if "UTF-8 BOM" in i["message"]]
assert len(bom_issues) == 1
assert bom_issues[0]["check"] == "encoding_issues"
assert bom_issues[0]["auto_fixable"] is False
def test_utf16_le_bom_detected(self):
subs = _make_subs([])
raw = b"\xff\xfeH\x00e\x00l\x00l\x00o\x00"
issues = check_encoding_issues(subs, raw_bytes=raw)
assert any("UTF-16 BOM" in i["message"] for i in issues)
def test_utf16_be_bom_detected(self):
subs = _make_subs([])
raw = b"\xfe\xff\x00H\x00e\x00l\x00l\x00o"
issues = check_encoding_issues(subs, raw_bytes=raw)
assert any("UTF-16 BOM" in i["message"] for i in issues)
def test_clean_utf8_no_issues(self):
subs = _make_subs([])
raw = b"Hello World"
issues = check_encoding_issues(subs, raw_bytes=raw)
# chardet may detect as ascii, which is fine
assert all(i["check"] == "encoding_issues" for i in issues)
# No BOM issues at minimum
assert not any("BOM" in i["message"] for i in issues)
def test_non_utf8_detected_with_chardet(self):
subs = _make_subs([])
# Shift-JIS encoded Japanese text
raw = "こんにちは世界".encode("shift_jis")
issues = check_encoding_issues(subs, raw_bytes=raw)
assert any("not UTF-8" in i.get("message", "") for i in issues)
def test_cp1252_is_reported_with_its_encoding(self):
subs = _make_subs([])
raw = "1\n00:00:01,000 --> 00:00:02,000\nGrüße\n".encode("cp1252")
issues = check_encoding_issues(subs, raw_bytes=raw)
assert any("not UTF-8" in i["message"] for i in issues)
def test_chardet_import_error_fallback(self):
"""When chardet is not available, fallback to manual UTF-8 decode check."""
subs = _make_subs([])
# Latin-1 bytes that are NOT valid UTF-8
raw = bytes([0xC0, 0xC1, 0xFE, 0xFF])
with (
patch.dict("sys.modules", {"chardet": None}),
patch("builtins.__import__", side_effect=_import_blocker("chardet")),
):
issues = check_encoding_issues(subs, raw_bytes=raw)
# Still detected without chardet
assert any("not UTF-8" in i.get("message", "") for i in issues)
def test_chardet_import_error_fallback_valid_utf8(self):
"""When chardet is unavailable but bytes are valid UTF-8, no issue."""
subs = _make_subs([])
raw = b"Hello"
with (
patch.dict("sys.modules", {"chardet": None}),
patch("builtins.__import__", side_effect=_import_blocker("chardet")),
):
issues = check_encoding_issues(subs, raw_bytes=raw)
assert issues == []
def _import_blocker(blocked_name):
"""Create an import side_effect that blocks a specific module."""
real_import = __builtins__.__import__ if hasattr(__builtins__, "__import__") else __import__
def _blocker(name, *args, **kwargs):
if name == blocked_name:
raise ImportError(f"Mocked: {blocked_name} not available")
return real_import(name, *args, **kwargs)
return _blocker
# ===========================================================================
# check_missing_styles
# ===========================================================================
class TestCheckMissingStyles:
def test_no_styles_attr_returns_empty(self):
subs = SimpleNamespace(events=[_make_event(style="Foo")])
# No styles attribute
assert check_missing_styles(subs) == []
def test_empty_styles_dict_returns_empty(self):
subs = _make_subs([_make_event(style="Foo")], styles={})
assert check_missing_styles(subs) == []
def test_all_styles_defined(self):
subs = _make_subs(
[_make_event(style="Default"), _make_event(style="Italic")],
styles={"Default": {}, "Italic": {}},
)
assert check_missing_styles(subs) == []
def test_detects_undefined_style(self):
subs = _make_subs(
[_make_event(style="Missing")],
styles={"Default": {}},
)
issues = check_missing_styles(subs)
assert len(issues) == 1
assert issues[0]["check"] == "missing_styles"
assert issues[0]["severity"] == "error"
assert issues[0]["auto_fixable"] is True
assert "Missing" in issues[0]["message"]
def test_comments_skipped(self):
subs = _make_subs(
[_make_event(style="Missing", is_comment=True)],
styles={"Default": {}},
)
assert check_missing_styles(subs) == []
# ===========================================================================
# check_empty_events
# ===========================================================================
class TestCheckEmptyEvents:
def test_no_empty_events(self):
subs = _make_subs([_make_event(text="Hello")])
assert check_empty_events(subs) == []
def test_detects_empty_plaintext(self):
ev = _make_event(text="")
ev.plaintext = ""
subs = _make_subs([ev])
issues = check_empty_events(subs)
assert len(issues) == 1
assert issues[0]["check"] == "empty_events"
assert issues[0]["auto_fixable"] is True
def test_whitespace_only_is_empty(self):
ev = _make_event(text=" ")
ev.plaintext = " "
subs = _make_subs([ev])
issues = check_empty_events(subs)
assert len(issues) == 1
def test_comments_skipped(self):
ev = _make_event(text="", is_comment=True)
ev.plaintext = ""
subs = _make_subs([ev])
assert check_empty_events(subs) == []
# ===========================================================================
# check_excessive_duration
# ===========================================================================
class TestCheckExcessiveDuration:
def test_normal_duration(self):
subs = _make_subs([_make_event(start=0, end=5000)])
assert check_excessive_duration(subs) == []
def test_exactly_10_seconds_no_issue(self):
subs = _make_subs([_make_event(start=0, end=10000)])
assert check_excessive_duration(subs) == []
def test_exceeds_10_seconds(self):
subs = _make_subs([_make_event(start=0, end=15000)])
issues = check_excessive_duration(subs)
assert len(issues) == 1
assert issues[0]["check"] == "excessive_duration"
assert issues[0]["severity"] == "info"
assert issues[0]["auto_fixable"] is False
assert "15.0s" in issues[0]["message"]
def test_comments_skipped(self):
subs = _make_subs([_make_event(start=0, end=15000, is_comment=True)])
assert check_excessive_duration(subs) == []
# ===========================================================================
# check_negative_timing
# ===========================================================================
class TestCheckNegativeTiming:
def test_normal_timing(self):
subs = _make_subs([_make_event(start=0, end=1000)])
assert check_negative_timing(subs) == []
def test_detects_negative(self):
subs = _make_subs([_make_event(start=2000, end=1000)])
issues = check_negative_timing(subs)
assert len(issues) == 1
assert issues[0]["check"] == "negative_timing"
assert issues[0]["severity"] == "error"
assert issues[0]["auto_fixable"] is True
def test_equal_timing_not_negative(self):
subs = _make_subs([_make_event(start=1000, end=1000)])
assert check_negative_timing(subs) == []
def test_comments_skipped(self):
subs = _make_subs([_make_event(start=2000, end=1000, is_comment=True)])
assert check_negative_timing(subs) == []
# ===========================================================================
# check_zero_duration
# ===========================================================================
class TestCheckZeroDuration:
def test_normal_duration(self):
subs = _make_subs([_make_event(start=0, end=1000)])
assert check_zero_duration(subs) == []
def test_detects_zero_duration(self):
subs = _make_subs([_make_event(start=1000, end=1000)])
issues = check_zero_duration(subs)
assert len(issues) == 1
assert issues[0]["check"] == "zero_duration"
assert issues[0]["severity"] == "warning"
assert issues[0]["auto_fixable"] is True
def test_comments_skipped(self):
subs = _make_subs([_make_event(start=1000, end=1000, is_comment=True)])
assert check_zero_duration(subs) == []
# ===========================================================================
# check_line_too_long
# ===========================================================================
class TestCheckLineTooLong:
def test_short_line(self):
subs = _make_subs([_make_event(text="Short")])
assert check_line_too_long(subs) == []
def test_exactly_80_chars_no_issue(self):
text = "A" * 80
ev = _make_event(text=text)
ev.plaintext = text
subs = _make_subs([ev])
assert check_line_too_long(subs) == []
def test_detects_long_line(self):
text = "A" * 81
ev = _make_event(text=text)
ev.plaintext = text
subs = _make_subs([ev])
issues = check_line_too_long(subs)
assert len(issues) == 1
assert issues[0]["check"] == "line_too_long"
assert issues[0]["severity"] == "info"
assert issues[0]["auto_fixable"] is False
assert "81 chars" in issues[0]["message"]
def test_multiline_one_long(self):
text = "Short\n" + "B" * 90
ev = _make_event(text=text)
ev.plaintext = text
subs = _make_subs([ev])
issues = check_line_too_long(subs)
assert len(issues) == 1
def test_multiline_all_short(self):
text = "Short line one\nShort line two"
ev = _make_event(text=text)
ev.plaintext = text
subs = _make_subs([ev])
assert check_line_too_long(subs) == []
def test_one_issue_per_event(self):
"""Even with multiple long lines, only one issue per event."""
text = ("A" * 90) + "\n" + ("B" * 90)
ev = _make_event(text=text)
ev.plaintext = text
subs = _make_subs([ev])
issues = check_line_too_long(subs)
assert len(issues) == 1
def test_comments_skipped(self):
text = "A" * 100
ev = _make_event(text=text, is_comment=True)
ev.plaintext = text
subs = _make_subs([ev])
assert check_line_too_long(subs) == []
# ===========================================================================
# check_missing_newlines
# ===========================================================================
class TestCheckMissingNewlines:
def test_no_styles_returns_empty(self):
"""SRT files have no styles — check should skip."""
ev = _make_event(text="A" * 100)
ev.plaintext = "A" * 100
subs = SimpleNamespace(events=[ev])
assert check_missing_newlines(subs) == []
def test_short_text_no_issue(self):
ev = _make_event(text="Short")
ev.plaintext = "Short"
subs = _make_subs([ev], styles={"Default": {}})
assert check_missing_newlines(subs) == []
def test_long_text_with_newline_no_issue(self):
long_text = "A" * 50 + "\\N" + "B" * 50
ev = _make_event(text=long_text)
ev.plaintext = "A" * 50 + "\n" + "B" * 50
subs = _make_subs([ev], styles={"Default": {}})
assert check_missing_newlines(subs) == []
def test_long_text_without_newline_detected(self):
long_text = "A" * 90
ev = _make_event(text=long_text)
ev.plaintext = long_text
subs = _make_subs([ev], styles={"Default": {}})
issues = check_missing_newlines(subs)
assert len(issues) == 1
assert issues[0]["check"] == "missing_newlines"
assert issues[0]["severity"] == "info"
def test_exactly_80_chars_no_issue(self):
text = "A" * 80
ev = _make_event(text=text)
ev.plaintext = text
subs = _make_subs([ev], styles={"Default": {}})
assert check_missing_newlines(subs) == []
def test_comments_skipped(self):
text = "A" * 100
ev = _make_event(text=text, is_comment=True)
ev.plaintext = text
subs = _make_subs([ev], styles={"Default": {}})
assert check_missing_newlines(subs) == []
# ===========================================================================
# _calculate_score
# ===========================================================================
class TestCalculateScore:
def test_no_issues_perfect_score(self):
assert _calculate_score([]) == 100
def test_error_penalty(self):
issues = [{"severity": "error"}]
assert _calculate_score(issues) == 90 # 100 - 10
def test_warning_penalty(self):
issues = [{"severity": "warning"}]
assert _calculate_score(issues) == 97 # 100 - 3
def test_info_penalty(self):
issues = [{"severity": "info"}]
assert _calculate_score(issues) == 99 # 100 - 1
def test_mixed_penalties(self):
issues = [
{"severity": "error"},
{"severity": "warning"},
{"severity": "info"},
]
# 100 - 10 - 3 - 1 = 86
assert _calculate_score(issues) == 86
def test_clamped_to_zero(self):
issues = [{"severity": "error"}] * 20
# 100 - 200 = -100, clamped to 0
assert _calculate_score(issues) == 0
def test_unknown_severity_treated_as_info(self):
issues = [{"severity": "unknown"}]
# Default branch: no penalty added (not error/warning/info)
assert _calculate_score(issues) == 100
def test_missing_severity_key(self):
issues = [{}]
# .get("severity", "info") -> "info" -> penalty 1
assert _calculate_score(issues) == 99
# ===========================================================================
# run_health_checks (orchestrator)
# ===========================================================================
class TestRunHealthChecks:
def test_healthy_file(self, tmp_path):
path = _make_ass(tmp_path)
result = run_health_checks(path)
assert result["file_path"] == path
assert result["checks_run"] == 10 # 1 encoding + 9 from ALL_CHECKS
assert result["score"] <= 100
assert result["score"] >= 0
assert "checked_at" in result
assert isinstance(result["issues"], list)
def test_file_not_found(self, tmp_path):
missing = str(tmp_path / "nonexistent.ass")
result = run_health_checks(missing)
assert result["checks_run"] == 0
assert result["score"] == 0
assert result["issues"] == []
def test_parse_error(self, tmp_path):
# Write garbage that pysubs2 can't parse
bad_path = tmp_path / "bad.ass"
bad_path.write_text("THIS IS NOT A SUBTITLE FILE\nRANDOM GARBAGE", encoding="utf-8")
result = run_health_checks(str(bad_path))
assert result["checks_run"] == 0
assert result["score"] == 0
assert len(result["issues"]) == 1
assert result["issues"][0]["check"] == "parse_error"
assert result["issues"][0]["severity"] == "error"
def test_file_with_issues(self, tmp_path):
# Create a file with a duplicate line
lines = [
"Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Hello",
"Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Hello",
]
path = _make_ass(tmp_path, lines=lines)
result = run_health_checks(path)
assert result["checks_run"] == 10
dup_issues = [i for i in result["issues"] if i["check"] == "duplicate_lines"]
assert len(dup_issues) >= 1
assert result["score"] < 100
def test_check_function_exception_handled(self, tmp_path):
"""If a check function raises, it's caught and others still run."""
path = _make_ass(tmp_path)
# Patch one check to raise
with patch(
"health_checker.ALL_CHECKS",
[lambda subs: (_ for _ in ()).throw(ValueError("boom")), check_empty_events],
):
result = run_health_checks(path)
# Should still complete with encoding check + 2 from patched ALL_CHECKS
assert result["checks_run"] == 3
assert result["score"] >= 0
def test_srt_file(self, tmp_path):
content = "1\n00:00:01,000 --> 00:00:03,000\nHello\n\n"
path = _make_srt(tmp_path, content)
result = run_health_checks(path)
assert result["checks_run"] == 10
assert result["score"] >= 0
def test_encoding_check_included(self, tmp_path):
# Write file with UTF-8 BOM
path = tmp_path / "bom.ass"
content = (
"[Script Info]\nTitle: Test\nScriptType: v4.00+\n\n"
"[V4+ Styles]\n"
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, "
"OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, "
"ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, "
"Alignment, MarginL, MarginR, MarginV, Encoding\n"
"Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,"
"&H80000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1\n\n"
"[Events]\n"
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"
"Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Hello\n"
)
path.write_bytes(b"\xef\xbb\xbf" + content.encode("utf-8"))
result = run_health_checks(str(path))
bom_issues = [i for i in result["issues"] if "BOM" in i.get("message", "")]
assert len(bom_issues) >= 1
# ===========================================================================
# Fix functions
# ===========================================================================
class TestFixDuplicateLines:
def test_removes_duplicates(self):
subs = _make_subs(
[
_make_event(start=0, end=1000, text="A"),
_make_event(start=0, end=1000, text="A"),
_make_event(start=1000, end=2000, text="B"),
]
)
removed = fix_duplicate_lines(subs)
assert removed == 1
assert len(subs.events) == 2
def test_no_duplicates_returns_zero(self):
subs = _make_subs(
[
_make_event(start=0, end=1000, text="A"),
_make_event(start=1000, end=2000, text="B"),
]
)
removed = fix_duplicate_lines(subs)
assert removed == 0
assert len(subs.events) == 2
def test_comments_preserved(self):
subs = _make_subs(
[
_make_event(start=0, end=1000, text="A", is_comment=True),
_make_event(start=0, end=1000, text="A", is_comment=True),
]
)
removed = fix_duplicate_lines(subs)
assert removed == 0
assert len(subs.events) == 2
class TestFixTimingOverlaps:
def test_fixes_overlap(self):
ev1 = _make_event(start=0, end=2000, text="A", style="Default")
ev2 = _make_event(start=1000, end=3000, text="B", style="Default")
subs = _make_subs([ev1, ev2])
fixed = fix_timing_overlaps(subs)
assert fixed == 1
assert ev1.end == 1000 # Trimmed to ev2.start
def test_no_overlap_returns_zero(self):
ev1 = _make_event(start=0, end=1000, text="A")
ev2 = _make_event(start=1000, end=2000, text="B")
subs = _make_subs([ev1, ev2])
fixed = fix_timing_overlaps(subs)
assert fixed == 0
def test_different_styles_not_fixed(self):
ev1 = _make_event(start=0, end=2000, text="A", style="Default")
ev2 = _make_event(start=500, end=3000, text="B", style="Italic")
subs = _make_subs([ev1, ev2])
fixed = fix_timing_overlaps(subs)
assert fixed == 0
assert ev1.end == 2000 # Unchanged
class TestFixMissingStyles:
def test_fixes_undefined_style(self):
ev = _make_event(style="Missing")
subs = _make_subs([ev], styles={"Default": {}})
fixed = fix_missing_styles(subs)
assert fixed == 1
assert ev.style == "Default"
def test_no_styles_returns_zero(self):
subs = SimpleNamespace(events=[_make_event(style="Foo")])
assert fix_missing_styles(subs) == 0
def test_empty_styles_returns_zero(self):
subs = _make_subs([_make_event(style="Foo")], styles={})
assert fix_missing_styles(subs) == 0
def test_all_defined_returns_zero(self):
subs = _make_subs([_make_event(style="Default")], styles={"Default": {}})
assert fix_missing_styles(subs) == 0
class TestFixEmptyEvents:
def test_removes_empty(self):
ev_empty = _make_event(text="")
ev_empty.plaintext = ""
ev_good = _make_event(text="Hello")
subs = _make_subs([ev_empty, ev_good])
removed = fix_empty_events(subs)
assert removed == 1
assert len(subs.events) == 1
def test_removes_whitespace_only(self):
ev = _make_event(text=" ")
ev.plaintext = " "
subs = _make_subs([ev])
removed = fix_empty_events(subs)
assert removed == 1
def test_no_empty_returns_zero(self):
subs = _make_subs([_make_event(text="Hello")])
removed = fix_empty_events(subs)
assert removed == 0
class TestFixNegativeTiming:
def test_swaps_negative(self):
ev = _make_event(start=2000, end=1000)
subs = _make_subs([ev])
fixed = fix_negative_timing(subs)
assert fixed == 1
assert ev.start == 1000
assert ev.end == 2000
def test_normal_timing_not_changed(self):
ev = _make_event(start=0, end=1000)
subs = _make_subs([ev])
fixed = fix_negative_timing(subs)
assert fixed == 0
assert ev.start == 0
class TestFixZeroDuration:
def test_removes_zero_duration(self):
ev_zero = _make_event(start=1000, end=1000)
ev_good = _make_event(start=0, end=1000)
subs = _make_subs([ev_zero, ev_good])
removed = fix_zero_duration(subs)
assert removed == 1
assert len(subs.events) == 1
def test_no_zero_duration_returns_zero(self):
subs = _make_subs([_make_event(start=0, end=1000)])
removed = fix_zero_duration(subs)
assert removed == 0
# ===========================================================================
# apply_fixes (integration)
# ===========================================================================
class TestApplyFixes:
def test_apply_fixes_creates_backup(self, tmp_path):
lines = [
"Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Hello",
"Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Hello",
]
path = _make_ass(tmp_path, lines=lines)
base, ext = os.path.splitext(path)
bak_path = f"{base}.bak{ext}"
result = apply_fixes(path, ["duplicate_lines"])
assert os.path.exists(bak_path)
assert "duplicate_lines" in result["fixes_applied"]
assert result["counts"]["duplicate_lines"] >= 1
assert result["new_score"] >= 0
def test_apply_fixes_unknown_fix_ignored(self, tmp_path):
path = _make_ass(tmp_path)
result = apply_fixes(path, ["nonexistent_fix"])
assert result["fixes_applied"] == []
assert result["counts"] == {}
def test_apply_fixes_empty_list(self, tmp_path):
path = _make_ass(tmp_path)
result = apply_fixes(path, [])
assert result["fixes_applied"] == []
assert result["counts"] == {}
def test_apply_fixes_multiple(self, tmp_path):
# Create file with duplicate + empty event
lines = [
"Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Hello",
"Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Hello",
"Dialogue: 0,0:00:04.00,0:00:05.00,Default,,0,0,0,,",
]
path = _make_ass(tmp_path, lines=lines)
result = apply_fixes(path, ["duplicate_lines", "empty_events"])
assert "duplicate_lines" in result["fixes_applied"]
assert "empty_events" in result["fixes_applied"]
def test_apply_fixes_zero_count_not_in_applied(self, tmp_path):
"""If a fix function returns 0, the name should NOT appear in fixes_applied."""
path = _make_ass(tmp_path) # Clean file with no issues
result = apply_fixes(path, ["duplicate_lines"])
assert "duplicate_lines" not in result["fixes_applied"]
assert "duplicate_lines" not in result["counts"]
def test_apply_fixes_saves_modified_file(self, tmp_path):
lines = [
"Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Hello",
"Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Hello",
]
path = _make_ass(tmp_path, lines=lines)
apply_fixes(path, ["duplicate_lines"])
# Reload and verify duplicate is gone
subs = pysubs2.load(path)
texts = [e.plaintext for e in subs.events if not e.is_comment]
assert texts.count("Hello") == 1