text_to_solver_program/tests/test_charts.py
dschlueter 29d058d8f1 feat: Report-Aufbau neu – Inhaltsverzeichnis, Aufgabe gerendert, Ergebnis nach dem Modell
Reihenfolge (Nutzerentscheidung): Titel → Laufdaten → Warnkasten → klickbares
Inhaltsverzeichnis (HTML, H2+H3) → Aufgabe → beigelegte Daten → Was in der Aufgabe
steht → Einordnung → „Wie das Modell die Aufgabe sieht“ (eigene H2) → Mathematisches
Modell → Ergebnis → Prüfbericht → Solver-Programm.

- Aufgabe als Markdown mit gerenderten Formeln (markdown-it-py, html=False,
  breaks=True; Formeln vorher durch Platzhalter geschützt); Formelsatz in reinem Text
  (nur_formeln.md) bleibt <pre>.
- „$0$“ gilt als Formel, „$5 und $7“ weiter als Geldbetrag (_is_money).
- markdown-it-py ausdrücklich in requirements.txt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:44:26 +02:00

193 lines
9.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.

"""Inline-SVG-Diagramme: gültiges SVG, korrekte Geometrie, Einbindung in den Report."""
import re
import xml.etree.ElementTree as ET
import pytest
from reporting import charts
from core.model_schema import PipelineResult, ResultTable, SolverRun, SolverStatus
from reporting.report import build_document, to_html, to_markdown
from test_model_checks import make_spec
from test_result_tables import PORTFOLIO_TABLE
from test_review import make_review
LABELS = ["A02", "A08", "A03"]
VALUES = [0.03433, 0.03093, 0.02587]
def parse(svg: str):
assert svg.startswith("<svg")
return ET.fromstring(svg)
def test_bar_chart_is_valid_svg_with_one_bar_per_label():
root = parse(charts.bar_chart(LABELS, VALUES, "Portfolio"))
assert len(root.findall(".//{http://www.w3.org/2000/svg}path")) == len(LABELS)
texts = [t.text for t in root.findall(".//{http://www.w3.org/2000/svg}text")]
for label in LABELS:
assert label in texts # Direktbeschriftung statt Legende (eine Serie)
assert root.get("role") == "img" and "Portfolio" in root.get("aria-label")
def test_bars_are_proportional_and_capped_in_thickness():
svg = charts.bar_chart(["groß", "klein"], [1.0, 0.5], "Test")
widths = [float(d.split("H")[1].split(" ")[0].split(",")[0]) for d in
[p.get("d") for p in parse(svg).findall(".//{http://www.w3.org/2000/svg}path")]]
assert widths[0] > widths[1]
# Balkenhöhe = unterer Rand (letztes „x,y“ vor „H… Z“) minus oberer Rand (Startpunkt „M x,y“)
heights = set()
for p in parse(svg).findall(".//{http://www.w3.org/2000/svg}path"):
ys = [float(y) for y in re.findall(r"[\d.]+,([\d.]+)", p.get("d"))]
heights.add(max(ys) - min(ys))
assert heights == {float(charts.BAR_HEIGHT)} and charts.BAR_HEIGHT <= 24
def test_negative_values_use_the_other_colour_role():
svg = charts.bar_chart(["Ertrag", "Kosten"], [1.0, -0.4], "Test")
fills = [p.get("fill") for p in parse(svg).findall(".//{http://www.w3.org/2000/svg}path")]
assert fills == ["var(--viz-pos)", "var(--viz-neg)"]
def test_waterfall_has_step_per_term_plus_total():
svg = charts.waterfall_chart(["Rendite", "Risiko", "Kosten"], [0.1277, -0.0052, -0.0256],
"Zielwert", 0.0969, "Zielwert-Zerlegung", " ")
root = parse(svg)
fills = [p.get("fill") for p in root.findall(".//{http://www.w3.org/2000/svg}path")]
assert fills == ["var(--viz-pos)", "var(--viz-neg)", "var(--viz-neg)", "var(--viz-total)"]
assert "Zielwert" in [t.text for t in root.findall(".//{http://www.w3.org/2000/svg}text")]
def test_tooltip_per_mark():
root = parse(charts.bar_chart(LABELS, VALUES, "Portfolio"))
titles = [t.text for t in root.findall(".//{http://www.w3.org/2000/svg}title")]
assert len(titles) == len(LABELS) and titles[0].startswith("A02: ")
def make_result(tables):
return PipelineResult(
task="Aufgabe", specification=make_spec(), review=make_review(), revisions=0,
solver_run=SolverRun(status=SolverStatus.OPTIMAL, objective_value=0.09756, tables=tables,
headline="Das Portfolio erreicht 9,76 %.", solution={"w[A02]": 0.3}),
)
def test_html_report_embeds_chart_and_markdown_does_not():
doc = build_document(make_result([ResultTable.model_validate(PORTFOLIO_TABLE)]))
page = to_html(doc)
assert '<figure class="viz-root">' in page and "<svg" in page
assert "<svg" not in to_markdown(doc)
def test_decomposition_table_gets_waterfall():
table = ResultTable(title="Zielwert-Zerlegung", columns=["Bestandteil", "Wert"],
rows=[["Rendite", 0.1277], ["Risiko", -0.0052], ["Kosten", -0.0256], ["Zielwert", 0.0969]],
objective_column="Wert")
page = to_html(build_document(make_result([table])))
assert page.count("var(--viz-total)") == 1 # Gesamtbalken nur im Wasserfall
def test_result_follows_the_model_and_headline_is_lead():
# Aufbau seit 18.09.2026: Aufgabe → … → Erklärung → Modell → Ergebnis → Prüfbericht
doc = build_document(make_result([ResultTable.model_validate(PORTFOLIO_TABLE)]))
h2 = [b[1] for b in doc if b[0] == "h2"]
assert h2 == ["Aufgabe", "Einordnung", "Wie das Modell die Aufgabe sieht", "Mathematisches Modell",
"Ergebnis", "Prüfbericht"]
assert ("lead", "Das Portfolio erreicht 9,76 %.") in doc
assert '<p class="lead">' in to_html(doc)
@pytest.mark.parametrize("table", [
ResultTable(title="Ohne Zahlen", columns=["A", "B"], rows=[["x", "1,5 %"]], objective_column="B"),
ResultTable(title="Nur eine Zeile", columns=["A", "B"], rows=[["x", 1.0]], objective_column="B"),
ResultTable(title="Ohne Beitragsspalte", columns=["A", "B"], rows=[["x", 1.0], ["y", 2.0]]),
])
def test_no_chart_without_usable_numbers(table):
assert "<svg" not in to_html(build_document(make_result([table])))
# --- Graphenzeichnung ---
def test_graph_chart_is_valid_svg_and_marks_the_path():
import xml.etree.ElementTree as ET
from reporting.charts import graph_chart
svg = graph_chart(["0", "1", "2", "3"],
[("0", "1", 8), ("0", "2", 18), ("0", "3", 31), ("1", "2", 10), ("1", "3", 21), ("2", "3", 12)],
path=["0", "1", "3"], title="Graph")
ET.fromstring(svg)
assert svg.count("<circle") == 4 # jeder Knoten einmal
assert svg.count("marker-end") == 6 # jeder Bogen einmal, und gerichtet
assert svg.count("--viz-pos") >= 2 # Weg und Knoten des Weges hervorgehoben
def test_graph_chart_refuses_unknown_nodes_and_huge_graphs():
from reporting.charts import graph_chart
assert graph_chart(["a"], [("a", "b", 1)]) == "" # Bogen ins Nichts
assert graph_chart([str(i) for i in range(20)], [("0", "1", 1)]) == "" # zu groß zum Zeichnen
assert graph_chart([], []) == ""
def test_graph_section_explains_before_it_shows():
from core.model_schema import GraphSolution, SolverRun, SolverStatus
from reporting.report import _graph_section
run = SolverRun(status=SolverStatus.OPTIMAL, objective_value=29, graph=GraphSolution.model_validate({
"nodes": ["0", "1", "3"],
"arcs": [{"source": "0", "target": "1", "weight": 8}, {"source": "1", "target": "3", "weight": 21}],
"path": ["0", "1", "3"],
"steps": [{"node": "0", "label": 0}, {"node": "1", "label": 8}, {"node": "3", "label": 29}],
"node_meaning": "ein Zeitpunkt – das Ende von Jahr i.",
"arc_meaning": "Kauf am Ende von Jahr i und Nutzung bis Ende von Jahr j.",
}))
doc = _graph_section(run)
arten = [block[0] for block in doc]
assert arten[0] == "p" and "Ende von Jahr" in doc[0][1] # Erklärung steht vor der Zeichnung
assert "svg" in arten
assert any(b[0] == "table" and "Markierungsverlauf" in str(doc[arten.index("table") - 1]) for b in doc)
assert any("8 + 21 = 29" in str(b) for b in doc) # Rechenweg ausgeschrieben
# --- Kopf, Inhaltsverzeichnis und gerenderte Aufgabe ---
def test_head_is_title_run_data_then_table_of_contents():
doc = build_document(make_result([ResultTable.model_validate(PORTFOLIO_TABLE)]))
assert [b[0] for b in doc[:3]] == ["h1", "list", "toc"] # zuverlässig: kein Warnkasten
assert "toc" not in to_markdown(doc) and "Inhalt" not in to_markdown(doc)
def test_table_of_contents_links_every_section_but_no_h4():
page = to_html(build_document(make_result([ResultTable.model_validate(PORTFOLIO_TABLE)])))
nav = page[page.index('<nav class="toc"'):page.index("</nav>")]
links = re.findall(r'href="#([^"]+)"', nav)
assert links and all(f'id="{anker}"' in page for anker in links) # jeder Link hat ein Ziel
assert len(links) == len(set(links))
for titel in ("Aufgabe", "Wie das Modell die Aufgabe sieht", "Mathematisches Modell", "Ergebnis", "Prüfbericht"):
assert f">{titel}</a>" in nav
assert "Restriktionen</a>" in nav # H3
assert not re.search(r"<h4 id=", page) # H4 bleibt ohne Anker und außerhalb
def test_task_markdown_and_formulas_are_rendered():
from reporting.report import task_html
text = ("### 1. Problemstellung\n\nAus $N = 100$ Aktien werden **genau** $k = 20$ gewählt:\n\n"
"- Rendite $\\mu_i$\n- Risiko \\(w^T \\Sigma w\\)\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\n"
"$$\\max \\sum_{i} \\mu_i w_i$$\n\nDas kostet $5 und $7 Gebühr. <script>alert(1)</script>")
seite = task_html(text)
assert "<h3>1. Problemstellung</h3>" in seite and "<strong>genau</strong>" in seite
assert r"\(N = 100\)" in seite and r"\(\mu_i\)" in seite and r"\(w^T \Sigma w\)" in seite
assert r"\[\max \sum_{i} \mu_i w_i\]" in seite # Unterstrich nicht von Markdown zerstört
assert "<table>" in seite and "<li>" in seite
assert "$5 und $7" in seite # Geldbeträge bleiben Text
assert "<script>" not in seite and "&lt;script&gt;" in seite # kein Roh-HTML aus dem Aufgabentext
def test_plain_formula_text_stays_preformatted():
# tasks/nur_formeln.md: Markdown machte aus „*x1 + 23*“ Kursivschrift und zöge die Zeilen zusammen
from reporting.report import task_html
text = "min z = 17*x1 + 23*x2\n\ns.t.\n\n 6*x1 + 4*x2 >= 120\n 3*x1 + 9*x2 >= 105"
seite = task_html(text)
assert seite.startswith('<pre class="task">') and "<em>" not in seite and " 6*x1 + 4*x2 &gt;= 120" in seite
def test_line_breaks_of_the_task_are_kept():
from reporting.report import task_html
seite = task_html("Pfeile:\n1 → 2: 3\n2 → 4: 5")
assert seite.count("<br") == 2