feat: mehrere Zeitbereiche (-z) mit Zeit-Überschriften in einer Datei

- Zeitformat: 00:05:10 (hⓂ️s), 00:20 (h:m = Minute 20), 25 (Minute 25)
- -z "00:05-00:06 00:10-00:12 00:25": A-B, A (bis Ende), -B (ab Anfang)
- jeder Bereich unter einer Überschrift [HH:MM:SS - HH:MM:SS] mit den
  tatsächlichen Zeiten; nach der Satz-Erweiterung überlappende Blöcke
  werden verschmolzen
- rede_korrigieren.py korrigiert und gliedert jeden Block einzeln; das
  Modell sieht die Überschriften nicht

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-11 20:12:49 +02:00
commit 0bf42dee74
3 changed files with 700 additions and 254 deletions

View file

@ -130,10 +130,12 @@ def test_cli_errors(tmp_path, monkeypatch, capsys, args, message):
def test_time_range_default_name(fake_yt_dlp, tmp_path, monkeypatch):
monkeypatch.setattr(yt.subprocess, "run", fake_yt_dlp(GERMAN_VIDEO, vtt=VTT))
monkeypatch.chdir(tmp_path)
run_main(monkeypatch, "-u", URL, "-s", "2", "-e", "0:03")
run_main(monkeypatch, "-u", URL, "-s", "0:00:02", "-e", "0:00:03")
name = "Kanal_Eine_Rede_2026-03-28_00-00-02_bis_00-00-03.txt"
assert (tmp_path / name).read_text(encoding="utf-8") == "Schön, dass Sie da sind.\n"
assert (tmp_path / name).read_text(encoding="utf-8") == (
"[00:00:02 - 00:00:03]\nSchön, dass Sie da sind.\n"
)
def test_time_range_without_text(fake_yt_dlp, tmp_path, monkeypatch):
@ -157,11 +159,32 @@ CUT_VTT = (
@pytest.mark.parametrize("exact, text", [
([], "Guten Abend und\nwillkommen. Schön, dass\nSie da sind.\n"),
(["--exakt"], "willkommen. Schön, dass\n"),
([], "[00:00:01 - 00:00:04]\nGuten Abend und\nwillkommen. Schön, dass\nSie da sind.\n"),
(["--exakt"], "[00:00:02 - 00:00:03]\nwillkommen. Schön, dass\n"),
])
def test_time_range_sentence_bounds(fake_yt_dlp, tmp_path, monkeypatch, exact, text):
monkeypatch.setattr(yt.subprocess, "run", fake_yt_dlp(GERMAN_VIDEO, vtt=CUT_VTT))
target = tmp_path / "out.txt"
run_main(monkeypatch, "-u", URL, "-s", "2", "-e", "3", "-o", target, *exact)
run_main(monkeypatch, "-u", URL, "-s", "0:00:02", "-e", "0:00:03", "-o", target, *exact)
assert target.read_text(encoding="utf-8") == text
MULTI_VTT = (
"WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nErster Satz der Rede.\n\n"
"00:01:00.000 --> 00:01:01.000\nZweiter Satz.\n\n"
"00:02:00.000 --> 00:02:01.000\nDritter Satz, der etwas länger ist.\n"
)
def test_several_ranges_flow_text_keeps_headers(fake_yt_dlp, tmp_path, monkeypatch, capsys):
monkeypatch.setattr(yt.subprocess, "run", fake_yt_dlp(GERMAN_VIDEO, vtt=MULTI_VTT))
monkeypatch.setattr(extract_text, "FLOW_WIDTH", 20)
monkeypatch.chdir(tmp_path)
run_main(monkeypatch, "-u", URL, "-z", "0:00:00-0:00:30 0:02", "-f")
name = "Kanal_Eine_Rede_2026-03-28_00-00-00_bis_00-00-30+ab_00-02-00.txt"
assert (tmp_path / name).read_text(encoding="utf-8") == (
"[00:00:01 - 00:00:02]\nErster Satz der\nRede.\n\n"
"[00:02:00 - 00:02:01]\nDritter Satz, der\netwas länger ist.\n"
)
assert "(7 Zeilen, 10 Wörter)" in capsys.readouterr().out

View file

@ -523,12 +523,13 @@ def test_cli_errors(sample_copy, monkeypatch, capsys, args, message):
def test_main_url_time_range_default_names(from_url, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
run_main(monkeypatch, "-u", URL, "-s", "0:02")
run_main(monkeypatch, "-u", URL, "-s", "0:00:02")
name = "Kanal_Rede_2026-03-28_ab_00-00-02"
raw = (tmp_path / f"{name}.txt").read_text(encoding="utf-8")
assert raw == "Das ist bekannt.\n"
assert (tmp_path / f"{name}.korrigiert.txt").is_file()
assert raw == "[00:00:02 - 00:00:03]\nDas ist bekannt.\n"
result = (tmp_path / f"{name}.korrigiert.txt").read_text(encoding="utf-8")
assert result == "[00:00:02 - 00:00:03]\n\nDas ist bekannt.\n"
def test_main_url_time_range_without_text(from_url, tmp_path, monkeypatch):
@ -541,10 +542,12 @@ def test_main_vtt_time_range(tmp_path, monkeypatch):
vtt.write_text(VTT, encoding="utf-8")
monkeypatch.setattr(rk, "correct_chunk", identity_correct)
monkeypatch.setattr(rk, "find_paragraph_starts", every_nth_start(5))
run_main(monkeypatch, "-i", vtt, "-e", "2")
run_main(monkeypatch, "-i", vtt, "-e", "0:00:02")
result = tmp_path / "rede.de_bis_00-00-02.korrigiert.txt"
assert result.read_text(encoding="utf-8") == "Der Kremmel ist in Moskau.\n"
assert result.read_text(encoding="utf-8") == (
"[00:00:01 - 00:00:02]\n\nDer Kremmel ist in Moskau.\n"
)
def test_main_vtt_time_range_without_text(tmp_path, monkeypatch):
@ -559,6 +562,7 @@ def test_main_vtt_time_range_without_text(tmp_path, monkeypatch):
(["-i", "{sample}", "-s", "1:00"], "nur mit .vtt-Eingabe oder -u"),
(["-i", "{sample}", "-s", "x"], "ungültige Zeit"),
(["-i", "{sample}", "-s", "2:00", "-e", "1:00"], "muss vor -e/--ende liegen"),
(["-i", "{sample}", "-z", "0:05 0:10"], "überschneiden"),
])
def test_cli_time_errors(sample_copy, monkeypatch, capsys, args, message):
args = [a.format(sample=sample_copy) for a in args]
@ -637,14 +641,81 @@ CUT_VTT = (
@pytest.mark.parametrize("exact, raw", [
([], "Der Kremmel ist\nin Moskau. Das ist\nbekannt.\n"),
(["--exakt"], "in Moskau. Das ist\n"),
([], "[00:00:01 - 00:00:04]\nDer Kremmel ist\nin Moskau. Das ist\nbekannt.\n"),
(["--exakt"], "[00:00:02 - 00:00:03]\nin Moskau. Das ist\n"),
])
def test_main_url_excerpt(fake_yt_dlp, yt_dlp_installed, tmp_path, monkeypatch, exact, raw):
monkeypatch.setattr(subprocess, "run", fake_yt_dlp(VIDEO, vtt=CUT_VTT))
systems = recording_model(monkeypatch)
target = tmp_path / "rede.korrigiert.txt"
run_main(monkeypatch, "-u", URL, "-s", "2", "-e", "3", "-o", target, *exact)
run_main(monkeypatch, "-u", URL, "-s", "0:00:02", "-e", "0:00:03", "-o", target, *exact)
assert (tmp_path / "rede.txt").read_text(encoding="utf-8") == raw
assert rk.PROMPTS["de"]["excerpt"] in systems[0]
# === Several time ranges (-z): blocks with headers ===
MULTI_VTT = (
"WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nDer Kremmel ist in Moskau.\n\n"
"00:01:00.000 --> 00:01:01.000\nZwischendurch etwas anderes.\n\n"
"00:02:00.000 --> 00:02:01.000\nDas ist bekannt.\n"
)
def chunk_recorder(monkeypatch):
"""Fake models; returns the chunks and system prompts sent for correction."""
sent = []
def correct(chunk, index, system):
sent.append((chunk, system))
return chunk.strip()
monkeypatch.setattr(rk, "correct_chunk", correct)
monkeypatch.setattr(rk, "find_paragraph_starts", every_nth_start(5))
return sent
def test_main_url_several_ranges(fake_yt_dlp, yt_dlp_installed, tmp_path, monkeypatch, capsys):
monkeypatch.setattr(subprocess, "run", fake_yt_dlp(VIDEO, vtt=MULTI_VTT))
sent = chunk_recorder(monkeypatch)
monkeypatch.chdir(tmp_path)
run_main(monkeypatch, "-u", URL, "-z", "0:00:00-0:00:30 0:02", "-g", SHARED_GLOSSARY)
name = "Kanal_Rede_2026-03-28_00-00-00_bis_00-00-30+ab_00-02-00"
assert (tmp_path / f"{name}.txt").read_text(encoding="utf-8") == (
"[00:00:01 - 00:00:02]\nDer Kremmel ist in Moskau.\n\n"
"[00:02:00 - 00:02:01]\nDas ist bekannt.\n"
)
assert (tmp_path / f"{name}.korrigiert.txt").read_text(encoding="utf-8") == (
"[00:00:01 - 00:00:02]\n\nDer Kreml ist in Moskau.\n\n"
"[00:02:00 - 00:02:01]\n\nDas ist bekannt.\n"
)
# One chunk per block, never a header; excerpt note in the prompt
assert [chunk for chunk, _ in sent] == ["Der Kreml ist in Moskau.", "Das ist bekannt."]
assert all(rk.PROMPTS["de"]["excerpt"] in system for _, system in sent)
out = capsys.readouterr().out
assert "Abschnitt 2/2" in out
# Headers are no change; only the glossary replacement is
assert "Änderungen gegenüber dem Rohtext: 1" in out
def test_main_txt_with_headers_corrected_in_blocks(tmp_path, monkeypatch):
raw = tmp_path / "rede.txt"
raw.write_text(
"[00:00:01 - 00:00:02]\nErster\nBlock.\n\n[00:02:00 - 00:02:01]\nZweiter Block.\n",
encoding="utf-8",
)
sent = chunk_recorder(monkeypatch)
run_main(monkeypatch, "-i", raw)
assert [chunk for chunk, _ in sent] == ["Erster Block.", "Zweiter Block."]
assert (tmp_path / "rede.korrigiert.txt").read_text(encoding="utf-8") == (
"[00:00:01 - 00:00:02]\n\nErster Block.\n\n[00:02:00 - 00:02:01]\n\nZweiter Block.\n"
)
def test_main_without_headers_has_no_excerpt_note(sample_copy, monkeypatch):
sent = chunk_recorder(monkeypatch)
run_main(monkeypatch, "-i", sample_copy)
assert rk.PROMPTS["de"]["excerpt"] not in sent[0][1]

View file

@ -164,51 +164,215 @@ def test_cli_errors(sample_copy, monkeypatch, capsys, args, message):
assert message in capsys.readouterr().err
# === Time format and ranges ===
@pytest.mark.parametrize("text, seconds", [
("00:03:17", 197), ("3:17", 197), ("197", 197), ("00:03:17.5", 197.5),
("00:03:17,5", 197.5), ("1:02:03", 3723),
("00:05:10", 310), ("00:20", 1200), ("1:30", 5400), ("25", 1500), ("00:5:00", 300),
("00:03:17.5", 197.5), ("00:03:17,5", 197.5), ("1:02:03", 3723),
])
def test_parse_time(text, seconds):
assert vtt2txt.parse_time(text) == seconds
def test_parse_user_time(text, seconds):
assert vtt2txt.parse_user_time(text) == seconds
@pytest.mark.parametrize("text", ["", "abc", "1:2:3:4", "-5"])
def test_parse_time_rejects_invalid(text):
@pytest.mark.parametrize("text", ["", "abc", "1:2:3:4", "-5", "1:75", "0:10:60", "1:30.5"])
def test_parse_user_time_rejects_invalid(text):
with pytest.raises(ValueError):
vtt2txt.parse_time(text)
vtt2txt.parse_user_time(text)
@pytest.mark.parametrize("start, end, suffix", [
(None, None, ""),
(197, 325, "_00-03-17_bis_00-05-25"),
(197, None, "_ab_00-03-17"),
(None, 325, "_bis_00-05-25"),
@pytest.mark.parametrize("text, seconds", [
("00:03:17.500", 197.5), ("03:17.500", 197.5), ("1:02:03.000", 3723),
])
def test_time_suffix(start, end, suffix):
assert vtt2txt.time_suffix(start, end) == suffix
def test_parse_cue_time_keeps_minutes_seconds(text, seconds):
assert vtt2txt.parse_cue_time(text) == seconds
def test_vtt_to_lines_exact_time_range():
@pytest.mark.parametrize("text, ranges", [
("00:5:00-00:6:00 00:10-00:12:00 00:25", [(300, 360), (600, 720), (1500, None)]),
("-00:20", [(None, 1200)]),
("00:25- 00:10-00:12 -00:05", [(None, 300), (600, 720), (1500, None)]),
("00:05-00:06 00:06-00:07", [(300, 360), (360, 420)]),
])
def test_parse_ranges(text, ranges):
assert vtt2txt.parse_ranges(text) == ranges
@pytest.mark.parametrize("text, message", [
("", "keine Bereiche"),
("-", "ungültiger Bereich"),
("00:06-00:05", "Start muss vor Ende"),
("00:05-00:10 00:08-00:12", "überschneiden"),
("00:05 00:10", "überschneiden"),
("-00:05 -00:10", "überschneiden"),
("00:05-x", "ungültiger Bereich"),
])
def test_parse_ranges_rejects(text, message):
with pytest.raises(ValueError, match=message):
vtt2txt.parse_ranges(text)
@pytest.mark.parametrize("ranges, suffix", [
([], ""),
([(197, 325)], "_00-03-17_bis_00-05-25"),
([(197, None)], "_ab_00-03-17"),
([(None, 325)], "_bis_00-05-25"),
([(300, 360), (600, 720), (1500, None)],
"_00-05-00_bis_00-06-00+00-10-00_bis_00-12-00+ab_00-25-00"),
])
def test_time_suffix(ranges, suffix):
assert vtt2txt.time_suffix(ranges) == suffix
# === Excerpts ===
def timed_cue(seconds, *lines):
return f"{vtt2txt.format_time(seconds)}.000 --> {vtt2txt.format_time(seconds + 1)}.000\n" \
+ "\n".join(lines) + "\n\n"
def captions(*cues):
"""VTT with one new line per cue: cues are (seconds, text)."""
return "WEBVTT\n\n" + "".join(timed_cue(t, text) for t, text in cues)
def block(vtt, start=None, end=None, exact=False):
"""Lines of the single excerpt of one time range ([] without captions)."""
blocks = vtt2txt.excerpts(vtt2txt.timed_lines(vtt), [(start, end)], exact)
return blocks[0][2] if blocks else []
SPEECH = captions(
(100, "Erster Satz ist hier."), (102, "Der zweite Satz"), (104, "beginnt vorher. Dann"),
(106, "kommt der dritte"), (108, "und endet. Der vierte"), (110, "läuft weiter"),
(112, "und endet auch. Schluss"), (114, "folgt."),
)
def test_exact_time_range():
vtt = "WEBVTT\n\n" + cue(1, "A") + cue(2, "A", "B") + cue(3, "B", "C") + cue(4, "C", "D")
assert vtt2txt.vtt_to_lines(vtt, start=2, end=4, exact=True) == ["B", "C"]
assert vtt2txt.vtt_to_lines(vtt, start=3, exact=True) == ["C", "D"]
assert vtt2txt.vtt_to_lines(vtt, end=2, exact=True) == ["A"]
assert block(vtt, 2, 4, exact=True) == ["B", "C"]
assert block(vtt, 3, exact=True) == ["C", "D"]
assert block(vtt, end=2, exact=True) == ["A"]
def test_vtt_to_lines_time_range_skips_window_from_before_start():
def test_time_range_skips_window_from_before_start():
# "B" was said before the range; its repetition in the first cue must not count
vtt = "WEBVTT\n\n" + cue(1, "A", "B") + cue(2, "B", "C")
assert vtt2txt.vtt_to_lines(vtt, start=2, exact=True) == ["C"]
assert block(vtt, 2, exact=True) == ["C"]
def test_snap_extends_both_sides_to_sentences():
# Range holds only "kommt der dritte" and "und endet. Der vierte"
assert block(SPEECH, 106, 110) == [
"Dann", "kommt der dritte", "und endet. Der vierte", "läuft weiter",
"und endet auch.",
]
def test_snap_keeps_range_already_at_sentence_bounds():
assert block(SPEECH, 102, 104) == ["Der zweite Satz", "beginnt vorher."]
assert block(SPEECH, 100, 102) == ["Erster Satz ist hier."]
def test_snap_reaches_start_and_end_of_captions():
assert block(SPEECH, 114) == ["Schluss", "folgt."]
speech = captions((0, "ohne Satzzeichen"), (2, "am Anfang. Rest"))
assert block(speech, 2) == ["ohne Satzzeichen", "am Anfang. Rest"]
def test_snap_cuts_exactly_without_sentence_end_in_reach():
far = vtt2txt.MAX_SNAP_SECONDS + 5
speech = captions(
(0, "Satz."), (2, "ein endloser"), (far, "Satz ohne"), (2 * far, "Ende"), (3 * far, "jetzt."),
)
assert block(speech, far, far + 1) == ["Satz ohne"]
def test_snap_exact_keeps_cut():
assert block(SPEECH, 106, 110, exact=True) == ["kommt der dritte", "und endet. Der vierte"]
def test_snap_closing_quote_belongs_to_sentence():
speech = captions((0, "Er sagte „Nein.“ Und"), (2, "dann"), (4, "ging er."))
assert block(speech, 2, 4) == ["Und", "dann", "ging er."]
def test_vtt_to_text_headers_with_actual_times():
text = vtt2txt.vtt_to_text(SPEECH, [(102, 104), (106, 110)])
assert text == (
"[00:01:42 - 00:01:45]\nDer zweite Satz\nbeginnt vorher.\n\n"
"[00:01:44 - 00:01:53]\nDann\nkommt der dritte\nund endet. Der vierte\n"
"läuft weiter\nund endet auch."
)
def test_vtt_to_text_merges_blocks_overlapping_after_snap():
# 106-110 grows to "und endet auch." (cue 112), 110-114 back to "Der vierte" (cue 108)
text = vtt2txt.vtt_to_text(SPEECH, [(106, 110), (110, 114)])
assert text == (
"[00:01:44 - 00:01:55]\nDann\nkommt der dritte\nund endet. Der vierte\n"
"läuft weiter\nund endet auch. Schluss\nfolgt."
)
def test_vtt_to_text_shared_line_split_at_sentence_end():
# Both blocks get their part of "und endet. Der vierte", without overlap
text = vtt2txt.vtt_to_text(SPEECH, [(106, 108), (110, 112)])
assert vtt2txt.split_segments(text) == [
("[00:01:44 - 00:01:49]", "Dann\nkommt der dritte\nund endet."),
("[00:01:48 - 00:01:53]", "Der vierte\nläuft weiter\nund endet auch."),
]
def test_vtt_to_text_skips_empty_range(capsys):
text = vtt2txt.vtt_to_text(SPEECH, [(0, 50), (100, 102), (200, None)])
assert text == "[00:01:40 - 00:01:41]\nErster Satz ist hier."
out = capsys.readouterr().out
assert "keine Untertitel im Bereich 00:00:00 – 00:00:50" in out
assert "keine Untertitel im Bereich 00:03:20 – Ende" in out
assert vtt2txt.vtt_to_text(SPEECH, [(200, None)]) == ""
def test_vtt_to_text_without_ranges_has_no_header():
assert vtt2txt.vtt_to_text(SPEECH).splitlines()[0] == "Erster Satz ist hier."
def test_split_segments():
text = "Vorspann\n\n[00:01:00 - 00:02:00]\nA\nB\n\n[applaus]\n[00:03:00 - 00:04:00]\n\nC\n"
assert vtt2txt.split_segments(text) == [
(None, "Vorspann"),
("[00:01:00 - 00:02:00]", "A\nB\n\n[applaus]"),
("[00:03:00 - 00:04:00]", "C"),
]
assert vtt2txt.split_segments("ohne\nÜberschrift") == [(None, "ohne\nÜberschrift")]
assert vtt2txt.split_segments(" \n") == []
def test_split_segments_inverts_join_segments():
text = vtt2txt.vtt_to_text(SPEECH, [(102, 104), (112, None)])
assert vtt2txt.join_segments(vtt2txt.split_segments(text)) == text
# === Command line ===
def test_main_time_range_default_name(sample_copy, monkeypatch):
run_main(monkeypatch, "-i", sample_copy, "-s", "0:30", "-e", "1:00")
run_main(monkeypatch, "-i", sample_copy, "-s", "0:00:30", "-e", "0:01")
target = sample_copy.with_name("ramstein.de_00-00-30_bis_00-01-00.txt")
full = SAMPLE_TXT.read_text(encoding="utf-8").splitlines()
part = target.read_text(encoding="utf-8").splitlines()
assert vtt2txt.HEADER.fullmatch(part[0])
assert 0 < len(part) < len(full)
def test_main_several_ranges(sample_copy, monkeypatch):
run_main(monkeypatch, "-i", sample_copy, "-z", "0:01-0:02 0:05")
target = sample_copy.with_name("ramstein.de_00-01-00_bis_00-02-00+ab_00-05-00.txt")
segments = vtt2txt.split_segments(target.read_text(encoding="utf-8"))
assert [header for header, _ in segments] == [
"[00:00:56 - 00:02:09]", "[00:04:57 - 00:48:07]",
]
def test_main_time_range_without_captions(sample_copy, monkeypatch, capsys):
with pytest.raises(SystemExit) as exc:
run_main(monkeypatch, "-i", sample_copy, "-s", "99:00:00")
@ -219,6 +383,9 @@ def test_main_time_range_without_captions(sample_copy, monkeypatch, capsys):
@pytest.mark.parametrize("args, message", [
(["-s", "abc"], "ungültige Zeit"),
(["-s", "1:00", "-e", "0:30"], "muss vor -e/--ende liegen"),
(["-z", "0:05-0:04"], "Start muss vor Ende"),
(["-z", "0:05 0:10"], "überschneiden"),
(["-z", "0:05", "-s", "0:01"], "nicht zusammen mit -s/-e"),
])
def test_cli_time_errors(sample_copy, monkeypatch, capsys, args, message):
with pytest.raises(SystemExit) as exc:
@ -227,68 +394,13 @@ def test_cli_time_errors(sample_copy, monkeypatch, capsys, args, message):
assert message in capsys.readouterr().err
def timed_cue(seconds, *lines):
return f"{vtt2txt.format_time(seconds)}.000 --> {vtt2txt.format_time(seconds + 1)}.000\n" \
+ "\n".join(lines) + "\n\n"
def captions(*cues):
"""VTT with one new line per cue: cues are (seconds, text)."""
return "WEBVTT\n\n" + "".join(timed_cue(t, text) for t, text in cues)
SPEECH = captions(
(100, "Erster Satz ist hier."), (102, "Der zweite Satz"), (104, "beginnt vorher. Dann"),
(106, "kommt der dritte"), (108, "und endet. Der vierte"), (110, "läuft weiter"),
(112, "und endet auch. Schluss"), (114, "folgt."),
)
def test_snap_extends_both_sides_to_sentences():
# Range holds only "kommt der dritte" and "und endet. Der vierte"
assert vtt2txt.vtt_to_lines(SPEECH, start=106, end=110) == [
"Dann", "kommt der dritte", "und endet. Der vierte", "läuft weiter",
"und endet auch.",
]
def test_snap_keeps_range_already_at_sentence_bounds():
assert vtt2txt.vtt_to_lines(SPEECH, start=102, end=104) == [
"Der zweite Satz", "beginnt vorher.",
]
assert vtt2txt.vtt_to_lines(SPEECH, start=100, end=102) == ["Erster Satz ist hier."]
def test_snap_reaches_start_and_end_of_captions():
assert vtt2txt.vtt_to_lines(SPEECH, start=114) == ["Schluss", "folgt."]
speech = captions((0, "ohne Satzzeichen"), (2, "am Anfang. Rest"))
assert vtt2txt.vtt_to_lines(speech, start=2) == ["ohne Satzzeichen", "am Anfang. Rest"]
def test_snap_cuts_exactly_without_sentence_end_in_reach():
far = vtt2txt.MAX_SNAP_SECONDS + 5
speech = captions(
(0, "Satz."), (2, "ein endloser"), (far, "Satz ohne"), (2 * far, "Ende"), (3 * far, "jetzt."),
)
assert vtt2txt.vtt_to_lines(speech, start=far, end=far + 1) == ["Satz ohne"]
def test_snap_exact_keeps_cut():
assert vtt2txt.vtt_to_lines(SPEECH, start=106, end=110, exact=True) == [
"kommt der dritte", "und endet. Der vierte",
]
def test_snap_closing_quote_belongs_to_sentence():
speech = captions((0, "Er sagte „Nein.“ Und"), (2, "dann"), (4, "ging er."))
assert vtt2txt.vtt_to_lines(speech, start=2, end=4) == ["Und", "dann", "ging er."]
def test_main_exact_option(tmp_path, monkeypatch):
source = tmp_path / "rede.vtt"
source.write_text(SPEECH, encoding="utf-8")
run_main(monkeypatch, "-i", source, "-s", "106", "-e", "110", "--exakt")
run_main(monkeypatch, "-i", source, "-s", "0:01:46", "-e", "0:01:50", "--exakt")
target = tmp_path / "rede_00-01-46_bis_00-01-50.txt"
assert target.read_text(encoding="utf-8") == "kommt der dritte\nund endet. Der vierte\n"
run_main(monkeypatch, "-i", source, "-s", "106", "-e", "110")
assert target.read_text(encoding="utf-8") == (
"[00:01:46 - 00:01:49]\nkommt der dritte\nund endet. Der vierte\n"
)
run_main(monkeypatch, "-i", source, "-s", "0:01:46", "-e", "0:01:50")
assert target.read_text(encoding="utf-8").endswith("und endet auch.\n")