Files
VoiceStudio/tests/test_srt_parser.py

402 lines
13 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Unit tests for the SRT parser used by /dub/import-srt."""
import sys
import os
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend"))
from services.srt_parser import parse_srt # noqa: E402
def test_parses_a_well_formed_srt_file():
srt = """1
00:00:01,000 --> 00:00:04,500
Hello world.
2
00:00:05,250 --> 00:00:08,000
Second line here.
"""
result = parse_srt(srt)
assert result.skipped_cues == 0
assert result.dropped_overlaps == 0
assert len(result.segments) == 2
assert result.segments[0]["start"] == 1.0
assert result.segments[0]["end"] == 4.5
assert result.segments[0]["text"] == "Hello world."
assert result.segments[1]["start"] == 5.25
assert result.segments[1]["text"] == "Second line here."
def test_joins_multi_line_cues_with_newline():
srt = """1
00:00:01,000 --> 00:00:04,000
Line one
Line two
"""
result = parse_srt(srt)
assert result.segments[0]["text"] == "Line one\nLine two"
def test_accepts_dot_as_milliseconds_separator():
# WebVTT-style timestamps inside an SRT file are common in the wild.
srt = """1
00:00:01.500 --> 00:00:03.250
Dotty.
"""
result = parse_srt(srt)
assert result.segments[0]["start"] == 1.5
assert result.segments[0]["end"] == 3.25
def test_handles_utf8_bom_at_start_of_file():
srt = "1\n00:00:01,000 --> 00:00:02,000\nBOM cue.\n"
result = parse_srt(srt)
assert len(result.segments) == 1
assert result.segments[0]["text"] == "BOM cue."
def test_handles_crlf_line_endings():
srt = "1\r\n00:00:01,000 --> 00:00:02,000\r\nWindows.\r\n\r\n"
result = parse_srt(srt)
assert len(result.segments) == 1
def test_skips_cue_with_non_positive_duration():
srt = """1
00:00:05,000 --> 00:00:05,000
Zero duration.
2
00:00:06,000 --> 00:00:05,500
End before start.
3
00:00:07,000 --> 00:00:08,000
Good cue.
"""
result = parse_srt(srt)
assert result.skipped_cues == 2
assert len(result.segments) == 1
assert result.segments[0]["text"] == "Good cue."
def test_skips_cue_with_only_whitespace_body():
srt = """1
00:00:01,000 --> 00:00:02,000
2
00:00:03,000 --> 00:00:04,000
Real text.
"""
result = parse_srt(srt)
assert result.skipped_cues == 1
assert len(result.segments) == 1
def test_shifts_overlapping_cue_to_keep_both():
# Cue 2 starts inside cue 1; we expect cue 2's start to be pushed
# forward to cue 1's end so both still play, in order.
srt = """1
00:00:01,000 --> 00:00:05,000
First.
2
00:00:03,000 --> 00:00:07,000
Second.
"""
result = parse_srt(srt)
assert len(result.segments) == 2
assert result.segments[1]["start"] == 5.0
assert result.segments[1]["end"] == 7.0
assert result.dropped_overlaps == 0
def test_drops_overlap_that_would_have_negative_duration():
srt = """1
00:00:01,000 --> 00:00:10,000
First.
2
00:00:03,000 --> 00:00:08,000
Second entirely inside first.
"""
result = parse_srt(srt)
assert len(result.segments) == 1
assert result.dropped_overlaps == 1
def test_parses_missing_index_numbers():
# No "1", "2" lines — just timings + text. This happens with some
# subtitle editors that strip indices.
srt = """00:00:01,000 --> 00:00:02,000
First.
00:00:03,000 --> 00:00:04,000
Second.
"""
result = parse_srt(srt)
assert len(result.segments) == 2
def test_returns_empty_result_for_empty_input():
assert parse_srt("").segments == []
assert parse_srt("").skipped_cues == 0
def test_blank_line_flood_parses_in_linear_time():
# Regression: the timing-line regex used `^\s*` under re.MULTILINE, so at
# every one of N line starts the engine consumed all remaining blank
# lines before failing — quadratic. 20k blank lines already took ~1.7s
# and a 2 MB blank-line file never returned, pinning the request thread
# (reachable from /dub/import-srt with a mis-saved export, and from the
# pasted-text endpoint). Horizontal-whitespace-only classes make it
# linear: this input parses in milliseconds.
import time
srt = "1\n00:00:01,000 --> 00:00:02,000\nOnly cue.\n" + "\n" * 400_000
started = time.monotonic()
result = parse_srt(srt)
elapsed = time.monotonic() - started
assert len(result.segments) == 1
assert result.segments[0]["text"] == "Only cue."
# Pre-fix this was minutes; the bound is loose enough for a slow CI box
# and still ~3 orders of magnitude under the quadratic behaviour.
assert elapsed < 5.0, f"parse_srt took {elapsed:.1f}s — quadratic scan is back"
def test_timing_line_tolerates_leading_and_inner_spaces():
# The linearity fix narrowed `\s*` to horizontal whitespace; indented
# cues and extra spaces around the arrow must still parse.
srt = " \t00:00:01,000 --> \t00:00:02,000\nIndented.\n"
result = parse_srt(srt)
assert len(result.segments) == 1
assert result.segments[0]["text"] == "Indented."
def test_segments_get_sequential_ids_and_required_fields():
srt = """1
00:00:01,000 --> 00:00:02,000
A
2
00:00:03,000 --> 00:00:04,000
B
"""
result = parse_srt(srt)
for i, seg in enumerate(result.segments):
assert seg["id"] == i
assert seg["text"] == seg["text_original"]
assert seg["speaker_id"] == "Speaker 1"
# -- WebVTT through the same parser (Dub -> Paste translation -> Load file) ---
def test_webvtt_cues_without_an_hours_field_are_parsed():
# WebVTT allows `mm:ss.ttt`; the paste dialog accepts .vtt files.
vtt = "WEBVTT\n\n00:01.000 --> 00:02.500\nHola\n\n01:03.000 --> 01:04.000\nQue tal\n"
result = parse_srt(vtt)
assert [(s["start"], s["end"], s["text"]) for s in result.segments] == [
(1.0, 2.5, "Hola"),
(63.0, 64.0, "Que tal"),
]
def test_webvtt_identifiers_and_note_blocks_stay_out_of_cue_text():
vtt = (
"WEBVTT\n\nNOTE made by a translator\n\n"
"intro\n00:00:01.000 --> 00:00:02.500 align:start\nHola\n\n"
"NOTE check this line\n\n"
"cue-2\n00:00:03.000 --> 00:00:04.000\nQue tal\n"
)
result = parse_srt(vtt)
assert [s["text"] for s in result.segments] == ["Hola", "Que tal"]
def test_srt_text_after_a_blank_line_inside_a_cue_is_still_kept():
# SRT keeps its lenient blank-line handling; only WebVTT has identifiers.
srt = "1\n00:00:01,000 --> 00:00:02,000\nFirst\n\nstill first\n2\n00:00:03,000 --> 00:00:04,000\nSecond\n"
result = parse_srt(srt)
assert [s["text"] for s in result.segments] == ["First\nstill first", "Second"]
def test_paste_endpoint_returns_webvtt_cues():
from fastapi.testclient import TestClient
from main import app
client = TestClient(app, client=("127.0.0.1", 50000))
res = client.post(
"/dub/parse-subtitle-text",
json={"text": "WEBVTT\n\nintro\n00:01.000 --> 00:02.000\nHola\n\nNOTE x\n\n00:03.000 --> 00:04.000\nAdios\n"},
)
assert res.status_code == 200, res.text
assert [(c["start"], c["text"]) for c in res.json()["segments"]] == [(1.0, "Hola"), (3.0, "Adios")]
def test_keeps_a_final_cue_that_is_only_a_number():
# Regression: cue bodies are sliced up to the next timing line, which
# swallows that cue's index, and the parser used to claw it back by
# popping *every* trailing digit-only line. The last cue has no next
# index to pop, so a closing "1999" was mistaken for one — the cue lost
# its only line and was dropped as empty. Numeric-only dialogue is
# everywhere in subtitles (a year, a score, a street number).
srt = """1
00:00:01,000 --> 00:00:02,000
The year was
2
00:00:03,000 --> 00:00:04,000
1999
"""
result = parse_srt(srt)
assert result.skipped_cues == 0
assert [s["text"] for s in result.segments] == ["The year was", "1999"]
def test_keeps_numeric_dialogue_in_an_index_less_file():
# An index-less export has no index lines to strip at all, so a cue
# ending in a number kept its text silently truncated — no skip counted,
# so the import reported itself as lossless while dropping a line.
srt = """00:00:01,000 --> 00:00:02,000
The answer is
42
00:00:03,000 --> 00:00:04,000
Next.
"""
result = parse_srt(srt)
assert [s["text"] for s in result.segments] == ["The answer is\n42", "Next."]
def test_keeps_numeric_text_when_the_blank_separator_is_missing():
# Off-spec file with no blank line between cue text and the next index:
# exactly one trailing index line may be reclaimed, never two.
srt = """1
00:00:01,000 --> 00:00:02,000
100
2
00:00:03,000 --> 00:00:04,000
Hi
"""
result = parse_srt(srt)
assert [s["text"] for s in result.segments] == ["100", "Hi"]
def test_keeps_a_multi_line_numeric_countdown():
# The old `while` loop popped digit lines until it hit a non-digit, so a
# "3 / 2 / 1" countdown cue was consumed line by line and then dropped.
srt = """1
00:00:01,000 --> 00:00:02,000
Ready?
2
00:00:03,000 --> 00:00:04,000
3
2
1
"""
result = parse_srt(srt)
assert [s["text"] for s in result.segments] == ["Ready?", "3\n2\n1"]
def test_non_ascii_numerals_are_dialogue_not_cue_indices():
# `str.isdigit()` is True for Arabic-Indic and Devanagari numerals, which
# SubRip never uses for indices but a 646-language dubbing app sees as
# dialogue constantly.
srt = """1
00:00:01,000 --> 00:00:02,000
١٩٩٩
2
00:00:03,000 --> 00:00:04,000
२०२६
"""
result = parse_srt(srt)
assert [s["text"] for s in result.segments] == ["١٩٩٩", "२०२६"]
def test_still_strips_the_index_line_swallowed_from_the_next_cue():
# The guard against over-correcting: a numeric-bodied cue followed by
# another must keep its own text and still not leak the next index.
srt = """1
00:00:01,000 --> 00:00:02,000
1999
2
00:00:03,000 --> 00:00:04,000
Next.
"""
result = parse_srt(srt)
assert [s["text"] for s in result.segments] == ["1999", "Next."]
@pytest.mark.parametrize("first_index", ["", "1\n"])
def test_mixed_indexed_and_unindexed_cues_preserve_numbers(first_index):
text = first_index + "00:00:01,000 --> 00:00:02,000\n42\n\n2\n00:00:03,000 --> 00:00:04,000\n1999\n\n00:00:05,000 --> 00:00:06,000\n3\n2\n1\n"
assert [x["text"] for x in parse_srt(text).segments] == ["42", "1999", "3\n2\n1"]
def test_webvtt_numeric_dialogue_is_not_a_cue_identifier():
text = "WEBVTT\n\n00:01.000 --> 00:02.000\n1999\n\nnext-id\n00:03.000 --> 00:04.000\n42\n"
assert [cue["text"] for cue in parse_srt(text).segments] == ["1999", "42"]
def test_indexless_numeric_dialogue_after_blank_line_is_preserved():
text = '00:00:01,000 --> 00:00:02,000\nFirst\n\n42\n00:00:03,000 --> 00:00:04,000\nNext'
segments = parse_srt(text).segments
assert segments[0]['text'] == 'First\n42'
@pytest.mark.parametrize("gap", ["", "\n"])
def test_initial_index_does_not_remove_later_numeric_dialogue(gap):
text = "1\n00:00:01,000 --> 00:00:02,000\nFirst\n\n2\n00:00:03,000 --> 00:00:04,000\nAnswer\n" + gap + "42\n00:00:05,000 --> 00:00:06,000\nLast"
assert [cue["text"] for cue in parse_srt(text).segments] == ["First", "Answer\n42", "Last"]
def test_nonsequential_ambiguous_number_is_retained_as_dialogue():
text = "00:00:01,000 --> 00:00:02,000\nFirst\n\n3\n00:00:03,000 --> 00:00:04,000\nNext"
assert parse_srt(text).segments[0]["text"] == "First\n3"
def test_skipped_cue_still_advances_numbering_state():
text = "1\n00:00:01,000 --> 00:00:02,000\nFirst\n\n2\n00:00:04,000 --> 00:00:03,000\nInvalid\n\n3\n00:00:05,000 --> 00:00:06,000\nThird\n\n4\n00:00:07,000 --> 00:00:08,000\nFourth"
result = parse_srt(text)
assert result.skipped_cues == 1
assert [cue["text"] for cue in result.segments] == ["First", "Third", "Fourth"]
def test_numeric_only_cue_after_invalid_cue_is_not_discarded():
text = "1\n00:00:01,000 --> 00:00:02,000\nFirst\n\n2\n00:00:04,000 --> 00:00:03,000\nInvalid\n\n3\n00:00:05,000 --> 00:00:06,000\n4\n00:00:07,000 --> 00:00:08,000\nLast"
assert [cue["text"] for cue in parse_srt(text).segments] == ["First", "4", "Last"]
@pytest.mark.parametrize('header', ['NOTE', 'NOTE translator notes', 'NOTE\ttranslator notes', 'STYLE', 'REGION'])
@pytest.mark.parametrize('separator', ['\n\n', '\n\n \n', '\n \n\n'])
def test_webvtt_metadata_timestamps_never_become_dialogue(header, separator):
text = f'WEBVTT{separator}{header}\nMetadata content\n00:00.000 --> 00:02.000\nMetadata only\n\ncue\n00:03.000 --> 00:04.000\nReal dialogue\n'
result = parse_srt(text)
assert [cue['text'] for cue in result.segments] == ['Real dialogue']
assert result.segments[0]['start'] == 3
def test_webvtt_note_words_inside_dialogue_are_retained():
text = 'WEBVTT\n\n00:01.000 --> 00:02.000\nNOTE this is spoken\nSTYLE\nREGION\n'
assert parse_srt(text).segments[0]['text'] == 'NOTE this is spoken\nSTYLE\nREGION'
@pytest.mark.parametrize('identifier', ['STYLE', 'REGION', 'NOTE', 'NOTE identifier'])
def test_webvtt_metadata_words_can_identify_a_cue(identifier):
text = f'WEBVTT\n\n{identifier}\n00:01.000 --> 00:02.000\nSpoken text\n'
assert [cue['text'] for cue in parse_srt(text).segments] == ['Spoken text']
@pytest.mark.parametrize('gap', ['\n', ' \n', '\n\n'])
def test_empty_webvtt_cue_does_not_capture_following_identifier(gap):
text = f'WEBVTT\n\n00:01.000 --> 00:02.000\n{gap}next-id\n00:03.000 --> 00:04.000\nSpoken text\n'
result = parse_srt(text)
assert result.skipped_cues == 1
assert [cue['text'] for cue in result.segments] == ['Spoken text']