this repo has no description
1from minizinc_testing import yaml
2from minizinc_testing.spec import CachedResult
3
4import pytest
5
6# pylint: disable=import-error,no-name-in-module
7from py.xml import html
8from html import escape
9import pytest_html
10import re
11import minizinc as mzn
12from difflib import HtmlDiff
13import sys
14
15
16def pytest_configure(config):
17 pytest.solver_cache = {}
18 search = config.getoption("--driver")
19 if search is not None:
20 driver = mzn.find_driver([search])
21 if driver is None:
22 raise Exception("Failed to find MiniZinc driver in {}".format(search))
23 driver.make_default()
24
25
26def pytest_addoption(parser):
27 parser.addoption(
28 "--solvers",
29 action="store",
30 metavar="SOLVERS",
31 help="only run tests with the comma separated SOLVERS.",
32 )
33 parser.addoption(
34 "--suite",
35 action="append",
36 default=[],
37 metavar="SUITE_NAME",
38 help="Use the given YAML configuration from suites.yml",
39 )
40 parser.addoption(
41 "--all-suites", action="store_true", dest="feature", help="Run all test suites"
42 )
43 parser.addoption(
44 "--driver",
45 action="store",
46 metavar="MINIZINC",
47 help="Directory containing MiniZinc executable",
48 )
49
50
51def pytest_collect_file(parent, path):
52 if path.ext == ".mzn":
53 return MznFile.from_parent(parent, fspath=path)
54
55
56def pytest_html_results_table_header(cells):
57 cells.insert(2, html.th("Solver", class_="sortable", col="solver"))
58 cells.insert(3, html.th("Checker", class_="sortable", col="checker"))
59 cells.pop()
60
61
62def pytest_html_results_table_row(report, cells):
63 if hasattr(report, "user_properties"):
64 props = {k: v for k, v in report.user_properties}
65 cells.insert(2, html.td(props["solver"]))
66 cells.insert(3, html.td(props["checker"] if "checker" in props else "-"))
67 cells.pop()
68
69
70@pytest.hookimpl(hookwrapper=True)
71def pytest_runtest_makereport(item, call):
72 outcome = yield
73 report = outcome.get_result()
74 extra = getattr(report, "extra", [])
75 if report.when == "call" and report.outcome != "skipped":
76 props = {k: v for k, v in report.user_properties}
77 if "compare" in props:
78 required, obtained = props["compare"]
79 html_content = """
80 <button class="copy-button" onclick="this.nextElementSibling.select();document.execCommand('copy');this.textContent = 'Copied!';">Copy obtained output to clipboard</button>
81 <textarea class="hidden-textarea" readonly>{}</textarea>
82 """.format(
83 escape(obtained)
84 )
85 actual = obtained.split("\n")
86 htmldiff = HtmlDiff(2)
87 html_content += '<h4>Diffs</h4><div class="diffs">'
88 html_content += "<hr>".join(
89 htmldiff.make_table(
90 expected.split("\n"),
91 actual,
92 fromdesc="expected",
93 todesc="actual",
94 context=True,
95 )
96 for expected in required
97 )
98 html_content += "</div>"
99 extra.append(pytest_html.extras.html(html_content))
100 report.extra = extra
101
102
103def pytest_metadata(metadata):
104 # Ensure that secrets don't get shown
105 # Can likely be removed after pytest-metadata is updated
106 metadata.pop("CI_JOB_TOKEN", None)
107 metadata.pop("CI_REPOSITORY_URL", None)
108 metadata.pop("CI_REGISTRY_PASSWORD", None)
109
110
111class MznFile(pytest.File):
112 def collect(self):
113 with open("./spec/suites.yml", encoding="utf-8") as suites_file:
114 suites = yaml.load(suites_file)
115
116 if not self.config.getoption("--all-suites"):
117 enabled_suites = self.config.getoption("--suite")
118 if len(enabled_suites) == 0:
119 suites = {"default": suites["default"]}
120 else:
121 suites = {k: v for k, v in suites.items() if k in enabled_suites}
122
123 with self.fspath.open(encoding="utf-8") as file:
124 contents = file.read()
125 yaml_comment = re.match(r"\/\*\*\*\n(.*?)\n\*\*\*\/", contents, flags=re.S)
126 if yaml_comment is None:
127 pytest.skip(
128 "skipping {} as no tests specified".format(str(self.fspath))
129 )
130 else:
131 tests = [doc for doc in yaml.load_all(yaml_comment.group(1))]
132
133 for suite_name, suite in suites.items():
134 if any(self.fspath.fnmatch(glob) for glob in suite.includes):
135 for i, spec in enumerate(tests):
136 for solver in spec.solvers:
137 base = (
138 str(i) if spec.name is yaml.Undefined else spec.name
139 )
140 name = "{}.{}.{}".format(suite_name, base, solver)
141 cache = CachedResult()
142 yield SolveItem.from_parent(
143 self,
144 name=name,
145 spec=spec,
146 solver=solver,
147 cache=cache,
148 markers=spec.markers,
149 suite=suite,
150 )
151 for checker in spec.check_against:
152 yield CheckItem.from_parent(
153 self,
154 name="{}:{}".format(name, checker),
155 cache=cache,
156 solver=solver,
157 checker=checker,
158 markers=spec.markers,
159 suite=suite,
160 )
161
162
163class MznItem(pytest.Item):
164 def __init__(self, name, parent, solver, markers, suite):
165 super().__init__(name, parent)
166 self.user_properties.append(("solver", solver))
167 for marker in markers:
168 self.add_marker(marker)
169
170 if self.config.getoption("--solvers") is not None:
171 self.allowed = [
172 x.strip() for x in self.config.getoption("--solvers").split(",")
173 ]
174 self.allowed = [x for x in self.allowed if x in suite.solvers]
175 else:
176 self.allowed = suite.solvers
177
178 if not self.solver_allowed(solver):
179 self.add_marker(
180 pytest.mark.skip("skipping {} not in {}".format(solver, self.allowed))
181 )
182
183 if not self.solver_exists(solver):
184 self.add_marker(pytest.mark.skip("Solver {} not available".format(solver)))
185
186 def solver_allowed(self, solver):
187 return self.allowed is None or solver in self.allowed
188
189 def solver_exists(self, solver):
190 solver_exists = pytest.solver_cache.get(solver, None)
191 if solver_exists is None:
192 try:
193 s = mzn.Solver.lookup(solver)
194 empty_model = mzn.Model()
195 empty_model.add_string("solve satisfy;")
196 instance = mzn.Instance(s, empty_model)
197 instance.solve()
198 solver_exists = True
199 except (mzn.MiniZincError, LookupError) as error:
200 solver_exists = False
201 finally:
202 pytest.solver_cache[solver] = solver_exists
203 return solver_exists
204
205
206class SolveItem(MznItem):
207 def __init__(self, name, parent, spec, solver, cache, markers, suite):
208 super().__init__(name, parent, solver, markers, suite)
209 self.spec = spec
210 self.solver = solver
211 self.cache = cache
212 self.default_options = suite.options
213 self.strict = suite.strict
214
215 def runtest(self):
216 model, result, required, obtained = self.spec.run(
217 str(self.fspath), self.solver, default_options=self.default_options
218 )
219
220 # To pass model and result to checker test item
221 self.cache.model = model
222 self.cache.result = result
223 self.cache.obtained = obtained
224
225 passed = self.spec.passed(result)
226
227 if not passed:
228 # Test fails if we still haven't passed
229 expected = [yaml.dump(exp) for exp in required]
230 actual = yaml.dump(obtained)
231 self.user_properties.append(("compare", (expected, actual)))
232 message = "expected one of\n\n{}\n\nbut got\n\n{}".format(
233 "\n---\n".join(expected), actual
234 )
235
236 # Doesn't match, so backup by checking against another solver
237 if isinstance(result, mzn.Result) and result.status.has_solution():
238 checkers = [s for s in self.spec.solvers if s is not self.solver]
239 if len(checkers) > 0:
240 checker = checkers[0]
241 non_strict_pass = self.cache.test(checker)
242 status = "but passed" if non_strict_pass else "and failed"
243 message += "\n\n{} check against {}.".format(status, checker)
244
245 if not self.strict and non_strict_pass:
246 print(message, file=sys.stderr)
247 return
248
249 assert False, message
250
251 def reportinfo(self):
252 return self.fspath, 0, "{}::{}".format(str(self.fspath), self.name)
253
254
255class CheckItem(MznItem):
256 def __init__(self, name, parent, cache, solver, checker, markers, suite):
257 super().__init__(name, parent, solver, markers, suite)
258 if not self.solver_allowed(checker):
259 self.add_marker(
260 pytest.mark.skip(
261 "skipping checker {} not in {}".format(checker, self.allowed)
262 )
263 )
264 if not self.solver_exists(checker):
265 self.add_marker(
266 pytest.mark.skip("skipping checker {} not available".format(checker))
267 )
268
269 self.cache = cache
270 self.solver = solver
271 self.checker = checker
272 self.user_properties.append(("checker", checker))
273 self.add_marker(pytest.mark.check)
274
275 def runtest(self):
276 if (
277 not isinstance(self.cache.result, mzn.Result)
278 or not self.cache.result.status.has_solution()
279 ):
280 pytest.skip("skipping check for no result/solution")
281 else:
282 passed = self.cache.test(self.checker)
283 assert passed, "failed when checking against {}. Got {}".format(
284 self.checker, yaml.dump(self.cache.obtained)
285 )
286
287 def reportinfo(self):
288 return self.fspath, 0, "{}::{}".format(str(self.fspath), self.name)