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>
This commit is contained in:
Dieter Schlüter 2026-10-06 22:14:54 +02:00
commit 01f60a41c4

418
tools/qa/run_matrix.py Executable file
View file

@ -0,0 +1,418 @@
"""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()