142 lines
5.6 KiB
Python
142 lines
5.6 KiB
Python
|
|
import ast
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
import warnings
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from model_schema import PipelineResult, SolverCode, SolverRun, SolverStatus
|
||
|
|
from report import build_document, to_html
|
||
|
|
from script_export import build_solver_script
|
||
|
|
from solver_stage import CodeRejected, assemble_script, run_solver_code, validate_structure
|
||
|
|
from test_model_checks import make_spec
|
||
|
|
from test_review import make_review
|
||
|
|
|
||
|
|
# min 3x + 2y s.t. x + y >= 4, 0 <= x, y <= 3 -> Zielwert 9
|
||
|
|
STRUCTURED_CODE = '''
|
||
|
|
from scipy.optimize import linprog
|
||
|
|
|
||
|
|
# Kosten je Tonne Rohstoff X und Y [Tsd. €]
|
||
|
|
COSTS = [3, 2]
|
||
|
|
|
||
|
|
|
||
|
|
def solve_model():
|
||
|
|
"""Baut das LP auf und löst es."""
|
||
|
|
# Bedarf: x + y >= 4 -> -x - y <= -4
|
||
|
|
return linprog(COSTS, A_ub=[[-1, -1]], b_ub=[-4], bounds=[(0, 3), (0, 3)], method="highs")
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
"""Löst das Modell und gibt das Ergebnis aus."""
|
||
|
|
res = solve_model()
|
||
|
|
x, y = float(res.x[0]), float(res.x[1])
|
||
|
|
report(
|
||
|
|
"optimal" if res.status == 0 else "error", res.fun, {"x": x, "y": y},
|
||
|
|
constraints=[{"name": "Bedarf", "lhs": x + y, "sense": ">=", "rhs": 4}],
|
||
|
|
variables=[{"name": "x", "value": x, "var_type": "continuous", "lb": 0, "ub": 3},
|
||
|
|
{"name": "y", "value": y, "var_type": "continuous", "lb": 0, "ub": 3}],
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|
||
|
|
'''
|
||
|
|
|
||
|
|
|
||
|
|
def make_result(task="Minimiere die Kosten für mindestens 4 t Rohstoff.", code=STRUCTURED_CODE):
|
||
|
|
return PipelineResult(
|
||
|
|
task=task,
|
||
|
|
specification=make_spec(),
|
||
|
|
review=make_review(),
|
||
|
|
revisions=0,
|
||
|
|
solver_code=SolverCode(code=code, data_assumptions=["Lieferfrist: keine (nicht im Text)"]),
|
||
|
|
solver_run=SolverRun(status=SolverStatus.OPTIMAL, objective_value=9.0),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_script_layout_order():
|
||
|
|
script = build_solver_script(make_result())
|
||
|
|
assert script.startswith('#!/usr/bin/env python3\nr"""\n')
|
||
|
|
tree = ast.parse(script)
|
||
|
|
docstring = ast.get_docstring(tree)
|
||
|
|
positions = [docstring.index(h) for h in ("AUFGABE", "MATHEMATISCHES MODELL", "ALGORITHMUS")]
|
||
|
|
assert positions == sorted(positions)
|
||
|
|
assert "Minimiere die Kosten" in docstring
|
||
|
|
assert "\\sum_{j \\in J} x_j \\ge b" in docstring # Restriktion in LaTeX
|
||
|
|
assert "Branch-and-Cut" not in docstring # SciPy-Stack, nicht HiGHS-Text
|
||
|
|
assert "linprog" in docstring
|
||
|
|
assert "Lieferfrist" in docstring # angenommene Daten
|
||
|
|
# Imports vor report(), report() vor dem abschließenden main-Guard
|
||
|
|
names = [type(n).__name__ + ":" + getattr(n, "name", "") for n in tree.body]
|
||
|
|
guard_index = len(names) - 1
|
||
|
|
assert names.index("ImportFrom:") < names.index("FunctionDef:main") < names.index("FunctionDef:report") < guard_index
|
||
|
|
assert isinstance(tree.body[guard_index], ast.If) and script.rstrip().endswith("main()")
|
||
|
|
|
||
|
|
|
||
|
|
def test_script_is_standalone_runnable(tmp_path):
|
||
|
|
path = tmp_path / "solve.py"
|
||
|
|
path.write_text(build_solver_script(make_result()), encoding="utf-8")
|
||
|
|
proc = subprocess.run([sys.executable, str(path)], capture_output=True, text=True, timeout=60)
|
||
|
|
assert proc.returncode == 0, proc.stderr
|
||
|
|
assert "Status: optimal" in proc.stdout
|
||
|
|
assert "Zielwert: 9.0" in proc.stdout
|
||
|
|
assert proc.stdout.strip().splitlines()[-1].startswith("RESULT_JSON:")
|
||
|
|
|
||
|
|
|
||
|
|
def test_latex_backslashes_and_quotes_do_not_break_script():
|
||
|
|
task = 'Text mit \\sum, \\( x \\), """ Anführungszeichen """ und Backslash am Ende \\'
|
||
|
|
script = build_solver_script(make_result(task=task))
|
||
|
|
with warnings.catch_warnings():
|
||
|
|
warnings.simplefilter("error") # SyntaxWarning (ungültige Escapes) wäre ein Fehler
|
||
|
|
compile(script, "solve.py", "exec")
|
||
|
|
|
||
|
|
|
||
|
|
def test_run_solver_code_with_structured_code():
|
||
|
|
run = run_solver_code(STRUCTURED_CODE)
|
||
|
|
assert run.status == SolverStatus.OPTIMAL
|
||
|
|
assert run.objective_value == pytest.approx(9)
|
||
|
|
|
||
|
|
|
||
|
|
def test_assemble_without_guard_prepends_helpers():
|
||
|
|
script = assemble_script("x = 1")
|
||
|
|
assert "def report(" in script and "_apply_data_override(globals())" in script
|
||
|
|
assert script.index("def report(") < script.index("x = 1")
|
||
|
|
|
||
|
|
|
||
|
|
def test_valid_structure_passes():
|
||
|
|
validate_structure(STRUCTURED_CODE)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("code, reason", [
|
||
|
|
("def solve():\n pass\n\nif __name__ == '__main__':\n solve()\n", "main()"),
|
||
|
|
("def main():\n pass\n\nmain()\n", "__main__"),
|
||
|
|
("def main():\n pass\n\nif __name__ == '__main__':\n main()\n\nx = 1\n", "__main__"),
|
||
|
|
])
|
||
|
|
def test_invalid_structure_is_rejected(code, reason):
|
||
|
|
with pytest.raises(CodeRejected, match=reason.replace("(", r"\(").replace(")", r"\)")):
|
||
|
|
validate_structure(code)
|
||
|
|
|
||
|
|
|
||
|
|
def test_html_report_has_copy_button_with_full_script():
|
||
|
|
page = to_html(build_document(make_result()))
|
||
|
|
assert page.count('class="copy-btn"') == 1
|
||
|
|
assert "navigator.clipboard" in page and "execCommand('copy')" in page
|
||
|
|
assert "#!/usr/bin/env python3" in page
|
||
|
|
assert "if __name__ == "__main__":" in page
|
||
|
|
|
||
|
|
|
||
|
|
def test_inline_math_delimiters_are_removed_from_docstring():
|
||
|
|
spec = make_spec(indices=["Tage: \\(d \\in D = \\{Mo,Di\\}\\)"])
|
||
|
|
result = make_result()
|
||
|
|
result.specification = spec
|
||
|
|
docstring = ast.get_docstring(ast.parse(build_solver_script(result)))
|
||
|
|
assert "Tage: d \\in D = \\{Mo,Di\\}" in docstring
|
||
|
|
assert "\\(" not in docstring and "\\)" not in docstring
|
||
|
|
|
||
|
|
|
||
|
|
def test_every_stack_and_class_has_algorithm_text():
|
||
|
|
# Synchron-Regel: neuer TargetStack/ProblemClass braucht Texte in script_export
|
||
|
|
from model_schema import ProblemClass, TargetStack
|
||
|
|
from script_export import ALGORITHMS, CLASS_FIT
|
||
|
|
assert set(ALGORITHMS) == set(TargetStack)
|
||
|
|
assert set(CLASS_FIT) == set(ProblemClass)
|