#!/usr/bin/python3 ## runtest.py # The runtest.py script runs regression tests on the CuraEngine. # It parses the json file for settings to know which settings can be passed towards the engine. # It runs the following test: # * Defaults # * Bounds/possible values # * Single random value # * All settings random import ast #For safe function evaluation. import math #For evaluating setting inheritance functions. import sys import subprocess import os import time import stat import argparse import random import json import threading from xml.etree import ElementTree # Mock function that occurs in fdmprinter.def.json # Use wrapper to provide locals def extruderValueWrapper(_locals): def extruderValue(extruder_nr, parameter): return eval(parameter, globals(), _locals) return extruderValue # Mock function that occurs in fdmprinter.def.json # Use wrapper to provide locals def extruderValuesWrapper(_locals): def extruderValues(parameter): return [eval(parameter, globals(), _locals)] return extruderValues # Mock function that occurs in fdmprinter.def.json # Use wrapper to provide locals def resolveOrValueWrapper(_locals): def resolveOrValue(parameter): return eval(parameter, globals(), _locals) return resolveOrValue ## The TestSuite class stores the test results of a single set of tests. # TestSuite objects are created by the TestResults class. class TestSuite: def __init__(self, name): self._name = name self._successes = [] self._failures = [] ## Add a successful test result to the test suite. def success(self, class_name, test_name): self._successes.append((class_name, test_name)) ## Add a failed test result to the test suite. def failure(self, class_name, test_name, error_message): self._failures.append((class_name, test_name, error_message)) ## Return the number of tests in this test suite def getTestCount(self): return self.getSuccessCount() + self.getFailureCount() ## Return the number of successful tests in this test suite def getSuccessCount(self): return len(self._successes) ## Return the number of failed tests in this test suite def getFailureCount(self): return len(self._failures) ## The TestResults class stores a group of TestSuite objects, each TestSuite object contains failed and successful test. # This class can output the result of the tests in a JUnit xml format for parsing in Jenkins. class TestResults: def __init__(self): self._testsuites = [] ## Create a new test suite with the name. def addTestSuite(self, name): suite = TestSuite(name) self._testsuites.append(suite) return suite def getFailureCount(self): result = 0 for testsuite in self._testsuites: result += testsuite.getFailureCount() return result ## Save the test results to the file given in the filename. def saveXML(self, filename): xml = ElementTree.Element("testsuites") xml.text = "\n" for testsuite in self._testsuites: testsuite_xml = ElementTree.SubElement(xml, "testsuite", {"name": testsuite._name, "errors": "0", "tests": str(testsuite.getTestCount()), "failures": str(testsuite.getFailureCount()), "time": "0", "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime())}) testsuite_xml.text = "\n" testsuite_xml.tail = "\n" for class_name, test_name in testsuite._successes: testcase_xml = ElementTree.SubElement(testsuite_xml, "testcase", {"classname": class_name, "name": test_name}) testcase_xml.text = "\n" testcase_xml.tail = "\n" for class_name, test_name, error_message in testsuite._failures: testcase_xml = ElementTree.SubElement(testsuite_xml, "testcase", {"classname": class_name, "name": test_name}) testcase_xml.text = "\n" testcase_xml.tail = "\n" failure = ElementTree.SubElement(testcase_xml, "failure", {"message": "test failure"}) failure.text = error_message failure.tail = "\n" return ElementTree.ElementTree(xml).write(filename, "utf-8", True) class Setting: ## Creates a new setting from a JSON node. # # Some parts of the setting may have to be evaluated as functions. For # these, the default values of all settings are added as local variables. # # \param key The name of the setting. # \param data The JSON node of the setting, containing the default value, # the setting type, the minimum value, maximum value, the minimum warning # value, the maximum warning value and (for enum types) the options. # \param locals The local variables for eventual function evaluation. def __init__(self, key, data, locals): self._key = key if "value" in data: #Evaluate "value" if we can, otherwise just take default_value. self._default = self._evaluateFunction(data.get("value", "0"), locals) else: self._default = data.get("default_value", 0) self._type = data["type"] self._min_value = self._evaluateFunction(data.get("minimum_value", None), locals) self._max_value = self._evaluateFunction(data.get("maximum_value", None), locals) self._min_value_warning = self._evaluateFunction(data.get("minimum_value_warning", None), locals) self._max_value_warning = self._evaluateFunction(data.get("maximum_value_warning", None), locals) self._options = data.get("options", None) if self._options is not None: self._options = list(self._options.keys()) ## Gets the default value of this setting, according to the source JSON. # # \return The default value. def getDefault(self): return self._default ## Return a list of possible values for this setting. This list depends on the setting type. # For number values it contains the minimal and maximal values. # For enums and booleans it will contain the exact possible values. # For string settings only the default value is returned. def getSettingValues(self): if self._type == "bool": return ["True", "False"] if self._type == "float" or self._type == "int": ret = [self._default] if self._min_value is not None: ret.append(self._min_value) else: ret.append(-2) if self._min_value_warning is not None: ret.append(self._min_value_warning) if self._max_value is not None: ret.append(self._max_value) else: if self._max_value_warning is None: ret.append(10000) elif self._type == "float": ret.append(float(self._max_value_warning) + 100) ret.append(float(self._max_value_warning) * 10) elif self._type == "int": ret.append(int(self._max_value_warning) + 100) ret.append(int(self._max_value_warning) * 10) # If the type is boolean, string or enum, the warning values make no sense anyway, so don't test them. if self._max_value_warning is not None: ret.append(self._max_value_warning) if self._type == "int": for n in range(0, len(ret)): ret[n] = int(ret[n]) return ret if self._type == "enum": return self._options if self._type == "str": return [self._default] if self._type == "extruder": return [self._default] # TODO: also allow for other values below machine_extruder_count if self._type == "polygon": return [self._default] if self._type == "polygons": return [self._default] print("Unknown setting type:", self._type) ## Return a random value for this setting. The returned value will be a valid value according to the settings json file. def getRandomValue(self): if self._type == "float" or self._type == "int": min = -2 if self._min_value_warning is not None: min = self._min_value_warning if self._min_value is not None: min = self._min_value max = 10000 if self._max_value_warning is not None: max = self._max_value_warning if self._max_value is not None: max = self._max_value if self._type == "int": return random.randint(int(min), int(max)) return random.uniform(float(min), float(max)) return random.choice(self.getSettingValues()) ## Evaluates a setting value that is described as a function. # # Note that this function should behave EXACTLY the same as it does in # UM/Settings/Setting.py:_createFunction. The only differences should be # that this evaluation always uses the default values instead of the # current profile values, and that this function directly evaluates the # setting instead of returning a function with which to evaluate the # setting. Also, this function doesn't need to compile the list of # settings that this setting depends on. # # \param code The string to evaluate as a function of default values of # other settings. # \param locals The default values of other settings, as dictionary keyed # by the setting names. # \return The evaluated value of the setting, or None if \p code was None. def _evaluateFunction(self, code, locals): if not code: #The input was None. This setting value doesn't exist in the JSON. return None try: tree = ast.parse(code, "eval") compiled = compile(code, self._key, "eval") except (SyntaxError, TypeError) as e: print("Parse error in function (" + str(code) + ") for setting", self._key + ":", str(e)) return None except IllegalMethodError as e: print("Use of illegal method", str(e), "in function (" + code + ") for setting", self._key) return None except Exception as e: print("Exception in function (" + code + ") for setting", self._key + ":", str(e)) return None return eval(compiled, globals(), locals) class EngineTest: def __init__(self, json_filename, engine_filename, models): self._json_filename = json_filename self._json = json.load(open(json_filename, "r")) self._locals = {} self._addAllLocals() #Fills the _locals dictionary. self._addLocalsFunctions() # Add mock functions used in fdmprinter self._engine = engine_filename self._models = models self._settings = {} self._test_results = TestResults() self._flattenAllSettings() def _flattenAllSettings(self): for key, data in self._json["settings"].items(): # top level settings are categories self._flattenSettings(data["children"]) # actual settings are children of top level category-settings def _flattenSettings(self, settings): for key, setting in settings.items(): if not ("type" in setting and setting["type"] == "category"): self._settings[key] = Setting(key, setting, self._locals) if "children" in setting: self._flattenSettings(setting["children"]) def testDefaults(self): suite = self._test_results.addTestSuite("Defaults") self._runTest(suite, "defaults", {}) return suite.getFailureCount() def testSingleChanges(self): suite = self._test_results.addTestSuite("SingleSetting") for key, setting in self._settings.items(): for value in setting.getSettingValues(): self._runTest(suite, key, {key: value}) return suite.getFailureCount() def testSingleRandom(self): suite = self._test_results.addTestSuite("SingleRandom") for key, setting in self._settings.items(): self._runTest(suite, key, {key: setting.getRandomValue()}) return suite.getFailureCount() def testDualRandom(self): suite = self._test_results.addTestSuite("DualRandom") for key, setting in self._settings.items(): for key2, setting2 in self._settings.items(): if key != key2: self._runTest(suite, key, {key: setting.getRandomValue(), key2: setting2.getRandomValue()}) return suite.getFailureCount() def testAllRandom(self, amount): suite = self._test_results.addTestSuite("AllRandom_%d" % (amount)) for n in range(0, amount): settings = {} for key, setting in self._settings.items(): settings[key] = setting.getRandomValue() self._runTest(suite, "Random", settings) return suite.getFailureCount() def _runTest(self, suite, class_name, settings): test_name = ', '.join("{!s}={!r}".format(key, val) for (key,val) in settings.items()) for model in self._models: this_test_name = '%s.%s' % (os.path.basename(model), test_name) cmd = [self._engine, "slice", "-j", self._json_filename, "-o", "/dev/null"] for key, value in settings.items(): cmd += ['-s', '%s=%s' % (key, value)] cmd += ["-l", model] error = self._runProcess(cmd) if error is not None: suite.failure(class_name, this_test_name, error) else: suite.success(class_name, this_test_name) def _runProcess(self, cmd): p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) p.error = "" t = threading.Thread(target=self._abortProcess, args=(p,)) p.aborting = False t.start() stdout, stderr = p.communicate() p.aborting = True if p.error == "Timeout": return "Timeout: %s" % (' '.join(cmd)) if p.wait() != 0: return "Execution failed: %s" % (' '.join(cmd)) return None def _abortProcess(self, p): for i in range(0, 60): time.sleep(1) #Check every 1000ms if we need to abort the thread. if p.aborting: break if p.poll() is None: p.terminate() p.error = "Timeout" def getResults(self): return self._test_results ## Adds all default values for all settings to the locals. # # The results are stored in self._locals, keyed by the setting name. def _addAllLocals(self): for key, data in self._json["settings"].items(): # top level categories self._addLocals(data["children"]) # the actual settings in each category def _addLocalsFunctions(self): extruderValue = extruderValueWrapper(self._locals) self._locals['extruderValue'] = extruderValue extruderValues = extruderValuesWrapper(self._locals) self._locals['extruderValues'] = extruderValues resolveOrValue = resolveOrValueWrapper(self._locals) self._locals['resolveOrValue'] = resolveOrValue ## Adds the default values in a node of the setting tree to the locals. # # The results are stored in self._locals, keyed by the setting name. # # \param settings The JSON node of which to add the default values. def _addLocals(self, settings): for key, setting in settings.items(): if not ("type" in setting and setting["type"] == "category"): # skip category-settings self._locals[key] = setting["default_value"] if "children" in setting: self._addLocals(setting["children"]) #Recursively go down the tree. def main(engine, model_path): filenames = sorted(os.listdir(model_path), key=lambda filename: os.stat(os.path.join(model_path, filename)).st_size) filenames = list(filter(lambda filename: filename.lower().endswith(".stl"), filenames)) for filename in filenames: print("Slicing: %s (%d/%d)" % (filename, filenames.index(filename), len(filenames))) t = time.time() p = subprocess.Popen([engine, "-vv", os.path.join(model_path, filename)], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdout, stderr = p.communicate() if p.wait() != 0: print ("Engine failed to report success on test object: %s" % (filename)) print(stderr.decode("utf-8", "replace").split("\n")[-5:]) sys.exit(1) else: print("Slicing took: %f" % (time.time() - t)) if __name__ == "__main__": parser = argparse.ArgumentParser(description="CuraEngine testing script") parser.add_argument("--simple", action="store_true", help="Only run the single test, exit") parser.add_argument("json", type=str, help="Machine JSON file to use") parser.add_argument("engine", type=str, help="Engine executable") parser.add_argument("models", type=str, nargs="+", help="List of models to use for testing") args = parser.parse_args() et = EngineTest(args.json, args.engine, args.models) if et.testDefaults() == 0: if not args.simple: et.testSingleChanges() if et.testSingleRandom() == 0: et.testDualRandom() if et.testAllRandom(10) == 0: et.testAllRandom(100) et.getResults().saveXML("output.xml") if args.simple: if et.getResults().getFailureCount() > 0: sys.exit(1)