epub2md/tools/qa/run_matrix.py
dschlueter 01f60a41c4 chore: QA-Skripte nach tools/qa übernommen
Die Werkzeuge der QA-Runden lagen nur im ignorierten .qa/: CLI-Matrix
(89 Fälle), Fixture-Generator, unabhängiger Markdown-Validator,
Wortvergleich gegen Datenverlust, Pandoc-Prüfung (HTML, EPUB, PDF).
Jetzt versioniert in tools/qa/ mit README und requirements.txt; erzeugte
Daten landen weiterhin in .qa/. Pfade auf die neue Lage angepasst
(Repo-Wurzel über parents[2], aufrufender Python-Interpreter).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:14:54 +02:00

418 lines
30 KiB
Python
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""CLI-Testmatrix gegen die QA-Fixtures. Jeder Lauf schreibt in ein neues Verzeichnis unter .qa/output/.
Ergebnis: .qa/output/matrix-results.json, Kommandos mit Rückgabecode in .qa/commands.log
"""
from __future__ import annotations
import json
import os
import re
import shutil
import stat
import subprocess
import sys
from datetime import datetime
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
QA = ROOT / ".qa"
FX = QA / "generated-fixtures"
OUT = QA / "output" / "matrix"
PY = sys.executable
sys.path.insert(0, str(Path(__file__).resolve().parent))
from validate_output import validate # noqa: E402
results: list[dict] = []
def run(args: list[str]) -> subprocess.CompletedProcess:
cmd = [PY, "epub2md.py", *args]
cp = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, timeout=600)
with open(QA / "commands.log", "a", encoding="utf-8") as fh:
rel = " ".join(a.replace(str(ROOT) + "/", "") for a in cmd[1:])
fh.write(f"{datetime.now().isoformat(timespec='seconds')}\trc={cp.returncode}\tpython {rel}\n")
return cp
def merge_case(name: str, book_dir: Path, expect_rc: int, checks=None) -> dict:
"""epub2md_merge.py on an existing output directory."""
cmd = [PY, "epub2md_merge.py", str(book_dir)]
cp = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, timeout=600)
with open(QA / "commands.log", "a", encoding="utf-8") as fh:
fh.write(f"{datetime.now().isoformat(timespec='seconds')}\trc={cp.returncode}\tpython epub2md_merge.py "
f"{str(book_dir).replace(str(ROOT) + '/', '')}\n")
entry: dict = {"case": name, "args": [str(book_dir)], "rc": cp.returncode, "expected_rc": expect_rc,
"ok": cp.returncode == expect_rc and "Traceback" not in cp.stderr, "notes": []}
for label, fn in checks or []:
good = bool(fn(book_dir, cp))
entry["ok"] = entry["ok"] and good
entry["notes"].append(("OK: " if good else "FEHLER: ") + label)
results.append(entry)
print(("PASS " if entry["ok"] else "FAIL ") + name, "rc=", cp.returncode)
return entry
def read(out: Path, pattern: str) -> str:
return "\n".join(p.read_text(encoding="utf-8") for p in sorted(out.glob(pattern)))
def case(name: str, fixture: str, args: list[str], expect_rc: int, checks=None, validate_out: bool = True,
out: Path | None = None, keep: bool = False) -> dict:
out = out or OUT / name
if out.exists() and not keep:
shutil.rmtree(out)
src = str(FX / fixture) if fixture and not fixture.startswith("/") else fixture
argv = ([src] if src else []) + ([str(out)] if src else []) + args
cp = run(argv)
entry: dict = {"case": name, "fixture": fixture, "args": args, "rc": cp.returncode, "expected_rc": expect_rc,
"ok": cp.returncode == expect_rc, "notes": [], "stderr_tail": cp.stderr.strip().splitlines()[-3:]}
if not entry["ok"]:
entry["notes"].append(f"Rückgabecode {cp.returncode}, erwartet {expect_rc}")
if "Traceback (most recent call last)" in cp.stderr:
entry["ok"] = False
entry["notes"].append("Traceback im Normalmodus")
if checks:
for label, fn in checks:
try:
good = bool(fn(out, cp))
except Exception as exc: # noqa: BLE001
good = False
label = f"{label} (Exception {type(exc).__name__}: {exc})"
if not good:
entry["ok"] = False
entry["notes"].append(("OK: " if good else "FEHLER: ") + label)
if validate_out and cp.returncode == 0 and out.exists() and "--dry-run" not in args:
v = validate(out)
entry["validator"] = v["severity_counts"]
entry["validator_issues"] = [i for i in v["issues"] if i["severity"] != "info"][:15]
if any(i["severity"] in ("critical", "high") for i in v["issues"]):
entry["ok"] = False
entry["notes"].append("Validator: kritische/hohe Befunde")
results.append(entry)
print(("PASS " if entry["ok"] else "FAIL ") + name, "rc=", cp.returncode, "; ".join(n for n in entry["notes"] if n.startswith("FEHLER") or "erwartet" in n or "Validator" in n or "Traceback" in n))
return entry
def ch(out: Path, prefix: str = "") -> str:
return read(out / "chapters", f"{prefix}*.md")
def report(out: Path) -> dict:
return json.loads((out / "conversion-report.json").read_text(encoding="utf-8"))
def main() -> None:
if OUT.exists():
shutil.rmtree(OUT)
OUT.mkdir(parents=True)
# --- help
cp = run(["--help"])
opts = ["--format", "--extract-media", "--no-extract-media", "--copy-original", "--chapter-level", "--toc", "--no-toc",
"--frontmatter", "--no-frontmatter", "--footnotes", "--math", "--images", "--slug-language", "--keep-html",
"--strict", "--force", "--verbose", "--dry-run", "--report", "--code-language", "--keep-wrap-markers",
"--keep-invisible-chars", "--css-emphasis", "--no-css-emphasis", "--boxes", "--styles", "--page-markers"]
missing = [o for o in opts if o not in cp.stdout]
results.append({"case": "help", "rc": cp.returncode, "expected_rc": 0, "ok": cp.returncode == 0 and not missing,
"notes": [f"fehlende Optionen in --help: {missing}"] if missing else ["alle Optionen dokumentiert"]})
print("PASS help" if not missing else f"FAIL help {missing}")
# --- basics on all good fixtures
for fx in ["01-minimal-epub2", "02-minimal-epub3-nav", "03-chapters-and-links", "04-code-and-tables", "05-images-and-svg",
"06-mathml-and-equations", "07-footnotes-and-endnotes", "08-glossary-bibliography-index", "09-complex-table",
"12-encoding-edge-cases"]:
case(f"basic-{fx[:2]}", f"{fx}.epub", [], 0)
f03 = "03-chapters-and-links.epub"
case("dry-run", f03, ["--dry-run", "--report", str(OUT / "dry-report.json")], 0, [
("kein Ausgabeordner", lambda o, c: not o.exists()),
("kein Report", lambda o, c: not (OUT / "dry-report.json").exists()),
("Planung im stdout", lambda o, c: "chapters/001-" in c.stdout)])
case("verbose", f03, ["--verbose"], 0, [("Schritte auf stderr", lambda o, c: "Rendere" in c.stderr and "Kapitel gebildet" in c.stderr)])
case("chapter-level-1", f03, ["--chapter-level", "1"], 0, [
("3+1 Kapitel (A, A2, B, C)", lambda o, c: len(list((o / "chapters").glob("*.md"))) == 4)])
case("chapter-level-2", f03, ["--chapter-level", "2"], 0, [
("h2 erzeugt eigene Dateien", lambda o, c: len(list((o / "chapters").glob("*.md"))) == 7)])
case("links-03", f03, [], 0, [
("Link Kapitel→Kapitel", lambda o, c: "[Kapitel B](003-kapitel-b.md)" in ch(o, "001")),
("Anker im selben EPUB-Dokument, anderes Kapitel", lambda o, c: "[Anker unten](002-kapitel-a2-in-derselben-datei.md#a-unten)" in ch(o, "001")),
("Anker in anderem Kapitel", lambda o, c: "[Ziel in B](003-kapitel-b.md#b-ziel)" in ch(o, "001")),
("nicht auflösbar → Text + Report", lambda o, c: "Nichts" in ch(o, "001") and "nirgendwo" not in ch(o, "001")
and any(u["href"] == "nirgendwo.xhtml#weg" for u in report(o)["unresolved_links"])),
("fehlender Anker → Warnung", lambda o, c: any(w["code"] == "anchor-missing" for w in report(o)["warnings"]))])
f04 = "04-code-and-tables.epub"
case("format-gfm", f04, ["--format", "gfm"], 0, [
("Tabelle mit | in Code als HTML (QA2-01)", lambda o, c: "<code>ls | wc -l</code>" in ch(o) and "| Befehl |" not in ch(o))])
case("format-commonmark", f04, ["--format", "commonmark"], 0, [("HTML-Tabelle statt Pipe", lambda o, c: "<table>" in ch(o) and "| Befehl |" not in ch(o))])
case("code-04", f04, [], 0, [
("Python-Einrückung + Tab exakt", lambda o, c: " def push(self, x):\n\tself.items.append(x) # Tab-Einrückung\n if len(self.items) > 10 and x < 0:" in ch(o)),
("Sprachlabel python", lambda o, c: "```python\nclass Stack:" in ch(o)),
("Shell exakt, Label aus class=shell", lambda o, c: "```bash\n$ find . -name '*.py' | xargs grep -n \"TODO\" \\\n | sort -u > todo.txt\n$ echo $HOME\n```" in ch(o)),
("Fence länger als Backticks im Code", lambda o, c: "````\nMarkdown mit ```fences```" in ch(o)),
("json via data-language", lambda o, c: "```json\n{" in ch(o)),
("c via #include", lambda o, c: "```c\n#include <stdio.h>" in ch(o)),
("Inline-Code mit < & ", lambda o, c: "`len(x) < 3 && y`" in ch(o)),
("Inline-Code mit Backtick", lambda o, c: "``a`b``" in ch(o)),
("verschachtelte Listen", lambda o, c: "- Punkt 1\n\n - Punkt 1.1\n\n 1. Schritt a\n 2. Schritt b" in ch(o) or "- Punkt 1\n - Punkt 1.1" in ch(o)),
("ol start=3", lambda o, c: "3. drei" in ch(o)),
("unbekanntes Element gemeldet", lambda o, c: "x-widget" in report(o)["unsupported_elements"] and "Widget-Inhalt" in ch(o))])
case("keep-html", f04, ["--keep-html"], 0, [
("unbekanntes Element als HTML, ohne onclick", lambda o, c: '<x-widget data-k="v">Widget-Inhalt</x-widget>' in ch(o) and "evil" not in ch(o))])
f05 = "05-images-and-svg.epub"
case("images-relative", f05, ["--images", "relative"], 0, [
("Foto einmal extrahiert", lambda o, c: len([p for p in (o / "media/images").iterdir() if "foto" in p.name]) == 1),
("SVG bleibt SVG", lambda o, c: any(p.suffix == ".svg" for p in (o / "media/images").iterdir())),
("alt + title", lambda o, c: '![Ein Foto](../media/images/' in ch(o) and '"Fototitel")' in ch(o)),
("Bildunterschrift", lambda o, c: "*Abbildung 1.1: Ein Foto mit Unterschrift.*" in ch(o)),
("Caption als Alt bei leerem alt", lambda o, c: "![Abbildung 1.2: Ein SVG-Diagramm.](" in ch(o)),
("Inline-SVG extrahiert", lambda o, c: any(m["role"] == "inline-svg" for m in report(o)["media"]["files"])),
("fehlendes Bild gewarnt", lambda o, c: any(w["code"] == "image-missing" for w in report(o)["warnings"])),
("alles im Ausgabeordner", lambda o, c: all(o.resolve() in p.resolve().parents for p in o.rglob("*")))])
case("images-flat", f05, ["--images", "flat"], 0, [("media/<name>", lambda o, c: (o / "media" / "foto 1 ä.png").exists())])
case("images-original-path", f05, ["--images", "original-path"], 0, [("media/images/<orig>", lambda o, c: (o / "media/images/foto 1 ä.png").exists())])
case("no-extract-media", f05, ["--no-extract-media"], 0, [("kein media/", lambda o, c: not (o / "media").exists()),
("Platzhalter statt Link", lambda o, c: "Bild: Ein Foto" in ch(o))])
case("extract-media", f05, ["--extract-media"], 0, [("media/ vorhanden", lambda o, c: (o / "media").exists())])
f06 = "06-mathml-and-equations.epub"
math_common = [
("Gleichungsnummer sichtbar", lambda o, c: "(2.1)" in ch(o) and "(2.2)" in ch(o)),
("SVG-Formel als SVG-Bild", lambda o, c: "![Formel E=mc²](" in ch(o)),
("Bildformel als Bild", lambda o, c: "![Summe über i](" in ch(o)),
("unbekanntes MathML nicht verloren", lambda o, c: "menclose" in ch(o))]
case("math-auto", f06, ["--math", "auto"], 0, math_common + [
("Bruch", lambda o, c: r"$\frac{a}{b}$" in ch(o)),
("Hoch/Index", lambda o, c: "$x^{2}$" in ch(o) and "$a_{i}$" in ch(o) and "$x_{i}^{2}$" in ch(o)),
("Block-Formel", lambda o, c: "$$\ne^{i \\pi } + 1 = 0\n$$" in ch(o) or "$$\ne^{i \\pi} + 1 = 0\n$$" in ch(o)),
("TeX-Annotation bevorzugt", lambda o, c: r"$\vec{y}$" in ch(o)),
("TeX aus alt-Bild", lambda o, c: r"\int_0^1 x\,dx = \frac{1}{2}" in ch(o)),
("Warnungen im Report", lambda o, c: {"mathml-kept", "formula-image"} <= {w["code"] for w in report(o)["warnings"]}),
("SVG-Formel als svg im Report", lambda o, c: "svg" in {f["origin"] for f in report(o)["formulas"]["items"]})])
case("math-tex", f06, ["--math", "tex"], 0, math_common + [
("\\( \\) Begrenzer", lambda o, c: r"\(\frac{a}{b}\)" in ch(o)),
("keine $-Begrenzer", lambda o, c: not re.search(r"(?<!\\)\$", ch(o).split("---\n", 2)[2]))])
case("math-mathml", f06, ["--math", "mathml"], 0, math_common + [("MathML erhalten", lambda o, c: "<mfrac><mi>a</mi><mi>b</mi></mfrac>" in ch(o))])
case("math-image", f06, ["--math", "image"], 0, math_common + [("TeX-Bild als Bild", lambda o, c: "tex" in " ".join(m["source"] for m in report(o)["media"]["files"]))])
f07 = "07-footnotes-and-endnotes.epub"
fn_common = [("Fußnotentext vorhanden", lambda o, c: "Erster Absatz mit" in read(o, "chapters/*.md") and "Zweiter Absatz." in read(o, "chapters/*.md")),
("Endnote vorhanden", lambda o, c: "Eine Endnote mit Rücklink." in read(o, "chapters/*.md")),
("Formel in Fußnote", lambda o, c: "x^{3}" in read(o, "chapters/*.md")),
("kaputter Verweis gewarnt", lambda o, c: any(w["code"] == "noteref-unresolved" for w in report(o)["warnings"]))]
case("footnotes-end-of-chapter", f07, ["--footnotes", "end-of-chapter"], 0, fn_common + [
("Mehrfachverweis auf eine Definition", lambda o, c: ch(o, "001").count("[^fn-001]") == 3),
("keine Doppelung im Fließtext", lambda o, c: ch(o, "001").count("Erster Absatz") == 1)])
case("footnotes-end-of-book", f07, ["--footnotes", "end-of-book"], 0, fn_common + [
("Anmerkungsdatei", lambda o, c: (o / "chapters" / "999-anmerkungen.md").exists()),
("Rücklinks", lambda o, c: "[↩](001-" in ch(o, "999"))])
case("footnotes-inline", f07, ["--footnotes", "inline"], 0, fn_common + [("inline", lambda o, c: "(Anmerkung: Erster Absatz" in ch(o, "001"))])
case("footnotes-keep-links", f07, ["--footnotes", "keep-links"], 0, fn_common + [("Links", lambda o, c: "](#fn1)" in ch(o, "001"))])
f08 = "08-glossary-bibliography-index.epub"
case("special-sections", f08, [], 0, [
("Typen erkannt", lambda o, c: {s["type"] for s in report(o)["special_sections"]} >= {"glossary", "bibliography", "index", "index-persons"}),
("Glossarbegriff", lambda o, c: "**API:** Application Programming Interface" in ch(o)),
("mehrteilige Definition", lambda o, c: "Selbstaufruf einer Funktion." in ch(o) and "Siehe auch Iteration." in ch(o)),
("Literatur vollständig", lambda o, c: all(s in ch(o) for s in ("ISBN 978-0-201-89683-1", "S. 1–650", "(Zugriff: 01.02.2024)"))),
("DOI/URL als Link", lambda o, c: "](https://doi.org/10.1000/xyz123)" in ch(o) and "](https://example.org/seite?a=1&b=2)" in ch(o)),
("Registerlink ins Kapitel", lambda o, c: "[17](001-hauptteil.md#idx-rekursion)" in ch(o)),
("Seitenzahl als Text", lambda o, c: ", 42" in ch(o)),
("Untereintrag eingerückt", lambda o, c: "– endständige, 45" in ch(o)),
("Buchstabengruppen", lambda o, c: "## R" in ch(o)),
("Querverweis ins Glossar", lambda o, c: "[API](002-glossar.md#g-api)" in ch(o, "001"))])
case("complex-table", "09-complex-table.epub", [], 0, [
("HTML-Tabelle mit colspan/rowspan", lambda o, c: 'rowspan="2"' in ch(o) and 'colspan="2"' in ch(o)),
("Zellinhalte vollständig", lambda o, c: all(s in ch(o) for s in ("1,5", "korrigiert", "2,0", "ausgefallen", "siehe Text", "Messwerte"))),
("Warnung", lambda o, c: any(w["code"] == "table-html" for w in report(o)["warnings"]))])
f12 = "12-encoding-edge-cases.epub"
enc_text = "Ärger über Öl und Straße – „deutsche Anführungszeichen“, ‚einfach‘ und «guillemets» — café, crème brûlée"
case("slug-de", f12, ["--slug-language", "de"], 0, [("ä→ae, ß→ss", lambda o, c: (o / "chapters/001-groesse-uebermass.md").exists()),
("Text unverändert UTF-8", lambda o, c: enc_text in ch(o) and "αβγ ΔΣΩ" in ch(o) and "∀x∈ℝ: x² ≥ 0" in ch(o))])
case("slug-en", f12, ["--slug-language", "en"], 0, [("ä→a", lambda o, c: (o / "chapters/001-grosse-ubermass.md").exists())])
case("slug-none", f12, ["--slug-language", "none"], 0, [("Unicode", lambda o, c: (o / "chapters/001-größe-übermaß.md").exists())])
f02 = "02-minimal-epub3-nav.epub"
case("copy-original", f02, ["--copy-original"], 0, [("source/", lambda o, c: (o / "source" / f02).exists())])
case("no-copy-original", f02, ["--no-copy-original"], 0, [("kein source/", lambda o, c: not (o / "source").exists())])
case("toc", f02, ["--toc"], 0, [("README mit TOC", lambda o, c: "## Inhaltsverzeichnis" in (o / "README.md").read_text())])
case("no-toc", f02, ["--no-toc"], 0, [("kein README", lambda o, c: not (o / "README.md").exists())])
case("frontmatter", f02, ["--frontmatter"], 0, [("YAML", lambda o, c: ch(o).startswith("---\n"))])
case("no-frontmatter", f02, ["--no-frontmatter"], 0, [("kein YAML", lambda o, c: not ch(o).startswith("---"))])
rep = OUT / "reports" / "r.json"
case("report", f02, ["--report", str(rep)], 0, [("Report-Datei", lambda o, c: json.loads(rep.read_text())["tool"] == "epub2md")])
# --- force / existing directory
fo = OUT / "force-target"
case("force-1-initial", f02, [], 0, out=fo)
case("force-2-exists-no-force", f02, [], 1, [("Meldung", lambda o, c: "existiert bereits" in c.stderr)], out=fo, keep=True, validate_out=False)
(fo / "zusatz.txt").write_text("x")
case("force-3-with-force", f02, ["--force"], 0, [("ersetzt", lambda o, c: not (o / "zusatz.txt").exists())], out=fo, keep=True)
foreign = OUT / "fremd"
foreign.mkdir()
(foreign / "wichtig.txt").write_text("nicht löschen")
case("force-4-foreign-dir", f02, ["--force"], 1, [("fremder Ordner bleibt", lambda o, c: (o / "wichtig.txt").exists())],
out=foreign, keep=True, validate_out=False)
# --- invalid options
case("invalid-math", f02, ["--math", "foo"], 1, [("Meldung", lambda o, c: "invalid choice" in c.stderr)], validate_out=False)
case("invalid-chapter-level", f02, ["--chapter-level", "0"], 1, validate_out=False)
case("invalid-chapter-level-text", f02, ["--chapter-level", "x"], 1, validate_out=False)
case("missing-input", "/nonexistent/datei.epub", [], 1, validate_out=False)
case("unknown-option", f02, ["--gibt-es-nicht"], 1, validate_out=False)
# --- broken input
case("broken-container", "11-broken-container.epub", [], 2, [("verständlich", lambda o, c: "OPF" in c.stderr), ("keine Ausgabe", lambda o, c: not o.exists())], validate_out=False)
case("broken-spine", "11b-broken-spine.epub", [], 0, [("Fehler im Report", lambda o, c: report(o)["status"] == "success-with-errors")])
case("broken-spine-strict", "11b-broken-spine.epub", ["--strict"], 3, [("keine Ausgabe", lambda o, c: not o.exists())], validate_out=False)
case("broken-opf-xml", "11c-broken-opf-xml.epub", [], 3, [("keine Ausgabe", lambda o, c: not o.exists())], validate_out=False)
case("missing-spine-file", "11d-missing-spine-file.epub", [], 3, [("verständlich", lambda o, c: "Spine" in c.stderr)], validate_out=False)
case("not-a-zip", "11e-not-a-zip.epub", [], 2, validate_out=False)
case("missing-container", "11f-missing-container.epub", [], 2, validate_out=False)
case("broken-xhtml", "11g-broken-xhtml.epub", [], 0, [("Text gerettet", lambda o, c: "nicht geschlossen" in ch(o)),
("Hinweis html-fallback/Entity", lambda o, c: True)])
case("strict-ok", "01-minimal-epub2.epub", ["--strict"], 0)
# --- security
escape = Path("/tmp/qa-epub2md-escape.txt")
escape.unlink(missing_ok=True)
case("malicious", "10-malicious-paths.epub", [], 0, [
("nichts außerhalb geschrieben", lambda o, c: not escape.exists() and not (ROOT / "outside.txt").exists()
and not (QA / "outside.txt").exists() and not (OUT / "outside.txt").exists()),
("alle Dateien im Zielordner", lambda o, c: all(o.resolve() in p.resolve().parents for p in o.rglob("*"))),
("unsichere Einträge gemeldet", lambda o, c: any(e["code"] == "unsafe-zip-path" for e in report(o)["errors"])),
("unsicherer Manifest-Pfad gemeldet", lambda o, c: any(e["code"] == "unsafe-href" for e in report(o)["errors"]))])
case("malicious-strict", "10-malicious-paths.epub", ["--strict"], 2, [("keine Ausgabe", lambda o, c: not o.exists())], validate_out=False)
# --- write error: parent directory not writable
ro = OUT / "readonly"
ro.mkdir()
os.chmod(ro, stat.S_IRUSR | stat.S_IXUSR)
try:
case("write-error", f02, [], 1, [("verständliche Meldung", lambda o, c: "Traceback" not in c.stderr and c.stderr.strip() != "")],
out=ro / "ziel", validate_out=False)
finally:
os.chmod(ro, stat.S_IRWXU)
# --- typography (P1–P7): CSS emphasis, invisible chars, scene breaks, poems, boxes, styles, page markers
f14 = "14-typography.epub"
body = lambda o: ch(o, "001") # noqa: E731
boxes = lambda o: ch(o, "002") # noqa: E731
case("typo-default", f14, [], 0, [
("CSS-kursiv/fett/unterstrichen", lambda o, c: "Ein *kursives* und **fettes** Wort, <u>unterstrichen</u>." in body(o)),
("display:none entfällt ohne Leerzeichen-Rest", lambda o, c: "VERSTECKT" not in body(o) and "</u>." in body(o)),
("@media print ignoriert", lambda o, c: "***kursives***" not in body(o)),
("unsichtbare Zeichen im Text entfernt", lambda o, c: "Nullbreite und weiches Trennzeichen." in body(o)),
("unsichtbare Zeichen im Code erhalten", lambda o, c: "x = 1\u200b # bleibt" in body(o)),
("Szenenwechsel", lambda o, c: "Szene eins endet hier.\n\n* * *\n\nSzene zwei beginnt hier." in body(o)),
("Gedicht als Zitatblock", lambda o, c: "> Zeile eins\\\n> Zeile zwei" in body(o)),
("semantic: <mark>, keine Stile", lambda o, c: "<mark>markierter</mark>" in body(o) and "style=" not in body(o)),
("Kasten mit Klassentyp und Titel ohne Symbol", lambda o, c: "> [!NOTE]\n> **Wichtige Regel**\n>\n> Immer testen." in boxes(o)),
("aside tip", lambda o, c: "> [!TIP]\n> Zwischendurch speichern." in boxes(o)),
("keine Seitenmarken", lambda o, c: "S. 12" not in body(o)),
("Bericht", lambda o, c: report(o)["layout"] == {"scene_breaks": 1, "poems": 1, "boxes": 2, "page_markers": 0}
and report(o)["text_cleanup"]["invisible_chars_removed"] == 3 and report(o)["css"]["hidden_elements"] == 1),
("Buch-README mit Pandoc-Filter", lambda o, c: "--lua-filter" in (o / "README.md").read_text(encoding="utf-8"))])
case("typo-no-css-emphasis", f14, ["--no-css-emphasis"], 0, [
("keine CSS-Hervorhebung, Verstecktes bleibt", lambda o, c: "Ein kursives und fettes Wort" in body(o) and "VERSTECKT" in body(o))])
case("typo-keep-invisible-chars", f14, ["--keep-invisible-chars"], 0, [
("Zeichen erhalten", lambda o, c: "Null\u200bbreite" in body(o) and "wei\u00adches" in body(o)),
("Bericht 0", lambda o, c: report(o)["text_cleanup"]["invisible_chars_removed"] == 0)])
case("typo-styles-html", f14, ["--styles", "html"], 0, [
("Farbe/Größe/Kapitälchen/Hintergrund", lambda o, c: all(x in body(o) for x in (
'<span style="color: #cc0000">roter</span>', '<span style="font-size: 1.6em">großer</span>',
'<span style="font-variant: small-caps">Kapitälchen</span>',
'<span style="background-color: #ffff00">markierter</span>'))),
("Sprache", lambda o, c: '<span lang="en">an English phrase</span>' in body(o)),
("Ausrichtung", lambda o, c: '<div style="text-align: center">\n\nZentrierte Widmung\n\n</div>' in body(o)),
("Grundschrift nicht wiederholt", lambda o, c: "Georgia" not in body(o))])
case("typo-page-markers-visible", f14, ["--page-markers", "visible"], 0, [
("Marker", lambda o, c: "Seite elf endet\\[S. 12\\] und Seite zwölf geht weiter\\[S. 13\\] bis dreizehn." in body(o)),
("Bericht", lambda o, c: report(o)["layout"]["page_markers"] == 2)])
case("typo-page-markers-comment", f14, ["--page-markers", "comment"], 0, [
("Kommentar", lambda o, c: "endet<!-- S. 12 --> und" in body(o) and "weiter<!-- S. 13 --> bis" in body(o))])
case("typo-boxes-quote", f14, ["--boxes", "quote"], 0, [
("Zitatblock ohne [!", lambda o, c: "> **Wichtige Regel**\n>\n> Immer testen." in boxes(o) and "[!" not in boxes(o))])
case("typo-boxes-html", f14, ["--boxes", "html"], 0, [
("div mit Typklasse", lambda o, c: '<div class="box box-note">' in boxes(o) and '<div class="box box-tip">' in boxes(o))])
case("typo-boxes-plain", f14, ["--boxes", "plain"], 0, [
("normaler Text", lambda o, c: "Immer testen." in boxes(o) and "> " not in boxes(o) and "<div" not in boxes(o)),
("Bericht 0 Kästen, kein Filter", lambda o, c: report(o)["layout"]["boxes"] == 0
and "callouts.lua" not in (o / "README.md").read_text(encoding="utf-8"))])
case("invalid-boxes", f14, ["--boxes", "bunt"], 1, [("Meldung", lambda o, c: "invalid choice" in c.stderr)], validate_out=False)
case("invalid-styles", f14, ["--styles", "css"], 1, validate_out=False)
case("invalid-page-markers", f14, ["--page-markers", "ja"], 1, validate_out=False)
# --- epub2md_merge.py on a converted book (+ Pandoc filter if Pandoc is installed)
merge_case("merge-typo", OUT / "typo-default", 0, [
("Gesamtdatei mit Original-Namen", lambda o, c: (o / "14-typography.md").exists()),
("Inhalt beider Kapitel", lambda o, c: all(x in (o / "14-typography.md").read_text(encoding="utf-8")
for x in ("*kursives*", "> [!NOTE]", "Zwischendurch speichern.")))])
merge_case("merge-again-no-force", OUT / "typo-default", 1, [("Meldung", lambda o, c: c.stderr.strip() != "")])
merge_case("merge-missing-dir", OUT / "gibt-es-nicht", 1)
if shutil.which("pandoc"):
src = OUT / "typo-default" / "14-typography.md"
cp = subprocess.run(["pandoc", str(src), "-s", "--lua-filter", str(ROOT / "pandoc" / "callouts.lua"), "-o", "-",
"-t", "html"], capture_output=True, text=True, timeout=120)
good = cp.returncode == 0 and 'class="callout callout-note"' in cp.stdout and "[!NOTE]" not in cp.stdout \
and "<strong>Tipp</strong>" in cp.stdout
results.append({"case": "pandoc-callouts-filter", "ok": good, "rc": cp.returncode,
"notes": ["Kästen als div.callout, keine [!…]-Marken" if good else cp.stderr[-300:]]})
print(("PASS " if good else "FAIL ") + "pandoc-callouts-filter")
cp = subprocess.run(["pandoc", str(src), "-s", "--lua-filter", str(ROOT / "pandoc" / "book.lua"), "-t", "html"],
capture_output=True, text=True, timeout=120)
h = cp.stdout
pos = [h.find(x) for x in ('id="title-block-header"', 'id="TOC"', 'class="chapter-start"')]
good = cp.returncode == 0 and -1 not in pos and pos == sorted(pos) and h.count('id="title-block-header"') == 1 \
and "epub2md-toc" not in h and 'href="#' in h[pos[1]:pos[2]]
results.append({"case": "pandoc-book-filter", "ok": good, "rc": cp.returncode,
"notes": ["Titelkopf → Inhaltsverzeichnis → Kapitel, Platzhalter ersetzt" if good else f"Reihenfolge {pos}"]})
print(("PASS " if good else "FAIL ") + "pandoc-book-filter")
step1 = OUT / "typo-step1.html"
subprocess.run(["pandoc", str(src), "-s", "--lua-filter", str(ROOT / "pandoc" / "book.lua"), "-o", str(step1)],
capture_output=True, text=True, timeout=120)
cp = subprocess.run(["pandoc", str(step1), "-s", "--lua-filter", str(ROOT / "pandoc" / "book.lua"), "-t", "latex"],
capture_output=True, text=True, timeout=120, cwd=OUT)
t = cp.stdout
good = cp.returncode == 0 and t.count("\\tableofcontents") == 1 and "\\maketitle" not in t \
and t.index("Typografie}") < t.index("\\tableofcontents") and "a4paper" in t
results.append({"case": "pandoc-book-filter-latex", "ok": good, "rc": cp.returncode,
"notes": ["LaTeX: Titel → \\tableofcontents, kein \\maketitle, A4" if good else "LaTeX-Aufbau falsch"]})
print(("PASS " if good else "FAIL ") + "pandoc-book-filter-latex")
hdr = OUT / "header.html"
hdr.write_text("<style>body { color: black; }</style>\n", encoding="utf-8")
cp = subprocess.run(["pandoc", str(src), "-s", "-H", str(hdr), "--lua-filter", str(ROOT / "pandoc" / "callouts.lua"),
"-t", "html"], capture_output=True, text=True, timeout=120)
good = cp.returncode == 0 and ".callout-tip {" in cp.stdout
results.append({"case": "pandoc-callouts-filter-with-H", "ok": good, "rc": cp.returncode,
"notes": ["Kasten-CSS trotz -H vorhanden" if good else "Kasten-CSS fehlt bei -H"]})
print(("PASS " if good else "FAIL ") + "pandoc-callouts-filter-with-H")
epub = OUT / "typo.epub"
cp = subprocess.run(["pandoc", str(src), "--lua-filter", str(ROOT / "pandoc" / "callouts.lua"), "-o", str(epub)],
capture_output=True, text=True, timeout=120)
import zipfile
with zipfile.ZipFile(epub) as z:
css = "".join(z.read(n).decode() for n in z.namelist() if n.endswith(".css"))
chapters = [z.read(n).decode() for n in z.namelist() if "/ch0" in n]
good = cp.returncode == 0 and ".callout-tip {" in css and "font-family" in css \
and all("stylesheet" in c for c in chapters) and any('class="callout' in c for c in chapters)
results.append({"case": "pandoc-callouts-filter-epub", "ok": good, "rc": cp.returncode,
"notes": ["EPUB: Kasten-CSS + Standard-CSS, in allen Kapiteln verlinkt" if good else "EPUB-CSS fehlt"]})
print(("PASS " if good else "FAIL ") + "pandoc-callouts-filter-epub")
# --- determinism
a = case("determinism-a", "06-mathml-and-equations.epub", [], 0, out=OUT / "det-a")
b = case("determinism-b", "06-mathml-and-equations.epub", [], 0, out=OUT / "det-b")
diff = [p.name for p in (OUT / "det-a/chapters").glob("*.md") if p.read_bytes() != (OUT / "det-b/chapters" / p.name).read_bytes()]
results.append({"case": "determinism", "ok": not diff and a["ok"] and b["ok"], "notes": [f"abweichende Dateien: {diff}"] if diff else ["Kapiteldateien identisch"]})
(QA / "output" / "matrix-results.json").write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")
failed = [r["case"] for r in results if not r["ok"]]
print(f"\n{len(results)} Fälle, {len(results) - len(failed)} ok, {len(failed)} fehlgeschlagen: {failed}")
if __name__ == "__main__":
main()