2026-10-10 19:59:37 +02:00
|
|
|
|
"""Tests for aenderungen.py (change report raw transcript -> corrected text)."""
|
|
|
|
|
|
import html
|
|
|
|
|
|
import re
|
|
|
|
|
|
import sys
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
|
|
import aenderungen as ae
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def changes(raw, corrected):
|
|
|
|
|
|
return [(tag, " ".join(old), " ".join(new))
|
|
|
|
|
|
for tag, old, new in ae.compare(raw, corrected).changes()]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def report_text(page):
|
|
|
|
|
|
"""Corrected text as shown in the report, without original/deleted words."""
|
|
|
|
|
|
body = page.split("<h2>Korrigierter Text</h2>")[1]
|
|
|
|
|
|
body = re.sub(r"<del[^>]*>.*?</del>|<span class=orig>.*?</span>", "", body)
|
|
|
|
|
|
return html.unescape(re.sub(r"<[^>]+>", " ", body)).split()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("raw, corrected", [
|
|
|
|
|
|
("das ist gut", "Das ist gut."), # case, punctuation
|
|
|
|
|
|
("all das zerstritten", "All das – zerstritten."), # dash only
|
|
|
|
|
|
("wie z.B. hier", "wie z. B. hier"), # split abbreviation
|
|
|
|
|
|
("eins zwei", "eins\n\nzwei"), # paragraph break
|
|
|
|
|
|
])
|
|
|
|
|
|
def test_expected_edits_are_no_changes(raw, corrected):
|
|
|
|
|
|
assert changes(raw, corrected) == []
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-10 20:33:04 +02:00
|
|
|
|
@pytest.mark.parametrize("raw, corrected", [
|
|
|
|
|
|
("gibt's", "gibt’s"), # apostrophe variants
|
|
|
|
|
|
("er sagte Frieden", "Er sagte ‚Frieden‘."), # quotes at the edges
|
|
|
|
|
|
])
|
|
|
|
|
|
def test_apostrophes_and_quotes_unchanged(raw, corrected):
|
|
|
|
|
|
assert changes(raw, corrected) == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_apostrophe_inside_word_is_a_change():
|
|
|
|
|
|
assert changes("Egon Bahrs Politik", "Egon Bahr’s Politik") == [
|
|
|
|
|
|
("replace", "Bahrs", "Bahr’s"),
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_english_review_marks():
|
|
|
|
|
|
comparison = ae.compare("his name is Kocher", "His name is Kocher [check: Kocha].")
|
|
|
|
|
|
assert list(comparison.changes()) == []
|
|
|
|
|
|
assert sum(len(m) for m in comparison.marks.values()) == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-10 19:59:37 +02:00
|
|
|
|
def test_replace_insert_delete():
|
|
|
|
|
|
raw = "die gedeiliche Zusammenarbeit war das gut heute"
|
|
|
|
|
|
corrected = "die gedeihliche Zusammenarbeit war doch gut"
|
|
|
|
|
|
assert changes(raw, corrected) == [
|
|
|
|
|
|
("replace", "gedeiliche", "gedeihliche"),
|
|
|
|
|
|
("replace", "das", "doch"),
|
|
|
|
|
|
("delete", "heute", ""),
|
|
|
|
|
|
]
|
|
|
|
|
|
assert changes("ein Wort", "ein neues Wort") == [("insert", "", "neues")]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_change_summary_counts_and_sorts():
|
|
|
|
|
|
raw = "Kremmel und Kremmel und Bar"
|
|
|
|
|
|
corrected = "Kreml und Kreml und Bahr"
|
|
|
|
|
|
assert ae.change_summary(ae.compare(raw, corrected)) == [
|
|
|
|
|
|
("Kremmel", "Kreml", 2), ("Bar", "Bahr", 1),
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_review_marks_are_not_changes_but_shown():
|
|
|
|
|
|
corrected = "Er heißt Gerhard [prüfen: Ghard] Kocher."
|
|
|
|
|
|
comparison = ae.compare("Er heißt Gerhard Kocher", corrected)
|
|
|
|
|
|
assert list(comparison.changes()) == []
|
|
|
|
|
|
page = ae.render_html(comparison, "t")
|
|
|
|
|
|
assert "<span class=check>[prüfen: Ghard]</span>" in page
|
|
|
|
|
|
assert "1 Prüfmarken" in page
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_render_html_shows_text_original_and_paragraphs():
|
|
|
|
|
|
raw = "die gedeiliche Zusammenarbeit <b> ist gut heute neuer Absatz"
|
|
|
|
|
|
corrected = "Die gedeihliche Zusammenarbeit <b> ist gut.\n\nNeuer Absatz."
|
|
|
|
|
|
page = ae.render_html(ae.compare(raw, corrected), "Rede & Co")
|
|
|
|
|
|
|
|
|
|
|
|
assert report_text(page) == corrected.split()
|
|
|
|
|
|
assert "title='Original: gedeiliche'" in page
|
|
|
|
|
|
assert "<del title='gestrichen'>heute</del>" in page
|
|
|
|
|
|
assert "<b>" in page and "<b>" not in page.split("<body>")[1]
|
|
|
|
|
|
assert "Rede & Co" in page
|
|
|
|
|
|
assert page.split("<h2>Korrigierter Text</h2>")[1].count("<p>") == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_report_path():
|
|
|
|
|
|
assert ae.report_path(Path("a/rede.korrigiert.txt")) == Path("a/rede.aenderungen.html")
|
|
|
|
|
|
assert ae.report_path(Path("out.txt")) == Path("out.aenderungen.html")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_cli(tmp_path, monkeypatch, capsys):
|
|
|
|
|
|
raw = tmp_path / "rede.vtt"
|
|
|
|
|
|
raw.write_text(
|
|
|
|
|
|
"WEBVTT\n\n00:00:01.000 --> 00:00:02.000\ndas Kremmel heute\n", encoding="utf-8"
|
|
|
|
|
|
)
|
|
|
|
|
|
corrected = tmp_path / "rede.korrigiert.txt"
|
|
|
|
|
|
corrected.write_text("Das Kreml heute.\n", encoding="utf-8")
|
|
|
|
|
|
monkeypatch.setattr(sys, "argv", ["aenderungen.py", "-a", str(raw), "-b", str(corrected)])
|
|
|
|
|
|
ae.main()
|
|
|
|
|
|
|
|
|
|
|
|
assert (tmp_path / "rede.aenderungen.html").is_file()
|
|
|
|
|
|
out = capsys.readouterr().out
|
|
|
|
|
|
assert "1× „Kremmel“ → „Kreml“" in out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_cli_missing_file(tmp_path, monkeypatch, capsys):
|
|
|
|
|
|
monkeypatch.setattr(sys, "argv", ["aenderungen.py", "-a", "fehlt.txt", "-b", "fehlt.txt"])
|
|
|
|
|
|
with pytest.raises(SystemExit):
|
|
|
|
|
|
ae.main()
|
|
|
|
|
|
assert "Datei nicht gefunden" in capsys.readouterr().err
|
2026-10-11 21:32:06 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# === Suspect changes ===
|
|
|
|
|
|
|
|
|
|
|
|
WORDS = {"professor", "das", "dass", "ist", "haus", "unterschreiben", "bar"}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-11 21:53:17 +02:00
|
|
|
|
def suspects(model_input, corrected, wordlist=WORDS, glossary=(), fillers=ae.FILLERS["de"]):
|
|
|
|
|
|
found = ae.review_candidates(
|
|
|
|
|
|
ae.compare(model_input, corrected), wordlist, glossary, fillers
|
|
|
|
|
|
)
|
2026-10-11 21:32:06 +02:00
|
|
|
|
return [(s.reason, s.old, s.new) for s in found]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("model_input, corrected, expected", [
|
|
|
|
|
|
("Der Professor kam", "Der Professorin kam", [(ae.VALID_WORD, "Professor", "Professorin")]),
|
|
|
|
|
|
("wir unterschreiben das", "wir unterscheiden das",
|
|
|
|
|
|
[(ae.VALID_WORD, "unterschreiben", "unterscheiden")]),
|
|
|
|
|
|
("im CT1 heute", "im ZDF heute", [(ae.CHANGED_A_LOT, "CT1", "ZDF")]),
|
|
|
|
|
|
("er kam heute", "er ist kam heute", [(ae.INSERTED, "", "ist")]),
|
|
|
|
|
|
("er kam heute", "er kam", [(ae.DELETED, "heute", "")]),
|
|
|
|
|
|
])
|
|
|
|
|
|
def test_review_candidates_reports(model_input, corrected, expected):
|
|
|
|
|
|
assert suspects(model_input, corrected) == expected
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("model_input, corrected", [
|
|
|
|
|
|
("das Kremmel heute", "das Kreml heute"), # recognition error
|
|
|
|
|
|
("die Gradwanderung", "die Gratwanderung"),
|
|
|
|
|
|
("die Clan Kriminalität", "die Clankriminalität"), # spacing only
|
|
|
|
|
|
("KI generierte Videos", "KI-generierte Videos"), # hyphen only
|
|
|
|
|
|
("er äh kam ähm heute", "er kam heute"), # fillers
|
|
|
|
|
|
("das ist gut", "Das ist gut."), # case, punctuation
|
|
|
|
|
|
])
|
|
|
|
|
|
def test_review_candidates_ignores(model_input, corrected):
|
|
|
|
|
|
assert suspects(model_input, corrected) == []
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-11 21:53:17 +02:00
|
|
|
|
def test_review_candidates_glossary_spellings_are_not_reported():
|
|
|
|
|
|
glossary = ae.glossary_entries(["Egon Bahr", ""], [("Bars", "Bahrs")])
|
|
|
|
|
|
assert glossary == {("egon", "bahr"), ("bahrs",)}
|
|
|
|
|
|
assert suspects("Bar sagte", "Bahr sagte", glossary=glossary) == []
|
2026-10-11 21:32:06 +02:00
|
|
|
|
assert suspects("Bar sagte", "Bahr sagte") == [(ae.VALID_WORD, "Bar", "Bahr")]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-11 21:53:17 +02:00
|
|
|
|
@pytest.mark.parametrize("model_input, corrected, expected", [
|
|
|
|
|
|
# A common word of a longer entry explains nothing on its own
|
|
|
|
|
|
("das sagt ihn nicht", "Das sagt Sie nicht.", [(ae.CHANGED_A_LOT, "ihn", "Sie")]),
|
|
|
|
|
|
# Inserted words stay suspect even if they are in the glossary
|
|
|
|
|
|
("er kam heute", "er ist kam heute", [(ae.INSERTED, "", "ist")]),
|
|
|
|
|
|
("Dobrind sagte", "Alexander Dobrindt sagte",
|
|
|
|
|
|
[(ae.CHANGED_A_LOT, "Dobrind", "Alexander Dobrindt")]),
|
|
|
|
|
|
])
|
|
|
|
|
|
def test_review_candidates_glossary_explains_only_spellings(model_input, corrected, expected):
|
|
|
|
|
|
glossary = ae.glossary_entries(
|
|
|
|
|
|
["Alexander Dobrindt"], [("befinden Sie", "finden Sie"), ("is", "ist")]
|
|
|
|
|
|
)
|
|
|
|
|
|
assert suspects(model_input, corrected, glossary=glossary) == expected
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_review_candidates_fillers_per_language():
|
|
|
|
|
|
# "er" and "um" are real German words; in English they are fillers
|
|
|
|
|
|
assert suspects("dann kam er um zu helfen", "Dann kam, zu helfen.") == [
|
|
|
|
|
|
(ae.DELETED, "er um", "")
|
|
|
|
|
|
]
|
|
|
|
|
|
english = suspects("so er we um go", "So we go.", fillers=ae.FILLERS["en"])
|
|
|
|
|
|
assert english == []
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-11 21:32:06 +02:00
|
|
|
|
def test_review_candidates_without_wordlist():
|
|
|
|
|
|
# Similar replacements of real words go unnoticed, the rest still counts
|
|
|
|
|
|
assert suspects("Der Professor kam", "Der Professorin kam", wordlist=None) == []
|
|
|
|
|
|
assert suspects("im CT1 heute", "im ZDF heute", wordlist=None) == [
|
|
|
|
|
|
(ae.CHANGED_A_LOT, "CT1", "ZDF")
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_review_candidates_indices_point_into_corrected_words():
|
|
|
|
|
|
comparison = ae.compare("a b Professor c d", "A b Professorin c d.")
|
|
|
|
|
|
[suspect] = ae.review_candidates(comparison, WORDS, set())
|
|
|
|
|
|
assert comparison.words[suspect.j1:suspect.j2] == ["Professorin"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_load_wordlist(capsys):
|
|
|
|
|
|
assert "professor" in ae.load_wordlist("de")
|
|
|
|
|
|
assert ae.load_wordlist("en") is None
|
|
|
|
|
|
assert "keine Wortliste für „en“ (Paket wamerican)" in capsys.readouterr().out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def suspect_section(page):
|
|
|
|
|
|
return page.split("<h2>Zu prüfen")[1].split("<h2>Alle Änderungen")[0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_report_suspects_only_against_model_input(tmp_path, capsys):
|
|
|
|
|
|
target = tmp_path / "bericht.html"
|
|
|
|
|
|
# Glossary turned "Kremmel" into "Kreml" before the model; the model
|
|
|
|
|
|
# replaced "Professor" and deleted "heute" on its own
|
|
|
|
|
|
comparison = ae.write_report(
|
|
|
|
|
|
"der Professor im Kremmel heute dort", "Der Professorin im Kreml dort.", target,
|
|
|
|
|
|
"rede", model_input="der Professor im Kreml heute dort",
|
|
|
|
|
|
)
|
|
|
|
|
|
page = target.read_text(encoding="utf-8")
|
|
|
|
|
|
section = suspect_section(page)
|
|
|
|
|
|
|
|
|
|
|
|
assert [s.reason for s in comparison.suspects] == [ae.VALID_WORD, ae.DELETED]
|
|
|
|
|
|
assert "Zu prüfen (2)" in page
|
|
|
|
|
|
assert "Professorin" in section and "Kremmel" not in section
|
|
|
|
|
|
assert "<span class=warn title='gültiges Wort ersetzt: „Professor“ → „Professorin“'>" in page
|
|
|
|
|
|
assert "<span class=warn title='gestrichen: „heute“ → „∅“'>⚠</span>" in page
|
|
|
|
|
|
# Framing changes nothing about the text itself
|
|
|
|
|
|
assert report_text(page) == "Der Professorin im Kreml ⚠ dort.".split()
|
|
|
|
|
|
|
|
|
|
|
|
ae.print_summary(comparison)
|
|
|
|
|
|
out = capsys.readouterr().out
|
|
|
|
|
|
assert "Zu prüfen: 2 Stellen" in out
|
|
|
|
|
|
assert "gültiges Wort ersetzt: „Professor“ → „Professorin“" in out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_report_without_suspects(tmp_path):
|
|
|
|
|
|
target = tmp_path / "bericht.html"
|
|
|
|
|
|
comparison = ae.write_report("das Kremmel", "Das Kreml.", target, "rede")
|
|
|
|
|
|
assert comparison.suspects == []
|
|
|
|
|
|
assert "Keine auffälligen Änderungen" in suspect_section(target.read_text(encoding="utf-8"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_cli_glossary(tmp_path, monkeypatch, capsys):
|
|
|
|
|
|
raw = tmp_path / "rede.txt"
|
|
|
|
|
|
raw.write_text("das Kremmel und\nBar heute\n", encoding="utf-8")
|
|
|
|
|
|
corrected = tmp_path / "rede.korrigiert.txt"
|
|
|
|
|
|
corrected.write_text("Das Kreml und Bahr heute.\n", encoding="utf-8")
|
|
|
|
|
|
glossary = tmp_path / "g.txt"
|
|
|
|
|
|
glossary.write_text("Kremmel => Kreml\nEgon Bahr\n", encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
argv = ["aenderungen.py", "-a", str(raw), "-b", str(corrected)]
|
|
|
|
|
|
monkeypatch.setattr(sys, "argv", argv)
|
|
|
|
|
|
ae.main()
|
|
|
|
|
|
assert "gültiges Wort ersetzt: „Bar“ → „Bahr“" in capsys.readouterr().out
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(sys, "argv", [*argv, "-g", str(glossary)])
|
|
|
|
|
|
ae.main()
|
|
|
|
|
|
out = capsys.readouterr().out
|
|
|
|
|
|
assert "Zu prüfen: 0 Stellen" in out
|
|
|
|
|
|
# The report still compares against the raw transcript
|
|
|
|
|
|
assert "1× „Kremmel“ → „Kreml“" in out
|