Skip to content

Commit 495e85e

Browse files
committed
Add MiniZinc model verification in tests
Introduce mzn verification utilities and integrate MiniZinc validation into unit tests. Added tests/mzn_verifier.py with verify_mzn_text and assert_valid_mzn helpers (refactoring CLI invocation to a single model-check call and returning (ok, message)). Updated tests/conftest.py to auto-verify rendered models when the MiniZinc CLI is available (monkeypatches Model._render_model_string to collect outputs and fail tests on invalid models); skips verification when minizinc is not found to keep local workflows fast. Updated several tests to call assert_valid_mzn and added a new expression embedding render test to ensure generated MiniZinc is valid.
1 parent 7296066 commit 495e85e

5 files changed

Lines changed: 75 additions & 32 deletions

File tree

‎tests/conftest.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import pytest
2+
import shutil
3+
4+
from tests.mzn_verifier import verify_mzn_text
25

36

47
def _has_minizinc_runtime() -> bool:
@@ -18,3 +21,34 @@ def pytest_collection_modifyitems(config, items):
1821
for item in items:
1922
if ("integration" in item.keywords):
2023
item.add_marker(skip_integration)
24+
25+
26+
@pytest.fixture(autouse=True)
27+
def verify_rendered_mzn_models(monkeypatch):
28+
# Keep local/unit workflow fast when CLI is unavailable.
29+
if (shutil.which("minizinc") is None):
30+
yield
31+
return
32+
33+
import pymzm
34+
35+
original_render = pymzm.Model._render_model_string
36+
rendered_models = []
37+
38+
def _wrapped_render(self, *args, **kwargs):
39+
rendered = original_render(self, *args, **kwargs)
40+
rendered_models.append(rendered)
41+
return rendered
42+
43+
monkeypatch.setattr(pymzm.Model, "_render_model_string", _wrapped_render)
44+
yield
45+
46+
seen = set()
47+
for rendered in rendered_models:
48+
if (rendered in seen):
49+
continue
50+
seen.add(rendered)
51+
52+
ok, message = verify_mzn_text(rendered)
53+
if (not ok):
54+
pytest.fail(f"Generated invalid MiniZinc model:\n{message}\n\nModel:\n{rendered}")

‎tests/mzn_verifier.py‎

Lines changed: 20 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -13,39 +13,27 @@ def _verify_with_minizinc_cli(model_path: Path):
1313
if (cli is None):
1414
return False, "MiniZinc CLI not found"
1515

16-
# Prefer explicit model-check mode and fall back to legacy spellings when unavailable.
17-
candidate_args = [
18-
["--model-check-only", str(model_path)],
19-
["--check-only", str(model_path)],
20-
]
21-
22-
last_error = ""
23-
for args in candidate_args:
24-
proc = subprocess.run(
25-
[cli, *args],
26-
stdout=subprocess.PIPE,
27-
stderr=subprocess.PIPE,
28-
text=True,
29-
)
30-
if (proc.returncode == 0):
31-
return True, ""
32-
33-
stderr = (proc.stderr or "").strip().lower()
34-
if ("unknown option" in stderr or "unrecognized option" in stderr):
35-
last_error = proc.stderr or proc.stdout
36-
continue
37-
38-
return False, (proc.stderr or proc.stdout or "MiniZinc verification failed")
39-
40-
return False, (last_error or "MiniZinc verification failed")
41-
42-
43-
def assert_valid_mzn_or_skip(testcase, mzn_text: str):
16+
proc = subprocess.run(
17+
[cli, "--model-check-only", str(model_path)],
18+
stdout=subprocess.PIPE,
19+
stderr=subprocess.PIPE,
20+
text=True,
21+
)
22+
if (proc.returncode == 0):
23+
return True, ""
24+
25+
return False, (proc.stderr or proc.stdout or "MiniZinc verification failed")
26+
27+
28+
def verify_mzn_text(mzn_text: str):
4429
with tempfile.TemporaryDirectory() as tmpdir:
4530
model_path = Path(tmpdir) / "model.mzn"
4631
model_path.write_text(mzn_text, encoding="utf-8")
32+
return _verify_with_minizinc_cli(model_path)
33+
4734

48-
ok, message = _verify_with_minizinc_cli(model_path)
49-
if (not ok and "cli not found" in message.lower()):
50-
testcase.skipTest(message)
51-
testcase.assertTrue(ok, msg=message)
35+
def assert_valid_mzn(testcase, mzn_text: str):
36+
ok, message = verify_mzn_text(mzn_text)
37+
if (not ok and "cli not found" in message.lower()):
38+
testcase.skipTest(message)
39+
testcase.assertTrue(ok, msg=message)

‎tests/test_architecture.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import pymzm
55

66
from pymzm.backends import MznTextBackend
7+
from tests.mzn_verifier import assert_valid_mzn
78

89

910
pytestmark = [pytest.mark.unit]
@@ -27,6 +28,7 @@ def test_model_to_ir_and_backend_render(self):
2728
expected = MznTextBackend().render_model(model_ir)
2829
model.generate()
2930
self.assertEqual(model.model_mzn_str, expected)
31+
assert_valid_mzn(self, model.model_mzn_str)
3032

3133

3234
if __name__ == "__main__":

‎tests/test_deterministic_generation.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import pytest
33

44
import pymzm
5+
from tests.mzn_verifier import assert_valid_mzn
56

67

78
pytestmark = [pytest.mark.unit, pytest.mark.snapshot]
@@ -54,6 +55,7 @@ def test_generate_is_deterministic_for_symbols_and_includes(self):
5455
self.assertIn('int: b = 2;', lines)
5556
self.assertLess(lines.index('int: a = 1;'), lines.index('int: b = 2;'))
5657
self.assertLess(lines.index('var 1..3: x;'), lines.index('var 1..3: y;'))
58+
assert_valid_mzn(self, model_a.model_mzn_str)
5759

5860

5961
if __name__ == "__main__":

‎tests/test_expression_validation.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import pytest
33

44
import pymzm
5+
from tests.mzn_verifier import assert_valid_mzn
56

67

78
pytestmark = [pytest.mark.unit]
@@ -111,6 +112,22 @@ def test_conditional_validates_branches_and_else(self):
111112
self.assertRaises(pymzm.PymzmValueIsNotExpression, pymzm.Expression.conditional, [(x >= 1, object())], 0)
112113
self.assertRaises(pymzm.PymzmValueIsNotExpression, pymzm.Expression.conditional, [(x >= 1, 1)], object())
113114

115+
def test_expression_render_is_valid_mzn_when_embedded_in_model(self):
116+
model = pymzm.Model()
117+
x = model.add_variable("x", val_min=0, val_max=10)
118+
119+
y_expr = pymzm.Expression.let(["int: y = x + 1"], pymzm.Expression("y"))
120+
guard = pymzm.Expression.conditional([(x >= 5, True), (x >= 2, x >= 3)], False)
121+
q = pymzm.Expression.forall("i", range(1, 4), lambda i: x >= i)
122+
123+
model.add_constraint(guard)
124+
model.add_constraint(q)
125+
model.add_constraint(y_expr >= 1)
126+
model.set_solve_criteria(pymzm.SOLVE_MAXIMIZE, y_expr)
127+
model.generate()
128+
129+
assert_valid_mzn(self, model.model_mzn_str)
130+
114131

115132
if __name__ == "__main__":
116133
unittest.main()

0 commit comments

Comments
 (0)