2026-10-05 23:57:42 +02:00
|
|
|
"""Regression tests for defects found in the independent QA run (.qa/QA_REPORT.md, IDs QA2-…)."""
|
|
|
|
|
|
|
|
|
|
import re
|
|
|
|
|
|
|
|
|
|
from conftest import Book, Doc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _pipe_rows(text: str) -> list[str]:
|
|
|
|
|
return [ln for ln in text.splitlines() if ln.startswith("| ")]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_pipe_in_inline_code_does_not_split_gfm_table(convert):
|
|
|
|
|
"""QA2-01: GFM splits table cells at every unescaped '|', also inside code spans and link targets."""
|
|
|
|
|
body = ("<h1>Operatoren</h1><table><thead><tr><th>Operator</th><th>Bedeutung</th></tr></thead><tbody>"
|
|
|
|
|
"<tr><td><code>||</code></td><td>logisches ODER</td></tr>"
|
|
|
|
|
"<tr><td><code>a | b</code></td><td><a href=\"https://example.org/a|b\">Link</a></td></tr>"
|
|
|
|
|
"</tbody></table>")
|
|
|
|
|
run = convert(Book(docs=[Doc("c.xhtml", body)]))
|
|
|
|
|
text = run.chapter("001")
|
|
|
|
|
for row in _pipe_rows(text):
|
|
|
|
|
cells = re.split(r"(?<!\\)\|", row.strip()[1:-1])
|
|
|
|
|
assert len(cells) == 2, f"Zeile zerfällt in GFM in {len(cells)} Zellen: {row}"
|
|
|
|
|
assert "||" in text and "a | b" in text and "logisches ODER" in text
|
2026-10-05 23:59:02 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
NOTE_WITH_CODE = ('<h1>K</h1><p>Text<a epub:type="noteref" href="#n1" id="r1">1</a>.</p>'
|
|
|
|
|
'<aside epub:type="footnote" id="n1"><p>Note mit Code:</p><pre>code()\n weiter()</pre></aside>'
|
|
|
|
|
'<p>Zweite<a epub:type="noteref" href="#n2" id="r2">2</a>.</p>'
|
|
|
|
|
'<aside epub:type="footnote" id="n2"><pre>nur_code()</pre></aside>'
|
|
|
|
|
"<p>Danach kommt normaler Text.</p>")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fences_closed(text: str) -> bool:
|
|
|
|
|
"""Every opening ``` fence has a closing line that consists only of backticks."""
|
|
|
|
|
open_fence = None
|
|
|
|
|
for line in text.splitlines():
|
|
|
|
|
s = line.strip()
|
|
|
|
|
if open_fence is None:
|
|
|
|
|
m = re.match(r"(`{3,})", s)
|
|
|
|
|
if m:
|
|
|
|
|
open_fence = m.group(1)
|
|
|
|
|
elif re.fullmatch(r"`{3,}", s) and len(s) >= len(open_fence):
|
|
|
|
|
open_fence = None
|
|
|
|
|
return open_fence is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_backlink_after_code_block_does_not_break_fence_commonmark(convert):
|
|
|
|
|
"""QA2-02: the ↩ backlink was appended to the closing ``` line, leaving the fence open."""
|
|
|
|
|
run = convert(Book(docs=[Doc("c.xhtml", NOTE_WITH_CODE)]), "--format", "commonmark")
|
|
|
|
|
text = run.chapter("001")
|
|
|
|
|
assert _fences_closed(text), text
|
|
|
|
|
assert not re.search(r"\*\*\d+[:.]\*\* ```", text), "Fence hinter Notennummer ist kein Codeblock"
|
|
|
|
|
assert "[↩](#fnref-001)" in text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_backlink_after_code_block_does_not_break_fence_end_of_book(convert):
|
|
|
|
|
run = convert(Book(docs=[Doc("c.xhtml", NOTE_WITH_CODE)]), "--footnotes", "end-of-book")
|
|
|
|
|
notes = run.chapter("999")
|
|
|
|
|
assert _fences_closed(notes), notes
|
|
|
|
|
assert not re.search(r"\*\*\d+[:.]\*\* ```", notes), "Fence hinter Notennummer ist kein Codeblock"
|
|
|
|
|
assert "[↩](001-k.md#fnref-001)" in notes
|
2026-10-05 23:59:46 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
NOTE_IN_HTML_TABLE = ('<h1>K</h1><table><tr><td colspan="2">Zelle<a epub:type="noteref" href="#n1" id="r1">1</a></td></tr>'
|
|
|
|
|
"<tr><td>a</td><td>b</td></tr></table>"
|
|
|
|
|
'<p>Text<a epub:type="noteref" href="#n1" id="r1b">1</a>.</p>'
|
|
|
|
|
'<aside epub:type="footnote" id="n1"><p>Die Note.</p></aside>')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_note_referenced_from_html_table_and_text_has_target(convert):
|
|
|
|
|
"""QA2-03: GFM: the HTML-table reference pointed to #fn-001, but only a [^fn-001] definition existed."""
|
|
|
|
|
run = convert(Book(docs=[Doc("c.xhtml", NOTE_IN_HTML_TABLE)]))
|
|
|
|
|
text = run.chapter("001")
|
|
|
|
|
assert 'href="#fn-001"' in text and "Text[^fn-001]." in text
|
|
|
|
|
assert re.search(r'id="fn-001"', text), text
|
|
|
|
|
assert run.report["output_link_check"]["broken_count"] == 0
|
2026-10-06 00:00:28 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _html_table(text: str) -> str:
|
|
|
|
|
return text[text.index("<table"): text.index("</table>")]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_end_of_book_reference_inside_html_table_is_html(convert):
|
|
|
|
|
"""QA2-04: a Markdown link inside an HTML block is not rendered (shows up as literal text)."""
|
|
|
|
|
run = convert(Book(docs=[Doc("c.xhtml", NOTE_IN_HTML_TABLE)]), "--footnotes", "end-of-book")
|
|
|
|
|
table = _html_table(run.chapter("001"))
|
|
|
|
|
assert "](" not in table, table
|
|
|
|
|
assert '<a href="999-anmerkungen.md#fn-001">1</a>' in table
|
2026-10-06 00:00:58 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_inline_svg_inside_html_table_is_html_img(convert):
|
|
|
|
|
"""QA2-05: inline SVG in a complex table was written as Markdown image inside the HTML block."""
|
|
|
|
|
body = ('<h1>T</h1><table><tr><th colspan="2">Kopf</th></tr><tr><td>A</td><td>'
|
|
|
|
|
'<svg xmlns="http://www.w3.org/2000/svg" width="5" height="5" aria-label="Kreis"><circle r="2"/></svg>'
|
|
|
|
|
"</td></tr></table>")
|
|
|
|
|
run = convert(Book(docs=[Doc("c.xhtml", body)]))
|
|
|
|
|
table = _html_table(run.chapter("001"))
|
|
|
|
|
assert "![" not in table, table
|
|
|
|
|
assert re.search(r'<img src="\.\./media/images/\d{4}-c-svg\.svg" alt="Kreis"/>', table), table
|
|
|
|
|
assert run.report["output_link_check"]["broken_count"] == 0
|
2026-10-06 00:01:54 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_multiple_blank_lines_inside_code_are_kept(convert):
|
|
|
|
|
"""QA2-06: runs of blank lines inside code blocks were collapsed to one (PEP 8: two between functions)."""
|
|
|
|
|
code = "def f():\n pass\n\n\nclass K:\n pass\n\n\n\nx = 1"
|
|
|
|
|
body = f"<h1>K</h1><p>Vorher</p><pre>{code}</pre><p>Nachher</p>"
|
|
|
|
|
text = convert(Book(docs=[Doc("c.xhtml", body)])).chapter("001")
|
|
|
|
|
assert f"```python\n{code}\n```" in text, text
|
|
|
|
|
assert "\n\n\n" not in text.replace(code, "") # outside code blank lines are still normalized
|
2026-10-06 00:02:50 +02:00
|
|
|
|
|
|
|
|
|
2026-10-06 00:03:29 +02:00
|
|
|
def test_dash_and_equals_lines_stay_text(convert):
|
|
|
|
|
"""QA2-08: a paragraph '---' became a thematic break, '===' after a line break a setext heading."""
|
|
|
|
|
body = "<h1>K</h1><p>---</p><p>Zeile<br/>===</p><p>Text<br/>-----</p>"
|
|
|
|
|
text = convert(Book(docs=[Doc("c.xhtml", body)])).chapter("001")
|
|
|
|
|
assert "\n\\---\n" in text, text
|
|
|
|
|
assert "Zeile\\\n\\===" in text, text
|
|
|
|
|
assert "Text\\\n\\-----" in text, text
|
|
|
|
|
|
|
|
|
|
|
2026-10-06 00:02:50 +02:00
|
|
|
def _simple() -> Book:
|
|
|
|
|
return Book(docs=[Doc("c.xhtml", "<h1>K</h1><p>Text.</p>")])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_report_must_not_overwrite_generated_output_file(convert, tmp_path):
|
|
|
|
|
"""QA2-07: --report OUTPUT/manifest.json silently replaced the manifest with the report."""
|
|
|
|
|
out = tmp_path / "ziel"
|
|
|
|
|
run = convert(_simple(), "--report", str(out / "manifest.json"), out=out)
|
|
|
|
|
assert run.rc == 1
|
|
|
|
|
assert "--report" in run.stderr
|
|
|
|
|
assert not out.exists()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_report_write_error_is_input_error_not_internal(convert, tmp_path):
|
|
|
|
|
"""QA2-07: an unwritable --report target ended with exit 4 ("Interner Fehler")."""
|
|
|
|
|
import os
|
|
|
|
|
import stat
|
|
|
|
|
|
|
|
|
|
ro = tmp_path / "ro"
|
|
|
|
|
ro.mkdir()
|
|
|
|
|
os.chmod(ro, stat.S_IRUSR | stat.S_IXUSR)
|
|
|
|
|
try:
|
|
|
|
|
run = convert(_simple(), "--report", str(ro / "bericht.json"), out=tmp_path / "ziel")
|
|
|
|
|
finally:
|
|
|
|
|
os.chmod(ro, stat.S_IRWXU)
|
|
|
|
|
assert run.rc == 1, run.stderr
|
|
|
|
|
assert "Interner Fehler" not in run.stderr
|
|
|
|
|
assert "Bericht" in run.stderr
|