diff --git a/.gitignore b/.gitignore index 8924a81..7ac2e8c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,7 @@ htmlcov/ build/ dist/ -# QA-Läufe (Prompt_Kontrolle.md) +# QA-Läufe (Prompt_Kontrolle.md): erzeugte Daten von tools/qa/ .qa/ .qa-output/ .qa-fixtures/ diff --git a/README.md b/README.md index d2f6793..091b931 100644 --- a/README.md +++ b/README.md @@ -485,6 +485,7 @@ pandoc/ ├── callouts.lua Pandoc-Filter: '> [!TIP]'-Kästen → gerahmte Kästen (HTML/EPUB) bzw. Zitatblock mit Titel ├── callouts.css Gestaltung der Kästen (vom Filter eingebunden; muss neben callouts.lua liegen) └── print.css Druck-Layout für PDFs mit weasyprint (A4, Seitenzahlen, Kapitel auf neuer Seite, Codeumbruch) +tools/qa/ QA-Skripte: CLI-Matrix, Validator, Wortvergleich, Pandoc-Prüfung (siehe tools/qa/README.md) ``` Statt BeautifulSoup wird direkt lxml verwendet: Es ist für die XHTML-Bäume ausreichend, schneller und @@ -508,4 +509,8 @@ unsichtbare Zeichen, Szenenwechsel, Gedichte, Infokästen, `--styles`, `--page-m in der Buch-README. Liegen EPUBs unter `Beispiele/`, werden sie zusätzlich auf Struktur-Invarianten geprüft: keine kaputten Links, balancierte Code-Fences, gültiges YAML, alle Medien vorhanden. Dabei werden keine Buchtexte verglichen. `Beispiele/` ist per `.gitignore` vom Repository -ausgeschlossen. +ausgeschlossen.`tests/test_pandoc.py` prüft den Pandoc-Weg (nur mit installiertem Pandoc). + +Für gründlichere Prüfungen nach `Prompt_Kontrolle.md` gibt es `tools/qa/`: eine CLI-Matrix über alle Optionen +und Fehlercodes, einen unabhängigen Markdown-Validator, einen Wortvergleich gegen Datenverlust und die +Prüfung des Pandoc-Wegs (HTML, EPUB, PDF) für alle Beispielbücher. Details in `tools/qa/README.md`. diff --git a/tools/qa/README.md b/tools/qa/README.md new file mode 100644 index 0000000..c8ab5a9 --- /dev/null +++ b/tools/qa/README.md @@ -0,0 +1,34 @@ +# QA-Werkzeuge + +Skripte für die Qualitätsprüfung nach `Prompt_Kontrolle.md`. Sie ergänzen `pytest` um CLI-Integrationstests, +einen unabhängigen Validator und die Prüfung des Pandoc-Wegs. Mit ihnen wurden die Befunde QA3-01 bis +QA3-06 gefunden (siehe `CHANGELOG.md`). Alle Aufrufe im Repo-Wurzelverzeichnis; erzeugte Daten landen in +`.qa/` (von Git ignoriert), Kommandos mit Rückgabecode in `.qa/commands.log`. + +```bash +python3 -m venv .qa/venv && . .qa/venv/bin/activate +pip install -r tools/qa/requirements.txt +``` + +| Skript | Zweck | +| --- | --- | +| `make_fixtures.py` | erzeugt synthetische Test-EPUBs 01–14 (u. a. Path Traversal, kaputter Container, MathML, Fußnoten, Typografie) in `.qa/generated-fixtures/` | +| `run_matrix.py` | CLI-Matrix (89 Fälle) über alle Optionen und Fehlercodes → `.qa/output/matrix-results.json`; braucht die Fixtures | +| `validate_output.py AUSGABE…` | unabhängiger Validator (markdown-it-py statt `epub2md/linkcheck.py`): Links, Anker, Bilder, Fences, YAML, JSON, Python-Einrückung, Fußnotenpaare → JSON | +| `worddiff.py ALT NEU` | Wortvergleich zweier Ausgaben – zeigt Datenverlust nach einer Codeänderung | +| `pyblocks.py DATEIEN…` | Python-Codeblöcke mit Einrückungsfehlern samt Inhalt (Vergleich mit der Quelle) | +| `pandoc_full.sh` | alle EPUBs aus `Beispiele/` konvertieren, zusammenfügen und mit den Pandoc-Befehlen der Buch-README nach HTML, EPUB und PDF (weasyprint, xelatex) bringen → `.qa/output/pandoc/` | +| `pandoc_run.sh`, `pandoc_pdfs.sh` | Teilschritte von `pandoc_full.sh` für einzelne Buchordner | +| `check_pandoc.py BUCHORDNER…` | HTML/EPUB von Pandoc: interne Links und Anker, Bilder, `[!TIP]`-Reste, Formeln, Lage des Inhaltsverzeichnisses | +| `check_pdf.sh PDF…` | Seiten, Seite des Inhaltsverzeichnisses, Text-Reste, nicht eingebettete Schriften (poppler-utils) | +| `log.sh` | `. tools/qa/log.sh; qa BEFEHL` protokolliert Befehl und rc | + +Typischer Ablauf nach einer Änderung: + +```bash +python -m pytest -q +python tools/qa/make_fixtures.py && python tools/qa/run_matrix.py +python epub2md.py Beispiele/BUCH.epub .qa/output/neu/BUCH +python tools/qa/worddiff.py .qa/output/alt/BUCH .qa/output/neu/BUCH # alt: z. B. mit git stash erzeugt +python tools/qa/validate_output.py .qa/output/neu/BUCH +``` diff --git a/tools/qa/check_pandoc.py b/tools/qa/check_pandoc.py new file mode 100755 index 0000000..cdbb9c1 --- /dev/null +++ b/tools/qa/check_pandoc.py @@ -0,0 +1,118 @@ +"""Prüft Pandoc-Ausgaben (HTML und EPUB) eines konvertierten Buches. + +Aufruf: python tools/qa/check_pandoc.py BUCHORDNER [...] -> JSON auf stdout +Geprüft: interne Links/Anker, Bilder, Reste von '[!TIP]', Lage des Inhaltsverzeichnisses, TeX-Reste. +""" + +from __future__ import annotations + +import json +import posixpath +import re +import sys +import zipfile +from pathlib import Path +from urllib.parse import unquote + +from lxml import html as lhtml + +ID_RE = re.compile(r"""\b(?:id|name)\s*=\s*["']([^"']+)["']""") + + +def _ids(text: str) -> set[str]: + return set(ID_RE.findall(text)) + + +def _links(doc) -> list[str]: + return [a.get("href") for a in doc.iter("a") if a.get("href")] + + +def _images(doc) -> list[str]: + srcs = [i.get("src") for i in doc.iter("img") if i.get("src")] + srcs += [i.get("{http://www.w3.org/1999/xlink}href") or i.get("href") for i in doc.iter("image")] + return [s for s in srcs if s and not s.startswith("data:")] + + +def _parse(text: str): + return lhtml.fromstring(text.encode("utf-8")) + + +def _toc_position(doc) -> dict: + """Where does the TOC (nav#TOC) stand relative to the first chapter heading?""" + nav = doc.xpath("//*[@id='TOC']") + if not nav: + return {"toc": False} + body_text = lhtml.tostring(doc, encoding="unicode") + pos = body_text.find('id="TOC"') + before = re.sub(r"<[^>]+>", " ", body_text[:pos]) + h1_before = len(re.findall(r"
Hello EPUB 2 world.
", "Chapter One")]) + + +def f02() -> Book: + return Book(title="Minimal EPUB 3", authors=["Grace Hopper"], + docs=[Doc("c1.xhtml", "Eins.
", "Erstes Kapitel"), + Doc("c2.xhtml", "Zwei.
", "Zweites Kapitel")]) + + +def f03() -> Book: + a = ('Zu Kapitel B, zum Anker unten, ' + 'zum Ziel in B und ins Nichts, ' + 'sowie fehlender Anker.
' + 'Text A.1
' + 'Unten in A2.
' + 'Mehr.
') + b = ('B-Text.
Ziel in B.
' + '') + c = 'Ende mit Link auf A.
' + return Book(title="Kapitel und Links", docs=[Doc("a.xhtml", a, "A"), Doc("b.xhtml", b, "B"), Doc("c.xhtml", c, "C")]) + + +PY = """class Stack: + def __init__(self): + self.items = [] + + def push(self, x): +\tself.items.append(x) # Tab-Einrückung + if len(self.items) > 10 and x < 0: + raise ValueError("& zu voll") +""" + + +def f04() -> Book: + body = ( + "Inline: len(x) < 3 && y, mit Backtick a`b und *nicht kursiv*.
{PY}'
+ "$ find . -name '*.py' | xargs grep -n \"TODO\" \\\n | sort -u > todo.txt\n$ echo $HOME" + "
Markdown mit ```fences``` und `ticks`\n eingerückt"
+ '{\n "a": [1, 2],\n "b": "<tag>"\n}'
+ "#include <stdio.h>\nint main(void) { return 0; }"
+ "Unbekanntes Element:
| Befehl | Bedeutung |
|---|---|
ls | wc -l | Dateien zählen |
a && b | und |
'
+ "Nochmals dasselbe Foto: 
Inline-SVG:
' + 'Siehe Abbildung 1.1 und SVG direkt.
' + 'Fehlendes Bild: 
Bruch inline: .
" + f"Potenz: , Index: , " + f"beides: .
" + f'Nach Gleichung (2.1) folgt …
' + f'Nicht unterstützt: .
' + f'Mit TeX-Annotation: .
" + f'alttext: .
' + 'SVG-Formel:
Bildformel: 
(2.2)Aussage A1 und B' + '2, nochmal A1.
' + '' + '' + f'" + ) + notes = ( + 'Der Begriff API und ' + 'Rekursion nach Knuth [Knu97].
') + glossary = ('Selbstaufruf einer Funktion.
Siehe auch Iteration.
API Glossar
Knuth, Donald E. 17
| Probe | Messung | |
|---|---|---|
| vorher | nachher | |
| A | 1,5 korrigiert | 2,0 |
| B | ausgefallen siehe Text | |
'
+ 'escape 
{text}
' + return Book(title="Kodierung – Ümläute", authors=["Zoë Ørsted"], + docs=[Doc("kapitel-ü.xhtml", body, "Größe"), Doc("café.xhtml", "Ça va.
", "Café")]) + + +TYPO_CSS = b""" +p { font-family: Georgia, serif; } +.kursiv { font-style: italic; } .fett { font-weight: bold; } span.u { text-decoration: underline; } +.weg { display: none; } .markiert { background-color: #ffff00; } .rot { color: #cc0000; } +.gross { font-size: 1.6em; } .kap { font-variant: small-caps; } p.mitte { text-align: center; } +@media print { .kursiv { font-weight: bold; } } +""" + + +def f14() -> Book: + """Typografie: CSS-Hervorhebungen, unsichtbare Zeichen, Szenenwechsel, Gedicht, Kästen, Stile, Seitenmarken.""" + c1 = ('Ein kursives und fettes Wort, ' + 'unterstrichenVERSTECKT.
' + 'Null\u200bbreite und wei\u00adches Trenn\u00adzeichen.
' + 'Szene eins endet hier.
Szene zwei beginnt hier.
' + 'Zeile eins
Zeile zwei
Ein markierter, roter und ' + 'großer Text in Kapitälchen, ' + 'an English phrase.
' + 'Zentrierte Widmung
' + 'Seite elf endet und Seite zwölf ' + 'geht weiter bis dreizehn.
' + 'x = 1\u200b # bleibt')
+ c2 = ('Immer testen.
Nachher.
') + return Book(title="Typografie", authors=["Tina Typo"], media={"style.css": TYPO_CSS}, + docs=[Doc("c1.xhtml", c1, "Gestaltung"), Doc("c2.xhtml", c2, "Kästen")]) + + +def main() -> None: + OUT.mkdir(parents=True, exist_ok=True) + for name, book in [ + ("01-minimal-epub2.epub", f01()), ("02-minimal-epub3-nav.epub", f02()), ("03-chapters-and-links.epub", f03()), + ("04-code-and-tables.epub", f04()), ("05-images-and-svg.epub", f05()), ("06-mathml-and-equations.epub", f06()), + ("07-footnotes-and-endnotes.epub", f07()), ("08-glossary-bibliography-index.epub", f08()), + ("09-complex-table.epub", f09()), ("10-malicious-paths.epub", f10()), ("12-encoding-edge-cases.epub", f12()), + ("14-typography.epub", f14()), + ]: + build_epub(OUT / name, book) + # 10: additionally a manifest item that points outside the EPUB root + p10 = OUT / "10-malicious-paths.epub" + with zipfile.ZipFile(p10) as z: + opf = z.read("OEBPS/content.opf").decode() + opf = opf.replace("", 'x
", "A")]) + p11 = build_epub(OUT / "11-broken-container.epub", base) + rewrite(p11, {"META-INF/container.xml": ( + b'x
", "A")], + spine_extra='nicht geschlossen &unbekannt; fett"})
+ print("\n".join(sorted(p.name for p in OUT.glob("*.epub"))))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/qa/pandoc_full.sh b/tools/qa/pandoc_full.sh
new file mode 100755
index 0000000..36ef93a
--- /dev/null
+++ b/tools/qa/pandoc_full.sh
@@ -0,0 +1,18 @@
+#!/bin/bash
+# Kompletter Pandoc-Durchlauf: jedes EPUB aus Beispiele/ konvertieren, zusammenfügen, alle Pandoc-Befehle der
+# Buch-README ausführen, beide PDF-Varianten erzeugen und prüfen. Aufruf im Repo-Wurzelverzeichnis:
+# tools/qa/pandoc_full.sh Ergebnis in .qa/output/pandoc/ (check.json, pdf-check.txt)
+# Python über $PYTHON (Standard: python3); braucht pandoc, weasyprint, xelatex, poppler-utils.
+PY=${PYTHON:-python3}
+Q=$(dirname "$0")
+P=.qa/output/pandoc; rm -rf $P; mkdir -p $P
+for f in Beispiele/*.epub; do
+ n=$(basename "$f" .epub | cut -d_ -f1 | cut -d- -f1); n=${n/Krooß/Krooss}
+ $PY epub2md.py "$f" "$P/$n" > /dev/null 2> "$P/$n.convert.err"; echo "$n convert rc=$?"
+ $PY epub2md_merge.py "$P/$n" > "$P/$n.merge.log" 2>&1; echo "$n merge rc=$? $(head -1 "$P/$n.merge.log" | cut -c1-120)"
+ SKIP_PDF=1 "$Q/pandoc_run.sh" "$P/$n"
+ "$Q/pandoc_pdfs.sh" "$P/$n"
+done
+$PY "$Q/check_pandoc.py" $P/*/ > $P/check.json
+"$Q/check_pdf.sh" $P/*/*.pdf > $P/pdf-check.txt
+echo FERTIG
diff --git a/tools/qa/pandoc_pdfs.sh b/tools/qa/pandoc_pdfs.sh
new file mode 100755
index 0000000..c248182
--- /dev/null
+++ b/tools/qa/pandoc_pdfs.sh
@@ -0,0 +1,15 @@
+#!/bin/bash
+# Erzeugt beide PDF-Varianten aus der Buch-README unter getrennten Namen: *.weasy.pdf und *.latex.pdf
+# (in der README schreiben beide Befehle dieselbe Datei). Aufruf im Repo-Wurzelverzeichnis.
+mkdir -p .qa
+for dir in "$@"; do
+ mkdir -p "$dir/pandoc-logs"
+ grep -E '^pandoc .*\.pdf' "$dir/README.md" | while IFS= read -r cmd; do
+ case "$cmd" in *weasyprint*) kind=weasy;; *) kind=latex;; esac
+ cmd=$(printf '%s' "$cmd" | sed -E "s/\.pdf('?)\$/.$kind.pdf\1/")
+ s=$(date +%s)
+ (cd "$dir" && timeout 1800 bash -c "$cmd") > "$dir/pandoc-logs/$kind.out" 2> "$dir/pandoc-logs/$kind.err"; rc=$?
+ printf '%s\t%s\trc=%s\t%ss\n' "$(basename "$dir")" $kind $rc $(( $(date +%s)-s ))
+ printf '%s\trc=%s\tpandoc %s-PDF (%s)\n' "$(date -Iseconds)" $rc $kind "$(basename "$dir")" >> .qa/commands.log
+ done
+done
diff --git a/tools/qa/pandoc_run.sh b/tools/qa/pandoc_run.sh
new file mode 100755
index 0000000..319f845
--- /dev/null
+++ b/tools/qa/pandoc_run.sh
@@ -0,0 +1,18 @@
+#!/bin/bash
+# Führt jede "pandoc …"-Zeile aus der README.md eines konvertierten Buches im Buchordner aus.
+# Aufruf im Repo-Wurzelverzeichnis: tools/qa/pandoc_run.sh BUCHORDNER... (SKIP_PDF=1: ohne PDF-Befehle)
+# Logs: BUCHORDNER/pandoc-logs/NN.{cmd,out,err} und summary.tsv; Kommandos mit rc in .qa/commands.log
+mkdir -p .qa
+for dir in "$@"; do
+ mkdir -p "$dir/pandoc-logs"
+ n=0
+ grep -E '^pandoc ' "$dir/README.md" | { [ -n "$SKIP_PDF" ] && grep -v '\.pdf' || cat; } | while IFS= read -r cmd; do
+ n=$((n+1)); f=$(printf '%s/pandoc-logs/%02d' "$dir" $n)
+ printf '%s\n' "$cmd" > "$f.cmd"
+ s=$(date +%s)
+ (cd "$dir" && timeout 1800 bash -c "$cmd") > "$f.out" 2> "$f.err"; rc=$?
+ printf '%s\trc=%s\t%ss\t%s warn\t%s\n' "$(basename "$dir")" $rc $(( $(date +%s)-s )) "$(grep -c . "$f.err")" \
+ "$(echo "$cmd" | grep -o -- '-o [^ ]*\.[a-z]*' | tail -1)" | tee -a "$dir/pandoc-logs/summary.tsv"
+ printf '%s\trc=%s\tpandoc (%s)\n' "$(date -Iseconds)" $rc "$(basename "$dir") #$n" >> .qa/commands.log
+ done
+done
diff --git a/tools/qa/pyblocks.py b/tools/qa/pyblocks.py
new file mode 100755
index 0000000..0876dde
--- /dev/null
+++ b/tools/qa/pyblocks.py
@@ -0,0 +1,36 @@
+"""Listet Python-Codeblöcke mit Einrückungsfehlern in Markdown-Dateien (mit Inhalt, zum Vergleich mit der Quelle).
+
+Aufruf: python tools/qa/pyblocks.py AUSGABE/chapters/*.md
+"""
+
+from __future__ import annotations
+
+import ast
+import re
+import sys
+
+
+def main() -> None:
+ for path in sys.argv[1:]:
+ with open(path, encoding="utf-8") as fh:
+ lines = fh.read().split("\n")
+ i = 0
+ while i < len(lines):
+ m = re.match(r"^(`{3,})python\s*$", lines[i])
+ if m:
+ j = i + 1
+ while j < len(lines) and not lines[j].startswith(m.group(1)):
+ j += 1
+ try:
+ ast.parse("\n".join(lines[i + 1:j]))
+ except IndentationError as exc:
+ print(f"== {path}:{i + 1} {exc.msg} (Blockzeile {exc.lineno})")
+ print("\n".join(f"{k + 1:3}|{line}" for k, line in enumerate(lines[i + 1:j])))
+ except SyntaxError:
+ pass # fragments and program output are no indentation defects
+ i = j
+ i += 1
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/qa/requirements.txt b/tools/qa/requirements.txt
new file mode 100644
index 0000000..78cae10
--- /dev/null
+++ b/tools/qa/requirements.txt
@@ -0,0 +1,5 @@
+# Zusätzlich zu requirements-dev.txt für die QA-Skripte (unabhängiger Markdown-Validator, Coverage)
+-r ../../requirements-dev.txt
+markdown-it-py>=3.0
+mdit-py-plugins>=0.4
+coverage>=7.0
diff --git a/tools/qa/run_matrix.py b/tools/qa/run_matrix.py
new file mode 100755
index 0000000..f8eea23
--- /dev/null
+++ b/tools/qa/run_matrix.py
@@ -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: "ls | wc -l" in ch(o) and "| Befehl |" not in ch(o))])
+ case("format-commonmark", f04, ["--format", "commonmark"], 0, [("HTML-Tabelle statt Pipe", lambda o, c: "" 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