epub2md/tests/test_integration.py
dschlueter 02449734a2 feat: epub2md – EPUB nach kapitelweisem Markdown
Kommandozeilenwerkzeug, das DRM-freie EPUB-2/3-Dateien offline in
kapitelweise Markdown-Dateien umwandelt. Erzeugt werden außerdem Medien,
metadata.yaml, manifest.json, conversion-report.json und Diagnose-Dateien.

- Spine-basierte Kapitelbildung mit --chapter-level, epub:type-Abschnitten,
  Fortsetzungsdateien und Erkennung von Layout-Überschriften
- Code mit exakter Einrückung, sicheren Fences und konservativer
  Spracherkennung
- MathML->TeX (konservativ), TeX aus alt-Texten, SVG-/Bildformeln
  bleiben Bilder
- Fußnoten-Modi end-of-chapter, end-of-book, inline und keep-links
- Interne Links/Anker werden umgeschrieben, Linkcheck über die Ausgabe
- Sicher: kein Entpacken, Schutz vor Path Traversal und XXE,
  DRM-Erkennung, atomares Schreiben, --force nur für frühere Ausgaben
- 82 pytest-Tests mit synthetischen EPUBs und Invariantentests für Beispiele/

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:33:42 +02:00

126 lines
5 KiB
Python
Raw 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.

import json
import re
from pathlib import Path
import pytest
import yaml
from conftest import ROOT, Book, Doc, build_epub
BEISPIELE = sorted((ROOT / "Beispiele").glob("*.epub"))
def simple_book() -> Book:
return Book(docs=[Doc("a.xhtml", "<h1>A</h1><p>Text</p>"), Doc("b.xhtml", "<h1>B</h1><p>Mehr</p>")])
def test_output_structure_and_reports(convert):
run = convert(simple_book(), "--copy-original")
assert run.rc == 0
for rel in ("README.md", "metadata.yaml", "manifest.json", "conversion-report.json",
"diagnostics/warnings.md", "diagnostics/unsupported-elements.md"):
assert (run.out / rel).is_file(), rel
assert len(list((run.out / "source").glob("*.epub"))) == 1
manifest = json.loads((run.out / "manifest.json").read_text(encoding="utf-8"))
assert [c["file"] for c in manifest["chapters"]] == ["chapters/001-a.md", "chapters/002-b.md"]
for c in manifest["chapters"]:
assert (run.out / c["file"]).is_file()
assert c["word_count"] > 0
assert run.report["status"] == "success"
assert run.report["input"]["sha256"]
fm = yaml.safe_load(run.chapter("001").split("---\n")[1])
assert fm["chapter_number"] == 1 and fm["source_href"] == "a.xhtml" and fm["book_title"] == "Testbuch"
def test_no_frontmatter_no_toc(convert):
run = convert(simple_book(), "--no-frontmatter", "--no-toc")
assert not run.chapter("001").startswith("---")
assert not (run.out / "README.md").exists()
def test_dry_run_writes_nothing(convert, tmp_path):
report = tmp_path / "r.json"
run = convert(simple_book(), "--dry-run", "--report", str(report))
assert run.rc == 0
assert not run.out.exists()
assert not report.exists()
assert "chapters/001-a.md" in run.stdout
def test_existing_output_requires_force(convert, tmp_path):
out = tmp_path / "ziel"
out.mkdir()
(out / "wichtig.txt").write_text("nicht löschen")
run = convert(simple_book(), out=out)
assert run.rc == 1
assert (out / "wichtig.txt").exists()
# --force refuses directories that are not epub2md output
assert convert(simple_book(), "--force", out=out).rc == 1
assert (out / "wichtig.txt").exists()
def test_force_replaces_previous_output(convert, tmp_path):
out = tmp_path / "ziel"
assert convert(simple_book(), out=out).rc == 0
(out / "alt.txt").write_text("alt")
run = convert(simple_book(), "--force", out=out)
assert run.rc == 0
assert not (out / "alt.txt").exists()
assert (out / "README.md").exists()
assert not list(tmp_path.glob(".epub2md-*")) # temp dir cleaned up
def test_report_option_and_verbose(convert, tmp_path):
report = tmp_path / "rep" / "bericht.json"
run = convert(simple_book(), "--report", str(report), "--verbose")
assert json.loads(report.read_text(encoding="utf-8"))["tool"] == "epub2md"
assert "Rendere" in run.stderr
def test_invalid_arguments_exit_1(convert, tmp_path):
assert convert(simple_book(), "--math", "falsch").rc == 1
assert convert(simple_book(), "--chapter-level", "9").rc == 1
assert convert(tmp_path / "gibt-es-nicht.epub").rc == 1
def test_deterministic_output(convert, tmp_path):
epub = build_epub(tmp_path / "same.epub", simple_book())
a = convert(epub, out=tmp_path / "x1")
b = convert(epub, out=tmp_path / "x2")
for p in a.chapters():
assert p.read_text() == (b.out / "chapters" / p.name).read_text()
def test_encoding_edge_cases(convert):
text_in = "Ärger über Öl – „Zitat“ — café naïve, Ωμέγα αβγ, ∀x∈ℝ: x² ≥ 0 … ‚einfach‘"
book = Book(docs=[Doc("Kapitel-é.xhtml", f'<h1 id="über">Grüße</h1><p>{text_in}</p>')])
run = convert(book)
text = run.chapter("001")
assert text_in in text
assert "<EFBFBD>" not in text
assert run.chapters()[0].name == "001-gruesse.md"
@pytest.mark.skipif(not BEISPIELE, reason="keine Beispiel-EPUBs vorhanden")
@pytest.mark.parametrize("epub", BEISPIELE, ids=[p.stem[:20] for p in BEISPIELE])
def test_example_books_structural_invariants(convert, epub: Path):
"""Checks structure only – no copyrighted text is asserted."""
run = convert(epub)
assert run.rc == 0, run.stderr
report = run.report
chapters = run.chapters()
assert len(chapters) >= 3
assert report["output_link_check"]["broken_count"] == 0, report["output_link_check"]["broken"][:5]
assert not report["errors"]
for path in chapters:
text = path.read_text(encoding="utf-8")
body = text.split("---\n", 2)[2] if text.startswith("---\n") else text
yaml.safe_load(text.split("---\n")[1])
assert body.strip(), f"leere Kapiteldatei {path.name}"
fences = re.findall(r"^(`{3,})", body, re.M)
assert len(fences) % 2 == 0, f"unbalancierte Code-Fences in {path.name}"
for block in re.findall(r"^```[\w+-]*\n(.*?)\n```$", body, re.M | re.S):
assert "&lt;" not in block and "&amp;" not in block
for m in report["media"]["files"]:
assert (run.out / m["file"]).is_file()
assert sum(c["stats"]["word_count"] for c in report["chapters"]) > 1000