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>
193 lines
9.5 KiB
Python
193 lines
9.5 KiB
Python
"""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 "<script>" 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 >= 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
|