text_to_solver_program/tests/test_charts.py

204 lines
10 KiB
Python
Raw Normal View History

"""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
def test_longest_path_is_not_called_shortest():
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"], "path": ["0", "1", "3"], "node_meaning": "-", "arc_meaning": "-",
"arcs": [{"source": "0", "target": "1", "weight": 8}, {"source": "1", "target": "3", "weight": 21}]}))
assert "kürzeste" in str(_graph_section(run)) and "längste" not in str(_graph_section(run))
lang = str(_graph_section(run, "maximize"))
assert "Hervorgehoben ist der längste Weg" in lang and "kürzest" not in lang