Ctrl K
ComunidadGuías…

backend/tests/test_health_checker.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""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