diff --git a/.gitignore b/.gitignore index 8d5c70a..ccfd8f3 100644 --- a/.gitignore +++ b/.gitignore @@ -8,9 +8,6 @@ __pycache__/ *.dll binary/ -# installed include files -include/ - # scons .sconsign.dblite workspace/build diff --git a/nexxT/core/ActiveApplication.py b/nexxT/core/ActiveApplication.py index c88a647..ae7e49f 100644 --- a/nexxT/core/ActiveApplication.py +++ b/nexxT/core/ActiveApplication.py @@ -15,6 +15,8 @@ from nexxT.core.CompositeFilter import CompositeFilter from nexxT.core.Utils import Barrier, assertMainThread, mainThread, MethodInvoker from nexxT.core.Thread import NexTThread +from nexxT.core.PropertyCollectionImpl import PropertyCollectionProxy +from nexxT.core.Variables import Variables logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -28,6 +30,8 @@ class ActiveApplication(QObject): stateChanged = Signal(int) # Signal is emitted after the state of the graph has been changed aboutToClose = Signal() # Signal is emitted before stop operation takes place + singleThreaded = False + def __init__(self, graph): super().__init__() assertMainThread() @@ -59,29 +63,43 @@ def getApplication(self): """ return self._graph.getSubConfig() - def _traverseAndSetup(self, graph, namePrefix=""): + def _traverseAndSetup(self, graph, namePrefix="", variables=None): """ Recursively create threads and add the filter mockups to them """ + if variables is None: + variables = graph.getSubConfig().getConfiguration().propertyCollection().getVariables() for basename in graph.allNodes(): filtername = namePrefix + "/" + basename mockup = graph.getMockup(basename) if issubclass(mockup.getPluginClass(), CompositeFilter.CompositeNode): with mockup.createFilter() as cf: self._composite2graphs[filtername] = cf.getPlugin().getGraph() - self._traverseAndSetup(cf.getPlugin().getGraph(), filtername) + compositeVars = mockup.getPropertyCollectionImpl().getVariables().copyAndReparent(variables) + self._traverseAndSetup(cf.getPlugin().getGraph(), filtername, compositeVars) elif issubclass(mockup.getPluginClass(), CompositeFilter.CompositeInputNode): pass elif issubclass(mockup.getPluginClass(), CompositeFilter.CompositeOutputNode): pass else: props = mockup.getPropertyCollectionImpl() + filterVars = Variables(parent=variables if variables is not None else props.getVariables()) + filterVars.setObjectName("proxy:" + filtername) + filterVars["COMPOSITENAME"] = namePrefix if namePrefix != "" else "" + filterVars["FILTERNAME"] = basename + filterVars["FULLQUALIFIEDFILTERNAME"] = filtername + filterVars["APPNAME"] = self._graph.getSubConfig().getName() + filterVars.setReadonly(["COMPOSITENAME", "FILTERNAME", "FULLQUALIFIEDFILTERNAME", "APPNAME"]) + props = PropertyCollectionProxy(props, filterVars) nexTprops = props.getChildCollection("_nexxT") threadName = nexTprops.getProperty("thread") - if not threadName in self._threads: + threadName = props.getVariables().subst(threadName) + if self.singleThreaded: + threadName = "main" + if threadName not in self._threads: # create threads as needed self._threads[threadName] = NexTThread(threadName) - self._threads[threadName].addMockup(filtername, mockup) + self._threads[threadName].addMockup(filtername, mockup, props) self._filters2threads[filtername] = threadName def __del__(self): diff --git a/nexxT/core/AppConsole.py b/nexxT/core/AppConsole.py index e932448..156b484 100644 --- a/nexxT/core/AppConsole.py +++ b/nexxT/core/AppConsole.py @@ -22,6 +22,8 @@ from nexxT.core.ConfigFiles import ConfigFileLoader from nexxT.core.Configuration import Configuration from nexxT.core.Application import Application +from nexxT.core.ActiveApplication import ActiveApplication +from nexxT.core.PluginManager import PythonLibrary # this import is needed for initializing the nexxT qt resources import nexxT.core.qrc_resources # pylint: disable=unused-import from nexxT.interface import Services, FilterState @@ -30,7 +32,7 @@ from nexxT.services.SrvConfiguration import MVCConfigurationBase from nexxT.services.SrvPlaybackControl import PlaybackControlConsole from nexxT.services.SrvRecordingControl import MVCRecordingControlBase -from nexxT.services.SrvProfiling import ProfilingService +from nexxT.services.SrvProfiling import ProfilingServiceDummy from nexxT.services.gui.GuiLogger import GuiLogger from nexxT.services.gui.MainWindow import MainWindow from nexxT.services.gui.Configuration import MVCConfigurationGUI @@ -50,8 +52,9 @@ def setupConsoleServices(config): Services.addService("PlaybackControl", PlaybackControlConsole(config)) Services.addService("RecordingControl", MVCRecordingControlBase(config)) Services.addService("Configuration", MVCConfigurationBase(config)) + Services.addService("Profiling", ProfilingServiceDummy()) -def setupGuiServices(config): +def setupGuiServices(config, disableProfiling=False): """ Adds services available in console mode. :param config: a nexxT.core.Configuration instance @@ -63,9 +66,13 @@ def setupGuiServices(config): Services.addService("PlaybackControl", MVCPlaybackControlGUI(config)) Services.addService("RecordingControl", MVCRecordingControlGUI(config)) Services.addService("Configuration", MVCConfigurationGUI(config)) - Services.addService("Profiling", Profiling()) + if not disableProfiling: + Services.addService("Profiling", Profiling()) + else: + Services.addService("Profiling", ProfilingServiceDummy()) -def startNexT(cfgfile, active, execScripts, execCode, withGui): +def startNexT(cfgfile, active, execScripts, execCode, withGui, singleThreaded=False, disableUnloadHeuristic=False, + disableProfiling=False): """ Starts next with the given config file and activates the given application. :param cfgfile: path to config file @@ -75,18 +82,23 @@ def startNexT(cfgfile, active, execScripts, execCode, withGui): logger.debug("Starting nexxT...") config = Configuration() QLocale.setDefault(QLocale.c()) + if withGui: app = QApplication() if QApplication.instance() is None else QApplication.instance() app.setWindowIcon(QIcon(":icons/nexxT.svg")) app.setOrganizationName("nexxT") app.setApplicationName("nexxT") - setupGuiServices(config) + setupGuiServices(config, disableProfiling=disableProfiling) else: app = QCoreApplication() if QCoreApplication.instance() is None else QCoreApplication.instance() app.setOrganizationName("nexxT") app.setApplicationName("nexxT") setupConsoleServices(config) + ActiveApplication.singleThreaded = singleThreaded + PythonLibrary.disableUnloadHeuristic = disableUnloadHeuristic + + if cfgfile is not None: ConfigFileLoader.load(config, cfgfile) if withGui: @@ -164,18 +176,25 @@ def main(withGui): """) parser.add_argument("cfg", nargs='?', help=".json configuration file of the project to be loaded.") parser.add_argument("-a", "--active", default=None, type=str, - help="active application; default: first application in config file") + help="active application; default: first application in config file.") parser.add_argument("-l", "--logfile", default=None, type=str, help="log file location (.db extension will use sqlite).") parser.add_argument("-v", "--verbosity", default="INFO", choices=["INTERNAL", "DEBUG", "INFO", "WARN", "ERROR", "FATAL", "CRITICAL"], help="sets the log verbosity") - parser.add_argument("-q", "--quiet", action="store_true", default=False, help="disble logging to stderr") + parser.add_argument("-q", "--quiet", action="store_true", default=False, help="disble logging to stderr.") parser.add_argument("-e", "--execpython", action="append", default=[], help="execute arbitrary python code given in a string before actually starting the " "application.") parser.add_argument("-s", "--execscript", action="append", default=[], help="execute arbitrary python code given in a file before actually starting the application.") + parser.add_argument("-t", "--single-threaded", action="store_true", default=False, + help="force using only the main thread") + parser.add_argument("-u", "--disable-unload-heuristic", action="store_true", default=False, + help="disable unload heuristic for python modules.") + parser.add_argument("-np", "--no-profiling", action="store_true", + help="disable profiling support (only relevant for GUI).") + def str2bool(value): if isinstance(value, bool): return value @@ -204,7 +223,9 @@ def str2bool(value): handler = logging.FileHandler(args.logfile) handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")) nexT_logger.addHandler(handler) - startNexT(args.cfg, args.active, args.execscript, args.execpython, withGui=args.gui) + startNexT(args.cfg, args.active, args.execscript, args.execpython, withGui=args.gui, + singleThreaded=args.single_threaded, disableUnloadHeuristic=args.disable_unload_heuristic, + disableProfiling=args.no_profiling) def mainConsole(): """ diff --git a/nexxT/core/BaseGraph.py b/nexxT/core/BaseGraph.py index 85317c8..7d8d15d 100644 --- a/nexxT/core/BaseGraph.py +++ b/nexxT/core/BaseGraph.py @@ -117,7 +117,7 @@ def renameNode(self, oldName, newName): self._connections[i] = c p = self._connectionProps[oldConn] del self._connectionProps[oldConn] - self._connectionProps[c] = p + self._connectionProps[c] = p self.nodeRenamed.emit(oldName, newName) self.dirtyChanged.emit() diff --git a/nexxT/core/CompositeFilter.py b/nexxT/core/CompositeFilter.py index d55f5ef..c0258f2 100644 --- a/nexxT/core/CompositeFilter.py +++ b/nexxT/core/CompositeFilter.py @@ -80,6 +80,7 @@ def __init__(self, name, configuration): def compositeNode(self, env): """ Factory function for creating a dummy filter instance (this one will never get active). + :param env: the FilterEnvironment instance :return: a Filter instance """ diff --git a/nexxT/core/ConfigFileSchema.json b/nexxT/core/ConfigFileSchema.json index 16bd489..a766f05 100644 --- a/nexxT/core/ConfigFileSchema.json +++ b/nexxT/core/ConfigFileSchema.json @@ -22,7 +22,29 @@ "type": "object", "propertyNames": { "$ref": "#/definitions/identifier" }, "patternProperties": { - "^.*$": {"type": ["string", "number", "boolean"]} + "^.*$": { + "anyOf": [ + {"type": "string"}, {"type": "number"}, {"type": "boolean"}, + { + "type": "object", + "additionalProperties": false, + "required": ["value", "subst"], + "properties": { + "value": {"anyOf": [{"type": "string"}, {"type": "number"}, {"type": "boolean"}]}, + "subst": {"type": "boolean"} + } + } + ] + } + } + }, + "variables": { + "type": "object", + "propertyNames": {"$ref": "#/definitions/identifier"}, + "patternProperties": { + "^.*$": { + "type": "string" + } } }, "sub_graph": { @@ -58,7 +80,7 @@ "type": "string" }, "thread": { - "$ref": "#/definitions/identifier", + "type": "string", "default": "main" }, "dynamicInputPorts": { @@ -80,6 +102,9 @@ "properties": { "$ref": "#/definitions/propertySection", "default": {} + }, + "variables": { + "$ref": "#/definitions/variables" } } } @@ -110,6 +135,9 @@ "$ref": "#/definitions/sub_graph" } }, + "variables": { + "$ref": "#/definitions/variables" + }, "_guiState": { "$ref": "#/definitions/propertySection", "default": {} diff --git a/nexxT/core/Configuration.py b/nexxT/core/Configuration.py index a618efb..5bb7428 100644 --- a/nexxT/core/Configuration.py +++ b/nexxT/core/Configuration.py @@ -13,7 +13,7 @@ from nexxT.core.Application import Application from nexxT.core.CompositeFilter import CompositeFilter from nexxT.core.Exceptions import (NexTRuntimeError, CompositeRecursion, NodeNotFoundError, NexTInternalError, - PropertyCollectionPropertyNotFound, PropertyCollectionChildNotFound) + PropertyCollectionChildNotFound) from nexxT.core.PropertyCollectionImpl import PropertyCollectionImpl from nexxT.core.PluginManager import PluginManager from nexxT.core.ConfigFiles import ConfigFileLoader @@ -50,11 +50,27 @@ def configType(subConfig): return Configuration.CONFIG_TYPE_COMPOSITE raise NexTRuntimeError("Unexpected instance type") + def _defaultRootPropColl(self): + variables = None if not hasattr(self, "_propertyCollection") else self._propertyCollection.getVariables() + res = PropertyCollectionImpl("root", None, variables=variables) + theVars = res.getVariables() + theVars.setReadonly({}) + for v in list(theVars.keys()): + del theVars[v] + # setup the default variables available on all platforms + theVars["CFG_DIR"] = "${!str(importlib.import_module('pathlib').Path(subst('$CFGFILE')).parent.absolute())}" + theVars["NEXXT_PLATFORM"] = "${!importlib.import_module('nexxT.core.Utils').nexxtPlatform()}" + theVars["NEXXT_VARIANT"] = "${!importlib.import_module('os').environ.get('NEXXT_VARIANT', 'release')}" + theVars.setReadonly({"CFG_DIR", "NEXXT_PLATFORM", "NEXXT_VARIANT", "CFGFILE"}) + theVars.variableAddedOrChanged.connect(lambda *args: self.setDirty()) + theVars.variableDeleted.connect(lambda *args: self.setDirty()) + return res + def __init__(self): super().__init__() self._compositeFilters = [] self._applications = [] - self._propertyCollection = PropertyCollectionImpl("root", None) + self._propertyCollection = self._defaultRootPropColl() self._guiState = PropertyCollectionImpl("_guiState", self._propertyCollection) self._dirty = False @@ -95,7 +111,7 @@ def close(self, avoidSave=False): self.subConfigRemoved.emit(a.getName(), self.CONFIG_TYPE_APPLICATION) self._applications = [] self._propertyCollection.deleteLater() - self._propertyCollection = PropertyCollectionImpl("root", None) + self._propertyCollection = self._defaultRootPropColl() self.configNameChanged.emit(None) self.appActivated.emit("", None) PluginManager.singleton().unloadAll() @@ -112,9 +128,10 @@ def load(self, cfg): try: if cfg["CFGFILE"] is not None: # might happen during reload - self._propertyCollection.defineProperty("CFGFILE", cfg["CFGFILE"], - "The absolute path to the configuration file.", - options=dict(enum=[cfg["CFGFILE"]])) + theVars = self._propertyCollection.getVariables() + origReadonly = theVars.setReadonly([]) + theVars["CFGFILE"] = cfg["CFGFILE"] + theVars.setReadonly(origReadonly) try: self._propertyCollection.deleteChild("_guiState") except PropertyCollectionChildNotFound: @@ -139,6 +156,13 @@ def compositeLookup(name): finally: recursiveset.remove(name) + variables = self._propertyCollection.getVariables() + for k in variables.keys(): + if not variables.isReadonly(k): + del variables[k] + if "variables" in cfg: + for k in cfg["variables"]: + variables[k] = cfg["variables"][k] for cfg_cf in cfg["composite_filters"]: compositeLookup(cfg_cf["name"]) for cfg_app in cfg["applications"]: @@ -160,14 +184,21 @@ def save(self, file=None): cfg = {} if file is not None: # TODO: we assume here that this is a new config; a "save to file" feature is not yet implemented. - self._propertyCollection.defineProperty("CFGFILE", str(file), - "The absolute path to the configuration file.", - options=dict(enum=[str(file)])) + theVars = self._propertyCollection.getVariables() + origReadonly = theVars.setReadonly([]) + theVars["CFGFILE"] = file + theVars.setReadonly(origReadonly) try: - cfg["CFGFILE"] = self._propertyCollection.getProperty("CFGFILE") - except PropertyCollectionPropertyNotFound: + cfg["CFGFILE"] = self._propertyCollection.getVariables()["CFGFILE"] + except KeyError: cfg["CFGFILE"] = None cfg["_guiState"] = self._guiState.saveDict() + variables = self._propertyCollection.getVariables() + if any(not variables.isReadonly(k) for k in variables.keys()): + cfg["variables"] = { + k: variables.getraw(k) + for k in variables.keys() if not variables.isReadonly(k) + } cfg["composite_filters"] = [cf.save() for cf in self._compositeFilters] cfg["applications"] = [app.save() for app in self._applications] self.configNameChanged.emit(cfg["CFGFILE"]) @@ -180,11 +211,10 @@ def filename(self): :return: """ try: - return self._propertyCollection.getProperty("CFGFILE") - except PropertyCollectionPropertyNotFound: + return self._propertyCollection.getVariables()["CFGFILE"] + except KeyError: return None - def propertyCollection(self): """ Get the (root) property collection. @@ -317,6 +347,7 @@ def addNewApplication(self): def addNewCompositeFilter(self): """ Add a new composite filter to this configuration. The name will be chosen automaitcally to be unique. + :return: the chosen name """ name = "composite" @@ -327,6 +358,35 @@ def addNewCompositeFilter(self): CompositeFilter(name, self) return name + def removeSubConfig(self, subConfig): + """ + Remove the referenced sub configuration. + + :param subConfig: a SubConfiguration instance + """ + if subConfig in self._compositeFilters: + assert isinstance(subConfig, CompositeFilter) + # check whether this composite filter is referenced by any other subconfig + for sc in self._compositeFilters + self._applications: + if sc is subConfig: + continue + assert isinstance(sc, (Application, CompositeFilter)) + graph = sc.getGraph() + for n in graph.allNodes(): + mockup = graph.getMockup(n) + if issubclass(mockup.getPluginClass(), CompositeFilter.CompositeNode): + if mockup.getLibrary() is subConfig: + raise RuntimeError(f"Composite filter is still in use by {sc.getName()}.") + self.setDirty() + self.subConfigRemoved.emit(subConfig.getName(), self.CONFIG_TYPE_COMPOSITE) + self._compositeFilters.remove(subConfig) + elif subConfig in self._applications: + self.setDirty() + self.subConfigRemoved.emit(subConfig.getName(), self.CONFIG_TYPE_APPLICATION) + self._applications.remove(subConfig) + else: + raise RuntimeError(f"Cannot find sub config {subConfig} to remove") + def getApplicationNames(self): """ Return list of application names diff --git a/nexxT/core/FilterMockup.py b/nexxT/core/FilterMockup.py index 8bac493..2a9a487 100644 --- a/nexxT/core/FilterMockup.py +++ b/nexxT/core/FilterMockup.py @@ -13,8 +13,7 @@ from nexxT.interface import InputPort, OutputPort, InputPortInterface, OutputPortInterface from nexxT.core.FilterEnvironment import FilterEnvironment from nexxT.core.PropertyCollectionImpl import PropertyCollectionImpl -from nexxT.core.Exceptions import (PortNotFoundError, PortExistsError, PropertyCollectionChildExists, - PropertyCollectionPropertyNotFound) +from nexxT.core.Exceptions import PortNotFoundError, PortExistsError, PropertyCollectionChildExists from nexxT.core.Utils import assertMainThread, MethodInvoker import nexxT @@ -34,16 +33,7 @@ def __init__(self, library, factoryFunction, propertyCollection, graph): self._propertyCollectionImpl = propertyCollection self._pluginClass = None self._createFilterAndUpdatePending = None - rootPc = propertyCollection - while rootPc.parent() is not None: - rootPc = rootPc.parent() - tmpRootPc = PropertyCollectionImpl("root", None) - try: - cfgfile = rootPc.getProperty("CFGFILE") - tmpRootPc.defineProperty("CFGFILE", cfgfile, "copy of original CFGFILE.", options=dict(enum=[cfgfile])) - except PropertyCollectionPropertyNotFound: - pass - tmpPc = PropertyCollectionImpl("temp", tmpRootPc) + tmpPc = PropertyCollectionImpl("__temp", propertyCollection) with FilterEnvironment(self._library, self._factoryFunction, tmpPc, self) as tmpEnv: self.updatePortInformation(tmpEnv) del tmpPc @@ -108,14 +98,15 @@ def _createFilterAndUpdate(self): if self._pluginClass is cnexxT.QSharedPointer_nexxT_Filter: self._pluginClass = tempEnv.getPlugin().data().__class__ - def createFilter(self): + def createFilter(self, propColl=None): """ Creates the filter for real usage. State is CONSTRUCTED. This function is thread safe and can be called from multiple threads. :return: None """ # called from threads - res = FilterEnvironment(self._library, self._factoryFunction, self._propertyCollectionImpl) + res = FilterEnvironment(self._library, self._factoryFunction, + self._propertyCollectionImpl if propColl is None else propColl) with QMutexLocker(self._portMutex): for p in self._ports: if p.dynamic(): diff --git a/nexxT/core/Graph.py b/nexxT/core/Graph.py index 0a92c9a..fae8ff5 100644 --- a/nexxT/core/Graph.py +++ b/nexxT/core/Graph.py @@ -96,7 +96,11 @@ def addNode(self, library, factoryFunction, suggestedName=None, propColl = self._properties.getChildCollection(name) except PropertyCollectionChildNotFound: propColl = PropertyCollectionImpl(name, self._properties) + if factoryFunction == "compositeNode" and hasattr(library, "checkRecursion"): + propColl = PropertyCollectionImpl(name, propColl) propColl.propertyChanged.connect(self.setDirty) + propColl.getVariables().variableAddedOrChanged.connect(self.setDirty) + propColl.getVariables().variableDeleted.connect(self.setDirty) filterMockup = FilterMockup(library, factoryFunction, propColl, self) self._filters[name] = filterMockup for din in dynamicInputPorts: diff --git a/nexxT/core/GuiStateSchema.json b/nexxT/core/GuiStateSchema.json index 1c4c309..1801892 100644 --- a/nexxT/core/GuiStateSchema.json +++ b/nexxT/core/GuiStateSchema.json @@ -10,7 +10,20 @@ "type": "object", "propertyNames": { "$ref": "#/definitions/identifier" }, "patternProperties": { - "^.*$": {"type": ["string", "number", "boolean"]} + "^.*$": { + "anyOf": [ + {"type": "string"}, {"type": "number"}, {"type": "boolean"}, + { + "type": "object", + "additionalProperties": false, + "required": ["value", "subst"], + "properties": { + "value": {"anyOf": [{"type": "string"}, {"type": "number"}, {"type": "boolean"}]}, + "subst": {"type": "boolean"} + } + } + ] + } } }, "sub_graph": { diff --git a/nexxT/core/PluginManager.py b/nexxT/core/PluginManager.py index 8997867..8d425d0 100644 --- a/nexxT/core/PluginManager.py +++ b/nexxT/core/PluginManager.py @@ -63,6 +63,8 @@ class PythonLibrary: LIBTYPE_MODULE = 1 LIBTYPE_ENTRY_POINT = 2 + disableUnloadHeuristic = False + # blacklisted packages are not unloaded when closing an application. BLACKLISTED_PACKAGES = ["h5py", "numpy", "matplotlib", "nexxT.Qt", "PySide6", "nexxT.shiboken", "shiboken2", "shiboken6", "torch", "tf"] @@ -143,6 +145,8 @@ def blacklisted(moduleName): :param moduleName: the name of the module as a key in sys.modules """ + if PythonLibrary.disableUnloadHeuristic: + return True pkg = PythonLibrary.BLACKLISTED_PACKAGES[:] if "NEXXT_BLACKLISTED_PACKAGES" in os.environ: if os.environ["NEXXT_BLACKLISTED_PACKAGES"] in ["*", "__all__"]: @@ -285,6 +289,7 @@ def _load(self, library): @staticmethod def _loadPyfile(library, prop=None): if prop is not None and nexxT.shiboken.isValid(prop): + library = prop.getVariables().subst(library) library = prop.evalpath(library) return PythonLibrary(library, libtype=PythonLibrary.LIBTYPE_FILE) @@ -301,6 +306,7 @@ def _loadBinary(library, prop=None): if PluginInterface is None: raise UnknownPluginType("binary plugins can only be loaded with c extension enabled.") if prop is not None: + library = prop.getVariables().subst(library) library = prop.evalpath(library) else: logger.warning("no property collection instance, string interpolation skipped.") diff --git a/nexxT/core/PropertyCollectionImpl.py b/nexxT/core/PropertyCollectionImpl.py index a54fcf5..243a8a2 100644 --- a/nexxT/core/PropertyCollectionImpl.py +++ b/nexxT/core/PropertyCollectionImpl.py @@ -11,9 +11,6 @@ from collections import OrderedDict from pathlib import Path import logging -import platform -import string -import os import nexxT.shiboken from nexxT.Qt.QtCore import Signal, Slot, QRecursiveMutex, QMutexLocker from nexxT.core.Exceptions import (PropertyCollectionChildNotFound, PropertyCollectionChildExists, @@ -21,6 +18,7 @@ PropertyInconsistentDefinition, PropertyCollectionPropertyNotFound) from nexxT.core.Utils import assertMainThread, checkIdentifier from nexxT.core.PropertyHandlers import defaultHandler +from nexxT.core.Variables import Variables from nexxT.interface import PropertyCollection, PropertyHandler logger = logging.getLogger(__name__) @@ -34,6 +32,7 @@ def __init__(self, defaultVal, helpstr, handler): self.value = defaultVal self.helpstr = helpstr self.handler = handler + self.useEnvironment = False self.used = True class PropertyCollectionImpl(PropertyCollection): @@ -41,17 +40,22 @@ class PropertyCollectionImpl(PropertyCollection): This class represents a collection of properties. These collections are organized in a tree, such that there are parent/child relations. """ - propertyChanged = Signal(object, str) propertyAdded = Signal(object, str) propertyRemoved = Signal(object, str) childAdded = Signal(object, str) childRemoved = Signal(object, str) childRenamed = Signal(object, str, str) - def __init__(self, name, parentPropColl, loadedFromConfig=None): + def __init__(self, name, parentPropColl, loadedFromConfig=None, variables=None): PropertyCollection.__init__(self) assertMainThread() self._properties = {} + if variables is None: + self._vars = Variables(parent=parentPropColl._vars if parentPropColl is not None else None) + self._vars.setObjectName("propcoll:" + name) + else: + self._vars = variables + assert parentPropColl is None # this should be the root property self._accessed = False # if no access to properties has been made, we stick with configs from config file. self._loadedFromConfig = loadedFromConfig if loadedFromConfig is not None else {} self._propertyMutex = QRecursiveMutex() @@ -98,7 +102,13 @@ def getChildCollection(self, name): raise PropertyCollectionChildNotFound(name) return res[0] - def defineProperty(self, name, defaultVal, helpstr, options=None, propertyHandler=None): + def getVariables(self): + """ + Return the associated variables instance. + """ + return self._vars + + def defineProperty(self, name, defaultVal, helpstr, options=None, propertyHandler=None, variables=None): """ Return the value of the given property, creating a new property if it doesn't exist. :param name: the name of the property @@ -138,11 +148,22 @@ def defineProperty(self, name, defaultVal, helpstr, options=None, propertyHandle p = self._properties[name] if name in self._loadedFromConfig: l = self._loadedFromConfig[name] - try: - p.value = p.handler.validate(p.handler.fromConfig(l)) - except Exception as e: - raise PropertyParsingError( - f"Error parsing property {name} from {repr(l)} (original exception: {str(e)})") from e + if isinstance(l, dict) and "subst" in l and "value" in l: + p.useEnvironment = l["subst"] + p.value = l["value"] + else: + p.useEnvironment = False + p.value = l + try: + if not p.useEnvironment: + p.value = p.handler.validate(p.handler.fromConfig(p.value)) + else: + if variables is None: + variables = self._vars + p.handler.validate(variables.subst(p.value)) + except Exception as e: + raise PropertyParsingError( + f"Error parsing property {name} from {repr(l)} (original exception: {str(e)})") from e self.propertyAdded.emit(self, name) else: # the arguments to getProperty shall be consistent among calls @@ -160,13 +181,17 @@ def defineProperty(self, name, defaultVal, helpstr, options=None, propertyHandle return p.value @Slot(str) - def getProperty(self, name): + def getProperty(self, name, subst=True, variables=None): self._accessed = True with QMutexLocker(self._propertyMutex): if name not in self._properties: raise PropertyCollectionPropertyNotFound(name) p = self._properties[name] p.used = True + if p.useEnvironment and subst: + if variables is None: + variables = self._vars + return p.handler.validate(variables.subst(p.value)) return p.value def getPropertyDetails(self, name): @@ -192,6 +217,7 @@ def getAllPropertyNames(self): def setProperty(self, name, value): """ Set the value of a named property. + :param name: property name :param value: the value to be set :return: None @@ -206,8 +232,28 @@ def setProperty(self, name, value): except Exception as e: raise PropertyParsingError( f"Error parsing property {name} from {repr(value)} (original exception: {str(e)})") from e - if value != p.value: + if value != p.value or p.useEnvironment: p.value = value + p.useEnvironment = False + self.propertyChanged.emit(self, name) + + @Slot(str, str) + def setVarProperty(self, name, value): + """ + Set the value of a named property using an variable substitution. + + :param name: property name + :param value: the value to be set + :return: None + """ + self._accessed = True + with QMutexLocker(self._propertyMutex): + if name not in self._properties: + raise PropertyCollectionPropertyNotFound(name) + p = self._properties[name] + if value != p.value or not p.useEnvironment: + p.value = value + p.useEnvironment = True self.propertyChanged.emit(self, name) def markAllUnused(self): @@ -247,7 +293,10 @@ def saveDict(self): with QMutexLocker(self._propertyMutex): for n in sorted(self._properties): p = self._properties[n] - res[n] = p.handler.toConfig(p.value) + if p.useEnvironment: + res[n] = {"subst": True, "value": p.value} + else: + res[n] = {"subst": False, "value": p.handler.toConfig(p.value)} return res return self._loadedFromConfig @@ -266,6 +315,7 @@ def addChild(self, name, propColl): pass propColl.setObjectName(name) propColl.setParent(self) + propColl.getVariables().setParent(self.getVariables()) logger.internal("Propcoll %s: add child %s", self.objectName(), name) def renameChild(self, oldName, newName): @@ -299,6 +349,113 @@ def deleteChild(self, name): if nexxT.shiboken.isValid(cc): # pylint: disable=no-member nexxT.shiboken.delete(cc) # pylint: disable=no-member + def evalpath(self, path, variables=None): + """ + Evaluates the string path. If it is an absolute path it is unchanged, otherwise it is converted + to an absolute path relative to the config file path. + :param path: a string + :return: absolute path as string + """ + if variables is None: + variables = self._vars + spath = variables.subst(path) + if spath != path or not Path(spath).is_absolute(): + logger.warning("Deprecated: Implicit substitution or relative paths to the config file. Consider to use " + "explicit variable substitution with ${CFG_DIR} to reference the directory of the config " + "file instead. Found while evaluating %s.", path) + if not Path(spath).is_absolute(): + spath = str((Path(variables.subst("$CFG_DIR")) / spath).absolute()) + return spath + +class PropertyCollectionProxy(PropertyCollection): + """ + This class proxies to a PropertyCollection object but uses a different instance of variables + """ + + def __init__(self, proxiedPropColl, variables): + PropertyCollection.__init__(self) + self._proxiedPropColl = proxiedPropColl + self._vars = variables + assertMainThread() + self._propertyMutex = QRecursiveMutex() + proxiedPropColl.propertyChanged.connect(self._propertyChanged) + self.setObjectName(self._proxiedPropColl.objectName()) + + def _propertyChanged(self, _, name): + self.propertyChanged.emit(self, name) + + def getChildCollection(self, name): + """ + Return child property collection with given name + :param name: the name of the child + :return: PropertyCollection instance + """ + return self._proxiedPropColl.getChildCollection(name) + + def getVariables(self): + """ + Return the associated variables instance. + """ + return self._vars + + def defineProperty(self, name, defaultVal, helpstr, options=None, propertyHandler=None): + """ + Return the value of the given property, creating a new property if it doesn't exist. + :param name: the name of the property + :param defaultVal: the default value of the property. Note that this value will be used to determine the + property's type. Currently supported types are string, int and float + :param helpstr: a help string for the user + :param options: a dict mapping string to qvariant (common options: min, max, enum) + :param propertyHandler: a PropertyHandler instance, or None for automatic choice according to defaultVal + :return: the current value of this property + """ + return self._proxiedPropColl.defineProperty(name, defaultVal, helpstr, options, propertyHandler, + variables=self._vars) + + @Slot(str) + def getProperty(self, name, subst=True): + """ + See PropertyCollectionImpl.getProperty for details + """ + return self._proxiedPropColl.getProperty(name, subst, variables=self._vars) + + def getPropertyDetails(self, name): + """ + returns the property details of the property identified by name. + :param name: the property name + :return: a Property instance + """ + return self._proxiedPropColl.getPropertyDetails(self, name) + + def getAllPropertyNames(self): + """ + Query all property names handled in this collection + :return: list of strings + """ + return self._proxiedPropColl.getAllPropertyNames() + + @Slot(str, str) + def setProperty(self, name, value): + """ + Set the value of a named property. + + :param name: property name + :param value: the value to be set + :return: None + """ + self._proxiedPropColl.setProperty(name, value) + + @Slot(str, str) + def setVarProperty(self, name, value): + """ + Set the value of a named property using an variable substitution. + + :param name: property name + :param value: the value to be set + :return: None + """ + self._proxiedPropColl.setVarProperty(name, value) + def evalpath(self, path): """ Evaluates the string path. If it is an absolute path it is unchanged, otherwise it is converted @@ -306,23 +463,4 @@ def evalpath(self, path): :param path: a string :return: absolute path as string """ - root_prop = self - while root_prop.parent() is not None: - root_prop = root_prop.parent() - # substitute ${VAR} with environment variables - default_environ = dict( - NEXXT_VARIANT="release" - ) - if platform.system() == "Windows": - default_environ["NEXXT_PLATFORM"] = f"msvc_x86{'_64' if platform.architecture()[0] == '64bit' else ''}" - else: - default_environ["NEXXT_PLATFORM"] = f"linux_{platform.machine()}" - origpath = path - path = string.Template(path).safe_substitute({**default_environ, **os.environ}) - logger.debug("interpolated path %s -> %s", origpath, path) - if Path(path).is_absolute(): - return path - try: - return str((Path(root_prop.getProperty("CFGFILE")).parent / path).absolute()) - except PropertyCollectionPropertyNotFound: - return path + return self._proxiedPropColl.evalpath(path, variables=self._vars) diff --git a/nexxT/core/PropertyHandlers.py b/nexxT/core/PropertyHandlers.py index 35e8f97..6011607 100644 --- a/nexxT/core/PropertyHandlers.py +++ b/nexxT/core/PropertyHandlers.py @@ -79,6 +79,12 @@ def validate(self, value): :param value: the value to be tested (an integer) :return: the adapted, valid value """ + if isinstance(value, str): + try: + value = int(value) + except ValueError: + logger.warning("Cannot interpret value '%s' as int. Using 0.", value) + value = 0 if "min" in self._options: if value < self._options["min"]: logger.warning("Adapted option value %d to minimum value %d.", value, self._options["min"]) @@ -189,7 +195,7 @@ def validate(self, value): """ if "enum" in self._options: if not value in self._options["enum"]: - logger.warning("Enum validation failed. Using first value in allowed list.") + logger.warning("Enum validation failed for '%s'. Using first value in allowed list.", value) return self._options["enum"][0] return str(value) @@ -284,6 +290,12 @@ def validate(self, value): :param value: the value to be tested (a float) :return: the adapted, valid value """ + if isinstance(value, str): + try: + value = float(value) + except ValueError: + logger.warning("Cannot interpret value '%s' as float. Using 0.0.", value) + value = 0.0 if "min" in self._options: if value < self._options["min"]: logger.warning("Adapted option value %f to minimum value %f.", value, self._options["min"]) @@ -330,7 +342,7 @@ class BoolHandler(PropertyHandler): def __init__(self, options): for k in options: - raise PropertyParsingError(f"Unexpected option {k}; expected 'min' or 'max'.") + raise PropertyParsingError(f"Unexpected option {k}.") self._options = options def options(self): @@ -374,7 +386,16 @@ def validate(self, value): :return: the adapted, valid value """ if isinstance(value, str): - return value.lower() == "true" + if value.lower() == "true": + return True + if value.lower() == "false": + return False + try: + value = float(value) + return bool(value) + except ValueError: + logger.warning("Cannot interpret value '%s' as bool. Using false.", value) + return False return bool(value) def createEditor(self, parent): diff --git a/nexxT/core/SubConfiguration.py b/nexxT/core/SubConfiguration.py index 4ff65f2..9489b89 100644 --- a/nexxT/core/SubConfiguration.py +++ b/nexxT/core/SubConfiguration.py @@ -160,6 +160,10 @@ def load(self, cfg, compositeLookup): raise NexTInternalError("addNode(...) has set unexpected name for node.") # make sure that the filter is instantiated and the port information is updated immediately self._graph.getMockup(n["name"]).createFilterAndUpdate() + if "variables" in n: + variables = self._graph.getMockup(n["name"]).getPropertyCollectionImpl().getVariables() + for k in n["variables"]: + variables[k] = n["variables"][k] for c in cfg["connections"]: contuple = self.connectionStringToTuple(c) self._graph.addConnection(*contuple[:-1]) @@ -223,6 +227,11 @@ def adaptLibAndFactory(lib, factory): pass except PropertyCollectionPropertyNotFound: pass + variables = mockup.propertyCollection().getVariables() + if len(variables.keys()) > 0: + ncfg["variables"] = {} + for k in variables.keys(): + ncfg["variables"][k] = variables.getraw(k) ncfg["properties"] = p.saveDict() cfg["nodes"].append(ncfg) cfg["connections"] = [] diff --git a/nexxT/core/Thread.py b/nexxT/core/Thread.py index dcf2bb5..ba5cdd3 100644 --- a/nexxT/core/Thread.py +++ b/nexxT/core/Thread.py @@ -11,10 +11,10 @@ import logging import sys import threading -from nexxT.Qt.QtCore import Qt, QObject, Signal, Slot, QCoreApplication, QThread +from nexxT.Qt.QtCore import QObject, Signal, Slot, QCoreApplication, QThread from nexxT.interface import FilterState, Services from nexxT.core.Exceptions import NodeExistsError, NexTInternalError, NodeNotFoundError, NexTRuntimeError -from nexxT.core.Utils import handleException, MethodInvoker +from nexxT.core.Utils import handleException logger = logging.getLogger(__name__) @@ -119,7 +119,7 @@ def cleanup(self): self._mockups.clear() logger.internal("Thread cleanup done") - def addMockup(self, name, mockup): + def addMockup(self, name, mockup, propColl): """ Add a FilterMockup instance by name. :param name: name of the filter @@ -128,7 +128,7 @@ def addMockup(self, name, mockup): """ if name in self._mockups: raise NodeExistsError(name) - self._mockups[name] = mockup + self._mockups[name] = (mockup, propColl) def getFilter(self, name): """ @@ -168,7 +168,7 @@ def performOperation(self, operation, barrier): # wait that all threads are in their event loop. inProcessEvents = self._qthread.property("processEventsRunning") if inProcessEvents: - logging.getLogger(__name__).warning( + logging.getLogger(__name__).debug( "operation %s happening during receiveAsync's processEvents. This shouldn't be happening.", operation) barrier.wait() @@ -180,10 +180,10 @@ def performOperation(self, operation, barrier): # wait for all threads barrier.wait() # perform operation for all filters - for name, mockup in self._mockups.items(): + for name, (mockup, propColl) in self._mockups.items(): try: if operation == "create": - res = mockup.createFilter() + res = mockup.createFilter(propColl) res.setParent(self) self._filters[name] = res self._filter2name[res] = name diff --git a/nexxT/core/Utils.py b/nexxT/core/Utils.py index f91c49f..c82bddf 100644 --- a/nexxT/core/Utils.py +++ b/nexxT/core/Utils.py @@ -8,15 +8,16 @@ This module contains various small utility classes. """ +import datetime import io -import re -import sys import logging -import datetime import os.path +import platform +import re import sqlite3 +import sys import time -from nexxT.Qt.QtCore import (QObject, Signal, Slot, QMutex, QWaitCondition, QCoreApplication, QThread, +from nexxT.Qt.QtCore import (QObject, Slot, QMutex, QWaitCondition, QCoreApplication, QThread, QMutexLocker, QRecursiveMutex, QTimer, Qt, QPoint, QMetaObject) from nexxT.Qt.QtGui import QColor, QPainter, QTextLayout, QTextOption from nexxT.Qt.QtWidgets import QFrame, QSizePolicy @@ -449,3 +450,13 @@ def _paintEvent(self, event): painter.drawText(QPoint(0, y + fontMetrics.ascent()), elidedLastLine) break textLayout.endLayout() + +def nexxtPlatform(): + """ + Return the nexxT platform identifier as a string. + """ + if platform.system() == "Windows": + return f"msvc_x86{'_64' if platform.architecture()[0] == '64bit' else ''}" + if platform.system() == "Linux": + return f"linux_{platform.machine()}" + raise RuntimeError("Unknown system: " + platform.system()) diff --git a/nexxT/core/Variables.py b/nexxT/core/Variables.py new file mode 100644 index 0000000..f812d5b --- /dev/null +++ b/nexxT/core/Variables.py @@ -0,0 +1,165 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2020 ifm electronic gmbh +# +# THE PROGRAM IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND. +# + +""" +This module implements a variable system for property substitution in nexxT +""" + +import importlib +import logging +import string +from collections import UserDict +from nexxT.Qt.QtCore import QObject, Signal + +logger = logging.getLogger(__name__) + +class Variables(QObject): + """ + This class represents a collection of variables suitable for substitution in properties of a filter. + + Substitution is performed according to the following rules: + 1. Variables are arranged in a tree structure. During substitution the tree is searched upwards until a variable is + found. + 2. The syntax for substitution is basically the same as string.Template(value).safe_substitute. + 3. Variables are always converted to upper case strings before substituting (i.e. they are case insensitive). + 4. In case a variable is expanded to '${!python_code}', the python_code is evaluated using eval(...). The result is + converted to a string, and the substitution proceeds (recursive substitution is possible). In case the python code + raises an exception e, the substitution result is f'<{str(e)}' and a warning is logged. Substitution might use + the importlib module to import modules and the special function subst(template) for substituting variables. + """ + + class VarDict(UserDict): + """ + Internal helper class for implementing the variable substitution in nexxT. + """ + + def __init__(self, variables): + self._variables = variables + super().__init__() + + def __getitem__(self, key): + key = key.upper() + try: + res = self.data[key] + if res.startswith("${!") and res.endswith("}"): + try: + # pylint: disable=eval-used + # eval is insecure, but nexxT is not designed to be secure against malicious input (the whole + # plugin concept is insecure either) + res = str(eval(res[3:-1], {'importlib': importlib, 'subst': self._variables.subst})) + except Exception as e: # pylint: disable=broad-except + logger.warning("An error occurred while substituting '%s' evaluating to python code '%s': %s", + key, res[3:-1], e) + res = f"<{str(e)}>" + res = self._variables.subst(res) + return res + except KeyError as e: + if self._variables._parent is not None: + return self._variables._parent[key] + raise e + + def getraw(self, key): + """ + get the raw, non substituted value corresponding to key + + :param key: the variable name + """ + return self.data[key] + + def __setitem__(self, key, value): + key = key.upper() + if self._variables.isReadonly(key) and self.data[key] != value: + raise RuntimeError(f"Trying to modify readonly variable {key}.") + self.data[key] = value + self._variables.variableAddedOrChanged.emit(key, value) + + def __delitem__(self, key): + key = key.upper() + super().__delitem__(key) + self._variables.variableDeleted.emit(key) + + variableAddedOrChanged = Signal(str, str) + variableDeleted = Signal(str) + + def __init__(self, parent = None): + self._parent = None + self.setParent(parent) + self._readonly = set() + self._vars = Variables.VarDict(self) + super().__init__() + + def copyAndReparent(self, newParent): + """ + Create a copy and reparent to the given parent. + + :param newParent: a Variables instance or None + """ + res = Variables(newParent) + for k in self.keys(): + res[k] = self.getraw(k) + res.setReadonly(self._readonly) + return res + + def setParent(self, parent): + """ + reparent the variable class (for lookups of unknown variables) + + :param parent: the new parent + """ + self._parent = parent + + def subst(self, content): + """ + Recursively substitute the given content. + + :param content: the string to be substituted + :return: the substituted string + """ + return string.Template(content).safe_substitute(self) + + def setReadonly(self, readonlyvars): + """ + Set the given variables as readonly and return the old set of readonly vars. + + :param readonlyvars: an iterable containing names of readonly variables + :return: a set containing the readonly variables before the change + """ + old = self._readonly + self._readonly = set() + for k in readonlyvars: + self._readonly.add(k.upper()) + return old + + def isReadonly(self, key): + """ + Return whether or not the given variable is readonly + + :param key: the variable name to be tested + """ + return key.upper() in self._readonly + + def keys(self): + """ + :return: the variables defined in this instance. + """ + return self._vars.keys() + + def getraw(self, key): + """ + Get the raw, non-substituted values of a variable. + + :return: a string instance + """ + return self._vars.getraw(key) + + def __setitem__(self, key, value): + self._vars[key] = value + + def __getitem__(self, key): + return self._vars[key] + + def __delitem__(self, key): + del self._vars[key] diff --git a/nexxT/examples/framework/ImageData.py b/nexxT/examples/framework/ImageData.py index fe708cc..eba9c34 100644 --- a/nexxT/examples/framework/ImageData.py +++ b/nexxT/examples/framework/ImageData.py @@ -77,7 +77,7 @@ def byteArrayToNumpy(qByteArray): # create the target array res = np.frombuffer(mv, dtype=dtype, offset=ct.sizeof(hdr)) # reshape to requested dimenstions - return np.reshape(res, (-1, hdr.lineInc//bpp, numChannels)) + return np.reshape(res, (-1, max(1,hdr.lineInc//bpp), numChannels)) def numpyToByteArray(img): """ diff --git a/nexxT/examples/framework/example.json b/nexxT/examples/framework/example.json index 8dbd153..341c42d 100644 --- a/nexxT/examples/framework/example.json +++ b/nexxT/examples/framework/example.json @@ -2,6 +2,9 @@ "_guiState": { "PlaybackControl_showAllFiles": 0 }, + "variables": { + "SRC": "fail" + }, "composite_filters": [ { "name": "visualization", @@ -43,8 +46,14 @@ "staticOutputPorts": [], "thread": "main", "properties": { - "caption": "Processed", - "scale": 0.5 + "caption": { + "subst": true, + "value": "Processed" + }, + "scale": { + "subst": false, + "value": 0.5 + } } }, { @@ -59,8 +68,14 @@ "staticOutputPorts": [], "thread": "main", "properties": { - "caption": "Original", - "scale": 0.5 + "caption": { + "subst": true, + "value": "Original - $SRC" + }, + "scale": { + "subst": false, + "value": 0.5 + } } } ], @@ -112,7 +127,12 @@ "video_out" ], "thread": "grabber", - "properties": {} + "properties": { + "device": { + "subst": false, + "value": "HP HD Camera: HP HD Camera" + } + } }, { "name": "ImageBlur", @@ -128,7 +148,10 @@ ], "thread": "compute", "properties": { - "kernelSize": 9 + "kernelSize": { + "subst": false, + "value": 9 + } } }, { @@ -143,12 +166,30 @@ "staticOutputPorts": [], "thread": "writer", "properties": { - "buffer_period": 1.0, - "buffer_samples": 0, - "filename": "${DATE}_${TIME}_${FILTER_NAME}.h5", - "silent_overwrite": false, - "use_posix_fadvise_if_available": true, - "use_receive_timestamps": true + "buffer_period": { + "subst": false, + "value": 1.0 + }, + "buffer_samples": { + "subst": false, + "value": 0 + }, + "filename": { + "subst": false, + "value": "${DATE}_${TIME}_${FILTER_NAME}.h5" + }, + "silent_overwrite": { + "subst": false, + "value": false + }, + "use_posix_fadvise_if_available": { + "subst": false, + "value": true + }, + "use_receive_timestamps": { + "subst": false, + "value": true + } } }, { @@ -163,6 +204,9 @@ "dynamicOutputPorts": [], "staticOutputPorts": [], "thread": "main", + "variables": { + "SRC": "live" + }, "properties": {} } ], @@ -200,7 +244,10 @@ ], "thread": "compute", "properties": { - "kernelSize": 9 + "kernelSize": { + "subst": false, + "value": 9 + } } }, { @@ -215,6 +262,9 @@ "dynamicOutputPorts": [], "staticOutputPorts": [], "thread": "main", + "variables": { + "SRC": "sim" + }, "properties": {} }, { @@ -228,7 +278,12 @@ ], "staticOutputPorts": [], "thread": "reader", - "properties": {} + "properties": { + "defaultStepStream": { + "subst": false, + "value": "" + } + } }, { "name": "AviReader", @@ -253,4 +308,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/nexxT/filters/GenericReader.py b/nexxT/filters/GenericReader.py index e900e5e..0cf97e8 100644 --- a/nexxT/filters/GenericReader.py +++ b/nexxT/filters/GenericReader.py @@ -382,14 +382,21 @@ def onSuggestDynamicPorts(self): def _timeSpan(self): tmin = math.inf + tminNonZero = math.inf tmax = -math.inf for p in self._portToIdx: - t = self._file.getRcvTimestamp(p, 0) - tmin = min(t, tmin) + for i in range(self._file.getNumberOfSamples(p)): + t = self._file.getRcvTimestamp(p, i) + tmin = min(t, tmin) + if t != 0: + tminNonZero = min(tminNonZero, t) + break t = self._file.getRcvTimestamp(p, self._file.getNumberOfSamples(p)-1) tmax = max(t, tmax) if tmin > tmax: raise RuntimeError("It seems that the input file doesn't have any usable samples.") + if tmin == 0 and tminNonZero > 60*24*self._file.getTimestampResolution(): + tmin = tminNonZero return (tmin*(1000000000//self._file.getTimestampResolution()), tmax*(1000000000//self._file.getTimestampResolution())) @@ -431,7 +438,8 @@ def _transmitNextSample(self): while time.perf_counter_ns() - nowTime < deltaT_ns: pass else: - self._timer.start(deltaT_ns//1000000) + if deltaT_ns < 10e9: + self._timer.start(deltaT_ns//1000000) break else: self.pausePlayback() diff --git a/nexxT/include/nexxT/PropertyCollection.hpp b/nexxT/include/nexxT/PropertyCollection.hpp index 0c59c2e..810cc52 100644 --- a/nexxT/include/nexxT/PropertyCollection.hpp +++ b/nexxT/include/nexxT/PropertyCollection.hpp @@ -143,7 +143,7 @@ namespace nexxT See \verbatim embed:rst:inline :py:attr:`nexxT.interface.PropertyCollections.PropertyCollection.propertyChanged` \endverbatim */ - void propertyChanged(const PropertyCollection &sender, const QString &name); + void propertyChanged(nexxT::PropertyCollection *sender, const QString &name); }; }; diff --git a/nexxT/services/SrvConfiguration.py b/nexxT/services/SrvConfiguration.py index d1af877..999a352 100644 --- a/nexxT/services/SrvConfiguration.py +++ b/nexxT/services/SrvConfiguration.py @@ -17,6 +17,7 @@ from nexxT.core.Configuration import Configuration from nexxT.core.Application import Application from nexxT.core.CompositeFilter import CompositeFilter +from nexxT.core.Variables import Variables from nexxT.core.Utils import assertMainThread, handleException, mainThread, MethodInvoker from nexxT.interface.Filters import FilterState from nexxT.interface import Services @@ -74,6 +75,14 @@ class SubConfigContent: def __init__(self, subConfig): self.subConfig = subConfig + class VariableContent: + """ + A variable definition in a Variables instance, for usage within the model. + """ + def __init__(self, name, variables): + self.name = name + self.variables = variables + # Model implementation def __init__(self, configuration, parent): @@ -81,10 +90,12 @@ def __init__(self, configuration, parent): self.root = self.Item(None, configuration) self.Item(self.root, "composite") self.Item(self.root, "apps") + vitem = self.Item(self.root, configuration.propertyCollection().getVariables()) self.activeApp = None configuration.subConfigAdded.connect(self.subConfigAdded) configuration.subConfigRemoved.connect(self.subConfigRemoved) configuration.appActivated.connect(self.appActivated) + self._connectVariables(vitem) def isSubConfigParent(self, index): """ @@ -165,6 +176,26 @@ def indexOfNode(self, subConfig, node): raise NexTRuntimeError("Unable to locate node.") return self.index(idx[0], 0, parent) + def indexOfVariable(self, vitem): + """ + Returns the index of the given variable by full-recursive searching. + + :param vitem: the variable item to be search for. + """ + def _traverse(parent): + for r in range(self.rowCount(parent)): + index = self.index(r, 0, parent) + if index.internalPointer() is vitem: + return index + ret = _traverse(index) + if ret is not None: + return ret + return None + res = _traverse(QModelIndex()) + if res is None: + raise NexTRuntimeError("Cannot locate variable item in model.") + return res + def subConfigByNameAndType(self, name, sctype): """ Returns a SubConfiguration instance, given its name and type @@ -204,6 +235,62 @@ def subConfigAdded(self, subConfig): self.Item(parentItem, self.SubConfigContent(subConfig)) self.endInsertRows() + def _connectVariables(self, vitem): + variables = vitem.content + for vname in variables.keys(): + self.variableAddedOrChanged(vitem, vname, variables) + variables.variableAddedOrChanged.connect( + lambda key, _value, self=self, vitem=vitem, variables=variables: + self.variableAddedOrChanged(vitem, key, variables)) + variables.variableDeleted.connect( + lambda key, self=self, vitem=vitem: + self.variableDeleted(vitem, key)) + + def variableAddedOrChanged(self, parentItem, key, variables): + """ + Slot which is called when variables of parentItem are added or changed. + + :param parentItem: an instance of VariableContent + :param key: the key which is changed or added + :param variables: the Variables instances managing the variables of parentItem + """ + parent = self.indexOfVariable(parentItem) + assert parent.internalPointer() is parentItem + found = False + for r in range(self.rowCount(parent)): + vcontent_key = self.index(r, 0, parent) + vcontent_value = self.index(r, 1, parent) + vcontent = vcontent_key.internalPointer() + assert isinstance(vcontent, self.Item), repr(vcontent) + assert isinstance(vcontent.content, self.VariableContent) + if vcontent.content.name == key: + self.dataChanged.emit(vcontent_value, vcontent_value) + found = True + if not found: + # var was added + self.beginInsertRows(parent, len(parentItem.children), len(parentItem.children)) + _item = self.Item(parentItem, self.VariableContent(key, variables)) + self.endInsertRows() + + + def variableDeleted(self, vitem, key): + """ + Slot which is called when variables of vitem are deleted. + + :param vitem: an instance of VariableContent + :param key: the key which is deleted + """ + assert isinstance(vitem, ConfigurationModel.Item) + parent = self.indexOfVariable(vitem) + for row, v in enumerate(vitem.children): + assert isinstance(v, ConfigurationModel.Item) and isinstance(v.content, ConfigurationModel.VariableContent) + if v.content.name == key: + self.beginRemoveRows(parent, row, row) + vitem.children = vitem.children[:row] + vitem.children[row+1:] + self.endRemoveRows() + return + raise RuntimeError("did not find matching variables object to be deleted.") + @Slot(object) def subConfigRenamed(self, subConfig, oldName): # pylint: disable=unused-argument """ @@ -284,6 +371,13 @@ def nodeAdded(self, subConfig, node): logger.debug("register propColl: %s", propColl) for pname in propColl.getAllPropertyNames(): self.propertyAdded(item, propColl, pname) + if issubclass(mockup.getPluginClass(), CompositeFilter.CompositeNode): + # add a variable editor + nodeIndex = self.index(len(parentItem.children)-1,0,parent) + self.beginInsertRows(nodeIndex, len(item.children), len(item.children)) + vitem = self.Item(item, propColl.getVariables()) + self.endInsertRows() + self._connectVariables(vitem) propColl.propertyAdded.connect(lambda pc, name: self.propertyAdded(item, pc, name)) propColl.propertyRemoved.connect(lambda pc, name: self.propertyRemoved(item, pc, name)) propColl.propertyChanged.connect(lambda pc, name: self.propertyChanged(item, pc, name)) @@ -436,16 +530,18 @@ def columnCount(self, parent): if parent.isValid(): parentItem = parent.internalPointer() if isinstance(parentItem.content, self.NodeContent): - return 2 # nodes children have the editable properties + return 3 # nodes children have the editable properties with name, value and indirect + if isinstance(parentItem.content, Variables): + return 2 return 1 - return 2 + return 3 def headerData(self, section, orientation, role): """ Overwritten from QAbstractItemModel. Provide header names for the columns. """ if orientation == Qt.Horizontal and role == Qt.DisplayRole: - return ["Name", "Property"][section] + return ["Name", "Property", "Indirect"][section] return super().headerData(section, orientation, role) def data(self, index, role): # pylint: disable=too-many-return-statements,too-many-branches @@ -467,11 +563,34 @@ def data(self, index, role): # pylint: disable=too-many-return-statements,too-ma if isinstance(item, self.NodeContent): return item.name if index.column() == 0 else None if isinstance(item, self.PropertyContent): + p = item.property.getPropertyDetails(item.name) if index.column() == 0: return item.name - p = item.property.getPropertyDetails(item.name) - return p.handler.toViewValue(item.property.getProperty(item.name)) + if index.column() == 1: + if not p.useEnvironment: + return p.handler.toViewValue(item.property.getProperty(item.name)) + return item.property.getProperty(item.name, subst=False) + return p.useEnvironment + if isinstance(item, Variables): + if index.column() == 0: + return "variables" + return None + if isinstance(item, self.VariableContent): + if index.column() == 0: + return item.name + try: + value = item.variables.getraw(item.name) + return value + except KeyError: + # this might happen when a variable is already deleted and the model updates itself + return "" logger.warning("Unknown item %s", repr(item)) + if role == Qt.CheckStateRole: + if isinstance(item, self.PropertyContent) and index.column() == 2: + p = item.property.getPropertyDetails(item.name) + if p.useEnvironment: + return Qt.Checked + return Qt.Unchecked if role == Qt.DecorationRole: if index.column() != 0: return None @@ -487,6 +606,10 @@ def data(self, index, role): # pylint: disable=too-many-return-statements,too-ma return QIcon.fromTheme("unknown", QApplication.style().standardIcon(QStyle.SP_FileIcon)) if isinstance(item, self.PropertyContent): return None + if isinstance(item, Variables): + return QIcon.fromTheme("unknown", QApplication.style().standardIcon(QStyle.SP_DirIcon)) + if isinstance(item, self.VariableContent): + return None logger.warning("Unknown item %s", repr(item)) if role == Qt.FontRole: if index.column() != 0: @@ -500,7 +623,13 @@ def data(self, index, role): # pylint: disable=too-many-return-statements,too-ma if role == Qt.ToolTipRole: if isinstance(item, self.PropertyContent): p = item.property.getPropertyDetails(item.name) - return p.helpstr + if index.column() == 0 or (index.column() == 1 and not p.useEnvironment): + return p.helpstr + if index.column() == 1: + return f"{item.property.getProperty(item.name, subst=False)}={item.property.getProperty(item.name)}" + return "If enabled, this property is evaluated using variable substitution." + if isinstance(item, self.VariableContent): + return item.variables.subst(f"{item.name} = ${item.name}") if role == ITEM_ROLE: return item return None @@ -526,7 +655,15 @@ def flags(self, index): # pylint: disable=too-many-return-statements,too-many-br if isinstance(item, self.PropertyContent): if index.column() == 0: return Qt.ItemIsEnabled - return Qt.ItemIsEnabled | Qt.ItemIsEditable + if index.column() == 1: + return Qt.ItemIsEnabled | Qt.ItemIsEditable + if index.column() == 2: + return Qt.ItemIsEnabled | Qt.ItemIsUserCheckable + if isinstance(item, self.VariableContent): + if item.variables.isReadonly(item.name): + return Qt.ItemFlag.NoItemFlags + if index.column() == 1: + return Qt.ItemIsEnabled | Qt.ItemIsEditable return Qt.ItemIsEnabled def setData(self, index, value, role):# pylint: disable=too-many-return-statements,too-many-branches,unused-argument @@ -576,29 +713,36 @@ def setData(self, index, value, role):# pylint: disable=too-many-return-statemen self.dataChanged.emit(index, index, [Qt.DisplayRole, Qt.EditRole]) return True if isinstance(item, self.PropertyContent): + if index.column() == 1: + if item.property.getPropertyDetails(item.name).useEnvironment: + item.property.setVarProperty(item.name, value) + else: + try: + item.property.setProperty(item.name, value) + except NexTRuntimeError: + return False + elif index.column() == 2: + p = item.property.getPropertyDetails(item.name) + if role == Qt.CheckStateRole: + value = not Qt.CheckState(value) == Qt.Unchecked + if value and not p.useEnvironment: + item.property.setVarProperty(item.name, str(item.property.getProperty(item.name))) + elif not value and p.useEnvironment: + item.property.setProperty(item.name, item.property.getProperty(item.name)) + return False + i0 = self.index(index.row(), 1, index.parent()) + i1 = self.index(index.row(), 2, index.parent()) + self.dataChanged.emit(i0, i1, [Qt.DisplayRole, Qt.EditRole]) + return True + if isinstance(item, self.VariableContent): try: - item.property.setProperty(item.name, value) + item.variables[item.name] = value except NexTRuntimeError: return False self.dataChanged.emit(index, index, [Qt.DisplayRole, Qt.EditRole]) return True return False - def headerDate(self, section, orientation, role): - """ - Returns the header data of this model - - :param section: section number starting from 0 - :param orientation: orientation - :param role: the role to be returned - :return: - """ - if orientation == Qt.Horizontal and role == Qt.DisplayRole: - if section == 0: - return "Name" - return "Value" - return None - def mimeTypes(self): """ Overwritten from QAbstractItemModel, provide a mime type for copy/pasting diff --git a/nexxT/services/SrvPlaybackControl.py b/nexxT/services/SrvPlaybackControl.py index 773f27d..d941887 100644 --- a/nexxT/services/SrvPlaybackControl.py +++ b/nexxT/services/SrvPlaybackControl.py @@ -279,8 +279,7 @@ def removeConnections(self, playbackDevice): if len(found) > 0: for devid, _ in found: del self._registeredDevices[devid] - for devid, dev in found: - del dev + del found logger.debug("disconnected connections of playback device. number of devices left: %d", len(self._registeredDevices)) MethodInvoker(dict(object=self, method="_updateFeatureSet", thread=mainThread()), Qt.QueuedConnection) diff --git a/nexxT/services/SrvProfiling.py b/nexxT/services/SrvProfiling.py index 6813d24..91b50eb 100644 --- a/nexxT/services/SrvProfiling.py +++ b/nexxT/services/SrvProfiling.py @@ -167,6 +167,39 @@ def cancel(self): self._portProfiling = {} self._portStack = [] +class ProfilingServiceDummy(QObject): + """ + This class can be used as a replacement for the ProfilingService which provides the same interface. + """ + + @Slot() + def registerThread(self): + """ + dummy implementation + """ + + @Slot() + def deregisterThread(self): + """ + dummy implementation + """ + + @Slot(str) + def beforePortDataChanged(self, portname): + """ + dummy implementation + + :param portname: name of the port + """ + + @Slot(str) + def afterPortDataChanged(self, portname): + """ + dummy implementation + + :param portname: name of the port + """ + class ProfilingService(QObject): """ This class provides a profiling service for the nexxT framework. @@ -258,8 +291,7 @@ def deregisterThread(self): self._threadSpecificProfiling[t].timer.stop() todel.append(self._threadSpecificProfiling[t]) del self._threadSpecificProfiling[t] - for tsp in todel: - del tsp + del todel self.threadDeregistered.emit(t.objectName()) @Slot() diff --git a/nexxT/services/gui/Configuration.py b/nexxT/services/gui/Configuration.py index d4c91fa..dc26810 100644 --- a/nexxT/services/gui/Configuration.py +++ b/nexxT/services/gui/Configuration.py @@ -13,10 +13,11 @@ from nexxT.Qt.QtCore import (Qt, QSettings, QByteArray, QDataStream, QIODevice, QTimer) from nexxT.Qt.QtGui import QIcon, QKeySequence, QAction from nexxT.Qt.QtWidgets import (QTreeView, QStyle, QApplication, QFileDialog, QAbstractItemView, QMessageBox, - QHeaderView, QMenu, QDockWidget) + QHeaderView, QMenu, QDockWidget, QInputDialog) from nexxT.interface import Services, FilterState from nexxT.core.Configuration import Configuration from nexxT.core.Application import Application +from nexxT.core.Variables import Variables from nexxT.core.Utils import assertMainThread, MethodInvoker, mainThread, handleException from nexxT.services.SrvConfiguration import MVCConfigurationBase, ConfigurationModel, ITEM_ROLE from nexxT.services.gui.PropertyDelegate import PropertyDelegate @@ -105,14 +106,16 @@ def __init__(self, configuration): self.treeView.setDragDropMode(QAbstractItemView.DragOnly) self.mainWidget.setWidget(self.treeView) self.treeView.setModel(self.model) - self.treeView.header().setStretchLastSection(True) + self.treeView.header().setStretchLastSection(False) self.treeView.header().setSectionResizeMode(0, QHeaderView.ResizeToContents) + self.treeView.header().setSectionResizeMode(1, QHeaderView.Stretch) + self.treeView.header().setSectionResizeMode(2, QHeaderView.ResizeToContents) self.treeView.doubleClicked.connect(self._onItemDoubleClicked) self.treeView.setContextMenuPolicy(Qt.CustomContextMenu) self.treeView.customContextMenuRequested.connect(self._execTreeViewContextMenu) # expand applications by default self.treeView.setExpanded(self.model.index(1, 0), True) - self.delegate = PropertyDelegate(self.model, ITEM_ROLE, ConfigurationModel.PropertyContent, self) + self.delegate = PropertyDelegate(self.model, ITEM_ROLE, ConfigurationModel.PropertyContent, self.treeView) self.treeView.setItemDelegate(self.delegate) self.restoreState() @@ -233,10 +236,10 @@ def _addGraphView(self, subConfig): graphDw.visibleChanged.connect(self._removeGraphViewFromList) def _subConfigRemoved(self, subConfigName, configType): - self.__subConfigRemoved(subConfigName, configType) + self._subConfigRemovedImpl(subConfigName, configType) @handleException - def __subConfigRemoved(self, subConfigName, configType): + def _subConfigRemovedImpl(self, subConfigName, configType): g = self._configuration.subConfigByNameAndTye(subConfigName, configType).getGraph() if nexxT.shiboken.isValid(g): for gv in self._graphViews: @@ -267,6 +270,9 @@ def _execTreeViewContextMenu(self, point): a1 = QAction("Edit graph") m.addAction(a1) a1.triggered.connect(lambda: self._addGraphView(item.subConfig)) + a1d5 = QAction(f"Remove {'app' if self.model.isApplication(index) else 'composite'} ...") + a1d5.triggered.connect(lambda: self._removeSubConfig(item.subConfig)) + m.addAction(a1d5) if self.model.isApplication(index): a2 = QAction("Select Application") a2.triggered.connect(lambda: self.changeActiveApp(self.model.data(index, Qt.DisplayRole))) @@ -313,6 +319,37 @@ def _execTreeViewContextMenu(self, point): if a is not None: self._configuration.addNewCompositeFilter() return + if isinstance(item, Variables): + m = QMenu() + a = QAction("Add variable ...") + m.addAction(a) + a = nexxT.Qt.call_exec(m, self.treeView.mapToGlobal(point)) + if a is not None: + vname, ok = QInputDialog.getText( + self.treeView, "Add Variable", "Variable Name", text="VARIABLE", + inputMethodHints=Qt.InputMethodHint.ImhUppercaseOnly|Qt.InputMethodHint.ImhLatinOnly) + variables = item + if ok and vname is not None and vname != "" and vname not in variables.keys(): + variables[vname] = "" + return + if isinstance(item, ConfigurationModel.VariableContent): + if not item.variables.isReadonly(item.name): + m = QMenu() + a = QAction("Remove variable") + m.addAction(a) + a = nexxT.Qt.call_exec(m, self.treeView.mapToGlobal(point)) + if a is not None: + del item.variables[item.name] + return + + def _removeSubConfig(self, subConfig): + ans = QMessageBox.question(self.mainWidget, "Confirm to remove", + f"Do you really want to remove {subConfig.getName()}?") + if ans is QMessageBox.StandardButton.Yes: + try: + self._configuration.removeSubConfig(subConfig) + except RuntimeError as e: + QMessageBox.warning(self.mainWidget, "Warning", f"Deletion failed: {e}") def _configNameChanged(self, cfgfile): logger.debug("_configNameChanged: %s", cfgfile) diff --git a/nexxT/services/gui/GraphEditor.py b/nexxT/services/gui/GraphEditor.py index 6bf4c59..dafbdd0 100644 --- a/nexxT/services/gui/GraphEditor.py +++ b/nexxT/services/gui/GraphEditor.py @@ -1255,18 +1255,33 @@ def onConnectionRemove(self): item.portTo.nodeItem.name, item.portTo.name) def onConnSetNonBlocking(self): + """ + Sets the conmnection to non blocking mode. + + :return: + """ item = self.itemOfContextMenu self.graph.setConnectionProperties(item.portFrom.nodeItem.name, item.portFrom.name, item.portTo.nodeItem.name, item.portTo.name, dict(width=0)) item.sync() def onConnSetBlocking(self): + """ + Sets the conmnection to blocking mode. + + :return: + """ item = self.itemOfContextMenu self.graph.setConnectionProperties(item.portFrom.nodeItem.name, item.portFrom.name, item.portTo.nodeItem.name, item.portTo.name, dict(width=1)) item.sync() def onConnSetCustom(self): + """ + Sets the connection to a custom width. + + :return: + """ item = self.itemOfContextMenu c = item.portFrom.nodeItem.name, item.portFrom.name, item.portTo.nodeItem.name, item.portTo.name width = self.graph.getConnectionProperties(*c)["width"] diff --git a/nexxT/services/gui/MainWindow.py b/nexxT/services/gui/MainWindow.py index 46edd87..001661e 100644 --- a/nexxT/services/gui/MainWindow.py +++ b/nexxT/services/gui/MainWindow.py @@ -440,6 +440,8 @@ def _substWindowTitle(title, theFilter): if ("${COMPOSITENAME}" in title or "${FILTERNAME}" in title or "${FULLQUALIFIEDFILTERNAME}" in title): + logger.warning("Deprecated: implicit variable substitution for window titles - " + "use explicit variable substitution instead.") if isinstance(theFilter, Filter): name = theFilter.environment().getFullQualifiedName() if "/" in name: diff --git a/nexxT/services/gui/PlaybackControl.py b/nexxT/services/gui/PlaybackControl.py index d359a15..0f2f98e 100644 --- a/nexxT/services/gui/PlaybackControl.py +++ b/nexxT/services/gui/PlaybackControl.py @@ -280,7 +280,7 @@ def _currentTimestampChanged(self, currentTime): if self.beginTime is None: self.currentLabel.setText("") else: - sliderVal = (currentTime - self.beginTime) // 1000000 # nanoseconds to milliseconds + sliderVal = max(0, currentTime - self.beginTime) // 1000000 # nanoseconds to milliseconds self.preventSeek = True self.positionSlider.setValue(sliderVal) self.preventSeek = False diff --git a/nexxT/services/gui/PropertyDelegate.py b/nexxT/services/gui/PropertyDelegate.py index 497b89f..9a6865b 100644 --- a/nexxT/services/gui/PropertyDelegate.py +++ b/nexxT/services/gui/PropertyDelegate.py @@ -9,7 +9,7 @@ """ from nexxT.Qt.QtCore import Qt -from nexxT.Qt.QtWidgets import QStyledItemDelegate +from nexxT.Qt.QtWidgets import QStyledItemDelegate, QLineEdit class PropertyDelegate(QStyledItemDelegate): """ @@ -41,11 +41,16 @@ def createEditor(self, parent, option, index): """ d = self.model.data(index, self.role) if isinstance(d, self.PropertyContent): - p = d.property.getPropertyDetails(d.name) - res = p.handler.createEditor(parent) - if res is not None: - res.setObjectName("PropertyDelegateEditor") - return res + if index.column() == 1: + p = d.property.getPropertyDetails(d.name) + if not p.useEnvironment: + res = p.handler.createEditor(parent) + if res is not None: + res.setObjectName("PropertyDelegateEditor") + return res + else: + res = QLineEdit(parent) + return res return super().createEditor(parent, option, index) def setEditorData(self, editor, index): @@ -59,9 +64,13 @@ def setEditorData(self, editor, index): d = self.model.data(index, self.role) if isinstance(d, self.PropertyContent): p = d.property.getPropertyDetails(d.name) - v = d.property.getProperty(d.name) - p.handler.setEditorData(editor, v) - return None + if index.column() == 1: + if not p.useEnvironment: + v = d.property.getProperty(d.name) + p.handler.setEditorData(editor, v) + else: + editor.setText(d.property.getProperty(d.name, subst=False)) + return None return super().setEditorData(editor, index) def setModelData(self, editor, model, index): @@ -77,7 +86,13 @@ def setModelData(self, editor, model, index): d = self.model.data(index, self.role) if isinstance(d, self.PropertyContent): p = d.property.getPropertyDetails(d.name) - value = p.handler.getEditorData(editor) - if value is not None: - model.setData(index, value, Qt.EditRole) + if index.column() == 1: + if not p.useEnvironment: + value = p.handler.getEditorData(editor) + if value is not None: + model.setData(index, value, Qt.EditRole) + else: + value = editor.text() + model.setData(index, value, Qt.EditRole) + return None return super().setModelData(editor, model, index) diff --git a/nexxT/src/FilterEnvironment.cpp b/nexxT/src/FilterEnvironment.cpp index 3cce3df..e435966 100644 --- a/nexxT/src/FilterEnvironment.cpp +++ b/nexxT/src/FilterEnvironment.cpp @@ -94,6 +94,7 @@ void BaseFilterEnvironment::portDataChanged(const InputPortInterface &port) switch( state() ) { case FilterState::OPENED: + case FilterState::STOPPING: NEXXT_LOG_INFO("DataSample discarded because application has been stopped already."); break; case FilterState::CONSTRUCTED: diff --git a/nexxT/src/cnexxT.xml b/nexxT/src/cnexxT.xml index de91b65..a974595 100644 --- a/nexxT/src/cnexxT.xml +++ b/nexxT/src/cnexxT.xml @@ -98,7 +98,7 @@ - + diff --git a/nexxT/tests/__init__.py b/nexxT/tests/__init__.py index 147a6b5..c43febd 100644 --- a/nexxT/tests/__init__.py +++ b/nexxT/tests/__init__.py @@ -19,3 +19,9 @@ "binary" / "${NEXXT_PLATFORM}" / "${NEXXT_VARIANT}" / "test_plugins").absolute()), "TestExceptionFilter" ) + +PropertyReceiver = FilterSurrogate( + "binary://" + str((Path(__file__).parent / + "binary" / "${NEXXT_PLATFORM}" / "${NEXXT_VARIANT}" / "test_plugins").absolute()), + "PropertyReceiver" +) \ No newline at end of file diff --git a/nexxT/tests/core/test_ActiveApplication.py b/nexxT/tests/core/test_ActiveApplication.py index 5f331e0..91b3747 100644 --- a/nexxT/tests/core/test_ActiveApplication.py +++ b/nexxT/tests/core/test_ActiveApplication.py @@ -28,12 +28,26 @@ def simple_setup(multithread, sourceFreq, sinkTime, activeTime_s, dynamicFilter) try: class DummySubConfig(object): + class DummyConfig: + def __init__(self): + self.pc = PropertyCollectionImpl("root", None) + + def propertyCollection(self): + return self.pc + def __init__(self): + self.dummyConfig = DummySubConfig.DummyConfig() self.pc = PropertyCollectionImpl("root", None) + def getConfiguration(self): + return self.dummyConfig + def getPropertyCollection(self): return self.pc + def getName(self): + return "dummy_subconfig" + fg = FilterGraph(DummySubConfig()) n1 = fg.addNode("pyfile://" + os.path.dirname(__file__) + "/../interface/SimpleStaticFilter.py", "SimpleSource") p = fg.getMockup(n1).getPropertyCollectionImpl() diff --git a/nexxT/tests/core/test_EntryPoints.py b/nexxT/tests/core/test_EntryPoints.py index 19e81a3..a02d5a9 100644 --- a/nexxT/tests/core/test_EntryPoints.py +++ b/nexxT/tests/core/test_EntryPoints.py @@ -8,13 +8,15 @@ import pytest from nexxT.core.FilterEnvironment import FilterEnvironment from nexxT.core.PropertyCollectionImpl import PropertyCollectionImpl +from nexxT.core.Configuration import Configuration from nexxT.core.PluginManager import PluginManager import nexxT cfilters = set(["examples.videoplayback.AviReader", "examples.framework.CameraGrabber", "tests.nexxT.CSimpleSource", - "tests.nexxT.CTestExceptionFilter"]) + "tests.nexxT.CTestExceptionFilter", + "tests.nexxT.CPropertyReceiver"]) blacklist = set([]) @@ -25,7 +27,8 @@ ] ) for e in pkg_resources.iter_entry_points("nexxT.filters")]) def test_EntryPoint(ep): - env = FilterEnvironment("entry_point://" + ep, "entry_point", PropertyCollectionImpl('propColl', None)) + cfg = Configuration() + env = FilterEnvironment("entry_point://" + ep, "entry_point", cfg._defaultRootPropColl()) PluginManager.singleton().unloadAll() if __name__ == "__main__": diff --git a/nexxT/tests/core/test_PropertyCollectionImpl.py b/nexxT/tests/core/test_PropertyCollectionImpl.py index 1898d4b..7095c3e 100644 --- a/nexxT/tests/core/test_PropertyCollectionImpl.py +++ b/nexxT/tests/core/test_PropertyCollectionImpl.py @@ -95,7 +95,7 @@ def newPropColl(name, parent): options=dict(min=0)) expect_exception(p_child1.defineProperty, PropertyInconsistentDefinition, "prop1", 1.0, "a sample float prop", propertyHandler=MySimplePropertyHandler({})) - expect_exception(p_child1.setProperty, PropertyParsingError, "prop1", "a") + #expect_exception(p_child1.setProperty, PropertyParsingError, "prop1", "a") assert p_child1.defineProperty("prop2", 4, "a sample int prop") == 4 assert signals_received == [("propertyAdded", p_child1, "prop2")] @@ -115,7 +115,7 @@ def newPropColl(name, parent): options=dict(min=1)) expect_exception(p_child1.defineProperty, PropertyInconsistentDefinition, "prop2", 4, "a sample int prop", propertyHandler=MySimplePropertyHandler({})) - expect_exception(p_child1.setProperty, PropertyParsingError, "prop2", "a") + #expect_exception(p_child1.setProperty, PropertyParsingError, "prop2", "a") assert p_child1.defineProperty("prop3", "a", "a sample str prop") == "a" assert signals_received == [("propertyAdded", p_child1, "prop3")] diff --git a/nexxT/tests/core/test_Variables.py b/nexxT/tests/core/test_Variables.py new file mode 100644 index 0000000..b1ecc0e --- /dev/null +++ b/nexxT/tests/core/test_Variables.py @@ -0,0 +1,75 @@ +import math +from nexxT.core.Variables import Variables + +def test_standardSubstitution(): + v = Variables() + v["var1"] = "Hello World" + v["var2"] = "$var1" + v["var3"] = "$var3" + v["var4"] = "${!importlib.import_module('math').exp(1)}" + v["var5"] = "${!subst('$var1')}" + v["var6"] = "${!xxx}" + # unfortunately this is not directly possible due to the usage of string.Template. + # you need a seperate variable defining only the python code, like var4 + v["var7"] = "exp(1) is ${!importlib.import_module('math').exp(1)}" + + assert v.subst("var1 is '$var1'") == "var1 is 'Hello World'" + assert v.subst("var2 is '$var2'") == "var2 is 'Hello World'" + try: + v.subst("var3 is '$var3'") + assert False + except RecursionError: + assert True + assert v.subst("var4 is '$var4'") == f"var4 is '{math.exp(1)}'" + assert v.subst("var5 is '$var5'") == f"var5 is 'Hello World'" + assert v.subst("var1 is '${var1}'") == f"var1 is 'Hello World'" + v.subst("var6 is '$var6'") == "var6 is ''" + assert v.subst("var7 is '$var7'") == "var7 is 'exp(1) is ${!importlib.import_module('math').exp(1)}'" + +def test_treeStructure(): + root = Variables() + child1 = Variables(root) + child2 = Variables(root) + grandchild = Variables(child1) + + root["id"] = "root" + root["root"] = "$id" + root["python"] = "${!subst('$id')}" + root["python_nonexist"] = "${!subst('$child1')}" + child1["id"] = "child1" + child1["child1"] = "$id" + child2["id"] = "child2" + child2["child2"] = "$id" + grandchild["id"] = "grandchild" + grandchild["grandchild"] = "$id" + + assert root.subst("$id") == "root" + assert child1.subst("$id") == "child1" + assert child2.subst("$id") == "child2" + assert grandchild.subst("$id") == "grandchild" + + assert root.subst("$python_nonexist") == "$child1" + assert child1.subst("$python_nonexist") == "$child1" + assert child2.subst("$python_nonexist") == "$child1" + assert grandchild.subst("$python_nonexist") == "$child1" + + assert root.subst("$python") == "root" + assert child1.subst("$python") == "root" + assert child2.subst("$python") == "root" + assert grandchild.subst("$python") == "root" + + assert child1.subst("$root") == "root" + assert child1.subst("$child1") == "child1" + assert child1.subst("$child2") == "$child2" + assert child1.subst("$grandchild") == "$grandchild" + + assert child2.subst("$root") == "root" + assert child2.subst("$child1") == "$child1" + assert child2.subst("$child2") == "child2" + assert child2.subst("$grandchild") == "$grandchild" + + assert grandchild.subst("$root") == "root" + assert grandchild.subst("$child1") == "child1" + assert grandchild.subst("$child2") == "$child2" + assert grandchild.subst("$grandchild") == "grandchild" + diff --git a/nexxT/tests/integration/basicworkflow.json b/nexxT/tests/integration/basicworkflow.json new file mode 100644 index 0000000..33cc028 --- /dev/null +++ b/nexxT/tests/integration/basicworkflow.json @@ -0,0 +1,5 @@ +{ + "_guiState": {}, + "composite_filters": [], + "applications": [] +} \ No newline at end of file diff --git a/nexxT/tests/integration/composite.json b/nexxT/tests/integration/composite.json new file mode 100644 index 0000000..724e097 --- /dev/null +++ b/nexxT/tests/integration/composite.json @@ -0,0 +1,367 @@ +{ + "_guiState": { + "MainWindow_framerate": { + "subst": false, + "value": 25 + }, + "PlaybackControl_showAllFiles": { + "subst": false, + "value": 0 + } + }, + "variables": { + "ROOTVAR": "root" + }, + "composite_filters": [ + { + "name": "comp1", + "nodes": [ + { + "name": "CompositeInput", + "library": "composite://port", + "factoryFunction": "CompositeInput", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "properties": {} + }, + { + "name": "CompositeOutput", + "library": "composite://port", + "factoryFunction": "CompositeOutput", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "properties": {} + }, + { + "name": "comp2", + "library": "composite://ref", + "factoryFunction": "comp2", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "variables": { + "COMP2VAR": "c" + }, + "properties": {} + }, + { + "name": "comp3", + "library": "composite://ref", + "factoryFunction": "comp3", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "variables": { + "COMP3VAR": "d" + }, + "properties": {} + }, + { + "name": "RootRef", + "library": "pyfile://./thefilter.py", + "factoryFunction": "TheFilter", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "properties": { + "string": { + "subst": true, + "value": "$FULLQUALIFIEDFILTERNAME : $ROOTVAR" + } + } + }, + { + "name": "Comp1Ref", + "library": "pyfile://./thefilter.py", + "factoryFunction": "TheFilter", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "properties": { + "string": { + "subst": true, + "value": "$FULLQUALIFIEDFILTERNAME : $COMP1VAR" + } + } + }, + { + "name": "Comp2Ref", + "library": "pyfile://./thefilter.py", + "factoryFunction": "TheFilter", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "properties": { + "string": { + "subst": true, + "value": "$FULLQUALIFIEDFILTERNAME : $COMP2VAR" + } + } + }, + { + "name": "Comp3Ref", + "library": "pyfile://./thefilter.py", + "factoryFunction": "TheFilter", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "properties": { + "string": { + "subst": true, + "value": "$FULLQUALIFIEDFILTERNAME : $COMP3VAR" + } + } + } + ], + "connections": [], + "_guiState": {} + }, + { + "name": "comp2", + "nodes": [ + { + "name": "CompositeInput", + "library": "composite://port", + "factoryFunction": "CompositeInput", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "properties": {} + }, + { + "name": "CompositeOutput", + "library": "composite://port", + "factoryFunction": "CompositeOutput", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "properties": {} + }, + { + "name": "RootRef", + "library": "pyfile://./thefilter.py", + "factoryFunction": "TheFilter", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "properties": { + "string": { + "subst": true, + "value": "$FULLQUALIFIEDFILTERNAME : $ROOTVAR" + } + } + }, + { + "name": "Comp1Ref", + "library": "pyfile://./thefilter.py", + "factoryFunction": "TheFilter", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "properties": { + "string": { + "subst": true, + "value": "$FULLQUALIFIEDFILTERNAME : $COMP1VAR" + } + } + }, + { + "name": "Comp2Ref", + "library": "pyfile://./thefilter.py", + "factoryFunction": "TheFilter", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "properties": { + "string": { + "subst": true, + "value": "$FULLQUALIFIEDFILTERNAME : $COMP2VAR" + } + } + }, + { + "name": "Comp3Ref", + "library": "pyfile://./thefilter.py", + "factoryFunction": "TheFilter", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "properties": { + "string": { + "subst": true, + "value": "$FULLQUALIFIEDFILTERNAME : $COMP3VAR" + } + } + } + ], + "connections": [], + "_guiState": {} + }, + { + "name": "comp3", + "nodes": [ + { + "name": "CompositeInput", + "library": "composite://port", + "factoryFunction": "CompositeInput", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "properties": {} + }, + { + "name": "CompositeOutput", + "library": "composite://port", + "factoryFunction": "CompositeOutput", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "properties": {} + }, + { + "name": "RootRef", + "library": "pyfile://./thefilter.py", + "factoryFunction": "TheFilter", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "properties": { + "string": { + "subst": true, + "value": "$FULLQUALIFIEDFILTERNAME : $ROOTVAR" + } + } + }, + { + "name": "Comp1Ref", + "library": "pyfile://./thefilter.py", + "factoryFunction": "TheFilter", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "properties": { + "string": { + "subst": true, + "value": "$FULLQUALIFIEDFILTERNAME : $COMP1VAR" + } + } + }, + { + "name": "Comp2Ref", + "library": "pyfile://./thefilter.py", + "factoryFunction": "TheFilter", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "properties": { + "string": { + "subst": true, + "value": "$FULLQUALIFIEDFILTERNAME : $COMP2VAR" + } + } + }, + { + "name": "Comp3Ref", + "library": "pyfile://./thefilter.py", + "factoryFunction": "TheFilter", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "properties": { + "string": { + "subst": true, + "value": "$FULLQUALIFIEDFILTERNAME : $COMP3VAR" + } + } + } + ], + "connections": [], + "_guiState": {} + } + ], + "applications": [ + { + "name": "application", + "_guiState": {}, + "nodes": [ + { + "name": "comp1_2", + "library": "composite://ref", + "factoryFunction": "comp1", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "variables": { + "COMP1VAR": "b" + }, + "properties": {} + }, + { + "name": "comp1_1", + "library": "composite://ref", + "factoryFunction": "comp1", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "variables": { + "COMP1VAR": "a" + }, + "properties": {} + } + ], + "connections": [] + }, + { + "name": "application_2", + "_guiState": {}, + "nodes": [], + "connections": [] + } + ] +} \ No newline at end of file diff --git a/nexxT/tests/integration/test_gui.py b/nexxT/tests/integration/test_gui.py index 79f13ba..094a98c 100644 --- a/nexxT/tests/integration/test_gui.py +++ b/nexxT/tests/integration/test_gui.py @@ -37,6 +37,8 @@ class ContextMenuEntry(str): pass CM_ADD_APPLICATION = ContextMenuEntry("Add application") CM_EDIT_GRAPH = ContextMenuEntry("Edit graph") +CM_REMOVE_APP = ContextMenuEntry("Remove app ...") +CM_REMOVE_COMPOSITE = ContextMenuEntry("Remove composite ...") CM_INIT_APP = ContextMenuEntry("Init Application") CM_INIT_APP_AND_OPEN =ContextMenuEntry("Init and load sequence") CM_INIT_APP_AND_PLAY = ContextMenuEntry("Init, load and play") @@ -51,6 +53,7 @@ class ContextMenuEntry(str): CM_FILTER_LIBRARY_TESTS_NEXXT = ContextMenuEntry("nexxT") CM_FILTER_LIBRARY_CSIMPLESOURCE = ContextMenuEntry("CSimpleSource") CM_FILTER_LIBRARY_PYSIMPLESTATICFILTER = ContextMenuEntry("PySimpleStaticFilter") +CM_FILTER_LIBRARY_CPROPERTY_RECEIVER = ContextMenuEntry("CPropertyReceiver") CM_FILTER_LIBRARY_PYSIMPLEVIEW = ContextMenuEntry("PySimpleView") CM_FILTER_LIBRARY_HDF5WRITER = ContextMenuEntry("HDF5Writer") CM_FILTER_LIBRARY_HDF5READER = ContextMenuEntry("HDF5Reader") @@ -62,6 +65,7 @@ class ContextMenuEntry(str): CM_SETTHREAD = ContextMenuEntry("Set thread ...") CM_RENAMEDYNPORT = ContextMenuEntry("Rename dynamic port ...") CM_REMOVEDYNPORT = ContextMenuEntry("Remove dynamic port ...") +CM_ADDVARIABLE = ContextMenuEntry("Add variable ...") CONFIG_MENU_DEINITIALIZE = ContextMenuEntry("Deinitialize") CONFIG_MENU_INITIALIZE = ContextMenuEntry("Initialize") LM_WARNING = ContextMenuEntry("Warning") @@ -261,6 +265,12 @@ def removeNodeFromGraph(self, graphEditView, node): QTimer.singleShot(2*self.delay, lambda: self.enterText("")) self.gsContextMenu(graphEditView, pos) + def setThreadOfNode(self, graphEditView, node, thread): + pos = node.nodeGrItem.sceneBoundingRect().center() + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_SETTHREAD)) + QTimer.singleShot(2*self.delay, lambda: self.enterText(thread)) + self.gsContextMenu(graphEditView, pos) + def addConnectionToGraphEditor(self, graphEditView, p1, p2): """ Adds a connection in the nexxT graph editor @@ -280,7 +290,7 @@ def addConnectionToGraphEditor(self, graphEditView, p1, p2): self.qtbot.mouseMove(graphEditView.viewport(), pos2, delay=self.delay) self.qtbot.mouseRelease(graphEditView.viewport(), Qt.LeftButton, pos=pos2, delay=self.delay) - def setFilterProperty(self, conf, subConfig, filterName, propName, propVal, expectedVal=None): + def setFilterProperty(self, conf, subConfig, filterName, propName, propVal, expectedVal=None, indirect=False): """ Sets a filter property in the configuration gui service. :param conf: the configuration gui service @@ -318,14 +328,26 @@ def setFilterProperty(self, conf, subConfig, filterName, propName, propVal, expe assert row is not None # start the editor by pressing F2 on the property value idxPropVal = conf.model.index(row, 1, idxFilter) + idxPropIndirect = conf.model.index(row, 2, idxFilter) + cindirect = conf.model.data(idxPropIndirect, Qt.CheckStateRole) != Qt.Unchecked + logger.info("cindirect=%s ==? indirect=%s", repr(cindirect), repr(indirect)) + if cindirect != indirect: + conf.model.setData(idxPropIndirect, True, Qt.EditRole) + self.qtbot.wait(self.delay) + cindirect = conf.model.data(idxPropIndirect, Qt.CheckStateRole) != Qt.Unchecked + assert cindirect == indirect conf.treeView.scrollTo(idxPropVal) region = conf.treeView.visualRegionForSelection(QItemSelection(idxPropVal, idxPropVal)) self.qtbot.mouseMove(conf.treeView.viewport(), pos=region.boundingRect().center(), delay=self.delay) self.qtbot.mouseClick(conf.treeView.viewport(), Qt.LeftButton, pos=region.boundingRect().center(), delay=self.delay) self.qtbot.keyClick(conf.treeView.viewport(), Qt.Key_F2, delay=self.delay) self.aw() + # there are some QT warnings when directly specifying the entertext widget manually, so we try to do without... + self.qtbot.wait(self.delay*3) mw = Services.getService("MainWindow") - self.enterText(propVal, mw.findChild(QWidget, "PropertyDelegateEditor")) + #widgets = [w for w in mw.findChildren(QWidget, "PropertyDelegateEditor") if w.isVisible()] + #assert len(widgets) == 1 + self.enterText(propVal) #, widgets[0]) self.qtbot.wait(self.delay) if expectedVal is None: expectedVal = propVal @@ -408,6 +430,10 @@ def assertLogItem(log, expectedLevel, expectedMsg): if not found: raise RuntimeError("expected message %s:%s not found in log", expectedLevel, expectedMsg) + @staticmethod + def clearLog(log): + log.logWidget.clear() + @staticmethod def noWarningsInLog(log, ignore=[]): """ @@ -761,9 +787,39 @@ def _first(self): # de-initialize application self.qtbot.keyClick(self.aw(), Qt.Key_C, Qt.AltModifier, delay=self.delay) self.activateContextMenu(CONFIG_MENU_DEINITIALIZE) - conf.actSave.trigger() - self.qtbot.wait(1000) + self.qtbot.wait(self.delay) + # test removal of subconfigs which are still in use + compidx = conf.model.indexOfSubConfig(conf.configuration().compositeFilterByName("composite")) + QTimer.singleShot(self.delay, lambda: self.cmContextMenu(conf, compidx, CM_REMOVE_COMPOSITE, "", "")) + self.qtbot.wait(self.delay * 5) + assert conf.model.indexOfSubConfig(conf.configuration().compositeFilterByName("composite")) != QModelIndex() + # test removal of applications + appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("application")) + QTimer.singleShot(self.delay, lambda: self.cmContextMenu(conf, appidx, CM_REMOVE_APP, "")) + self.qtbot.wait(self.delay * 4) + try: + conf.configuration().applicationByName("application") + assert False + except: + pass + appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("application_2")) + QTimer.singleShot(self.delay, lambda: self.cmContextMenu(conf, appidx, CM_REMOVE_APP, "")) + self.qtbot.wait(self.delay * 4) + try: + conf.configuration().applicationByName("application_2") + assert False + except: + pass + compidx = conf.model.indexOfSubConfig(conf.configuration().compositeFilterByName("composite")) + QTimer.singleShot(self.delay, lambda: self.cmContextMenu(conf, compidx, CM_REMOVE_COMPOSITE, "")) + self.qtbot.wait(self.delay * 4) + try: + conf.configuration().compositeFilterByName("composite") + assert False + except: + pass + self.noWarningsInLog(log) finally: if not self.keep_open: @@ -785,16 +841,16 @@ def _second(self): # this is the offline config appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("application_2")) self.cmContextMenu(conf, appidx, CM_INIT_APP) - self.qtbot.wait(1000) + self.qtbot.wait(self.delay) assert not playback.actPause.isEnabled() self.cmContextMenu(conf, appidx, CM_INIT_APP_AND_OPEN, 0) - self.qtbot.wait(1000) + self.qtbot.wait(self.delay) assert not playback.actPause.isEnabled() playback.actStepFwd.trigger() - self.qtbot.wait(1000) + self.qtbot.wait(self.delay) firstFrame = self.getLastLogFrameIdx(log) self.cmContextMenu(conf, appidx, CM_INIT_APP_AND_PLAY, 0) - self.qtbot.wait(1000) + self.qtbot.wait(self.delay) self.qtbot.waitUntil(playback.actStart.isEnabled, timeout=10000) lastFrame = self.getLastLogFrameIdx(log) assert lastFrame >= firstFrame + 10 @@ -1031,6 +1087,113 @@ def onInit(self): QTimer.singleShot(self.delay, self.clickDiscardChanges) mw.close() + def _prop_changed(self, variant): + conf = None + mw = None + try: + # load last config + mw = Services.getService("MainWindow") + conf = Services.getService("Configuration") + log = Services.getService("Logging") + idxComposites = conf.model.index(0, 0) + idxApplications = conf.model.index(1, 0) + # add application + conf.treeView.setMinimumSize(QSize(300, 300)) + conf.treeView.scrollTo(idxApplications) + region = conf.treeView.visualRegionForSelection(QItemSelection(idxApplications, idxApplications)) + self.qtbot.mouseMove(conf.treeView.viewport(), region.boundingRect().center(), delay=self.delay) + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADD_APPLICATION)) + conf._execTreeViewContextMenu(region.boundingRect().center()) + app = conf.configuration().applicationByName("application") + # start graph editor + gev = self.startGraphEditor(conf, mw, "application") + self.qtbot.wait(self.delay) + if variant == "c": + # create a node "TheFilter" + the_filter = self.addNodeToGraphEditor(gev, QPoint(20,20), + CM_FILTER_LIBRARY, CM_FILTER_LIBRARY_TESTS, + CM_FILTER_LIBRARY_TESTS_NEXXT, + CM_FILTER_LIBRARY_CPROPERTY_RECEIVER) + self.setThreadOfNode(gev, the_filter, "non_main") + self.qtbot.wait(self.delay) + logger.info("Filter: %s", repr(the_filter)) + elif variant == "py": + thefilter_py = (Path(self.tmpdir) / "thepropfilter.py") + thefilter_py.write_text( +""" +from nexxT.interface import Filter +import logging + +class CPropertyReceiver(Filter): + def __init__(self, env): + super().__init__(False, False, env) + + def onInit(self): + pc = self.propertyCollection() + pc.defineProperty("int", 1, "an integer property") + pc.defineProperty("float", 10.0, "a float property") + pc.defineProperty("str", "Hello World", "a string property") + pc.defineProperty("bool", False, "a bool property") + pc.defineProperty("enum", "v1", "an enum property", options=dict(enum=["v1", "v2", "v3"])) + pc.propertyChanged.connect(self.propertyChanged) + + def onDeinit(self): + pc = self.propertyCollection() + pc.propertyChanged.disconnect(self.propertyChanged) + + def propertyChanged(self, pc, name): + v = pc.getProperty(name) + logging.getLogger(__name__).info("propertyChanged %s is %s", name, v) +""" + ) + # create a node "TheFilter" + the_filter = self.addNodeToGraphEditor(gev, QPoint(20, 20), + CM_FILTER_FROM_FILE, str(thefilter_py), "CPropertyReceiver") + self.qtbot.keyClick(self.aw(), Qt.Key_Return, delay=self.delay) + self.qtbot.keyClick(None, Qt.Key_Return, delay=self.delay) + else: + assert False + # init application + appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("application")) + self.cmContextMenu(conf, appidx, CM_INIT_APP) + self.qtbot.wait(1000) + + self.setFilterProperty(conf, app, "CPropertyReceiver", "int", + ["7", Qt.Key_Return], "7") + self.setFilterProperty(conf, app, "CPropertyReceiver", "float", + "1.0", "1.0") + self.setFilterProperty(conf, app, "CPropertyReceiver", "str", + "changed", "changed") + self.setFilterProperty(conf, app, "CPropertyReceiver", "bool", + [Qt.Key_Down, Qt.Key_Return], "True") + self.setFilterProperty(conf, app, "CPropertyReceiver", "enum", + [Qt.Key_Down, Qt.Key_Return], "v2") + + expectedLogItems = [ + "propertyChanged int is 7", + "propertyChanged float is 1" if variant == "c" else "propertyChanged float is 1.0", + "propertyChanged str is changed", + "propertyChanged bool is true" if variant == "c" else "propertyChanged bool is True", + "propertyChanged enum is v2", + ] + + self.qtbot.wait(1000) + + model = log.logWidget.model() + numRows = model.rowCount(QModelIndex()) + for row in range(numRows): + level = model.data(model.index(row, 1, QModelIndex()), Qt.DisplayRole) + msg = model.data(model.index(row, 2, QModelIndex()), Qt.DisplayRole) + if len(expectedLogItems) > 0 and level == "INFO" and msg in expectedLogItems[0]: + expectedLogItems = expectedLogItems[1:] + assert level not in ["WARN", "WARNING", "ERROR"] + assert len(expectedLogItems) == 0 + finally: + if not self.keep_open: + if conf.configuration().dirty(): + QTimer.singleShot(self.delay, self.clickDiscardChanges) + mw.close() + def test(self): """ test property editing in config editor @@ -1047,6 +1210,20 @@ def test_dyn(self): QTimer.singleShot(self.delay, self._dynamic_properties) startNexT(None, None, [], [], True) + def test_propChangedC(self): + """ + test connections to propertyChanged signals + """ + QTimer.singleShot(self.delay, lambda: self._prop_changed("c")) + startNexT(None, None, [], [], True) + + def test_propChangedPy(self): + """ + test connections to propertyChanged signals + """ + QTimer.singleShot(self.delay, lambda: self._prop_changed("py")) + startNexT(None, None, [], [], True) + @pytest.mark.gui @pytest.mark.parametrize("delay", [300]) def test_properties(qtbot, xvfb, keep_open, delay, tmpdir): @@ -1059,6 +1236,18 @@ def test_dyn_properties(qtbot, xvfb, keep_open, delay, tmpdir): test = PropertyTest(qtbot, xvfb, keep_open, delay, tmpdir) test.test_dyn() +@pytest.mark.gui +@pytest.mark.parametrize("delay", [300]) +def test_prop_changed_c(qtbot, xvfb, keep_open, delay, tmpdir): + test = PropertyTest(qtbot, xvfb, keep_open, delay, tmpdir) + test.test_propChangedC() + +@pytest.mark.gui +@pytest.mark.parametrize("delay", [300]) +def test_prop_changed_py(qtbot, xvfb, keep_open, delay, tmpdir): + test = PropertyTest(qtbot, xvfb, keep_open, delay, tmpdir) + test.test_propChangedPy() + class GuiStateTest(GuiTestBase): """ Concrete test class for the guistate test case @@ -1680,3 +1869,252 @@ def test(self): def test_reload(qtbot, xvfb, keep_open, delay, tmpdir): test = ReloadTest(qtbot, xvfb, keep_open, delay, tmpdir) test.test() + + +class VariablesTest(GuiTestBase): + """ + Concrete test class for the test_variables test case + """ + + def __init__(self, qtbot, xvfb, keep_open, delay, tmpdir): + super().__init__(qtbot, xvfb, keep_open, delay, tmpdir) + + def _variables(self): + conf = None + mw = None + thefilter_py = (Path(self.tmpdir) / "thefilter.py") + thefilter_py.write_text("""\ +import logging +from nexxT.interface import Filter + +logger = logging.getLogger(__name__) + +class TheFilter(Filter): + def __init__(self, env): + super().__init__(False, False, env) + pc = self.propertyCollection() + pc.defineProperty("bool_prop", False, "a boolean") + pc.defineProperty("unbound_float", 7., "an unbound float") + pc.defineProperty("low_bound_float", 7., "a low bound float", dict(min=-3)) + pc.defineProperty("high_bound_float", 7., "a high bound float", dict(max=123)) + pc.defineProperty("bound_float", 7., "a bound float", dict(min=6, max=1203)) + pc.defineProperty("unbound_int", 7, "an unbound integer") + pc.defineProperty("low_bound_int", 7, "a low bound integer", dict(min=-3)) + pc.defineProperty("high_bound_int", 7, "a high bound integer", dict(max=123)) + pc.defineProperty("bound_int", 7, "a bound integer", dict(min=6, max=1203)) + pc.defineProperty("string", "str", "an arbitrary string") + pc.defineProperty("enum", "v1", "an enum", dict(enum=["v1", "v2", "v3"])) + + def onOpen(self): + pc = self.propertyCollection() + for n in ["bool_prop", "unbound_float", "low_bound_float", "high_bound_float", "bound_float", + "unbound_int", "low_bound_int", "high_bound_int", "bound_int", "string", "enum"]: + logger.info("getProperty(%s) = %s", n, repr(pc.getProperty(n))) +""" + ) + try: + # load last config + mw = Services.getService("MainWindow") + conf = Services.getService("Configuration") + log = Services.getService("Logging") + idxComposites = conf.model.index(0, 0) + idxApplications = conf.model.index(1, 0) + idxVariables = conf.model.index(2, 0) + # add application + conf.treeView.setMinimumSize(QSize(300, 300)) + conf.treeView.scrollTo(idxApplications) + region = conf.treeView.visualRegionForSelection(QItemSelection(idxApplications, idxApplications)) + self.qtbot.mouseMove(conf.treeView.viewport(), region.boundingRect().center(), delay=self.delay) + # mouse click does not trigger context menu :( + # qtbot.mouseClick(conf.treeView.viewport(), Qt.RightButton, pos=region.boundingRect().center()) + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADD_APPLICATION)) + conf._execTreeViewContextMenu(region.boundingRect().center()) + app = conf.configuration().applicationByName("application") + appidx = conf.model.indexOfSubConfig(app) + # start graph editor + gev = self.startGraphEditor(conf, mw, "application") + self.qtbot.wait(self.delay) + # create a node "TheFilter" + the_filter = self.addNodeToGraphEditor(gev, QPoint(20, 20), + CM_FILTER_FROM_FILE, str(thefilter_py), "TheFilter") + self.qtbot.keyClick(self.aw(), Qt.Key_Return, delay=self.delay) + self.qtbot.keyClick(None, Qt.Key_Return, delay=self.delay) + logger.info("Filter: %s", repr(the_filter)) + # add variables + self.aw() + for name, value in [("BFALSE", "False"), ("BTRUE", "True"), + ("FHIGH", "3.4028235e+38"), ("FLOW", "-3.4028235e+38"), + ("IHIGH", "2147483647"), ("ILOW", "-2147483648"), + ("IM4", "-4"), ("I1", "1"), + ("EV1", "v1"), ("EV2", "v2"), ("EV3", "v3")]: + conf.treeView.scrollTo(idxVariables) + region = conf.treeView.visualRegionForSelection(QItemSelection(idxVariables, idxVariables)) + self.qtbot.mouseMove(conf.treeView.viewport(), region.boundingRect().center(), delay=self.delay) + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADDVARIABLE)) + QTimer.singleShot(self.delay * 2, lambda: self.enterText(name)) + conf._execTreeViewContextMenu(region.boundingRect().center()) + def find_var_index(parentIndex, varname): + m = conf.model + for r in range(m.rowCount(parentIndex)): + idx = m.index(r, 0, parentIndex) + t = m.data(idx, Qt.DisplayRole) + if t == varname: + return m.index(r, 1, parentIndex) + idx = find_var_index(idxVariables, name) + assert idx is not None + conf.treeView.scrollTo(idx) + region = conf.treeView.visualRegionForSelection(QItemSelection(idx, idx)) + self.qtbot.mouseMove(conf.treeView.viewport(), region.boundingRect().center(), delay=self.delay) + self.qtbot.mouseClick(conf.treeView.viewport(), Qt.LeftButton, pos=region.boundingRect().center(), + delay=self.delay) + self.qtbot.keyClick(conf.treeView.viewport(), Qt.Key_F2, delay=self.delay) + self.aw() + mw = Services.getService("MainWindow") + QTimer.singleShot(self.delay*2, lambda: self.enterText(value)) + self.qtbot.wait(self.delay*3) + assert conf.model.data(idx, Qt.DisplayRole) == value + logger.info("Added variable %s=%s", name, value) + tests = dict( + bool_prop=[("$BFALSE", repr(False)), ("$BTRUE", repr(True)), ("$NONEXIST", repr(False))], + unbound_float=[("$FHIGH", repr(3.4028235e+38)), ("$FLOW", repr(-3.4028235e+38)), + ("$NONEXIST", repr(0.0))], + low_bound_float=[("$FHIGH", repr(3.4028235e+38)), ("$FLOW", repr(-3.0)), ("$IM4", repr(-3.0)), + ("$I1", repr(1.0)), ("$NONEXIST", repr(0.0))], + high_bound_float=[("$FHIGH", repr(123.0)), ("$FLOW", repr(-3.4028235e+38)), ("$IM4", repr(-4.0)), + ("$I1", repr(1.0)), ("$NONEXIST", repr(0.0))], + bound_float=[("$FHIGH", repr(1203.0)), ("$FLOW", repr(6.0)), + ("$NONEXIST", repr(6.0))], + unbound_int=[("$IHIGH", repr(2147483647)), ("$ILOW", repr(-2147483648)), ("$IM4", repr(-4)), + ("$I1", repr(1)), ("$NONEXIST", repr(0))], + low_bound_int=[("$IHIGH", repr(2147483647)), ("$ILOW", repr(-3)), ("$IM4", repr(-3)), + ("$I1", repr(1)), ("$NONEXIST", repr(0))], + high_bound_int=[("$IHIGH", repr(123)), ("$ILOW", repr(-2147483648)), ("$IM4", repr(-4)), + ("$I1", repr(1)), ("$NONEXIST", repr(0))], + bound_int=[("$IHIGH", repr(1203)), ("$ILOW", repr(6)), ("$IM4", repr(6)), + ("$I1", repr(6)), ("$NONEXIST", repr(6))], + string=[("$IHIGH", repr("2147483647")), ("$FILTERNAME", repr("TheFilter")), + ("$FULLQUALIFIEDFILTERNAME", repr("/TheFilter")), ("$COMPOSITENAME", repr("")), + ("$APPNAME", repr("application"))], + enum=[("$EV1", repr("v1")), ("$EV2", repr("v2")), ("$EV3", repr("v3")), ("$NONEXIST", repr("v1"))] + ) + for tidx in range(max(len(tests[k]) for k in tests)): + expectedLogs = [] + logger.info("test_gui:variables:start test cycle") + for k in tests: + if tidx < len(tests[k]): + t = tests[k][tidx] + logger.info("test_gui:variables:set property %s->%s", k, t[0]) + self.setFilterProperty(conf, app, "TheFilter", k, t[0], t[0], indirect=True) + expectedLogs.append("getProperty(%s) = %s" % (k, t[1])) + logger.info("test_gui:variables:Initializing app") + self.cmContextMenu(conf, appidx, CM_INIT_APP) + logger.info("test_gui:variables:wait") + self.qtbot.wait(1000) + for logMsg in expectedLogs: + logger.info("test_gui:variables:checklog") + self.assertLogItem(log, "INFO", logMsg) + logger.info("test_gui:variables:clearlog") + self.clearLog(log) + finally: + if not self.keep_open: + if conf.configuration().dirty(): + self.aw() + QTimer.singleShot(self.delay, self.clickDiscardChanges) + mw.close() + + def _composite(self): + conf = None + mw = None + thefilter_py = (Path(self.tmpdir) / "thefilter.py") + thefilter_py.write_text("""\ +import logging +from nexxT.interface import Filter + +logger = logging.getLogger(__name__) + +class TheFilter(Filter): + def __init__(self, env): + super().__init__(False, False, env) + pc = self.propertyCollection() + pc.defineProperty("string", "str", "an arbitrary string") + + def onOpen(self): + pc = self.propertyCollection() + logger.info("getProperty(string) = %s", repr(pc.getProperty("string"))) +""") + config_file = Path(self.tmpdir) / "composite.json" + config_file.write_text((Path(__file__).parent / "composite.json").read_text()) + try: + # load config + mw = Services.getService("MainWindow") + conf = Services.getService("Configuration") + log = Services.getService("Logging") + + conf.loadConfig(str(config_file)) + logger.info("test_gui:variables:Initializing app") + app = conf.configuration().applicationByName("application") + appidx = conf.model.indexOfSubConfig(app) + self.cmContextMenu(conf, appidx, CM_INIT_APP) + logger.info("test_gui:variables:wait") + self.qtbot.wait(1000) + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_2/comp2/RootRef : root'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_2/comp2/Comp1Ref : b'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_2/comp2/Comp2Ref : c'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_2/comp2/Comp3Ref : $COMP3VAR'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_2/comp3/RootRef : root'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_2/comp3/Comp1Ref : b'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_2/comp3/Comp2Ref : $COMP2VAR'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_2/comp3/Comp3Ref : d'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_2/RootRef : root'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_2/Comp1Ref : b'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_2/Comp2Ref : $COMP2VAR'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_2/Comp3Ref : $COMP3VAR'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_1/comp2/RootRef : root'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_1/comp2/Comp1Ref : a'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_1/comp2/Comp2Ref : c'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_1/comp2/Comp3Ref : $COMP3VAR'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_1/comp3/RootRef : root'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_1/comp3/Comp1Ref : a'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_1/comp3/Comp2Ref : $COMP2VAR'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_1/comp3/Comp3Ref : d'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_1/RootRef : root'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_1/Comp1Ref : a'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_1/Comp2Ref : $COMP2VAR'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_1/Comp3Ref : $COMP3VAR'") + finally: + if not self.keep_open: + if conf.configuration().dirty(): + self.aw() + QTimer.singleShot(self.delay, self.clickDiscardChanges) + mw.close() + + def test(self): + """ + test property editing in config editor + :return: + """ + QTimer.singleShot(self.delay, self._variables) + startNexT(None, None, [], [], True) + + + def test_composite(self): + """ + test property editing in config editor + :return: + """ + QTimer.singleShot(self.delay, self._composite) + startNexT(None, None, [], [], True) + + +@pytest.mark.gui +@pytest.mark.parametrize("delay", [300]) +def test_variables(qtbot, xvfb, keep_open, delay, tmpdir): + test = VariablesTest(qtbot, xvfb, keep_open, delay, tmpdir) + test.test() + +@pytest.mark.gui +@pytest.mark.parametrize("delay", [300]) +def test_variablesComposite(qtbot, xvfb, keep_open, delay, tmpdir): + test = VariablesTest(qtbot, xvfb, keep_open, delay, tmpdir) + test.test_composite() + diff --git a/nexxT/tests/integration/test_gui.py.fixed b/nexxT/tests/integration/test_gui.py.fixed new file mode 100644 index 0000000..bed200f --- /dev/null +++ b/nexxT/tests/integration/test_gui.py.fixed @@ -0,0 +1,1685 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2020 ifm electronic gmbh +# +# THE PROGRAM IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND. +# + +""" +Real gui testing, a handy command for monitoring what's going on in the headless mode is: + +> x11vnc -display :27 -localhost & (sleep 1; vncviewer :0) # 27 must match the display printed at the test start + +You can also pass --no-xvfb to pytest and also --keep-open for inspecting the issue in head mode. +""" + +import json +import os +import logging +from pathlib import Path +import re +import pytest +import nexxT.shiboken +from nexxT.Qt.QtCore import QItemSelection, Qt, QTimer, QSize, QPoint, QModelIndex +from nexxT.Qt.QtWidgets import QGraphicsSceneContextMenuEvent, QWidget, QApplication, QTreeView +from nexxT.core.AppConsole import startNexT +from nexxT.core import Compatibility +from nexxT.interface import Services +from nexxT.services.gui.GraphEditorView import GraphEditorView + +logger = logging.getLogger(__name__) + +@pytest.fixture +def keep_open(request): + return request.config.getoption("--keep-open") + +# context menu actions +class ContextMenuEntry(str): + pass +CM_ADD_APPLICATION = ContextMenuEntry("Add application") +CM_EDIT_GRAPH = ContextMenuEntry("Edit graph") +CM_INIT_APP = ContextMenuEntry("Init Application") +CM_INIT_APP_AND_OPEN =ContextMenuEntry("Init and load sequence") +CM_INIT_APP_AND_PLAY = ContextMenuEntry("Init, load and play") +CM_FILTER_LIBRARY = ContextMenuEntry("Filter Library") +CM_FILTER_FROM_PYMOD = ContextMenuEntry("Add filter from python module ...") +CM_FILTER_FROM_FILE = ContextMenuEntry("Add filter from file ...") +CM_FILTER_FROM_COMPOSITE = ContextMenuEntry("Add filter form composite definition ...") +CM_ADDCOMPOSITE = ContextMenuEntry("Add composite filter") +CM_AUTOLAYOUT = ContextMenuEntry("Auto layout") +CM_FILTER_LIBRARY_TESTS = ContextMenuEntry("tests") +CM_FILTER_LIBRARY_HARDDISK = ContextMenuEntry("harddisk") +CM_FILTER_LIBRARY_TESTS_NEXXT = ContextMenuEntry("nexxT") +CM_FILTER_LIBRARY_CSIMPLESOURCE = ContextMenuEntry("CSimpleSource") +CM_FILTER_LIBRARY_PYSIMPLESTATICFILTER = ContextMenuEntry("PySimpleStaticFilter") +CM_FILTER_LIBRARY_PYSIMPLEVIEW = ContextMenuEntry("PySimpleView") +CM_FILTER_LIBRARY_HDF5WRITER = ContextMenuEntry("HDF5Writer") +CM_FILTER_LIBRARY_HDF5READER = ContextMenuEntry("HDF5Reader") +CM_RENAME_NODE = ContextMenuEntry("Rename node ...") +CM_REMOVE_NODE = ContextMenuEntry('Remove node ...') +CM_ADDDYNINPORT = ContextMenuEntry("Add dynamic input port ...") +CM_ADDDYNOUTPORT = ContextMenuEntry("Add dynamic output port ...") +CM_SUGGEST_DYNPORTS = ContextMenuEntry("Suggest dynamic ports ...") +CM_SETTHREAD = ContextMenuEntry("Set thread ...") +CM_RENAMEDYNPORT = ContextMenuEntry("Rename dynamic port ...") +CM_REMOVEDYNPORT = ContextMenuEntry("Remove dynamic port ...") +CONFIG_MENU_DEINITIALIZE = ContextMenuEntry("Deinitialize") +CONFIG_MENU_INITIALIZE = ContextMenuEntry("Initialize") +LM_WARNING = ContextMenuEntry("Warning") + +class GuiTestBase: + def __init__(self, qtbot, xvfb, keep_open, delay, tmpdir): + self.qtbot = qtbot + self.delay = delay + self.xvfb = xvfb + self.keep_open = keep_open + self.tmpdir = tmpdir + if xvfb is not None: + print("dims = ",xvfb.width, xvfb.height) + print("DISPLAY=",xvfb.display) + # make sure that we have a fresh environment + os.environ["HOME"] = str(tmpdir) + logger.info("TMPDIR=%s", tmpdir) + + """ + Class encapsulates useful method for gui testing the nexxT application. + """ + def activateContextMenu(self, *menuItems, **kwargs): + """ + In a given context menu navigate to the given index using key presses and activate it using return + :param menuItems: Might be either integers referencing the position in the menu or (better) strings referencing + the menu text + :return: + """ + def activeMenuEntry(): + """ + return the text of the active menu item including submenus + :return: + """ + menu = QApplication.activePopupWidget() + if menu is None: + return None + act = menu.activeAction() + if act is None: + return None + + while Compatibility.getMenuFromAction(act) is not None and Compatibility.getMenuFromAction(act).activeAction() is not None: + act = Compatibility.getMenuFromAction(act).activeAction() + return act.text() + + if kwargs.get("debug", False): + logger_debug = logger.info + else: + logger_debug = logger.debug + try: + # navigate to the requested menu item + for j in range(len(menuItems)): + if isinstance(menuItems[j], int): + for i in range(menuItems[j]): + self.qtbot.keyClick(None, Qt.Key_Down, delay=self.delay) + logger_debug("(int) Current action: '%s'", activeMenuEntry()) + else: + nonNoneAction = None + while activeMenuEntry() is None or activeMenuEntry() != menuItems[j]: + logger_debug("(str) Current action: '%s' != '%s'", activeMenuEntry(), menuItems[j]) + self.qtbot.keyClick(None, Qt.Key_Down, delay=self.delay) + if nonNoneAction is None: + nonNoneAction = activeMenuEntry() + else: + assert nonNoneAction != activeMenuEntry() + logger_debug("(str) Current action: '%s'", activeMenuEntry()) + if j < len(menuItems) - 1: + self.qtbot.keyClick(None, Qt.Key_Right, delay=self.delay) + self.qtbot.keyClick(None, Qt.Key_Return, delay=self.delay) + except Exception: + logger.exception("exception while activating context menu") + raise + + @staticmethod + def aw(): + """ + on xvfb, the main window sometimes looses focus leading to a crash of the qtbot's keyClick(s) function + this function avoids this + :return: + """ + w = QApplication.activeWindow() + if w is None: + QApplication.setActiveWindow(Services.getService("MainWindow").data()) + w = QApplication.activeWindow() + return w + + def enterText(self, text, w=None): + """ + Enter the given text into the widget w (or the current widget, if w is None) + :param text: the text to be entered + :param w: the widget it should be entered to + :return: + """ + if isinstance(text, str): + if text != "": + self.qtbot.keyClicks(w, text) + else: + for k in text: + self.qtbot.keyClick(w, k) + self.qtbot.keyClick(w, Qt.Key_Return) + + def gsContextMenu(self, graphView, pos): + """ + This function starts a context menu on a graphics view. + :param graphView: the respective QGraphicsView + :param pos: the position where the context menu shall be raised + :return: + """ + ev = QGraphicsSceneContextMenuEvent() + ev.setScenePos(pos) + ev.setPos(QPoint(0,0)) # item position + ev.setScreenPos(graphView.viewport().mapToGlobal(graphView.mapFromScene(pos))) + #print("scenePos=", ev.scenePos(), ", pos=", ev.pos(), ", screenPos=", ev.screenPos()) + self.qtbot.mouseMove(graphView.viewport(), graphView.mapFromScene(ev.scenePos())) + graphView.scene().contextMenuEvent(ev) + + def cmContextMenu(self, conf, idx, *contextMenuIndices, **kwargs): + """ + This function executes a context menu on the configuration tree view + :param conf: The configuration gui service + :param idx: A QModelIndex of the item where the context menu shall be raised + :param contextMenuIndices: A list of ContextMenuEntry, int and str instances. ContextMenuEntry and int instances + are used to navigate through the context menu, afterwards the str instances are + entered as text (in dialogs resulting from the context menu). + :return: + """ + treeView = conf.treeView + assert isinstance(treeView, QTreeView) + treeView.scrollTo(idx) + self.qtbot.wait(1000) + pos = treeView.visualRegionForSelection(QItemSelection(idx, idx)).boundingRect().center() + self.qtbot.mouseMove(treeView.viewport(), pos=pos, delay=self.delay) + try: + intIdx = max([i for i in range(-1, -len(contextMenuIndices)-1, -1) + if isinstance(contextMenuIndices[i], (int,ContextMenuEntry))]) + intIdx += len(contextMenuIndices) + except ValueError: + logger.exception("exception contextMenuIndices:%s empty?!?", contextMenuIndices) + intIdx = -1 + cmIdx = contextMenuIndices[:intIdx+1] + texts = contextMenuIndices[intIdx+1:] + if kwargs.get("debug", False): + logger.info("contextMenuIndices:%s cmIdx:%s texts:%s", contextMenuIndices, cmIdx, texts) + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(*cmIdx, **kwargs)) + for i, t in enumerate(texts): + QTimer.singleShot(self.delay*(i+2), lambda text=t: self.enterText(text)) + conf._execTreeViewContextMenu(pos) + + def addNodeToGraphEditor(self, graphEditView, scenePos, *contextMenuItems): + """ + Adds a node to the nexxT graph editor. + :param graphEditView: the GraphEditorView instance + :param scenePos: the position where the node shall be created + :param contextMenuItems: the context menu items to be processed (see activateContextMenu(...)) + :return: the newly created node + """ + oldNodes = set(graphEditView.scene().nodes.keys()) + try: + intIdx = max([i for i in range(-1,-len(contextMenuItems)-1,-1) + if isinstance(contextMenuItems[i], (int, ContextMenuEntry))]) + intIdx += len(contextMenuItems) + except ValueError: + intIdx = -1 + cmIdx = contextMenuItems[:intIdx+1] + texts = contextMenuItems[intIdx+1:] + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(*cmIdx)) + for i,t in enumerate(texts): + QTimer.singleShot(self.delay*(i+2), lambda text=t: self.enterText(text)) + with self.qtbot.waitSignal(graphEditView.scene().changed): + self.gsContextMenu(graphEditView, scenePos) + res = None + assert len(graphEditView.scene().nodes) == len(oldNodes) + 1 + for n in graphEditView.scene().nodes: + if n not in oldNodes: + assert res is None + res = graphEditView.scene().nodes[n] + assert res is not None + # hover this item + scenePos = res.nodeGrItem.sceneBoundingRect().center() + self.qtbot.mouseMove(graphEditView.viewport(), QPoint(0,0), delay=self.delay) + self.qtbot.mouseMove(graphEditView.viewport(), graphEditView.mapFromScene(scenePos), delay=self.delay) + # set item selected and deselected again + self.qtbot.mouseClick(graphEditView.viewport(), Qt.LeftButton, pos=graphEditView.mapFromScene(scenePos), + delay=self.delay) + self.qtbot.mouseClick(graphEditView.viewport(), Qt.LeftButton, pos=graphEditView.mapFromScene(scenePos), + delay=self.delay) + return res + + def removeNodeFromGraph(self, graphEditView, node): + """ + Removes a node from the nexxT graph editor + :param graphEditView: the GraphEditorView instance + :param node: the node to be removed + :return: + """ + pos = node.nodeGrItem.sceneBoundingRect().center() + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_REMOVE_NODE)) + QTimer.singleShot(2*self.delay, lambda: self.enterText("")) + self.gsContextMenu(graphEditView, pos) + + def addConnectionToGraphEditor(self, graphEditView, p1, p2): + """ + Adds a connection in the nexxT graph editor + :param graphEditView: the GraphEditorView instance + :param p1: The port to start from + :param p2: The port to end to + :return: + """ + pos1 = graphEditView.mapFromScene(p1.portGrItem.sceneBoundingRect().center()) + pos2 = graphEditView.mapFromScene(p2.portGrItem.sceneBoundingRect().center()) + self.qtbot.mouseMove(graphEditView.viewport(), pos1, delay=self.delay) + self.qtbot.mousePress(graphEditView.viewport(), Qt.LeftButton, pos=pos1, delay=self.delay) + # mouse move event will not be triggered (yet?), see https://bugreports.qt.io/browse/QTBUG-5232 + for i in range(30): + w = i/29 + self.qtbot.mouseMove(graphEditView.viewport(), (pos1*(1-w)+pos2*w), delay=(self.delay+15)//30) + self.qtbot.mouseMove(graphEditView.viewport(), pos2, delay=self.delay) + self.qtbot.mouseRelease(graphEditView.viewport(), Qt.LeftButton, pos=pos2, delay=self.delay) + + def setFilterProperty(self, conf, subConfig, filterName, propName, propVal, expectedVal=None): + """ + Sets a filter property in the configuration gui service. + :param conf: the configuration gui service + :param subConfig: the SubConfiguration instance + :param filterName: the name of the filter + :param propName: the name of the property + :param propVal: the value of the property (which will be entered using enterText) + :param expectedVal: if not None, the new expected value after editing, otherwise propVal will be used as the + expected value. + :return: + """ + idxapp = conf.model.indexOfSubConfig(subConfig) + # search for filter + idxFilter = None + for r in range(conf.model.rowCount(idxapp)): + idxFilter = conf.model.index(r, 0, idxapp) + name = conf.model.data(idxFilter, Qt.DisplayRole) + if name == filterName: + break + else: + idxFilter = None + assert idxFilter is not None + # search for property + idxProp = None + row = None + for r in range(conf.model.rowCount(idxFilter)): + idxProp = conf.model.index(r, 0, idxFilter) + name = conf.model.data(idxProp, Qt.DisplayRole) + if name == propName: + row = r + break + else: + idxProp = None + assert idxProp is not None + assert row is not None + # start the editor by pressing F2 on the property value + idxPropVal = conf.model.index(row, 1, idxFilter) + conf.treeView.scrollTo(idxPropVal) + region = conf.treeView.visualRegionForSelection(QItemSelection(idxPropVal, idxPropVal)) + self.qtbot.mouseMove(conf.treeView.viewport(), pos=region.boundingRect().center(), delay=self.delay) + self.qtbot.mouseClick(conf.treeView.viewport(), Qt.LeftButton, pos=region.boundingRect().center(), delay=self.delay) + self.qtbot.keyClick(conf.treeView.viewport(), Qt.Key_F2, delay=self.delay) + self.aw() + mw = Services.getService("MainWindow") + self.enterText(propVal, mw.findChild(QWidget, "PropertyDelegateEditor")) + self.qtbot.wait(self.delay) + if expectedVal is None: + expectedVal = propVal + assert conf.model.data(idxPropVal, Qt.DisplayRole) == expectedVal + + def getFilterProperty(self, conf, subConfig, filterName, propName): + """ + Sets a filter property in the configuration gui service. + :param conf: the configuration gui service + :param subConfig: the SubConfiguration instance + :param filterName: the name of the filter + :param propName: the name of the property + :return: the current property value + """ + idxapp = conf.model.indexOfSubConfig(subConfig) + # search for filter + idxFilter = None + for r in range(conf.model.rowCount(idxapp)): + idxFilter = conf.model.index(r, 0, idxapp) + name = conf.model.data(idxFilter, Qt.DisplayRole) + if name == filterName: + break + else: + idxFilter = None + assert idxFilter is not None + # search for property + idxProp = None + row = None + for r in range(conf.model.rowCount(idxFilter)): + idxProp = conf.model.index(r, 0, idxFilter) + name = conf.model.data(idxProp, Qt.DisplayRole) + if name == propName: + row = r + break + else: + idxProp = None + assert idxProp is not None + assert row is not None + # start the editor by pressing F2 on the property value + idxPropVal = conf.model.index(row, 1, idxFilter) + return conf.model.data(idxPropVal, Qt.DisplayRole) + + + def getLastLogFrameIdx(self, log): + """ + Convert the last received log line to a frame index (assuming that the PySimpleStaticFilter has been used) + Changed: this is an alias for getCurrentFrameIdx, since there might be some other log messages intervening + + :param log: the logging service + :return: the frame index + """ + self.qtbot.wait(1000) # log may be delayed + return self.getCurrentFrameIdx(log) + + @staticmethod + def getCurrentFrameIdx(log): + """ + Same as getLastLogFrameIdx but searches upwards + :param log: the logging service + :return: the frame index + """ + numRows = log.logWidget.model().rowCount(QModelIndex()) + for row in range(numRows-1,0,-1): + lidx = log.logWidget.model().index(row, 2, QModelIndex()) + lastmsg = log.logWidget.model().data(lidx, Qt.DisplayRole) + if "received: Sample" in lastmsg: + return int(lastmsg.strip().split(" ")[-1]) + + @staticmethod + def assertLogItem(log, expectedLevel, expectedMsg): + found = False + model = log.logWidget.model() + numRows = model.rowCount(QModelIndex()) + for row in range(numRows-1,0,-1): + level = model.data(model.index(row, 1, QModelIndex()), Qt.DisplayRole) + msg = model.data(model.index(row, 2, QModelIndex()), Qt.DisplayRole) + if level == expectedLevel and msg in expectedMsg: + found = True + if not found: + raise RuntimeError("expected message %s:%s not found in log", expectedLevel, expectedMsg) + + @staticmethod + def noWarningsInLog(log, ignore=[]): + """ + assert that there are no warnings logged + :param log: the logging service + :return: + """ + model = log.logWidget.model() + numRows = model.rowCount(QModelIndex()) + for row in range(numRows-1,0,-1): + level = model.data(model.index(row, 1, QModelIndex()), Qt.DisplayRole) + if level not in ["INFO", "DEBUG", "INTERNAL"]: + msg = model.data(model.index(row, 2, QModelIndex()), Qt.DisplayRole) + if not msg in ignore: + raise RuntimeError("Warnings or errors found in log: %s(%s)", level, msg) + + def clickDiscardChanges(self): + """ + Discard the config changes if being asked to. + :return: + """ + self.qtbot.keyClick(None, Qt.Key_Tab, delay=self.delay) + self.qtbot.keyClick(None, Qt.Key_Return, delay=self.delay) + + def startGraphEditor(self, conf, mw, appName, isComposite=False): + """ + Start the graph editor of the given application. + :param conf: the configuration service + :param mw: the main window + :param appName: the name of the application to be edited + :param isComposite: if true, the name is related to a composite filter + :return: the graph editor view + """ + oldChildren = mw.findChildren(GraphEditorView, None) + if isComposite: + app = conf.configuration().compositeFilterByName(appName) + else: + app = conf.configuration().applicationByName(appName) + # start graph editor + self.cmContextMenu(conf, conf.model.indexOfSubConfig(app), 1) + newChildren = mw.findChildren(GraphEditorView, None) + gev = None + for w in newChildren: + if w not in oldChildren: + gev = w + gev.setMinimumSize(QSize(400, 350)) + return gev + + def select(self, graphEditView, nodes): + """ + Select the given nodes in the graph editor + :param graphEditView: The graph editor instances + :param nodes: the nodes to be selected + :return: + """ + pos = nodes[0].nodeGrItem.sceneBoundingRect().center() + self.qtbot.mouseClick(graphEditView.viewport(), Qt.LeftButton, pos=graphEditView.mapFromScene(pos), + delay=self.delay) + for node in nodes[1:]: + node.nodeGrItem.setSelected(True) + +class BasicTest(GuiTestBase): + """ + Concrete instance for the test_basic(...) test + """ + def __init__(self, qtbot, xvfb, keep_open, delay, tmpdir): + super().__init__(qtbot, xvfb, keep_open, delay, tmpdir) + + def _first(self): + conf = None + mw = None + try: + mw = Services.getService("MainWindow") + mw.resize(1980,1080) + conf = Services.getService("Configuration") + rec = Services.getService("RecordingControl") + playback = Services.getService("PlaybackControl") + log = Services.getService("Logging") + idxComposites = conf.model.index(0, 0) + idxApplications = conf.model.index(1, 0) + # add application + conf.treeView.setMinimumSize(QSize(300,300)) + conf.treeView.scrollTo(idxApplications) + region = conf.treeView.visualRegionForSelection(QItemSelection(idxApplications, idxApplications)) + self.qtbot.mouseMove(conf.treeView.viewport(), region.boundingRect().center(), delay=self.delay) + # mouse click does not trigger context menu :( + #qtbot.mouseClick(conf.treeView.viewport(), Qt.RightButton, pos=region.boundingRect().center()) + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADD_APPLICATION)) + conf._execTreeViewContextMenu(region.boundingRect().center()) + app = conf.configuration().applicationByName("application") + # start graph editor + gev = self.startGraphEditor(conf, mw, "application") + self.qtbot.wait(self.delay) + # create 3 nodes: CSimpleSource, PySimpleStaticFilter, HDF5Writer + n1 = self.addNodeToGraphEditor(gev, QPoint(20,20), + CM_FILTER_LIBRARY, CM_FILTER_LIBRARY_TESTS, CM_FILTER_LIBRARY_TESTS_NEXXT, + CM_FILTER_LIBRARY_CSIMPLESOURCE) + self.removeNodeFromGraph(gev, n1) + n1 = self.addNodeToGraphEditor(gev, QPoint(20,20), + CM_FILTER_LIBRARY, CM_FILTER_LIBRARY_TESTS, CM_FILTER_LIBRARY_TESTS_NEXXT, + CM_FILTER_LIBRARY_CSIMPLESOURCE) + n2 = self.addNodeToGraphEditor(gev, QPoint(20,80), + CM_FILTER_LIBRARY, CM_FILTER_LIBRARY_TESTS, CM_FILTER_LIBRARY_TESTS_NEXXT, + CM_FILTER_LIBRARY_PYSIMPLESTATICFILTER) + n3 = self.addNodeToGraphEditor(gev, QPoint(20,140), + CM_FILTER_LIBRARY, CM_FILTER_LIBRARY_HARDDISK, CM_FILTER_LIBRARY_HDF5WRITER) + n4 = self.addNodeToGraphEditor(gev, QPoint(-120,-60), CM_FILTER_FROM_PYMOD, + "nexxT.tests.interface.SimpleStaticFilter", "SimpleView") + n5 = self.addNodeToGraphEditor(gev, QPoint(-120, 140), CM_FILTER_FROM_PYMOD, + "nexxT.tests.interface.SimpleStaticFilter", "SimpleView") + n6 = self.addNodeToGraphEditor(gev, QPoint(20, -60), CM_FILTER_FROM_PYMOD, + "nexxT.tests.interface.SimpleStaticFilter", "SimpleView") + # auto layout + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_AUTOLAYOUT)) + self.gsContextMenu(gev, QPoint(-120,40)) + self.qtbot.wait(self.delay) + # rename n4 + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_RENAME_NODE)) + QTimer.singleShot(self.delay*2, lambda: self.enterText("view_source")) + #print(n4, n4.nodeGrItem.sceneBoundingRect().center()) + self.gsContextMenu(gev, n4.nodeGrItem.sceneBoundingRect().center()) + # rename n5 + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_RENAME_NODE)) + QTimer.singleShot(self.delay*2, lambda: self.enterText("view_filter")) + #print(n5, n5.nodeGrItem.sceneBoundingRect().center()) + self.gsContextMenu(gev, n5.nodeGrItem.sceneBoundingRect().center()) + # rename n6 + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_RENAME_NODE)) + QTimer.singleShot(self.delay*2, lambda: self.enterText("view_filter2")) + #print(n6, n6.nodeGrItem.sceneBoundingRect().center()) + self.gsContextMenu(gev, n6.nodeGrItem.sceneBoundingRect().center()) + # setup dynamic input port of HDF5Writer + n3p = n3.nodeGrItem.sceneBoundingRect().center() + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADDDYNINPORT)) + QTimer.singleShot(self.delay*2, lambda: self.enterText("CSimpleSource_out")) + self.gsContextMenu(gev, n3p) + # rename the dynamic port + pp = n3.inPortItems[0].portGrItem.sceneBoundingRect().center() + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_RENAMEDYNPORT)) + QTimer.singleShot(2*self.delay, lambda: self.enterText("xxx")) + self.gsContextMenu(gev, pp) + # remove the dynamic port + pp = n3.inPortItems[0].portGrItem.sceneBoundingRect().center() + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_REMOVEDYNPORT)) + QTimer.singleShot(2*self.delay, lambda: self.enterText("")) + self.gsContextMenu(gev, pp) + # setup dynamic input port of HDF5Writer + n3p = n3.nodeGrItem.sceneBoundingRect().center() + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADDDYNINPORT)) + QTimer.singleShot(self.delay*2, lambda: self.enterText("CSimpleSource_out")) + self.gsContextMenu(gev, n3p) + # set thread of souurce + n1p = n1.nodeGrItem.sceneBoundingRect().center() + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_SETTHREAD)) + QTimer.singleShot(self.delay*2, lambda: self.enterText("source_thread")) + self.gsContextMenu(gev, n1p) + # set thread of HDF5Writer + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_SETTHREAD)) + QTimer.singleShot(self.delay*2, lambda: self.enterText("writer_thread")) + self.gsContextMenu(gev, n3p) + # connect the ports + self.addConnectionToGraphEditor(gev, n1.outPortItems[0], n2.inPortItems[0]) + self.addConnectionToGraphEditor(gev, n3.inPortItems[0], n1.outPortItems[0]) + # set frequency to 10 + self.setFilterProperty(conf, app, "CSimpleSource", "frequency", "10.0") + # copy a part of the app to a composite filter + self.select(gev, [n1,n2]) + self.qtbot.keyClick(gev.viewport(), Qt.Key_X, Qt.ControlModifier, delay=self.delay) + # add composite + conf.treeView.scrollTo(idxComposites) + region = conf.treeView.visualRegionForSelection(QItemSelection(idxComposites, idxComposites)) + self.qtbot.mouseMove(conf.treeView.viewport(), region.boundingRect().center(), delay=self.delay) + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADDCOMPOSITE)) + conf._execTreeViewContextMenu(region.boundingRect().center()) + self.qtbot.wait(self.delay) + gevc = self.startGraphEditor(conf, mw, "composite", True) + assert gevc != gev + self.qtbot.wait(self.delay) + self.qtbot.keyClick(gevc.viewport(), Qt.Key_V, Qt.ControlModifier, delay=self.delay) + gevc_in = gevc.scene().nodes["CompositeInput"] + gevc_out = gevc.scene().nodes["CompositeOutput"] + n1 = gevc.scene().nodes["CSimpleSource"] + n2 = gevc.scene().nodes["PySimpleStaticFilter"] + # setup dynamic port of gevc_in + gevc_inp = gevc_in.nodeGrItem.sceneBoundingRect().center() + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADDDYNOUTPORT)) + QTimer.singleShot(self.delay*2, lambda: self.enterText("comp_in")) + self.gsContextMenu(gevc, gevc_inp) + # setup dynamic ports of gevc_out + gevc_outp = gevc_out.nodeGrItem.sceneBoundingRect().center() + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADDDYNINPORT)) + QTimer.singleShot(self.delay*2, lambda: self.enterText("source")) + self.gsContextMenu(gevc, gevc_outp) + gevc_outp = gevc_out.nodeGrItem.sceneBoundingRect().center() + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADDDYNINPORT)) + QTimer.singleShot(self.delay*2, lambda: self.enterText("filter")) + self.gsContextMenu(gevc, gevc_outp) + # setup connections + self.addConnectionToGraphEditor(gevc, gevc_out.inPortItems[0], n1.outPortItems[0]) + self.addConnectionToGraphEditor(gevc, gevc_out.inPortItems[1], n2.outPortItems[0]) + # add composite filter to gev + comp = self.addNodeToGraphEditor(gev, QPoint(20,20), CM_FILTER_FROM_COMPOSITE, "composite") + nexxT.shiboken.delete(gevc.parent()) + self.qtbot.wait(self.delay) + # auto layout + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_AUTOLAYOUT)) + self.gsContextMenu(gev, QPoint(-120,40)) + self.qtbot.wait(self.delay) + self.addConnectionToGraphEditor(gev, comp.outPortItems[0], n3.inPortItems[0]) + # add visualization filters + self.addConnectionToGraphEditor(gev, comp.outPortItems[0], n4.inPortItems[0]) + self.addConnectionToGraphEditor(gev, comp.outPortItems[1], n5.inPortItems[0]) + self.addConnectionToGraphEditor(gev, comp.outPortItems[1], n6.inPortItems[0]) + # set captions + self.setFilterProperty(conf, app, "view_source", "caption", "view[0,0]") + self.setFilterProperty(conf, app, "view_filter", "caption", "view[1,0]") + self.setFilterProperty(conf, app, "view_filter2", "caption", "filter2") + # activate and initialize the application + with self.qtbot.waitSignal(conf.configuration().appActivated): + conf.configuration().activate("application") + self.aw() + self.qtbot.keyClick(self.aw(), Qt.Key_C, Qt.AltModifier, delay=self.delay) + self.activateContextMenu(CONFIG_MENU_INITIALIZE) + rec.dockWidget.raise_() + # application runs for 2 seconds + self.qtbot.wait(2000) + # set the folder for the recording service and start recording + QTimer.singleShot(self.delay, lambda: self.enterText(str(self.tmpdir))) + rec.actSetDir.trigger() + recStartFrame = self.getCurrentFrameIdx(log) + rec.actStart.trigger() + # record for 2 seconds + self.qtbot.wait(2000) + # stop recording + recStopFrame = self.getCurrentFrameIdx(log) + rec.actStop.trigger() + assert recStopFrame >= recStartFrame + 10 + self.qtbot.wait(2000) + # de-initialize application + self.qtbot.keyClick(self.aw(), Qt.Key_C, Qt.AltModifier, delay=self.delay) + self.activateContextMenu(CONFIG_MENU_DEINITIALIZE) + # check that the last log message is from the SimpleStaticFilter and it should have received more than 60 + # samples + assert self.getLastLogFrameIdx(log) >= 60 + # save the configuration file + prjfile = self.tmpdir / "test_project.json" + h5file = list(Path(self.tmpdir).glob("*.h5")) + assert len(h5file) == 1 + h5file = h5file[0] + QTimer.singleShot(self.delay, lambda: self.enterText(str(prjfile))) + conf.actSave.trigger() + gevc = self.startGraphEditor(conf, mw, "composite", True) + self.removeNodeFromGraph(gevc, gevc.scene().nodes["PySimpleStaticFilter"]) + # load the confiugration file + assert conf.configuration().dirty() + QTimer.singleShot(self.delay, lambda: self.clickDiscardChanges()) + QTimer.singleShot(2*self.delay, lambda: self.enterText(str(prjfile))) + conf.actLoad.trigger() + + # add another application for offline use + conf.configuration().addNewApplication() + # start and delete a graph editor for the old application + gev = self.startGraphEditor(conf, mw, "application") + self.qtbot.wait(self.delay) + nexxT.shiboken.delete(gev.parent()) + self.qtbot.wait(self.delay) + # start the editor for the new application + gev = self.startGraphEditor(conf, mw, "application_2") + # start graph editor + self.qtbot.mouseMove(gev, pos=QPoint(20,20), delay=self.delay) + # create 2 nodes: HDF5Reader and PySimpleStaticFilter + n1 = self.addNodeToGraphEditor(gev, QPoint(20,80), CM_FILTER_LIBRARY, CM_FILTER_LIBRARY_HARDDISK, + CM_FILTER_LIBRARY_HDF5READER) + n2 = self.addNodeToGraphEditor(gev, QPoint(20,80), CM_FILTER_LIBRARY, CM_FILTER_LIBRARY_TESTS, + CM_FILTER_LIBRARY_TESTS_NEXXT, CM_FILTER_LIBRARY_PYSIMPLESTATICFILTER) + # auto layout + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_AUTOLAYOUT)) + self.gsContextMenu(gev, QPoint(1,1)) + # setup dynamic output port of HDF5Reader + n1p = n1.nodeGrItem.sceneBoundingRect().center() + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADDDYNOUTPORT)) + QTimer.singleShot(self.delay*2, lambda: self.enterText("yyy")) + self.gsContextMenu(gev, n1p) + # rename the dynamic port + pp = n1.outPortItems[0].portGrItem.sceneBoundingRect().center() + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_RENAMEDYNPORT)) + QTimer.singleShot(2*self.delay, lambda: self.enterText("xxx")) + self.gsContextMenu(gev, pp) + self.qtbot.wait(self.delay) + # remove the dynamic port + pp = n1.outPortItems[0].portGrItem.sceneBoundingRect().center() + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_REMOVEDYNPORT)) + QTimer.singleShot(2*self.delay, lambda: self.enterText("")) + self.gsContextMenu(gev, pp) + # setup dynamic ports of HDF5Reader using the suggest ports feature + n1p = n1.nodeGrItem.sceneBoundingRect().center() + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_SUGGEST_DYNPORTS)) + QTimer.singleShot(self.delay*2, lambda: self.enterText(str(h5file))) + QTimer.singleShot(self.delay*4, lambda: self.enterText("")) + self.gsContextMenu(gev, n1p) + # set thread of HDF5Writer + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_SETTHREAD)) + QTimer.singleShot(self.delay*2, lambda: self.enterText("reader_thread")) + self.gsContextMenu(gev, n1p) + # connect the ports + self.addConnectionToGraphEditor(gev, n1.outPortItems[0], n2.inPortItems[0]) + # activate and initialize the application + with self.qtbot.waitSignal(conf.configuration().appActivated): + conf.configuration().activate("application_2") + self.qtbot.keyClick(self.aw(), Qt.Key_C, Qt.AltModifier, delay=self.delay) + self.activateContextMenu(CONFIG_MENU_INITIALIZE) + # turn off load monitoring + self.qtbot.keyClick(self.aw(), Qt.Key_O, Qt.AltModifier, delay=self.delay) + self.qtbot.keyClick(None, Qt.Key_Return, delay=self.delay) + self.qtbot.wait(self.delay) + # turn on load monitoring + self.qtbot.keyClick(self.aw(), Qt.Key_O, Qt.AltModifier, delay=self.delay) + self.qtbot.keyClick(None, Qt.Key_Return, delay=self.delay) + self.qtbot.wait(self.delay) + # turn on port profiling + self.qtbot.keyClick(self.aw(), Qt.Key_O, Qt.AltModifier, delay=self.delay) + self.qtbot.keyClick(None, Qt.Key_Down, delay=self.delay) + self.qtbot.keyClick(None, Qt.Key_Return, delay=self.delay) + self.qtbot.wait(self.delay) + # select file in browser + playback.dockWidget.raise_() + self.qtbot.keyClick(playback.browser._lineedit, Qt.Key_A, Qt.ControlModifier) + self.qtbot.keyClicks(playback.browser._lineedit, str(h5file)) + self.qtbot.keyClick(playback.browser._lineedit, Qt.Key_Return) + # wait until action start is enabled + self.qtbot.waitUntil(playback.actStart.isEnabled) + # play until finished + playback.actStart.trigger() + self.qtbot.waitUntil(lambda: not playback.actStart.isEnabled()) + self.qtbot.waitUntil(lambda: not playback.actPause.isEnabled(), timeout=10000) + # check that the last log message is from the SimpleStaticFilter and it should be in the range of 40-50 + lastFrame = self.getLastLogFrameIdx(log) + assert recStopFrame-10 <= lastFrame <= recStopFrame+10 + playback.actStepBwd.trigger() + self.qtbot.wait(self.delay) + currFrame = self.getLastLogFrameIdx(log) + assert currFrame == lastFrame - 1 + playback.actStepFwd.trigger() + self.qtbot.wait(self.delay) + assert self.getLastLogFrameIdx(log) == lastFrame + playback.actSeekBegin.trigger() + firstFrame = self.getLastLogFrameIdx(log) + assert recStartFrame-10 <= firstFrame <= recStartFrame+10 + playback.actSeekEnd.trigger() + assert self.getLastLogFrameIdx(log) == lastFrame + # de-initialize application + self.qtbot.keyClick(self.aw(), Qt.Key_C, Qt.AltModifier, delay=self.delay) + self.activateContextMenu(CONFIG_MENU_DEINITIALIZE) + + conf.actSave.trigger() + self.qtbot.wait(1000) + self.noWarningsInLog(log) + finally: + if not self.keep_open: + if conf.configuration().dirty(): + QTimer.singleShot(self.delay, self.clickDiscardChanges) + mw.close() + + def _second(self): + conf = None + mw = None + try: + # load last config + mw = Services.getService("MainWindow") + conf = Services.getService("Configuration") + playback = Services.getService("PlaybackControl") + log = Services.getService("Logging") + # load recent config + self.qtbot.keyClick(self.aw(), Qt.Key_R, Qt.ControlModifier, delay=self.delay) + # this is the offline config + appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("application_2")) + self.cmContextMenu(conf, appidx, CM_INIT_APP) + self.qtbot.wait(1000) + assert not playback.actPause.isEnabled() + self.cmContextMenu(conf, appidx, CM_INIT_APP_AND_OPEN, 0) + self.qtbot.wait(1000) + assert not playback.actPause.isEnabled() + playback.actStepFwd.trigger() + self.qtbot.wait(1000) + firstFrame = self.getLastLogFrameIdx(log) + self.cmContextMenu(conf, appidx, CM_INIT_APP_AND_PLAY, 0) + self.qtbot.wait(1000) + self.qtbot.waitUntil(playback.actStart.isEnabled, timeout=10000) + lastFrame = self.getLastLogFrameIdx(log) + assert lastFrame >= firstFrame + 10 + # this is the online config + appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("application")) + self.cmContextMenu(conf, appidx, CM_INIT_APP) + self.qtbot.wait(2000) + self.cmContextMenu(conf, appidx, CM_INIT_APP_AND_OPEN, 0) + self.qtbot.wait(2000) + self.cmContextMenu(conf, appidx, CM_INIT_APP_AND_PLAY, 0) + self.qtbot.wait(2000) + self.noWarningsInLog(log, ignore=[ + "did not find a playback device taking control", + "The inter-thread connection is set to stopped mode; data sample discarded.", + "operation stop happening during receiveAsync's processEvents. This shouldn't be happening."]) + finally: + if not self.keep_open: + if conf.configuration().dirty(): + QTimer.singleShot(self.delay, self.clickDiscardChanges) + mw.close() + + def test_first(self): + """ + first start of nexxT in a clean environment, click through a pretty exhaustive scenario. + :return: + """ + QTimer.singleShot(self.delay, self._first) + startNexT(None, None, [], [], True) + + def test_second(self): + """ + second start of nexxT, make sure that the history is saved correctly + :return: + """ + QTimer.singleShot(self.delay, self._second) + startNexT(None, None, [], [], True) + +@pytest.mark.gui +@pytest.mark.parametrize("delay", ["rand"]) +def test_basic(qtbot, xvfb, keep_open, delay, tmpdir): + if delay == "rand": + import random + delay = random.randint(100, 300) + test = BasicTest(qtbot, xvfb, keep_open, delay, tmpdir) + test.test_first() + test.test_second() + +class PropertyTest(GuiTestBase): + """ + Concrete test class for the test_property test case + """ + def __init__(self, qtbot, xvfb, keep_open, delay, tmpdir): + super().__init__(qtbot, xvfb, keep_open, delay, tmpdir) + + def _properties(self): + conf = None + mw = None + thefilter_py = (Path(self.tmpdir) / "thefilter.py") + thefilter_py.write_text( +""" +from nexxT.interface import Filter + +class TheFilter(Filter): + def __init__(self, env): + super().__init__(False, False, env) + pc = self.propertyCollection() + pc.defineProperty("bool_prop", False, "a boolean") + pc.defineProperty("unbound_float", 7., "an unbound float") + pc.defineProperty("low_bound_float", 7., "a low bound float", dict(min=-3)) + pc.defineProperty("high_bound_float", 7., "a high bound float", dict(max=123)) + pc.defineProperty("bound_float", 7., "a bound float", dict(min=6, max=1203)) + pc.defineProperty("unbound_int", 7, "an unbound integer") + pc.defineProperty("low_bound_int", 7, "a low bound integer", dict(min=-3)) + pc.defineProperty("high_bound_int", 7, "a high bound integer", dict(max=123)) + pc.defineProperty("bound_int", 7, "a bound integer", dict(min=6, max=1203)) + pc.defineProperty("string", "str", "an arbitrary string") + pc.defineProperty("enum", "v1", "an enum", dict(enum=["v1", "v2", "v3"])) +""" + ) + try: + # load last config + mw = Services.getService("MainWindow") + conf = Services.getService("Configuration") + idxComposites = conf.model.index(0, 0) + idxApplications = conf.model.index(1, 0) + # add application + conf.treeView.setMinimumSize(QSize(300,300)) + conf.treeView.scrollTo(idxApplications) + region = conf.treeView.visualRegionForSelection(QItemSelection(idxApplications, idxApplications)) + self.qtbot.mouseMove(conf.treeView.viewport(), region.boundingRect().center(), delay=self.delay) + # mouse click does not trigger context menu :( + #qtbot.mouseClick(conf.treeView.viewport(), Qt.RightButton, pos=region.boundingRect().center()) + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADD_APPLICATION)) + conf._execTreeViewContextMenu(region.boundingRect().center()) + app = conf.configuration().applicationByName("application") + # start graph editor + gev = self.startGraphEditor(conf, mw, "application") + self.qtbot.wait(self.delay) + # create a node "TheFilter" + the_filter = self.addNodeToGraphEditor(gev, QPoint(20,20), + CM_FILTER_FROM_FILE, str(thefilter_py), "TheFilter") + self.qtbot.keyClick(self.aw(), Qt.Key_Return, delay=self.delay) + self.qtbot.keyClick(None, Qt.Key_Return, delay=self.delay) + logger.info("Filter: %s", repr(the_filter)) + self.setFilterProperty(conf, app, "TheFilter", "bool_prop", [Qt.Key_Down, Qt.Key_Return], "True") + self.setFilterProperty(conf, app, "TheFilter", "bool_prop", [Qt.Key_Down, Qt.Key_Return], "True") + self.setFilterProperty(conf, app, "TheFilter", "bool_prop", [Qt.Key_Up, Qt.Key_Return], "False") + self.setFilterProperty(conf, app, "TheFilter", "bool_prop", [Qt.Key_Up, Qt.Key_Return], "False") + self.setFilterProperty(conf, app, "TheFilter", "unbound_float", "3.4028235e+38") + self.setFilterProperty(conf, app, "TheFilter", "unbound_float", "-3.4028235e+38") + self.setFilterProperty(conf, app, "TheFilter", "low_bound_float", "3.4028235e+38") + self.setFilterProperty(conf, app, "TheFilter", "low_bound_float", "-3.4028235e+38", "-3.0") + self.setFilterProperty(conf, app, "TheFilter", "low_bound_float", "-4", "-3.0") + self.setFilterProperty(conf, app, "TheFilter", "low_bound_float", "-3", "-3.0") + self.setFilterProperty(conf, app, "TheFilter", "high_bound_float", "-3.4028235e+38") + self.setFilterProperty(conf, app, "TheFilter", "high_bound_float", "3.4028235e+38", "123.0") + self.setFilterProperty(conf, app, "TheFilter", "high_bound_float", "124", "123.0") + self.setFilterProperty(conf, app, "TheFilter", "high_bound_float", "123", "123.0") + self.setFilterProperty(conf, app, "TheFilter", "bound_float", "-9", "6.0") + self.setFilterProperty(conf, app, "TheFilter", "bound_float", "5", "6.0") + self.setFilterProperty(conf, app, "TheFilter", "bound_float", "6.0") + self.setFilterProperty(conf, app, "TheFilter", "bound_float", "1204", "1203.0") + self.setFilterProperty(conf, app, "TheFilter", "bound_float", "1203", "1203.0") + self.setFilterProperty(conf, app, "TheFilter", "unbound_int", "2147483647") + self.setFilterProperty(conf, app, "TheFilter", "unbound_int", "-2147483648") + self.setFilterProperty(conf, app, "TheFilter", "low_bound_int", "2147483647") + self.setFilterProperty(conf, app, "TheFilter", "low_bound_int", "-2147483648", "-2") + self.setFilterProperty(conf, app, "TheFilter", "low_bound_int", "-4", "-2") + self.setFilterProperty(conf, app, "TheFilter", "low_bound_int", "-3") + self.setFilterProperty(conf, app, "TheFilter", "high_bound_int", "-2147483648") + self.setFilterProperty(conf, app, "TheFilter", "high_bound_int", "2147483647", "21") + self.setFilterProperty(conf, app, "TheFilter", "high_bound_int", "124", "12") + self.setFilterProperty(conf, app, "TheFilter", "high_bound_int", "123") + self.setFilterProperty(conf, app, "TheFilter", "bound_int", "-9", "9") + self.setFilterProperty(conf, app, "TheFilter", "bound_int", "5", "9") + self.setFilterProperty(conf, app, "TheFilter", "bound_int", "6") + self.setFilterProperty(conf, app, "TheFilter", "bound_int", "1204", "120") + self.setFilterProperty(conf, app, "TheFilter", "bound_int", "1203") + self.setFilterProperty(conf, app, "TheFilter", "string", "", "str") + self.setFilterProperty(conf, app, "TheFilter", "string", [Qt.Key_Backspace], "") + self.setFilterProperty(conf, app, "TheFilter", "string", "an arbitrary value") + # the enum editor is a combo box, so the text editing does not work here. + self.setFilterProperty(conf, app, "TheFilter", "enum", [Qt.Key_Down, Qt.Key_Return], "v2") + self.setFilterProperty(conf, app, "TheFilter", "enum", [Qt.Key_Down, Qt.Key_Return], "v3") + self.setFilterProperty(conf, app, "TheFilter", "enum", [Qt.Key_Down, Qt.Key_Return], "v3") + self.setFilterProperty(conf, app, "TheFilter", "enum", [Qt.Key_Up, Qt.Key_Return], "v2") + self.setFilterProperty(conf, app, "TheFilter", "enum", [Qt.Key_Up, Qt.Key_Return], "v1") + self.setFilterProperty(conf, app, "TheFilter", "enum", [Qt.Key_Up, Qt.Key_Return], "v1") + finally: + if not self.keep_open: + if conf.configuration().dirty(): + QTimer.singleShot(self.delay, self.clickDiscardChanges) + mw.close() + + def _dynamic_properties(self): + conf = None + mw = None + thefilter_py = (Path(self.tmpdir) / "thedynfilter.py") + thefilter_py.write_text( +""" +from nexxT.interface import Filter + +class TheDynFilter(Filter): + def __init__(self, env): + super().__init__(True, True, env) + + def onInit(self): + pc = self.propertyCollection() + din = self.getDynamicInputPorts() + dout = self.getDynamicOutputPorts() + pc.defineProperty("enum_input_ports", "(none)", "help", + dict(enum=["(none)"] +[p.name() for p in din], ignoreInconsistentOptions=True)) + pc.defineProperty("enum_output_ports", "(none)", "help", + dict(enum=["(none)"] +[p.name() for p in dout], ignoreInconsistentOptions=True)) +""" + ) + try: + # load last config + mw = Services.getService("MainWindow") + conf = Services.getService("Configuration") + idxComposites = conf.model.index(0, 0) + idxApplications = conf.model.index(1, 0) + # add application + conf.treeView.setMinimumSize(QSize(300, 300)) + conf.treeView.scrollTo(idxApplications) + region = conf.treeView.visualRegionForSelection(QItemSelection(idxApplications, idxApplications)) + self.qtbot.mouseMove(conf.treeView.viewport(), region.boundingRect().center(), delay=self.delay) + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADD_APPLICATION)) + conf._execTreeViewContextMenu(region.boundingRect().center()) + app = conf.configuration().applicationByName("application") + # start graph editor + gev = self.startGraphEditor(conf, mw, "application") + self.qtbot.wait(self.delay) + # create a node "TheFilter" + the_filter = self.addNodeToGraphEditor(gev, QPoint(20, 20), + CM_FILTER_FROM_FILE, str(thefilter_py), "TheDynFilter") + self.qtbot.keyClick(self.aw(), Qt.Key_Return, delay=self.delay) + self.qtbot.keyClick(None, Qt.Key_Return, delay=self.delay) + + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADDDYNINPORT)) + QTimer.singleShot(self.delay * 2, lambda: self.enterText("input_port_1")) + self.gsContextMenu(gev, the_filter.nodeGrItem.sceneBoundingRect().center()) + + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADDDYNINPORT)) + QTimer.singleShot(self.delay * 2, lambda: self.enterText("input_port_2")) + self.gsContextMenu(gev, the_filter.nodeGrItem.sceneBoundingRect().center()) + + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADDDYNOUTPORT)) + QTimer.singleShot(self.delay * 2, lambda: self.enterText("output_port")) + self.gsContextMenu(gev, the_filter.nodeGrItem.sceneBoundingRect().center()) + + logger.info("Filter: %s", repr(the_filter)) + self.setFilterProperty(conf, app, "TheDynFilter", "enum_input_ports", + [Qt.Key_Down, Qt.Key_Return], "input_port_1") + self.setFilterProperty(conf, app, "TheDynFilter", "enum_input_ports", + [Qt.Key_Down, Qt.Key_Return], "input_port_2") + self.setFilterProperty(conf, app, "TheDynFilter", "enum_output_ports", + [Qt.Key_Down, Qt.Key_Return], "output_port") + + cfgfile = str((Path(self.tmpdir) / "dynprops.json").absolute()) + QTimer.singleShot(self.delay, lambda: self.enterText(cfgfile)) + conf.actSave.trigger() + + logger.info("saved application") + + QTimer.singleShot(self.delay, lambda: self.enterText(cfgfile)) + conf.actLoad.trigger() + + logger.info("loaded application") + app = conf.configuration().applicationByName("application") + assert self.getFilterProperty(conf, app, "TheDynFilter", "enum_input_ports") == "input_port_2" + assert self.getFilterProperty(conf, app, "TheDynFilter", "enum_output_ports") == "output_port" + + + finally: + if not self.keep_open: + if conf.configuration().dirty(): + QTimer.singleShot(self.delay, self.clickDiscardChanges) + mw.close() + + def test(self): + """ + test property editing in config editor + :return: + """ + QTimer.singleShot(self.delay, self._properties) + startNexT(None, None, [], [], True) + + def test_dyn(self): + """ + test property editing in config editor + :return: + """ + QTimer.singleShot(self.delay, self._dynamic_properties) + startNexT(None, None, [], [], True) + +@pytest.mark.gui +@pytest.mark.parametrize("delay", [300]) +def test_properties(qtbot, xvfb, keep_open, delay, tmpdir): + test = PropertyTest(qtbot, xvfb, keep_open, delay, tmpdir) + test.test() + +@pytest.mark.gui +@pytest.mark.parametrize("delay", [300]) +def test_dyn_properties(qtbot, xvfb, keep_open, delay, tmpdir): + test = PropertyTest(qtbot, xvfb, keep_open, delay, tmpdir) + test.test_dyn() + +class GuiStateTest(GuiTestBase): + """ + Concrete test class for the guistate test case + """ + def __init__(self, qtbot, xvfb, keep_open, delay, tmpdir): + super().__init__(qtbot, xvfb, keep_open, delay, tmpdir) + self.prjfile = self.tmpdir / "test_guistate.json" + self.guistatefile = self.tmpdir / "test_guistate.json.guistate" + + def getMdiWindow(self): + mw = Services.getService("MainWindow") + assert len(mw.managedSubplots) == 1 + title = list(mw.managedSubplots.keys())[0] + return mw.managedSubplots[title]["mdiSubWindow"] + + def _stage0(self): + conf = None + mw = None + try: + # load last config + mw = Services.getService("MainWindow") + conf = Services.getService("Configuration") + idxApplications = conf.model.index(1, 0) + # add application + conf.treeView.setMinimumSize(QSize(300,300)) + conf.treeView.scrollTo(idxApplications) + region = conf.treeView.visualRegionForSelection(QItemSelection(idxApplications, idxApplications)) + self.qtbot.mouseMove(conf.treeView.viewport(), region.boundingRect().center(), delay=self.delay) + # mouse click does not trigger context menu :( + #qtbot.mouseClick(conf.treeView.viewport(), Qt.RightButton, pos=region.boundingRect().center()) + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADD_APPLICATION)) + conf._execTreeViewContextMenu(region.boundingRect().center()) + app = conf.configuration().applicationByName("application") + # start graph editor + gev = self.startGraphEditor(conf, mw, "application") + self.qtbot.wait(self.delay) + # create a visualization node + pysimpleview = self.addNodeToGraphEditor(gev, QPoint(20,20), + CM_FILTER_LIBRARY, CM_FILTER_LIBRARY_TESTS, + CM_FILTER_LIBRARY_TESTS_NEXXT, CM_FILTER_LIBRARY_PYSIMPLEVIEW) + self.qtbot.keyClick(self.aw(), Qt.Key_Return, delay=self.delay) + self.qtbot.keyClick(self.aw(), Qt.Key_Return, delay=self.delay) + # save the configuration file + QTimer.singleShot(self.delay, lambda: self.enterText(str(self.prjfile))) + conf.actSave.trigger() + self.prjfile_contents = self.prjfile.read_text("utf-8") + assert not self.guistatefile.exists() + # initialize the application, window is shown the first time + appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("application")) + self.cmContextMenu(conf, appidx, CM_INIT_APP) + self.qtbot.wait(1000) + self.getMdiWindow().move(QPoint(37, 63)) + self.qtbot.wait(1000) + self.mdigeom = self.getMdiWindow().geometry() + # de-initialize application + self.qtbot.keyClick(self.aw(), Qt.Key_C, Qt.AltModifier, delay=self.delay) + self.activateContextMenu(CONFIG_MENU_DEINITIALIZE) + assert not self.guistatefile.exists() + finally: + if not self.keep_open: + if conf.configuration().dirty(): + QTimer.singleShot(self.delay, self.clickDiscardChanges) + mw.close() + + def _stage1(self): + conf = None + mw = None + try: + # load last config + mw = Services.getService("MainWindow") + conf = Services.getService("Configuration") + idxApplications = conf.model.index(1, 0) + # load recent config + self.qtbot.keyClick(self.aw(), Qt.Key_R, Qt.ControlModifier, delay=self.delay) + appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("application")) + self.cmContextMenu(conf, appidx, CM_INIT_APP) + self.qtbot.wait(1000) + assert self.mdigeom == self.getMdiWindow().geometry() + # de-initialize application + self.qtbot.keyClick(self.aw(), Qt.Key_C, Qt.AltModifier, delay=self.delay) + self.activateContextMenu(CONFIG_MENU_DEINITIALIZE) + finally: + if not self.keep_open: + if conf.configuration().dirty(): + QTimer.singleShot(self.delay, self.clickDiscardChanges) + mw.close() + + def _stage2(self): + conf = None + mw = None + try: + # load last config + mw = Services.getService("MainWindow") + conf = Services.getService("Configuration") + idxApplications = conf.model.index(1, 0) + # load recent config + self.qtbot.keyClick(self.aw(), Qt.Key_R, Qt.ControlModifier, delay=self.delay) + appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("application")) + self.cmContextMenu(conf, appidx, CM_INIT_APP) + self.qtbot.wait(1000) + # should be moved to default location + assert self.mdigeom != self.getMdiWindow().geometry() + self.getMdiWindow().move(QPoint(42, 51)) + self.qtbot.wait(1000) + self.mdigeom = self.getMdiWindow().geometry() + # because the gui state is not correctly saved when an application is active, the action is disabled in + # active state + assert not conf.actSaveWithGuiState.isEnabled() + # de-initialize application + self.qtbot.keyClick(self.aw(), Qt.Key_C, Qt.AltModifier, delay=self.delay) + self.activateContextMenu(CONFIG_MENU_DEINITIALIZE) + self.qtbot.wait(self.delay) + # action should be enabled in non-active state + assert conf.actSaveWithGuiState.isEnabled() + conf.actSaveWithGuiState.trigger() + finally: + if not self.keep_open: + if conf.configuration().dirty(): + QTimer.singleShot(self.delay, self.clickDiscardChanges) + mw.close() + + def _stage3(self): + conf = None + mw = None + try: + # load last config + mw = Services.getService("MainWindow") + conf = Services.getService("Configuration") + idxApplications = conf.model.index(1, 0) + # load recent config + self.qtbot.keyClick(self.aw(), Qt.Key_R, Qt.ControlModifier, delay=self.delay) + appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("application")) + self.cmContextMenu(conf, appidx, CM_INIT_APP) + self.qtbot.wait(1000) + # should be moved to default location + self.getMdiWindow().move(QPoint(17, 22)) + self.qtbot.wait(1000) + self.mdigeom = self.getMdiWindow().geometry() + #self.qtbot.keyClick(self.aw(), Qt.Key_C, Qt.AltModifier, delay=self.delay) + #self.activateContextMenu(CONFIG_MENU_DEINITIALIZE) + # reload the config + self.qtbot.keyClick(self.aw(), Qt.Key_R, Qt.ControlModifier, delay=self.delay) + self.qtbot.wait(1000) + appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("application")) + self.cmContextMenu(conf, appidx, CM_INIT_APP) + self.qtbot.wait(1000) + # should be moved to last location + assert self.mdigeom == self.getMdiWindow().geometry() + # de-initialize application + self.qtbot.keyClick(self.aw(), Qt.Key_C, Qt.AltModifier, delay=self.delay) + self.activateContextMenu(CONFIG_MENU_DEINITIALIZE) + finally: + if not self.keep_open: + if conf.configuration().dirty(): + QTimer.singleShot(self.delay, self.clickDiscardChanges) + mw.close() + + def test(self): + """ + first start of nexxT in a clean environment + :return: + """ + # create application and move window to non-default location + QTimer.singleShot(self.delay, self._stage0) + startNexT(None, None, [], [], True) + assert self.guistatefile.exists() + self.guistate_contents = self.guistatefile.read_text("utf-8") + logger.info("guistate_contents: %s", self.guistate_contents) + + # assert that the window is in the non-default location + QTimer.singleShot(self.delay, self._stage1) + startNexT(None, None, [], [], True) + guistate_contents = self.guistatefile.read_text("utf-8") + logger.info("guistate_contents: %s", guistate_contents) + assert self.guistate_contents == guistate_contents + + # remove gui state -> the window should be placed in default location + os.remove(str(self.guistatefile)) + # assert that window is in default location and save the gui state to the config file + QTimer.singleShot(self.delay, self._stage2) + startNexT(None, None, [], [], True) + guistate_contents = self.guistatefile.read_text("utf-8") + logger.info("guistate_contents: %s", guistate_contents) + assert self.guistate_contents != guistate_contents + self.guistate_contents = guistate_contents + + # assert that the window is in the non-default location + QTimer.singleShot(self.delay, self._stage1) + startNexT(None, None, [], [], True) + guistate_contents = self.guistatefile.read_text("utf-8") + logger.info("guistate_contents: %s", guistate_contents) + assert self.guistate_contents == guistate_contents + + # remove gui state -> the window should still be placed in non-default location + os.remove(str(self.guistatefile)) + QTimer.singleShot(self.delay, self._stage1) + startNexT(None, None, [], [], True) + guistate_contents = self.guistatefile.read_text("utf-8") + logger.info("guistate_contents: %s", guistate_contents) + assert self.guistate_contents == guistate_contents + + # check that re-opening the same config correctly restores the gui state + QTimer.singleShot(self.delay, self._stage3) + startNexT(None, None, [], [], True) + +@pytest.mark.gui +@pytest.mark.parametrize("delay", [300]) +def test_guistate(qtbot, xvfb, keep_open, delay, tmpdir): + test = GuiStateTest(qtbot, xvfb, keep_open, delay, tmpdir) + test.test() + +class DeadlockTestIssue25(GuiTestBase): + """ + Concrete test class for the test_property test case + """ + def __init__(self, qtbot, xvfb, keep_open, delay, tmpdir, change_conn): + super().__init__(qtbot, xvfb, keep_open, delay, tmpdir) + self.change_conn = change_conn + self.tmpdir = tmpdir + + def _stage0(self): + tmpdir = self.tmpdir + conf = None + mw = None + try: + # load last config + mw = Services.getService("MainWindow") + conf = Services.getService("Configuration") + log = Services.getService("Logging") + # load recent config + if self.change_conn is None: + fn = str((Path(__file__).parent.parent / "core" / "test_deadlock.json").absolute()) + else: + cfg = json.load((Path(__file__).parent.parent / "core" / "test_deadlock.json").open("rb")) + conns = cfg["applications"][0]["connections"] + conns = conns[:self.change_conn] + conns[self.change_conn+1:] + cfg["applications"][0]["connections"] = conns + fn = Path(tmpdir) / "test_deadlock.json" + with fn.open("w") as fp: + json.dump(cfg, fp) + fn = str(fn.absolute()) + logger.info("laoding fn=%s", fn) + QTimer.singleShot(self.delay, lambda: self.enterText(fn)) + conf.actLoad.trigger() + self.qtbot.wait(self.delay*2) + + appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("deadlock")) + self.cmContextMenu(conf, appidx, CM_INIT_APP) + if self.change_conn in [None, 0, 2]: + self.qtbot.wait(1000) + logMsg = "This graph is not deadlock-safe. A cycle has been found in the thread graph: main->compute->main" + self.noWarningsInLog(log, ignore=[logMsg]) + self.assertLogItem(log, "ERROR", logMsg) + else: + self.qtbot.wait(10000) + self.noWarningsInLog(log) + + # assert that the samples arrived in the correct order + def assertSampleOrder(): + numRows = log.logWidget.model().rowCount(QModelIndex()) + filters = {} + for row in range(numRows): + lidx = log.logWidget.model().index(row, 2, QModelIndex()) + msg = log.logWidget.model().data(lidx, Qt.DisplayRole) + if "received: Sample" in msg: + flt = msg.split(":")[0] + idx = int(msg.strip().split(" ")[-1]) + if not flt in filters: + assert idx == 1 + else: + assert idx == filters[flt] + 1 + filters[flt] = idx + # at the moment we do not let the user start these configs, but the sample order is still ok with no samples + # at all + assertSampleOrder() + logger.info("finishing") + finally: + if not self.keep_open: + if conf.configuration().dirty(): + QTimer.singleShot(self.delay, self.clickDiscardChanges) + mw.close() + + def test(self): + QTimer.singleShot(self.delay, self._stage0) + startNexT(None, None, [], [], True) + self.qtbot.wait(1000) + +@pytest.mark.gui +@pytest.mark.parametrize("delay", [300]) +@pytest.mark.timeout(60, method="thread") +@pytest.mark.parametrize("change_conn", [None, 0, 1, 2, 3]) +def test_deadlock_issue25(qtbot, xvfb, keep_open, delay, tmpdir, change_conn): + test = DeadlockTestIssue25(qtbot, xvfb, keep_open, delay, tmpdir, change_conn) + test.test() + +class ExecutionOrderTest(GuiTestBase): + """ + Concrete test class for the test_property test case + """ + def __init__(self, qtbot, xvfb, keep_open, delay, tmpdir): + super().__init__(qtbot, xvfb, keep_open, delay, tmpdir) + + def _stage0(self): + # binary tree execution order + conf = None + mw = None + try: + # execution order config + mw = Services.getService("MainWindow") + conf = Services.getService("Configuration") + + # this is the offline config + appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("binarytree")) + self.cmContextMenu(conf, appidx, CM_INIT_APP) + self.qtbot.wait(3000) + log = Services.getService("Logging") + + model = log.logWidget.model() + numRows = model.rowCount(QModelIndex()) + # depth first execution order + expected = [(1,1), (2,1), (2,2), (1,2), (2,3), (2,4)] + order = [] + for row in range(numRows): + msg = model.data(model.index(row, 2, QModelIndex()), Qt.DisplayRole) + M = re.match(r'^layer(\d)_f(\d)', msg) + if M is not None: + item = (int(M.group(1)),int(M.group(2))) + order.append( item ) + for i, item in enumerate(order): + assert item == expected[i % len(expected)] + assert len(order) >= len(expected) + finally: + if not self.keep_open: + if conf.configuration().dirty(): + QTimer.singleShot(self.delay, self.clickDiscardChanges) + mw.close() + + def _stage1(self): + # recursion single thread execution order + conf = None + mw = None + try: + # execution order config + mw = Services.getService("MainWindow") + conf = Services.getService("Configuration") + + # this is the offline config + appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("recursion_single_thread")) + self.cmContextMenu(conf, appidx, CM_INIT_APP) + self.qtbot.wait(3000) + log = Services.getService("Logging") + + model = log.logWidget.model() + numRows = model.rowCount(QModelIndex()) + expected = [(1, "recursive", "in"), (1, "filter", None), (1, "recursive", "recursive")] + order = [] + for row in range(numRows): + msg = model.data(model.index(row, 2, QModelIndex()), Qt.DisplayRole) + M = re.match(r'^([^:]+):received: Sample (\d+)(.*)', msg) + if M is not None: + M2 = re.match(r" on port (.*)$", M.group(3)) + item = (int(M.group(2)), M.group(1), M2.group(1) if M2 is not None else None) + order.append( item ) + k = 0 + for i, item in enumerate(order): + if i % len(expected) == 0: + k += 1 + eitem = expected[i % len(expected)] + eitem = (k,) + eitem[1:] + assert item == eitem + assert len(order) >= len(expected) + finally: + if not self.keep_open: + if conf.configuration().dirty(): + QTimer.singleShot(self.delay, self.clickDiscardChanges) + mw.close() + + def _stage2(self): + self._throughput("singlethread") + + def _stage3(self): + self._throughput("multithread") + + def _throughput(self, threadmode): + # throughput + conf = None + mw = None + try: + # execution order config + mw = Services.getService("MainWindow") + conf = Services.getService("Configuration") + + app = conf.configuration().applicationByName("binarytree") + # start graph editor + self.setFilterProperty(conf, app, "layer1_f1", "log_throughput_at_end", [Qt.Key_Down, Qt.Key_Return], "True") + self.setFilterProperty(conf, app, "source", "frequency", "10000.0") + + if threadmode == "multithread": + graph = app.getGraph() + for n in graph.allNodes(): + mockup = graph.getMockup(n) + pc = mockup.getPropertyCollectionImpl() + pc.children()[0].setProperty("thread", "thread_"+n) + + self.qtbot.keyClick(self.aw(), Qt.Key_L, Qt.AltModifier, delay=self.delay) + self.activateContextMenu(LM_WARNING) + # this is the offline config + appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("binarytree")) + self.cmContextMenu(conf, appidx, CM_INIT_APP) + self.qtbot.wait(3000) + log = Services.getService("Logging") + self.qtbot.keyClick(self.aw(), Qt.Key_C, Qt.AltModifier, delay=self.delay) + self.activateContextMenu(CONFIG_MENU_DEINITIALIZE) + self.qtbot.wait(2*self.delay) + + model = log.logWidget.model() + numRows = model.rowCount(QModelIndex()) + throughput = None + for row in range(numRows): + msg = model.data(model.index(row, 2, QModelIndex()), Qt.DisplayRole) + logger.info(repr(msg)) + M = re.match(r'layer1_f1:throughput: (\d+.\d+) samples/second', msg) + if M is not None: + throughput = float(M.group(1)) + if threadmode == "multithread": + self.record_property("multithread_smp_per_sec", throughput) + assert throughput >= 750 + else: + self.record_property("throughput_smp_per_sec", throughput) + assert throughput >= 4500 + finally: + if not self.keep_open: + if conf.configuration().dirty(): + QTimer.singleShot(self.delay, self.clickDiscardChanges) + mw.close() + + def test(self, record_property): + """ + test property editing in config editor + :return: + """ + self.record_property = record_property + QTimer.singleShot(self.delay, self._stage0) + startNexT(str(Path(__file__).parent.parent / "core" / "test_tree_order.json"), None, [], [], True) + QTimer.singleShot(self.delay, self._stage1) + startNexT(str(Path(__file__).parent.parent / "core" / "test_tree_order.json"), None, [], [], True) + QTimer.singleShot(self.delay, self._stage2) + startNexT(str(Path(__file__).parent.parent / "core" / "test_tree_order.json"), None, [], [], True) + QTimer.singleShot(self.delay, self._stage3) + startNexT(str(Path(__file__).parent.parent / "core" / "test_tree_order.json"), None, [], [], True) + + +@pytest.mark.gui +@pytest.mark.parametrize("delay", [300]) +def test_executionOrder(qtbot, xvfb, keep_open, delay, tmpdir, record_property): + test = ExecutionOrderTest(qtbot, xvfb, keep_open, delay, tmpdir) + test.test(record_property) + +class ReloadTest(GuiTestBase): + """ + Concrete test class for the guistate test case + """ + def __init__(self, qtbot, xvfb, keep_open, delay, tmpdir): + super().__init__(qtbot, xvfb, keep_open, delay, tmpdir) + self.prjfile = self.tmpdir / "test_reload.json" + self.guistatefile = self.tmpdir / "test_reload.json.guistate" + + def generateFilter(self, messageAtOpen): + myfilter = self.tmpdir / "myfilter.py" + myfilter.write_text(""" +import logging +from nexxT.Qt.QtWidgets import QLabel +from nexxT.interface import Filter, InputPort, Services + +logger = logging.getLogger(__name__) + +class MyFilter(Filter): # almost same as SimpleView + def __init__(self, env): + super().__init__(False, False, env) + self.inputPort = InputPort(False, "in", env) + self.addStaticPort(self.inputPort) + self.propertyCollection().defineProperty("caption", "view", "Caption of view window.") + self.label = None + + def onOpen(self): + caption = self.propertyCollection().getProperty("caption") + mw = Services.getService("MainWindow") + self.label = QLabel() + self.label.setMinimumSize(100, 20) + mw.subplot(caption, self, self.label) + logger.info("%s") + + def onPortDataChanged(self, inputPort): + dataSample = inputPort.getData() + if dataSample.getDatatype() == "text/utf8": + self.label.setText(dataSample.getContent().data().decode("utf8")) + + def onClose(self): + mw = Services.getService("MainWindow") + mw.releaseSubplot(self.label) + self.label = None +""" % messageAtOpen, encoding="utf-8") + return Path(myfilter) + + def getMdiWindow(self): + mw = Services.getService("MainWindow") + assert len(mw.managedSubplots) == 1 + title = list(mw.managedSubplots.keys())[0] + return mw.managedSubplots[title]["mdiSubWindow"] + + def _stage0(self): + conf = None + mw = None + try: + mw = Services.getService("MainWindow") + conf = Services.getService("Configuration") + log = Services.getService("Logging") + idxApplications = conf.model.index(1, 0) + # add application + conf.treeView.setMinimumSize(QSize(300,300)) + conf.treeView.scrollTo(idxApplications) + region = conf.treeView.visualRegionForSelection(QItemSelection(idxApplications, idxApplications)) + self.qtbot.mouseMove(conf.treeView.viewport(), region.boundingRect().center(), delay=self.delay) + # mouse click does not trigger context menu :( + #qtbot.mouseClick(conf.treeView.viewport(), Qt.RightButton, pos=region.boundingRect().center()) + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADD_APPLICATION)) + conf._execTreeViewContextMenu(region.boundingRect().center()) + app = conf.configuration().applicationByName("application") + # start graph editor + gev = self.startGraphEditor(conf, mw, "application") + self.qtbot.wait(self.delay) + # create a visualization node + pyfile = str(self.generateFilter("myfilter version 1").absolute()) + logger.info("pyfile=%s", pyfile) + pysimpleview = self.addNodeToGraphEditor(gev, QPoint(20,20), CM_FILTER_FROM_FILE, + pyfile, "MyFilter") + self.qtbot.keyClick(self.aw(), Qt.Key_Return, delay=self.delay) + self.qtbot.keyClick(self.aw(), Qt.Key_Return, delay=self.delay) + # init application + appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("application")) + self.cmContextMenu(conf, appidx, CM_INIT_APP) + self.qtbot.wait(1000) + # note: the following depends on --forked isolation which is broken with PySide6 + self.assertLogItem(log, "INFO", "myfilter version 1") + # move the window to non-standard position + self.getMdiWindow().move(QPoint(37, 63)) + self.qtbot.wait(1000) + mdigeom = self.getMdiWindow().geometry() + # generate new filter version + self.generateFilter("myfilter version 2") + # reload (without config being saved, no gui state) + self.qtbot.keyClick(self.aw(), Qt.Key_P, Qt.ControlModifier, delay=self.delay) + self.qtbot.wait(1000) + assert mdigeom == self.getMdiWindow().geometry() + self.assertLogItem(log, "INFO", "myfilter version 2") + # save the configuration file + QTimer.singleShot(self.delay, lambda: self.enterText(str(self.prjfile))) + conf.actSave.trigger() + self.prjfile_contents = self.prjfile.read_text("utf-8") + assert not self.guistatefile.exists() + # move window + self.getMdiWindow().move(QPoint(31, 23)) + self.qtbot.wait(1000) + mdigeom = self.getMdiWindow().geometry() + # generate new filter version + self.generateFilter("myfilter version 3") + # reload (without config being saved, no gui state) + self.qtbot.keyClick(self.aw(), Qt.Key_P, Qt.ControlModifier, delay=self.delay) + self.qtbot.wait(1000) + assert mdigeom == self.getMdiWindow().geometry() + self.assertLogItem(log, "INFO", "myfilter version 3") + # save again to create gui state + # can't save again without setting config to dirty + conf._dirtyChanged(True) + assert conf.actSave.isEnabled() + conf.actSave.trigger() + assert self.guistatefile.exists() + # move window + self.getMdiWindow().move(QPoint(17, 11)) + self.qtbot.wait(1000) + mdigeom = self.getMdiWindow().geometry() + # generate new filter version + self.generateFilter("myfilter version 4") + # reload (without config being saved, no gui state) + self.qtbot.keyClick(self.aw(), Qt.Key_P, Qt.ControlModifier, delay=self.delay) + self.qtbot.wait(1000) + assert mdigeom == self.getMdiWindow().geometry() + self.assertLogItem(log, "INFO", "myfilter version 4") + # de-initialize application + self.qtbot.keyClick(self.aw(), Qt.Key_C, Qt.AltModifier, delay=self.delay) + self.activateContextMenu(CONFIG_MENU_DEINITIALIZE) + self.generateFilter("myfilter version 5") + # reload + self.qtbot.keyClick(self.aw(), Qt.Key_P, Qt.ControlModifier, delay=self.delay) + self.qtbot.wait(1000) + appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("application")) + self.cmContextMenu(conf, appidx, CM_INIT_APP) + self.qtbot.wait(1000) + self.assertLogItem(log, "INFO", "myfilter version 5") + self.qtbot.keyClick(self.aw(), Qt.Key_C, Qt.AltModifier, delay=self.delay) + self.activateContextMenu(CONFIG_MENU_DEINITIALIZE) + finally: + if not self.keep_open: + if conf.configuration().dirty(): + QTimer.singleShot(self.delay, self.clickDiscardChanges) + mw.close() + + def test(self): + """ + first start of nexxT in a clean environment + :return: + """ + # create application and move window to non-default location + QTimer.singleShot(self.delay, self._stage0) + startNexT(None, None, [], [], True) + +@pytest.mark.gui +@pytest.mark.parametrize("delay", [300]) +def test_reload(qtbot, xvfb, keep_open, delay, tmpdir): + test = ReloadTest(qtbot, xvfb, keep_open, delay, tmpdir) + test.test() diff --git a/nexxT/tests/src/Plugins.cpp b/nexxT/tests/src/Plugins.cpp index 9008503..1468f7a 100644 --- a/nexxT/tests/src/Plugins.cpp +++ b/nexxT/tests/src/Plugins.cpp @@ -5,33 +5,16 @@ * THE PROGRAM IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND. */ #include - -#if QT_VERSION < 0x060000 #include "AviFilePlayback.hpp" #include "CameraGrabber.hpp" #include "SimpleSource.hpp" #include "TestExceptionFilter.hpp" +#include "Properties.hpp" NEXXT_PLUGIN_DEFINE_START() NEXXT_PLUGIN_ADD_FILTER(VideoPlaybackDevice) NEXXT_PLUGIN_ADD_FILTER(CameraGrabber) NEXXT_PLUGIN_ADD_FILTER(SimpleSource) NEXXT_PLUGIN_ADD_FILTER(TestExceptionFilter) +NEXXT_PLUGIN_ADD_FILTER(PropertyReceiver) NEXXT_PLUGIN_DEFINE_FINISH() - -#else -/* QT 6.1.x does not support multimedia */ - -#include "AviFilePlayback.hpp" -#include "CameraGrabber.hpp" -#include "SimpleSource.hpp" -#include "TestExceptionFilter.hpp" - -NEXXT_PLUGIN_DEFINE_START() -NEXXT_PLUGIN_ADD_FILTER(VideoPlaybackDevice) -NEXXT_PLUGIN_ADD_FILTER(CameraGrabber) -NEXXT_PLUGIN_ADD_FILTER(SimpleSource) -NEXXT_PLUGIN_ADD_FILTER(TestExceptionFilter) -NEXXT_PLUGIN_DEFINE_FINISH() - -#endif \ No newline at end of file diff --git a/nexxT/tests/src/Properties.cpp b/nexxT/tests/src/Properties.cpp new file mode 100644 index 0000000..6c2b745 --- /dev/null +++ b/nexxT/tests/src/Properties.cpp @@ -0,0 +1,60 @@ +#include "Properties.hpp" +#include "nexxT/Logger.hpp" +#include "nexxT/PropertyCollection.hpp" + +using namespace nexxT; + +PropertyReceiver::PropertyReceiver(BaseFilterEnvironment *env) : + Filter(false, false, env) + {} + +PropertyReceiver::~PropertyReceiver() +{} + +void PropertyReceiver::onInit() +{ + propertyCollection()->defineProperty("int", 1, "an integer property", {{"min", 0}, {"max", 10}}); + propertyCollection()->defineProperty("float", 10.0, "a float property", {{"min", -1.0}, {"max", 100.0}}); + propertyCollection()->defineProperty("str", "Hello", "a string property"); + propertyCollection()->defineProperty("bool", false, "a bool property"); + propertyCollection()->defineProperty("enum", "v1", "an enum property", {{"enum", QStringList{"v1", "v2", "v3"}}}); + + if(!(bool) + connect(propertyCollection(), SIGNAL(propertyChanged(nexxT::PropertyCollection *, const QString &)), + this, SLOT(propertyChanged(nexxT::PropertyCollection *, const QString &))) + ) { + NEXXT_LOG_ERROR("connect failed!"); + } else { + } +} + +void PropertyReceiver::propertyChanged(nexxT::PropertyCollection *propcoll, const QString &name) +{ + QVariant v = propcoll->getProperty(name); + NEXXT_LOG_INFO(QString("propertyChanged %1 is %2").arg(name, v.toString())); +} + +void PropertyReceiver::onOpen() +{ +} + +void PropertyReceiver::onStart() +{ +} + +void PropertyReceiver::onStop() +{ +} + +void PropertyReceiver::onClose() +{ +} + +void PropertyReceiver::onDeinit() +{ + if( !disconnect(propertyCollection(), SIGNAL(propertyChanged(nexxT::PropertyCollection *, const QString &)), + this, SLOT(propertyChanged(nexxT::PropertyCollection *, const QString &))) ) + { + NEXXT_LOG_ERROR("disconnect failed!"); + } +} diff --git a/nexxT/tests/src/Properties.hpp b/nexxT/tests/src/Properties.hpp new file mode 100644 index 0000000..019a64b --- /dev/null +++ b/nexxT/tests/src/Properties.hpp @@ -0,0 +1,27 @@ +#ifndef PROPERTY_RECEIVER_HPP +#define PROPERTY_RECEIVER_HPP + +#include "nexxT/Filters.hpp" +#include "nexxT/NexxTPlugins.hpp" + +class PropertyReceiver : public nexxT::Filter +{ + Q_OBJECT +public: + NEXXT_PLUGIN_DECLARE_FILTER(PropertyReceiver) + + PropertyReceiver(nexxT::BaseFilterEnvironment *env); + virtual ~PropertyReceiver(); + + virtual void onInit(); + virtual void onOpen(); + virtual void onStart(); + virtual void onStop(); + virtual void onClose(); + virtual void onDeinit(); + +public slots: + void propertyChanged(nexxT::PropertyCollection *sender, const QString &name); +}; + +#endif diff --git a/nexxT/tests/src/SConscript.py b/nexxT/tests/src/SConscript.py index 153175e..aee8fee 100644 --- a/nexxT/tests/src/SConscript.py +++ b/nexxT/tests/src/SConscript.py @@ -9,42 +9,23 @@ Import("env") env = env.Clone() -if os.environ.get("PYSIDEVERSION", "6") in "52": - env.EnableQt5Modules(['QtCore', "QtMultimedia", "QtGui"]) - srcDir = Dir(".").srcnode() - - env.Append(CPPPATH=["../../src", "."], - LIBPATH=["../../src"], - LIBS=["nexxT"]) - - plugin = env.SharedLibrary("test_plugins", env.RegisterSources(Split(""" - SimpleSource.cpp - AviFilePlayback.cpp - TestExceptionFilter.cpp - Plugins.cpp - CameraGrabber.cpp - VideoGrabber.cpp - """))) - env.RegisterTargets(plugin) -elif os.environ.get("PYSIDEVERSION", "6") == "6": - env.EnableQt6Modules(['QtCore', "QtGui", "QtMultimedia"]) - srcDir = Dir(".").srcnode() - - env.Append(CPPPATH=[srcDir.Dir("../../include"), "."], - LIBPATH=["../../src"], - LIBS=["nexxT"]) - - plugin = env.SharedLibrary("test_plugins", env.RegisterSources(Split(""" - SimpleSource.cpp - AviFilePlayback.cpp - TestExceptionFilter.cpp - Plugins.cpp - VideoGrabber.cpp - CameraGrabber.cpp - """))) - env.RegisterTargets(plugin) -else: - raise RuntimeError("invalid env variable PYSIDEVERSION=%s" % os.environ["PYSIDEVERSION"]) +env.EnableQt6Modules(['QtCore', "QtGui", "QtMultimedia"]) +srcDir = Dir(".").srcnode() + +env.Append(CPPPATH=[srcDir.Dir("../../include"), "."], + LIBPATH=["../../src"], + LIBS=["nexxT"]) + +plugin = env.SharedLibrary("test_plugins", env.RegisterSources(Split(""" + SimpleSource.cpp + AviFilePlayback.cpp + TestExceptionFilter.cpp + Plugins.cpp + VideoGrabber.cpp + CameraGrabber.cpp + Properties.cpp +"""))) +env.RegisterTargets(plugin) installed = env.Install(srcDir.Dir("..").Dir("binary").Dir(env.subst("$deploy_platform")).Dir(env.subst("$variant")).abspath, plugin) env.RegisterTargets(installed) diff --git a/setup.py b/setup.py index 2469b8e..63dfa90 100644 --- a/setup.py +++ b/setup.py @@ -187,6 +187,7 @@ def is_pure(*args): 'tests.nexxT.PySimpleDynInFilter = nexxT.tests.interface.SimpleDynamicFilter:SimpleDynInFilter', 'tests.nexxT.PySimpleDynOutFilter = nexxT.tests.interface.SimpleDynamicFilter:SimpleDynOutFilter', 'tests.nexxT.CTestExceptionFilter = nexxT.tests:CTestExceptionFilter', + 'tests.nexxT.CPropertyReceiver = nexxT.tests:PropertyReceiver', 'tests.nexxT.PyTestExceptionFilter = nexxT.tests.interface.TestExceptionFilter:TestExceptionFilter', ], }, diff --git a/workspace/sconstools3/qt6/__init__.py b/workspace/sconstools3/qt6/__init__.py index 12a1b96..a7d084a 100644 --- a/workspace/sconstools3/qt6/__init__.py +++ b/workspace/sconstools3/qt6/__init__.py @@ -239,10 +239,16 @@ def __automoc_strategy_simple(self, env, moc_options, if cpp and self.qo_search.search(cpp_contents): # cpp file with Q_OBJECT macro found -> add moc # (to be included in cpp) - moc = env.Moc6(cpp) - env.Ignore(moc, moc) - if moc_options['debug']: - print("scons: qt6: found Q_OBJECT macro in '%s', moc'ing to '%s'" % (str(cpp), str(moc))) + # check if this has been built by a MOC builder + if (cpp.get_builder() == env['BUILDERS']['Moc6'].builder or + cpp.get_builder() == env['BUILDERS']['XMoc6'].builder): + if moc_options["debug"]: + print("scons: qt6: Ignoring source file %s since this has been built by moc" % str(cpp)) + else: + moc = env.Moc6(cpp) + env.Ignore(moc, moc) + if moc_options['debug']: + print("scons: qt6: found Q_OBJECT macro in '%s', moc'ing to '%s'" % (str(cpp), str(moc))) def __automoc_strategy_include_driven(self, env, moc_options, cpp, cpp_contents, out_sources):