From 61068d6dac2c8847b9c1b9baa60f544874f71d6f Mon Sep 17 00:00:00 2001 From: D Osinga <46380170+osingaatje@users.noreply.github.com> Date: Tue, 30 Sep 2025 16:08:44 +0200 Subject: [PATCH 001/131] Typehints + formatting (#4) * added type hints to modelspace.py, steparguments.py, substitutionmap.py, version.py and __init__.py, a little bit of suitedata.py and to tracestate.py. Reordered classes in tracestate.py for this purpose. * added nearly all remaining type hints, including missing "Self" types from files typed in earlier commits. Not everything in suitereplacer.py is typed because of severe ambiguity issues * reformatted according to PEP8 (using in VSCode) --------- Co-authored-by: tychodub --- robotmbt/__init__.py | 4 +- robotmbt/modelspace.py | 60 ++++++---- robotmbt/steparguments.py | 36 +++--- robotmbt/substitutionmap.py | 52 ++++++--- robotmbt/suitedata.py | 208 ++++++++++++++++++++------------- robotmbt/suiteprocessors.py | 227 +++++++++++++++++++++++++----------- robotmbt/suitereplacer.py | 129 ++++++++++++-------- robotmbt/tracestate.py | 80 ++++++++----- robotmbt/version.py | 2 +- 9 files changed, 512 insertions(+), 286 deletions(-) diff --git a/robotmbt/__init__.py b/robotmbt/__init__.py index 6ea102f8..bcdfcc7b 100644 --- a/robotmbt/__init__.py +++ b/robotmbt/__init__.py @@ -33,9 +33,11 @@ from .version import VERSION from .suitereplacer import SuiteReplacer + class robotmbt(SuiteReplacer): """ Process test suites on-the-fly to optimise test suite execution """ -__version__ = VERSION + +__version__: str = VERSION diff --git a/robotmbt/modelspace.py b/robotmbt/modelspace.py index b01d2a66..8f5c3b80 100644 --- a/robotmbt/modelspace.py +++ b/robotmbt/modelspace.py @@ -31,31 +31,36 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import copy +from typing import Self from .steparguments import StepArguments + class ModellingError(Exception): pass + class ModelSpace: def __init__(self, reference_id=None): - self.ref_id = str(reference_id) - self.std_attrs = [] - self.props = dict() - self.values = dict() # For using literals without having to use quotes (abc='abc') - self.scenario_vars = [] + self.ref_id: str = str(reference_id) + self.std_attrs: list[str] = [] + self.props: dict[str, RecursiveScope | ModelSpace] = dict() + + # For using literals without having to use quotes (abc='abc') + self.values: dict[str, any] = dict() + self.scenario_vars: list[RecursiveScope] = [] self.std_attrs = dir(self) def __repr__(self): return self.ref_id if self.ref_id else super().__repr__() - def copy(self): + def copy(self) -> Self: return copy.deepcopy(self) def __eq__(self, other): return self.get_status_text() == other.get_status_text() - def add_prop(self, name): + def add_prop(self, name: str): if name == 'scenario': raise ModellingError(f"scenario is a reserved attribute.") if name in self.props or name in self.values: @@ -63,7 +68,7 @@ def add_prop(self, name): self.props[name] = ModelSpace(name) setattr(self, name, self.props[name]) - def del_prop(self, name): + def del_prop(self, name: str): if name == 'scenario': raise ModellingError(f"scenario is a reserved attribute and cannot be removed.") if name not in self.props: @@ -78,18 +83,20 @@ def __dir__(self, recurse=True): return self.__dict__.keys() def new_scenario_scope(self): - self.scenario_vars.append(RecursiveScope(self.scenario_vars[-1] if len(self.scenario_vars) else None)) + self.scenario_vars.append(RecursiveScope( + self.scenario_vars[-1] if len(self.scenario_vars) else None)) self.props['scenario'] = self.scenario_vars[-1] def end_scenario_scope(self): - assert len(self.scenario_vars) > 0, ".end_scenario_scope() called, but there is no scenario scope open." + assert len( + self.scenario_vars) > 0, ".end_scenario_scope() called, but there is no scenario scope open." self.scenario_vars.pop() if len(self.scenario_vars): self.props['scenario'] = self.scenario_vars[-1] else: self.props.pop('scenario') - def process_expression(self, expression, step_args=StepArguments()): + def process_expression(self, expression: str, step_args: StepArguments = StepArguments()) -> any: expr = step_args.fill_in_args(expression.strip(), as_code=True) if self._is_new_vocab_expression(expr): self.add_prop(self._vocab_term(expr)) @@ -103,7 +110,8 @@ def process_expression(self, expression, step_args=StepArguments()): for p in self.props: exec(f"{p} = self.props['{p}']", local_locals) for v in self.values: - value = f"'{self.values[v]}'" if isinstance(self.values[v], str) else self.values[v] + value = f"'{self.values[v]}'" if isinstance( + self.values[v], str) else self.values[v] exec(f"{v} = {value}", local_locals) try: result = eval(expr, local_locals) @@ -118,7 +126,7 @@ def process_expression(self, expression, step_args=StepArguments()): self.__handle_attribute_error(err) except NameError as missing: if missing.name == expr: - raise # Putting only a name in an expression can be used as exists check + raise # Putting only a name in an expression can be used as exists check self.__add_alias(missing.name, step_args) result = self.process_expression(expression, step_args) except AttributeError as err: @@ -137,31 +145,33 @@ def __handle_attribute_error(self, err): raise ModellingError(f"{err.obj} used before definition") raise ModellingError(f"{err.name} used before assignment") - def __add_alias(self, missing_name, step_args): + def __add_alias(self, missing_name: str, step_args): if missing_name == 'scenario': raise ModellingError("Accessing scenario scope while there is no scenario active.\n" "If you intended this to be a literal, please use quotes ('scenario' or \"scenario\").") - matching_args = [arg.value for arg in step_args if arg.codestring == missing_name] + matching_args = [ + arg.value for arg in step_args if arg.codestring == missing_name] value = matching_args[0] if matching_args else missing_name if isinstance(value, str): - for esc_char in "$@&=": # Prevent "Syntaxwarning: invalid escape sequence" on Robot escapes like '\$' and '\=' + for esc_char in "$@&=": # Prevent "Syntaxwarning: invalid escape sequence" on Robot escapes like '\$' and '\=' value = value.replace(f'\\{esc_char}', f'\\\\{esc_char}') - value = value.replace("'", r"\'") # Needed because we use single quotes in low level processing later on + # Needed because we use single quotes in low level processing later on + value = value.replace("'", r"\'") self.values[missing_name] = value @staticmethod - def _is_new_vocab_expression(expression): + def _is_new_vocab_expression(expression: str) -> bool: return expression.lower().startswith('new ') and len(expression.split()) == 2 @staticmethod - def _is_del_vocab_expression(expression): + def _is_del_vocab_expression(expression: str) -> bool: return expression.lower().startswith('del ') and len(expression.split()) == 2 @staticmethod - def _vocab_term(expression): + def _vocab_term(expression: str) -> str: return expression.split()[-1] - def get_status_text(self): + def get_status_text(self) -> str: status = str() scenario_attrs = [] for p in self.props: @@ -177,6 +187,7 @@ def get_status_text(self): status += f" {attr}={value}\n" return status + class RecursiveScope: """ Generic scoping object with the properties needed for handling scenario variables with refinement. @@ -190,15 +201,16 @@ class RecursiveScope: executed on the highest available level. Creating new attributes, will make the current level the highest available level for that atrribute. """ + def __init__(self, outer): super().__setattr__('_outer_scope', outer) - def __getattr__(self, attr): + def __getattr__(self, attr: str): if hasattr(super().__getattribute__('_outer_scope'), attr): return getattr(self._outer_scope, attr) return super().__getattribute__(attr) - def __setattr__(self, attr, value): + def __setattr__(self, attr: str, value): if hasattr(self._outer_scope, attr): setattr(self._outer_scope, attr, value) else: @@ -206,7 +218,7 @@ def __setattr__(self, attr, value): def __iter__(self): return iter([(attr, getattr(self, attr)) for attr in dir(self._outer_scope) + dir(self) - if not attr.startswith('__') and attr != '_outer_scope']) + if not attr.startswith('__') and attr != '_outer_scope']) def __bool__(self): return any(True for _ in self) diff --git a/robotmbt/steparguments.py b/robotmbt/steparguments.py index 7ea0fee3..2bd4451d 100644 --- a/robotmbt/steparguments.py +++ b/robotmbt/steparguments.py @@ -32,13 +32,14 @@ from keyword import iskeyword import builtins +from typing import Self class StepArguments(list): def __init__(self, iterable=[]): super().__init__(item.copy() for item in iterable) - def fill_in_args(self, text, as_code=False): + def fill_in_args(self, text: str, as_code: bool = False) -> str: result = text for arg in self: sub = arg.codestring if as_code else str(arg.value) @@ -52,9 +53,10 @@ def __getitem__(self, key): return super()[key] @property - def modified(self): + def modified(self) -> bool: return any([arg.modified for arg in self]) + class StepArgument: EMBEDDED = 'EMBEDDED' POSITIONAL = 'POSITIONAL' @@ -62,42 +64,42 @@ class StepArgument: NAMED = 'NAMED' FREE_NAMED = 'FREE_NAMED' - def __init__(self, arg_name, value, kind=None): - self.name = arg_name - self.org_value = value - self.kind = kind - self._value = None - self._codestr = None - self.value = value + def __init__(self, arg_name: str, value: any, kind: str | None = None): + self.name: str = arg_name + self.org_value: any = value + self.kind: str | None = kind + self._value: any = None + self._codestr: str | None = None + self.value: any = value @property - def arg(self): + def arg(self) -> str: return "${%s}" % self.name @property - def value(self): + def value(self) -> any: return self._value @value.setter - def value(self, value): + def value(self, value: any): self._value = value self._codestr = self.make_codestring(value) @property - def modified(self): + def modified(self) -> bool: return self.org_value != self.value @property - def codestring(self): + def codestring(self) -> str | None: return self._codestr - def copy(self): + def copy(self) -> Self: cp = StepArgument(self.arg.strip('${}'), self.value, self.kind) cp.org_value = self.org_value return cp @staticmethod - def make_codestring(text): + def make_codestring(text: any) -> str: codestr = str(text) if codestr.title() in ['None', 'True', 'False']: return codestr.title() @@ -108,7 +110,7 @@ def make_codestring(text): return codestr @staticmethod - def make_identifier(s): + def make_identifier(s: any) -> str: _s = str(s).replace(' ', '_') if _s.isidentifier(): return f"{_s}_" if iskeyword(_s) or _s in dir(builtins) else _s diff --git a/robotmbt/substitutionmap.py b/robotmbt/substitutionmap.py index 29ba0a25..1f5445bd 100644 --- a/robotmbt/substitutionmap.py +++ b/robotmbt/substitutionmap.py @@ -31,6 +31,7 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import random +from typing import Self class SubstitutionMap: @@ -41,39 +42,48 @@ class SubstitutionMap: constraints. solve() takes the current set of example values and assigns a unique concrete value to each. """ + def __init__(self): - self.substitutions = {} # {example_value:Constraint} - self.solution = {} # {example_value:solution_value} + # {example_value:Constraint} + self.substitutions: dict[str, Constraint] = {} + + # {example_value:solution_value} + self.solution: dict[str, int | str] = {} def __str__(self): src = self.solution or self.substitutions return ", ".join([f"{k} ⤝ {v}" for k, v in src.items()]) - def copy(self): + def copy(self) -> Self: new = SubstitutionMap() - new.substitutions = {k: v.copy() for k,v in self.substitutions.items()} + new.substitutions = {k: v.copy() + for k, v in self.substitutions.items()} + new.solution = self.solution.copy() return new - def substitute(self, example_value, constraint): + def substitute(self, example_value: str, constraint: list[int]): self.solution = {} if example_value in self.substitutions: self.substitutions[example_value].add_constraint(constraint) else: self.substitutions[example_value] = Constraint(constraint) - def solve(self): + def solve(self) -> dict[str, str]: self.solution = {} - solution = dict() + solution: dict[str, str] = dict() substitutions = self.copy().substitutions unsolved_subs = list(substitutions) subs_stack = [] + while unsolved_subs: unsolved_subs.sort(key=lambda i: len(substitutions[i].optionset)) example_value = unsolved_subs[0] - solution[example_value] = random.choice(substitutions[example_value].optionset) + solution[example_value] = random.choice( + substitutions[example_value].optionset) subs_stack.append(example_value) others_list = [] + try: # exclude the choice from all others for other in [e for e in substitutions if e != example_value]: @@ -95,17 +105,20 @@ def solve(self): subs_stack.pop() except IndexError: # nothing left to roll back, no options remaining - raise ValueError("No solution found within the set of given constraints") + raise ValueError( + "No solution found within the set of given constraints") last_item = subs_stack[-1] unsolved_subs.insert(0, last_item) for other in [e for e in substitutions if e != last_item]: substitutions[other].undo_remove() try: - substitutions[last_item].remove_option(solution.pop(last_item)) + substitutions[last_item].remove_option( + solution.pop(last_item)) rollback_done = True except ValueError: # next level must also be rolled back example_value = last_item + self.solution = solution return solution @@ -115,12 +128,15 @@ def __init__(self, constraint): try: # Keep the items in optionset unique. Refrain from using Python sets # due to non-deterministic behaviour when using random seeding. - self.optionset = list(dict.fromkeys(constraint)) + self.optionset: list | None = list(dict.fromkeys(constraint)) except: - self.optionset = None + self.optionset: list | None = None if not self.optionset or isinstance(constraint, str): - raise ValueError(f"Invalid option set for initial constraint: {constraint}") - self.removed_stack = [] + raise ValueError( + f"Invalid option set for initial constraint: {constraint}" + ) + + self.removed_stack: list[str | Placeholder] = [] def __repr__(self): return f'Constraint([{", ".join([str(e) for e in self.optionset])}])' @@ -128,16 +144,18 @@ def __repr__(self): def __iter__(self): return iter(self.optionset) - def copy(self): + def copy(self) -> Self: return Constraint(self.optionset) def add_constraint(self, constraint): - if constraint is None: return + if constraint is None: + return + self.optionset = [opt for opt in self.optionset if opt in constraint] if not len(self.optionset): raise ValueError('No options left after adding constraint') - def remove_option(self, option): + def remove_option(self, option: str): try: self.optionset.remove(option) self.removed_stack.append(option) diff --git a/robotmbt/suitedata.py b/robotmbt/suitedata.py index c84ba4ad..d0619008 100644 --- a/robotmbt/suitedata.py +++ b/robotmbt/suitedata.py @@ -31,74 +31,86 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import copy +from typing import Self + from robot.running.arguments.argumentvalidator import ArgumentValidator from .steparguments import StepArgument, StepArguments +from .substitutionmap import SubstitutionMap + class Suite: - def __init__(self, name, parent=None): - self.name = name - self.filename = '' - self.parent = parent - self.suites = [] - self.scenarios = [] - self.setup = None # Can be a single step or None - self.teardown = None # Can be a single step or None + def __init__(self, name: str, parent=None): + self.name: str = name + self.filename: str = '' + self.parent: Suite | None = parent + self.suites: list[Suite] = [] + self.scenarios: list[Scenario] = [] + self.setup: Step | str | None = None # Can be a single step or None + self.teardown: Step | str | None = None # Can be a single step or None @property - def longname(self): + def longname(self) -> str: return f"{self.parent.longname}.{self.name}" if self.parent else self.name - def has_error(self): - return ( (self.setup.has_error() if self.setup else False) - or any([s.has_error() for s in self.suites]) - or any([s.has_error() for s in self.scenarios]) - or (self.teardown.has_error() if self.teardown else False)) + def has_error(self) -> bool: + return ((self.setup.has_error() if self.setup else False) + or any([s.has_error() for s in self.suites]) + or any([s.has_error() for s in self.scenarios]) + or (self.teardown.has_error() if self.teardown else False)) + # list[Step | str | None], Step needs to be moved up def steps_with_errors(self): - return ( ([self.setup] if self.setup and self.setup.has_error() else []) - + [e for s in map(Suite.steps_with_errors, self.suites) for e in s] - + [e for s in map(Scenario.steps_with_errors, self.scenarios) for e in s] - + ([self.teardown] if self.teardown and self.teardown.has_error() else [])) + return (([self.setup] if self.setup and self.setup.has_error() else []) + + [e for s in map(Suite.steps_with_errors, self.suites) + for e in s] + + [e for s in map(Scenario.steps_with_errors, + self.scenarios) for e in s] + + ([self.teardown] if self.teardown and self.teardown.has_error() else [])) + class Scenario: - def __init__(self, name, parent=None): - self.name = name - self.parent = parent # Parent scenario for easy searching, processing and referencing - # after steps and scenarios have been potentially moved around - self.setup = None # Can be a single step or None - self.teardown = None # Can be a single step or None - self.steps = [] - self.src_id = None - self.data_choices = {} + def __init__(self, name: str, parent=None): + self.name: str = name + # Parent scenario for easy searching, processing and referencing + self.parent: Suite | None = parent + # after steps and scenarios have been potentially moved around + # Can be a single step or None, may also be a str in tests + self.setup: Step | None = None + # Can be a single step or None, may also be a str in tests + self.teardown: Step | None = None + self.steps: list[Step] = [] + self.src_id: int | None = None + self.data_choices: dict | SubstitutionMap = {} # may be Dummy type in a test @property - def longname(self): + def longname(self) -> str: return f"{self.parent.longname}.{self.name}" if self.parent else self.name - def has_error(self): + def has_error(self) -> bool: return ((self.setup.has_error() if self.setup else False) - or any([s.has_error() for s in self.steps]) - or (self.teardown.has_error() if self.teardown else False)) + or any([s.has_error() for s in self.steps]) + or (self.teardown.has_error() if self.teardown else False)) - def steps_with_errors(self): - return ( ([self.setup] if self.setup and self.setup.has_error() else []) - + [s for s in self.steps if s.has_error()] - + ([self.teardown] if self.teardown and self.teardown.has_error() else [])) + def steps_with_errors(self): # list[Step | None] + return (([self.setup] if self.setup and self.setup.has_error() else []) + + [s for s in self.steps if s.has_error()] + + ([self.teardown] if self.teardown and self.teardown.has_error() else [])) - def copy(self): + def copy(self) -> Self: duplicate = copy.copy(self) duplicate.steps = [step.copy() for step in self.steps] duplicate.data_choices = self.data_choices.copy() return duplicate - def split_at_step(self, stepindex): + def split_at_step(self, stepindex: int) -> tuple[Self, Self]: """Returns 2 partial scenarios. With stepindex 0 the first part has no steps and all steps are in the last part. With stepindex 1 the first step is in the first part, the other in the last part, and so on. """ - assert stepindex <= len(self.steps), "Split index out of range. Not enough steps in scenario." + assert stepindex <= len( + self.steps), "Split index out of range. Not enough steps in scenario." front = self.copy() front.teardown = None front.steps = self.steps[:stepindex] @@ -107,27 +119,48 @@ def split_at_step(self, stepindex): back.setup = None return front, back + class Step: - def __init__(self, steptext, *args, parent, assign=(), prev_gherkin_kw=None): - self.org_step = steptext # first keyword cell of the Robot line, including step_kw, - # excluding positional args, excluding variable assignment. - self.org_pn_args = args # positional and named arguments as parsed from Robot text ('posA' , 'posB', 'named1=namedA') - self.parent = parent # Parent scenario for easy searching and processing. - self.assign = assign # For when a keyword's return value is assigned to a variable. - # Taken directly from Robot. - self.gherkin_kw = self.step_kw if str(self.step_kw).lower() in ['given', 'when', 'then', 'none'] else prev_gherkin_kw - # 'given', 'when', 'then' or None for non-bdd keywords. - self.signature = None # Robot keyword with its embedded arguments in ${...} notation. - self.args = StepArguments() # embedded arguments list of StepArgument objects. - self.detached = False # Decouples StepArguments from the step text (refinement use case) - self.model_info = dict() # Modelling information is available as a dictionary. - # The standard format is dict(IN=[], OUT=[]) and can - # optionally contain an error field. - # IN and OUT are lists of Python evaluatable expressions. - # The `new vocab` form can be used to create new domain objects. - # The `vocab.attribute` form can then be used to express relations - # between properties from the domain vocabulaire. - # Custom processors can define their own attributes. + def __init__(self, steptext: str, *args, parent: Suite | Scenario, assign: tuple[str] = (), + prev_gherkin_kw: str | None = None): + # first keyword cell of the Robot line, including step_kw, + self.org_step: str = steptext + + # excluding positional args, excluding variable assignment. + # positional and named arguments as parsed from Robot text ('posA' , 'posB', 'named1=namedA') + self.org_pn_args = args + + # Parent scenario for easy searching and processing. + self.parent: Suite | Scenario = parent + + # For when a keyword's return value is assigned to a variable. + self.assign: tuple[str] = assign + + # Taken directly from Robot. + self.gherkin_kw: str | None = self.step_kw \ + if str(self.step_kw).lower() in ['given', 'when', 'then', 'none'] \ + else prev_gherkin_kw + + # 'given', 'when', 'then' or None for non-bdd keywords. + # Robot keyword with its embedded arguments in ${...} notation. + self.signature: str | None = None + + # embedded arguments list of StepArgument objects. + self.args: StepArguments = StepArguments() + + # Decouples StepArguments from the step text (refinement use case) + self.detached: bool = False + + # Modelling information is available as a dictionary. + # TODO: Maybe use a data structure for this instead of a dict with specific keys. + self.model_info: dict[str, str | list[str]] = dict() + # The standard format of `model_info` is dict(IN=[], OUT=[]) and can + # optionally contain an error field. + # IN and OUT are lists of Python evaluatable expressions. + # The `new vocab` form can be used to create new domain objects. + # The `vocab.attribute` form can then be used to express relations + # between properties from the domain vocabulaire. + # Custom processors can define their own attributes. def __str__(self): return self.keyword @@ -135,8 +168,9 @@ def __str__(self): def __repr__(self): return f"Step: '{self}' with model info: {self.model_info}" - def copy(self): - cp = Step(self.org_step, *self.org_pn_args, parent=self.parent, assign=self.assign) + def copy(self) -> Self: + cp = Step(self.org_step, *self.org_pn_args, + parent=self.parent, assign=self.assign) cp.gherkin_kw = self.gherkin_kw cp.signature = self.signature cp.args = StepArguments(self.args) @@ -144,30 +178,30 @@ def copy(self): cp.model_info = self.model_info.copy() return cp - def has_error(self): + def has_error(self) -> bool: return 'error' in self.model_info - def get_error(self): + def get_error(self) -> str | None: return self.model_info.get('error') @property - def full_keyword(self): + def full_keyword(self) -> str: """The full keyword text, quad space separated, including its arguments and return value assignment""" return " ".join(str(p) for p in (*self.assign, self.keyword, *self.posnom_args_str)) @property - def keyword(self): + def keyword(self) -> str: if not self.signature: return self.org_step s = f"{self.step_kw} {self.signature}" if self.step_kw else self.signature return self.args.fill_in_args(s) @property - def posnom_args_str(self): + def posnom_args_str(self) -> tuple[any]: """A tuple with all arguments in Robot accepted text format ('posA' , 'posB', 'named1=namedA')""" if self.detached or not self.args.modified: return self.org_pn_args - result = [] + result: list[any] = [] for arg in self.args: if arg.kind == arg.POSITIONAL: result.append(arg.value) @@ -179,25 +213,25 @@ def posnom_args_str(self): elif arg.kind == arg.FREE_NAMED: for name, value in arg.value.items(): result.append(f"{name}={value}") - else: + else: # TODO: remove this - has no impact on the control flow. continue return tuple(result) @property - def gherkin_kw(self): + def gherkin_kw(self) -> str | None: return self._gherkin_kw @gherkin_kw.setter - def gherkin_kw(self, value): + def gherkin_kw(self, value: str | None): self._gherkin_kw = value.lower() if value else None @property - def step_kw(self): + def step_kw(self) -> str | None: first_word = self.org_step.split()[0] - return first_word if first_word.lower() in ['given','when','then','and','but'] else None + return first_word if first_word.lower() in ['given', 'when', 'then', 'and', 'but'] else None @property - def kw_wo_gherkin(self): + def kw_wo_gherkin(self) -> str: """The keyword without its Gherkin keyword. I.e., as it is known in Robot framework.""" return self.keyword.replace(self.step_kw, '', 1).strip() if self.step_kw else self.keyword @@ -210,32 +244,39 @@ def add_robot_dependent_data(self, robot_kw): raise ValueError(robot_kw.error) if robot_kw.embedded: self.args = StepArguments([StepArgument(*match, kind=StepArgument.EMBEDDED) for match in - zip(robot_kw.embedded.args, robot_kw.embedded.parse_args(self.kw_wo_gherkin))]) + zip(robot_kw.embedded.args, + robot_kw.embedded.parse_args(self.kw_wo_gherkin))]) + self.args += self.__handle_non_embedded_arguments(robot_kw.args) self.signature = robot_kw.name self.model_info = self.__parse_model_info(robot_kw._doc) except Exception as ex: - self.model_info['error']=str(ex) + self.model_info['error'] = str(ex) - def __handle_non_embedded_arguments(self, robot_argspec): + def __handle_non_embedded_arguments(self, robot_argspec) -> list[StepArgument]: result = [] + p_args, n_args = robot_argspec.map([a for a in self.org_pn_args if '=' not in a or r'\=' in a], [a.split('=', 1) for a in self.org_pn_args if '=' in a and r'\=' not in a]) + + # for some reason .map() returns [None] instead of the empty list when there are no arguments if p_args == [None]: - # for some reason .map() returns [None] instead of the empty list when there are no arguments - p_args= [] + p_args = [] + ArgumentValidator(robot_argspec).validate(p_args, n_args) robot_args = [a for a in robot_argspec] argument_names = list(robot_argspec.argument_names) for arg in robot_argspec: if arg.kind != arg.POSITIONAL_ONLY and arg.kind != arg.POSITIONAL_OR_NAMED: break - result += [StepArgument(argument_names.pop(0), p_args.pop(0), kind=StepArgument.POSITIONAL)] + result += [StepArgument(argument_names.pop(0), + p_args.pop(0), kind=StepArgument.POSITIONAL)] robot_args.pop(0) if not p_args: break if p_args and robot_args[0].kind == robot_args[0].VAR_POSITIONAL: - result += [StepArgument(argument_names.pop(0), p_args, kind=StepArgument.VAR_POS)] + result += [StepArgument(argument_names.pop(0), + p_args, kind=StepArgument.VAR_POS)] free = {} for name, value in n_args: if name in argument_names: @@ -243,10 +284,11 @@ def __handle_non_embedded_arguments(self, robot_argspec): else: free[name] = value if free: - result += [StepArgument(argument_names[-1], free, kind=StepArgument.FREE_NAMED)] + result += [StepArgument(argument_names[-1], + free, kind=StepArgument.FREE_NAMED)] return result - def __parse_model_info(self, docu): + def __parse_model_info(self, docu: str) -> dict[str, list[str]]: model_info = dict() mi_index = docu.find("*model info*") if mi_index == -1: @@ -256,6 +298,7 @@ def __parse_model_info(self, docu): if "" in lines: lines = lines[:lines.index("")] format_msg = "*model info* expected format: :: |" + while lines: line = lines.pop(0) if not line.startswith(":"): @@ -266,7 +309,8 @@ def __parse_model_info(self, docu): key = elms[1].strip() expressions = [e.strip() for e in elms[-1].split("|") if e] while lines and not lines[0].startswith(":"): - expressions.extend([e.strip() for e in lines.pop(0).split("|") if e]) + expressions.extend([e.strip() + for e in lines.pop(0).split("|") if e]) model_info[key] = expressions if not model_info: raise ValueError("When present, *model info* cannot be empty") diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index 0b0bb871..0fb992e0 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -39,14 +39,15 @@ from .substitutionmap import SubstitutionMap from .modelspace import ModelSpace from .suitedata import Suite, Scenario, Step -from .tracestate import TraceState +from .tracestate import TraceState, TraceSnapShot from .steparguments import StepArgument, StepArguments + class SuiteProcessors: def echo(self, in_suite): return in_suite - def flatten(self, in_suite): + def flatten(self, in_suite: Suite) -> Suite: """ Takes a Suite as input and returns a Suite as output. The output Suite does not have any sub-suites, only scenarios. The scenarios do not have a setup. Any setup @@ -61,6 +62,7 @@ def flatten(self, in_suite): if scenario.teardown: scenario.steps.append(scenario.teardown) scenario.teardown = None + out_suite.scenarios = [] for suite in in_suite.suites: subsuite = self.flatten(suite) @@ -70,11 +72,12 @@ def flatten(self, in_suite): if subsuite.teardown: scenario.steps.append(subsuite.teardown) out_suite.scenarios.extend(subsuite.scenarios) + out_suite.scenarios.extend(outer_scenarios) out_suite.suites = [] return out_suite - def process_test_suite(self, in_suite, *, seed='new'): + def process_test_suite(self, in_suite: Suite, *, seed: any = 'new') -> Suite: self.out_suite = Suite(in_suite.name) self.out_suite.filename = in_suite.filename self.out_suite.parent = in_suite.parent @@ -85,7 +88,7 @@ def process_test_suite(self, in_suite, *, seed='new'): scenario.src_id = id self.scenarios = self.flat_suite.scenarios[:] logger.debug("Use these numbers to reference scenarios from traces\n\t" + - "\n\t".join([f"{s.src_id}: {s.name}" for s in self.scenarios])) + "\n\t".join([f"{s.src_id}: {s.name}" for s in self.scenarios])) self._init_randomiser(seed) random.shuffle(self.scenarios) @@ -94,7 +97,8 @@ def process_test_suite(self, in_suite, *, seed='new'): self._try_to_reach_full_coverage(allow_duplicate_scenarios=False) if not self.tracestate.coverage_reached(): - logger.debug("Direct trace not available. Allowing repetition of scenarios") + logger.debug( + "Direct trace not available. Allowing repetition of scenarios") self._try_to_reach_full_coverage(allow_duplicate_scenarios=True) if not self.tracestate.coverage_reached(): raise Exception("Unable to compose a consistent suite") @@ -103,18 +107,21 @@ def process_test_suite(self, in_suite, *, seed='new'): self._report_tracestate_wrapup() return self.out_suite - def _try_to_reach_full_coverage(self, allow_duplicate_scenarios): + def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool): self.tracestate = TraceState(len(self.scenarios)) self.active_model = ModelSpace() while not self.tracestate.coverage_reached(): - i_candidate = self.tracestate.next_candidate(retry=allow_duplicate_scenarios) + i_candidate = self.tracestate.next_candidate( + retry=allow_duplicate_scenarios) + if i_candidate is None: if not self.tracestate.can_rewind(): break tail = self._rewind() logger.debug("Having to roll back up to " - f"{tail.scenario.name if tail else 'the beginning'}") + f"{tail.scenario.name if tail else 'the beginning'}") self._report_tracestate_to_user() + else: self.active_model.new_scenario_scope() inserted = self._try_to_fit_in_scenario(i_candidate, self._scenario_with_repeat_counter(i_candidate), @@ -122,63 +129,78 @@ def _try_to_reach_full_coverage(self, allow_duplicate_scenarios): if inserted: self.DROUGHT_LIMIT = 50 if self.__last_candidate_changed_nothing(): - logger.debug("Repeated scenario did not change the model's state. Stop trying.") + logger.debug( + "Repeated scenario did not change the model's state. Stop trying.") self._rewind() + elif self.tracestate.coverage_drought > self.DROUGHT_LIMIT: logger.debug(f"Went too long without new coverage (>{self.DROUGHT_LIMIT}x). " "Roll back to last coverage increase and try something else.") self._rewind(drought_recovery=True) self._report_tracestate_to_user() - logger.debug(f"last state:\n{self.active_model.get_status_text()}") + logger.debug( + f"last state:\n{self.active_model.get_status_text()}") - def __last_candidate_changed_nothing(self): + def __last_candidate_changed_nothing(self) -> bool: if len(self.tracestate) < 2: return False + if self.tracestate[-1].id != self.tracestate[-2].id: return False + return self.tracestate[-1].model == self.tracestate[-2].model - def _scenario_with_repeat_counter(self, index): + def _scenario_with_repeat_counter(self, index: int) -> Scenario: """Fetches the scenario by index and, if this scenario is already used in the trace, adds a repetition counter to its name.""" candidate = self.scenarios[index] rep_count = self.tracestate.count(index) + if rep_count: candidate = candidate.copy() - candidate.name = f"{candidate.name} (rep {rep_count+1})" + candidate.name = f"{candidate.name} (rep {rep_count + 1})" return candidate @staticmethod - def _fail_on_step_errors(suite): + def _fail_on_step_errors(suite: Suite): error_list = suite.steps_with_errors() + if error_list: err_msg = "Steps with errors in their model info found:\n" err_msg += '\n'.join([f"{s.keyword} [{s.model_info['error']}] used in {s.parent.name}" - for s in error_list]) + for s in error_list]) raise Exception(err_msg) - def _try_to_fit_in_scenario(self, index, candidate, retry_flag): - candidate = self._generate_scenario_variant(candidate, self.active_model) + def _try_to_fit_in_scenario(self, index: int, candidate: Scenario, retry_flag: bool) -> bool: + candidate = self._generate_scenario_variant( + candidate, self.active_model) + if not candidate: self.active_model.end_scenario_scope() self.tracestate.reject_scenario(index) self._report_tracestate_to_user() return False - confirmed_candidate, new_model = self._process_scenario(candidate, self.active_model) + confirmed_candidate, new_model = self._process_scenario( + candidate, self.active_model) + if confirmed_candidate: self.active_model = new_model self.active_model.end_scenario_scope() - self.tracestate.confirm_full_scenario(index, confirmed_candidate, self.active_model) - logger.debug(f"Inserted scenario {confirmed_candidate.src_id}, {confirmed_candidate.name}") + self.tracestate.confirm_full_scenario( + index, confirmed_candidate, self.active_model) + logger.debug( + f"Inserted scenario {confirmed_candidate.src_id}, {confirmed_candidate.name}") self._report_tracestate_to_user() logger.debug(f"last state:\n{self.active_model.get_status_text()}") return True - part1, part2 = self._split_candidate_if_refinement_needed(candidate, self.active_model) + part1, part2 = self._split_candidate_if_refinement_needed( + candidate, self.active_model) + if part2: exit_conditions = part2.steps[1].model_info['OUT'] - part1.name = f"{part1.name} (part {self.tracestate.highest_part(index)+1})" + part1.name = f"{part1.name} (part {self.tracestate.highest_part(index) + 1})" part1, new_model = self._process_scenario(part1, self.active_model) self.tracestate.push_partial_scenario(index, part1, new_model) self.active_model = new_model @@ -187,13 +209,16 @@ def _try_to_fit_in_scenario(self, index, candidate, retry_flag): i_refine = self.tracestate.next_candidate(retry=retry_flag) if i_refine is None: - logger.debug("Refinement needed, but there are no scenarios left") + logger.debug( + "Refinement needed, but there are no scenarios left") self._rewind() self._report_tracestate_to_user() return False + while i_refine is not None: self.active_model.new_scenario_scope() - m_inserted = self._try_to_fit_in_scenario(i_refine, self._scenario_with_repeat_counter(i_refine), retry_flag) + m_inserted = self._try_to_fit_in_scenario(i_refine, self._scenario_with_repeat_counter(i_refine), + retry_flag) if m_inserted: insert_valid_here = True try: @@ -201,21 +226,28 @@ def _try_to_fit_in_scenario(self, index, candidate, retry_flag): model_scratchpad = self.active_model.copy() for expr in exit_conditions: if model_scratchpad.process_expression(expr, part2.steps[1].args) is False: - insert_valid_here = False - break + insert_valid_here = False + break except Exception: insert_valid_here = False + if insert_valid_here: - m_finished = self._try_to_fit_in_scenario(index, part2, retry_flag) + m_finished = self._try_to_fit_in_scenario( + index, part2, retry_flag) if m_finished: return True else: - logger.debug(f"Scenario did not meet refinement conditions {exit_conditions}") - logger.debug(f"last state:\n{self.active_model.get_status_text()}") - logger.debug(f"Reconsidering {self.scenarios[i_refine].name}, scenario excluded") + logger.debug( + f"Scenario did not meet refinement conditions {exit_conditions}") + logger.debug( + f"last state:\n{self.active_model.get_status_text()}") + logger.debug( + f"Reconsidering {self.scenarios[i_refine].name}, scenario excluded") self._rewind() self._report_tracestate_to_user() + i_refine = self.tracestate.next_candidate(retry=retry_flag) + self._rewind() self._report_tracestate_to_user() return False @@ -225,21 +257,25 @@ def _try_to_fit_in_scenario(self, index, candidate, retry_flag): self._report_tracestate_to_user() return False - def _rewind(self, drought_recovery=False): + def _rewind(self, drought_recovery: bool = False) -> TraceSnapShot | None: tail = self.tracestate.rewind() + while drought_recovery and self.tracestate.coverage_drought: tail = self.tracestate.rewind() + self.active_model = self.tracestate.model or ModelSpace() return tail @staticmethod - def _split_candidate_if_refinement_needed(scenario, model): + def _split_candidate_if_refinement_needed(scenario: Scenario, model: ModelSpace) \ + -> tuple[Scenario, Scenario | None]: m = model.copy() scenario = scenario.copy() no_split = (scenario, None) for step in scenario.steps: if 'error' in step.model_info: return no_split + if step.gherkin_kw in ['given', 'when', None]: for expr in step.model_info.get('IN', []): try: @@ -247,48 +283,62 @@ def _split_candidate_if_refinement_needed(scenario, model): return no_split except Exception: return no_split + if step.gherkin_kw in ['when', 'then', None]: for expr in step.model_info.get('OUT', []): refine_here = False try: if m.process_expression(expr, step.args) is False: if step.gherkin_kw in ['when', None]: - logger.debug(f"Refinement needed for scenario: {scenario.name}\nat step: {step}") + logger.debug( + f"Refinement needed for scenario: {scenario.name}\nat step: {step}") refine_here = True else: return no_split + except Exception: return no_split + if refine_here: - front, back = scenario.split_at_step(scenario.steps.index(step)) - remaining_steps = '\n\t'.join([step.full_keyword, '- '*35] + [s.full_keyword for s in back.steps[1:]]) - remaining_steps = SuiteProcessors.escape_robot_vars(remaining_steps) - edge_step = Step('Log', f"Refinement follows for step:\n\t{remaining_steps}", parent=scenario) + front, back = scenario.split_at_step( + scenario.steps.index(step)) + remaining_steps = '\n\t'.join( + [step.full_keyword, '- ' * 35] + [s.full_keyword for s in back.steps[1:]]) + remaining_steps = SuiteProcessors.escape_robot_vars( + remaining_steps) + edge_step = Step( + 'Log', f"Refinement follows for step:\n\t{remaining_steps}", parent=scenario) edge_step.gherkin_kw = step.gherkin_kw - edge_step.model_info = dict(IN=step.model_info['IN'], OUT=[]) + edge_step.model_info = dict( + IN=step.model_info['IN'], OUT=[]) edge_step.detached = True edge_step.args = StepArguments(step.args) front.steps.append(edge_step) - back.steps.insert(0, Step('Log', f"Refinement ready, completing step", parent=scenario)) + back.steps.insert( + 0, Step('Log', f"Refinement ready, completing step", parent=scenario)) back.steps[1] = back.steps[1].copy() back.steps[1].model_info['IN'] = [] return (front, back) + return no_split @staticmethod - def escape_robot_vars(text): + def escape_robot_vars(text: str) -> str: for seq in ("${", "@{", "%{", "&{", "*{"): text = text.replace(seq, "\\" + seq) + return text @staticmethod - def _process_scenario(scenario, model): + def _process_scenario(scenario: Scenario, model: ModelSpace) -> tuple[Scenario, ModelSpace] | tuple[None, None]: m = model.copy() scenario = scenario.copy() for step in scenario.steps: if 'error' in step.model_info: - logger.debug(f"Error in scenario {scenario.name} at step {step}: {step.model_info['error']}") + logger.debug( + f"Error in scenario {scenario.name} at step {step}: {step.model_info['error']}") return None, None + for expr in SuiteProcessors._relevant_expressions(step): try: if m.process_expression(expr, step.args) is False: @@ -297,22 +347,27 @@ def _process_scenario(scenario, model): logger.debug(f"Unable to insert scenario {scenario.src_id}, {scenario.name}, " f"due to step '{step}': [{expr}] {err}") return None, None + return scenario, m @staticmethod - def _relevant_expressions(step): + def _relevant_expressions(step: Step) -> list[str | list[str]]: if step.gherkin_kw is None and not step.model_info: - return [] # model info is optional for action keywords + return [] # model info is optional for action keywords + expressions = [] if 'IN' not in step.model_info or 'OUT' not in step.model_info: raise Exception(f"Model info incomplete for step: {step}") + if step.gherkin_kw in ['given', 'when', None]: expressions += step.model_info['IN'] + if step.gherkin_kw in ['when', 'then', None]: expressions += step.model_info['OUT'] + return expressions - def _generate_scenario_variant(self, scenario, model): + def _generate_scenario_variant(self, scenario: Scenario, model: ModelSpace) -> Scenario | None: m = model.copy() scenario = scenario.copy() scenarios_in_refinement = self.tracestate.find_scenarios_with_active_refinement() @@ -324,33 +379,49 @@ def _generate_scenario_variant(self, scenario, model): # collect set of constraints subs = SubstitutionMap() - try: + try: # TODO: look into refactoring this... interestingly structured code. for step in scenario.steps: if 'MOD' in step.model_info: for expr in step.model_info['MOD']: - modded_arg, constraint = self._parse_modifier_expression(expr, step.args) + modded_arg, constraint = self._parse_modifier_expression( + expr, step.args) + if step.args[modded_arg].kind != StepArgument.EMBEDDED: - raise ValueError("Modifers are currently only supported for embedded arguments.") + raise ValueError( + "Modifers are currently only supported for embedded arguments.") + org_example = step.args[modded_arg].org_value if step.gherkin_kw == 'then': - constraint = None # No new constraints are processed for then-steps + constraint = None # No new constraints are processed for then-steps if org_example not in subs.substitutions: # if a then-step signals the first use of an example value, it is considered a new definition subs.substitute(org_example, [org_example]) continue + if not constraint and org_example not in subs.substitutions: - raise ValueError(f"No options to choose from at first assignment to {org_example}") + raise ValueError( + f"No options to choose from at first assignment to {org_example}") + if constraint and constraint != '.*': - options = m.process_expression(constraint, step.args) + options = m.process_expression( + constraint, step.args) if options == 'exec': - raise ValueError(f"Invalid constraint for argument substitution: {expr}") + raise ValueError( + f"Invalid constraint for argument substitution: {expr}") + if not options: - raise ValueError(f"Constraint on modifer did not yield any options: {expr}") + raise ValueError( + f"Constraint on modifer did not yield any options: {expr}") + if not is_list_like(options): - raise ValueError(f"Constraint on modifer did not yield a set of options: {expr}") + raise ValueError( + f"Constraint on modifer did not yield a set of options: {expr}") + else: options = None + subs.substitute(org_example, options) + except Exception as err: logger.debug(f"Unable to insert scenario {scenario.src_id}, {scenario.name}, due to modifier\n" f" In step {step}: {err}") @@ -359,31 +430,41 @@ def _generate_scenario_variant(self, scenario, model): try: subs.solve() except ValueError as err: - logger.debug(f"Unable to insert scenario {scenario.src_id}, {scenario.name}, due to modifier\n {err}: {subs}") + logger.debug( + f"Unable to insert scenario {scenario.src_id}, {scenario.name}, due to modifier\n {err}: {subs}") return None # Update scenario with generated values if subs.solution: - logger.debug(f"Example variant generated with argument substitution: {subs}") + logger.debug( + f"Example variant generated with argument substitution: {subs}") + scenario.data_choices = subs + for step in scenario.steps: if 'MOD' in step.model_info: for expr in step.model_info['MOD']: - modded_arg, _ = self._parse_modifier_expression(expr, step.args) + modded_arg, _ = self._parse_modifier_expression( + expr, step.args) org_example = step.args[modded_arg].org_value step.args[modded_arg].value = subs.solution[org_example] + return scenario @staticmethod - def _parse_modifier_expression(expression, args): + def _parse_modifier_expression(expression: str, args: StepArguments) -> tuple[str, str]: if expression.startswith('${'): for var in args: if expression.casefold().startswith(var.arg.casefold()): - assignment_expr = expression.replace(var.arg, '', 1).strip() + assignment_expr = expression.replace( + var.arg, '', 1).strip() + if not assignment_expr.startswith('=') or assignment_expr.startswith('=='): - break # not an assignment + break # not an assignment + constraint = assignment_expr.replace('=', '', 1).strip() return var.arg, constraint + raise ValueError(f"Invalid argument substitution: {expression}") def _report_tracestate_to_user(self): @@ -391,8 +472,10 @@ def _report_tracestate_to_user(self): for snapshot in self.tracestate: part = f".{snapshot.id.split('.')[1]}" if '.' in snapshot.id else "" user_trace += f"{snapshot.scenario.src_id}{part}, " + user_trace = user_trace[:-2] + "]" if ',' in user_trace else "[]" - reject_trace = [self.scenarios[i].src_id for i in self.tracestate.tried] + reject_trace = [ + self.scenarios[i].src_id for i in self.tracestate.tried] logger.debug(f"Trace: {user_trace} Reject: {reject_trace}") def _report_tracestate_wrapup(self): @@ -402,11 +485,13 @@ def _report_tracestate_wrapup(self): logger.debug(f"model\n{step.model.get_status_text()}\n") @staticmethod - def _init_randomiser(seed): + def _init_randomiser(seed: any): if isinstance(seed, str): seed = seed.strip() + if str(seed).lower() == 'none': - logger.debug(f"Using system's random seed for trace generation. This trace cannot be rerun. Use `seed=new` to generate a reusable seed.") + logger.debug( + f"Using system's random seed for trace generation. This trace cannot be rerun. Use `seed=new` to generate a reusable seed.") elif str(seed).lower() == 'new': new_seed = SuiteProcessors._generate_seed() logger.debug(f"seed={new_seed} (use seed to rerun this trace)") @@ -416,24 +501,30 @@ def _init_randomiser(seed): random.seed(seed) @staticmethod - def _generate_seed(): + def _generate_seed() -> str: """Creates a random string of 5 words between 3 and 6 letters long""" vowels = ['a', 'e', 'i', 'o', 'u', 'y'] - consonants = ['b', 'c', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'm', 'n', 'p', 'q', 'r', 's', 't', 'v', 'w', 'x', 'z'] + consonants = ['b', 'c', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'm', 'n', 'p', 'q', 'r', 's', 't', 'v', 'w', 'x', + 'z'] words = [] for word in range(5): prior_choice = random.choice([vowels, consonants]) - last_choice = random.choice([vowels, consonants]) - string = random.choice(prior_choice) + random.choice(last_choice) # add first two letters - for letter in range(random.randint(1, 4)): # add 1 to 4 more letters + last_choice = random.choice([vowels, consonants]) + + # add first two letters + string = random.choice(prior_choice) + random.choice(last_choice) + for letter in range(random.randint(1, 4)): # add 1 to 4 more letters if prior_choice is last_choice: new_choice = consonants if prior_choice is vowels else vowels else: new_choice = random.choice([vowels, consonants]) + prior_choice = last_choice last_choice = new_choice string += random.choice(new_choice) + words.append(string) + seed = '-'.join(words) return seed diff --git a/robotmbt/suitereplacer.py b/robotmbt/suitereplacer.py index dec54104..645a93a8 100644 --- a/robotmbt/suitereplacer.py +++ b/robotmbt/suitereplacer.py @@ -1,4 +1,10 @@ # -*- coding: utf-8 -*- +from .suitedata import Suite, Scenario, Step +from .suiteprocessors import SuiteProcessors +import robot.running.model as rmodel +from robot.api import logger +from robot.api.deco import keyword +from typing import Self # BSD 3-Clause License # @@ -30,41 +36,42 @@ # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -from robot.libraries.BuiltIn import BuiltIn;Robot = BuiltIn() -from robot.api.deco import keyword -from robot.api import logger -import robot.running.model as rmodel +from robot.libraries.BuiltIn import BuiltIn + +Robot = BuiltIn() -from .suiteprocessors import SuiteProcessors -from .suitedata import Suite, Scenario, Step class SuiteReplacer: - ROBOT_LIBRARY_SCOPE = 'GLOBAL' - ROBOT_LISTENER_API_VERSION = 3 - - def __init__(self, processor='process_test_suite', processor_lib=None): - self.ROBOT_LIBRARY_LISTENER = self - self.current_suite = None - self.robot_suite = None - self.processor_lib_name = processor_lib - self.processor_name = processor - self._processor_lib = None + ROBOT_LIBRARY_SCOPE: str = 'GLOBAL' + ROBOT_LISTENER_API_VERSION: int = 3 + + def __init__(self, processor: str = 'process_test_suite', processor_lib: str | None = None): + self.ROBOT_LIBRARY_LISTENER: Self = self + self.current_suite: Suite | None = None + self.robot_suite: Suite | None = None + self.processor_lib_name: str | None = processor_lib + self.processor_name: str = processor + self._processor_lib: SuiteProcessors | None = None self._processor_method = None self.processor_options = {} @property - def processor_lib(self): + def processor_lib(self) -> SuiteProcessors: if self._processor_lib is None: self._processor_lib = SuiteProcessors() if self.processor_lib_name is None \ - else Robot.get_library_instance(self.processor_lib_name) + else Robot.get_library_instance(self.processor_lib_name) return self._processor_lib @property def processor_method(self): if self._processor_method is None: if not hasattr(self.processor_lib, self.processor_name): - Robot.fail(f"Processor '{self.processor_name}' not available for model-based processor library {self.processor_lib_name}") - self._processor_method = getattr(self._processor_lib, self.processor_name) + Robot.fail( + f"Processor '{self.processor_name}' not available for model-based processor library {self.processor_lib_name}") + + self._processor_method = getattr( + self._processor_lib, self.processor_name) + return self._processor_method @keyword(name="Treat this test suite Model-based") @@ -81,10 +88,16 @@ def treat_model_based(self, **kwargs): """ self.robot_suite = self.current_suite - logger.info(f"Analysing Robot test suite '{self.robot_suite.name}' for model-based execution.") + logger.info( + f"Analysing Robot test suite '{self.robot_suite.name}' for model-based execution.") + self.update_model_based_options(**kwargs) - master_suite = self.__process_robot_suite(self.robot_suite, parent=None) - modelbased_suite = self.processor_method(master_suite, **self.processor_options) + master_suite = self.__process_robot_suite( + self.robot_suite, parent=None) + + modelbased_suite = self.processor_method( + master_suite, **self.processor_options) + self.__clearTestSuite(self.robot_suite) self.__generateRobotSuite(modelbased_suite, self.robot_suite) @@ -104,53 +117,73 @@ def update_model_based_options(self, **kwargs): """ self.processor_options.update(kwargs) - def __process_robot_suite(self, in_suite, parent): + def __process_robot_suite(self, in_suite: Suite, parent: Suite | None) -> Suite: out_suite = Suite(in_suite.name, parent) out_suite.filename = in_suite.source if in_suite.setup and parent is not None: - step_info = Step(in_suite.setup.name, *in_suite.setup.args, parent=out_suite) - step_info.add_robot_dependent_data(Robot._namespace.get_runner(step_info.org_step).keyword) + step_info = Step(in_suite.setup.name, * + in_suite.setup.args, parent=out_suite) + step_info.add_robot_dependent_data( + Robot._namespace.get_runner(step_info.org_step).keyword) out_suite.setup = step_info + if in_suite.teardown and parent is not None: - step_info =Step(in_suite.teardown.name, *in_suite.teardown.args, parent=out_suite) - step_info.add_robot_dependent_data(Robot._namespace.get_runner(step_info.org_step).keyword) + step_info = Step(in_suite.teardown.name, * + in_suite.teardown.args, parent=out_suite) + step_info.add_robot_dependent_data( + Robot._namespace.get_runner(step_info.org_step).keyword) out_suite.teardown = step_info + for st in in_suite.suites: - out_suite.suites.append(self.__process_robot_suite(st, parent=out_suite)) + out_suite.suites.append( + self.__process_robot_suite(st, parent=out_suite)) + for tc in in_suite.tests: scenario = Scenario(tc.name, parent=out_suite) if tc.setup: - step_info = Step(tc.setup.name, *tc.setup.args, parent=scenario) - step_info.add_robot_dependent_data(Robot._namespace.get_runner(step_info.org_step).keyword) + step_info = Step( + tc.setup.name, *tc.setup.args, parent=scenario) + step_info.add_robot_dependent_data( + Robot._namespace.get_runner(step_info.org_step).keyword) scenario.setup = step_info + if tc.teardown: - step_info = Step(tc.teardown.name, *tc.teardown.args, parent=scenario) - step_info.add_robot_dependent_data(Robot._namespace.get_runner(step_info.org_step).keyword) + step_info = Step(tc.teardown.name, * + tc.teardown.args, parent=scenario) + step_info.add_robot_dependent_data( + Robot._namespace.get_runner(step_info.org_step).keyword) scenario.teardown = step_info last_gwt = None + for step_def in tc.body: if isinstance(step_def, rmodel.Keyword): step_info = Step(step_def.name, *step_def.args, parent=scenario, assign=step_def.assign, prev_gherkin_kw=last_gwt) - step_info.add_robot_dependent_data(Robot._namespace.get_runner(step_info.org_step).keyword) + step_info.add_robot_dependent_data( + Robot._namespace.get_runner(step_info.org_step).keyword) scenario.steps.append(step_info) + if step_info.gherkin_kw: last_gwt = step_info.gherkin_kw + elif isinstance(step_def, rmodel.Var): - scenario.steps.append(Step('VAR', step_def.name, *step_def.value, parent=scenario)) + scenario.steps.append( + Step('VAR', step_def.name, *step_def.value, parent=scenario)) else: unsupported_step = Step(str(step_def), parent=scenario) unsupported_step.model_info['error'] = f"Robot construct not supported" scenario.steps.append(unsupported_step) + out_suite.scenarios.append(scenario) + return out_suite - def __clearTestSuite(self, suite): + def __clearTestSuite(self, suite: Suite): suite.tests.clear() suite.suites.clear() - def __generateRobotSuite(self, suite_model, target_suite): + def __generateRobotSuite(self, suite_model: Suite, target_suite): for subsuite in suite_model.suites: new_suite = target_suite.suites.create(name=subsuite.name) new_suite.resource = target_suite.resource @@ -166,22 +199,24 @@ def __generateRobotSuite(self, suite_model, target_suite): for tc in suite_model.scenarios: new_tc = target_suite.tests.create(name=tc.name) if tc.setup: - new_tc.setup= rmodel.Keyword(name=tc.setup.keyword, - args=tc.setup.posnom_args_str, - type='setup') + new_tc.setup = rmodel.Keyword(name=tc.setup.keyword, + args=tc.setup.posnom_args_str, + type='setup') if tc.teardown: - new_tc.teardown= rmodel.Keyword(name=tc.teardown.keyword, - args=tc.teardown.posnom_args_str, - type='teardown') + new_tc.teardown = rmodel.Keyword(name=tc.teardown.keyword, + args=tc.teardown.posnom_args_str, + type='teardown') for step in tc.steps: if step.keyword == 'VAR': - new_tc.body.create_var(step.posnom_args_str[0], step.posnom_args_str[1:]) + new_tc.body.create_var( + step.posnom_args_str[0], step.posnom_args_str[1:]) else: - new_tc.body.create_keyword(name=step.keyword, assign=step.assign, args=step.posnom_args_str) + new_tc.body.create_keyword( + name=step.keyword, assign=step.assign, args=step.posnom_args_str) - def _start_suite(self, suite, result): + def _start_suite(self, suite: Suite | None, result): self.current_suite = suite - def _end_suite(self, suite, result): + def _end_suite(self, suite: Suite | None, result): if suite == self.robot_suite: self.robot_suite = None diff --git a/robotmbt/tracestate.py b/robotmbt/tracestate.py index 4d0e526c..59b0151c 100644 --- a/robotmbt/tracestate.py +++ b/robotmbt/tracestate.py @@ -1,4 +1,6 @@ # -*- coding: utf-8 -*- +from robotmbt.suitedata import Scenario + # BSD 3-Clause License # @@ -30,81 +32,103 @@ # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +class TraceSnapShot: + def __init__(self, id: str, inserted_scenario: str | Scenario, model_state: dict[str, int], drought: int = 0): + self.id: str = id + self.scenario: str | Scenario = inserted_scenario + self.model: dict[str, int] = model_state.copy() + self.coverage_drought: int = drought + + class TraceState: - def __init__(self, n_scenarios): - self._c_pool = [False] * n_scenarios # coverage pool: True means scenario is in trace - self._tried = [[]] # Keeps track of the scenarios already tried at each step in the trace - self._trace = [] # Choice trace, when was which scenario inserted (e.g. ['1', '2.1', '3', '2.0']) - self._snapshots = [] # Keeps details for elements in trace - self._open_refinements = [] + def __init__(self, n_scenarios: int): + # coverage pool: True means scenario is in trace + self._c_pool: list[bool] = [False] * n_scenarios + + # Keeps track of the scenarios already tried at each step in the trace + self._tried: list[list[int]] = [[]] + + # Choice trace, when was which scenario inserted (e.g. ['1', '2.1', '3', '2.0']) + self._trace: list[str] = [] + + # Keeps details for elements in trace + self._snapshots: list[TraceSnapShot] = [] + self._open_refinements: list[int] = [] @property - def model(self): + def model(self) -> dict[str, int] | None: """returns the model as it is at the end of the current trace""" return self._snapshots[-1].model if self._trace else None @property - def tried(self): + def tried(self) -> tuple[int]: """returns the indices that were rejected or previously inserted at the current position""" return tuple(self._tried[-1]) - def coverage_reached(self): + def coverage_reached(self) -> bool: return all(self._c_pool) @property - def coverage_drought(self): + def coverage_drought(self) -> int: """Number of scenarios since last new coverage""" return self._snapshots[-1].coverage_drought if self._snapshots else 0 - def get_trace(self): + def get_trace(self) -> list[str]: return [snap.scenario for snap in self._snapshots] - def next_candidate(self, retry=False): + def next_candidate(self, retry: bool = False) -> int | None: for i in range(len(self._c_pool)): if i not in self._tried[-1] and not self._is_refinement_active(i) and self.count(i) == 0: return i + if not retry: return None + for i in range(len(self._c_pool)): if i not in self._tried[-1] and not self._is_refinement_active(i): return i + return None - def count(self, index): + def count(self, index: int) -> int: """Count the number of times the index is present in the trace. unfinished partial scenarios are excluded.""" return self._trace.count(str(index)) + self._trace.count(str(f"{index}.0")) - def highest_part(self, index): + def highest_part(self, index: int) -> int: """Given the current trace and an index, returns the highest part number of an ongoing refinement for the related scenario. Returns 0 when there is no refinement active.""" for i in range(1, len(self._trace)+1): if self._trace[-i] == f'{index}': return 0 + if self._trace[-i].startswith(f'{index}.'): return int(self._trace[-i].split('.')[1]) + return 0 - def _is_refinement_active(self, index): + def _is_refinement_active(self, index: int) -> bool: return self.highest_part(index) != 0 - def find_scenarios_with_active_refinement(self): + def find_scenarios_with_active_refinement(self) -> list[str | Scenario]: scenarios = [] for i in self._open_refinements: index = -self._trace[::-1].index(f'{i}.1')-1 scenarios.append(self._snapshots[index].scenario) + return scenarios - def reject_scenario(self, i_scenario): + def reject_scenario(self, i_scenario: int): """Trying a scenario excludes it from further cadidacy on this level""" self._tried[-1].append(i_scenario) - def confirm_full_scenario(self, index, scenario, model): + def confirm_full_scenario(self, index: int, scenario: str, model: dict[str, int]): if not self._c_pool[index]: self._c_pool[index] = True c_drought = 0 else: c_drought = self.coverage_drought+1 + if self._is_refinement_active(index): id = f"{index}.0" self._open_refinements.pop() @@ -112,24 +136,27 @@ def confirm_full_scenario(self, index, scenario, model): id = str(index) self._tried[-1].append(index) self._tried.append([]) + self._trace.append(id) self._snapshots.append(TraceSnapShot(id, scenario, model, c_drought)) - def push_partial_scenario(self, index, scenario, model): + def push_partial_scenario(self, index: int, scenario: str, model: dict[str, int]): if self._is_refinement_active(index): id = f"{index}.{self.highest_part(index)+1}" + else: id = f"{index}.1" self._tried[-1].append(index) self._tried.append([]) self._open_refinements.append(index) self._trace.append(id) - self._snapshots.append(TraceSnapShot(id, scenario, model, self.coverage_drought)) + self._snapshots.append(TraceSnapShot( + id, scenario, model, self.coverage_drought)) - def can_rewind(self): + def can_rewind(self) -> bool: return len(self._trace) > 0 - def rewind(self): + def rewind(self) -> TraceSnapShot | None: id = self._trace.pop() index = int(id.split('.')[0]) if id.endswith('.0'): @@ -144,8 +171,10 @@ def rewind(self): if self.count(index) == 0: self._c_pool[index] = False self._tried.pop() + if id.endswith('.1'): self._open_refinements.pop() + return self._snapshots[-1] if self._snapshots else None def __iter__(self): @@ -156,10 +185,3 @@ def __getitem__(self, key): def __len__(self): return len(self._snapshots) - -class TraceSnapShot: - def __init__(self, id, inserted_scenario, model_state, drought=0): - self.id = id - self.scenario = inserted_scenario - self.model = model_state.copy() - self.coverage_drought = drought diff --git a/robotmbt/version.py b/robotmbt/version.py index b9b28aa2..58e1a598 100644 --- a/robotmbt/version.py +++ b/robotmbt/version.py @@ -1 +1 @@ -VERSION = '0.9.0' +VERSION: str = '0.9.0' From b360e92f450371ae55bf633c2fb350805695eded Mon Sep 17 00:00:00 2001 From: tychodub <93142605+tychodub@users.noreply.github.com> Date: Wed, 8 Oct 2025 10:40:10 +0200 Subject: [PATCH 002/131] fixed typing of get_trace in tracestate.py (#5) --- robotmbt/tracestate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/robotmbt/tracestate.py b/robotmbt/tracestate.py index 59b0151c..9e8dbb5f 100644 --- a/robotmbt/tracestate.py +++ b/robotmbt/tracestate.py @@ -73,7 +73,7 @@ def coverage_drought(self) -> int: """Number of scenarios since last new coverage""" return self._snapshots[-1].coverage_drought if self._snapshots else 0 - def get_trace(self) -> list[str]: + def get_trace(self) -> list[str | Scenario]: return [snap.scenario for snap in self._snapshots] def next_candidate(self, retry: bool = False) -> int | None: From 3e8952f358db869907290649ac754a6168e14758 Mon Sep 17 00:00:00 2001 From: Thomas Kas Date: Thu, 9 Oct 2025 11:52:59 +0200 Subject: [PATCH 003/131] Jetbrains gitignore (#7) --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 727c718a..bb2d04ff 100644 --- a/.gitignore +++ b/.gitignore @@ -152,4 +152,4 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ From 3878cc0f696a9d19372162deda5ec60ddf639570 Mon Sep 17 00:00:00 2001 From: tychodub <93142605+tychodub@users.noreply.github.com> Date: Thu, 9 Oct 2025 12:16:57 +0200 Subject: [PATCH 004/131] Create python-app.yml, for Github actions * Create python-app.yml, for Github actions Create python-app.yml, runs utests when pushing/merging pull-request into main * test run actual python file * use python latest version * use python version 3.13 * fix comment * actually return exit code that robot returns --------- Co-authored-by: Douwe Osinga --- .github/workflows/python-app.yml | 34 ++++++++++++++++++++++++++++++++ run_tests.py | 4 +++- 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/python-app.yml diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml new file mode 100644 index 00000000..fa29c16c --- /dev/null +++ b/.github/workflows/python-app.yml @@ -0,0 +1,34 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +name: Python application + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: "3.13" + - name: Install dependencies + run: | + python -m pip install --upgrade pip # upgrade pip to latest version + pip install . # install PyProject.toml dependencies + - name: Test with pytest + run: | + python run_tests.py + #pytest # test unit tests only + diff --git a/run_tests.py b/run_tests.py index c0c66bea..3289d173 100644 --- a/run_tests.py +++ b/run_tests.py @@ -40,8 +40,10 @@ # Adding the robotframeworkMBT folder to the python path forces the development # version to be used instead of the one installed on your system. You will also # need to add this path to your IDE options when running from there. - robot.run_cli(['--outputdir', OUTPUT_ROOT, + exit_code :int = robot.run_cli(['--outputdir', OUTPUT_ROOT, # type: ignore '--pythonpath', THIS_DIR] + sys.argv[1:], exit=False) if utest: print(f"Also ran {utestrun.result.testsRun} unit tests") + + sys.exit(exit_code) From 6a6f4b4530d29e7af72d5a57830a1756b4b19519 Mon Sep 17 00:00:00 2001 From: D Osinga <46380170+osingaatje@users.noreply.github.com> Date: Mon, 20 Oct 2025 22:04:57 +0200 Subject: [PATCH 005/131] Visual Paradigm Architectural Designs (#8) * added ignore for VPP * added initial VPP * vpp * modified gitignore * added new diagram - extension plan * added initial renders * .vpp commit * added improved event-based design * changed design to accomodate different graph repr. better * vpp --- .gitignore | 4 + DESIGN/Render/2025-09-25/RobotMBT-current.svg | 1322 +++++++++++ .../2025-09-25/RobotMBT-extensionplan.svg | 2037 +++++++++++++++++ .../2025-10-15/RobotMBT-extensionplan.svg | 1474 ++++++++++++ DESIGN/VPP/RobotMBT.vpp | Bin 0 -> 1179648 bytes 5 files changed, 4837 insertions(+) create mode 100644 DESIGN/Render/2025-09-25/RobotMBT-current.svg create mode 100644 DESIGN/Render/2025-09-25/RobotMBT-extensionplan.svg create mode 100644 DESIGN/Render/2025-10-15/RobotMBT-extensionplan.svg create mode 100644 DESIGN/VPP/RobotMBT.vpp diff --git a/.gitignore b/.gitignore index bb2d04ff..2ff75fba 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ __pycache__/ # C extensions *.so +# VPP (Visual Paradigm) files +*.vpp.bak_* +*.vpp.lck + # Distribution / packaging .Python build/ diff --git a/DESIGN/Render/2025-09-25/RobotMBT-current.svg b/DESIGN/Render/2025-09-25/RobotMBT-current.svg new file mode 100644 index 00000000..7e9e5c83 --- /dev/null +++ b/DESIGN/Render/2025-09-25/RobotMBT-current.svg @@ -0,0 +1,1322 @@ + + ++ref_id : str+standard_attrs : list[?]+properties : dict[?,?]+values : dict[?,?]+scenario_variables : list[?]+add_prop(name : str)+del_prop(name : str)+new_scenario_scope()+end_scenario_scope()+process_expression(expr : str) : objectModelSpacethe current +ROBOT_LIBRARY_LISTENER : self+current_suite+robot_suite+processor_lib_name : str+__processor_lib+__processor_method+processor_options : dict[?,?]+__generateRobotSuite()+__init__(processor : str, processor_lib : Library|None)SuiteReplacer+EMBEDDED+POSITIONAL+VAR_POS+NAMED+FREE_NAMED+arg() : str+value() : any+modified() : bool+codestring() : strStepArgumentStepArguments<<primitive>>list+substitutions : dict [any,Constraint]+solutions : dict [any,any]+substitute(example_value, constraint)+solve() : dict [any,any]SubstitutionMap+optionset : list [str]+removed_stack : list [str]+__init__(constraint : list [str])ConstraintPlaceholder+name : str+filename : str+parent : ?+suites : list[?]+scenarios : list [Scenario]+setup : Step|None+teardown : Step|NoneSuite+name : str+parent : Scenario|None+setup : Step|None+teardown : Step|None+steps : list [Step]+src_id : ?|None+data_choices : dict[?,?]Scenario+org_step : str+org_pn_args : args+parent : Scenario+assign : ?+gherkin_kw [given,when,then]+signature+args : StepArguments+detached : bool+model_info : dict[?,?]Step-__init__(outer : Scope)-__getattr__(attr : str)-__setattr__(attr : str, value : obj)-__iter__()-__bool__()RecursiveScopeDefines -tracestate : TraceState+flatten(in_suite : Suite)+process_test_suite(in_suite : Suite, seed : str)-_try_to_reach_full_coverage(allow_duplicate_scenarios : bool)-_try_to_fit_in_scenario(index, candidate, retry_flag)SuiteProcessors-_c_pool : list [bool]-_tried : list [list[?]-_trace : list [str]-_snapshots : list[?]-_open_refinements : list[?]+model() : ?+tried() : tuple[?]+coverage_reached() : bool+coverage_drought() : int+next_candidate(retry : bool)+count(index : str)+highest_part(index : str)+find_scenarios_with_active_refinement() : list[?]TraceState+id : ?+inserted_scenario : ?+model_state : ?+drought : intTraceSnapShot1*1*1*1*suiteprocessors.pysuitedata.pymodelspace.pysteparguments.pysubstitutionmap.pysuitereplacer.pytracestate.py<<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>>"substitutions"Powered ByVisual Paradigm Community Edition diff --git a/DESIGN/Render/2025-09-25/RobotMBT-extensionplan.svg b/DESIGN/Render/2025-09-25/RobotMBT-extensionplan.svg new file mode 100644 index 00000000..1c4ee17e --- /dev/null +++ b/DESIGN/Render/2025-09-25/RobotMBT-extensionplan.svg @@ -0,0 +1,2037 @@ + + ++ref_id : str+standard_attrs : list[?]+properties : dict[?,?]+values : dict[?,?]+scenario_variables : list[?]+add_prop(name : str)+del_prop(name : str)+new_scenario_scope()+end_scenario_scope()+process_expression(expr : str) : objectModelSpacethe current +ROBOT_LIBRARY_LISTENER : self+current_suite+robot_suite+processor_lib_name : str+__processor_lib+__processor_method+processor_options : dict[?,?]+__generateRobotSuite()+__init__(processor : str, processor_lib : Library|None)SuiteReplacer+EMBEDDED+POSITIONAL+VAR_POS+NAMED+FREE_NAMED+arg() : str+value() : any+modified() : bool+codestring() : strStepArgumentStepArguments<<primitive>>list+substitutions : dict [any,Constraint]+solutions : dict [any,any]+substitute(example_value, constraint)+solve() : dict [any,any]SubstitutionMap+optionset : list [str]+removed_stack : list [str]+__init__(constraint : list [str])ConstraintPlaceholder+name : str+filename : str+parent : ?+suites : list[?]+scenarios : list [Scenario]+setup : Step|None+teardown : Step|NoneSuite+name : str+parent : Scenario|None+setup : Step|None+teardown : Step|None+steps : list [Step]+src_id : ?|None+data_choices : dict[?,?]Scenario+org_step : str+org_pn_args : args+parent : Scenario+assign : ?+gherkin_kw [given,when,then]+signature+args : StepArguments+detached : bool+model_info : dict[?,?]Step-__init__(outer : Scope)-__getattr__(attr : str)-__setattr__(attr : str, value : obj)-__iter__()-__bool__()RecursiveScopeDefines -tracestate : TraceState+flatten(in_suite : Suite)+process_test_suite(in_suite : Suite, seed : str)-_try_to_reach_full_coverage(allow_duplicate_scenarios : bool)-_try_to_fit_in_scenario(index, candidate, retry_flag)-_report_tracestate_wrapup()SuiteProcessors-_c_pool : list [bool]-_tried : list [list[?]-_trace : list [str]-_snapshots : list[?]-_open_refinements : list[?]+model() : ?+tried() : tuple[?]+coverage_reached() : bool+coverage_drought() : int+next_candidate(retry : bool)+count(index : str)+highest_part(index : str)+find_scenarios_with_active_refinement() : list[?]TraceState+id : ?+inserted_scenario : ?+model_state : ?+drought : intTraceSnapShot+scenarios : dict [str,Scenario]+trace : TraceState+todo other stuffVisualisationInfo+generate_visualisation(flags)Visualiser+generate_interactive(TraceNetwork) : void+generate_html(TraceNetwork) : strNetworkVisualiserGUIButtonGUIProgressBar+pre_process_trace(TraceState) : TraceNetworkVisualPreProcessor+scenarios : dict [str,Scenario]+scenarioOrder : dict [Scenario,list Scenario]TraceNetwork...+run_tests(args)RunTestsRobot+get_trace_state() : VisualisationInfoTracePreparerdependencies to all the other 1*1*1*1*modelsvisualiser-processingvisualisersgui-componentssuiteprocessors.pysuitedata.pymodelspace.pysteparguments.pysubstitutionmap.pysuitereplacer.pytracestate.py<<use>><<use>>produces ><<use>>Hooks into the methods of Robot<<use>><<use>>uses < to generate GUIuses > to generatedata structures<<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>>"substitutions"Powered ByVisual Paradigm Community Edition diff --git a/DESIGN/Render/2025-10-15/RobotMBT-extensionplan.svg b/DESIGN/Render/2025-10-15/RobotMBT-extensionplan.svg new file mode 100644 index 00000000..c74ab783 --- /dev/null +++ b/DESIGN/Render/2025-10-15/RobotMBT-extensionplan.svg @@ -0,0 +1,1474 @@ + + ++ref_id : str+standard_attrs : list[?]+properties : dict[?,?]+values : dict[?,?]+scenario_variables : list[?]+add_prop(name : str)+del_prop(name : str)+new_scenario_scope()+end_scenario_scope()+process_expression(expr : str) : objectModelSpacethe current +name : str+filename : str+parent : ?+suites : list[?]+scenarios : list [Scenario]+setup : Step|None+teardown : Step|NoneSuite+name : str+parent : Scenario|None+setup : Step|None+teardown : Step|None+steps : list [Step]+src_id : ?|None+data_choices : dict[?,?]Scenario+org_step : str+org_pn_args : args+parent : Scenario+assign : ?+gherkin_kw [given,when,then]+signature+args : StepArguments+detached : bool+model_info : dict[?,?]Step-__init__(outer : Scope)-__getattr__(attr : str)-__setattr__(attr : str, value : obj)-__iter__()-__bool__()RecursiveScopeDefines -tracestate : TraceState+flatten(in_suite : Suite)+process_test_suite(in_suite : Suite, seed : str)-_try_to_reach_full_coverage(allow_duplicate_scenarios : bool)-_try_to_fit_in_scenario(index, candidate, retry_flag)-_report_tracestate_wrapup()SuiteProcessors-_c_pool : list [bool]-_tried : list [list[?]-_trace : list [str]-_snapshots : list[?]-_open_refinements : list[?]+model() : ?+tried() : tuple[?]+coverage_reached() : bool+coverage_drought() : int+next_candidate(retry : bool)+count(index : str)+highest_part(index : str)+find_scenarios_with_active_refinement() : list[?]TraceState+id : ?+inserted_scenario : ?+model_state : ?+drought : intTraceSnapShot+scenarios : dict [str,Scenario]+trace : TraceState+modelspace : ModelSpaceTraceInfo+update_visualisation(Suite, TraceInfo)Visualiser+generate_interactive(GraphInstance) : void+generate_html(GraphInstance) : strNetworkVisualiserGUIButtonGUIProgressBar+pre_process_trace(TraceState) : IGraphTraceTransfomer+nodes+edgesIGraph...+run_tests(args)RunTestsRobot+traceUpdate(Suite, TraceState, ModelSpace)TraceGathererScenarioGraphStateGraphNetworkX 1*1*gui-componentssuiteprocessors.pysuitedata.pymodelspace.pytracestate.pyvisualisersvisualiser-processingmodels1. constructs2. notifies ^calls ^ uponupdate<<use>><<use>><<use>><<use>><<use>><<use>>uses < to generate GUIuses > to generatedata structures<<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>>Powered ByVisual Paradigm Community Edition diff --git a/DESIGN/VPP/RobotMBT.vpp b/DESIGN/VPP/RobotMBT.vpp new file mode 100644 index 0000000000000000000000000000000000000000..735e8c10bce86c2129fcf0623e00692d8d732c29 GIT binary patch literal 1179648 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⧁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

JWG9mpH` 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%0AI@vc~v-k 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 z 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

G+Hkxvt*OWUYhO3CvV3+UNQ^+g?|;_ zXRdFXgz9ToKBnD$gmd@Q+_Ur#c}2foXA*ZTStwu9ULe=Nu;`$=Ii2L&B`<02olkyh z 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൤W}yj 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 BjoAPI literal 0 HcmV?d00001 From b4b6daaaa2b4c5c46598b1ee0920776ba794000c Mon Sep 17 00:00:00 2001 From: D Osinga <46380170+osingaatje@users.noreply.github.com> Date: Wed, 22 Oct 2025 11:46:59 +0200 Subject: [PATCH 006/131] Visualisation architecture (#9) * Implement basic scenario graph architecture (without actual visualisation) * Also output graph if coverage could not be reached * from visualiser.graph to networkx graph * added optional dependencies for visualization feature * rework scenariograph to use networkx * Update python-app.yml to install optional dependencies * moved documentation according to python * remove static variables --------- Co-authored-by: Thomas Co-authored-by: jonathan <148167.jw@gmail.com> Co-authored-by: tychodub Co-authored-by: Jonathan <61787386+JWillegers@users.noreply.github.com> --- .github/workflows/python-app.yml | 2 +- pyproject.toml | 3 + robotmbt/suiteprocessors.py | 12 +++ robotmbt/suitereplacer.py | 10 ++- robotmbt/visualise/visualiser.py | 147 +++++++++++++++++++++++++++++++ 5 files changed, 170 insertions(+), 4 deletions(-) create mode 100644 robotmbt/visualise/visualiser.py diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index fa29c16c..a61f91c8 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -26,7 +26,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip # upgrade pip to latest version - pip install . # install PyProject.toml dependencies + pip install ".[visualization]" # install PyProject.toml dependencies (including optional dependencies) - name: Test with pytest run: | python run_tests.py diff --git a/pyproject.toml b/pyproject.toml index 748cf8f3..4199073b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,3 +28,6 @@ packages = ["robotmbt"] [project.urls] Homepage = "https://github.com/JFoederer/robotframeworkMBT" + +[project.optional-dependencies] +visualization = ["scipy","numpy","networkx","matplotlib"] diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index 0fb992e0..e076d0a0 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -41,9 +41,13 @@ from .suitedata import Suite, Scenario, Step from .tracestate import TraceState, TraceSnapShot from .steparguments import StepArgument, StepArguments +from .visualise.visualiser import Visualiser, TraceInfo, ScenarioInfo, ScenarioGraph class SuiteProcessors: + def __init__(self): + self.visualiser = Visualiser() + def echo(self, in_suite): return in_suite @@ -93,6 +97,8 @@ def process_test_suite(self, in_suite: Suite, *, seed: any = 'new') -> Suite: self._init_randomiser(seed) random.shuffle(self.scenarios) + self.visualiser = Visualiser() + # a short trace without the need for repeating scenarios is preferred self._try_to_reach_full_coverage(allow_duplicate_scenarios=False) @@ -101,10 +107,15 @@ def process_test_suite(self, in_suite: Suite, *, seed: any = 'new') -> Suite: "Direct trace not available. Allowing repetition of scenarios") self._try_to_reach_full_coverage(allow_duplicate_scenarios=True) if not self.tracestate.coverage_reached(): + logger.write(self.visualiser.generate_html(), html=True) raise Exception("Unable to compose a consistent suite") self.out_suite.scenarios = self.tracestate.get_trace() self._report_tracestate_wrapup() + + self.visualiser.set_start(ScenarioInfo(self.tracestate.get_trace()[0])) + self.visualiser.set_end(ScenarioInfo(self.tracestate.get_trace()[-1])) + return self.out_suite def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool): @@ -140,6 +151,7 @@ def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool): self._report_tracestate_to_user() logger.debug( f"last state:\n{self.active_model.get_status_text()}") + self.visualiser.update_visualisation(TraceInfo(self.tracestate, self.active_model)) def __last_candidate_changed_nothing(self) -> bool: if len(self.tracestate) < 2: diff --git a/robotmbt/suitereplacer.py b/robotmbt/suitereplacer.py index 645a93a8..ea0cb893 100644 --- a/robotmbt/suitereplacer.py +++ b/robotmbt/suitereplacer.py @@ -90,17 +90,21 @@ def treat_model_based(self, **kwargs): logger.info( f"Analysing Robot test suite '{self.robot_suite.name}' for model-based execution.") - + self.update_model_based_options(**kwargs) master_suite = self.__process_robot_suite( self.robot_suite, parent=None) - + modelbased_suite = self.processor_method( master_suite, **self.processor_options) - + self.__clearTestSuite(self.robot_suite) self.__generateRobotSuite(modelbased_suite, self.robot_suite) + # TODO: add flag using kwargs to disable this + if isinstance(self.processor_lib, SuiteProcessors): + logger.write(self.processor_lib.visualiser.generate_html(), html=True) + @keyword("Set model-based options") def set_model_based_options(self, **kwargs): """ diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py new file mode 100644 index 00000000..f527789f --- /dev/null +++ b/robotmbt/visualise/visualiser.py @@ -0,0 +1,147 @@ +from robotmbt.modelspace import ModelSpace +from robotmbt.suitedata import Scenario +from robotmbt.tracestate import TraceState +import networkx as nx +import matplotlib.pyplot as plt +#numpy +#scipy + + +class ScenarioInfo: + """ + This contains all information we need from scenarios, abstracting away from the actual Scenario class: + - name + - src_id + """ + + def __init__(self, scenario: Scenario | str): + if isinstance(scenario, Scenario): + self.name = scenario.name + self.src_id = scenario.src_id + else: + self.name = scenario + self.src_id = None + + def __str__(self): + return f"Scen{self.src_id}: {self.name}" + + +class TraceInfo: + """ + This contains all information we need at any given step in trace exploration: + - trace: the strung together scenarios up until this point + - state: the model space + """ + + + def __init__(self, trace: TraceState, state: ModelSpace): + self.trace = [ScenarioInfo(s) for s in trace.get_trace()] + # TODO: actually use state + self.state = state + + +class ScenarioGraph: + """ + The scenario graph is the most basic representation of trace exploration. + It represents scenarios as nodes, and the trace as edges. + """ + def __init__(self): + # We use simplified IDs for nodes, and store the actual scenario info here + self.ids: dict[str, ScenarioInfo] = {} + + # The networkx graph is a directional graph + self.networkx = nx.DiGraph() + + # Stores the position (x, y) of the nodes + self.pos = {} + + # List of nodes which positions cannot be changed + self.fixed = [] + + # add the start node + self.networkx.add_node('start') + + def update_visualisation(self, info: TraceInfo): + """ + Update the visualisation with new trace information from another exploration step. + This will add nodes for all new scenarios in the provided trace, as well as edges for all pairs in the provided trace. + """ + for i in range(0, len(info.trace) - 1): + from_node = self.__get_or_create_id(info.trace[i]) + to_node = self.__get_or_create_id(info.trace[i + 1]) + + if from_node not in self.networkx.nodes: + self.networkx.add_node(from_node, text=self.ids[from_node].name) + if to_node not in self.networkx.nodes: + self.networkx.add_node(to_node, text=self.ids[to_node].name) + + if (from_node, to_node) not in self.networkx.edges: + self.networkx.add_edge(from_node, to_node) + + def __get_or_create_id(self, scenario: ScenarioInfo) -> str: + """ + Get the ID for a scenario that has been added before, or create and store a new one. + """ + for i in self.ids.keys(): + # TODO: decide how to deal with repeating scenarios, this merges repeated scenarios into a single scenario + if self.ids[i].src_id == scenario.src_id: + return i + + new_id = f"node{len(self.ids)}" + self.ids[new_id] = scenario + return new_id + + def set_starting_node(self, scenario: ScenarioInfo): + """ + Update the starting node. + """ + node = self.__get_or_create_id(scenario) + self.networkx.add_edge('start', node) + + def set_ending_node(self, scenario: ScenarioInfo): + """ + Update the end node. + """ + node = self.__get_or_create_id(scenario) + self.pos[node] = (len(self.networkx.nodes), 0) + self.fixed.append(node) + + def calculate_pos(self): + """ + Calculate the position (x, y) for all nodes in self.networkx + """ + self.pos['start'] = (0, len(self.networkx.nodes)) + self.fixed.append('start') + if not self.fixed: + self.pos = nx.spring_layout(self.networkx, seed=42) + else: + self.pos = nx.spring_layout(self.networkx, pos=self.pos, fixed=self.fixed, seed=42, method='energy', gravity=0.25) + + +class Visualiser: + """ + The Visualiser class bridges the different concerns to provide a simple interface through which the graph can be updated, and retrieved in HTML format. + """ + def __init__(self): + self.graph = ScenarioGraph() + + def update_visualisation(self, info: TraceInfo): + self.graph.update_visualisation(info) + + def set_start(self, scenario: ScenarioInfo): + self.graph.set_starting_node(scenario) + + def set_end(self, scenario: ScenarioInfo): + self.graph.set_ending_node(scenario) + + def generate_graph(self): + # temporary code for visualisation + self.graph.calculate_pos() + nx.draw(self.graph.networkx, pos=self.graph.pos, with_labels=True, node_color="lightblue", node_size=600) + plt.show() + + # TODO: use a graph library to actually create a graph + def generate_html(self) -> str: + self.generate_graph() + return f"" + # return f"

nodes: {self.graph.nodes}\nedges: {self.graph.edges}\nstart: {self.graph.start}\nend: {self.graph.end}\nids: {[f"{name}: {str(val)}" for (name, val) in self.graph.ids.items()]}

" From dd971b7529f3ccc766f88b9b3d95b4acbaaa66d4 Mon Sep 17 00:00:00 2001 From: Jonathan <61787386+JWillegers@users.noreply.github.com> Date: Wed, 22 Oct 2025 12:11:48 +0200 Subject: [PATCH 007/131] Restructure + disable check on main (#10) * Seperated ScenarioInfo, TraceInfo, and ScnearioGraph into seperate file according to architecture * Don't run automated testing on main * fixing imports --- .github/workflows/python-app.yml | 4 +- robotmbt/suiteprocessors.py | 3 +- robotmbt/visualise/models.py | 114 ++++++++++++++++++++++++++++++ robotmbt/visualise/visualiser.py | 115 +------------------------------ 4 files changed, 119 insertions(+), 117 deletions(-) create mode 100644 robotmbt/visualise/models.py diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index a61f91c8..8ee45428 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -4,8 +4,8 @@ name: Python application on: - push: - branches: [ "main" ] + # push: + # branches: [ "main" ] pull_request: branches: [ "main" ] diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index e076d0a0..9ba73a3a 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -41,7 +41,8 @@ from .suitedata import Suite, Scenario, Step from .tracestate import TraceState, TraceSnapShot from .steparguments import StepArgument, StepArguments -from .visualise.visualiser import Visualiser, TraceInfo, ScenarioInfo, ScenarioGraph +from .visualise.visualiser import Visualiser +from .visualise.models import TraceInfo, ScenarioInfo class SuiteProcessors: diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py new file mode 100644 index 00000000..b20b971c --- /dev/null +++ b/robotmbt/visualise/models.py @@ -0,0 +1,114 @@ +from robotmbt.modelspace import ModelSpace +from robotmbt.suitedata import Scenario +from robotmbt.tracestate import TraceState +import networkx as nx + +class ScenarioInfo: + """ + This contains all information we need from scenarios, abstracting away from the actual Scenario class: + - name + - src_id + """ + + def __init__(self, scenario: Scenario | str): + if isinstance(scenario, Scenario): + self.name = scenario.name + self.src_id = scenario.src_id + else: + self.name = scenario + self.src_id = None + + def __str__(self): + return f"Scen{self.src_id}: {self.name}" + + +class TraceInfo: + """ + This contains all information we need at any given step in trace exploration: + - trace: the strung together scenarios up until this point + - state: the model space + """ + + + def __init__(self, trace: TraceState, state: ModelSpace): + self.trace = [ScenarioInfo(s) for s in trace.get_trace()] + # TODO: actually use state + self.state = state + + +class ScenarioGraph: + """ + The scenario graph is the most basic representation of trace exploration. + It represents scenarios as nodes, and the trace as edges. + """ + def __init__(self): + # We use simplified IDs for nodes, and store the actual scenario info here + self.ids: dict[str, ScenarioInfo] = {} + + # The networkx graph is a directional graph + self.networkx = nx.DiGraph() + + # Stores the position (x, y) of the nodes + self.pos = {} + + # List of nodes which positions cannot be changed + self.fixed = [] + + # add the start node + self.networkx.add_node('start') + + def update_visualisation(self, info: TraceInfo): + """ + Update the visualisation with new trace information from another exploration step. + This will add nodes for all new scenarios in the provided trace, as well as edges for all pairs in the provided trace. + """ + for i in range(0, len(info.trace) - 1): + from_node = self.__get_or_create_id(info.trace[i]) + to_node = self.__get_or_create_id(info.trace[i + 1]) + + if from_node not in self.networkx.nodes: + self.networkx.add_node(from_node, text=self.ids[from_node].name) + if to_node not in self.networkx.nodes: + self.networkx.add_node(to_node, text=self.ids[to_node].name) + + if (from_node, to_node) not in self.networkx.edges: + self.networkx.add_edge(from_node, to_node) + + def __get_or_create_id(self, scenario: ScenarioInfo) -> str: + """ + Get the ID for a scenario that has been added before, or create and store a new one. + """ + for i in self.ids.keys(): + # TODO: decide how to deal with repeating scenarios, this merges repeated scenarios into a single scenario + if self.ids[i].src_id == scenario.src_id: + return i + + new_id = f"node{len(self.ids)}" + self.ids[new_id] = scenario + return new_id + + def set_starting_node(self, scenario: ScenarioInfo): + """ + Update the starting node. + """ + node = self.__get_or_create_id(scenario) + self.networkx.add_edge('start', node) + + def set_ending_node(self, scenario: ScenarioInfo): + """ + Update the end node. + """ + node = self.__get_or_create_id(scenario) + self.pos[node] = (len(self.networkx.nodes), 0) + self.fixed.append(node) + + def calculate_pos(self): + """ + Calculate the position (x, y) for all nodes in self.networkx + """ + self.pos['start'] = (0, len(self.networkx.nodes)) + self.fixed.append('start') + if not self.fixed: + self.pos = nx.spring_layout(self.networkx, seed=42) + else: + self.pos = nx.spring_layout(self.networkx, pos=self.pos, fixed=self.fixed, seed=42, method='energy', gravity=0.25) \ No newline at end of file diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py index f527789f..ee51d7b5 100644 --- a/robotmbt/visualise/visualiser.py +++ b/robotmbt/visualise/visualiser.py @@ -1,123 +1,10 @@ -from robotmbt.modelspace import ModelSpace -from robotmbt.suitedata import Scenario -from robotmbt.tracestate import TraceState +from .models import ScenarioGraph, TraceInfo, ScenarioInfo import networkx as nx import matplotlib.pyplot as plt #numpy #scipy -class ScenarioInfo: - """ - This contains all information we need from scenarios, abstracting away from the actual Scenario class: - - name - - src_id - """ - - def __init__(self, scenario: Scenario | str): - if isinstance(scenario, Scenario): - self.name = scenario.name - self.src_id = scenario.src_id - else: - self.name = scenario - self.src_id = None - - def __str__(self): - return f"Scen{self.src_id}: {self.name}" - - -class TraceInfo: - """ - This contains all information we need at any given step in trace exploration: - - trace: the strung together scenarios up until this point - - state: the model space - """ - - - def __init__(self, trace: TraceState, state: ModelSpace): - self.trace = [ScenarioInfo(s) for s in trace.get_trace()] - # TODO: actually use state - self.state = state - - -class ScenarioGraph: - """ - The scenario graph is the most basic representation of trace exploration. - It represents scenarios as nodes, and the trace as edges. - """ - def __init__(self): - # We use simplified IDs for nodes, and store the actual scenario info here - self.ids: dict[str, ScenarioInfo] = {} - - # The networkx graph is a directional graph - self.networkx = nx.DiGraph() - - # Stores the position (x, y) of the nodes - self.pos = {} - - # List of nodes which positions cannot be changed - self.fixed = [] - - # add the start node - self.networkx.add_node('start') - - def update_visualisation(self, info: TraceInfo): - """ - Update the visualisation with new trace information from another exploration step. - This will add nodes for all new scenarios in the provided trace, as well as edges for all pairs in the provided trace. - """ - for i in range(0, len(info.trace) - 1): - from_node = self.__get_or_create_id(info.trace[i]) - to_node = self.__get_or_create_id(info.trace[i + 1]) - - if from_node not in self.networkx.nodes: - self.networkx.add_node(from_node, text=self.ids[from_node].name) - if to_node not in self.networkx.nodes: - self.networkx.add_node(to_node, text=self.ids[to_node].name) - - if (from_node, to_node) not in self.networkx.edges: - self.networkx.add_edge(from_node, to_node) - - def __get_or_create_id(self, scenario: ScenarioInfo) -> str: - """ - Get the ID for a scenario that has been added before, or create and store a new one. - """ - for i in self.ids.keys(): - # TODO: decide how to deal with repeating scenarios, this merges repeated scenarios into a single scenario - if self.ids[i].src_id == scenario.src_id: - return i - - new_id = f"node{len(self.ids)}" - self.ids[new_id] = scenario - return new_id - - def set_starting_node(self, scenario: ScenarioInfo): - """ - Update the starting node. - """ - node = self.__get_or_create_id(scenario) - self.networkx.add_edge('start', node) - - def set_ending_node(self, scenario: ScenarioInfo): - """ - Update the end node. - """ - node = self.__get_or_create_id(scenario) - self.pos[node] = (len(self.networkx.nodes), 0) - self.fixed.append(node) - - def calculate_pos(self): - """ - Calculate the position (x, y) for all nodes in self.networkx - """ - self.pos['start'] = (0, len(self.networkx.nodes)) - self.fixed.append('start') - if not self.fixed: - self.pos = nx.spring_layout(self.networkx, seed=42) - else: - self.pos = nx.spring_layout(self.networkx, pos=self.pos, fixed=self.fixed, seed=42, method='energy', gravity=0.25) - - class Visualiser: """ The Visualiser class bridges the different concerns to provide a simple interface through which the graph can be updated, and retrieved in HTML format. From 708a0348cb9a56e76c809fb252f9374031f29093 Mon Sep 17 00:00:00 2001 From: tychodub <93142605+tychodub@users.noreply.github.com> Date: Wed, 29 Oct 2025 10:28:26 +0100 Subject: [PATCH 008/131] Rollback self typing branch (#11) * commented Self typing annotations * changed Github actions python-version to 3.10 * change from spring layout to planar layout --------- Co-authored-by: jonathan <148167.jw@gmail.com> --- .github/workflows/python-app.yml | 2 +- robotmbt/modelspace.py | 4 ++-- robotmbt/steparguments.py | 4 ++-- robotmbt/substitutionmap.py | 7 ++++--- robotmbt/suitedata.py | 10 ++++++---- robotmbt/suitereplacer.py | 31 +++++++++++++++---------------- robotmbt/visualise/models.py | 18 +++++++++--------- 7 files changed, 39 insertions(+), 37 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 8ee45428..7fee3b4e 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -22,7 +22,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v3 with: - python-version: "3.13" + python-version: "3.10" - name: Install dependencies run: | python -m pip install --upgrade pip # upgrade pip to latest version diff --git a/robotmbt/modelspace.py b/robotmbt/modelspace.py index 8f5c3b80..f7e8489f 100644 --- a/robotmbt/modelspace.py +++ b/robotmbt/modelspace.py @@ -31,7 +31,6 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import copy -from typing import Self from .steparguments import StepArguments @@ -54,7 +53,8 @@ def __init__(self, reference_id=None): def __repr__(self): return self.ref_id if self.ref_id else super().__repr__() - def copy(self) -> Self: + def copy(self): + # -> Self return copy.deepcopy(self) def __eq__(self, other): diff --git a/robotmbt/steparguments.py b/robotmbt/steparguments.py index 2bd4451d..503481fc 100644 --- a/robotmbt/steparguments.py +++ b/robotmbt/steparguments.py @@ -32,7 +32,6 @@ from keyword import iskeyword import builtins -from typing import Self class StepArguments(list): @@ -93,7 +92,8 @@ def modified(self) -> bool: def codestring(self) -> str | None: return self._codestr - def copy(self) -> Self: + def copy(self): + # -> Self cp = StepArgument(self.arg.strip('${}'), self.value, self.kind) cp.org_value = self.org_value return cp diff --git a/robotmbt/substitutionmap.py b/robotmbt/substitutionmap.py index 1f5445bd..6cdbc736 100644 --- a/robotmbt/substitutionmap.py +++ b/robotmbt/substitutionmap.py @@ -31,7 +31,6 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import random -from typing import Self class SubstitutionMap: @@ -54,7 +53,8 @@ def __str__(self): src = self.solution or self.substitutions return ", ".join([f"{k} ⤝ {v}" for k, v in src.items()]) - def copy(self) -> Self: + def copy(self): + # -> Self new = SubstitutionMap() new.substitutions = {k: v.copy() for k, v in self.substitutions.items()} @@ -144,7 +144,8 @@ def __repr__(self): def __iter__(self): return iter(self.optionset) - def copy(self) -> Self: + def copy(self): + # -> Self return Constraint(self.optionset) def add_constraint(self, constraint): diff --git a/robotmbt/suitedata.py b/robotmbt/suitedata.py index d0619008..95c2b116 100644 --- a/robotmbt/suitedata.py +++ b/robotmbt/suitedata.py @@ -31,7 +31,6 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import copy -from typing import Self from robot.running.arguments.argumentvalidator import ArgumentValidator @@ -97,13 +96,15 @@ def steps_with_errors(self): # list[Step | None] + [s for s in self.steps if s.has_error()] + ([self.teardown] if self.teardown and self.teardown.has_error() else [])) - def copy(self) -> Self: + def copy(self): + # -> Self duplicate = copy.copy(self) duplicate.steps = [step.copy() for step in self.steps] duplicate.data_choices = self.data_choices.copy() return duplicate - def split_at_step(self, stepindex: int) -> tuple[Self, Self]: + def split_at_step(self, stepindex: int): + # -> tuple[Self, Self] """Returns 2 partial scenarios. With stepindex 0 the first part has no steps and all steps are in the last part. With @@ -168,7 +169,8 @@ def __str__(self): def __repr__(self): return f"Step: '{self}' with model info: {self.model_info}" - def copy(self) -> Self: + def copy(self): + # -> Self cp = Step(self.org_step, *self.org_pn_args, parent=self.parent, assign=self.assign) cp.gherkin_kw = self.gherkin_kw diff --git a/robotmbt/suitereplacer.py b/robotmbt/suitereplacer.py index ea0cb893..363a2ad4 100644 --- a/robotmbt/suitereplacer.py +++ b/robotmbt/suitereplacer.py @@ -4,7 +4,6 @@ import robot.running.model as rmodel from robot.api import logger from robot.api.deco import keyword -from typing import Self # BSD 3-Clause License # @@ -46,7 +45,7 @@ class SuiteReplacer: ROBOT_LISTENER_API_VERSION: int = 3 def __init__(self, processor: str = 'process_test_suite', processor_lib: str | None = None): - self.ROBOT_LIBRARY_LISTENER: Self = self + self.ROBOT_LIBRARY_LISTENER = self # : Self self.current_suite: Suite | None = None self.robot_suite: Suite | None = None self.processor_lib_name: str | None = processor_lib @@ -68,10 +67,10 @@ def processor_method(self): if not hasattr(self.processor_lib, self.processor_name): Robot.fail( f"Processor '{self.processor_name}' not available for model-based processor library {self.processor_lib_name}") - + self._processor_method = getattr( self._processor_lib, self.processor_name) - + return self._processor_method @keyword(name="Treat this test suite Model-based") @@ -127,22 +126,22 @@ def __process_robot_suite(self, in_suite: Suite, parent: Suite | None) -> Suite: if in_suite.setup and parent is not None: step_info = Step(in_suite.setup.name, * - in_suite.setup.args, parent=out_suite) + in_suite.setup.args, parent=out_suite) step_info.add_robot_dependent_data( Robot._namespace.get_runner(step_info.org_step).keyword) out_suite.setup = step_info - + if in_suite.teardown and parent is not None: step_info = Step(in_suite.teardown.name, * - in_suite.teardown.args, parent=out_suite) + in_suite.teardown.args, parent=out_suite) step_info.add_robot_dependent_data( Robot._namespace.get_runner(step_info.org_step).keyword) out_suite.teardown = step_info - + for st in in_suite.suites: out_suite.suites.append( self.__process_robot_suite(st, parent=out_suite)) - + for tc in in_suite.tests: scenario = Scenario(tc.name, parent=out_suite) if tc.setup: @@ -151,15 +150,15 @@ def __process_robot_suite(self, in_suite: Suite, parent: Suite | None) -> Suite: step_info.add_robot_dependent_data( Robot._namespace.get_runner(step_info.org_step).keyword) scenario.setup = step_info - + if tc.teardown: step_info = Step(tc.teardown.name, * - tc.teardown.args, parent=scenario) + tc.teardown.args, parent=scenario) step_info.add_robot_dependent_data( Robot._namespace.get_runner(step_info.org_step).keyword) scenario.teardown = step_info last_gwt = None - + for step_def in tc.body: if isinstance(step_def, rmodel.Keyword): step_info = Step(step_def.name, *step_def.args, parent=scenario, assign=step_def.assign, @@ -167,10 +166,10 @@ def __process_robot_suite(self, in_suite: Suite, parent: Suite | None) -> Suite: step_info.add_robot_dependent_data( Robot._namespace.get_runner(step_info.org_step).keyword) scenario.steps.append(step_info) - + if step_info.gherkin_kw: last_gwt = step_info.gherkin_kw - + elif isinstance(step_def, rmodel.Var): scenario.steps.append( Step('VAR', step_def.name, *step_def.value, parent=scenario)) @@ -178,9 +177,9 @@ def __process_robot_suite(self, in_suite: Suite, parent: Suite | None) -> Suite: unsupported_step = Step(str(step_def), parent=scenario) unsupported_step.model_info['error'] = f"Robot construct not supported" scenario.steps.append(unsupported_step) - + out_suite.scenarios.append(scenario) - + return out_suite def __clearTestSuite(self, suite: Suite): diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index b20b971c..1712e67e 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -3,6 +3,7 @@ from robotmbt.tracestate import TraceState import networkx as nx + class ScenarioInfo: """ This contains all information we need from scenarios, abstracting away from the actual Scenario class: @@ -29,7 +30,6 @@ class TraceInfo: - state: the model space """ - def __init__(self, trace: TraceState, state: ModelSpace): self.trace = [ScenarioInfo(s) for s in trace.get_trace()] # TODO: actually use state @@ -41,6 +41,7 @@ class ScenarioGraph: The scenario graph is the most basic representation of trace exploration. It represents scenarios as nodes, and the trace as edges. """ + def __init__(self): # We use simplified IDs for nodes, and store the actual scenario info here self.ids: dict[str, ScenarioInfo] = {} @@ -67,7 +68,8 @@ def update_visualisation(self, info: TraceInfo): to_node = self.__get_or_create_id(info.trace[i + 1]) if from_node not in self.networkx.nodes: - self.networkx.add_node(from_node, text=self.ids[from_node].name) + self.networkx.add_node( + from_node, text=self.ids[from_node].name) if to_node not in self.networkx.nodes: self.networkx.add_node(to_node, text=self.ids[to_node].name) @@ -99,16 +101,14 @@ def set_ending_node(self, scenario: ScenarioInfo): Update the end node. """ node = self.__get_or_create_id(scenario) - self.pos[node] = (len(self.networkx.nodes), 0) self.fixed.append(node) def calculate_pos(self): """ Calculate the position (x, y) for all nodes in self.networkx """ - self.pos['start'] = (0, len(self.networkx.nodes)) - self.fixed.append('start') - if not self.fixed: - self.pos = nx.spring_layout(self.networkx, seed=42) - else: - self.pos = nx.spring_layout(self.networkx, pos=self.pos, fixed=self.fixed, seed=42, method='energy', gravity=0.25) \ No newline at end of file + try: + self.pos = nx.planar_layout(self.networkx) + except nx.NetworkXException: + # if planar layout cannot find a graph without crossing edges + self.pos = nx.arf_layout(self.networkx, seed=42) From 461eee643b387f7668a00ab9d7415038e8b6d549 Mon Sep 17 00:00:00 2001 From: D Osinga <46380170+osingaatje@users.noreply.github.com> Date: Wed, 29 Oct 2025 11:27:06 +0100 Subject: [PATCH 009/131] Documentation (+gitignore) for setting up virtual environment. (#13) * add initial readme adjustments for pipenv * ignore pyenv pipfile * updated README - windows specific stuff and overall improvement --- .gitignore | 3 +++ README.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/.gitignore b/.gitignore index 2ff75fba..3ab9aefe 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,9 @@ results/ *.manifest *.spec +# Ignore pyenv Pipfile +Pipfile + # Installer logs pip-log.txt pip-delete-this-directory.txt diff --git a/README.md b/README.md index 927436bd..a33cad24 100644 --- a/README.md +++ b/README.md @@ -202,3 +202,53 @@ Using `seed=new` will force generation of a new reusable seed and is identical t ## Disclaimer Please note that this library is in a premature state and hasn't reached its first official (1.0) release yet. Developments are ongoing within the context of the [TiCToC](https://tictoc.cs.ru.nl/) research project. Interface changes are still frequent, and no deprecation warnings are being issued yet. + + +## Development +### Python virtual environment +Installing the proper virtual environment can be done with the default `python -m venv ./.venv` command built into python. However, if you have another version of python on your system, this might break dependencies. + +#### Pipenv+Pyenv (verified on Windows and Linux) +For the optimal experience (at least on Linux), we suggest installing the following packages: +- [`pyenv`](https://github.com/pyenv/pyenv) (Linux/Mac) or [`pyenv-win`](https://github.com/pyenv-win/pyenv-win) (Windows) +- [`pipenv`](https://github.com/pypa/pipenv) + +Then, you can install a python virtual environment with: + +```bash +pipenv --python +``` +..where the python version can be found in the `pyproject.toml`. For example, for 3.10: `pipenv --python 3.10`. + +You might need to manually make the folder `.venv` by doing `mkdir .venv`. + +You can verify if the install went correctly with: +```bash +pipenv check +``` +This should return `Passed!` + +Errors related to minor versions (for example `3.10.0rc2` != `3.10.0`) can be ignored. + +Now activate the virtual environment by running +```bash +pipenv shell +``` + +..and you should have a virtual env! If you run +```bash +python --version +``` +..while in your virtual environment, it should show the `` from before. + + +### Installing dependencies +***NOTE: making sure that you are in the virtual environment***. + +It is recommended that you also include the optional depedencies for visualisation, e.g.: +```bash +pip install ".[visualization]" +``` + + + From 8147e241034a23faac4352d4536a4bc55465ab5d Mon Sep 17 00:00:00 2001 From: Jonathan <61787386+JWillegers@users.noreply.github.com> Date: Wed, 5 Nov 2025 12:19:59 +0100 Subject: [PATCH 010/131] Generate html with bokeh (#15) * add bokeh as optional dependency * remove dependency on scipy * bokeh graph with arrows and a start on selfloops * add labels to vertices * restructure according to architecture * fixed self-loops; fixed arrowheads not being at boundary of vertex * embedded plot in html * added extra documentation * fixed edge_case where there is only 1 scenario * consistently use "node" in the code * remove draw_from_networkx and draw nodes in a for-loop * moved width/height/edge styling/... to constants * fixed zooming with tools * adding future support for having labels at edges * reorder imports, prepend private methods with underscore * fix requested changes (code comments) * test class traceInfo --------- Co-authored-by: Douwe Osinga --- pyproject.toml | 2 +- robotmbt/suiteprocessors.py | 48 ++++---- robotmbt/suitereplacer.py | 9 +- robotmbt/visualise/models.py | 22 ++-- robotmbt/visualise/visualiser.py | 202 +++++++++++++++++++++++++++++-- utest/test_visualise_models.py | 20 +++ 6 files changed, 255 insertions(+), 48 deletions(-) create mode 100644 utest/test_visualise_models.py diff --git a/pyproject.toml b/pyproject.toml index 4199073b..71ae0217 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,4 +30,4 @@ packages = ["robotmbt"] Homepage = "https://github.com/JFoederer/robotframeworkMBT" [project.optional-dependencies] -visualization = ["scipy","numpy","networkx","matplotlib"] +visualization = ["numpy","networkx","bokeh"] diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index 9ba73a3a..f6799691 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -108,7 +108,8 @@ def process_test_suite(self, in_suite: Suite, *, seed: any = 'new') -> Suite: "Direct trace not available. Allowing repetition of scenarios") self._try_to_reach_full_coverage(allow_duplicate_scenarios=True) if not self.tracestate.coverage_reached(): - logger.write(self.visualiser.generate_html(), html=True) + logger.write( + self.visualiser.generate_visualisation(), html=True) raise Exception("Unable to compose a consistent suite") self.out_suite.scenarios = self.tracestate.get_trace() @@ -152,7 +153,8 @@ def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool): self._report_tracestate_to_user() logger.debug( f"last state:\n{self.active_model.get_status_text()}") - self.visualiser.update_visualisation(TraceInfo(self.tracestate, self.active_model)) + self.visualiser.update_visualisation( + TraceInfo(self.tracestate, self.active_model)) def __last_candidate_changed_nothing(self) -> bool: if len(self.tracestate) < 2: @@ -398,11 +400,11 @@ def _generate_scenario_variant(self, scenario: Scenario, model: ModelSpace) -> S for expr in step.model_info['MOD']: modded_arg, constraint = self._parse_modifier_expression( expr, step.args) - + if step.args[modded_arg].kind != StepArgument.EMBEDDED: raise ValueError( "Modifers are currently only supported for embedded arguments.") - + org_example = step.args[modded_arg].org_value if step.gherkin_kw == 'then': constraint = None # No new constraints are processed for then-steps @@ -410,31 +412,31 @@ def _generate_scenario_variant(self, scenario: Scenario, model: ModelSpace) -> S # if a then-step signals the first use of an example value, it is considered a new definition subs.substitute(org_example, [org_example]) continue - + if not constraint and org_example not in subs.substitutions: raise ValueError( f"No options to choose from at first assignment to {org_example}") - + if constraint and constraint != '.*': options = m.process_expression( constraint, step.args) if options == 'exec': raise ValueError( f"Invalid constraint for argument substitution: {expr}") - + if not options: raise ValueError( f"Constraint on modifer did not yield any options: {expr}") - + if not is_list_like(options): raise ValueError( f"Constraint on modifer did not yield a set of options: {expr}") - + else: options = None - + subs.substitute(org_example, options) - + except Exception as err: logger.debug(f"Unable to insert scenario {scenario.src_id}, {scenario.name}, due to modifier\n" f" In step {step}: {err}") @@ -451,9 +453,9 @@ def _generate_scenario_variant(self, scenario: Scenario, model: ModelSpace) -> S if subs.solution: logger.debug( f"Example variant generated with argument substitution: {subs}") - + scenario.data_choices = subs - + for step in scenario.steps: if 'MOD' in step.model_info: for expr in step.model_info['MOD']: @@ -461,7 +463,7 @@ def _generate_scenario_variant(self, scenario: Scenario, model: ModelSpace) -> S expr, step.args) org_example = step.args[modded_arg].org_value step.args[modded_arg].value = subs.solution[org_example] - + return scenario @staticmethod @@ -471,13 +473,13 @@ def _parse_modifier_expression(expression: str, args: StepArguments) -> tuple[st if expression.casefold().startswith(var.arg.casefold()): assignment_expr = expression.replace( var.arg, '', 1).strip() - + if not assignment_expr.startswith('=') or assignment_expr.startswith('=='): break # not an assignment - + constraint = assignment_expr.replace('=', '', 1).strip() return var.arg, constraint - + raise ValueError(f"Invalid argument substitution: {expression}") def _report_tracestate_to_user(self): @@ -485,7 +487,7 @@ def _report_tracestate_to_user(self): for snapshot in self.tracestate: part = f".{snapshot.id.split('.')[1]}" if '.' in snapshot.id else "" user_trace += f"{snapshot.scenario.src_id}{part}, " - + user_trace = user_trace[:-2] + "]" if ',' in user_trace else "[]" reject_trace = [ self.scenarios[i].src_id for i in self.tracestate.tried] @@ -501,7 +503,7 @@ def _report_tracestate_wrapup(self): def _init_randomiser(seed: any): if isinstance(seed, str): seed = seed.strip() - + if str(seed).lower() == 'none': logger.debug( f"Using system's random seed for trace generation. This trace cannot be rerun. Use `seed=new` to generate a reusable seed.") @@ -524,7 +526,7 @@ def _generate_seed() -> str: for word in range(5): prior_choice = random.choice([vowels, consonants]) last_choice = random.choice([vowels, consonants]) - + # add first two letters string = random.choice(prior_choice) + random.choice(last_choice) for letter in range(random.randint(1, 4)): # add 1 to 4 more letters @@ -532,12 +534,12 @@ def _generate_seed() -> str: new_choice = consonants if prior_choice is vowels else vowels else: new_choice = random.choice([vowels, consonants]) - + prior_choice = last_choice last_choice = new_choice string += random.choice(new_choice) - + words.append(string) - + seed = '-'.join(words) return seed diff --git a/robotmbt/suitereplacer.py b/robotmbt/suitereplacer.py index 363a2ad4..837f40b0 100644 --- a/robotmbt/suitereplacer.py +++ b/robotmbt/suitereplacer.py @@ -102,7 +102,8 @@ def treat_model_based(self, **kwargs): # TODO: add flag using kwargs to disable this if isinstance(self.processor_lib, SuiteProcessors): - logger.write(self.processor_lib.visualiser.generate_html(), html=True) + logger.write( + self.processor_lib.visualiser.generate_visualisation(), html=True) @keyword("Set model-based options") def set_model_based_options(self, **kwargs): @@ -126,14 +127,14 @@ def __process_robot_suite(self, in_suite: Suite, parent: Suite | None) -> Suite: if in_suite.setup and parent is not None: step_info = Step(in_suite.setup.name, * - in_suite.setup.args, parent=out_suite) + in_suite.setup.args, parent=out_suite) step_info.add_robot_dependent_data( Robot._namespace.get_runner(step_info.org_step).keyword) out_suite.setup = step_info if in_suite.teardown and parent is not None: step_info = Step(in_suite.teardown.name, * - in_suite.teardown.args, parent=out_suite) + in_suite.teardown.args, parent=out_suite) step_info.add_robot_dependent_data( Robot._namespace.get_runner(step_info.org_step).keyword) out_suite.teardown = step_info @@ -153,7 +154,7 @@ def __process_robot_suite(self, in_suite: Suite, parent: Suite | None) -> Suite: if tc.teardown: step_info = Step(tc.teardown.name, * - tc.teardown.args, parent=scenario) + tc.teardown.args, parent=scenario) step_info.add_robot_dependent_data( Robot._namespace.get_runner(step_info.org_step).keyword) scenario.teardown = step_info diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index 1712e67e..551ccb43 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -56,7 +56,7 @@ def __init__(self): self.fixed = [] # add the start node - self.networkx.add_node('start') + self.networkx.add_node('start', label='start') def update_visualisation(self, info: TraceInfo): """ @@ -67,14 +67,12 @@ def update_visualisation(self, info: TraceInfo): from_node = self.__get_or_create_id(info.trace[i]) to_node = self.__get_or_create_id(info.trace[i + 1]) - if from_node not in self.networkx.nodes: - self.networkx.add_node( - from_node, text=self.ids[from_node].name) - if to_node not in self.networkx.nodes: - self.networkx.add_node(to_node, text=self.ids[to_node].name) + self.add_node(from_node) + self.add_node(to_node) if (from_node, to_node) not in self.networkx.edges: - self.networkx.add_edge(from_node, to_node) + self.networkx.add_edge( + from_node, to_node, label='') def __get_or_create_id(self, scenario: ScenarioInfo) -> str: """ @@ -89,12 +87,20 @@ def __get_or_create_id(self, scenario: ScenarioInfo) -> str: self.ids[new_id] = scenario return new_id + def add_node(self, node: str): + """ + Add node if it doesn't already exist + """ + if node not in self.networkx.nodes: + self.networkx.add_node(node, label=self.ids[node].name) + def set_starting_node(self, scenario: ScenarioInfo): """ Update the starting node. """ node = self.__get_or_create_id(scenario) - self.networkx.add_edge('start', node) + self.add_node(node) + self.networkx.add_edge('start', node, label='') def set_ending_node(self, scenario: ScenarioInfo): """ diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py index ee51d7b5..2c27455e 100644 --- a/robotmbt/visualise/visualiser.py +++ b/robotmbt/visualise/visualiser.py @@ -1,14 +1,28 @@ from .models import ScenarioGraph, TraceInfo, ScenarioInfo +from bokeh.palettes import Spectral4 +from bokeh.models import ( + Plot, Range1d, Circle, + Arrow, NormalHead, LabelSet, + Bezier, ColumnDataSource, ResetTool, + SaveTool, WheelZoomTool, PanTool +) +from bokeh.embed import file_html +from bokeh.resources import CDN +from math import sqrt +import html import networkx as nx -import matplotlib.pyplot as plt -#numpy -#scipy class Visualiser: """ - The Visualiser class bridges the different concerns to provide a simple interface through which the graph can be updated, and retrieved in HTML format. + The Visualiser class bridges the different concerns to provide + a simple interface through which the graph can be updated, + and retrieved in HTML format. """ + GRAPH_SIZE_PX: int = 600 # in px, needs to be equal for height and width otherwise calculations are wrong + GRAPH_PADDING_PERC: int = 15 # % + MAX_VERTEX_NAME_LEN: int = 20 # no. of characters + def __init__(self): self.graph = ScenarioGraph() @@ -21,14 +35,178 @@ def set_start(self, scenario: ScenarioInfo): def set_end(self, scenario: ScenarioInfo): self.graph.set_ending_node(scenario) - def generate_graph(self): - # temporary code for visualisation + def generate_visualisation(self) -> str: self.graph.calculate_pos() - nx.draw(self.graph.networkx, pos=self.graph.pos, with_labels=True, node_color="lightblue", node_size=600) - plt.show() + networkvisualiser = NetworkVisualiser(self.graph) + html_bokeh = networkvisualiser.generate_html() + return f"" + + +class NetworkVisualiser: + """ + Generate plot with Bokeh + """ + + EDGE_WIDTH: float = 2.0 + EDGE_ALPHA: float = 0.7 + EDGE_COLOUR: str | tuple[int, int, int] = ( + 12, 12, 12) # 'visual studio black' + + def __init__(self, graph: ScenarioGraph): + self.plot = None + self.graph = graph + self.labels = dict(x=[], y=[], label=[]) + + # graph customisation options + self.node_radius = 1.0 - # TODO: use a graph library to actually create a graph def generate_html(self) -> str: - self.generate_graph() - return f"" - # return f"

nodes: {self.graph.nodes}\nedges: {self.graph.edges}\nstart: {self.graph.start}\nend: {self.graph.end}\nids: {[f"{name}: {str(val)}" for (name, val) in self.graph.ids.items()]}

" + """ + Generate html file from networkx graph via Bokeh + """ + self._initialise_plot() + self._add_edges() + self._add_nodes() + label_source = ColumnDataSource(data=self.labels) + labels = LabelSet(x="x", y="y", text="label", source=label_source, + text_color=NetworkVisualiser.EDGE_COLOUR, + text_align="center") + + self.plot.add_layout(labels) + + return file_html(self.plot, CDN, "graph") + + def _initialise_plot(self): + """ + Define plot with width, height, x_range, y_range and enable tools. + x_range and y_range are padded. Plot needs to be a square + """ + padding: float = Visualiser.GRAPH_PADDING_PERC / 100 + + x_range, y_range = zip(*self.graph.pos.values()) + x_min = min(x_range) - padding * (max(x_range) - min(x_range)) + x_max = max(x_range) + padding * (max(x_range) - min(x_range)) + y_min = min(y_range) - padding * (max(y_range) - min(y_range)) + y_max = max(y_range) + padding * (max(y_range) - min(y_range)) + + # scale node radius based on range + nodes_range = max(x_max-x_min, y_max-y_min) + self.node_radius = nodes_range / 50 + + # create plot + x_range = Range1d(min(x_min, y_min), max(x_max, y_max)) + y_range = Range1d(min(x_min, y_min), max(x_max, y_max)) + + self.plot = Plot(width=Visualiser.GRAPH_SIZE_PX, + height=Visualiser.GRAPH_SIZE_PX, + x_range=x_range, + y_range=y_range) + + # add tools + self.plot.add_tools(ResetTool(), SaveTool(), + WheelZoomTool(), PanTool()) + + def _add_nodes(self): + """ + Add labels to the nodes in bokeh plot + """ + node_labels = nx.get_node_attributes(self.graph.networkx, "label") + for node in self.graph.networkx.nodes: + # prepare adding labels + self.labels['x'].append(self.graph.pos[node][0]) + self.labels['y'].append(self.graph.pos[node][1]+self.node_radius) + self.labels['label'].append(self._cap_name(node_labels[node])) + + # add node + bokeh_node = Circle(radius=self.node_radius, + fill_color=Spectral4[0], + x=self.graph.pos[node][0], + y=self.graph.pos[node][1]) + + self.plot.add_glyph(bokeh_node) + + def add_self_loop(self, x: float, y: float, label: str): + """ + Self-loop as a Bezier curve with arrow head + """ + loop = Bezier( + # starting point + x0=x + self.node_radius, + y0=y, + + # end point + x1=x, + y1=y - self.node_radius, + + # control points + cx0=x + 5*self.node_radius, + cy0=y, + cx1=x, + cy1=y - 5*self.node_radius, + + # styling + line_color=NetworkVisualiser.EDGE_COLOUR, + line_width=NetworkVisualiser.EDGE_WIDTH, + line_alpha=NetworkVisualiser.EDGE_ALPHA, + ) + self.plot.add_glyph(loop) + + # add arrow head + arrow = Arrow( + end=NormalHead(size=10, + line_color=NetworkVisualiser.EDGE_COLOUR, + fill_color=NetworkVisualiser.EDGE_COLOUR), + + # -0.01 to guarantee that arrow points upwards. + x_start=x, y_start=y-self.node_radius-0.01, + x_end=x, y_end=y-self.node_radius + ) + + # add edge label + self.labels['x'].append(x + self.node_radius) + self.labels['y'].append(y - 4*self.node_radius) + self.labels['label'].append(label) + + self.plot.add_layout(arrow) + + def _add_edges(self): + edge_labels = nx.get_edge_attributes(self.graph.networkx, "label") + for edge in self.graph.networkx.edges(): + x0, y0 = self.graph.pos[edge[0]] + x1, y1 = self.graph.pos[edge[1]] + if edge[0] == edge[1]: + self.add_self_loop( + x=x0, y=y0, label=self._cap_name(edge_labels[edge])) + + else: + # edge between 2 different nodes + dx = x1 - x0 + dy = y1 - y0 + + length = sqrt(dx**2 + dy**2) + + arrow = Arrow( + end=NormalHead( + size=10, + line_color=NetworkVisualiser.EDGE_COLOUR, + fill_color=NetworkVisualiser.EDGE_COLOUR), + + x_start=x0 + dx / length * self.node_radius, + y_start=y0 + dy / length * self.node_radius, + x_end=x1 - dx / length * self.node_radius, + y_end=y1 - dy / length * self.node_radius + ) + + self.plot.add_layout(arrow) + + # add edge label + self.labels['x'].append((x0+x1)/2) + self.labels['y'].append((y0+y1)/2) + self.labels['label'].append(self._cap_name(edge_labels[edge])) + + @staticmethod + def _cap_name(name: str) -> str: + if len(name) < Visualiser.MAX_VERTEX_NAME_LEN: + return name + + return f"{name[:(Visualiser.MAX_VERTEX_NAME_LEN-3)]}..." diff --git a/utest/test_visualise_models.py b/utest/test_visualise_models.py new file mode 100644 index 00000000..f9651f03 --- /dev/null +++ b/utest/test_visualise_models.py @@ -0,0 +1,20 @@ +import unittest +from robotmbt.tracestate import TraceState +from robotmbt.visualise.models import * + + +class TestVisualiseModels(unittest.TestCase): + def test_create_TraceInfo(self): + ts = TraceState(3) + candidates = [] + for scenario in range(3): + candidates.append(ts.next_candidate()) + ts.confirm_full_scenario(candidates[-1], scenario, {}) + ti = TraceInfo(trace=ts, state=None) + + self.assertEqual(0, ti.trace[0].name) + self.assertEqual(1, ti.trace[1].name) + self.assertEqual(2, ti.trace[2].name) + + # TODO change when state is implemented + self.assertIsNone(ti.state) From d4dfd5c9a6c1cadcae7a1ceb55413174c6e63459 Mon Sep 17 00:00:00 2001 From: Jonathan <61787386+JWillegers@users.noreply.github.com> Date: Wed, 12 Nov 2025 10:58:31 +0100 Subject: [PATCH 011/131] Unit test for models.py (#17) * removed self.fixed from ScenarioGraph added self.end_node in ScenarioGraph * test cases for most methods in models.py (missing: set_ending_node and calculate_pos) * added unit test for scenariograph.set_end_node() --- robotmbt/visualise/models.py | 21 ++--- utest/test_visualise_models.py | 158 ++++++++++++++++++++++++++++++++- 2 files changed, 166 insertions(+), 13 deletions(-) diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index 551ccb43..96f416e5 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -13,11 +13,13 @@ class ScenarioInfo: def __init__(self, scenario: Scenario | str): if isinstance(scenario, Scenario): + # default case self.name = scenario.name self.src_id = scenario.src_id else: + # unit tests self.name = scenario - self.src_id = None + self.src_id = scenario def __str__(self): return f"Scen{self.src_id}: {self.name}" @@ -52,20 +54,20 @@ def __init__(self): # Stores the position (x, y) of the nodes self.pos = {} - # List of nodes which positions cannot be changed - self.fixed = [] - # add the start node self.networkx.add_node('start', label='start') + # indicates last scenario of trace + self.end_node = 'start' + def update_visualisation(self, info: TraceInfo): """ Update the visualisation with new trace information from another exploration step. This will add nodes for all new scenarios in the provided trace, as well as edges for all pairs in the provided trace. """ for i in range(0, len(info.trace) - 1): - from_node = self.__get_or_create_id(info.trace[i]) - to_node = self.__get_or_create_id(info.trace[i + 1]) + from_node = self._get_or_create_id(info.trace[i]) + to_node = self._get_or_create_id(info.trace[i + 1]) self.add_node(from_node) self.add_node(to_node) @@ -74,7 +76,7 @@ def update_visualisation(self, info: TraceInfo): self.networkx.add_edge( from_node, to_node, label='') - def __get_or_create_id(self, scenario: ScenarioInfo) -> str: + def _get_or_create_id(self, scenario: ScenarioInfo) -> str: """ Get the ID for a scenario that has been added before, or create and store a new one. """ @@ -98,7 +100,7 @@ def set_starting_node(self, scenario: ScenarioInfo): """ Update the starting node. """ - node = self.__get_or_create_id(scenario) + node = self._get_or_create_id(scenario) self.add_node(node) self.networkx.add_edge('start', node, label='') @@ -106,8 +108,7 @@ def set_ending_node(self, scenario: ScenarioInfo): """ Update the end node. """ - node = self.__get_or_create_id(scenario) - self.fixed.append(node) + self.end_node = self._get_or_create_id(scenario) def calculate_pos(self): """ diff --git a/utest/test_visualise_models.py b/utest/test_visualise_models.py index f9651f03..a6f6ff7e 100644 --- a/utest/test_visualise_models.py +++ b/utest/test_visualise_models.py @@ -1,9 +1,34 @@ import unittest +import networkx as nx from robotmbt.tracestate import TraceState from robotmbt.visualise.models import * class TestVisualiseModels(unittest.TestCase): + """ + Contains tests for robotmbt/visualise/models.py + """ + + """ + Class: ScenarioInfo + """ + + def test_scenarioInfo_str(self): + si = ScenarioInfo('test') + self.assertEqual(si.name, 'test') + self.assertEqual(si.src_id, 'test') + + def test_scenarioInfo_Scenario(self): + s = Scenario('test') + s.src_id = 0 + si = ScenarioInfo(s) + self.assertEqual(si.name, 'test') + self.assertEqual(si.src_id, 0) + + """ + Class: TraceInfo + """ + def test_create_TraceInfo(self): ts = TraceState(3) candidates = [] @@ -12,9 +37,136 @@ def test_create_TraceInfo(self): ts.confirm_full_scenario(candidates[-1], scenario, {}) ti = TraceInfo(trace=ts, state=None) - self.assertEqual(0, ti.trace[0].name) - self.assertEqual(1, ti.trace[1].name) - self.assertEqual(2, ti.trace[2].name) + self.assertEqual(ti.trace[0].name, 0) + self.assertEqual(ti.trace[1].name, 1) + self.assertEqual(ti.trace[2].name, 2) # TODO change when state is implemented self.assertIsNone(ti.state) + + """ + Class: ScenarioGraph + """ + + def test_scenario_graph_init(self): + sg = ScenarioGraph() + self.assertIn('start', sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') + + def test_scenario_graph_ids_empty(self): + sg = ScenarioGraph() + si = ScenarioInfo('test') + id = sg._get_or_create_id(si) + self.assertEqual(id, 'node0') + + def test_scenario_graph_ids_duplicate_scenario(self): + sg = ScenarioGraph() + si = ScenarioInfo('test') + id0 = sg._get_or_create_id(si) + id1 = sg._get_or_create_id(si) + self.assertEqual(id0, id1) + + def test_scenario_graph_ids_different_scenarios(self): + sg = ScenarioGraph() + si0 = ScenarioInfo('test0') + si1 = ScenarioInfo('test1') + id0 = sg._get_or_create_id(si0) + id1 = sg._get_or_create_id(si1) + self.assertEqual(id0, 'node0') + self.assertEqual(id1, 'node1') + + def test_scenario_graph_add_new_node(self): + sg = ScenarioGraph() + sg.ids['test'] = ScenarioInfo('test') + sg.add_node('test') + self.assertIn('test', sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes['test']['label'], 'test') + + def test_scenario_graph_add_existing_node(self): + sg = ScenarioGraph() + sg.add_node('start') + self.assertIn('start', sg.networkx.nodes) + self.assertEqual(len(sg.networkx.nodes), 1) + + def test_scenario_graph_update_visualisation_nodes(self): + ts = TraceState(3) + candidates = [] + for scenario in range(3): + candidates.append(ts.next_candidate()) + ts.confirm_full_scenario(candidates[-1], scenario, {}) + ti = TraceInfo(trace=ts, state=None) + sg = ScenarioGraph() + sg.update_visualisation(ti) + + self.assertIn('node0', sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes['node0']['label'], 0) + self.assertIn('node1', sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes['node1']['label'], 1) + self.assertIn('node2', sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes['node2']['label'], 2) + + def test_scenario_graph_update_visualisation_edges(self): + ts = TraceState(3) + candidates = [] + for scenario in range(3): + candidates.append(ts.next_candidate()) + ts.confirm_full_scenario(candidates[-1], scenario, {}) + ti = TraceInfo(trace=ts, state=None) + sg = ScenarioGraph() + sg.update_visualisation(ti) + + self.assertIn(('node0', 'node1'), sg.networkx.edges) + self.assertIn(('node1', 'node2'), sg.networkx.edges) + + edge_labels = nx.get_edge_attributes(sg.networkx, "label") + self.assertEqual(edge_labels[('node0', 'node1')], '') + self.assertEqual(edge_labels[('node1', 'node2')], '') + + def test_scenario_graph_update_visualisation_single_node(self): + ts = TraceState(1) + ts.confirm_full_scenario(0, 'one', {}) + self.assertEqual(ts.get_trace(), ['one']) + ti = TraceInfo(trace=ts, state=None) + sg = ScenarioGraph() + sg.update_visualisation(ti) + + # expected behaviour: no nodes nor edges are added + self.assertEqual(len(sg.networkx.nodes), 1) + self.assertEqual(len(sg.networkx.edges), 0) + + def test_scenario_graph_set_starting_node_new_node(self): + sg = ScenarioGraph() + si = ScenarioInfo('test') + sg.set_starting_node(si) + id = sg._get_or_create_id(si) + # node + self.assertIn(id, sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes[id]['label'], 'test') + + # edge + self.assertIn(('start', id), sg.networkx.edges) + edge_labels = nx.get_edge_attributes(sg.networkx, "label") + self.assertEqual(edge_labels[('start', id)], '') + + def test_scenario_graph_set_starting_node_existing_node(self): + sg = ScenarioGraph() + si = ScenarioInfo('test') + id = sg._get_or_create_id(si) + sg.add_node(id) + self.assertIn(id, sg.networkx.nodes) + + sg.set_starting_node(si) + self.assertIn(('start', id), sg.networkx.edges) + edge_labels = nx.get_edge_attributes(sg.networkx, "label") + self.assertEqual(edge_labels[('start', id)], '') + + def test_scenario_graph_set_end_node(self): + sg = ScenarioGraph() + si = ScenarioInfo('test') + id = sg._get_or_create_id(si) + sg.set_ending_node(si) + self.assertEqual(sg.end_node, id) + + +if __name__ == '__main__': + unittest.main() From 63c8eb18e08db45d21116dbf9825198389e04698 Mon Sep 17 00:00:00 2001 From: D Osinga <46380170+osingaatje@users.noreply.github.com> Date: Thu, 13 Nov 2025 17:45:00 +0100 Subject: [PATCH 012/131] Acceptance testing (#18) * initial acceptance test PoC * added traceInfo generator, adjusted some constructors to work with Robot testing, first working PoC of Atest * found first bug with Acceptance Test * fix unit tests * refactored helper, removed unnecessary logging * added return type for * deleted useless testing file * inlined suite setup * reduced type of state to ModelSpace, fixed incorrect type hint * added warning for future state implementations * changed utest to reflect comments from PR pull #8 (acceptance testing) - TODO add model state Thomas/Tycho * fixed constructor TraceInfo to require ModelSpace * Revert accidental change --------- Co-authored-by: jonathan <148167.jw@gmail.com> Co-authored-by: Thomas --- atest/resources/helpers/__init__.py | 0 atest/resources/helpers/modelgenerator.py | 27 +++++++++++ atest/resources/visualisation.resource | 47 +++++++++++++++++++ .../GraphHasNodeCountEqualtoScenarios.robot | 11 +++++ robotmbt/suiteprocessors.py | 2 +- robotmbt/visualise/__init__.py | 0 robotmbt/visualise/models.py | 14 ++++-- robotmbt/visualise/visualiser.py | 7 ++- utest/test_visualise_models.py | 12 ++--- 9 files changed, 108 insertions(+), 12 deletions(-) create mode 100644 atest/resources/helpers/__init__.py create mode 100644 atest/resources/helpers/modelgenerator.py create mode 100644 atest/resources/visualisation.resource create mode 100644 atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/GraphHasNodeCountEqualtoScenarios.robot create mode 100644 robotmbt/visualise/__init__.py diff --git a/atest/resources/helpers/__init__.py b/atest/resources/helpers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/atest/resources/helpers/modelgenerator.py b/atest/resources/helpers/modelgenerator.py new file mode 100644 index 00000000..5a9e535b --- /dev/null +++ b/atest/resources/helpers/modelgenerator.py @@ -0,0 +1,27 @@ +import random +import string + +from robot.api.deco import keyword # type:ignore +from robotmbt.visualise.models import TraceInfo, ScenarioInfo, ModelSpace + +class ModelGenerator: + @keyword(name="Generate Trace Information") # type: ignore + def generate_trace_info(self, scenario_count :int) -> TraceInfo: + """Generates a list of unique random scenarios.""" + scenarios :list[ScenarioInfo] = ModelGenerator.generate_scenario_names(scenario_count) + + return TraceInfo(scenarios, ModelSpace()) + + @staticmethod + def generate_random_scenario_name(length :int=10) -> str: + """Generates a random scenario name.""" + return ''.join(random.choices(string.ascii_letters + string.digits, k=length)) + + @staticmethod + def generate_scenario_names(count :int) -> list[ScenarioInfo]: + """Generates a list of unique random scenarios.""" + scenarios :set[str] = set() + while len(scenarios) < count: + scenario = ModelGenerator.generate_random_scenario_name() + scenarios.add(scenario) + return [ScenarioInfo(s) for s in scenarios] diff --git a/atest/resources/visualisation.resource b/atest/resources/visualisation.resource new file mode 100644 index 00000000..79914241 --- /dev/null +++ b/atest/resources/visualisation.resource @@ -0,0 +1,47 @@ +*** Settings *** +Documentation Resource file for testing the visualisation of RobotMBT +Library atest.resources.helpers.modelgenerator.ModelGenerator +Library robotmbt.visualise.visualiser.Visualiser +Library Collections + + +*** Keywords *** +Test Suite ${suite} exists + [Documentation] Makes a test suite + ... :IN: suite = ${suite} + ... :OUT: None + Set Suite Variable ${suite} + +Test Suite ${suite} has ${count} scenarios + [Documentation] Makes a test suite + ... :IN: scenario_count = ${count}, suite = ${suite} + ... :OUT: None + # use customer ScenarioGenerator.py to generate some scenarios + + ${trace_info} = Generate Trace Information ${count} + Set Suite Variable ${trace_info} + +# WARNING: this only works for Scenario graphs, this needs to be adjusted to continually update the model when adding new state. +Graph ${graph} is generated based on Test Suite ${suite} + [Documentation] Generates the graph + ... :IN: graph = ${graph}, suite = ${suite} + ... :OUT: None + Variable Should Exist ${trace_info} + ${visualiser} = robotmbt.visualise.visualiser.Visualiser.Construct + Call Method ${visualiser} update_visualisation ${trace_info} + ${html} = Call Method ${visualiser} generate_visualisation + + Set Suite Variable ${visualiser} + Set Suite Variable ${html} + +Graph ${graph} contains ${number} vertices + [Documentation] Verifies that the graph contains the specified number of vertices. + ... :IN: graph = ${graph}, number = ${number} + ... :OUT: None + Variable Should Exist ${visualiser} + Variable Should Exist ${trace_info} + + ${vertex_count} = Get Length ${visualiser.graph.networkx.nodes} + Should Be Equal As Integers ${vertex_count} ${number} + + diff --git a/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/GraphHasNodeCountEqualtoScenarios.robot b/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/GraphHasNodeCountEqualtoScenarios.robot new file mode 100644 index 00000000..a4c19baa --- /dev/null +++ b/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/GraphHasNodeCountEqualtoScenarios.robot @@ -0,0 +1,11 @@ +*** Settings *** +Resource ../../../../resources/visualisation.resource +Library robotmbt processor=echo +Suite Setup Set Global Variable ${scen_count} ${2} + +*** Test Cases *** +Graph should contain vertex count equal to scenario count + 1 for scenario-graph + Given Test Suite s exists + Given Test Suite s has ${scen_count} scenarios + When Graph g is generated based on Test Suite s + Then Graph g contains ${scen_count + 1} vertices \ No newline at end of file diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index f6799691..5c8d003d 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -154,7 +154,7 @@ def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool): logger.debug( f"last state:\n{self.active_model.get_status_text()}") self.visualiser.update_visualisation( - TraceInfo(self.tracestate, self.active_model)) + TraceInfo.from_trace_state(self.tracestate, self.active_model)) def __last_candidate_changed_nothing(self) -> bool: if len(self.tracestate) < 2: diff --git a/robotmbt/visualise/__init__.py b/robotmbt/visualise/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index 96f416e5..f5e6ebc6 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -31,11 +31,17 @@ class TraceInfo: - trace: the strung together scenarios up until this point - state: the model space """ + @classmethod + def from_trace_state(cls, trace: TraceState, state: ModelSpace): + return cls([ScenarioInfo(t) for t in trace.get_trace()], state) - def __init__(self, trace: TraceState, state: ModelSpace): - self.trace = [ScenarioInfo(s) for s in trace.get_trace()] + def __init__(self, trace :list[ScenarioInfo], state :ModelSpace): + self.trace :list[ScenarioInfo] = trace # TODO: actually use state - self.state = state + self.state :ModelSpace = state + + def __repr__(self) -> str: + return f"TraceInfo(trace=[{[str(t) for t in self.trace]}], state={self.state})" class ScenarioGraph: @@ -82,7 +88,7 @@ def _get_or_create_id(self, scenario: ScenarioInfo) -> str: """ for i in self.ids.keys(): # TODO: decide how to deal with repeating scenarios, this merges repeated scenarios into a single scenario - if self.ids[i].src_id == scenario.src_id: + if self.ids[i].src_id == scenario.src_id and scenario.src_id is not None: return i new_id = f"node{len(self.ids)}" diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py index 2c27455e..f2faeb5f 100644 --- a/robotmbt/visualise/visualiser.py +++ b/robotmbt/visualise/visualiser.py @@ -1,4 +1,4 @@ -from .models import ScenarioGraph, TraceInfo, ScenarioInfo +from robotmbt.visualise.models import ScenarioGraph, TraceInfo, ScenarioInfo from bokeh.palettes import Spectral4 from bokeh.models import ( Plot, Range1d, Circle, @@ -23,6 +23,11 @@ class Visualiser: GRAPH_PADDING_PERC: int = 15 # % MAX_VERTEX_NAME_LEN: int = 20 # no. of characters + # glue method to let us construct Visualiser objects in Robot tests. + @classmethod + def construct(cls): + return cls() # just calls __init__, but without having underscores etc. + def __init__(self): self.graph = ScenarioGraph() diff --git a/utest/test_visualise_models.py b/utest/test_visualise_models.py index a6f6ff7e..f4db9cda 100644 --- a/utest/test_visualise_models.py +++ b/utest/test_visualise_models.py @@ -35,14 +35,14 @@ def test_create_TraceInfo(self): for scenario in range(3): candidates.append(ts.next_candidate()) ts.confirm_full_scenario(candidates[-1], scenario, {}) - ti = TraceInfo(trace=ts, state=None) + ti = TraceInfo.from_trace_state(trace=ts, state=ModelSpace()) self.assertEqual(ti.trace[0].name, 0) self.assertEqual(ti.trace[1].name, 1) self.assertEqual(ti.trace[2].name, 2) - # TODO change when state is implemented - self.assertIsNone(ti.state) + self.assertIsNotNone(ti.state) + # TODO: add state tests to this. """ Class: ScenarioGraph @@ -94,7 +94,7 @@ def test_scenario_graph_update_visualisation_nodes(self): for scenario in range(3): candidates.append(ts.next_candidate()) ts.confirm_full_scenario(candidates[-1], scenario, {}) - ti = TraceInfo(trace=ts, state=None) + ti = TraceInfo.from_trace_state(ts, ModelSpace()) sg = ScenarioGraph() sg.update_visualisation(ti) @@ -111,7 +111,7 @@ def test_scenario_graph_update_visualisation_edges(self): for scenario in range(3): candidates.append(ts.next_candidate()) ts.confirm_full_scenario(candidates[-1], scenario, {}) - ti = TraceInfo(trace=ts, state=None) + ti = TraceInfo.from_trace_state(ts, ModelSpace()) sg = ScenarioGraph() sg.update_visualisation(ti) @@ -126,7 +126,7 @@ def test_scenario_graph_update_visualisation_single_node(self): ts = TraceState(1) ts.confirm_full_scenario(0, 'one', {}) self.assertEqual(ts.get_trace(), ['one']) - ti = TraceInfo(trace=ts, state=None) + ti = TraceInfo.from_trace_state(ts, ModelSpace()) sg = ScenarioGraph() sg.update_visualisation(ti) From 905614f44404665c4764492012e911603b0b6671 Mon Sep 17 00:00:00 2001 From: D Osinga <46380170+osingaatje@users.noreply.github.com> Date: Mon, 17 Nov 2025 11:21:46 +0100 Subject: [PATCH 013/131] Acceptance test - Vertex and Edge rules (#19) * initial acceptance test PoC * added traceInfo generator, adjusted some constructors to work with Robot testing, first working PoC of Atest * refactored helper, removed unnecessary logging * added edge representation acceptance test --- atest/resources/helpers/modelgenerator.py | 51 +++++++++++++++++-- atest/resources/visualisation.resource | 27 +++++++++- ...Scenarios.robot => M1a1_VertexCount.robot} | 0 .../M1a2_EdgeRepresentation.robot | 12 +++++ robotmbt/visualise/models.py | 24 +++++++++ 5 files changed, 109 insertions(+), 5 deletions(-) rename atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/{GraphHasNodeCountEqualtoScenarios.robot => M1a1_VertexCount.robot} (100%) create mode 100644 atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a2_EdgeRepresentation.robot diff --git a/atest/resources/helpers/modelgenerator.py b/atest/resources/helpers/modelgenerator.py index 5a9e535b..e94cca60 100644 --- a/atest/resources/helpers/modelgenerator.py +++ b/atest/resources/helpers/modelgenerator.py @@ -2,7 +2,7 @@ import string from robot.api.deco import keyword # type:ignore -from robotmbt.visualise.models import TraceInfo, ScenarioInfo, ModelSpace +from robotmbt.visualise.models import TraceInfo, ScenarioInfo, ScenarioGraph class ModelGenerator: @keyword(name="Generate Trace Information") # type: ignore @@ -10,7 +10,52 @@ def generate_trace_info(self, scenario_count :int) -> TraceInfo: """Generates a list of unique random scenarios.""" scenarios :list[ScenarioInfo] = ModelGenerator.generate_scenario_names(scenario_count) - return TraceInfo(scenarios, ModelSpace()) + return TraceInfo(scenarios, None) + + @keyword(name="Ensure Scenario Present") # type: ignore + def ensure_scenario_present(self, trace_info :TraceInfo, scenario_name :str) -> TraceInfo: + if trace_info.contains_scenario(scenario_name): + return trace_info + + trace_info.add_scenario(ScenarioInfo(scenario_name)) + return trace_info + + @keyword(name="Ensure Scenario Follows") # type: ignore + def ensure_scenario_follows(self, trace_info :TraceInfo, scen1 :str, scen2 :str) -> TraceInfo: + scen1_info :ScenarioInfo|None = trace_info.get_scenario(scen1) + scen2_info :ScenarioInfo|None = trace_info.get_scenario(scen2) + + if scen1_info is None or scen2_info is None: + raise Exception(f"Ensure Scenario Follows for scenarios that did not exist! scen1={scen1}, scen2={scen2}") + + # both scenarios apparently exist, now make sure that scenario2 follows after some appearance of scenario 1: + scen1_index :int = trace_info.trace.index(scen1_info) + scen2_index :int = trace_info.trace.index(scen2_info) + if scen2_index == scen1_index + 1: + return trace_info + + # if it doesn't follow, make it follow + trace_info.insert_trace_at(scen1_index, scen2_info) + return trace_info + + @keyword(name="Ensure Edge Exists") # type: ignore + def ensure_edge_exists(self, graph :ScenarioGraph, scen_name1 :str, scen_name2 :str): + # get node name based on scenario + nodename1 :str = "" + nodename2 :str = "" + for (nodename, label) in graph.networkx.nodes(data='label', default=None): + if label == scen_name1: + nodename1 = nodename + + if label == scen_name2: + nodename2 = nodename + + # now check the relation: + if (nodename1, nodename2) in graph.networkx.edges: # type: ignore + return # exists :) + + # make sure that it exists + graph.networkx.add_edge(nodename1, nodename2) @staticmethod def generate_random_scenario_name(length :int=10) -> str: @@ -24,4 +69,4 @@ def generate_scenario_names(count :int) -> list[ScenarioInfo]: while len(scenarios) < count: scenario = ModelGenerator.generate_random_scenario_name() scenarios.add(scenario) - return [ScenarioInfo(s) for s in scenarios] + return [ScenarioInfo(s) for s in scenarios] \ No newline at end of file diff --git a/atest/resources/visualisation.resource b/atest/resources/visualisation.resource index 79914241..c756abc7 100644 --- a/atest/resources/visualisation.resource +++ b/atest/resources/visualisation.resource @@ -11,17 +11,34 @@ Test Suite ${suite} exists ... :IN: suite = ${suite} ... :OUT: None Set Suite Variable ${suite} + ${trace_info} = Generate Trace Information ${0} + Set Suite Variable ${trace_info} # make empty trace info Test Suite ${suite} has ${count} scenarios [Documentation] Makes a test suite ... :IN: scenario_count = ${count}, suite = ${suite} ... :OUT: None - # use customer ScenarioGenerator.py to generate some scenarios - ${trace_info} = Generate Trace Information ${count} Set Suite Variable ${trace_info} # WARNING: this only works for Scenario graphs, this needs to be adjusted to continually update the model when adding new state. +Test Suite ${suite} contains scenario ${scenario_name} + [Documentation] Ensures test suite Suite has scenario with name=Scenario name + ... :IN: suite = ${suite}, scenario_name = ${scenario_namef} + ... :OUT: None + Variable Should Exist ${trace_info} + + ${trace_info} = Ensure Scenario Present ${trace_info} ${scenario_name} + Set Suite Variable ${trace_info} + +In Test Suite ${suite}, scenario ${s2} can be reached from ${s1} + [Documentation] Ensures a scenario s2 immediately follows s1 in a trace + ... :IN: suite = ${suite}, scenario2 = ${s2}, scenario1 = ${s1} + ... :OUT: None + + ${trace_info} = Ensure Scenario Follows ${trace_info} ${s1} ${s2} + Set Suite Variable ${trace_info} + Graph ${graph} is generated based on Test Suite ${suite} [Documentation] Generates the graph ... :IN: graph = ${graph}, suite = ${suite} @@ -44,4 +61,10 @@ Graph ${graph} contains ${number} vertices ${vertex_count} = Get Length ${visualiser.graph.networkx.nodes} Should Be Equal As Integers ${vertex_count} ${number} +Graph ${graph} shows an edge from ${scenname1} towards ${scenname2} + [Documentation] Verifies that a generated graph contains a certain edge + ... :IN: graph = ${graph}, scenario name 1 = ${scenname1}, scenario name 2 = ${scenname2} + ... :OUT: None + Variable Should Exist ${visualiser} + ${res} = Ensure Edge Exists ${visualiser.graph} ${scenname1} ${scenname2} \ No newline at end of file diff --git a/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/GraphHasNodeCountEqualtoScenarios.robot b/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a1_VertexCount.robot similarity index 100% rename from atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/GraphHasNodeCountEqualtoScenarios.robot rename to atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a1_VertexCount.robot diff --git a/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a2_EdgeRepresentation.robot b/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a2_EdgeRepresentation.robot new file mode 100644 index 00000000..7f56bc60 --- /dev/null +++ b/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a2_EdgeRepresentation.robot @@ -0,0 +1,12 @@ +*** Settings *** +Resource ../../../../resources/visualisation.resource +Library robotmbt processor=echo + +*** Test Cases *** +Graph should contain edge from vertex A to vertex B if B can be reached from A + Given Test Suite s exists + Given Test Suite s contains scenario Drive To Destination + Given Test Suite s contains scenario Arrive At Destination + Given In Test Suite s, scenario Arrive At Destination can be reached from Drive To Destination + When Graph g is generated based on Test Suite s + Then Graph g shows an edge from Drive To Destination towards Arrive At Destination diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index f5e6ebc6..1fb0625b 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -43,6 +43,30 @@ def __init__(self, trace :list[ScenarioInfo], state :ModelSpace): def __repr__(self) -> str: return f"TraceInfo(trace=[{[str(t) for t in self.trace]}], state={self.state})" + def contains_scenario(self, scen_name :str) -> bool: + for scen in self.trace: + if scen.name == scen_name: + return True + return False + + def add_scenario(self, scen :ScenarioInfo): + """ + Used in acceptance testing + """ + self.trace.append(scen) + + def get_scenario(self, scen_name :str) -> ScenarioInfo|None: + for scenario in self.trace: + if scenario.name == scen_name: + return scenario + return None + + def insert_trace_at(self, index :int, scen_info :ScenarioInfo): + if index < 0 or index >= len(self.trace): + raise IndexError(f"InsertTraceAt received invalid index ({index}) for length of list ({len(self.trace)})") + + self.trace.insert(index, scen_info) + class ScenarioGraph: """ From 30367abcb6f18411f11add9ac833afc462a90170 Mon Sep 17 00:00:00 2001 From: Jonathan <61787386+JWillegers@users.noreply.github.com> Date: Tue, 18 Nov 2025 09:27:30 +0100 Subject: [PATCH 014/131] Add subfolders to toml (#20) --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 71ae0217..1dfea0fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,8 +23,8 @@ dependencies = [ ] requires-python = ">=3.10" -[tool.setuptools] -packages = ["robotmbt"] +[tool.setuptools.packages.find] +include = ["robotmbt*"] [project.urls] Homepage = "https://github.com/JFoederer/robotframeworkMBT" From 87ca4198868c3893725ef9f2cc47a7428814d968 Mon Sep 17 00:00:00 2001 From: D Osinga <46380170+osingaatje@users.noreply.github.com> Date: Tue, 18 Nov 2025 16:38:05 +0100 Subject: [PATCH 015/131] Node redesign (#16) * Refactor node rendering to use rectangles for scenarios * Improve edge connection points and arrow positioning * Redesign self-loops for rectangle nodes * Fix self-loop arrow alignment and improve visual consistency * Fix visualization issues according to reviwer's feedback * magic fix from Jonathan * Simplify self-loop logic and remove invalid edge cases * redo part of Jonathan fix - makes ERROR:bokeh.core.validation.check:E-1001 (BAD_COLUMN_NAME) disappear * remove duplicate code from edge calculation --------- Co-authored-by: Diogo Silva --- robotmbt/visualise/visualiser.py | 343 +++++++++++++++++++++++-------- 1 file changed, 259 insertions(+), 84 deletions(-) diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py index f2faeb5f..e18a5ded 100644 --- a/robotmbt/visualise/visualiser.py +++ b/robotmbt/visualise/visualiser.py @@ -1,10 +1,10 @@ from robotmbt.visualise.models import ScenarioGraph, TraceInfo, ScenarioInfo from bokeh.palettes import Spectral4 from bokeh.models import ( - Plot, Range1d, Circle, + Plot, Range1d, Circle, Rect, Arrow, NormalHead, LabelSet, Bezier, ColumnDataSource, ResetTool, - SaveTool, WheelZoomTool, PanTool + SaveTool, WheelZoomTool, PanTool, Text ) from bokeh.embed import file_html from bokeh.resources import CDN @@ -12,7 +12,6 @@ import html import networkx as nx - class Visualiser: """ The Visualiser class bridges the different concerns to provide @@ -46,7 +45,6 @@ def generate_visualisation(self) -> str: html_bokeh = networkvisualiser.generate_html() return f"" - class NetworkVisualiser: """ Generate plot with Bokeh @@ -54,31 +52,27 @@ class NetworkVisualiser: EDGE_WIDTH: float = 2.0 EDGE_ALPHA: float = 0.7 - EDGE_COLOUR: str | tuple[int, int, int] = ( - 12, 12, 12) # 'visual studio black' + EDGE_COLOUR: str | tuple[int, int, int] = (12, 12, 12) # 'visual studio black' + ARROWHEAD_SIZE: int = 6 # Consistent arrowhead size def __init__(self, graph: ScenarioGraph): self.plot = None self.graph = graph - self.labels = dict(x=[], y=[], label=[]) + self.node_props = {} # Store node properties for arrow calculations # graph customisation options self.node_radius = 1.0 + self.char_width = 0.1 + self.char_height = 0.1 + self.padding = 0.1 def generate_html(self) -> str: """ Generate html file from networkx graph via Bokeh """ self._initialise_plot() + self._add_nodes_with_labels() self._add_edges() - self._add_nodes() - label_source = ColumnDataSource(data=self.labels) - labels = LabelSet(x="x", y="y", text="label", source=label_source, - text_color=NetworkVisualiser.EDGE_COLOUR, - text_align="center") - - self.plot.add_layout(labels) - return file_html(self.plot, CDN, "graph") def _initialise_plot(self): @@ -96,7 +90,9 @@ def _initialise_plot(self): # scale node radius based on range nodes_range = max(x_max-x_min, y_max-y_min) - self.node_radius = nodes_range / 50 + self.node_radius = nodes_range / 150 + self.char_width = nodes_range / 150 + self.char_height = nodes_range / 150 # create plot x_range = Range1d(min(x_min, y_min), max(x_max, y_max)) @@ -111,107 +107,286 @@ def _initialise_plot(self): self.plot.add_tools(ResetTool(), SaveTool(), WheelZoomTool(), PanTool()) - def _add_nodes(self): + def _calculate_text_dimensions(self, text: str) -> tuple[float, float]: + """Calculate width and height needed for text based on actual text length""" + # Calculate width based on character count + text_length = len(text) + width = (text_length * self.char_width) + (2 * self.padding) + + # Reduced height for more compact rectangles + height = self.char_height + (self.padding) + + return width, height + + def _add_nodes_with_labels(self): """ - Add labels to the nodes in bokeh plot + Add nodes with text labels inside them """ node_labels = nx.get_node_attributes(self.graph.networkx, "label") + + # Create data sources for nodes and labels + circle_data = dict(x=[], y=[], radius=[], label=[]) + rect_data = dict(x=[], y=[], width=[], height=[], label=[]) + text_data = dict(x=[], y=[], text=[]) + for node in self.graph.networkx.nodes: - # prepare adding labels - self.labels['x'].append(self.graph.pos[node][0]) - self.labels['y'].append(self.graph.pos[node][1]+self.node_radius) - self.labels['label'].append(self._cap_name(node_labels[node])) - - # add node - bokeh_node = Circle(radius=self.node_radius, - fill_color=Spectral4[0], - x=self.graph.pos[node][0], - y=self.graph.pos[node][1]) - - self.plot.add_glyph(bokeh_node) - - def add_self_loop(self, x: float, y: float, label: str): + # Labels are always defined and cannot be lists + label = node_labels[node] + label = self._cap_name(label) + x, y = self.graph.pos[node] + + if node == 'start': + # For start node (circle), calculate radius based on text width + text_width, text_height = self._calculate_text_dimensions(label) + # Calculate radius from text dimensions + radius = (text_width / 2.5) + + circle_data['x'].append(x) + circle_data['y'].append(y) + circle_data['radius'].append(radius) + circle_data['label'].append(label) + + # Store node properties for arrow calculations + self.node_props[node] = {'type': 'circle', 'x': x, 'y': y, 'radius': radius, 'label': label} + + else: + # For scenario nodes (rectangles), calculate dimensions based on text + text_width, text_height = self._calculate_text_dimensions(label) + + rect_data['x'].append(x) + rect_data['y'].append(y) + rect_data['width'].append(text_width) + rect_data['height'].append(text_height) + rect_data['label'].append(label) + + # Store node properties for arrow calculations + self.node_props[node] = {'type': 'rect', 'x': x, 'y': y, 'width': text_width, 'height': text_height, 'label': label} + + # Add text for all nodes + text_data['x'].append(x) + text_data['y'].append(y) + text_data['text'].append(label) + + # Add circles for start node + if circle_data['x']: + circle_source = ColumnDataSource(circle_data) + circles = Circle(x='x', y='y', radius='radius', + fill_color=Spectral4[0]) + self.plot.add_glyph(circle_source, circles) + + # Add rectangles for scenario nodes + if rect_data['x']: + rect_source = ColumnDataSource(rect_data) + rectangles = Rect(x='x', y='y', width='width', height='height', + fill_color=Spectral4[0]) + self.plot.add_glyph(rect_source, rectangles) + + # Add text labels for all nodes + text_source = ColumnDataSource(text_data) + text_labels = Text(x='x', y='y', text='text', + text_align='center', text_baseline='middle', + text_color='white', text_font_size='9pt') + self.plot.add_glyph(text_source, text_labels) + + def _get_edge_points(self, start_node, end_node): + """Calculate edge start and end points at node borders""" + start_props = self.node_props.get(start_node) + end_props = self.node_props.get(end_node) + + # Node properties should always exist + if not start_props or not end_props: + raise ValueError(f"Node properties not found for nodes: {start_node}, {end_node}") + + # Calculate direction vector + dx = end_props['x'] - start_props['x'] + dy = end_props['y'] - start_props['y'] + distance = sqrt(dx*dx + dy*dy) + + # Self-loops are handled separately, distance should never be 0 + if distance == 0: + raise ValueError("Distance between different nodes should not be zero") + + # Normalize direction vector + dx /= distance + dy /= distance + + # Calculate start point at border + if start_props['type'] == 'circle': + start_x = start_props['x'] + dx * start_props['radius'] + start_y = start_props['y'] + dy * start_props['radius'] + else: + # Find where the line intersects the rectangle border + rect_width = start_props['width'] + rect_height = start_props['height'] + + # Calculate scaling factors for x and y directions + scale_x = rect_width / (2 * abs(dx)) if dx != 0 else float('inf') + scale_y = rect_height / (2 * abs(dy)) if dy != 0 else float('inf') + + # Use the smaller scale to ensure we hit the border + scale = min(scale_x, scale_y) + + start_x = start_props['x'] + dx * scale + start_y = start_props['y'] + dy * scale + + # Calculate end point at border (reverse direction) + # End nodes should never be circles for regular edges + if end_props['type'] == 'circle': + raise ValueError(f"End node should not be a circle for regular edges: {end_node}") + else: + rect_width = end_props['width'] + rect_height = end_props['height'] + + # Calculate scaling factors for x and y directions (reverse) + scale_x = rect_width / (2 * abs(dx)) if dx != 0 else float('inf') + scale_y = rect_height / (2 * abs(dy)) if dy != 0 else float('inf') + + # Use the smaller scale to ensure we hit the border + scale = min(scale_x, scale_y) + + end_x = end_props['x'] - dx * scale + end_y = end_props['y'] - dy * scale + + return start_x, start_y, end_x, end_y + + def add_self_loop(self, node_id: str): """ - Self-loop as a Bezier curve with arrow head + Circular arc that starts and ends at the top side of the rectangle + Start at 1/4 width, end at 3/4 width, with a circular arc above + The arc itself ends with the arrowhead pointing into the rectangle """ + # Get node properties directly by node ID + node_props = self.node_props.get(node_id) + + # Node properties should always exist + if node_props is None: + raise ValueError(f"Node properties not found for node: {node_id}") + + # Self-loops should only be for rectangle nodes (scenarios) + if node_props['type'] != 'rect': + raise ValueError(f"Self-loops should only be for rectangle nodes, got: {node_props['type']}") + + x, y = node_props['x'], node_props['y'] + width = node_props['width'] + height = node_props['height'] + + # Start: 1/4 width from left, top side + start_x = x - width/4 + start_y = y + height/2 + + # End: 3/4 width from left, top side + end_x = x + width/4 + end_y = y + height/2 + + # Arc height above the rectangle + arc_height = width * 0.4 + + # Control points for a circular arc above + control1_x = x - width/8 + control1_y = y + height/2 + arc_height + + control2_x = x + width/8 + control2_y = y + height/2 + arc_height + + # Create the Bezier curve (the main arc) with the same thickness as straight lines loop = Bezier( - # starting point - x0=x + self.node_radius, - y0=y, - - # end point - x1=x, - y1=y - self.node_radius, - - # control points - cx0=x + 5*self.node_radius, - cy0=y, - cx1=x, - cy1=y - 5*self.node_radius, - - # styling + x0=start_x, y0=start_y, + x1=end_x, y1=end_y, + cx0=control1_x, cy0=control1_y, + cx1=control2_x, cy1=control2_y, line_color=NetworkVisualiser.EDGE_COLOUR, line_width=NetworkVisualiser.EDGE_WIDTH, line_alpha=NetworkVisualiser.EDGE_ALPHA, ) self.plot.add_glyph(loop) - # add arrow head + # Calculate the tangent direction at the end of the Bezier curve + # For a cubic Bezier, the tangent at the end point is from the last control point to the end point + tangent_x = end_x - control2_x + tangent_y = end_y - control2_y + + # Normalize the tangent vector + tangent_length = sqrt(tangent_x**2 + tangent_y**2) + if tangent_length > 0: + tangent_x /= tangent_length + tangent_y /= tangent_length + + # Add just the arrowhead (NormalHead) at the end point, oriented along the tangent + arrowhead = NormalHead( + size=NetworkVisualiser.ARROWHEAD_SIZE, + line_color=NetworkVisualiser.EDGE_COLOUR, + fill_color=NetworkVisualiser.EDGE_COLOUR, + line_width=NetworkVisualiser.EDGE_WIDTH + ) + + # Create a standalone arrowhead at the end point + # Strategy: use a very short Arrow that's essentially just the head arrow = Arrow( - end=NormalHead(size=10, - line_color=NetworkVisualiser.EDGE_COLOUR, - fill_color=NetworkVisualiser.EDGE_COLOUR), - - # -0.01 to guarantee that arrow points upwards. - x_start=x, y_start=y-self.node_radius-0.01, - x_end=x, y_end=y-self.node_radius + end=arrowhead, + x_start=end_x - tangent_x * 0.001, # Almost zero length line + y_start=end_y - tangent_y * 0.001, + x_end=end_x, + y_end=end_y, + line_color=NetworkVisualiser.EDGE_COLOUR, + line_width=NetworkVisualiser.EDGE_WIDTH, + line_alpha=NetworkVisualiser.EDGE_ALPHA ) + self.plot.add_layout(arrow) - # add edge label - self.labels['x'].append(x + self.node_radius) - self.labels['y'].append(y - 4*self.node_radius) - self.labels['label'].append(label) + # Add edge label - positioned above the arc + label_x = x + label_y = y + height/2 + arc_height * 0.6 - self.plot.add_layout(arrow) + return label_x, label_y def _add_edges(self): edge_labels = nx.get_edge_attributes(self.graph.networkx, "label") + + # Create data sources for edges and edge labels + edge_text_data = dict(x=[], y=[], text=[]) + for edge in self.graph.networkx.edges(): - x0, y0 = self.graph.pos[edge[0]] - x1, y1 = self.graph.pos[edge[1]] + # Edge labels are always defined and cannot be lists + edge_label = edge_labels[edge] + edge_label = self._cap_name(edge_label) + edge_text_data['text'].append(edge_label) + if edge[0] == edge[1]: - self.add_self_loop( - x=x0, y=y0, label=self._cap_name(edge_labels[edge])) - + # Self-loop handled separately + label_x, label_y = self.add_self_loop(edge[0]) + edge_text_data['x'].append(label_x) + edge_text_data['y'].append(label_y) + else: - # edge between 2 different nodes - dx = x1 - x0 - dy = y1 - y0 - - length = sqrt(dx**2 + dy**2) - + # Calculate edge points at node borders + start_x, start_y, end_x, end_y = self._get_edge_points(edge[0], edge[1]) + + # Add arrow between the calculated points arrow = Arrow( end=NormalHead( - size=10, + size=NetworkVisualiser.ARROWHEAD_SIZE, line_color=NetworkVisualiser.EDGE_COLOUR, fill_color=NetworkVisualiser.EDGE_COLOUR), - - x_start=x0 + dx / length * self.node_radius, - y_start=y0 + dy / length * self.node_radius, - x_end=x1 - dx / length * self.node_radius, - y_end=y1 - dy / length * self.node_radius + x_start=start_x, y_start=start_y, + x_end=end_x, y_end=end_y ) - self.plot.add_layout(arrow) - # add edge label - self.labels['x'].append((x0+x1)/2) - self.labels['y'].append((y0+y1)/2) - self.labels['label'].append(self._cap_name(edge_labels[edge])) + # Collect edge label data (position at midpoint) + edge_text_data['x'].append((start_x + end_x) / 2) + edge_text_data['y'].append((start_y + end_y) / 2) + + # Add all edge labels at once + if edge_text_data['x']: + edge_text_source = ColumnDataSource(edge_text_data) + edge_labels_glyph = Text(x='x', y='y', text='text', + text_align='center', text_baseline='middle', + text_font_size='7pt') + self.plot.add_glyph(edge_text_source, edge_labels_glyph) @staticmethod def _cap_name(name: str) -> str: if len(name) < Visualiser.MAX_VERTEX_NAME_LEN: return name - return f"{name[:(Visualiser.MAX_VERTEX_NAME_LEN-3)]}..." + return f"{name[:(Visualiser.MAX_VERTEX_NAME_LEN-3)]}..." \ No newline at end of file From 6fe31533b483558395c971eb5e9a0a225e14b1d8 Mon Sep 17 00:00:00 2001 From: Thomas Kas Date: Wed, 19 Nov 2025 11:22:54 +0100 Subject: [PATCH 016/131] State graphs + graph switching + dependency checks (#21) * Update using final trace info internally * Add StateInfo abstraction * Implement StateGraph * cleaned up StateInfo.__init__ a little * Merge with main * Fixed oopsie * Implement abstract base class for graphs * Implement per-suite graph choice * Only generate graph if dependencies are installed * Don't run our tests without dependencies * Use empty string instead of None * doodoo * Merge and fix some issues * Run formatter * Fixed doc * Implement requested changes --------- Co-authored-by: tychodub --- atest/resources/helpers/modelgenerator.py | 58 ++--- atest/resources/visualisation.resource | 10 +- .../M1a1_VertexCount.robot | 2 +- .../M1a2_EdgeRepresentation.robot | 2 +- robotmbt/modelspace.py | 2 +- robotmbt/substitutionmap.py | 2 +- robotmbt/suitedata.py | 10 +- robotmbt/suiteprocessors.py | 40 +-- robotmbt/suitereplacer.py | 11 +- robotmbt/tracestate.py | 36 +-- robotmbt/visualise/models.py | 236 ++++++++++++++++-- robotmbt/visualise/visualiser.py | 173 ++++++------- run_tests.py | 12 +- utest/test_visualise_models.py | 74 +++--- 14 files changed, 439 insertions(+), 229 deletions(-) diff --git a/atest/resources/helpers/modelgenerator.py b/atest/resources/helpers/modelgenerator.py index e94cca60..18b8a1b8 100644 --- a/atest/resources/helpers/modelgenerator.py +++ b/atest/resources/helpers/modelgenerator.py @@ -1,72 +1,74 @@ import random import string -from robot.api.deco import keyword # type:ignore +from robot.api.deco import keyword # type:ignore +from robotmbt.modelspace import ModelSpace from robotmbt.visualise.models import TraceInfo, ScenarioInfo, ScenarioGraph -class ModelGenerator: - @keyword(name="Generate Trace Information") # type: ignore - def generate_trace_info(self, scenario_count :int) -> TraceInfo: + +class ModelGenerator: + @keyword(name="Generate Trace Information") # type: ignore + def generate_trace_info(self, scenario_count: int) -> TraceInfo: """Generates a list of unique random scenarios.""" - scenarios :list[ScenarioInfo] = ModelGenerator.generate_scenario_names(scenario_count) + scenarios: list[ScenarioInfo] = ModelGenerator.generate_scenario_names(scenario_count) - return TraceInfo(scenarios, None) + return TraceInfo(scenarios, ModelSpace()) - @keyword(name="Ensure Scenario Present") # type: ignore - def ensure_scenario_present(self, trace_info :TraceInfo, scenario_name :str) -> TraceInfo: + @keyword(name="Ensure Scenario Present") # type: ignore + def ensure_scenario_present(self, trace_info: TraceInfo, scenario_name: str) -> TraceInfo: if trace_info.contains_scenario(scenario_name): return trace_info trace_info.add_scenario(ScenarioInfo(scenario_name)) return trace_info - @keyword(name="Ensure Scenario Follows") # type: ignore - def ensure_scenario_follows(self, trace_info :TraceInfo, scen1 :str, scen2 :str) -> TraceInfo: - scen1_info :ScenarioInfo|None = trace_info.get_scenario(scen1) - scen2_info :ScenarioInfo|None = trace_info.get_scenario(scen2) + @keyword(name="Ensure Scenario Follows") # type: ignore + def ensure_scenario_follows(self, trace_info: TraceInfo, scen1: str, scen2: str) -> TraceInfo: + scen1_info: ScenarioInfo | None = trace_info.get_scenario(scen1) + scen2_info: ScenarioInfo | None = trace_info.get_scenario(scen2) if scen1_info is None or scen2_info is None: raise Exception(f"Ensure Scenario Follows for scenarios that did not exist! scen1={scen1}, scen2={scen2}") # both scenarios apparently exist, now make sure that scenario2 follows after some appearance of scenario 1: - scen1_index :int = trace_info.trace.index(scen1_info) - scen2_index :int = trace_info.trace.index(scen2_info) + scen1_index: int = trace_info.trace.index(scen1_info) + scen2_index: int = trace_info.trace.index(scen2_info) if scen2_index == scen1_index + 1: return trace_info - + # if it doesn't follow, make it follow trace_info.insert_trace_at(scen1_index, scen2_info) return trace_info - @keyword(name="Ensure Edge Exists") # type: ignore - def ensure_edge_exists(self, graph :ScenarioGraph, scen_name1 :str, scen_name2 :str): + @keyword(name="Ensure Edge Exists") # type: ignore + def ensure_edge_exists(self, graph: ScenarioGraph, scen_name1: str, scen_name2: str): # get node name based on scenario - nodename1 :str = "" - nodename2 :str = "" + nodename1: str = "" + nodename2: str = "" for (nodename, label) in graph.networkx.nodes(data='label', default=None): if label == scen_name1: nodename1 = nodename if label == scen_name2: nodename2 = nodename - + # now check the relation: - if (nodename1, nodename2) in graph.networkx.edges: # type: ignore - return # exists :) - + if (nodename1, nodename2) in graph.networkx.edges: # type: ignore + return # exists :) + # make sure that it exists graph.networkx.add_edge(nodename1, nodename2) @staticmethod - def generate_random_scenario_name(length :int=10) -> str: + def generate_random_scenario_name(length: int = 10) -> str: """Generates a random scenario name.""" return ''.join(random.choices(string.ascii_letters + string.digits, k=length)) - + @staticmethod - def generate_scenario_names(count :int) -> list[ScenarioInfo]: + def generate_scenario_names(count: int) -> list[ScenarioInfo]: """Generates a list of unique random scenarios.""" - scenarios :set[str] = set() + scenarios: set[str] = set() while len(scenarios) < count: scenario = ModelGenerator.generate_random_scenario_name() scenarios.add(scenario) - return [ScenarioInfo(s) for s in scenarios] \ No newline at end of file + return [ScenarioInfo(s) for s in scenarios] diff --git a/atest/resources/visualisation.resource b/atest/resources/visualisation.resource index c756abc7..0dc9af9e 100644 --- a/atest/resources/visualisation.resource +++ b/atest/resources/visualisation.resource @@ -1,7 +1,7 @@ *** Settings *** Documentation Resource file for testing the visualisation of RobotMBT Library atest.resources.helpers.modelgenerator.ModelGenerator -Library robotmbt.visualise.visualiser.Visualiser +Library robotmbt.visualise.visualiser.Visualiser scenario Library Collections @@ -24,7 +24,7 @@ Test Suite ${suite} has ${count} scenarios # WARNING: this only works for Scenario graphs, this needs to be adjusted to continually update the model when adding new state. Test Suite ${suite} contains scenario ${scenario_name} [Documentation] Ensures test suite Suite has scenario with name=Scenario name - ... :IN: suite = ${suite}, scenario_name = ${scenario_namef} + ... :IN: suite = ${suite}, scenario_name = ${scenario_name} ... :OUT: None Variable Should Exist ${trace_info} @@ -39,12 +39,12 @@ In Test Suite ${suite}, scenario ${s2} can be reached from ${s1} ${trace_info} = Ensure Scenario Follows ${trace_info} ${s1} ${s2} Set Suite Variable ${trace_info} -Graph ${graph} is generated based on Test Suite ${suite} +Graph ${graph} of type ${graph_type} is generated based on Test Suite ${suite} [Documentation] Generates the graph - ... :IN: graph = ${graph}, suite = ${suite} + ... :IN: graph = ${graph}, graph_type = ${graph_type}, suite = ${suite} ... :OUT: None Variable Should Exist ${trace_info} - ${visualiser} = robotmbt.visualise.visualiser.Visualiser.Construct + ${visualiser} = robotmbt.visualise.visualiser.Visualiser.Construct graph_type=${graph_type} Call Method ${visualiser} update_visualisation ${trace_info} ${html} = Call Method ${visualiser} generate_visualisation diff --git a/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a1_VertexCount.robot b/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a1_VertexCount.robot index a4c19baa..9224c2af 100644 --- a/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a1_VertexCount.robot +++ b/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a1_VertexCount.robot @@ -7,5 +7,5 @@ Suite Setup Set Global Variable ${scen_count} ${2} Graph should contain vertex count equal to scenario count + 1 for scenario-graph Given Test Suite s exists Given Test Suite s has ${scen_count} scenarios - When Graph g is generated based on Test Suite s + When Graph g of type scenario is generated based on Test Suite s Then Graph g contains ${scen_count + 1} vertices \ No newline at end of file diff --git a/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a2_EdgeRepresentation.robot b/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a2_EdgeRepresentation.robot index 7f56bc60..a8162950 100644 --- a/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a2_EdgeRepresentation.robot +++ b/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a2_EdgeRepresentation.robot @@ -8,5 +8,5 @@ Graph should contain edge from vertex A to vertex B if B can be reached from A Given Test Suite s contains scenario Drive To Destination Given Test Suite s contains scenario Arrive At Destination Given In Test Suite s, scenario Arrive At Destination can be reached from Drive To Destination - When Graph g is generated based on Test Suite s + When Graph g of type scenario is generated based on Test Suite s Then Graph g shows an edge from Drive To Destination towards Arrive At Destination diff --git a/robotmbt/modelspace.py b/robotmbt/modelspace.py index f7e8489f..f30c9012 100644 --- a/robotmbt/modelspace.py +++ b/robotmbt/modelspace.py @@ -44,7 +44,7 @@ def __init__(self, reference_id=None): self.ref_id: str = str(reference_id) self.std_attrs: list[str] = [] self.props: dict[str, RecursiveScope | ModelSpace] = dict() - + # For using literals without having to use quotes (abc='abc') self.values: dict[str, any] = dict() self.scenario_vars: list[RecursiveScope] = [] diff --git a/robotmbt/substitutionmap.py b/robotmbt/substitutionmap.py index 6cdbc736..b47ada71 100644 --- a/robotmbt/substitutionmap.py +++ b/robotmbt/substitutionmap.py @@ -151,7 +151,7 @@ def copy(self): def add_constraint(self, constraint): if constraint is None: return - + self.optionset = [opt for opt in self.optionset if opt in constraint] if not len(self.optionset): raise ValueError('No options left after adding constraint') diff --git a/robotmbt/suitedata.py b/robotmbt/suitedata.py index 95c2b116..2684ad41 100644 --- a/robotmbt/suitedata.py +++ b/robotmbt/suitedata.py @@ -248,7 +248,7 @@ def add_robot_dependent_data(self, robot_kw): self.args = StepArguments([StepArgument(*match, kind=StepArgument.EMBEDDED) for match in zip(robot_kw.embedded.args, robot_kw.embedded.parse_args(self.kw_wo_gherkin))]) - + self.args += self.__handle_non_embedded_arguments(robot_kw.args) self.signature = robot_kw.name self.model_info = self.__parse_model_info(robot_kw._doc) @@ -257,14 +257,14 @@ def add_robot_dependent_data(self, robot_kw): def __handle_non_embedded_arguments(self, robot_argspec) -> list[StepArgument]: result = [] - + p_args, n_args = robot_argspec.map([a for a in self.org_pn_args if '=' not in a or r'\=' in a], [a.split('=', 1) for a in self.org_pn_args if '=' in a and r'\=' not in a]) - + # for some reason .map() returns [None] instead of the empty list when there are no arguments if p_args == [None]: p_args = [] - + ArgumentValidator(robot_argspec).validate(p_args, n_args) robot_args = [a for a in robot_argspec] argument_names = list(robot_argspec.argument_names) @@ -312,7 +312,7 @@ def __parse_model_info(self, docu: str) -> dict[str, list[str]]: expressions = [e.strip() for e in elms[-1].split("|") if e] while lines and not lines[0].startswith(":"): expressions.extend([e.strip() - for e in lines.pop(0).split("|") if e]) + for e in lines.pop(0).split("|") if e]) model_info[key] = expressions if not model_info: raise ValueError("When present, *model info* cannot be empty") diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index 5c8d003d..e3c9ecac 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -41,15 +41,21 @@ from .suitedata import Suite, Scenario, Step from .tracestate import TraceState, TraceSnapShot from .steparguments import StepArgument, StepArguments -from .visualise.visualiser import Visualiser -from .visualise.models import TraceInfo, ScenarioInfo +try: + from .visualise.visualiser import Visualiser + from .visualise.models import TraceInfo + + VISUALISE = True +except ImportError: + Visualiser = None + TraceInfo = None + VISUALISE = False -class SuiteProcessors: - def __init__(self): - self.visualiser = Visualiser() - def echo(self, in_suite): +class SuiteProcessors: + @staticmethod + def echo(in_suite): return in_suite def flatten(self, in_suite: Suite) -> Suite: @@ -82,7 +88,7 @@ def flatten(self, in_suite: Suite) -> Suite: out_suite.suites = [] return out_suite - def process_test_suite(self, in_suite: Suite, *, seed: any = 'new') -> Suite: + def process_test_suite(self, in_suite: Suite, *, seed: any = 'new', graph: str = '') -> Suite: self.out_suite = Suite(in_suite.name) self.out_suite.filename = in_suite.filename self.out_suite.parent = in_suite.parent @@ -98,7 +104,12 @@ def process_test_suite(self, in_suite: Suite, *, seed: any = 'new') -> Suite: self._init_randomiser(seed) random.shuffle(self.scenarios) - self.visualiser = Visualiser() + self.visualiser = None + if graph != '' and VISUALISE: + self.visualiser = Visualiser(graph) + elif graph != '' and not VISUALISE: + logger.warn(f'Visualisation {graph} requested, but required dependencies are not installed.' + 'Install them with `pip install .[visualization]`.') # a short trace without the need for repeating scenarios is preferred self._try_to_reach_full_coverage(allow_duplicate_scenarios=False) @@ -108,15 +119,16 @@ def process_test_suite(self, in_suite: Suite, *, seed: any = 'new') -> Suite: "Direct trace not available. Allowing repetition of scenarios") self._try_to_reach_full_coverage(allow_duplicate_scenarios=True) if not self.tracestate.coverage_reached(): - logger.write( - self.visualiser.generate_visualisation(), html=True) + if self.visualiser is not None: + logger.write(self.visualiser.generate_visualisation(), html=True) raise Exception("Unable to compose a consistent suite") self.out_suite.scenarios = self.tracestate.get_trace() self._report_tracestate_wrapup() - self.visualiser.set_start(ScenarioInfo(self.tracestate.get_trace()[0])) - self.visualiser.set_end(ScenarioInfo(self.tracestate.get_trace()[-1])) + if self.visualiser is not None: + self.visualiser.set_final_trace(TraceInfo.from_trace_state(self.tracestate, self.active_model)) + logger.write(self.visualiser.generate_visualisation(), html=True) return self.out_suite @@ -153,8 +165,8 @@ def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool): self._report_tracestate_to_user() logger.debug( f"last state:\n{self.active_model.get_status_text()}") - self.visualiser.update_visualisation( - TraceInfo.from_trace_state(self.tracestate, self.active_model)) + if self.visualiser is not None: + self.visualiser.update_visualisation(TraceInfo.from_trace_state(self.tracestate, self.active_model)) def __last_candidate_changed_nothing(self) -> bool: if len(self.tracestate) < 2: diff --git a/robotmbt/suitereplacer.py b/robotmbt/suitereplacer.py index 837f40b0..de4a0ee8 100644 --- a/robotmbt/suitereplacer.py +++ b/robotmbt/suitereplacer.py @@ -100,11 +100,6 @@ def treat_model_based(self, **kwargs): self.__clearTestSuite(self.robot_suite) self.__generateRobotSuite(modelbased_suite, self.robot_suite) - # TODO: add flag using kwargs to disable this - if isinstance(self.processor_lib, SuiteProcessors): - logger.write( - self.processor_lib.visualiser.generate_visualisation(), html=True) - @keyword("Set model-based options") def set_model_based_options(self, **kwargs): """ @@ -127,14 +122,14 @@ def __process_robot_suite(self, in_suite: Suite, parent: Suite | None) -> Suite: if in_suite.setup and parent is not None: step_info = Step(in_suite.setup.name, * - in_suite.setup.args, parent=out_suite) + in_suite.setup.args, parent=out_suite) step_info.add_robot_dependent_data( Robot._namespace.get_runner(step_info.org_step).keyword) out_suite.setup = step_info if in_suite.teardown and parent is not None: step_info = Step(in_suite.teardown.name, * - in_suite.teardown.args, parent=out_suite) + in_suite.teardown.args, parent=out_suite) step_info.add_robot_dependent_data( Robot._namespace.get_runner(step_info.org_step).keyword) out_suite.teardown = step_info @@ -154,7 +149,7 @@ def __process_robot_suite(self, in_suite: Suite, parent: Suite | None) -> Suite: if tc.teardown: step_info = Step(tc.teardown.name, * - tc.teardown.args, parent=scenario) + tc.teardown.args, parent=scenario) step_info.add_robot_dependent_data( Robot._namespace.get_runner(step_info.org_step).keyword) scenario.teardown = step_info diff --git a/robotmbt/tracestate.py b/robotmbt/tracestate.py index 9e8dbb5f..362375d7 100644 --- a/robotmbt/tracestate.py +++ b/robotmbt/tracestate.py @@ -44,13 +44,13 @@ class TraceState: def __init__(self, n_scenarios: int): # coverage pool: True means scenario is in trace self._c_pool: list[bool] = [False] * n_scenarios - + # Keeps track of the scenarios already tried at each step in the trace self._tried: list[list[int]] = [[]] - + # Choice trace, when was which scenario inserted (e.g. ['1', '2.1', '3', '2.0']) self._trace: list[str] = [] - + # Keeps details for elements in trace self._snapshots: list[TraceSnapShot] = [] self._open_refinements: list[int] = [] @@ -80,14 +80,14 @@ def next_candidate(self, retry: bool = False) -> int | None: for i in range(len(self._c_pool)): if i not in self._tried[-1] and not self._is_refinement_active(i) and self.count(i) == 0: return i - + if not retry: return None - + for i in range(len(self._c_pool)): if i not in self._tried[-1] and not self._is_refinement_active(i): return i - + return None def count(self, index: int) -> int: @@ -98,13 +98,13 @@ def count(self, index: int) -> int: def highest_part(self, index: int) -> int: """Given the current trace and an index, returns the highest part number of an ongoing refinement for the related scenario. Returns 0 when there is no refinement active.""" - for i in range(1, len(self._trace)+1): + for i in range(1, len(self._trace) + 1): if self._trace[-i] == f'{index}': return 0 - + if self._trace[-i].startswith(f'{index}.'): return int(self._trace[-i].split('.')[1]) - + return 0 def _is_refinement_active(self, index: int) -> bool: @@ -113,9 +113,9 @@ def _is_refinement_active(self, index: int) -> bool: def find_scenarios_with_active_refinement(self) -> list[str | Scenario]: scenarios = [] for i in self._open_refinements: - index = -self._trace[::-1].index(f'{i}.1')-1 + index = -self._trace[::-1].index(f'{i}.1') - 1 scenarios.append(self._snapshots[index].scenario) - + return scenarios def reject_scenario(self, i_scenario: int): @@ -127,8 +127,8 @@ def confirm_full_scenario(self, index: int, scenario: str, model: dict[str, int] self._c_pool[index] = True c_drought = 0 else: - c_drought = self.coverage_drought+1 - + c_drought = self.coverage_drought + 1 + if self._is_refinement_active(index): id = f"{index}.0" self._open_refinements.pop() @@ -136,14 +136,14 @@ def confirm_full_scenario(self, index: int, scenario: str, model: dict[str, int] id = str(index) self._tried[-1].append(index) self._tried.append([]) - + self._trace.append(id) self._snapshots.append(TraceSnapShot(id, scenario, model, c_drought)) def push_partial_scenario(self, index: int, scenario: str, model: dict[str, int]): if self._is_refinement_active(index): - id = f"{index}.{self.highest_part(index)+1}" - + id = f"{index}.{self.highest_part(index) + 1}" + else: id = f"{index}.1" self._tried[-1].append(index) @@ -171,10 +171,10 @@ def rewind(self) -> TraceSnapShot | None: if self.count(index) == 0: self._c_pool[index] = False self._tried.pop() - + if id.endswith('.1'): self._open_refinements.pop() - + return self._snapshots[-1] if self._snapshots else None def __iter__(self): diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index 1fb0625b..d9c661fb 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -1,3 +1,4 @@ +from abc import ABC, abstractmethod from robotmbt.modelspace import ModelSpace from robotmbt.suitedata import Scenario from robotmbt.tracestate import TraceState @@ -22,7 +23,37 @@ def __init__(self, scenario: Scenario | str): self.src_id = scenario def __str__(self): - return f"Scen{self.src_id}: {self.name}" + return f"Scenario {self.src_id}: {self.name}" + + +class StateInfo: + """ + This contains all information we need from states, abstracting away from the actual ModelSpace class: + - domain + - properties + """ + + def __init__(self, state: ModelSpace): + self.domain = state.ref_id + self.properties = {} + for p in state.props: + self.properties[p] = {} + if p == 'scenario': + self.properties['scenario'] = dict(state.props['scenario']) + else: + for attr in dir(state.props[p]): + self.properties[p][attr] = getattr(state.props[p], attr) + + def __eq__(self, other): + return self.domain == other.domain and self.properties == other.properties + + def __str__(self): + res = "" + for p in self.properties: + res += f"{p}:\n" + for k, v in self.properties[p].items(): + res += f"\t{k}={v}\n" + return res class TraceInfo: @@ -31,44 +62,93 @@ class TraceInfo: - trace: the strung together scenarios up until this point - state: the model space """ + @classmethod def from_trace_state(cls, trace: TraceState, state: ModelSpace): return cls([ScenarioInfo(t) for t in trace.get_trace()], state) - def __init__(self, trace :list[ScenarioInfo], state :ModelSpace): - self.trace :list[ScenarioInfo] = trace - # TODO: actually use state - self.state :ModelSpace = state - + def __init__(self, trace: list[ScenarioInfo], state: ModelSpace): + self.trace: list[ScenarioInfo] = trace + self.state = StateInfo(state) + def __repr__(self) -> str: return f"TraceInfo(trace=[{[str(t) for t in self.trace]}], state={self.state})" - def contains_scenario(self, scen_name :str) -> bool: + def contains_scenario(self, scen_name: str) -> bool: for scen in self.trace: if scen.name == scen_name: return True return False - def add_scenario(self, scen :ScenarioInfo): + def add_scenario(self, scen: ScenarioInfo): """ Used in acceptance testing """ self.trace.append(scen) - def get_scenario(self, scen_name :str) -> ScenarioInfo|None: + def get_scenario(self, scen_name: str) -> ScenarioInfo | None: for scenario in self.trace: if scenario.name == scen_name: return scenario return None - def insert_trace_at(self, index :int, scen_info :ScenarioInfo): + def insert_trace_at(self, index: int, scen_info: ScenarioInfo): if index < 0 or index >= len(self.trace): raise IndexError(f"InsertTraceAt received invalid index ({index}) for length of list ({len(self.trace)})") self.trace.insert(index, scen_info) -class ScenarioGraph: +class AbstractGraph(ABC): + @abstractmethod + def update_visualisation(self, info: TraceInfo): + """ + Update the visualisation with new trace information from another exploration step. + """ + pass + + @abstractmethod + def set_final_trace(self, info: TraceInfo): + """ + Update the graph with information on the final trace. + """ + pass + + @abstractmethod + def calculate_pos(self): + """ + Calculate the position (x, y) for all nodes in self.networkx + """ + pass + + @property + @abstractmethod + def networkx(self) -> nx.DiGraph: + """ + We use networkx to store nodes and edges. + """ + pass + + @networkx.setter + @abstractmethod + def networkx(self, value: nx.DiGraph): + pass + + @property + @abstractmethod + def pos(self) -> dict: + """ + A dictionary with the positions of nodes. + """ + pass + + @pos.setter + @abstractmethod + def pos(self, value: dict): + pass + + +class ScenarioGraph(AbstractGraph): """ The scenario graph is the most basic representation of trace exploration. It represents scenarios as nodes, and the trace as edges. @@ -92,15 +172,14 @@ def __init__(self): def update_visualisation(self, info: TraceInfo): """ - Update the visualisation with new trace information from another exploration step. This will add nodes for all new scenarios in the provided trace, as well as edges for all pairs in the provided trace. """ for i in range(0, len(info.trace) - 1): from_node = self._get_or_create_id(info.trace[i]) to_node = self._get_or_create_id(info.trace[i + 1]) - self.add_node(from_node) - self.add_node(to_node) + self._add_node(from_node) + self._add_node(to_node) if (from_node, to_node) not in self.networkx.edges: self.networkx.add_edge( @@ -119,33 +198,150 @@ def _get_or_create_id(self, scenario: ScenarioInfo) -> str: self.ids[new_id] = scenario return new_id - def add_node(self, node: str): + def _add_node(self, node: str): """ - Add node if it doesn't already exist + Add node if it doesn't already exist. """ if node not in self.networkx.nodes: self.networkx.add_node(node, label=self.ids[node].name) - def set_starting_node(self, scenario: ScenarioInfo): + def _set_starting_node(self, scenario: ScenarioInfo): """ Update the starting node. """ node = self._get_or_create_id(scenario) - self.add_node(node) + self._add_node(node) self.networkx.add_edge('start', node, label='') - def set_ending_node(self, scenario: ScenarioInfo): + def _set_ending_node(self, scenario: ScenarioInfo): """ Update the end node. """ self.end_node = self._get_or_create_id(scenario) + def set_final_trace(self, info: TraceInfo): + """ + Update the graph with information on the final trace. + """ + self._set_starting_node(info.trace[0]) + self._set_ending_node(info.trace[-1]) + def calculate_pos(self): + try: + self.pos = nx.planar_layout(self.networkx) + except nx.NetworkXException: + # if planar layout cannot find a graph without crossing edges + self.pos = nx.arf_layout(self.networkx, seed=42) + + @property + def networkx(self) -> nx.DiGraph: + return self._networkx + + @networkx.setter + def networkx(self, value: nx.DiGraph): + self._networkx = value + + @property + def pos(self) -> dict: + return self._pos + + @pos.setter + def pos(self, value: dict): + self._pos = value + + +class StateGraph(AbstractGraph): + """ + The state graph is a more advanced representation of trace exploration, allowing you to see the internal state. + It represents states as nodes, and scenarios as edges. + """ + + def __init__(self): + # We use simplified IDs for nodes, and store the actual state info here + self.ids: dict[str, StateInfo] = {} + + # The networkx graph is a directional graph + self.networkx = nx.DiGraph() + + # Stores the position (x, y) of the nodes + self.pos = {} + + # add the start node + self.networkx.add_node('start', label='start') + + self.prev_state = StateInfo(ModelSpace()) + self.prev_trace_len = 0 + + def update_visualisation(self, info: TraceInfo): """ - Calculate the position (x, y) for all nodes in self.networkx + This will add nodes the newly reached state (if we did not roll back), as well as an edge from the previous to + the current state labeled with the scenario that took it there. """ + if len(info.trace) > 0: + scenario = info.trace[-1] + + from_node = self._get_or_create_id(self.prev_state) + if len(info.trace) == 1: + from_node = 'start' + to_node = self._get_or_create_id(info.state) + + self._add_node(from_node) + self._add_node(to_node) + + if self.prev_trace_len < len(info.trace): + if (from_node, to_node) not in self.networkx.edges: + self.networkx.add_edge(from_node, to_node, label=scenario.name) + + self.prev_state = info.state + self.prev_trace_len = len(info.trace) + + def _get_or_create_id(self, state: StateInfo) -> str: + """ + Get the ID for a state that has been added before, or create and store a new one. + """ + for i in self.ids.keys(): + if self.ids[i] == state: + return i + + new_id = f"node{len(self.ids)}" + self.ids[new_id] = state + return new_id + + def _add_node(self, node: str): + """ + Add node if it doesn't already exist. + """ + if node not in self.networkx.nodes: + self.networkx.add_node(node, label=str(self.ids[node])) + + def set_final_trace(self, info: TraceInfo): + self._set_ending_node(info.state) + + def _set_ending_node(self, state: StateInfo): + """ + Update the end node. + """ + self.end_node = self._get_or_create_id(state) + + def calculate_pos(self): try: self.pos = nx.planar_layout(self.networkx) except nx.NetworkXException: # if planar layout cannot find a graph without crossing edges self.pos = nx.arf_layout(self.networkx, seed=42) + + @property + def networkx(self) -> nx.DiGraph: + return self._networkx + + @networkx.setter + def networkx(self, value: nx.DiGraph): + self._networkx = value + + @property + def pos(self) -> dict: + return self._pos + + @pos.setter + def pos(self, value: dict): + self._pos = value diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py index e18a5ded..5e81eec1 100644 --- a/robotmbt/visualise/visualiser.py +++ b/robotmbt/visualise/visualiser.py @@ -1,8 +1,8 @@ -from robotmbt.visualise.models import ScenarioGraph, TraceInfo, ScenarioInfo +from robotmbt.visualise.models import AbstractGraph, ScenarioGraph, StateGraph, TraceInfo from bokeh.palettes import Spectral4 from bokeh.models import ( Plot, Range1d, Circle, Rect, - Arrow, NormalHead, LabelSet, + Arrow, NormalHead, Bezier, ColumnDataSource, ResetTool, SaveTool, WheelZoomTool, PanTool, Text ) @@ -12,6 +12,7 @@ import html import networkx as nx + class Visualiser: """ The Visualiser class bridges the different concerns to provide @@ -24,27 +25,29 @@ class Visualiser: # glue method to let us construct Visualiser objects in Robot tests. @classmethod - def construct(cls): - return cls() # just calls __init__, but without having underscores etc. + def construct(cls, graph_type: str): + return cls(graph_type) # just calls __init__, but without having underscores etc. - def __init__(self): - self.graph = ScenarioGraph() + def __init__(self, graph_type: str): + if graph_type == 'scenario': + self.graph: AbstractGraph = ScenarioGraph() + elif graph_type == 'state': + self.graph: AbstractGraph = StateGraph() + else: + raise ValueError(f"Unknown graph type: {graph_type}!") def update_visualisation(self, info: TraceInfo): self.graph.update_visualisation(info) - def set_start(self, scenario: ScenarioInfo): - self.graph.set_starting_node(scenario) - - def set_end(self, scenario: ScenarioInfo): - self.graph.set_ending_node(scenario) + def set_final_trace(self, info: TraceInfo): + self.graph.set_final_trace(info) def generate_visualisation(self) -> str: self.graph.calculate_pos() - networkvisualiser = NetworkVisualiser(self.graph) - html_bokeh = networkvisualiser.generate_html() + html_bokeh = NetworkVisualiser(self.graph).generate_html() return f"" + class NetworkVisualiser: """ Generate plot with Bokeh @@ -55,7 +58,7 @@ class NetworkVisualiser: EDGE_COLOUR: str | tuple[int, int, int] = (12, 12, 12) # 'visual studio black' ARROWHEAD_SIZE: int = 6 # Consistent arrowhead size - def __init__(self, graph: ScenarioGraph): + def __init__(self, graph: AbstractGraph): self.plot = None self.graph = graph self.node_props = {} # Store node properties for arrow calculations @@ -89,9 +92,9 @@ def _initialise_plot(self): y_max = max(y_range) + padding * (max(y_range) - min(y_range)) # scale node radius based on range - nodes_range = max(x_max-x_min, y_max-y_min) + nodes_range = max(x_max - x_min, y_max - y_min) self.node_radius = nodes_range / 150 - self.char_width = nodes_range / 150 + self.char_width = nodes_range / 150 self.char_height = nodes_range / 150 # create plot @@ -112,10 +115,10 @@ def _calculate_text_dimensions(self, text: str) -> tuple[float, float]: # Calculate width based on character count text_length = len(text) width = (text_length * self.char_width) + (2 * self.padding) - + # Reduced height for more compact rectangles - height = self.char_height + (self.padding) - + height = self.char_height + self.padding + return width, height def _add_nodes_with_labels(self): @@ -123,93 +126,94 @@ def _add_nodes_with_labels(self): Add nodes with text labels inside them """ node_labels = nx.get_node_attributes(self.graph.networkx, "label") - + # Create data sources for nodes and labels circle_data = dict(x=[], y=[], radius=[], label=[]) rect_data = dict(x=[], y=[], width=[], height=[], label=[]) text_data = dict(x=[], y=[], text=[]) - + for node in self.graph.networkx.nodes: # Labels are always defined and cannot be lists label = node_labels[node] label = self._cap_name(label) x, y = self.graph.pos[node] - + if node == 'start': # For start node (circle), calculate radius based on text width text_width, text_height = self._calculate_text_dimensions(label) # Calculate radius from text dimensions radius = (text_width / 2.5) - + circle_data['x'].append(x) circle_data['y'].append(y) circle_data['radius'].append(radius) circle_data['label'].append(label) - + # Store node properties for arrow calculations self.node_props[node] = {'type': 'circle', 'x': x, 'y': y, 'radius': radius, 'label': label} - + else: # For scenario nodes (rectangles), calculate dimensions based on text text_width, text_height = self._calculate_text_dimensions(label) - + rect_data['x'].append(x) rect_data['y'].append(y) rect_data['width'].append(text_width) rect_data['height'].append(text_height) rect_data['label'].append(label) - + # Store node properties for arrow calculations - self.node_props[node] = {'type': 'rect', 'x': x, 'y': y, 'width': text_width, 'height': text_height, 'label': label} - + self.node_props[node] = {'type': 'rect', 'x': x, 'y': y, 'width': text_width, 'height': text_height, + 'label': label} + # Add text for all nodes text_data['x'].append(x) text_data['y'].append(y) text_data['text'].append(label) - + # Add circles for start node if circle_data['x']: circle_source = ColumnDataSource(circle_data) - circles = Circle(x='x', y='y', radius='radius', - fill_color=Spectral4[0]) + circles = Circle(x='x', y='y', radius='radius', + fill_color=Spectral4[0]) self.plot.add_glyph(circle_source, circles) - + # Add rectangles for scenario nodes if rect_data['x']: rect_source = ColumnDataSource(rect_data) rectangles = Rect(x='x', y='y', width='width', height='height', - fill_color=Spectral4[0]) + fill_color=Spectral4[0]) self.plot.add_glyph(rect_source, rectangles) - + # Add text labels for all nodes text_source = ColumnDataSource(text_data) text_labels = Text(x='x', y='y', text='text', - text_align='center', text_baseline='middle', - text_color='white', text_font_size='9pt') + text_align='center', text_baseline='middle', + text_color='white', text_font_size='9pt') self.plot.add_glyph(text_source, text_labels) def _get_edge_points(self, start_node, end_node): """Calculate edge start and end points at node borders""" start_props = self.node_props.get(start_node) end_props = self.node_props.get(end_node) - + # Node properties should always exist if not start_props or not end_props: raise ValueError(f"Node properties not found for nodes: {start_node}, {end_node}") - + # Calculate direction vector dx = end_props['x'] - start_props['x'] dy = end_props['y'] - start_props['y'] - distance = sqrt(dx*dx + dy*dy) - + distance = sqrt(dx * dx + dy * dy) + # Self-loops are handled separately, distance should never be 0 if distance == 0: raise ValueError("Distance between different nodes should not be zero") - + # Normalize direction vector dx /= distance dy /= distance - + # Calculate start point at border if start_props['type'] == 'circle': start_x = start_props['x'] + dx * start_props['radius'] @@ -218,17 +222,17 @@ def _get_edge_points(self, start_node, end_node): # Find where the line intersects the rectangle border rect_width = start_props['width'] rect_height = start_props['height'] - + # Calculate scaling factors for x and y directions scale_x = rect_width / (2 * abs(dx)) if dx != 0 else float('inf') scale_y = rect_height / (2 * abs(dy)) if dy != 0 else float('inf') - + # Use the smaller scale to ensure we hit the border scale = min(scale_x, scale_y) - + start_x = start_props['x'] + dx * scale start_y = start_props['y'] + dy * scale - + # Calculate end point at border (reverse direction) # End nodes should never be circles for regular edges if end_props['type'] == 'circle': @@ -236,17 +240,17 @@ def _get_edge_points(self, start_node, end_node): else: rect_width = end_props['width'] rect_height = end_props['height'] - + # Calculate scaling factors for x and y directions (reverse) scale_x = rect_width / (2 * abs(dx)) if dx != 0 else float('inf') scale_y = rect_height / (2 * abs(dy)) if dy != 0 else float('inf') - + # Use the smaller scale to ensure we hit the border scale = min(scale_x, scale_y) - + end_x = end_props['x'] - dx * scale end_y = end_props['y'] - dy * scale - + return start_x, start_y, end_x, end_y def add_self_loop(self, node_id: str): @@ -257,37 +261,37 @@ def add_self_loop(self, node_id: str): """ # Get node properties directly by node ID node_props = self.node_props.get(node_id) - + # Node properties should always exist if node_props is None: raise ValueError(f"Node properties not found for node: {node_id}") - + # Self-loops should only be for rectangle nodes (scenarios) if node_props['type'] != 'rect': raise ValueError(f"Self-loops should only be for rectangle nodes, got: {node_props['type']}") - + x, y = node_props['x'], node_props['y'] width = node_props['width'] height = node_props['height'] - + # Start: 1/4 width from left, top side - start_x = x - width/4 - start_y = y + height/2 - + start_x = x - width / 4 + start_y = y + height / 2 + # End: 3/4 width from left, top side - end_x = x + width/4 - end_y = y + height/2 - + end_x = x + width / 4 + end_y = y + height / 2 + # Arc height above the rectangle arc_height = width * 0.4 - + # Control points for a circular arc above - control1_x = x - width/8 - control1_y = y + height/2 + arc_height - - control2_x = x + width/8 - control2_y = y + height/2 + arc_height - + control1_x = x - width / 8 + control1_y = y + height / 2 + arc_height + + control2_x = x + width / 8 + control2_y = y + height / 2 + arc_height + # Create the Bezier curve (the main arc) with the same thickness as straight lines loop = Bezier( x0=start_x, y0=start_y, @@ -304,13 +308,13 @@ def add_self_loop(self, node_id: str): # For a cubic Bezier, the tangent at the end point is from the last control point to the end point tangent_x = end_x - control2_x tangent_y = end_y - control2_y - + # Normalize the tangent vector - tangent_length = sqrt(tangent_x**2 + tangent_y**2) + tangent_length = sqrt(tangent_x ** 2 + tangent_y ** 2) if tangent_length > 0: tangent_x /= tangent_length tangent_y /= tangent_length - + # Add just the arrowhead (NormalHead) at the end point, oriented along the tangent arrowhead = NormalHead( size=NetworkVisualiser.ARROWHEAD_SIZE, @@ -318,7 +322,7 @@ def add_self_loop(self, node_id: str): fill_color=NetworkVisualiser.EDGE_COLOUR, line_width=NetworkVisualiser.EDGE_WIDTH ) - + # Create a standalone arrowhead at the end point # Strategy: use a very short Arrow that's essentially just the head arrow = Arrow( @@ -335,32 +339,32 @@ def add_self_loop(self, node_id: str): # Add edge label - positioned above the arc label_x = x - label_y = y + height/2 + arc_height * 0.6 + label_y = y + height / 2 + arc_height * 0.6 return label_x, label_y def _add_edges(self): edge_labels = nx.get_edge_attributes(self.graph.networkx, "label") - + # Create data sources for edges and edge labels edge_text_data = dict(x=[], y=[], text=[]) - + for edge in self.graph.networkx.edges(): # Edge labels are always defined and cannot be lists edge_label = edge_labels[edge] edge_label = self._cap_name(edge_label) edge_text_data['text'].append(edge_label) - + if edge[0] == edge[1]: # Self-loop handled separately label_x, label_y = self.add_self_loop(edge[0]) edge_text_data['x'].append(label_x) edge_text_data['y'].append(label_y) - + else: # Calculate edge points at node borders start_x, start_y, end_x, end_y = self._get_edge_points(edge[0], edge[1]) - + # Add arrow between the calculated points arrow = Arrow( end=NormalHead( @@ -375,18 +379,17 @@ def _add_edges(self): # Collect edge label data (position at midpoint) edge_text_data['x'].append((start_x + end_x) / 2) edge_text_data['y'].append((start_y + end_y) / 2) - + # Add all edge labels at once if edge_text_data['x']: edge_text_source = ColumnDataSource(edge_text_data) edge_labels_glyph = Text(x='x', y='y', text='text', - text_align='center', text_baseline='middle', - text_font_size='7pt') + text_align='center', text_baseline='middle', + text_font_size='7pt') self.plot.add_glyph(edge_text_source, edge_labels_glyph) - @staticmethod - def _cap_name(name: str) -> str: - if len(name) < Visualiser.MAX_VERTEX_NAME_LEN: + def _cap_name(self, name: str) -> str: + if len(name) < Visualiser.MAX_VERTEX_NAME_LEN or isinstance(self.graph, StateGraph): return name - return f"{name[:(Visualiser.MAX_VERTEX_NAME_LEN-3)]}..." \ No newline at end of file + return f"{name[:(Visualiser.MAX_VERTEX_NAME_LEN - 3)]}..." diff --git a/run_tests.py b/run_tests.py index 3289d173..3eb8bba0 100644 --- a/run_tests.py +++ b/run_tests.py @@ -27,8 +27,8 @@ if utest: utestrun = unittest.main(module=None, - argv=[__file__, 'discover', os.path.join(THIS_DIR, 'utest')], - exit=False) + argv=[__file__, 'discover', os.path.join(THIS_DIR, 'utest')], + exit=False) if not utestrun.result.wasSuccessful(): sys.exit(1) @@ -40,10 +40,10 @@ # Adding the robotframeworkMBT folder to the python path forces the development # version to be used instead of the one installed on your system. You will also # need to add this path to your IDE options when running from there. - exit_code :int = robot.run_cli(['--outputdir', OUTPUT_ROOT, # type: ignore - '--pythonpath', THIS_DIR] - + sys.argv[1:], exit=False) + exit_code: int = robot.run_cli(['--outputdir', OUTPUT_ROOT, # type: ignore + '--pythonpath', THIS_DIR] + + sys.argv[1:], exit=False) if utest: print(f"Also ran {utestrun.result.testsRun} unit tests") - + sys.exit(exit_code) diff --git a/utest/test_visualise_models.py b/utest/test_visualise_models.py index f4db9cda..f52e24e6 100644 --- a/utest/test_visualise_models.py +++ b/utest/test_visualise_models.py @@ -1,10 +1,12 @@ import unittest -import networkx as nx -from robotmbt.tracestate import TraceState -from robotmbt.visualise.models import * - - -class TestVisualiseModels(unittest.TestCase): +try: + from robotmbt.visualise.models import * + VISUALISE = True +except ImportError: + VISUALISE = False + +if VISUALISE: + class TestVisualiseModels(unittest.TestCase): """ Contains tests for robotmbt/visualise/models.py """ @@ -34,15 +36,15 @@ def test_create_TraceInfo(self): candidates = [] for scenario in range(3): candidates.append(ts.next_candidate()) - ts.confirm_full_scenario(candidates[-1], scenario, {}) + ts.confirm_full_scenario(candidates[-1], str(scenario), {}) ti = TraceInfo.from_trace_state(trace=ts, state=ModelSpace()) - self.assertEqual(ti.trace[0].name, 0) - self.assertEqual(ti.trace[1].name, 1) - self.assertEqual(ti.trace[2].name, 2) + self.assertEqual(ti.trace[0].name, str(0)) + self.assertEqual(ti.trace[1].name, str(1)) + self.assertEqual(ti.trace[2].name, str(2)) self.assertIsNotNone(ti.state) - # TODO: add state tests to this. + # TODO check state """ Class: ScenarioGraph @@ -56,8 +58,8 @@ def test_scenario_graph_init(self): def test_scenario_graph_ids_empty(self): sg = ScenarioGraph() si = ScenarioInfo('test') - id = sg._get_or_create_id(si) - self.assertEqual(id, 'node0') + node_id = sg._get_or_create_id(si) + self.assertEqual(node_id, 'node0') def test_scenario_graph_ids_duplicate_scenario(self): sg = ScenarioGraph() @@ -78,13 +80,13 @@ def test_scenario_graph_ids_different_scenarios(self): def test_scenario_graph_add_new_node(self): sg = ScenarioGraph() sg.ids['test'] = ScenarioInfo('test') - sg.add_node('test') + sg._add_node('test') self.assertIn('test', sg.networkx.nodes) self.assertEqual(sg.networkx.nodes['test']['label'], 'test') def test_scenario_graph_add_existing_node(self): sg = ScenarioGraph() - sg.add_node('start') + sg._add_node('start') self.assertIn('start', sg.networkx.nodes) self.assertEqual(len(sg.networkx.nodes), 1) @@ -93,25 +95,25 @@ def test_scenario_graph_update_visualisation_nodes(self): candidates = [] for scenario in range(3): candidates.append(ts.next_candidate()) - ts.confirm_full_scenario(candidates[-1], scenario, {}) + ts.confirm_full_scenario(candidates[-1], str(scenario), {}) ti = TraceInfo.from_trace_state(ts, ModelSpace()) sg = ScenarioGraph() sg.update_visualisation(ti) self.assertIn('node0', sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes['node0']['label'], 0) + self.assertEqual(sg.networkx.nodes['node0']['label'], str(0)) self.assertIn('node1', sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes['node1']['label'], 1) + self.assertEqual(sg.networkx.nodes['node1']['label'], str(1)) self.assertIn('node2', sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes['node2']['label'], 2) + self.assertEqual(sg.networkx.nodes['node2']['label'], str(2)) def test_scenario_graph_update_visualisation_edges(self): ts = TraceState(3) candidates = [] for scenario in range(3): candidates.append(ts.next_candidate()) - ts.confirm_full_scenario(candidates[-1], scenario, {}) - ti = TraceInfo.from_trace_state(ts, ModelSpace()) + ts.confirm_full_scenario(candidates[-1], str(scenario), {}) + ti = TraceInfo.from_trace_state(trace=ts, state=ModelSpace()) sg = ScenarioGraph() sg.update_visualisation(ti) @@ -137,35 +139,35 @@ def test_scenario_graph_update_visualisation_single_node(self): def test_scenario_graph_set_starting_node_new_node(self): sg = ScenarioGraph() si = ScenarioInfo('test') - sg.set_starting_node(si) - id = sg._get_or_create_id(si) + sg._set_starting_node(si) + node_id = sg._get_or_create_id(si) # node - self.assertIn(id, sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes[id]['label'], 'test') + self.assertIn(node_id, sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes[node_id]['label'], 'test') # edge - self.assertIn(('start', id), sg.networkx.edges) + self.assertIn(('start', node_id), sg.networkx.edges) edge_labels = nx.get_edge_attributes(sg.networkx, "label") - self.assertEqual(edge_labels[('start', id)], '') + self.assertEqual(edge_labels[('start', node_id)], '') def test_scenario_graph_set_starting_node_existing_node(self): sg = ScenarioGraph() si = ScenarioInfo('test') - id = sg._get_or_create_id(si) - sg.add_node(id) - self.assertIn(id, sg.networkx.nodes) + node_id = sg._get_or_create_id(si) + sg._add_node(node_id) + self.assertIn(node_id, sg.networkx.nodes) - sg.set_starting_node(si) - self.assertIn(('start', id), sg.networkx.edges) + sg._set_starting_node(si) + self.assertIn(('start', node_id), sg.networkx.edges) edge_labels = nx.get_edge_attributes(sg.networkx, "label") - self.assertEqual(edge_labels[('start', id)], '') + self.assertEqual(edge_labels[('start', node_id)], '') def test_scenario_graph_set_end_node(self): sg = ScenarioGraph() si = ScenarioInfo('test') - id = sg._get_or_create_id(si) - sg.set_ending_node(si) - self.assertEqual(sg.end_node, id) + node_id = sg._get_or_create_id(si) + sg._set_ending_node(si) + self.assertEqual(sg.end_node, node_id) if __name__ == '__main__': From 1ff8ed75d8ed70a5a2a5cb76635d7d3849f02876 Mon Sep 17 00:00:00 2001 From: Jonathan <61787386+JWillegers@users.noreply.github.com> Date: Fri, 21 Nov 2025 11:41:25 +0100 Subject: [PATCH 017/131] Restructure (#22) * Seperate network_visualiser from visualiser * - renamed pos to graph_layout - moved graph_layout from models to network_visualiser * refactor file name to not contain underscore * taking the graphs classes out of models.py * split unit tests * added test for scenariograph.set_final_trace * add arguments to atests * correct orientation for bfs --- atest/resources/helpers/modelgenerator.py | 9 +- atest/resources/visualisation.resource | 2 +- .../01__then_to_given_linking.robot | 2 +- .../02__swapped_then_to_given_linking.robot | 2 +- ...single_when_to_given_step_refinement.robot | 2 +- .../05__composed_scenario.robot | 2 +- .../01__single_repetition.robot | 2 +- .../04__multi_repetition.robot | 2 +- .../05__paired_repetition.robot | 2 +- .../06__repetition_twin_with_tail.robot | 2 +- .../07__repetition_with_refinement.robot | 2 +- robotmbt/suiteprocessors.py | 9 +- robotmbt/visualise/graphs/__init__.py | 0 robotmbt/visualise/graphs/abstractgraph.py | 32 ++ robotmbt/visualise/graphs/scenariograph.py | 87 ++++ robotmbt/visualise/graphs/stategraph.py | 84 ++++ robotmbt/visualise/models.py | 257 +----------- robotmbt/visualise/networkvisualiser.py | 387 ++++++++++++++++++ robotmbt/visualise/visualiser.py | 376 +---------------- utest/test_visualise_models.py | 179 ++------ utest/test_visualise_scenariograph.py | 148 +++++++ utest/test_visualise_stategraph.py | 19 + 22 files changed, 822 insertions(+), 785 deletions(-) create mode 100644 robotmbt/visualise/graphs/__init__.py create mode 100644 robotmbt/visualise/graphs/abstractgraph.py create mode 100644 robotmbt/visualise/graphs/scenariograph.py create mode 100644 robotmbt/visualise/graphs/stategraph.py create mode 100644 robotmbt/visualise/networkvisualiser.py create mode 100644 utest/test_visualise_scenariograph.py create mode 100644 utest/test_visualise_stategraph.py diff --git a/atest/resources/helpers/modelgenerator.py b/atest/resources/helpers/modelgenerator.py index 18b8a1b8..58339df8 100644 --- a/atest/resources/helpers/modelgenerator.py +++ b/atest/resources/helpers/modelgenerator.py @@ -3,14 +3,16 @@ from robot.api.deco import keyword # type:ignore from robotmbt.modelspace import ModelSpace -from robotmbt.visualise.models import TraceInfo, ScenarioInfo, ScenarioGraph +from robotmbt.visualise.models import TraceInfo, ScenarioInfo +from robotmbt.visualise.graphs.scenariograph import ScenarioGraph class ModelGenerator: @keyword(name="Generate Trace Information") # type: ignore def generate_trace_info(self, scenario_count: int) -> TraceInfo: """Generates a list of unique random scenarios.""" - scenarios: list[ScenarioInfo] = ModelGenerator.generate_scenario_names(scenario_count) + scenarios: list[ScenarioInfo] = ModelGenerator.generate_scenario_names( + scenario_count) return TraceInfo(scenarios, ModelSpace()) @@ -28,7 +30,8 @@ def ensure_scenario_follows(self, trace_info: TraceInfo, scen1: str, scen2: str) scen2_info: ScenarioInfo | None = trace_info.get_scenario(scen2) if scen1_info is None or scen2_info is None: - raise Exception(f"Ensure Scenario Follows for scenarios that did not exist! scen1={scen1}, scen2={scen2}") + raise Exception( + f"Ensure Scenario Follows for scenarios that did not exist! scen1={scen1}, scen2={scen2}") # both scenarios apparently exist, now make sure that scenario2 follows after some appearance of scenario 1: scen1_index: int = trace_info.trace.index(scen1_info) diff --git a/atest/resources/visualisation.resource b/atest/resources/visualisation.resource index 0dc9af9e..2e883d26 100644 --- a/atest/resources/visualisation.resource +++ b/atest/resources/visualisation.resource @@ -40,7 +40,7 @@ In Test Suite ${suite}, scenario ${s2} can be reached from ${s1} Set Suite Variable ${trace_info} Graph ${graph} of type ${graph_type} is generated based on Test Suite ${suite} - [Documentation] Generates the graph + [Documentation] Generates the graph ... :IN: graph = ${graph}, graph_type = ${graph_type}, suite = ${suite} ... :OUT: None Variable Should Exist ${trace_info} diff --git a/atest/robotMBT tests/04__scenario_linking/01__then_to_given_linking.robot b/atest/robotMBT tests/04__scenario_linking/01__then_to_given_linking.robot index b0ecf40d..13075c71 100644 --- a/atest/robotMBT tests/04__scenario_linking/01__then_to_given_linking.robot +++ b/atest/robotMBT tests/04__scenario_linking/01__then_to_given_linking.robot @@ -4,7 +4,7 @@ Documentation This test suite demonstrates direct one-on-one linking of scen ... the trailing scenario. ... ... Note that this test suite would also pass when run in Robot Framework without additional processing. -Suite Setup Treat this test suite Model-based +Suite Setup Treat this test suite Model-based graph=scenario Resource ../../resources/birthday_cards_flat.resource Library robotmbt diff --git a/atest/robotMBT tests/04__scenario_linking/02__swapped_then_to_given_linking.robot b/atest/robotMBT tests/04__scenario_linking/02__swapped_then_to_given_linking.robot index c8619356..e587223e 100644 --- a/atest/robotMBT tests/04__scenario_linking/02__swapped_then_to_given_linking.robot +++ b/atest/robotMBT tests/04__scenario_linking/02__swapped_then_to_given_linking.robot @@ -5,7 +5,7 @@ Documentation This test suite demonstrates direct one-on-one linking of scen ... reverse order, this test suite would fail in a regular Robot Framework ... test run. It passes when the model figures out the dependency between ... the test cases and swaps their order. -Suite Setup Treat this test suite Model-based +Suite Setup Treat this test suite Model-based graph=scenario Resource ../../resources/birthday_cards_flat.resource Library robotmbt diff --git a/atest/robotMBT tests/04__scenario_linking/04__single_when_to_given_step_refinement.robot b/atest/robotMBT tests/04__scenario_linking/04__single_when_to_given_step_refinement.robot index 2fc8c87e..d2cef617 100644 --- a/atest/robotMBT tests/04__scenario_linking/04__single_when_to_given_step_refinement.robot +++ b/atest/robotMBT tests/04__scenario_linking/04__single_when_to_given_step_refinement.robot @@ -7,7 +7,7 @@ Documentation This suite demonstrates step refinement in its simplest form. ... the _WHEN_ step. For this to be successful, the _WHEN_ step from the ... high-level scenario must match the _GIVEN_ step of the refinement ... scenario. -Suite Setup Treat this test suite Model-based +Suite Setup Treat this test suite Model-based graph=scenario Resource ../../resources/birthday_cards_composed.resource Library robotmbt diff --git a/atest/robotMBT tests/04__scenario_linking/05__composed_scenario.robot b/atest/robotMBT tests/04__scenario_linking/05__composed_scenario.robot index a079c13b..2ed8d4a0 100644 --- a/atest/robotMBT tests/04__scenario_linking/05__composed_scenario.robot +++ b/atest/robotMBT tests/04__scenario_linking/05__composed_scenario.robot @@ -3,7 +3,7 @@ Documentation This is a composed scenario where there is a sequence of three ... scenarios on the highest level. The middle of these three scenarios ... has multiple steps that require refinement. One of those refinement ... steps needs refinement of it own, yielding double-layerd refinement. -Suite Setup Treat this test suite Model-based +Suite Setup Treat this test suite Model-based graph=scenario Resource ../../resources/birthday_cards_composed.resource Library robotmbt diff --git a/atest/robotMBT tests/05__repeating_scenarios/01__single_repetition.robot b/atest/robotMBT tests/05__repeating_scenarios/01__single_repetition.robot index 1f41fd95..401cc7ce 100644 --- a/atest/robotMBT tests/05__repeating_scenarios/01__single_repetition.robot +++ b/atest/robotMBT tests/05__repeating_scenarios/01__single_repetition.robot @@ -2,7 +2,7 @@ Documentation This suite consists of 3 scenarios. After inserting the leading ... scenario, the trailing scenario cannot be reached, unless the middle ... scenario is inserted twice. -Suite Setup Treat this test suite Model-based +Suite Setup Treat this test suite Model-based graph=scenario Resource ../../resources/birthday_cards_flat.resource Library robotmbt diff --git a/atest/robotMBT tests/05__repeating_scenarios/04__multi_repetition.robot b/atest/robotMBT tests/05__repeating_scenarios/04__multi_repetition.robot index a08567a8..88c8629c 100644 --- a/atest/robotMBT tests/05__repeating_scenarios/04__multi_repetition.robot +++ b/atest/robotMBT tests/05__repeating_scenarios/04__multi_repetition.robot @@ -1,7 +1,7 @@ *** Settings *** Documentation This suite requires a larger amount of repetitions to reach the final ... scenario. -Suite Setup Treat this test suite Model-based +Suite Setup Treat this test suite Model-based graph=scenario Resource ../../resources/birthday_cards_flat.resource Library robotmbt diff --git a/atest/robotMBT tests/05__repeating_scenarios/05__paired_repetition.robot b/atest/robotMBT tests/05__repeating_scenarios/05__paired_repetition.robot index 39f6c65c..5d9ac59d 100644 --- a/atest/robotMBT tests/05__repeating_scenarios/05__paired_repetition.robot +++ b/atest/robotMBT tests/05__repeating_scenarios/05__paired_repetition.robot @@ -2,7 +2,7 @@ Documentation This suite cannot be completed by repeating a single scenario. Two ... scenarios are linked in such a way that they must be repeated in ... pairs to reach the final scenario. -Suite Setup Treat this test suite Model-based +Suite Setup Treat this test suite Model-based graph=scenario Resource ../../resources/birthday_cards_flat.resource Library robotmbt diff --git a/atest/robotMBT tests/05__repeating_scenarios/06__repetition_twin_with_tail.robot b/atest/robotMBT tests/05__repeating_scenarios/06__repetition_twin_with_tail.robot index f9ec9211..51b479e2 100644 --- a/atest/robotMBT tests/05__repeating_scenarios/06__repetition_twin_with_tail.robot +++ b/atest/robotMBT tests/05__repeating_scenarios/06__repetition_twin_with_tail.robot @@ -3,7 +3,7 @@ Documentation This suite includes multiplicity on multiple ends. Most notabl ... - Two scenarios that are equaly valid to include in the repetition part ... - Two scenarios at the tail end that cannot be reached without the ... \ \ repetitions, but each has a different style entry condition. -Suite Setup Treat this test suite Model-based +Suite Setup Treat this test suite Model-based graph=scenario Resource ../../resources/birthday_cards_flat.resource Library robotmbt diff --git a/atest/robotMBT tests/05__repeating_scenarios/07__repetition_with_refinement.robot b/atest/robotMBT tests/05__repeating_scenarios/07__repetition_with_refinement.robot index 54169443..d7e552e6 100644 --- a/atest/robotMBT tests/05__repeating_scenarios/07__repetition_with_refinement.robot +++ b/atest/robotMBT tests/05__repeating_scenarios/07__repetition_with_refinement.robot @@ -2,7 +2,7 @@ Documentation This suite is similar to the Single repetition scenario, but ... with the difference that this time the repeated scenario has ... a step that requires refinement. -Suite Setup Treat this test suite Model-based +Suite Setup Treat this test suite Model-based graph=scenario Resource ../../resources/birthday_cards_composed.resource Library robotmbt diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index e3c9ecac..c41a4a5a 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -120,14 +120,16 @@ def process_test_suite(self, in_suite: Suite, *, seed: any = 'new', graph: str = self._try_to_reach_full_coverage(allow_duplicate_scenarios=True) if not self.tracestate.coverage_reached(): if self.visualiser is not None: - logger.write(self.visualiser.generate_visualisation(), html=True) + logger.write( + self.visualiser.generate_visualisation(), html=True) raise Exception("Unable to compose a consistent suite") self.out_suite.scenarios = self.tracestate.get_trace() self._report_tracestate_wrapup() if self.visualiser is not None: - self.visualiser.set_final_trace(TraceInfo.from_trace_state(self.tracestate, self.active_model)) + self.visualiser.set_final_trace( + TraceInfo.from_trace_state(self.tracestate, self.active_model)) logger.write(self.visualiser.generate_visualisation(), html=True) return self.out_suite @@ -166,7 +168,8 @@ def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool): logger.debug( f"last state:\n{self.active_model.get_status_text()}") if self.visualiser is not None: - self.visualiser.update_visualisation(TraceInfo.from_trace_state(self.tracestate, self.active_model)) + self.visualiser.update_visualisation( + TraceInfo.from_trace_state(self.tracestate, self.active_model)) def __last_candidate_changed_nothing(self) -> bool: if len(self.tracestate) < 2: diff --git a/robotmbt/visualise/graphs/__init__.py b/robotmbt/visualise/graphs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/robotmbt/visualise/graphs/abstractgraph.py b/robotmbt/visualise/graphs/abstractgraph.py new file mode 100644 index 00000000..a51a9443 --- /dev/null +++ b/robotmbt/visualise/graphs/abstractgraph.py @@ -0,0 +1,32 @@ +from abc import ABC, abstractmethod +from robotmbt.visualise.models import TraceInfo +import networkx as nx + + +class AbstractGraph(ABC): + @abstractmethod + def update_visualisation(self, info: TraceInfo): + """ + Update the visualisation with new trace information from another exploration step. + """ + pass + + @abstractmethod + def set_final_trace(self, info: TraceInfo): + """ + Update the graph with information on the final trace. + """ + pass + + @property + @abstractmethod + def networkx(self) -> nx.DiGraph: + """ + We use networkx to store nodes and edges. + """ + pass + + @networkx.setter + @abstractmethod + def networkx(self, value: nx.DiGraph): + pass diff --git a/robotmbt/visualise/graphs/scenariograph.py b/robotmbt/visualise/graphs/scenariograph.py new file mode 100644 index 00000000..fee43987 --- /dev/null +++ b/robotmbt/visualise/graphs/scenariograph.py @@ -0,0 +1,87 @@ +from robotmbt.visualise.graphs.abstractgraph import AbstractGraph +from robotmbt.visualise.models import TraceInfo, ScenarioInfo +import networkx as nx + + +class ScenarioGraph(AbstractGraph): + """ + The scenario graph is the most basic representation of trace exploration. + It represents scenarios as nodes, and the trace as edges. + """ + + def __init__(self): + # We use simplified IDs for nodes, and store the actual scenario info here + self.ids: dict[str, ScenarioInfo] = {} + + # The networkx graph is a directional graph + self.networkx = nx.DiGraph() + + # add the start node + self.networkx.add_node('start', label='start') + + # indicates last scenario of trace + self.end_node = 'start' + + def update_visualisation(self, info: TraceInfo): + """ + This will add nodes for all new scenarios in the provided trace, as well as edges for all pairs in the provided trace. + """ + for i in range(0, len(info.trace) - 1): + from_node = self._get_or_create_id(info.trace[i]) + to_node = self._get_or_create_id(info.trace[i + 1]) + + self._add_node(from_node) + self._add_node(to_node) + + if (from_node, to_node) not in self.networkx.edges: + self.networkx.add_edge( + from_node, to_node, label='') + + def _get_or_create_id(self, scenario: ScenarioInfo) -> str: + """ + Get the ID for a scenario that has been added before, or create and store a new one. + """ + for i in self.ids.keys(): + # TODO: decide how to deal with repeating scenarios, this merges repeated scenarios into a single scenario + if self.ids[i].src_id == scenario.src_id and scenario.src_id is not None: + return i + + new_id = f"node{len(self.ids)}" + self.ids[new_id] = scenario + return new_id + + def _add_node(self, node: str): + """ + Add node if it doesn't already exist. + """ + if node not in self.networkx.nodes: + self.networkx.add_node(node, label=self.ids[node].name) + + def _set_starting_node(self, scenario: ScenarioInfo): + """ + Update the starting node. + """ + node = self._get_or_create_id(scenario) + self._add_node(node) + self.networkx.add_edge('start', node, label='') + + def _set_ending_node(self, scenario: ScenarioInfo): + """ + Update the end node. + """ + self.end_node = self._get_or_create_id(scenario) + + def set_final_trace(self, info: TraceInfo): + """ + Update the graph with information on the final trace. + """ + self._set_starting_node(info.trace[0]) + self._set_ending_node(info.trace[-1]) + + @property + def networkx(self) -> nx.DiGraph: + return self._networkx + + @networkx.setter + def networkx(self, value: nx.DiGraph): + self._networkx = value diff --git a/robotmbt/visualise/graphs/stategraph.py b/robotmbt/visualise/graphs/stategraph.py new file mode 100644 index 00000000..d23ebcb1 --- /dev/null +++ b/robotmbt/visualise/graphs/stategraph.py @@ -0,0 +1,84 @@ +from robotmbt.visualise.graphs.abstractgraph import AbstractGraph +from robotmbt.visualise.models import TraceInfo, StateInfo +from robotmbt.modelspace import ModelSpace +import networkx as nx + + +class StateGraph(AbstractGraph): + """ + The state graph is a more advanced representation of trace exploration, allowing you to see the internal state. + It represents states as nodes, and scenarios as edges. + """ + + def __init__(self): + # We use simplified IDs for nodes, and store the actual state info here + self.ids: dict[str, StateInfo] = {} + + # The networkx graph is a directional graph + self.networkx = nx.DiGraph() + + # add the start node + self.networkx.add_node('start', label='start') + + self.prev_state = StateInfo(ModelSpace()) + self.prev_trace_len = 0 + + def update_visualisation(self, info: TraceInfo): + """ + This will add nodes the newly reached state (if we did not roll back), as well as an edge from the previous to + the current state labeled with the scenario that took it there. + """ + if len(info.trace) > 0: + scenario = info.trace[-1] + + from_node = self._get_or_create_id(self.prev_state) + if len(info.trace) == 1: + from_node = 'start' + to_node = self._get_or_create_id(info.state) + + self._add_node(from_node) + self._add_node(to_node) + + if self.prev_trace_len < len(info.trace): + if (from_node, to_node) not in self.networkx.edges: + self.networkx.add_edge( + from_node, to_node, label=scenario.name) + + self.prev_state = info.state + self.prev_trace_len = len(info.trace) + + def _get_or_create_id(self, state: StateInfo) -> str: + """ + Get the ID for a state that has been added before, or create and store a new one. + """ + for i in self.ids.keys(): + if self.ids[i] == state: + return i + + new_id = f"node{len(self.ids)}" + self.ids[new_id] = state + return new_id + + def _add_node(self, node: str): + """ + Add node if it doesn't already exist. + """ + if node not in self.networkx.nodes: + self.networkx.add_node(node, label=str(self.ids[node])) + + def set_final_trace(self, info: TraceInfo): + self._set_ending_node(info.state) + + def _set_ending_node(self, state: StateInfo): + """ + Update the end node. + """ + self.end_node = self._get_or_create_id(state) + + @property + def networkx(self) -> nx.DiGraph: + return self._networkx + + @networkx.setter + def networkx(self, value: nx.DiGraph): + self._networkx = value diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index d9c661fb..20327437 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -1,8 +1,6 @@ -from abc import ABC, abstractmethod from robotmbt.modelspace import ModelSpace from robotmbt.suitedata import Scenario from robotmbt.tracestate import TraceState -import networkx as nx class ScenarioInfo: @@ -75,8 +73,8 @@ def __repr__(self) -> str: return f"TraceInfo(trace=[{[str(t) for t in self.trace]}], state={self.state})" def contains_scenario(self, scen_name: str) -> bool: - for scen in self.trace: - if scen.name == scen_name: + for scenario in self.trace: + if scenario.name == scen_name: return True return False @@ -94,254 +92,7 @@ def get_scenario(self, scen_name: str) -> ScenarioInfo | None: def insert_trace_at(self, index: int, scen_info: ScenarioInfo): if index < 0 or index >= len(self.trace): - raise IndexError(f"InsertTraceAt received invalid index ({index}) for length of list ({len(self.trace)})") + raise IndexError( + f"InsertTraceAt received invalid index ({index}) for length of list ({len(self.trace)})") self.trace.insert(index, scen_info) - - -class AbstractGraph(ABC): - @abstractmethod - def update_visualisation(self, info: TraceInfo): - """ - Update the visualisation with new trace information from another exploration step. - """ - pass - - @abstractmethod - def set_final_trace(self, info: TraceInfo): - """ - Update the graph with information on the final trace. - """ - pass - - @abstractmethod - def calculate_pos(self): - """ - Calculate the position (x, y) for all nodes in self.networkx - """ - pass - - @property - @abstractmethod - def networkx(self) -> nx.DiGraph: - """ - We use networkx to store nodes and edges. - """ - pass - - @networkx.setter - @abstractmethod - def networkx(self, value: nx.DiGraph): - pass - - @property - @abstractmethod - def pos(self) -> dict: - """ - A dictionary with the positions of nodes. - """ - pass - - @pos.setter - @abstractmethod - def pos(self, value: dict): - pass - - -class ScenarioGraph(AbstractGraph): - """ - The scenario graph is the most basic representation of trace exploration. - It represents scenarios as nodes, and the trace as edges. - """ - - def __init__(self): - # We use simplified IDs for nodes, and store the actual scenario info here - self.ids: dict[str, ScenarioInfo] = {} - - # The networkx graph is a directional graph - self.networkx = nx.DiGraph() - - # Stores the position (x, y) of the nodes - self.pos = {} - - # add the start node - self.networkx.add_node('start', label='start') - - # indicates last scenario of trace - self.end_node = 'start' - - def update_visualisation(self, info: TraceInfo): - """ - This will add nodes for all new scenarios in the provided trace, as well as edges for all pairs in the provided trace. - """ - for i in range(0, len(info.trace) - 1): - from_node = self._get_or_create_id(info.trace[i]) - to_node = self._get_or_create_id(info.trace[i + 1]) - - self._add_node(from_node) - self._add_node(to_node) - - if (from_node, to_node) not in self.networkx.edges: - self.networkx.add_edge( - from_node, to_node, label='') - - def _get_or_create_id(self, scenario: ScenarioInfo) -> str: - """ - Get the ID for a scenario that has been added before, or create and store a new one. - """ - for i in self.ids.keys(): - # TODO: decide how to deal with repeating scenarios, this merges repeated scenarios into a single scenario - if self.ids[i].src_id == scenario.src_id and scenario.src_id is not None: - return i - - new_id = f"node{len(self.ids)}" - self.ids[new_id] = scenario - return new_id - - def _add_node(self, node: str): - """ - Add node if it doesn't already exist. - """ - if node not in self.networkx.nodes: - self.networkx.add_node(node, label=self.ids[node].name) - - def _set_starting_node(self, scenario: ScenarioInfo): - """ - Update the starting node. - """ - node = self._get_or_create_id(scenario) - self._add_node(node) - self.networkx.add_edge('start', node, label='') - - def _set_ending_node(self, scenario: ScenarioInfo): - """ - Update the end node. - """ - self.end_node = self._get_or_create_id(scenario) - - def set_final_trace(self, info: TraceInfo): - """ - Update the graph with information on the final trace. - """ - self._set_starting_node(info.trace[0]) - self._set_ending_node(info.trace[-1]) - - def calculate_pos(self): - try: - self.pos = nx.planar_layout(self.networkx) - except nx.NetworkXException: - # if planar layout cannot find a graph without crossing edges - self.pos = nx.arf_layout(self.networkx, seed=42) - - @property - def networkx(self) -> nx.DiGraph: - return self._networkx - - @networkx.setter - def networkx(self, value: nx.DiGraph): - self._networkx = value - - @property - def pos(self) -> dict: - return self._pos - - @pos.setter - def pos(self, value: dict): - self._pos = value - - -class StateGraph(AbstractGraph): - """ - The state graph is a more advanced representation of trace exploration, allowing you to see the internal state. - It represents states as nodes, and scenarios as edges. - """ - - def __init__(self): - # We use simplified IDs for nodes, and store the actual state info here - self.ids: dict[str, StateInfo] = {} - - # The networkx graph is a directional graph - self.networkx = nx.DiGraph() - - # Stores the position (x, y) of the nodes - self.pos = {} - - # add the start node - self.networkx.add_node('start', label='start') - - self.prev_state = StateInfo(ModelSpace()) - self.prev_trace_len = 0 - - def update_visualisation(self, info: TraceInfo): - """ - This will add nodes the newly reached state (if we did not roll back), as well as an edge from the previous to - the current state labeled with the scenario that took it there. - """ - if len(info.trace) > 0: - scenario = info.trace[-1] - - from_node = self._get_or_create_id(self.prev_state) - if len(info.trace) == 1: - from_node = 'start' - to_node = self._get_or_create_id(info.state) - - self._add_node(from_node) - self._add_node(to_node) - - if self.prev_trace_len < len(info.trace): - if (from_node, to_node) not in self.networkx.edges: - self.networkx.add_edge(from_node, to_node, label=scenario.name) - - self.prev_state = info.state - self.prev_trace_len = len(info.trace) - - def _get_or_create_id(self, state: StateInfo) -> str: - """ - Get the ID for a state that has been added before, or create and store a new one. - """ - for i in self.ids.keys(): - if self.ids[i] == state: - return i - - new_id = f"node{len(self.ids)}" - self.ids[new_id] = state - return new_id - - def _add_node(self, node: str): - """ - Add node if it doesn't already exist. - """ - if node not in self.networkx.nodes: - self.networkx.add_node(node, label=str(self.ids[node])) - - def set_final_trace(self, info: TraceInfo): - self._set_ending_node(info.state) - - def _set_ending_node(self, state: StateInfo): - """ - Update the end node. - """ - self.end_node = self._get_or_create_id(state) - - def calculate_pos(self): - try: - self.pos = nx.planar_layout(self.networkx) - except nx.NetworkXException: - # if planar layout cannot find a graph without crossing edges - self.pos = nx.arf_layout(self.networkx, seed=42) - - @property - def networkx(self) -> nx.DiGraph: - return self._networkx - - @networkx.setter - def networkx(self, value: nx.DiGraph): - self._networkx = value - - @property - def pos(self) -> dict: - return self._pos - - @pos.setter - def pos(self, value: dict): - self._pos = value diff --git a/robotmbt/visualise/networkvisualiser.py b/robotmbt/visualise/networkvisualiser.py new file mode 100644 index 00000000..2fc9acba --- /dev/null +++ b/robotmbt/visualise/networkvisualiser.py @@ -0,0 +1,387 @@ +from robotmbt.visualise.graphs.abstractgraph import AbstractGraph +from robotmbt.visualise.graphs.stategraph import StateGraph +from bokeh.palettes import Spectral4 +from bokeh.models import ( + Plot, Range1d, Circle, Rect, + Arrow, NormalHead, + Bezier, ColumnDataSource, ResetTool, + SaveTool, WheelZoomTool, PanTool, Text +) +from bokeh.embed import file_html +from bokeh.resources import CDN +from math import sqrt +import networkx as nx + + +class NetworkVisualiser: + """ + Generate plot with Bokeh + """ + + ARROWHEAD_SIZE: int = 6 # Consistent arrowhead size + EDGE_WIDTH: float = 2.0 + EDGE_ALPHA: float = 0.7 + EDGE_COLOUR: str | tuple[int, int, int] = ( + 12, 12, 12) # 'visual studio black' + GRAPH_PADDING_PERC: int = 15 # % + # in px, needs to be equal for height and width otherwise calculations are wrong + GRAPH_SIZE_PX: int = 600 + MAX_VERTEX_NAME_LEN: int = 20 # no. of characters + + def __init__(self, graph: AbstractGraph): + self.plot = None + self.graph = graph + self.node_props = {} # Store node properties for arrow calculations + self.graph_layout = {} + + # graph customisation options + self.node_radius = 1.0 + self.char_width = 0.1 + self.char_height = 0.1 + self.padding = 0.1 + + def generate_html(self) -> str: + """ + Generate html file from networkx graph via Bokeh + """ + self._calculate_graph_layout() + self._initialise_plot() + self._add_nodes_with_labels() + self._add_edges() + return file_html(self.plot, CDN, "graph") + + def _initialise_plot(self): + """ + Define plot with width, height, x_range, y_range and enable tools. + x_range and y_range are padded. Plot needs to be a square + """ + padding: float = self.GRAPH_PADDING_PERC / 100 + + x_range, y_range = zip(*self.graph_layout.values()) + x_min = min(x_range) - padding * (max(x_range) - min(x_range)) + x_max = max(x_range) + padding * (max(x_range) - min(x_range)) + y_min = min(y_range) - padding * (max(y_range) - min(y_range)) + y_max = max(y_range) + padding * (max(y_range) - min(y_range)) + + # scale node radius based on range + nodes_range = max(x_max - x_min, y_max - y_min) + self.node_radius = nodes_range / 150 + self.char_width = nodes_range / 150 + self.char_height = nodes_range / 150 + + # create plot + x_range = Range1d(min(x_min, y_min), max(x_max, y_max)) + y_range = Range1d(min(x_min, y_min), max(x_max, y_max)) + + self.plot = Plot(width=self.GRAPH_SIZE_PX, + height=self.GRAPH_SIZE_PX, + x_range=x_range, + y_range=y_range) + + # add tools + self.plot.add_tools(ResetTool(), SaveTool(), + WheelZoomTool(), PanTool()) + + def _calculate_text_dimensions(self, text: str) -> tuple[float, float]: + """Calculate width and height needed for text based on actual text length""" + # Calculate width based on character count + text_length = len(text) + width = (text_length * self.char_width) + (2 * self.padding) + + # Reduced height for more compact rectangles + height = self.char_height + self.padding + + return width, height + + def _add_nodes_with_labels(self): + """ + Add nodes with text labels inside them + """ + node_labels = nx.get_node_attributes(self.graph.networkx, "label") + + # Create data sources for nodes and labels + circle_data = dict(x=[], y=[], radius=[], label=[]) + rect_data = dict(x=[], y=[], width=[], height=[], label=[]) + text_data = dict(x=[], y=[], text=[]) + + for node in self.graph.networkx.nodes: + # Labels are always defined and cannot be lists + label = node_labels[node] + label = self._cap_name(label) + x, y = self.graph_layout[node] + + if node == 'start': + # For start node (circle), calculate radius based on text width + text_width, text_height = self._calculate_text_dimensions( + label) + # Calculate radius from text dimensions + radius = (text_width / 2.5) + + circle_data['x'].append(x) + circle_data['y'].append(y) + circle_data['radius'].append(radius) + circle_data['label'].append(label) + + # Store node properties for arrow calculations + self.node_props[node] = { + 'type': 'circle', 'x': x, 'y': y, 'radius': radius, 'label': label} + + else: + # For scenario nodes (rectangles), calculate dimensions based on text + text_width, text_height = self._calculate_text_dimensions( + label) + + rect_data['x'].append(x) + rect_data['y'].append(y) + rect_data['width'].append(text_width) + rect_data['height'].append(text_height) + rect_data['label'].append(label) + + # Store node properties for arrow calculations + self.node_props[node] = {'type': 'rect', 'x': x, 'y': y, 'width': text_width, 'height': text_height, + 'label': label} + + # Add text for all nodes + text_data['x'].append(x) + text_data['y'].append(y) + text_data['text'].append(label) + + # Add circles for start node + if circle_data['x']: + circle_source = ColumnDataSource(circle_data) + circles = Circle(x='x', y='y', radius='radius', + fill_color=Spectral4[0]) + self.plot.add_glyph(circle_source, circles) + + # Add rectangles for scenario nodes + if rect_data['x']: + rect_source = ColumnDataSource(rect_data) + rectangles = Rect(x='x', y='y', width='width', height='height', + fill_color=Spectral4[0]) + self.plot.add_glyph(rect_source, rectangles) + + # Add text labels for all nodes + text_source = ColumnDataSource(text_data) + text_labels = Text(x='x', y='y', text='text', + text_align='center', text_baseline='middle', + text_color='white', text_font_size='9pt') + self.plot.add_glyph(text_source, text_labels) + + def _get_edge_points(self, start_node, end_node): + """Calculate edge start and end points at node borders""" + start_props = self.node_props.get(start_node) + end_props = self.node_props.get(end_node) + + # Node properties should always exist + if not start_props or not end_props: + raise ValueError( + f"Node properties not found for nodes: {start_node}, {end_node}") + + # Calculate direction vector + dx = end_props['x'] - start_props['x'] + dy = end_props['y'] - start_props['y'] + distance = sqrt(dx * dx + dy * dy) + + # Self-loops are handled separately, distance should never be 0 + if distance == 0: + raise ValueError( + "Distance between different nodes should not be zero") + + # Normalize direction vector + dx /= distance + dy /= distance + + # Calculate start point at border + if start_props['type'] == 'circle': + start_x = start_props['x'] + dx * start_props['radius'] + start_y = start_props['y'] + dy * start_props['radius'] + else: + # Find where the line intersects the rectangle border + rect_width = start_props['width'] + rect_height = start_props['height'] + + # Calculate scaling factors for x and y directions + scale_x = rect_width / (2 * abs(dx)) if dx != 0 else float('inf') + scale_y = rect_height / (2 * abs(dy)) if dy != 0 else float('inf') + + # Use the smaller scale to ensure we hit the border + scale = min(scale_x, scale_y) + + start_x = start_props['x'] + dx * scale + start_y = start_props['y'] + dy * scale + + # Calculate end point at border (reverse direction) + # End nodes should never be circles for regular edges + if end_props['type'] == 'circle': + raise ValueError( + f"End node should not be a circle for regular edges: {end_node}") + else: + rect_width = end_props['width'] + rect_height = end_props['height'] + + # Calculate scaling factors for x and y directions (reverse) + scale_x = rect_width / (2 * abs(dx)) if dx != 0 else float('inf') + scale_y = rect_height / (2 * abs(dy)) if dy != 0 else float('inf') + + # Use the smaller scale to ensure we hit the border + scale = min(scale_x, scale_y) + + end_x = end_props['x'] - dx * scale + end_y = end_props['y'] - dy * scale + + return start_x, start_y, end_x, end_y + + def add_self_loop(self, node_id: str): + """ + Circular arc that starts and ends at the top side of the rectangle + Start at 1/4 width, end at 3/4 width, with a circular arc above + The arc itself ends with the arrowhead pointing into the rectangle + """ + # Get node properties directly by node ID + node_props = self.node_props.get(node_id) + + # Node properties should always exist + if node_props is None: + raise ValueError(f"Node properties not found for node: {node_id}") + + # Self-loops should only be for rectangle nodes (scenarios) + if node_props['type'] != 'rect': + raise ValueError( + f"Self-loops should only be for rectangle nodes, got: {node_props['type']}") + + x, y = node_props['x'], node_props['y'] + width = node_props['width'] + height = node_props['height'] + + # Start: 1/4 width from left, top side + start_x = x - width / 4 + start_y = y + height / 2 + + # End: 3/4 width from left, top side + end_x = x + width / 4 + end_y = y + height / 2 + + # Arc height above the rectangle + arc_height = width * 0.4 + + # Control points for a circular arc above + control1_x = x - width / 8 + control1_y = y + height / 2 + arc_height + + control2_x = x + width / 8 + control2_y = y + height / 2 + arc_height + + # Create the Bezier curve (the main arc) with the same thickness as straight lines + loop = Bezier( + x0=start_x, y0=start_y, + x1=end_x, y1=end_y, + cx0=control1_x, cy0=control1_y, + cx1=control2_x, cy1=control2_y, + line_color=NetworkVisualiser.EDGE_COLOUR, + line_width=NetworkVisualiser.EDGE_WIDTH, + line_alpha=NetworkVisualiser.EDGE_ALPHA, + ) + self.plot.add_glyph(loop) + + # Calculate the tangent direction at the end of the Bezier curve + # For a cubic Bezier, the tangent at the end point is from the last control point to the end point + tangent_x = end_x - control2_x + tangent_y = end_y - control2_y + + # Normalize the tangent vector + tangent_length = sqrt(tangent_x ** 2 + tangent_y ** 2) + if tangent_length > 0: + tangent_x /= tangent_length + tangent_y /= tangent_length + + # Add just the arrowhead (NormalHead) at the end point, oriented along the tangent + arrowhead = NormalHead( + size=NetworkVisualiser.ARROWHEAD_SIZE, + line_color=NetworkVisualiser.EDGE_COLOUR, + fill_color=NetworkVisualiser.EDGE_COLOUR, + line_width=NetworkVisualiser.EDGE_WIDTH + ) + + # Create a standalone arrowhead at the end point + # Strategy: use a very short Arrow that's essentially just the head + arrow = Arrow( + end=arrowhead, + x_start=end_x - tangent_x * 0.001, # Almost zero length line + y_start=end_y - tangent_y * 0.001, + x_end=end_x, + y_end=end_y, + line_color=NetworkVisualiser.EDGE_COLOUR, + line_width=NetworkVisualiser.EDGE_WIDTH, + line_alpha=NetworkVisualiser.EDGE_ALPHA + ) + self.plot.add_layout(arrow) + + # Add edge label - positioned above the arc + label_x = x + label_y = y + height / 2 + arc_height * 0.6 + + return label_x, label_y + + def _add_edges(self): + edge_labels = nx.get_edge_attributes(self.graph.networkx, "label") + + # Create data sources for edges and edge labels + edge_text_data = dict(x=[], y=[], text=[]) + + for edge in self.graph.networkx.edges(): + # Edge labels are always defined and cannot be lists + edge_label = edge_labels[edge] + edge_label = self._cap_name(edge_label) + edge_text_data['text'].append(edge_label) + + if edge[0] == edge[1]: + # Self-loop handled separately + label_x, label_y = self.add_self_loop(edge[0]) + edge_text_data['x'].append(label_x) + edge_text_data['y'].append(label_y) + + else: + # Calculate edge points at node borders + start_x, start_y, end_x, end_y = self._get_edge_points( + edge[0], edge[1]) + + # Add arrow between the calculated points + arrow = Arrow( + end=NormalHead( + size=NetworkVisualiser.ARROWHEAD_SIZE, + line_color=NetworkVisualiser.EDGE_COLOUR, + fill_color=NetworkVisualiser.EDGE_COLOUR), + x_start=start_x, y_start=start_y, + x_end=end_x, y_end=end_y + ) + self.plot.add_layout(arrow) + + # Collect edge label data (position at midpoint) + edge_text_data['x'].append((start_x + end_x) / 2) + edge_text_data['y'].append((start_y + end_y) / 2) + + # Add all edge labels at once + if edge_text_data['x']: + edge_text_source = ColumnDataSource(edge_text_data) + edge_labels_glyph = Text(x='x', y='y', text='text', + text_align='center', text_baseline='middle', + text_font_size='7pt') + self.plot.add_glyph(edge_text_source, edge_labels_glyph) + + def _cap_name(self, name: str) -> str: + if len(name) < self.MAX_VERTEX_NAME_LEN or isinstance(self.graph, StateGraph): + return name + + return f"{name[:(self.MAX_VERTEX_NAME_LEN - 3)]}..." + + def _calculate_graph_layout(self): + try: + self.graph_layout = nx.bfs_layout( + self.graph.networkx, 'start', align='horizontal') + # horizontal mirror + for node in self.graph_layout: + self.graph_layout[node] = (self.graph_layout[node][0], + -1 * self.graph_layout[node][1]) + except nx.NetworkXException: + # if planar layout cannot find a graph without crossing edges + self.graph_layout = nx.arf_layout(self.graph.networkx, seed=42) diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py index 5e81eec1..2bd69cea 100644 --- a/robotmbt/visualise/visualiser.py +++ b/robotmbt/visualise/visualiser.py @@ -1,16 +1,9 @@ -from robotmbt.visualise.models import AbstractGraph, ScenarioGraph, StateGraph, TraceInfo -from bokeh.palettes import Spectral4 -from bokeh.models import ( - Plot, Range1d, Circle, Rect, - Arrow, NormalHead, - Bezier, ColumnDataSource, ResetTool, - SaveTool, WheelZoomTool, PanTool, Text -) -from bokeh.embed import file_html -from bokeh.resources import CDN -from math import sqrt +from robotmbt.visualise.networkvisualiser import NetworkVisualiser +from robotmbt.visualise.graphs.abstractgraph import AbstractGraph +from robotmbt.visualise.graphs.scenariograph import ScenarioGraph +from robotmbt.visualise.graphs.stategraph import StateGraph +from robotmbt.visualise.models import TraceInfo import html -import networkx as nx class Visualiser: @@ -19,14 +12,12 @@ class Visualiser: a simple interface through which the graph can be updated, and retrieved in HTML format. """ - GRAPH_SIZE_PX: int = 600 # in px, needs to be equal for height and width otherwise calculations are wrong - GRAPH_PADDING_PERC: int = 15 # % - MAX_VERTEX_NAME_LEN: int = 20 # no. of characters # glue method to let us construct Visualiser objects in Robot tests. @classmethod def construct(cls, graph_type: str): - return cls(graph_type) # just calls __init__, but without having underscores etc. + # just calls __init__, but without having underscores etc. + return cls(graph_type) def __init__(self, graph_type: str): if graph_type == 'scenario': @@ -43,353 +34,8 @@ def set_final_trace(self, info: TraceInfo): self.graph.set_final_trace(info) def generate_visualisation(self) -> str: - self.graph.calculate_pos() html_bokeh = NetworkVisualiser(self.graph).generate_html() - return f"" - - -class NetworkVisualiser: - """ - Generate plot with Bokeh - """ - - EDGE_WIDTH: float = 2.0 - EDGE_ALPHA: float = 0.7 - EDGE_COLOUR: str | tuple[int, int, int] = (12, 12, 12) # 'visual studio black' - ARROWHEAD_SIZE: int = 6 # Consistent arrowhead size - - def __init__(self, graph: AbstractGraph): - self.plot = None - self.graph = graph - self.node_props = {} # Store node properties for arrow calculations - - # graph customisation options - self.node_radius = 1.0 - self.char_width = 0.1 - self.char_height = 0.1 - self.padding = 0.1 - - def generate_html(self) -> str: - """ - Generate html file from networkx graph via Bokeh - """ - self._initialise_plot() - self._add_nodes_with_labels() - self._add_edges() - return file_html(self.plot, CDN, "graph") - - def _initialise_plot(self): - """ - Define plot with width, height, x_range, y_range and enable tools. - x_range and y_range are padded. Plot needs to be a square - """ - padding: float = Visualiser.GRAPH_PADDING_PERC / 100 - - x_range, y_range = zip(*self.graph.pos.values()) - x_min = min(x_range) - padding * (max(x_range) - min(x_range)) - x_max = max(x_range) + padding * (max(x_range) - min(x_range)) - y_min = min(y_range) - padding * (max(y_range) - min(y_range)) - y_max = max(y_range) + padding * (max(y_range) - min(y_range)) - - # scale node radius based on range - nodes_range = max(x_max - x_min, y_max - y_min) - self.node_radius = nodes_range / 150 - self.char_width = nodes_range / 150 - self.char_height = nodes_range / 150 - - # create plot - x_range = Range1d(min(x_min, y_min), max(x_max, y_max)) - y_range = Range1d(min(x_min, y_min), max(x_max, y_max)) - - self.plot = Plot(width=Visualiser.GRAPH_SIZE_PX, - height=Visualiser.GRAPH_SIZE_PX, - x_range=x_range, - y_range=y_range) - - # add tools - self.plot.add_tools(ResetTool(), SaveTool(), - WheelZoomTool(), PanTool()) - - def _calculate_text_dimensions(self, text: str) -> tuple[float, float]: - """Calculate width and height needed for text based on actual text length""" - # Calculate width based on character count - text_length = len(text) - width = (text_length * self.char_width) + (2 * self.padding) - - # Reduced height for more compact rectangles - height = self.char_height + self.padding - - return width, height - - def _add_nodes_with_labels(self): - """ - Add nodes with text labels inside them - """ - node_labels = nx.get_node_attributes(self.graph.networkx, "label") - - # Create data sources for nodes and labels - circle_data = dict(x=[], y=[], radius=[], label=[]) - rect_data = dict(x=[], y=[], width=[], height=[], label=[]) - text_data = dict(x=[], y=[], text=[]) - - for node in self.graph.networkx.nodes: - # Labels are always defined and cannot be lists - label = node_labels[node] - label = self._cap_name(label) - x, y = self.graph.pos[node] - - if node == 'start': - # For start node (circle), calculate radius based on text width - text_width, text_height = self._calculate_text_dimensions(label) - # Calculate radius from text dimensions - radius = (text_width / 2.5) - - circle_data['x'].append(x) - circle_data['y'].append(y) - circle_data['radius'].append(radius) - circle_data['label'].append(label) - - # Store node properties for arrow calculations - self.node_props[node] = {'type': 'circle', 'x': x, 'y': y, 'radius': radius, 'label': label} - - else: - # For scenario nodes (rectangles), calculate dimensions based on text - text_width, text_height = self._calculate_text_dimensions(label) - - rect_data['x'].append(x) - rect_data['y'].append(y) - rect_data['width'].append(text_width) - rect_data['height'].append(text_height) - rect_data['label'].append(label) - - # Store node properties for arrow calculations - self.node_props[node] = {'type': 'rect', 'x': x, 'y': y, 'width': text_width, 'height': text_height, - 'label': label} - - # Add text for all nodes - text_data['x'].append(x) - text_data['y'].append(y) - text_data['text'].append(label) - - # Add circles for start node - if circle_data['x']: - circle_source = ColumnDataSource(circle_data) - circles = Circle(x='x', y='y', radius='radius', - fill_color=Spectral4[0]) - self.plot.add_glyph(circle_source, circles) - - # Add rectangles for scenario nodes - if rect_data['x']: - rect_source = ColumnDataSource(rect_data) - rectangles = Rect(x='x', y='y', width='width', height='height', - fill_color=Spectral4[0]) - self.plot.add_glyph(rect_source, rectangles) - - # Add text labels for all nodes - text_source = ColumnDataSource(text_data) - text_labels = Text(x='x', y='y', text='text', - text_align='center', text_baseline='middle', - text_color='white', text_font_size='9pt') - self.plot.add_glyph(text_source, text_labels) - - def _get_edge_points(self, start_node, end_node): - """Calculate edge start and end points at node borders""" - start_props = self.node_props.get(start_node) - end_props = self.node_props.get(end_node) - - # Node properties should always exist - if not start_props or not end_props: - raise ValueError(f"Node properties not found for nodes: {start_node}, {end_node}") - - # Calculate direction vector - dx = end_props['x'] - start_props['x'] - dy = end_props['y'] - start_props['y'] - distance = sqrt(dx * dx + dy * dy) - - # Self-loops are handled separately, distance should never be 0 - if distance == 0: - raise ValueError("Distance between different nodes should not be zero") - - # Normalize direction vector - dx /= distance - dy /= distance - - # Calculate start point at border - if start_props['type'] == 'circle': - start_x = start_props['x'] + dx * start_props['radius'] - start_y = start_props['y'] + dy * start_props['radius'] - else: - # Find where the line intersects the rectangle border - rect_width = start_props['width'] - rect_height = start_props['height'] - - # Calculate scaling factors for x and y directions - scale_x = rect_width / (2 * abs(dx)) if dx != 0 else float('inf') - scale_y = rect_height / (2 * abs(dy)) if dy != 0 else float('inf') - - # Use the smaller scale to ensure we hit the border - scale = min(scale_x, scale_y) - - start_x = start_props['x'] + dx * scale - start_y = start_props['y'] + dy * scale - - # Calculate end point at border (reverse direction) - # End nodes should never be circles for regular edges - if end_props['type'] == 'circle': - raise ValueError(f"End node should not be a circle for regular edges: {end_node}") - else: - rect_width = end_props['width'] - rect_height = end_props['height'] - - # Calculate scaling factors for x and y directions (reverse) - scale_x = rect_width / (2 * abs(dx)) if dx != 0 else float('inf') - scale_y = rect_height / (2 * abs(dy)) if dy != 0 else float('inf') - - # Use the smaller scale to ensure we hit the border - scale = min(scale_x, scale_y) - - end_x = end_props['x'] - dx * scale - end_y = end_props['y'] - dy * scale - - return start_x, start_y, end_x, end_y - - def add_self_loop(self, node_id: str): - """ - Circular arc that starts and ends at the top side of the rectangle - Start at 1/4 width, end at 3/4 width, with a circular arc above - The arc itself ends with the arrowhead pointing into the rectangle - """ - # Get node properties directly by node ID - node_props = self.node_props.get(node_id) - - # Node properties should always exist - if node_props is None: - raise ValueError(f"Node properties not found for node: {node_id}") - - # Self-loops should only be for rectangle nodes (scenarios) - if node_props['type'] != 'rect': - raise ValueError(f"Self-loops should only be for rectangle nodes, got: {node_props['type']}") - - x, y = node_props['x'], node_props['y'] - width = node_props['width'] - height = node_props['height'] - - # Start: 1/4 width from left, top side - start_x = x - width / 4 - start_y = y + height / 2 - - # End: 3/4 width from left, top side - end_x = x + width / 4 - end_y = y + height / 2 - - # Arc height above the rectangle - arc_height = width * 0.4 - - # Control points for a circular arc above - control1_x = x - width / 8 - control1_y = y + height / 2 + arc_height - - control2_x = x + width / 8 - control2_y = y + height / 2 + arc_height - - # Create the Bezier curve (the main arc) with the same thickness as straight lines - loop = Bezier( - x0=start_x, y0=start_y, - x1=end_x, y1=end_y, - cx0=control1_x, cy0=control1_y, - cx1=control2_x, cy1=control2_y, - line_color=NetworkVisualiser.EDGE_COLOUR, - line_width=NetworkVisualiser.EDGE_WIDTH, - line_alpha=NetworkVisualiser.EDGE_ALPHA, - ) - self.plot.add_glyph(loop) - - # Calculate the tangent direction at the end of the Bezier curve - # For a cubic Bezier, the tangent at the end point is from the last control point to the end point - tangent_x = end_x - control2_x - tangent_y = end_y - control2_y - - # Normalize the tangent vector - tangent_length = sqrt(tangent_x ** 2 + tangent_y ** 2) - if tangent_length > 0: - tangent_x /= tangent_length - tangent_y /= tangent_length - - # Add just the arrowhead (NormalHead) at the end point, oriented along the tangent - arrowhead = NormalHead( - size=NetworkVisualiser.ARROWHEAD_SIZE, - line_color=NetworkVisualiser.EDGE_COLOUR, - fill_color=NetworkVisualiser.EDGE_COLOUR, - line_width=NetworkVisualiser.EDGE_WIDTH - ) - - # Create a standalone arrowhead at the end point - # Strategy: use a very short Arrow that's essentially just the head - arrow = Arrow( - end=arrowhead, - x_start=end_x - tangent_x * 0.001, # Almost zero length line - y_start=end_y - tangent_y * 0.001, - x_end=end_x, - y_end=end_y, - line_color=NetworkVisualiser.EDGE_COLOUR, - line_width=NetworkVisualiser.EDGE_WIDTH, - line_alpha=NetworkVisualiser.EDGE_ALPHA - ) - self.plot.add_layout(arrow) - - # Add edge label - positioned above the arc - label_x = x - label_y = y + height / 2 + arc_height * 0.6 - - return label_x, label_y - - def _add_edges(self): - edge_labels = nx.get_edge_attributes(self.graph.networkx, "label") - - # Create data sources for edges and edge labels - edge_text_data = dict(x=[], y=[], text=[]) - - for edge in self.graph.networkx.edges(): - # Edge labels are always defined and cannot be lists - edge_label = edge_labels[edge] - edge_label = self._cap_name(edge_label) - edge_text_data['text'].append(edge_label) - - if edge[0] == edge[1]: - # Self-loop handled separately - label_x, label_y = self.add_self_loop(edge[0]) - edge_text_data['x'].append(label_x) - edge_text_data['y'].append(label_y) - - else: - # Calculate edge points at node borders - start_x, start_y, end_x, end_y = self._get_edge_points(edge[0], edge[1]) - - # Add arrow between the calculated points - arrow = Arrow( - end=NormalHead( - size=NetworkVisualiser.ARROWHEAD_SIZE, - line_color=NetworkVisualiser.EDGE_COLOUR, - fill_color=NetworkVisualiser.EDGE_COLOUR), - x_start=start_x, y_start=start_y, - x_end=end_x, y_end=end_y - ) - self.plot.add_layout(arrow) - - # Collect edge label data (position at midpoint) - edge_text_data['x'].append((start_x + end_x) / 2) - edge_text_data['y'].append((start_y + end_y) / 2) - - # Add all edge labels at once - if edge_text_data['x']: - edge_text_source = ColumnDataSource(edge_text_data) - edge_labels_glyph = Text(x='x', y='y', text='text', - text_align='center', text_baseline='middle', - text_font_size='7pt') - self.plot.add_glyph(edge_text_source, edge_labels_glyph) - - def _cap_name(self, name: str) -> str: - if len(name) < Visualiser.MAX_VERTEX_NAME_LEN or isinstance(self.graph, StateGraph): - return name - - return f"{name[:(Visualiser.MAX_VERTEX_NAME_LEN - 3)]}..." + return f"" diff --git a/utest/test_visualise_models.py b/utest/test_visualise_models.py index f52e24e6..7fbc59ab 100644 --- a/utest/test_visualise_models.py +++ b/utest/test_visualise_models.py @@ -6,168 +6,45 @@ VISUALISE = False if VISUALISE: - class TestVisualiseModels(unittest.TestCase): - """ - Contains tests for robotmbt/visualise/models.py - """ + class TestVisualiseModels(unittest.TestCase): + """ + Contains tests for robotmbt/visualise/models.py + """ - """ + """ Class: ScenarioInfo """ - def test_scenarioInfo_str(self): - si = ScenarioInfo('test') - self.assertEqual(si.name, 'test') - self.assertEqual(si.src_id, 'test') + def test_scenarioInfo_str(self): + si = ScenarioInfo('test') + self.assertEqual(si.name, 'test') + self.assertEqual(si.src_id, 'test') - def test_scenarioInfo_Scenario(self): - s = Scenario('test') - s.src_id = 0 - si = ScenarioInfo(s) - self.assertEqual(si.name, 'test') - self.assertEqual(si.src_id, 0) + def test_scenarioInfo_Scenario(self): + s = Scenario('test') + s.src_id = 0 + si = ScenarioInfo(s) + self.assertEqual(si.name, 'test') + self.assertEqual(si.src_id, 0) - """ + """ Class: TraceInfo """ - def test_create_TraceInfo(self): - ts = TraceState(3) - candidates = [] - for scenario in range(3): - candidates.append(ts.next_candidate()) - ts.confirm_full_scenario(candidates[-1], str(scenario), {}) - ti = TraceInfo.from_trace_state(trace=ts, state=ModelSpace()) - - self.assertEqual(ti.trace[0].name, str(0)) - self.assertEqual(ti.trace[1].name, str(1)) - self.assertEqual(ti.trace[2].name, str(2)) - - self.assertIsNotNone(ti.state) - # TODO check state - - """ - Class: ScenarioGraph - """ - - def test_scenario_graph_init(self): - sg = ScenarioGraph() - self.assertIn('start', sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes['start']['label'], 'start') - - def test_scenario_graph_ids_empty(self): - sg = ScenarioGraph() - si = ScenarioInfo('test') - node_id = sg._get_or_create_id(si) - self.assertEqual(node_id, 'node0') - - def test_scenario_graph_ids_duplicate_scenario(self): - sg = ScenarioGraph() - si = ScenarioInfo('test') - id0 = sg._get_or_create_id(si) - id1 = sg._get_or_create_id(si) - self.assertEqual(id0, id1) - - def test_scenario_graph_ids_different_scenarios(self): - sg = ScenarioGraph() - si0 = ScenarioInfo('test0') - si1 = ScenarioInfo('test1') - id0 = sg._get_or_create_id(si0) - id1 = sg._get_or_create_id(si1) - self.assertEqual(id0, 'node0') - self.assertEqual(id1, 'node1') - - def test_scenario_graph_add_new_node(self): - sg = ScenarioGraph() - sg.ids['test'] = ScenarioInfo('test') - sg._add_node('test') - self.assertIn('test', sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes['test']['label'], 'test') - - def test_scenario_graph_add_existing_node(self): - sg = ScenarioGraph() - sg._add_node('start') - self.assertIn('start', sg.networkx.nodes) - self.assertEqual(len(sg.networkx.nodes), 1) - - def test_scenario_graph_update_visualisation_nodes(self): - ts = TraceState(3) - candidates = [] - for scenario in range(3): - candidates.append(ts.next_candidate()) - ts.confirm_full_scenario(candidates[-1], str(scenario), {}) - ti = TraceInfo.from_trace_state(ts, ModelSpace()) - sg = ScenarioGraph() - sg.update_visualisation(ti) - - self.assertIn('node0', sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes['node0']['label'], str(0)) - self.assertIn('node1', sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes['node1']['label'], str(1)) - self.assertIn('node2', sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes['node2']['label'], str(2)) - - def test_scenario_graph_update_visualisation_edges(self): - ts = TraceState(3) - candidates = [] - for scenario in range(3): - candidates.append(ts.next_candidate()) - ts.confirm_full_scenario(candidates[-1], str(scenario), {}) - ti = TraceInfo.from_trace_state(trace=ts, state=ModelSpace()) - sg = ScenarioGraph() - sg.update_visualisation(ti) - - self.assertIn(('node0', 'node1'), sg.networkx.edges) - self.assertIn(('node1', 'node2'), sg.networkx.edges) - - edge_labels = nx.get_edge_attributes(sg.networkx, "label") - self.assertEqual(edge_labels[('node0', 'node1')], '') - self.assertEqual(edge_labels[('node1', 'node2')], '') - - def test_scenario_graph_update_visualisation_single_node(self): - ts = TraceState(1) - ts.confirm_full_scenario(0, 'one', {}) - self.assertEqual(ts.get_trace(), ['one']) - ti = TraceInfo.from_trace_state(ts, ModelSpace()) - sg = ScenarioGraph() - sg.update_visualisation(ti) - - # expected behaviour: no nodes nor edges are added - self.assertEqual(len(sg.networkx.nodes), 1) - self.assertEqual(len(sg.networkx.edges), 0) - - def test_scenario_graph_set_starting_node_new_node(self): - sg = ScenarioGraph() - si = ScenarioInfo('test') - sg._set_starting_node(si) - node_id = sg._get_or_create_id(si) - # node - self.assertIn(node_id, sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes[node_id]['label'], 'test') - - # edge - self.assertIn(('start', node_id), sg.networkx.edges) - edge_labels = nx.get_edge_attributes(sg.networkx, "label") - self.assertEqual(edge_labels[('start', node_id)], '') - - def test_scenario_graph_set_starting_node_existing_node(self): - sg = ScenarioGraph() - si = ScenarioInfo('test') - node_id = sg._get_or_create_id(si) - sg._add_node(node_id) - self.assertIn(node_id, sg.networkx.nodes) + def test_create_TraceInfo(self): + ts = TraceState(3) + candidates = [] + for scenario in range(3): + candidates.append(ts.next_candidate()) + ts.confirm_full_scenario(candidates[-1], str(scenario), {}) + ti = TraceInfo.from_trace_state(trace=ts, state=ModelSpace()) - sg._set_starting_node(si) - self.assertIn(('start', node_id), sg.networkx.edges) - edge_labels = nx.get_edge_attributes(sg.networkx, "label") - self.assertEqual(edge_labels[('start', node_id)], '') + self.assertEqual(ti.trace[0].name, str(0)) + self.assertEqual(ti.trace[1].name, str(1)) + self.assertEqual(ti.trace[2].name, str(2)) - def test_scenario_graph_set_end_node(self): - sg = ScenarioGraph() - si = ScenarioInfo('test') - node_id = sg._get_or_create_id(si) - sg._set_ending_node(si) - self.assertEqual(sg.end_node, node_id) + self.assertIsNotNone(ti.state) + # TODO check state if __name__ == '__main__': diff --git a/utest/test_visualise_scenariograph.py b/utest/test_visualise_scenariograph.py new file mode 100644 index 00000000..bcac393c --- /dev/null +++ b/utest/test_visualise_scenariograph.py @@ -0,0 +1,148 @@ +import unittest +import networkx as nx +from robotmbt.tracestate import TraceState +try: + from robotmbt.visualise.graphs.scenariograph import ScenarioGraph + from robotmbt.visualise.models import TraceInfo, ScenarioInfo, ModelSpace + VISUALISE = True +except ImportError: + VISUALISE = False + +if VISUALISE: + class TestVisualiseScenarioGraph(unittest.TestCase): + def test_scenario_graph_init(self): + sg = ScenarioGraph() + self.assertIn('start', sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') + + def test_scenario_graph_ids_empty(self): + sg = ScenarioGraph() + si = ScenarioInfo('test') + node_id = sg._get_or_create_id(si) + self.assertEqual(node_id, 'node0') + + def test_scenario_graph_ids_duplicate_scenario(self): + sg = ScenarioGraph() + si = ScenarioInfo('test') + id0 = sg._get_or_create_id(si) + id1 = sg._get_or_create_id(si) + self.assertEqual(id0, id1) + + def test_scenario_graph_ids_different_scenarios(self): + sg = ScenarioGraph() + si0 = ScenarioInfo('test0') + si1 = ScenarioInfo('test1') + id0 = sg._get_or_create_id(si0) + id1 = sg._get_or_create_id(si1) + self.assertEqual(id0, 'node0') + self.assertEqual(id1, 'node1') + + def test_scenario_graph_add_new_node(self): + sg = ScenarioGraph() + sg.ids['test'] = ScenarioInfo('test') + sg._add_node('test') + self.assertIn('test', sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes['test']['label'], 'test') + + def test_scenario_graph_add_existing_node(self): + sg = ScenarioGraph() + sg._add_node('start') + self.assertIn('start', sg.networkx.nodes) + self.assertEqual(len(sg.networkx.nodes), 1) + + def test_scenario_graph_update_visualisation_nodes(self): + ts = TraceState(3) + candidates = [] + for scenario in range(3): + candidates.append(ts.next_candidate()) + ts.confirm_full_scenario(candidates[-1], str(scenario), {}) + ti = TraceInfo.from_trace_state(ts, ModelSpace()) + sg = ScenarioGraph() + sg.update_visualisation(ti) + + self.assertIn('node0', sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes['node0']['label'], str(0)) + self.assertIn('node1', sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes['node1']['label'], str(1)) + self.assertIn('node2', sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes['node2']['label'], str(2)) + + def test_scenario_graph_update_visualisation_edges(self): + ts = TraceState(3) + candidates = [] + for scenario in range(3): + candidates.append(ts.next_candidate()) + ts.confirm_full_scenario(candidates[-1], str(scenario), {}) + ti = TraceInfo.from_trace_state(trace=ts, state=ModelSpace()) + sg = ScenarioGraph() + sg.update_visualisation(ti) + + self.assertIn(('node0', 'node1'), sg.networkx.edges) + self.assertIn(('node1', 'node2'), sg.networkx.edges) + + edge_labels = nx.get_edge_attributes(sg.networkx, "label") + self.assertEqual(edge_labels[('node0', 'node1')], '') + self.assertEqual(edge_labels[('node1', 'node2')], '') + + def test_scenario_graph_update_visualisation_single_node(self): + ts = TraceState(1) + ts.confirm_full_scenario(0, 'one', {}) + self.assertEqual(ts.get_trace(), ['one']) + ti = TraceInfo.from_trace_state(ts, ModelSpace()) + sg = ScenarioGraph() + sg.update_visualisation(ti) + + # expected behaviour: no nodes nor edges are added + self.assertEqual(len(sg.networkx.nodes), 1) + self.assertEqual(len(sg.networkx.edges), 0) + + def test_scenario_graph_set_starting_node_new_node(self): + sg = ScenarioGraph() + si = ScenarioInfo('test') + sg._set_starting_node(si) + node_id = sg._get_or_create_id(si) + # node + self.assertIn(node_id, sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes[node_id]['label'], 'test') + + # edge + self.assertIn(('start', node_id), sg.networkx.edges) + edge_labels = nx.get_edge_attributes(sg.networkx, "label") + self.assertEqual(edge_labels[('start', node_id)], '') + + def test_scenario_graph_set_starting_node_existing_node(self): + sg = ScenarioGraph() + si = ScenarioInfo('test') + node_id = sg._get_or_create_id(si) + sg._add_node(node_id) + self.assertIn(node_id, sg.networkx.nodes) + + sg._set_starting_node(si) + self.assertIn(('start', node_id), sg.networkx.edges) + edge_labels = nx.get_edge_attributes(sg.networkx, "label") + self.assertEqual(edge_labels[('start', node_id)], '') + + def test_scenario_graph_set_end_node(self): + sg = ScenarioGraph() + si = ScenarioInfo('test') + node_id = sg._get_or_create_id(si) + sg._set_ending_node(si) + self.assertEqual(sg.end_node, node_id) + + def test_scenario_graph_set_final_trace(self): + ts = TraceState(3) + candidates = [] + for scenario in range(3): + candidates.append(ts.next_candidate()) + ts.confirm_full_scenario(candidates[-1], str(scenario), {}) + ti = TraceInfo.from_trace_state(trace=ts, state=ModelSpace()) + sg = ScenarioGraph() + sg.update_visualisation(ti) + sg.set_final_trace(ti) + # test start node + self.assertIn(('start', 'node0'), sg.networkx.edges) + # test end node + self.assertEqual(sg.end_node, 'node2') + + if __name__ == '__main__': + unittest.main() diff --git a/utest/test_visualise_stategraph.py b/utest/test_visualise_stategraph.py new file mode 100644 index 00000000..c9d30a4e --- /dev/null +++ b/utest/test_visualise_stategraph.py @@ -0,0 +1,19 @@ +import unittest +import networkx as nx +try: + from robotmbt.visualise.graphs.stategraph import StateGraph + from robotmbt.visualise.models import * + VISUALISE = True +except ImportError: + VISUALISE = False + +if VISUALISE: + class TestVisualiseStateGraph(unittest.TestCase): + def test_state_graph_init(self): + sg = StateGraph() + self.assertIn('start', sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') + + +if __name__ == '__main__': + unittest.main() From 60f56fc259623f9c36d30aefe42504cc7236bb0f Mon Sep 17 00:00:00 2001 From: D Osinga <46380170+osingaatje@users.noreply.github.com> Date: Mon, 24 Nov 2025 17:07:23 +0100 Subject: [PATCH 018/131] Updated design files according to implementation (#23) --- .../RobotMBT-extensionplan-REDUCED.svg | 822 +++++++++++ DESIGN/Render/2025-11-24/RobotMBT-current.svg | 1307 +++++++++++++++++ .../2025-11-24/RobotMBT-extensionplan.svg | 589 ++++++++ DESIGN/VPP/RobotMBT.vpp | Bin 1179648 -> 1179648 bytes 4 files changed, 2718 insertions(+) create mode 100644 DESIGN/Render/2025-09-25/RobotMBT-extensionplan-REDUCED.svg create mode 100644 DESIGN/Render/2025-11-24/RobotMBT-current.svg create mode 100644 DESIGN/Render/2025-11-24/RobotMBT-extensionplan.svg diff --git a/DESIGN/Render/2025-09-25/RobotMBT-extensionplan-REDUCED.svg b/DESIGN/Render/2025-09-25/RobotMBT-extensionplan-REDUCED.svg new file mode 100644 index 00000000..6ff0daec --- /dev/null +++ b/DESIGN/Render/2025-09-25/RobotMBT-extensionplan-REDUCED.svg @@ -0,0 +1,822 @@ + + +-tracestate : TraceState+flatten(in_suite : Suite)+process_test_suite(in_suite : Suite, seed : str)-_try_to_reach_full_coverage(allow_duplicate_scenarios : bool)-_try_to_fit_in_scenario(index, candidate, retry_flag)-_report_tracestate_wrapup()SuiteProcessors+scenarios : dict [str,Scenario]+trace : TraceState+modelspace : ModelSpaceTraceInfo+update_visualisation(Suite, TraceInfo)Visualiser+generate_interactive(GraphInstance) : void+generate_html(GraphInstance) : strNetworkVisualiserGUIButtonGUIProgressBar+pre_process_trace(TraceState) : IGraphTraceTransfomer+nodes+edgesIGraph...+run_tests(args)RunTests+traceUpdate(Suite, TraceState, ModelSpace)TraceGathererScenarioGraphStateGraphNetworkX gui-componentssuiteprocessors.pyvisualisersvisualiser-processingmodels1. constructs2. notifies ^calls ^ uponupdate<<use>>uses < to generate GUIuses > to generatedata structures<<use>><<use>><<use>><<use>><<use>><<use>>Powered ByVisual Paradigm Community Edition diff --git a/DESIGN/Render/2025-11-24/RobotMBT-current.svg b/DESIGN/Render/2025-11-24/RobotMBT-current.svg new file mode 100644 index 00000000..f28924f5 --- /dev/null +++ b/DESIGN/Render/2025-11-24/RobotMBT-current.svg @@ -0,0 +1,1307 @@ + + ++ref_id : str+standard_attrs : list[?]+properties : dict[?,?]+values : dict[?,?]+scenario_variables : list[?]+add_prop(name : str)+del_prop(name : str)+new_scenario_scope()+end_scenario_scope()+process_expression(expr : str) : objectModelSpacethe current +ROBOT_LIBRARY_LISTENER : self+current_suite+robot_suite+processor_lib_name : str+__processor_lib+__processor_method+processor_options : dict[?,?]+__generateRobotSuite()+__init__(processor : str, processor_lib : Library|None)SuiteReplacer+EMBEDDED+POSITIONAL+VAR_POS+NAMED+FREE_NAMED+arg() : str+value() : any+modified() : bool+codestring() : strStepArgumentStepArguments<<primitive>>list+substitutions : dict [any,Constraint]+solutions : dict [any,any]+substitute(example_value, constraint)+solve() : dict [any,any]SubstitutionMap+optionset : list [str]+removed_stack : list [str]+__init__(constraint : list [str])ConstraintPlaceholder+name : str+filename : str+parent : ?+suites : list[?]+scenarios : list [Scenario]+setup : Step|None+teardown : Step|NoneSuite+name : str+parent : Scenario|None+setup : Step|None+teardown : Step|None+steps : list [Step]+src_id : ?|None+data_choices : dict[?,?]Scenario+org_step : str+org_pn_args : args+parent : Scenario+assign : ?+gherkin_kw [given,when,then]+signature+args : StepArguments+detached : bool+model_info : dict[?,?]Step-__init__(outer : Scope)-__getattr__(attr : str)-__setattr__(attr : str, value : obj)-__iter__()-__bool__()RecursiveScopeDefines -tracestate : TraceState+flatten(in_suite : Suite)+process_test_suite(in_suite : Suite, seed : str)-_try_to_reach_full_coverage(allow_duplicate_scenarios : bool)-_try_to_fit_in_scenario(index, candidate, retry_flag)SuiteProcessors-_c_pool : list [bool]-_tried : list [list[?]-_trace : list [str]-_snapshots : list[?]-_open_refinements : list[?]+model() : ?+tried() : tuple[?]+coverage_reached() : bool+coverage_drought() : int+next_candidate(retry : bool)+count(index : str)+highest_part(index : str)+find_scenarios_with_active_refinement() : list[?]TraceState+id : ?+inserted_scenario : ?+model_state : ?+drought : intTraceSnapShot1*1*1*1*suiteprocessors.pysuitedata.pymodelspace.pysteparguments.pysubstitutionmap.pysuitereplacer.pytracestate.py<<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>>"substitutions"Powered ByVisual Paradigm Community Edition diff --git a/DESIGN/Render/2025-11-24/RobotMBT-extensionplan.svg b/DESIGN/Render/2025-11-24/RobotMBT-extensionplan.svg new file mode 100644 index 00000000..788f9179 --- /dev/null +++ b/DESIGN/Render/2025-11-24/RobotMBT-extensionplan.svg @@ -0,0 +1,589 @@ + + +-tracestate : TraceState+flatten(in_suite : Suite)+process_test_suite(in_suite : Suite, seed : str)-_try_to_reach_full_coverage(allow_duplicate_scenarios : bool)-_try_to_fit_in_scenario(index, candidate, retry_flag)-_report_tracestate_wrapup()SuiteProcessors+trace : TraceState+state : StateInfoTraceInfo+update_visualisation(Suite, TraceInfo)+set_final_trace(info : TraceInfo)+generate_visualisation() : strVisualiser+generate_html(AbstractGraph) : strNetworkVisualiser+networkx : DiGraph+update_visualisation(info : TraceInfo)+set_final_trace(info : TraceInfo)AbstractGraphScenarioGraphStateGraph+name : str+src_id : str|NoneScenarioInfo+domain : str+properties : dictStateInfoBokehNetworkXsuiteprocessors.pyvisualisersmodels<<use>><<use>><<use>><<use>><<use>><<use>>1. constructs ^2. calls ^ with TraceInfouses < to generate HTML<<use>>Powered ByVisual Paradigm Community Edition diff --git a/DESIGN/VPP/RobotMBT.vpp b/DESIGN/VPP/RobotMBT.vpp index 735e8c10bce86c2129fcf0623e00692d8d732c29..3c0016343fb517aaf905ef654da22155d9a6c744 100644 GIT binary patch delta 88715 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 delta 136716 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 + """ + self.hover.tooltips = tooltips + else: + self.hover = None + # Add the nodes to the graph self._add_nodes(nodes) @@ -112,7 +129,7 @@ def _add_nodes(self, nodes: list[Node]): """ # The ColumnDataSources to store our nodes and edges in Bokeh's format node_source: ColumnDataSource = ColumnDataSource( - {'id': [], 'x': [], 'y': [], 'w': [], 'h': [], 'color': []}) + {'id': [], 'x': [], 'y': [], 'w': [], 'h': [], 'color': [], 'description': []}) node_label_source: ColumnDataSource = ColumnDataSource( {'id': [], 'x': [], 'y': [], 'label': []}) @@ -122,7 +139,10 @@ def _add_nodes(self, nodes: list[Node]): # Add the glyphs for nodes and their labels node_glyph = Rect(x='x', y='y', width='w', height='h', fill_color='color') - self.plot.add_glyph(node_source, node_glyph) + node_glyph_renderer = self.plot.add_glyph(node_source, node_glyph) + + if self.hover is not None: + self.hover.renderers = [node_glyph_renderer] node_label_glyph = Text(x='x', y='y', text='label', text_align='left', text_baseline='middle', text_font_size=f'{MAJOR_FONT_SIZE}pt', text_font=value("Courier New")) @@ -221,7 +241,8 @@ def _create_layout(self) -> tuple[list[Node], list[Edge]]: label = self.networkx.nodes[node_id]['label'] (x, y) = v.view.xy (w, h) = _calculate_dimensions(label) - ns.append(Node(node_id, label, x, -y, w, h)) + description = self.networkx.nodes[node_id]['description'] + ns.append(Node(node_id, label, x, -y, w, h, description)) es = [] for e in g.C[0].sE: @@ -250,6 +271,9 @@ def _add_features(self, suite_name: str): FullscreenTool(), ZoomInTool(factor=0.4), ZoomOutTool(factor=0.4)) self.plot.toolbar.active_scroll = wheel_zoom + if self.hover: + self.plot.add_tools(self.hover) + # Specify the default range - these values represent the aspect ratio of the actual view in the window self.plot.x_range = Range1d(-INNER_WINDOW_WIDTH / 2, INNER_WINDOW_WIDTH / 2) self.plot.y_range = Range1d(-INNER_WINDOW_HEIGHT, 0) @@ -556,6 +580,7 @@ def _add_node_to_sources(node: Node, final_trace: list[str], node_source: Column node_source.data['h'].append(node.height) node_source.data['color'].append( FINAL_TRACE_NODE_COLOR if node.node_id in final_trace else OTHER_NODE_COLOR) + node_source.data['description'].append(node.description) node_label_source.data['id'].append(node.node_id) node_label_source.data['x'].append(node.x - node.width / 2 + HORIZONTAL_PADDING_WITHIN_NODES) From 47ad27bd1f7e4691553db1d0455329ae4468af99 Mon Sep 17 00:00:00 2001 From: D Osinga <46380170+osingaatje@users.noreply.github.com> Date: Tue, 30 Sep 2025 16:08:44 +0200 Subject: [PATCH 055/131] Typehints + formatting (#4) * added type hints to modelspace.py, steparguments.py, substitutionmap.py, version.py and __init__.py, a little bit of suitedata.py and to tracestate.py. Reordered classes in tracestate.py for this purpose. * added nearly all remaining type hints, including missing "Self" types from files typed in earlier commits. Not everything in suitereplacer.py is typed because of severe ambiguity issues * reformatted according to PEP8 (using in VSCode) --------- Co-authored-by: tychodub --- robotmbt/modelspace.py | 29 ++- robotmbt/steparguments.py | 51 ++-- robotmbt/substitutionmap.py | 49 ++-- robotmbt/suitedata.py | 188 +++++++-------- robotmbt/suiteprocessors.py | 456 ++++++++++++++++++++++++++++++------ robotmbt/suitereplacer.py | 108 ++++++--- robotmbt/tracestate.py | 175 +++++++------- robotmbt/version.py | 2 +- 8 files changed, 702 insertions(+), 356 deletions(-) diff --git a/robotmbt/modelspace.py b/robotmbt/modelspace.py index b3238670..8f5c3b80 100644 --- a/robotmbt/modelspace.py +++ b/robotmbt/modelspace.py @@ -31,7 +31,7 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import copy -from typing import Any +from typing import Self from .steparguments import StepArguments @@ -45,14 +45,16 @@ def __init__(self, reference_id=None): self.ref_id: str = str(reference_id) self.std_attrs: list[str] = [] self.props: dict[str, RecursiveScope | ModelSpace] = dict() - self.values: dict[str, Any] = dict() # For using literals without having to use quotes (abc='abc') + + # For using literals without having to use quotes (abc='abc') + self.values: dict[str, any] = dict() self.scenario_vars: list[RecursiveScope] = [] self.std_attrs = dir(self) def __repr__(self): return self.ref_id if self.ref_id else super().__repr__() - def copy(self): + def copy(self) -> Self: return copy.deepcopy(self) def __eq__(self, other): @@ -81,18 +83,20 @@ def __dir__(self, recurse=True): return self.__dict__.keys() def new_scenario_scope(self): - self.scenario_vars.append(RecursiveScope(self.scenario_vars[-1] if len(self.scenario_vars) else None)) + self.scenario_vars.append(RecursiveScope( + self.scenario_vars[-1] if len(self.scenario_vars) else None)) self.props['scenario'] = self.scenario_vars[-1] def end_scenario_scope(self): - assert len(self.scenario_vars) > 0, ".end_scenario_scope() called, but there is no scenario scope open." + assert len( + self.scenario_vars) > 0, ".end_scenario_scope() called, but there is no scenario scope open." self.scenario_vars.pop() if len(self.scenario_vars): self.props['scenario'] = self.scenario_vars[-1] else: self.props.pop('scenario') - def process_expression(self, expression: str, step_args: StepArguments = StepArguments()) -> Any: + def process_expression(self, expression: str, step_args: StepArguments = StepArguments()) -> any: expr = step_args.fill_in_args(expression.strip(), as_code=True) if self._is_new_vocab_expression(expr): self.add_prop(self._vocab_term(expr)) @@ -106,7 +110,8 @@ def process_expression(self, expression: str, step_args: StepArguments = StepArg for p in self.props: exec(f"{p} = self.props['{p}']", local_locals) for v in self.values: - value = f"'{self.values[v]}'" if isinstance(self.values[v], str) else self.values[v] + value = f"'{self.values[v]}'" if isinstance( + self.values[v], str) else self.values[v] exec(f"{v} = {value}", local_locals) try: result = eval(expr, local_locals) @@ -132,7 +137,7 @@ def process_expression(self, expression: str, step_args: StepArguments = StepArg return result - def __handle_attribute_error(self, err: AttributeError): + def __handle_attribute_error(self, err): if isinstance(err.obj, str) and err.obj in self.values: # This situation occurs when using e.g. 'foo.bar' in the model before calling 'new foo'. # The NameError on foo is handled by adding its alias, which results in an AttributeError @@ -140,16 +145,18 @@ def __handle_attribute_error(self, err: AttributeError): raise ModellingError(f"{err.obj} used before definition") raise ModellingError(f"{err.name} used before assignment") - def __add_alias(self, missing_name: str, step_args: StepArguments): + def __add_alias(self, missing_name: str, step_args): if missing_name == 'scenario': raise ModellingError("Accessing scenario scope while there is no scenario active.\n" "If you intended this to be a literal, please use quotes ('scenario' or \"scenario\").") - matching_args = [arg.value for arg in step_args if arg.codestring == missing_name] + matching_args = [ + arg.value for arg in step_args if arg.codestring == missing_name] value = matching_args[0] if matching_args else missing_name if isinstance(value, str): for esc_char in "$@&=": # Prevent "Syntaxwarning: invalid escape sequence" on Robot escapes like '\$' and '\=' value = value.replace(f'\\{esc_char}', f'\\\\{esc_char}') - value = value.replace("'", r"\'") # Needed because we use single quotes in low level processing later on + # Needed because we use single quotes in low level processing later on + value = value.replace("'", r"\'") self.values[missing_name] = value @staticmethod diff --git a/robotmbt/steparguments.py b/robotmbt/steparguments.py index 4a8d96be..2bd4451d 100644 --- a/robotmbt/steparguments.py +++ b/robotmbt/steparguments.py @@ -30,17 +30,16 @@ # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -from enum import Enum, auto from keyword import iskeyword -from typing import Any import builtins +from typing import Self class StepArguments(list): def __init__(self, iterable=[]): super().__init__(item.copy() for item in iterable) - def fill_in_args(self, text: str, as_code: bool = False): + def fill_in_args(self, text: str, as_code: bool = False) -> str: result = text for arg in self: sub = arg.codestring if as_code else str(arg.value) @@ -58,40 +57,33 @@ def modified(self) -> bool: return any([arg.modified for arg in self]) -class ArgKind(Enum): - EMBEDDED = auto() - POSITIONAL = auto() - VAR_POS = auto() - NAMED = auto() - FREE_NAMED = auto() - UNKNOWN = auto() - - class StepArgument: - def __init__(self, arg_name: str, value: Any, kind: ArgKind = ArgKind.UNKNOWN, is_default: bool = False): + EMBEDDED = 'EMBEDDED' + POSITIONAL = 'POSITIONAL' + VAR_POS = 'VAR_POS' + NAMED = 'NAMED' + FREE_NAMED = 'FREE_NAMED' + + def __init__(self, arg_name: str, value: any, kind: str | None = None): self.name: str = arg_name - self.org_value: Any = value - self.kind: ArgKind = kind - self._value: Any = None + self.org_value: any = value + self.kind: str | None = kind + self._value: any = None self._codestr: str | None = None - self.value = value - # is_default indicates that the argument was not filled in from the scenario. This - # argment's value is taken from the keyword's default as provided by Robot. - self.is_default: bool = is_default + self.value: any = value @property def arg(self) -> str: return "${%s}" % self.name @property - def value(self) -> Any: + def value(self) -> any: return self._value @value.setter - def value(self, value: Any): - self._value: Any = value + def value(self, value: any): + self._value = value self._codestr = self.make_codestring(value) - self.is_default = False @property def modified(self) -> bool: @@ -101,16 +93,13 @@ def modified(self) -> bool: def codestring(self) -> str | None: return self._codestr - def copy(self): - cp = StepArgument(self.arg.strip('${}'), self.value, self.kind, self.is_default) + def copy(self) -> Self: + cp = StepArgument(self.arg.strip('${}'), self.value, self.kind) cp.org_value = self.org_value return cp - def __str__(self): - return f"{self.name}={self.value}" - @staticmethod - def make_codestring(text: Any) -> str: + def make_codestring(text: any) -> str: codestr = str(text) if codestr.title() in ['None', 'True', 'False']: return codestr.title() @@ -121,7 +110,7 @@ def make_codestring(text: Any) -> str: return codestr @staticmethod - def make_identifier(s: Any) -> str: + def make_identifier(s: any) -> str: _s = str(s).replace(' ', '_') if _s.isidentifier(): return f"{_s}_" if iskeyword(_s) or _s in dir(builtins) else _s diff --git a/robotmbt/substitutionmap.py b/robotmbt/substitutionmap.py index 5336d4e1..1f5445bd 100644 --- a/robotmbt/substitutionmap.py +++ b/robotmbt/substitutionmap.py @@ -31,7 +31,7 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import random -from typing import Any +from typing import Self class SubstitutionMap: @@ -44,20 +44,25 @@ class SubstitutionMap: """ def __init__(self): - self.substitutions = {} # {example_value:Constraint} - self.solution = {} # {example_value:solution_value} + # {example_value:Constraint} + self.substitutions: dict[str, Constraint] = {} + + # {example_value:solution_value} + self.solution: dict[str, int | str] = {} def __str__(self): src = self.solution or self.substitutions return ", ".join([f"{k} ⤝ {v}" for k, v in src.items()]) - def copy(self): + def copy(self) -> Self: new = SubstitutionMap() - new.substitutions = {k: v.copy() for k, v in self.substitutions.items()} + new.substitutions = {k: v.copy() + for k, v in self.substitutions.items()} + new.solution = self.solution.copy() return new - def substitute(self, example_value: str, constraint: list[Any]): + def substitute(self, example_value: str, constraint: list[int]): self.solution = {} if example_value in self.substitutions: self.substitutions[example_value].add_constraint(constraint) @@ -66,16 +71,19 @@ def substitute(self, example_value: str, constraint: list[Any]): def solve(self) -> dict[str, str]: self.solution = {} - solution = dict() + solution: dict[str, str] = dict() substitutions = self.copy().substitutions unsolved_subs = list(substitutions) subs_stack = [] + while unsolved_subs: unsolved_subs.sort(key=lambda i: len(substitutions[i].optionset)) example_value = unsolved_subs[0] - solution[example_value] = random.choice(substitutions[example_value].optionset) + solution[example_value] = random.choice( + substitutions[example_value].optionset) subs_stack.append(example_value) others_list = [] + try: # exclude the choice from all others for other in [e for e in substitutions if e != example_value]: @@ -97,32 +105,38 @@ def solve(self) -> dict[str, str]: subs_stack.pop() except IndexError: # nothing left to roll back, no options remaining - raise ValueError("No solution found within the set of given constraints") + raise ValueError( + "No solution found within the set of given constraints") last_item = subs_stack[-1] unsolved_subs.insert(0, last_item) for other in [e for e in substitutions if e != last_item]: substitutions[other].undo_remove() try: - substitutions[last_item].remove_option(solution.pop(last_item)) + substitutions[last_item].remove_option( + solution.pop(last_item)) rollback_done = True except ValueError: # next level must also be rolled back example_value = last_item + self.solution = solution return solution class Constraint: - def __init__(self, constraint: list[Any]): + def __init__(self, constraint): try: # Keep the items in optionset unique. Refrain from using Python sets # due to non-deterministic behaviour when using random seeding. - self.optionset: list[Any] = list(dict.fromkeys(constraint)) + self.optionset: list | None = list(dict.fromkeys(constraint)) except: - self.optionset = None + self.optionset: list | None = None if not self.optionset or isinstance(constraint, str): - raise ValueError(f"Invalid option set for initial constraint: {constraint}") - self.removed_stack = [] + raise ValueError( + f"Invalid option set for initial constraint: {constraint}" + ) + + self.removed_stack: list[str | Placeholder] = [] def __repr__(self): return f'Constraint([{", ".join([str(e) for e in self.optionset])}])' @@ -130,12 +144,13 @@ def __repr__(self): def __iter__(self): return iter(self.optionset) - def copy(self): + def copy(self) -> Self: return Constraint(self.optionset) - def add_constraint(self, constraint: list[Any] | None): + def add_constraint(self, constraint): if constraint is None: return + self.optionset = [opt for opt in self.optionset if opt in constraint] if not len(self.optionset): raise ValueError('No options left after adding constraint') diff --git a/robotmbt/suitedata.py b/robotmbt/suitedata.py index e64b2b38..d0619008 100644 --- a/robotmbt/suitedata.py +++ b/robotmbt/suitedata.py @@ -31,14 +31,11 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import copy -from typing import Literal +from typing import Self -from robot.running.arguments.argumentspec import ArgumentSpec from robot.running.arguments.argumentvalidator import ArgumentValidator -from robot.running.keywordimplementation import KeywordImplementation -import robot.utils.notset -from .steparguments import StepArgument, StepArguments, ArgKind +from .steparguments import StepArgument, StepArguments from .substitutionmap import SubstitutionMap @@ -49,8 +46,8 @@ def __init__(self, name: str, parent=None): self.parent: Suite | None = parent self.suites: list[Suite] = [] self.scenarios: list[Scenario] = [] - self.setup: Step | None = None # Can be a single step or None - self.teardown: Step | None = None # Can be a single step or None + self.setup: Step | str | None = None # Can be a single step or None + self.teardown: Step | str | None = None # Can be a single step or None @property def longname(self) -> str: @@ -62,24 +59,29 @@ def has_error(self) -> bool: or any([s.has_error() for s in self.scenarios]) or (self.teardown.has_error() if self.teardown else False)) + # list[Step | str | None], Step needs to be moved up def steps_with_errors(self): return (([self.setup] if self.setup and self.setup.has_error() else []) - + [e for s in map(Suite.steps_with_errors, self.suites) for e in s] - + [e for s in map(Scenario.steps_with_errors, self.scenarios) for e in s] + + [e for s in map(Suite.steps_with_errors, self.suites) + for e in s] + + [e for s in map(Scenario.steps_with_errors, + self.scenarios) for e in s] + ([self.teardown] if self.teardown and self.teardown.has_error() else [])) class Scenario: - def __init__(self, name: str, parent: Suite | None = None): + def __init__(self, name: str, parent=None): self.name: str = name - # Parent scenario is kept for easy searching, processing and referencing - # after steps and scenarios have been potentially moved around + # Parent scenario for easy searching, processing and referencing self.parent: Suite | None = parent - self.setup: Step | None = None # Can be a single step or None - self.teardown: Step | None = None # Can be a single step or None + # after steps and scenarios have been potentially moved around + # Can be a single step or None, may also be a str in tests + self.setup: Step | None = None + # Can be a single step or None, may also be a str in tests + self.teardown: Step | None = None self.steps: list[Step] = [] self.src_id: int | None = None - self.data_choices: SubstitutionMap = SubstitutionMap() + self.data_choices: dict | SubstitutionMap = {} # may be Dummy type in a test @property def longname(self) -> str: @@ -90,24 +92,25 @@ def has_error(self) -> bool: or any([s.has_error() for s in self.steps]) or (self.teardown.has_error() if self.teardown else False)) - def steps_with_errors(self): + def steps_with_errors(self): # list[Step | None] return (([self.setup] if self.setup and self.setup.has_error() else []) + [s for s in self.steps if s.has_error()] + ([self.teardown] if self.teardown and self.teardown.has_error() else [])) - def copy(self): + def copy(self) -> Self: duplicate = copy.copy(self) duplicate.steps = [step.copy() for step in self.steps] duplicate.data_choices = self.data_choices.copy() return duplicate - def split_at_step(self, stepindex: int): + def split_at_step(self, stepindex: int) -> tuple[Self, Self]: """Returns 2 partial scenarios. With stepindex 0 the first part has no steps and all steps are in the last part. With stepindex 1 the first step is in the first part, the other in the last part, and so on. """ - assert stepindex <= len(self.steps), "Split index out of range. Not enough steps in scenario." + assert stepindex <= len( + self.steps), "Split index out of range. Not enough steps in scenario." front = self.copy() front.teardown = None front.steps = self.steps[:stepindex] @@ -119,31 +122,45 @@ def split_at_step(self, stepindex: int): class Step: def __init__(self, steptext: str, *args, parent: Suite | Scenario, assign: tuple[str] = (), - prev_gherkin_kw: Literal['given', 'when', 'then'] | None = None): - # org_step is the first keyword cell of the Robot line, including step_kw, - # excluding positional args, excluding variable assignment. + prev_gherkin_kw: str | None = None): + # first keyword cell of the Robot line, including step_kw, self.org_step: str = steptext - # org_pn_args are the positional and named arguments as parsed - # from the Robot text ('posA' , 'posB', 'named1=namedA') - self.org_pn_args: tuple[str, ...] = args - self.parent: Suite | Scenario = parent # Parent scenario for easy searching and processing. + + # excluding positional args, excluding variable assignment. + # positional and named arguments as parsed from Robot text ('posA' , 'posB', 'named1=namedA') + self.org_pn_args = args + + # Parent scenario for easy searching and processing. + self.parent: Suite | Scenario = parent + # For when a keyword's return value is assigned to a variable. - # Taken directly from Robot. self.assign: tuple[str] = assign - # gherkin_kw is one of 'given', 'when', 'then', or None for non-bdd keywords. - self.gherkin_kw = self.step_kw if \ - str(self.step_kw).lower() in ['given', 'when', 'then', 'none'] else prev_gherkin_kw - self.signature: str | None = None # Robot keyword with its embedded arguments in ${...} notation. - self.args: StepArguments = StepArguments() # embedded arguments list of StepArgument objects. - self.detached: bool = False # Decouples StepArguments from the step text (refinement use case) - # model_info contains modelling information as a dictionary. The standard format is - # dict(IN=[], OUT=[]) and can optionally contain an error field. - # The values of IN and OUT are lists of Python evaluatable expressions. + + # Taken directly from Robot. + self.gherkin_kw: str | None = self.step_kw \ + if str(self.step_kw).lower() in ['given', 'when', 'then', 'none'] \ + else prev_gherkin_kw + + # 'given', 'when', 'then' or None for non-bdd keywords. + # Robot keyword with its embedded arguments in ${...} notation. + self.signature: str | None = None + + # embedded arguments list of StepArgument objects. + self.args: StepArguments = StepArguments() + + # Decouples StepArguments from the step text (refinement use case) + self.detached: bool = False + + # Modelling information is available as a dictionary. + # TODO: Maybe use a data structure for this instead of a dict with specific keys. + self.model_info: dict[str, str | list[str]] = dict() + # The standard format of `model_info` is dict(IN=[], OUT=[]) and can + # optionally contain an error field. + # IN and OUT are lists of Python evaluatable expressions. # The `new vocab` form can be used to create new domain objects. # The `vocab.attribute` form can then be used to express relations # between properties from the domain vocabulaire. # Custom processors can define their own attributes. - self.model_info: dict[str, str | list[str]] = dict() def __str__(self): return self.keyword @@ -151,8 +168,9 @@ def __str__(self): def __repr__(self): return f"Step: '{self}' with model info: {self.model_info}" - def copy(self): - cp = Step(self.org_step, *self.org_pn_args, parent=self.parent, assign=self.assign) + def copy(self) -> Self: + cp = Step(self.org_step, *self.org_pn_args, + parent=self.parent, assign=self.assign) cp.gherkin_kw = self.gherkin_kw cp.signature = self.signature cp.args = StepArguments(self.args) @@ -179,35 +197,32 @@ def keyword(self) -> str: return self.args.fill_in_args(s) @property - def posnom_args_str(self) -> tuple[str, ...]: + def posnom_args_str(self) -> tuple[any]: """A tuple with all arguments in Robot accepted text format ('posA' , 'posB', 'named1=namedA')""" if self.detached or not self.args.modified: return self.org_pn_args - result = [] + result: list[any] = [] for arg in self.args: - if arg.is_default: - continue - if arg.kind == ArgKind.POSITIONAL: + if arg.kind == arg.POSITIONAL: result.append(arg.value) - elif arg.kind == ArgKind.VAR_POS: + elif arg.kind == arg.VAR_POS: for vararg in arg.value: result.append(vararg) - elif arg.kind == ArgKind.NAMED: + elif arg.kind == arg.NAMED: result.append(f"{arg.name}={arg.value}") - elif arg.kind == ArgKind.FREE_NAMED: + elif arg.kind == arg.FREE_NAMED: for name, value in arg.value.items(): result.append(f"{name}={value}") - else: + else: # TODO: remove this - has no impact on the control flow. continue return tuple(result) @property - def gherkin_kw(self) -> Literal['given', 'when', 'then'] | None: + def gherkin_kw(self) -> str | None: return self._gherkin_kw @gherkin_kw.setter def gherkin_kw(self, value: str | None): - """if value is type str, it must be a case insensitive variant of given, when, then""" self._gherkin_kw = value.lower() if value else None @property @@ -220,74 +235,59 @@ def kw_wo_gherkin(self) -> str: """The keyword without its Gherkin keyword. I.e., as it is known in Robot framework.""" return self.keyword.replace(self.step_kw, '', 1).strip() if self.step_kw else self.keyword - def add_robot_dependent_data(self, robot_kw: KeywordImplementation): - """robot_kw must be Robot Framework's keyword object from Robot's runner context""" + def add_robot_dependent_data(self, robot_kw): + """ + robot_kw must be Robot Framework's keyword object from Robot's runner context + """ try: if robot_kw.error: raise ValueError(robot_kw.error) if robot_kw.embedded: - self.args = StepArguments([StepArgument(*match, kind=ArgKind.EMBEDDED) for match in + self.args = StepArguments([StepArgument(*match, kind=StepArgument.EMBEDDED) for match in zip(robot_kw.embedded.args, robot_kw.embedded.parse_args(self.kw_wo_gherkin))]) + self.args += self.__handle_non_embedded_arguments(robot_kw.args) self.signature = robot_kw.name self.model_info = self.__parse_model_info(robot_kw._doc) except Exception as ex: self.model_info['error'] = str(ex) - def __handle_non_embedded_arguments(self, robot_argspec: ArgumentSpec) -> list[StepArgument]: + def __handle_non_embedded_arguments(self, robot_argspec) -> list[StepArgument]: result = [] - p_args = [a for a in self.org_pn_args if '=' not in a or r'\=' in a] - n_args = [a.split('=', 1) for a in self.org_pn_args if '=' in a and r'\=' not in a] - self.__validate_arguments(robot_argspec, p_args, n_args) - + + p_args, n_args = robot_argspec.map([a for a in self.org_pn_args if '=' not in a or r'\=' in a], + [a.split('=', 1) for a in self.org_pn_args if '=' in a and r'\=' not in a]) + + # for some reason .map() returns [None] instead of the empty list when there are no arguments + if p_args == [None]: + p_args = [] + + ArgumentValidator(robot_argspec).validate(p_args, n_args) robot_args = [a for a in robot_argspec] - argument_names = [a for a in robot_argspec.argument_names if a not in robot_argspec.embedded] + argument_names = list(robot_argspec.argument_names) for arg in robot_argspec: - if not p_args or (arg.kind != arg.POSITIONAL_ONLY and arg.kind != arg.POSITIONAL_OR_NAMED): + if arg.kind != arg.POSITIONAL_ONLY and arg.kind != arg.POSITIONAL_OR_NAMED: break - result.append(StepArgument(argument_names.pop(0), p_args.pop(0), kind=ArgKind.POSITIONAL)) + result += [StepArgument(argument_names.pop(0), + p_args.pop(0), kind=StepArgument.POSITIONAL)] robot_args.pop(0) + if not p_args: + break if p_args and robot_args[0].kind == robot_args[0].VAR_POSITIONAL: - result.append(StepArgument(argument_names.pop(0), p_args, kind=ArgKind.VAR_POS)) + result += [StepArgument(argument_names.pop(0), + p_args, kind=StepArgument.VAR_POS)] free = {} for name, value in n_args: if name in argument_names: - result.append(StepArgument(name, value, kind=ArgKind.NAMED)) - argument_names.remove(name) + result += [StepArgument(name, value, kind=StepArgument.NAMED)] else: free[name] = value if free: - result.append(StepArgument(argument_names.pop(-1), free, kind=ArgKind.FREE_NAMED)) - for unmentioned_arg in argument_names: - arg = next(arg for arg in robot_args if arg.name == unmentioned_arg) - default_value = arg.default - if default_value is robot.utils.notset.NOT_SET: - if arg.kind == arg.VAR_POSITIONAL: - default_value = [] - elif arg.kind == arg.VAR_NAMED: - default_value = {} - else: - # This can happen when using library keywords that specify embedded arguments in the @keyword decorator - # but use different names in the method signature. Robot Framework implementation is incomplete for this - # aspect and differs between library and user keywords. - assert False, f"No default argument expected to be needed for '{unmentioned_arg}' here" - result.append(StepArgument(unmentioned_arg, default_value, kind=ArgKind.NAMED, is_default=True)) + result += [StepArgument(argument_names[-1], + free, kind=StepArgument.FREE_NAMED)] return result - @staticmethod - def __validate_arguments(spec, positionals, nameds): - # Robot uses a slightly different mapping for positional and named arguments. - # We keep the notation from the scenario (with or without the argument's name). - # Robot's mapping favours positional when possible, even when the name is used - # in the keyword call. The validator is sensitive to these differences. - p, n = spec.map(positionals, nameds) - if p == [None]: - # for some reason .map() returns [None] instead of the empty list when there are no arguments - p = [] - # Use the Robot mechanism for validation to yield familiar error messages - ArgumentValidator(spec).validate(p, n) - def __parse_model_info(self, docu: str) -> dict[str, list[str]]: model_info = dict() mi_index = docu.find("*model info*") @@ -298,6 +298,7 @@ def __parse_model_info(self, docu: str) -> dict[str, list[str]]: if "" in lines: lines = lines[:lines.index("")] format_msg = "*model info* expected format: :: |" + while lines: line = lines.pop(0) if not line.startswith(":"): @@ -308,7 +309,8 @@ def __parse_model_info(self, docu: str) -> dict[str, list[str]]: key = elms[1].strip() expressions = [e.strip() for e in elms[-1].split("|") if e] while lines and not lines[0].startswith(":"): - expressions.extend([e.strip() for e in lines.pop(0).split("|") if e]) + expressions.extend([e.strip() + for e in lines.pop(0).split("|") if e]) model_info[key] = expressions if not model_info: raise ValueError("When present, *model info* cannot be empty") diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index ebed36dd..0fb992e0 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -32,18 +32,19 @@ import copy import random -from typing import Any from robot.api import logger +from robot.utils import is_list_like -from . import modeller +from .substitutionmap import SubstitutionMap from .modelspace import ModelSpace -from .suitedata import Suite, Scenario -from .tracestate import TraceState +from .suitedata import Suite, Scenario, Step +from .tracestate import TraceState, TraceSnapShot +from .steparguments import StepArgument, StepArguments class SuiteProcessors: - def echo(self, in_suite: Suite) -> Suite: + def echo(self, in_suite): return in_suite def flatten(self, in_suite: Suite) -> Suite: @@ -61,6 +62,7 @@ def flatten(self, in_suite: Suite) -> Suite: if scenario.teardown: scenario.steps.append(scenario.teardown) scenario.teardown = None + out_suite.scenarios = [] for suite in in_suite.suites: subsuite = self.flatten(suite) @@ -70,11 +72,12 @@ def flatten(self, in_suite: Suite) -> Suite: if subsuite.teardown: scenario.steps.append(subsuite.teardown) out_suite.scenarios.extend(subsuite.scenarios) + out_suite.scenarios.extend(outer_scenarios) out_suite.suites = [] return out_suite - def process_test_suite(self, in_suite: Suite, *, seed: str | int | bytes | bytearray = 'new') -> Suite: + def process_test_suite(self, in_suite: Suite, *, seed: any = 'new') -> Suite: self.out_suite = Suite(in_suite.name) self.out_suite.filename = in_suite.filename self.out_suite.parent = in_suite.parent @@ -83,140 +86,445 @@ def process_test_suite(self, in_suite: Suite, *, seed: str | int | bytes | bytea for id, scenario in enumerate(self.flat_suite.scenarios, start=1): scenario.src_id = id - self.scenarios: list[Scenario] = self.flat_suite.scenarios[:] + self.scenarios = self.flat_suite.scenarios[:] logger.debug("Use these numbers to reference scenarios from traces\n\t" + "\n\t".join([f"{s.src_id}: {s.name}" for s in self.scenarios])) self._init_randomiser(seed) - self.shuffled: list[int] = [s.src_id for s in self.scenarios] - random.shuffle(self.shuffled) # Keep a single shuffle for all TraceStates (non-essential) + random.shuffle(self.scenarios) # a short trace without the need for repeating scenarios is preferred - tracestate = self._try_to_reach_full_coverage(allow_duplicate_scenarios=False) + self._try_to_reach_full_coverage(allow_duplicate_scenarios=False) - if not tracestate.coverage_reached(): - logger.debug("Direct trace not available. Allowing repetition of scenarios") - tracestate = self._try_to_reach_full_coverage(allow_duplicate_scenarios=True) - if not tracestate.coverage_reached(): + if not self.tracestate.coverage_reached(): + logger.debug( + "Direct trace not available. Allowing repetition of scenarios") + self._try_to_reach_full_coverage(allow_duplicate_scenarios=True) + if not self.tracestate.coverage_reached(): raise Exception("Unable to compose a consistent suite") - self.out_suite.scenarios = tracestate.get_trace() - self._report_tracestate_wrapup(tracestate) + self.out_suite.scenarios = self.tracestate.get_trace() + self._report_tracestate_wrapup() return self.out_suite - def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool) -> TraceState: - tracestate = TraceState(self.shuffled) - while not tracestate.coverage_reached(): - candidate_id = tracestate.next_candidate(retry=allow_duplicate_scenarios) - if candidate_id is None: # No more candidates remaining for this level - if not tracestate.can_rewind(): + def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool): + self.tracestate = TraceState(len(self.scenarios)) + self.active_model = ModelSpace() + while not self.tracestate.coverage_reached(): + i_candidate = self.tracestate.next_candidate( + retry=allow_duplicate_scenarios) + + if i_candidate is None: + if not self.tracestate.can_rewind(): break - tail = modeller.rewind(tracestate) - logger.debug(f"Having to roll back up to {tail.scenario.name if tail else 'the beginning'}") - self._report_tracestate_to_user(tracestate) + tail = self._rewind() + logger.debug("Having to roll back up to " + f"{tail.scenario.name if tail else 'the beginning'}") + self._report_tracestate_to_user() + else: - candidate = self._select_scenario_variant(candidate_id, tracestate) - if not candidate: # No valid variant available in the current state - tracestate.reject_scenario(candidate_id) - continue - previous_len = len(tracestate) - modeller.try_to_fit_in_scenario(candidate, tracestate) - self._report_tracestate_to_user(tracestate) - if len(tracestate) > previous_len: - logger.debug(f"last state:\n{tracestate.model.get_status_text()}") + self.active_model.new_scenario_scope() + inserted = self._try_to_fit_in_scenario(i_candidate, self._scenario_with_repeat_counter(i_candidate), + retry_flag=allow_duplicate_scenarios) + if inserted: self.DROUGHT_LIMIT = 50 - if self.__last_candidate_changed_nothing(tracestate): - logger.debug("Repeated scenario did not change the model's state. Stop trying.") - modeller.rewind(tracestate) - elif tracestate.coverage_drought > self.DROUGHT_LIMIT: + if self.__last_candidate_changed_nothing(): + logger.debug( + "Repeated scenario did not change the model's state. Stop trying.") + self._rewind() + + elif self.tracestate.coverage_drought > self.DROUGHT_LIMIT: logger.debug(f"Went too long without new coverage (>{self.DROUGHT_LIMIT}x). " "Roll back to last coverage increase and try something else.") - modeller.rewind(tracestate, drought_recovery=True) - self._report_tracestate_to_user(tracestate) - logger.debug(f"last state:\n{tracestate.model.get_status_text()}") - return tracestate + self._rewind(drought_recovery=True) + self._report_tracestate_to_user() + logger.debug( + f"last state:\n{self.active_model.get_status_text()}") - @staticmethod - def __last_candidate_changed_nothing(tracestate: TraceState) -> bool: - if len(tracestate) < 2: + def __last_candidate_changed_nothing(self) -> bool: + if len(self.tracestate) < 2: return False - if tracestate[-1].id != tracestate[-2].id: + + if self.tracestate[-1].id != self.tracestate[-2].id: return False - return tracestate[-1].model == tracestate[-2].model - def _select_scenario_variant(self, candidate_id: int, tracestate: TraceState) -> Scenario: - candidate = self._scenario_with_repeat_counter(candidate_id, tracestate) - candidate = modeller.generate_scenario_variant(candidate, tracestate.model or ModelSpace()) - return candidate + return self.tracestate[-1].model == self.tracestate[-2].model + + def _scenario_with_repeat_counter(self, index: int) -> Scenario: + """Fetches the scenario by index and, if this scenario is already used in the trace, + adds a repetition counter to its name.""" + candidate = self.scenarios[index] + rep_count = self.tracestate.count(index) - def _scenario_with_repeat_counter(self, index: int, tracestate: TraceState) -> Scenario: - """ - Fetches the scenario by index and, if this scenario is already - used in the trace, adds a repetition counter to its name. - """ - candidate = next(s for s in self.scenarios if s.src_id == index) - rep_count = tracestate.count(index) if rep_count: candidate = candidate.copy() - candidate.name = f"{candidate.name} (rep {rep_count+1})" + candidate.name = f"{candidate.name} (rep {rep_count + 1})" return candidate @staticmethod def _fail_on_step_errors(suite: Suite): error_list = suite.steps_with_errors() + if error_list: err_msg = "Steps with errors in their model info found:\n" err_msg += '\n'.join([f"{s.keyword} [{s.model_info['error']}] used in {s.parent.name}" for s in error_list]) raise Exception(err_msg) + def _try_to_fit_in_scenario(self, index: int, candidate: Scenario, retry_flag: bool) -> bool: + candidate = self._generate_scenario_variant( + candidate, self.active_model) + + if not candidate: + self.active_model.end_scenario_scope() + self.tracestate.reject_scenario(index) + self._report_tracestate_to_user() + return False + + confirmed_candidate, new_model = self._process_scenario( + candidate, self.active_model) + + if confirmed_candidate: + self.active_model = new_model + self.active_model.end_scenario_scope() + self.tracestate.confirm_full_scenario( + index, confirmed_candidate, self.active_model) + logger.debug( + f"Inserted scenario {confirmed_candidate.src_id}, {confirmed_candidate.name}") + self._report_tracestate_to_user() + logger.debug(f"last state:\n{self.active_model.get_status_text()}") + return True + + part1, part2 = self._split_candidate_if_refinement_needed( + candidate, self.active_model) + + if part2: + exit_conditions = part2.steps[1].model_info['OUT'] + part1.name = f"{part1.name} (part {self.tracestate.highest_part(index) + 1})" + part1, new_model = self._process_scenario(part1, self.active_model) + self.tracestate.push_partial_scenario(index, part1, new_model) + self.active_model = new_model + self._report_tracestate_to_user() + logger.debug(f"last state:\n{self.active_model.get_status_text()}") + + i_refine = self.tracestate.next_candidate(retry=retry_flag) + if i_refine is None: + logger.debug( + "Refinement needed, but there are no scenarios left") + self._rewind() + self._report_tracestate_to_user() + return False + + while i_refine is not None: + self.active_model.new_scenario_scope() + m_inserted = self._try_to_fit_in_scenario(i_refine, self._scenario_with_repeat_counter(i_refine), + retry_flag) + if m_inserted: + insert_valid_here = True + try: + # Check exit condition before finalizing refinement and inserting the tail part + model_scratchpad = self.active_model.copy() + for expr in exit_conditions: + if model_scratchpad.process_expression(expr, part2.steps[1].args) is False: + insert_valid_here = False + break + except Exception: + insert_valid_here = False + + if insert_valid_here: + m_finished = self._try_to_fit_in_scenario( + index, part2, retry_flag) + if m_finished: + return True + else: + logger.debug( + f"Scenario did not meet refinement conditions {exit_conditions}") + logger.debug( + f"last state:\n{self.active_model.get_status_text()}") + logger.debug( + f"Reconsidering {self.scenarios[i_refine].name}, scenario excluded") + self._rewind() + self._report_tracestate_to_user() + + i_refine = self.tracestate.next_candidate(retry=retry_flag) + + self._rewind() + self._report_tracestate_to_user() + return False + + self.active_model.end_scenario_scope() + self.tracestate.reject_scenario(index) + self._report_tracestate_to_user() + return False + + def _rewind(self, drought_recovery: bool = False) -> TraceSnapShot | None: + tail = self.tracestate.rewind() + + while drought_recovery and self.tracestate.coverage_drought: + tail = self.tracestate.rewind() + + self.active_model = self.tracestate.model or ModelSpace() + return tail + + @staticmethod + def _split_candidate_if_refinement_needed(scenario: Scenario, model: ModelSpace) \ + -> tuple[Scenario, Scenario | None]: + m = model.copy() + scenario = scenario.copy() + no_split = (scenario, None) + for step in scenario.steps: + if 'error' in step.model_info: + return no_split + + if step.gherkin_kw in ['given', 'when', None]: + for expr in step.model_info.get('IN', []): + try: + if m.process_expression(expr, step.args) is False: + return no_split + except Exception: + return no_split + + if step.gherkin_kw in ['when', 'then', None]: + for expr in step.model_info.get('OUT', []): + refine_here = False + try: + if m.process_expression(expr, step.args) is False: + if step.gherkin_kw in ['when', None]: + logger.debug( + f"Refinement needed for scenario: {scenario.name}\nat step: {step}") + refine_here = True + else: + return no_split + + except Exception: + return no_split + + if refine_here: + front, back = scenario.split_at_step( + scenario.steps.index(step)) + remaining_steps = '\n\t'.join( + [step.full_keyword, '- ' * 35] + [s.full_keyword for s in back.steps[1:]]) + remaining_steps = SuiteProcessors.escape_robot_vars( + remaining_steps) + edge_step = Step( + 'Log', f"Refinement follows for step:\n\t{remaining_steps}", parent=scenario) + edge_step.gherkin_kw = step.gherkin_kw + edge_step.model_info = dict( + IN=step.model_info['IN'], OUT=[]) + edge_step.detached = True + edge_step.args = StepArguments(step.args) + front.steps.append(edge_step) + back.steps.insert( + 0, Step('Log', f"Refinement ready, completing step", parent=scenario)) + back.steps[1] = back.steps[1].copy() + back.steps[1].model_info['IN'] = [] + return (front, back) + + return no_split + @staticmethod - def _report_tracestate_to_user(tracestate: TraceState): - user_trace = f"[{', '.join(tracestate.id_trace)}]" - logger.debug(f"Trace: {user_trace} Reject: {list(tracestate.tried)}") + def escape_robot_vars(text: str) -> str: + for seq in ("${", "@{", "%{", "&{", "*{"): + text = text.replace(seq, "\\" + seq) + + return text + + @staticmethod + def _process_scenario(scenario: Scenario, model: ModelSpace) -> tuple[Scenario, ModelSpace] | tuple[None, None]: + m = model.copy() + scenario = scenario.copy() + for step in scenario.steps: + if 'error' in step.model_info: + logger.debug( + f"Error in scenario {scenario.name} at step {step}: {step.model_info['error']}") + return None, None + + for expr in SuiteProcessors._relevant_expressions(step): + try: + if m.process_expression(expr, step.args) is False: + raise Exception(False) + except Exception as err: + logger.debug(f"Unable to insert scenario {scenario.src_id}, {scenario.name}, " + f"due to step '{step}': [{expr}] {err}") + return None, None + + return scenario, m @staticmethod - def _report_tracestate_wrapup(tracestate: TraceState): + def _relevant_expressions(step: Step) -> list[str | list[str]]: + if step.gherkin_kw is None and not step.model_info: + return [] # model info is optional for action keywords + + expressions = [] + if 'IN' not in step.model_info or 'OUT' not in step.model_info: + raise Exception(f"Model info incomplete for step: {step}") + + if step.gherkin_kw in ['given', 'when', None]: + expressions += step.model_info['IN'] + + if step.gherkin_kw in ['when', 'then', None]: + expressions += step.model_info['OUT'] + + return expressions + + def _generate_scenario_variant(self, scenario: Scenario, model: ModelSpace) -> Scenario | None: + m = model.copy() + scenario = scenario.copy() + scenarios_in_refinement = self.tracestate.find_scenarios_with_active_refinement() + + # reuse previous solution for all parts in split-up scenario + for sir in scenarios_in_refinement: + if sir.src_id == scenario.src_id: + return scenario + + # collect set of constraints + subs = SubstitutionMap() + try: # TODO: look into refactoring this... interestingly structured code. + for step in scenario.steps: + if 'MOD' in step.model_info: + for expr in step.model_info['MOD']: + modded_arg, constraint = self._parse_modifier_expression( + expr, step.args) + + if step.args[modded_arg].kind != StepArgument.EMBEDDED: + raise ValueError( + "Modifers are currently only supported for embedded arguments.") + + org_example = step.args[modded_arg].org_value + if step.gherkin_kw == 'then': + constraint = None # No new constraints are processed for then-steps + if org_example not in subs.substitutions: + # if a then-step signals the first use of an example value, it is considered a new definition + subs.substitute(org_example, [org_example]) + continue + + if not constraint and org_example not in subs.substitutions: + raise ValueError( + f"No options to choose from at first assignment to {org_example}") + + if constraint and constraint != '.*': + options = m.process_expression( + constraint, step.args) + if options == 'exec': + raise ValueError( + f"Invalid constraint for argument substitution: {expr}") + + if not options: + raise ValueError( + f"Constraint on modifer did not yield any options: {expr}") + + if not is_list_like(options): + raise ValueError( + f"Constraint on modifer did not yield a set of options: {expr}") + + else: + options = None + + subs.substitute(org_example, options) + + except Exception as err: + logger.debug(f"Unable to insert scenario {scenario.src_id}, {scenario.name}, due to modifier\n" + f" In step {step}: {err}") + return None + + try: + subs.solve() + except ValueError as err: + logger.debug( + f"Unable to insert scenario {scenario.src_id}, {scenario.name}, due to modifier\n {err}: {subs}") + return None + + # Update scenario with generated values + if subs.solution: + logger.debug( + f"Example variant generated with argument substitution: {subs}") + + scenario.data_choices = subs + + for step in scenario.steps: + if 'MOD' in step.model_info: + for expr in step.model_info['MOD']: + modded_arg, _ = self._parse_modifier_expression( + expr, step.args) + org_example = step.args[modded_arg].org_value + step.args[modded_arg].value = subs.solution[org_example] + + return scenario + + @staticmethod + def _parse_modifier_expression(expression: str, args: StepArguments) -> tuple[str, str]: + if expression.startswith('${'): + for var in args: + if expression.casefold().startswith(var.arg.casefold()): + assignment_expr = expression.replace( + var.arg, '', 1).strip() + + if not assignment_expr.startswith('=') or assignment_expr.startswith('=='): + break # not an assignment + + constraint = assignment_expr.replace('=', '', 1).strip() + return var.arg, constraint + + raise ValueError(f"Invalid argument substitution: {expression}") + + def _report_tracestate_to_user(self): + user_trace = "[" + for snapshot in self.tracestate: + part = f".{snapshot.id.split('.')[1]}" if '.' in snapshot.id else "" + user_trace += f"{snapshot.scenario.src_id}{part}, " + + user_trace = user_trace[:-2] + "]" if ',' in user_trace else "[]" + reject_trace = [ + self.scenarios[i].src_id for i in self.tracestate.tried] + logger.debug(f"Trace: {user_trace} Reject: {reject_trace}") + + def _report_tracestate_wrapup(self): logger.info("Trace composed:") - for progression in tracestate: - logger.info(progression.scenario.name) - logger.debug(f"model\n{progression.model.get_status_text()}\n") + for step in self.tracestate: + logger.info(step.scenario.name) + logger.debug(f"model\n{step.model.get_status_text()}\n") @staticmethod - def _init_randomiser(seed: str | int | bytes | bytearray): + def _init_randomiser(seed: any): if isinstance(seed, str): seed = seed.strip() + if str(seed).lower() == 'none': - logger.info( + logger.debug( f"Using system's random seed for trace generation. This trace cannot be rerun. Use `seed=new` to generate a reusable seed.") elif str(seed).lower() == 'new': new_seed = SuiteProcessors._generate_seed() - logger.info(f"seed={new_seed} (use seed to rerun this trace)") + logger.debug(f"seed={new_seed} (use seed to rerun this trace)") random.seed(new_seed) else: - logger.info(f"seed={seed} (as provided)") + logger.debug(f"seed={seed} (as provided)") random.seed(seed) @staticmethod def _generate_seed() -> str: """Creates a random string of 5 words between 3 and 6 letters long""" vowels = ['a', 'e', 'i', 'o', 'u', 'y'] - consonants = ['b', 'c', 'd', 'f', 'g', 'h', 'j', 'k', 'l', - 'm', 'n', 'p', 'q', 'r', 's', 't', 'v', 'w', 'x', 'z'] + consonants = ['b', 'c', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'm', 'n', 'p', 'q', 'r', 's', 't', 'v', 'w', 'x', + 'z'] words = [] for word in range(5): prior_choice = random.choice([vowels, consonants]) last_choice = random.choice([vowels, consonants]) - string = random.choice(prior_choice) + random.choice(last_choice) # add first two letters - for letter in range(random.randint(1, 4)): # add 1 to 4 more letters + + # add first two letters + string = random.choice(prior_choice) + random.choice(last_choice) + for letter in range(random.randint(1, 4)): # add 1 to 4 more letters if prior_choice is last_choice: new_choice = consonants if prior_choice is vowels else vowels else: new_choice = random.choice([vowels, consonants]) + prior_choice = last_choice last_choice = new_choice string += random.choice(new_choice) + words.append(string) + seed = '-'.join(words) return seed diff --git a/robotmbt/suitereplacer.py b/robotmbt/suitereplacer.py index 7163a5cd..645a93a8 100644 --- a/robotmbt/suitereplacer.py +++ b/robotmbt/suitereplacer.py @@ -1,4 +1,10 @@ # -*- coding: utf-8 -*- +from .suitedata import Suite, Scenario, Step +from .suiteprocessors import SuiteProcessors +import robot.running.model as rmodel +from robot.api import logger +from robot.api.deco import keyword +from typing import Self # BSD 3-Clause License # @@ -30,27 +36,24 @@ # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -import robot.model -import robot.running.model as rmodel -from .suitedata import Suite, Scenario, Step -from .suiteprocessors import SuiteProcessors -from robot.api import logger -from robot.api.deco import library, keyword -from typing import Any, Literal from robot.libraries.BuiltIn import BuiltIn + Robot = BuiltIn() -@library(scope="GLOBAL", listener='SELF') class SuiteReplacer: + ROBOT_LIBRARY_SCOPE: str = 'GLOBAL' + ROBOT_LISTENER_API_VERSION: int = 3 + def __init__(self, processor: str = 'process_test_suite', processor_lib: str | None = None): - self.current_suite: robot.model.TestSuite | None = None - self.robot_suite: robot.model.TestSuite | None = None + self.ROBOT_LIBRARY_LISTENER: Self = self + self.current_suite: Suite | None = None + self.robot_suite: Suite | None = None self.processor_lib_name: str | None = processor_lib self.processor_name: str = processor - self._processor_lib: SuiteProcessors | None | object = None - self._processor_method: Any = None - self.processor_options: dict[str, Any] = {} + self._processor_lib: SuiteProcessors | None = None + self._processor_method = None + self.processor_options = {} @property def processor_lib(self) -> SuiteProcessors: @@ -65,7 +68,10 @@ def processor_method(self): if not hasattr(self.processor_lib, self.processor_name): Robot.fail( f"Processor '{self.processor_name}' not available for model-based processor library {self.processor_lib_name}") - self._processor_method = getattr(self._processor_lib, self.processor_name) + + self._processor_method = getattr( + self._processor_lib, self.processor_name) + return self._processor_method @keyword(name="Treat this test suite Model-based") @@ -78,18 +84,20 @@ def treat_model_based(self, **kwargs): model info that is included in the test steps, the test cases are modifed, mixed and matched to create unique traces and achieve more test coverage quicker. - Any arguments must be named arguments. They are passed on as options to the selected model-based - processor. If an option was already set on library level (See: `Set model-based options` and - `Update model-based options`, then these arguments take precedence over the library option and - affect only the current test suite. + Any arguments are handled as if using keyword `Update model-based options` """ self.robot_suite = self.current_suite - logger.info(f"Analysing Robot test suite '{self.robot_suite.name}' for model-based execution.") - local_settings = self.processor_options.copy() - local_settings.update(kwargs) - master_suite = self.__process_robot_suite(self.robot_suite, parent=None) - modelbased_suite = self.processor_method(master_suite, **local_settings) + logger.info( + f"Analysing Robot test suite '{self.robot_suite.name}' for model-based execution.") + + self.update_model_based_options(**kwargs) + master_suite = self.__process_robot_suite( + self.robot_suite, parent=None) + + modelbased_suite = self.processor_method( + master_suite, **self.processor_options) + self.__clearTestSuite(self.robot_suite) self.__generateRobotSuite(modelbased_suite, self.robot_suite) @@ -109,53 +117,73 @@ def update_model_based_options(self, **kwargs): """ self.processor_options.update(kwargs) - def __process_robot_suite(self, in_suite: robot.model.TestSuite, parent: Suite | None) -> Suite: + def __process_robot_suite(self, in_suite: Suite, parent: Suite | None) -> Suite: out_suite = Suite(in_suite.name, parent) out_suite.filename = in_suite.source if in_suite.setup and parent is not None: - step_info = Step(in_suite.setup.name, *in_suite.setup.args, parent=out_suite) - step_info.add_robot_dependent_data(Robot._namespace.get_runner(step_info.org_step).keyword) + step_info = Step(in_suite.setup.name, * + in_suite.setup.args, parent=out_suite) + step_info.add_robot_dependent_data( + Robot._namespace.get_runner(step_info.org_step).keyword) out_suite.setup = step_info + if in_suite.teardown and parent is not None: - step_info = Step(in_suite.teardown.name, *in_suite.teardown.args, parent=out_suite) - step_info.add_robot_dependent_data(Robot._namespace.get_runner(step_info.org_step).keyword) + step_info = Step(in_suite.teardown.name, * + in_suite.teardown.args, parent=out_suite) + step_info.add_robot_dependent_data( + Robot._namespace.get_runner(step_info.org_step).keyword) out_suite.teardown = step_info + for st in in_suite.suites: - out_suite.suites.append(self.__process_robot_suite(st, parent=out_suite)) + out_suite.suites.append( + self.__process_robot_suite(st, parent=out_suite)) + for tc in in_suite.tests: scenario = Scenario(tc.name, parent=out_suite) if tc.setup: - step_info = Step(tc.setup.name, *tc.setup.args, parent=scenario) - step_info.add_robot_dependent_data(Robot._namespace.get_runner(step_info.org_step).keyword) + step_info = Step( + tc.setup.name, *tc.setup.args, parent=scenario) + step_info.add_robot_dependent_data( + Robot._namespace.get_runner(step_info.org_step).keyword) scenario.setup = step_info + if tc.teardown: - step_info = Step(tc.teardown.name, *tc.teardown.args, parent=scenario) - step_info.add_robot_dependent_data(Robot._namespace.get_runner(step_info.org_step).keyword) + step_info = Step(tc.teardown.name, * + tc.teardown.args, parent=scenario) + step_info.add_robot_dependent_data( + Robot._namespace.get_runner(step_info.org_step).keyword) scenario.teardown = step_info last_gwt = None + for step_def in tc.body: if isinstance(step_def, rmodel.Keyword): step_info = Step(step_def.name, *step_def.args, parent=scenario, assign=step_def.assign, prev_gherkin_kw=last_gwt) - step_info.add_robot_dependent_data(Robot._namespace.get_runner(step_info.org_step).keyword) + step_info.add_robot_dependent_data( + Robot._namespace.get_runner(step_info.org_step).keyword) scenario.steps.append(step_info) + if step_info.gherkin_kw: last_gwt = step_info.gherkin_kw + elif isinstance(step_def, rmodel.Var): - scenario.steps.append(Step('VAR', step_def.name, *step_def.value, parent=scenario)) + scenario.steps.append( + Step('VAR', step_def.name, *step_def.value, parent=scenario)) else: unsupported_step = Step(str(step_def), parent=scenario) unsupported_step.model_info['error'] = f"Robot construct not supported" scenario.steps.append(unsupported_step) + out_suite.scenarios.append(scenario) + return out_suite - def __clearTestSuite(self, suite: robot.model.TestSuite): + def __clearTestSuite(self, suite: Suite): suite.tests.clear() suite.suites.clear() - def __generateRobotSuite(self, suite_model: Suite, target_suite: robot.model.TestSuite): + def __generateRobotSuite(self, suite_model: Suite, target_suite): for subsuite in suite_model.suites: new_suite = target_suite.suites.create(name=subsuite.name) new_suite.resource = target_suite.resource @@ -180,9 +208,11 @@ def __generateRobotSuite(self, suite_model: Suite, target_suite: robot.model.Tes type='teardown') for step in tc.steps: if step.keyword == 'VAR': - new_tc.body.create_var(step.posnom_args_str[0], step.posnom_args_str[1:]) + new_tc.body.create_var( + step.posnom_args_str[0], step.posnom_args_str[1:]) else: - new_tc.body.create_keyword(name=step.keyword, assign=step.assign, args=step.posnom_args_str) + new_tc.body.create_keyword( + name=step.keyword, assign=step.assign, args=step.posnom_args_str) def _start_suite(self, suite: Suite | None, result): self.current_suite = suite diff --git a/robotmbt/tracestate.py b/robotmbt/tracestate.py index 21f1ed6c..59b0151c 100644 --- a/robotmbt/tracestate.py +++ b/robotmbt/tracestate.py @@ -1,4 +1,6 @@ # -*- coding: utf-8 -*- +from robotmbt.suitedata import Scenario + # BSD 3-Clause License # @@ -30,156 +32,149 @@ # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -from robotmbt.modelspace import ModelSpace -from robotmbt.suitedata import Scenario - - class TraceSnapShot: - def __init__(self, id: str, inserted_scenario: Scenario, model_state: ModelSpace, - remainder: Scenario | None = None, drought: int = 0): + def __init__(self, id: str, inserted_scenario: str | Scenario, model_state: dict[str, int], drought: int = 0): self.id: str = id - self.scenario: Scenario = inserted_scenario - self.remainder: Scenario | None = remainder - self._model: ModelSpace = model_state.copy() + self.scenario: str | Scenario = inserted_scenario + self.model: dict[str, int] = model_state.copy() self.coverage_drought: int = drought - @property - def model(self) -> ModelSpace: - return self._model.copy() - class TraceState: - def __init__(self, scenario_indexes: list[int]): - self.c_pool: dict[int, int] = {index: 0 for index in scenario_indexes} - if len(self.c_pool) != len(scenario_indexes): - raise ValueError("Scenarios must be uniquely identifiable") - self._tried: list[list[int]] = [[]] # Keeps track of the scenarios already tried at each step in the trace - self._snapshots: list[TraceSnapShot] = [] # Keeps details for elements in trace + def __init__(self, n_scenarios: int): + # coverage pool: True means scenario is in trace + self._c_pool: list[bool] = [False] * n_scenarios + + # Keeps track of the scenarios already tried at each step in the trace + self._tried: list[list[int]] = [[]] + + # Choice trace, when was which scenario inserted (e.g. ['1', '2.1', '3', '2.0']) + self._trace: list[str] = [] + + # Keeps details for elements in trace + self._snapshots: list[TraceSnapShot] = [] self._open_refinements: list[int] = [] @property - def model(self) -> ModelSpace | None: + def model(self) -> dict[str, int] | None: """returns the model as it is at the end of the current trace""" - return self._snapshots[-1].model if self._snapshots else None + return self._snapshots[-1].model if self._trace else None @property - def tried(self) -> tuple[int, ...]: + def tried(self) -> tuple[int]: """returns the indices that were rejected or previously inserted at the current position""" return tuple(self._tried[-1]) + def coverage_reached(self) -> bool: + return all(self._c_pool) + @property def coverage_drought(self) -> int: """Number of scenarios since last new coverage""" return self._snapshots[-1].coverage_drought if self._snapshots else 0 - @property - def id_trace(self): - return [snap.id for snap in self._snapshots] - - @property - def active_refinements(self): - return self._open_refinements[:] - - def coverage_reached(self): - return all(self.c_pool.values()) - - def get_trace(self) -> list[Scenario]: + def get_trace(self) -> list[str]: return [snap.scenario for snap in self._snapshots] - def next_candidate(self, retry: bool = False): - for i in self.c_pool: - if i not in self._tried[-1] and not self.is_refinement_active(i) and self.count(i) == 0: + def next_candidate(self, retry: bool = False) -> int | None: + for i in range(len(self._c_pool)): + if i not in self._tried[-1] and not self._is_refinement_active(i) and self.count(i) == 0: return i + if not retry: return None - for i in self.c_pool: - if i not in self._tried[-1] and not self.is_refinement_active(i): + + for i in range(len(self._c_pool)): + if i not in self._tried[-1] and not self._is_refinement_active(i): return i + return None def count(self, index: int) -> int: - """ - Count the number of times the index is present in the trace. - unfinished partial scenarios are excluded. - """ - return self.c_pool[index] + """Count the number of times the index is present in the trace. + unfinished partial scenarios are excluded.""" + return self._trace.count(str(index)) + self._trace.count(str(f"{index}.0")) def highest_part(self, index: int) -> int: - """ - Given the current trace and an index, returns the highest part number of an ongoing - refinement for the related scenario. Returns 0 when there is no refinement active. - """ - for i in range(1, len(self.id_trace) + 1): - if self.id_trace[-i] == f'{index}': + """Given the current trace and an index, returns the highest part number of an ongoing + refinement for the related scenario. Returns 0 when there is no refinement active.""" + for i in range(1, len(self._trace)+1): + if self._trace[-i] == f'{index}': return 0 - if self.id_trace[-i].startswith(f'{index}.'): - return int(self.id_trace[-i].split('.')[1]) + + if self._trace[-i].startswith(f'{index}.'): + return int(self._trace[-i].split('.')[1]) + return 0 - def is_refinement_active(self, index: int | None = None) -> bool: - """ - When called with an index, returns True if that scenario is currently being refined - When index is ommitted, return True if any refinement is active - """ - if index is None: - return self._open_refinements != [] - else: - return self.highest_part(index) != 0 + def _is_refinement_active(self, index: int) -> bool: + return self.highest_part(index) != 0 - def get_remainder(self, index: int) -> Scenario | None: - """ - When pushing a partial scenario, the remainder can be passed along for safe keeping. - This method retrieves the remainder for the last part that was pushed. - """ - last_part = self.highest_part(index) - index = -self.id_trace[::-1].index(f'{index}.{last_part}') - 1 - return self._snapshots[index].remainder + def find_scenarios_with_active_refinement(self) -> list[str | Scenario]: + scenarios = [] + for i in self._open_refinements: + index = -self._trace[::-1].index(f'{i}.1')-1 + scenarios.append(self._snapshots[index].scenario) + + return scenarios def reject_scenario(self, i_scenario: int): """Trying a scenario excludes it from further cadidacy on this level""" self._tried[-1].append(i_scenario) - def confirm_full_scenario(self, index: int, scenario: Scenario, model: ModelSpace): - c_drought = 0 if self.c_pool[index] == 0 else self.coverage_drought + 1 - self.c_pool[index] += 1 - if self.is_refinement_active(index): + def confirm_full_scenario(self, index: int, scenario: str, model: dict[str, int]): + if not self._c_pool[index]: + self._c_pool[index] = True + c_drought = 0 + else: + c_drought = self.coverage_drought+1 + + if self._is_refinement_active(index): id = f"{index}.0" self._open_refinements.pop() else: id = str(index) self._tried[-1].append(index) self._tried.append([]) - self._snapshots.append(TraceSnapShot(id, scenario, model, drought=c_drought)) - - def push_partial_scenario(self, index: int, scenario: Scenario, model: ModelSpace, remainder=None): - if self.is_refinement_active(index): - id = f"{index}.{self.highest_part(index) + 1}" + + self._trace.append(id) + self._snapshots.append(TraceSnapShot(id, scenario, model, c_drought)) + + def push_partial_scenario(self, index: int, scenario: str, model: dict[str, int]): + if self._is_refinement_active(index): + id = f"{index}.{self.highest_part(index)+1}" + else: id = f"{index}.1" self._tried[-1].append(index) + self._tried.append([]) self._open_refinements.append(index) - self._tried.append([]) - self._snapshots.append(TraceSnapShot(id, scenario, model, remainder, self.coverage_drought)) + self._trace.append(id) + self._snapshots.append(TraceSnapShot( + id, scenario, model, self.coverage_drought)) def can_rewind(self) -> bool: - return len(self._snapshots) > 0 + return len(self._trace) > 0 def rewind(self) -> TraceSnapShot | None: - id = self._snapshots[-1].id + id = self._trace.pop() index = int(id.split('.')[0]) - self._snapshots.pop() if id.endswith('.0'): - self.c_pool[index] -= 1 + self._snapshots.pop() self._open_refinements.append(index) - while self._snapshots[-1].id != f"{index}.1": + while self._trace[-1] != f"{index}.1": self.rewind() return self.rewind() - self._tried.pop() - if '.' not in id: - self.c_pool[index] -= 1 - if id.endswith('.1'): - self._open_refinements.pop() + self._snapshots.pop() + if '.' not in id or id.endswith('.1'): + if self.count(index) == 0: + self._c_pool[index] = False + self._tried.pop() + + if id.endswith('.1'): + self._open_refinements.pop() + return self._snapshots[-1] if self._snapshots else None def __iter__(self): diff --git a/robotmbt/version.py b/robotmbt/version.py index a5bdcd62..58e1a598 100644 --- a/robotmbt/version.py +++ b/robotmbt/version.py @@ -1 +1 @@ -VERSION: str = '0.10.0' +VERSION: str = '0.9.0' From 1fb004da3b0cb637f595e14cb9d296e2ced4bda8 Mon Sep 17 00:00:00 2001 From: tychodub <93142605+tychodub@users.noreply.github.com> Date: Wed, 8 Oct 2025 10:40:10 +0200 Subject: [PATCH 056/131] fixed typing of get_trace in tracestate.py (#5) --- robotmbt/tracestate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/robotmbt/tracestate.py b/robotmbt/tracestate.py index 59b0151c..9e8dbb5f 100644 --- a/robotmbt/tracestate.py +++ b/robotmbt/tracestate.py @@ -73,7 +73,7 @@ def coverage_drought(self) -> int: """Number of scenarios since last new coverage""" return self._snapshots[-1].coverage_drought if self._snapshots else 0 - def get_trace(self) -> list[str]: + def get_trace(self) -> list[str | Scenario]: return [snap.scenario for snap in self._snapshots] def next_candidate(self, retry: bool = False) -> int | None: From 61f368387c61ed08b1e430b03bf1716f19d44dee Mon Sep 17 00:00:00 2001 From: Thomas Kas Date: Thu, 9 Oct 2025 11:52:59 +0200 Subject: [PATCH 057/131] Jetbrains gitignore (#7) --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 727c718a..bb2d04ff 100644 --- a/.gitignore +++ b/.gitignore @@ -152,4 +152,4 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ From 1786ae5adcf7a4a5c9eb1d1e35d3a5f374a90218 Mon Sep 17 00:00:00 2001 From: tychodub <93142605+tychodub@users.noreply.github.com> Date: Thu, 9 Oct 2025 12:16:57 +0200 Subject: [PATCH 058/131] Create python-app.yml, for Github actions * Create python-app.yml, for Github actions Create python-app.yml, runs utests when pushing/merging pull-request into main * test run actual python file * use python latest version * use python version 3.13 * fix comment * actually return exit code that robot returns --------- Co-authored-by: Douwe Osinga --- .github/workflows/python-app.yml | 34 ++++++++++++++++++++++++++++++++ run_tests.py | 12 +++++------ 2 files changed, 40 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/python-app.yml diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml new file mode 100644 index 00000000..fa29c16c --- /dev/null +++ b/.github/workflows/python-app.yml @@ -0,0 +1,34 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +name: Python application + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: "3.13" + - name: Install dependencies + run: | + python -m pip install --upgrade pip # upgrade pip to latest version + pip install . # install PyProject.toml dependencies + - name: Test with pytest + run: | + python run_tests.py + #pytest # test unit tests only + diff --git a/run_tests.py b/run_tests.py index 5387e5c8..3289d173 100644 --- a/run_tests.py +++ b/run_tests.py @@ -27,8 +27,8 @@ if utest: utestrun = unittest.main(module=None, - argv=[__file__, 'discover', os.path.join(THIS_DIR, 'utest')], - exit=False) + argv=[__file__, 'discover', os.path.join(THIS_DIR, 'utest')], + exit=False) if not utestrun.result.wasSuccessful(): sys.exit(1) @@ -40,10 +40,10 @@ # Adding the robotframeworkMBT folder to the python path forces the development # version to be used instead of the one installed on your system. You will also # need to add this path to your IDE options when running from there. - exit_code: int = robot.run_cli(['--outputdir', OUTPUT_ROOT, - '--pythonpath', THIS_DIR] - + sys.argv[1:], exit=False) + exit_code :int = robot.run_cli(['--outputdir', OUTPUT_ROOT, # type: ignore + '--pythonpath', THIS_DIR] + + sys.argv[1:], exit=False) if utest: print(f"Also ran {utestrun.result.testsRun} unit tests") - + sys.exit(exit_code) From c17d889fc7d559c195743cf8670e5e5252a0cd41 Mon Sep 17 00:00:00 2001 From: D Osinga <46380170+osingaatje@users.noreply.github.com> Date: Mon, 20 Oct 2025 22:04:57 +0200 Subject: [PATCH 059/131] Visual Paradigm Architectural Designs (#8) * added ignore for VPP * added initial VPP * vpp * modified gitignore * added new diagram - extension plan * added initial renders * .vpp commit * added improved event-based design * changed design to accomodate different graph repr. better * vpp --- .gitignore | 4 + DESIGN/Render/2025-09-25/RobotMBT-current.svg | 1322 +++++++++++ .../2025-09-25/RobotMBT-extensionplan.svg | 2037 +++++++++++++++++ .../2025-10-15/RobotMBT-extensionplan.svg | 1474 ++++++++++++ DESIGN/VPP/RobotMBT.vpp | Bin 0 -> 1179648 bytes 5 files changed, 4837 insertions(+) create mode 100644 DESIGN/Render/2025-09-25/RobotMBT-current.svg create mode 100644 DESIGN/Render/2025-09-25/RobotMBT-extensionplan.svg create mode 100644 DESIGN/Render/2025-10-15/RobotMBT-extensionplan.svg create mode 100644 DESIGN/VPP/RobotMBT.vpp diff --git a/.gitignore b/.gitignore index bb2d04ff..2ff75fba 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ __pycache__/ # C extensions *.so +# VPP (Visual Paradigm) files +*.vpp.bak_* +*.vpp.lck + # Distribution / packaging .Python build/ diff --git a/DESIGN/Render/2025-09-25/RobotMBT-current.svg b/DESIGN/Render/2025-09-25/RobotMBT-current.svg new file mode 100644 index 00000000..7e9e5c83 --- /dev/null +++ b/DESIGN/Render/2025-09-25/RobotMBT-current.svg @@ -0,0 +1,1322 @@ + + ++ref_id : str+standard_attrs : list[?]+properties : dict[?,?]+values : dict[?,?]+scenario_variables : list[?]+add_prop(name : str)+del_prop(name : str)+new_scenario_scope()+end_scenario_scope()+process_expression(expr : str) : objectModelSpacethe current +ROBOT_LIBRARY_LISTENER : self+current_suite+robot_suite+processor_lib_name : str+__processor_lib+__processor_method+processor_options : dict[?,?]+__generateRobotSuite()+__init__(processor : str, processor_lib : Library|None)SuiteReplacer+EMBEDDED+POSITIONAL+VAR_POS+NAMED+FREE_NAMED+arg() : str+value() : any+modified() : bool+codestring() : strStepArgumentStepArguments<<primitive>>list+substitutions : dict [any,Constraint]+solutions : dict [any,any]+substitute(example_value, constraint)+solve() : dict [any,any]SubstitutionMap+optionset : list [str]+removed_stack : list [str]+__init__(constraint : list [str])ConstraintPlaceholder+name : str+filename : str+parent : ?+suites : list[?]+scenarios : list [Scenario]+setup : Step|None+teardown : Step|NoneSuite+name : str+parent : Scenario|None+setup : Step|None+teardown : Step|None+steps : list [Step]+src_id : ?|None+data_choices : dict[?,?]Scenario+org_step : str+org_pn_args : args+parent : Scenario+assign : ?+gherkin_kw [given,when,then]+signature+args : StepArguments+detached : bool+model_info : dict[?,?]Step-__init__(outer : Scope)-__getattr__(attr : str)-__setattr__(attr : str, value : obj)-__iter__()-__bool__()RecursiveScopeDefines -tracestate : TraceState+flatten(in_suite : Suite)+process_test_suite(in_suite : Suite, seed : str)-_try_to_reach_full_coverage(allow_duplicate_scenarios : bool)-_try_to_fit_in_scenario(index, candidate, retry_flag)SuiteProcessors-_c_pool : list [bool]-_tried : list [list[?]-_trace : list [str]-_snapshots : list[?]-_open_refinements : list[?]+model() : ?+tried() : tuple[?]+coverage_reached() : bool+coverage_drought() : int+next_candidate(retry : bool)+count(index : str)+highest_part(index : str)+find_scenarios_with_active_refinement() : list[?]TraceState+id : ?+inserted_scenario : ?+model_state : ?+drought : intTraceSnapShot1*1*1*1*suiteprocessors.pysuitedata.pymodelspace.pysteparguments.pysubstitutionmap.pysuitereplacer.pytracestate.py<<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>>"substitutions"Powered ByVisual Paradigm Community Edition diff --git a/DESIGN/Render/2025-09-25/RobotMBT-extensionplan.svg b/DESIGN/Render/2025-09-25/RobotMBT-extensionplan.svg new file mode 100644 index 00000000..1c4ee17e --- /dev/null +++ b/DESIGN/Render/2025-09-25/RobotMBT-extensionplan.svg @@ -0,0 +1,2037 @@ + + ++ref_id : str+standard_attrs : list[?]+properties : dict[?,?]+values : dict[?,?]+scenario_variables : list[?]+add_prop(name : str)+del_prop(name : str)+new_scenario_scope()+end_scenario_scope()+process_expression(expr : str) : objectModelSpacethe current +ROBOT_LIBRARY_LISTENER : self+current_suite+robot_suite+processor_lib_name : str+__processor_lib+__processor_method+processor_options : dict[?,?]+__generateRobotSuite()+__init__(processor : str, processor_lib : Library|None)SuiteReplacer+EMBEDDED+POSITIONAL+VAR_POS+NAMED+FREE_NAMED+arg() : str+value() : any+modified() : bool+codestring() : strStepArgumentStepArguments<<primitive>>list+substitutions : dict [any,Constraint]+solutions : dict [any,any]+substitute(example_value, constraint)+solve() : dict [any,any]SubstitutionMap+optionset : list [str]+removed_stack : list [str]+__init__(constraint : list [str])ConstraintPlaceholder+name : str+filename : str+parent : ?+suites : list[?]+scenarios : list [Scenario]+setup : Step|None+teardown : Step|NoneSuite+name : str+parent : Scenario|None+setup : Step|None+teardown : Step|None+steps : list [Step]+src_id : ?|None+data_choices : dict[?,?]Scenario+org_step : str+org_pn_args : args+parent : Scenario+assign : ?+gherkin_kw [given,when,then]+signature+args : StepArguments+detached : bool+model_info : dict[?,?]Step-__init__(outer : Scope)-__getattr__(attr : str)-__setattr__(attr : str, value : obj)-__iter__()-__bool__()RecursiveScopeDefines -tracestate : TraceState+flatten(in_suite : Suite)+process_test_suite(in_suite : Suite, seed : str)-_try_to_reach_full_coverage(allow_duplicate_scenarios : bool)-_try_to_fit_in_scenario(index, candidate, retry_flag)-_report_tracestate_wrapup()SuiteProcessors-_c_pool : list [bool]-_tried : list [list[?]-_trace : list [str]-_snapshots : list[?]-_open_refinements : list[?]+model() : ?+tried() : tuple[?]+coverage_reached() : bool+coverage_drought() : int+next_candidate(retry : bool)+count(index : str)+highest_part(index : str)+find_scenarios_with_active_refinement() : list[?]TraceState+id : ?+inserted_scenario : ?+model_state : ?+drought : intTraceSnapShot+scenarios : dict [str,Scenario]+trace : TraceState+todo other stuffVisualisationInfo+generate_visualisation(flags)Visualiser+generate_interactive(TraceNetwork) : void+generate_html(TraceNetwork) : strNetworkVisualiserGUIButtonGUIProgressBar+pre_process_trace(TraceState) : TraceNetworkVisualPreProcessor+scenarios : dict [str,Scenario]+scenarioOrder : dict [Scenario,list Scenario]TraceNetwork...+run_tests(args)RunTestsRobot+get_trace_state() : VisualisationInfoTracePreparerdependencies to all the other 1*1*1*1*modelsvisualiser-processingvisualisersgui-componentssuiteprocessors.pysuitedata.pymodelspace.pysteparguments.pysubstitutionmap.pysuitereplacer.pytracestate.py<<use>><<use>>produces ><<use>>Hooks into the methods of Robot<<use>><<use>>uses < to generate GUIuses > to generatedata structures<<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>>"substitutions"Powered ByVisual Paradigm Community Edition diff --git a/DESIGN/Render/2025-10-15/RobotMBT-extensionplan.svg b/DESIGN/Render/2025-10-15/RobotMBT-extensionplan.svg new file mode 100644 index 00000000..c74ab783 --- /dev/null +++ b/DESIGN/Render/2025-10-15/RobotMBT-extensionplan.svg @@ -0,0 +1,1474 @@ + + ++ref_id : str+standard_attrs : list[?]+properties : dict[?,?]+values : dict[?,?]+scenario_variables : list[?]+add_prop(name : str)+del_prop(name : str)+new_scenario_scope()+end_scenario_scope()+process_expression(expr : str) : objectModelSpacethe current +name : str+filename : str+parent : ?+suites : list[?]+scenarios : list [Scenario]+setup : Step|None+teardown : Step|NoneSuite+name : str+parent : Scenario|None+setup : Step|None+teardown : Step|None+steps : list [Step]+src_id : ?|None+data_choices : dict[?,?]Scenario+org_step : str+org_pn_args : args+parent : Scenario+assign : ?+gherkin_kw [given,when,then]+signature+args : StepArguments+detached : bool+model_info : dict[?,?]Step-__init__(outer : Scope)-__getattr__(attr : str)-__setattr__(attr : str, value : obj)-__iter__()-__bool__()RecursiveScopeDefines -tracestate : TraceState+flatten(in_suite : Suite)+process_test_suite(in_suite : Suite, seed : str)-_try_to_reach_full_coverage(allow_duplicate_scenarios : bool)-_try_to_fit_in_scenario(index, candidate, retry_flag)-_report_tracestate_wrapup()SuiteProcessors-_c_pool : list [bool]-_tried : list [list[?]-_trace : list [str]-_snapshots : list[?]-_open_refinements : list[?]+model() : ?+tried() : tuple[?]+coverage_reached() : bool+coverage_drought() : int+next_candidate(retry : bool)+count(index : str)+highest_part(index : str)+find_scenarios_with_active_refinement() : list[?]TraceState+id : ?+inserted_scenario : ?+model_state : ?+drought : intTraceSnapShot+scenarios : dict [str,Scenario]+trace : TraceState+modelspace : ModelSpaceTraceInfo+update_visualisation(Suite, TraceInfo)Visualiser+generate_interactive(GraphInstance) : void+generate_html(GraphInstance) : strNetworkVisualiserGUIButtonGUIProgressBar+pre_process_trace(TraceState) : IGraphTraceTransfomer+nodes+edgesIGraph...+run_tests(args)RunTestsRobot+traceUpdate(Suite, TraceState, ModelSpace)TraceGathererScenarioGraphStateGraphNetworkX 1*1*gui-componentssuiteprocessors.pysuitedata.pymodelspace.pytracestate.pyvisualisersvisualiser-processingmodels1. constructs2. notifies ^calls ^ uponupdate<<use>><<use>><<use>><<use>><<use>><<use>>uses < to generate GUIuses > to generatedata structures<<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>>Powered ByVisual Paradigm Community Edition diff --git a/DESIGN/VPP/RobotMBT.vpp b/DESIGN/VPP/RobotMBT.vpp new file mode 100644 index 0000000000000000000000000000000000000000..735e8c10bce86c2129fcf0623e00692d8d732c29 GIT binary patch literal 1179648 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⧁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

JWG9mpH` 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%0AI@vc~v-k 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 z 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

G+Hkxvt*OWUYhO3CvV3+UNQ^+g?|;_ zXRdFXgz9ToKBnD$gmd@Q+_Ur#c}2foXA*ZTStwu9ULe=Nu;`$=Ii2L&B`<02olkyh z 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൤W}yj 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 BjoAPI literal 0 HcmV?d00001 From 2ba3de25aeb7e2667c319f191272cb87cb61e895 Mon Sep 17 00:00:00 2001 From: D Osinga <46380170+osingaatje@users.noreply.github.com> Date: Wed, 22 Oct 2025 11:46:59 +0200 Subject: [PATCH 060/131] Visualisation architecture (#9) * Implement basic scenario graph architecture (without actual visualisation) * Also output graph if coverage could not be reached * from visualiser.graph to networkx graph * added optional dependencies for visualization feature * rework scenariograph to use networkx * Update python-app.yml to install optional dependencies * moved documentation according to python * remove static variables --------- Co-authored-by: Thomas Co-authored-by: jonathan <148167.jw@gmail.com> Co-authored-by: tychodub Co-authored-by: Jonathan <61787386+JWillegers@users.noreply.github.com> --- .github/workflows/python-app.yml | 2 +- pyproject.toml | 3 + robotmbt/suiteprocessors.py | 12 +++ robotmbt/suitereplacer.py | 10 ++- robotmbt/visualise/visualiser.py | 147 +++++++++++++++++++++++++++++++ 5 files changed, 170 insertions(+), 4 deletions(-) create mode 100644 robotmbt/visualise/visualiser.py diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index fa29c16c..a61f91c8 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -26,7 +26,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip # upgrade pip to latest version - pip install . # install PyProject.toml dependencies + pip install ".[visualization]" # install PyProject.toml dependencies (including optional dependencies) - name: Test with pytest run: | python run_tests.py diff --git a/pyproject.toml b/pyproject.toml index 25e5cd49..617b906f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,3 +28,6 @@ packages = ["robotmbt"] [project.urls] Homepage = "https://github.com/JFoederer/robotframeworkMBT" + +[project.optional-dependencies] +visualization = ["scipy","numpy","networkx","matplotlib"] diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index 0fb992e0..e076d0a0 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -41,9 +41,13 @@ from .suitedata import Suite, Scenario, Step from .tracestate import TraceState, TraceSnapShot from .steparguments import StepArgument, StepArguments +from .visualise.visualiser import Visualiser, TraceInfo, ScenarioInfo, ScenarioGraph class SuiteProcessors: + def __init__(self): + self.visualiser = Visualiser() + def echo(self, in_suite): return in_suite @@ -93,6 +97,8 @@ def process_test_suite(self, in_suite: Suite, *, seed: any = 'new') -> Suite: self._init_randomiser(seed) random.shuffle(self.scenarios) + self.visualiser = Visualiser() + # a short trace without the need for repeating scenarios is preferred self._try_to_reach_full_coverage(allow_duplicate_scenarios=False) @@ -101,10 +107,15 @@ def process_test_suite(self, in_suite: Suite, *, seed: any = 'new') -> Suite: "Direct trace not available. Allowing repetition of scenarios") self._try_to_reach_full_coverage(allow_duplicate_scenarios=True) if not self.tracestate.coverage_reached(): + logger.write(self.visualiser.generate_html(), html=True) raise Exception("Unable to compose a consistent suite") self.out_suite.scenarios = self.tracestate.get_trace() self._report_tracestate_wrapup() + + self.visualiser.set_start(ScenarioInfo(self.tracestate.get_trace()[0])) + self.visualiser.set_end(ScenarioInfo(self.tracestate.get_trace()[-1])) + return self.out_suite def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool): @@ -140,6 +151,7 @@ def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool): self._report_tracestate_to_user() logger.debug( f"last state:\n{self.active_model.get_status_text()}") + self.visualiser.update_visualisation(TraceInfo(self.tracestate, self.active_model)) def __last_candidate_changed_nothing(self) -> bool: if len(self.tracestate) < 2: diff --git a/robotmbt/suitereplacer.py b/robotmbt/suitereplacer.py index 645a93a8..ea0cb893 100644 --- a/robotmbt/suitereplacer.py +++ b/robotmbt/suitereplacer.py @@ -90,17 +90,21 @@ def treat_model_based(self, **kwargs): logger.info( f"Analysing Robot test suite '{self.robot_suite.name}' for model-based execution.") - + self.update_model_based_options(**kwargs) master_suite = self.__process_robot_suite( self.robot_suite, parent=None) - + modelbased_suite = self.processor_method( master_suite, **self.processor_options) - + self.__clearTestSuite(self.robot_suite) self.__generateRobotSuite(modelbased_suite, self.robot_suite) + # TODO: add flag using kwargs to disable this + if isinstance(self.processor_lib, SuiteProcessors): + logger.write(self.processor_lib.visualiser.generate_html(), html=True) + @keyword("Set model-based options") def set_model_based_options(self, **kwargs): """ diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py new file mode 100644 index 00000000..f527789f --- /dev/null +++ b/robotmbt/visualise/visualiser.py @@ -0,0 +1,147 @@ +from robotmbt.modelspace import ModelSpace +from robotmbt.suitedata import Scenario +from robotmbt.tracestate import TraceState +import networkx as nx +import matplotlib.pyplot as plt +#numpy +#scipy + + +class ScenarioInfo: + """ + This contains all information we need from scenarios, abstracting away from the actual Scenario class: + - name + - src_id + """ + + def __init__(self, scenario: Scenario | str): + if isinstance(scenario, Scenario): + self.name = scenario.name + self.src_id = scenario.src_id + else: + self.name = scenario + self.src_id = None + + def __str__(self): + return f"Scen{self.src_id}: {self.name}" + + +class TraceInfo: + """ + This contains all information we need at any given step in trace exploration: + - trace: the strung together scenarios up until this point + - state: the model space + """ + + + def __init__(self, trace: TraceState, state: ModelSpace): + self.trace = [ScenarioInfo(s) for s in trace.get_trace()] + # TODO: actually use state + self.state = state + + +class ScenarioGraph: + """ + The scenario graph is the most basic representation of trace exploration. + It represents scenarios as nodes, and the trace as edges. + """ + def __init__(self): + # We use simplified IDs for nodes, and store the actual scenario info here + self.ids: dict[str, ScenarioInfo] = {} + + # The networkx graph is a directional graph + self.networkx = nx.DiGraph() + + # Stores the position (x, y) of the nodes + self.pos = {} + + # List of nodes which positions cannot be changed + self.fixed = [] + + # add the start node + self.networkx.add_node('start') + + def update_visualisation(self, info: TraceInfo): + """ + Update the visualisation with new trace information from another exploration step. + This will add nodes for all new scenarios in the provided trace, as well as edges for all pairs in the provided trace. + """ + for i in range(0, len(info.trace) - 1): + from_node = self.__get_or_create_id(info.trace[i]) + to_node = self.__get_or_create_id(info.trace[i + 1]) + + if from_node not in self.networkx.nodes: + self.networkx.add_node(from_node, text=self.ids[from_node].name) + if to_node not in self.networkx.nodes: + self.networkx.add_node(to_node, text=self.ids[to_node].name) + + if (from_node, to_node) not in self.networkx.edges: + self.networkx.add_edge(from_node, to_node) + + def __get_or_create_id(self, scenario: ScenarioInfo) -> str: + """ + Get the ID for a scenario that has been added before, or create and store a new one. + """ + for i in self.ids.keys(): + # TODO: decide how to deal with repeating scenarios, this merges repeated scenarios into a single scenario + if self.ids[i].src_id == scenario.src_id: + return i + + new_id = f"node{len(self.ids)}" + self.ids[new_id] = scenario + return new_id + + def set_starting_node(self, scenario: ScenarioInfo): + """ + Update the starting node. + """ + node = self.__get_or_create_id(scenario) + self.networkx.add_edge('start', node) + + def set_ending_node(self, scenario: ScenarioInfo): + """ + Update the end node. + """ + node = self.__get_or_create_id(scenario) + self.pos[node] = (len(self.networkx.nodes), 0) + self.fixed.append(node) + + def calculate_pos(self): + """ + Calculate the position (x, y) for all nodes in self.networkx + """ + self.pos['start'] = (0, len(self.networkx.nodes)) + self.fixed.append('start') + if not self.fixed: + self.pos = nx.spring_layout(self.networkx, seed=42) + else: + self.pos = nx.spring_layout(self.networkx, pos=self.pos, fixed=self.fixed, seed=42, method='energy', gravity=0.25) + + +class Visualiser: + """ + The Visualiser class bridges the different concerns to provide a simple interface through which the graph can be updated, and retrieved in HTML format. + """ + def __init__(self): + self.graph = ScenarioGraph() + + def update_visualisation(self, info: TraceInfo): + self.graph.update_visualisation(info) + + def set_start(self, scenario: ScenarioInfo): + self.graph.set_starting_node(scenario) + + def set_end(self, scenario: ScenarioInfo): + self.graph.set_ending_node(scenario) + + def generate_graph(self): + # temporary code for visualisation + self.graph.calculate_pos() + nx.draw(self.graph.networkx, pos=self.graph.pos, with_labels=True, node_color="lightblue", node_size=600) + plt.show() + + # TODO: use a graph library to actually create a graph + def generate_html(self) -> str: + self.generate_graph() + return f"" + # return f"

nodes: {self.graph.nodes}\nedges: {self.graph.edges}\nstart: {self.graph.start}\nend: {self.graph.end}\nids: {[f"{name}: {str(val)}" for (name, val) in self.graph.ids.items()]}

" From ea7f59a61d7689e60a22e1775f2fd9194922d228 Mon Sep 17 00:00:00 2001 From: Jonathan <61787386+JWillegers@users.noreply.github.com> Date: Wed, 22 Oct 2025 12:11:48 +0200 Subject: [PATCH 061/131] Restructure + disable check on main (#10) * Seperated ScenarioInfo, TraceInfo, and ScnearioGraph into seperate file according to architecture * Don't run automated testing on main * fixing imports --- .github/workflows/python-app.yml | 4 +- robotmbt/suiteprocessors.py | 3 +- robotmbt/visualise/models.py | 114 ++++++++++++++++++++++++++++++ robotmbt/visualise/visualiser.py | 115 +------------------------------ 4 files changed, 119 insertions(+), 117 deletions(-) create mode 100644 robotmbt/visualise/models.py diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index a61f91c8..8ee45428 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -4,8 +4,8 @@ name: Python application on: - push: - branches: [ "main" ] + # push: + # branches: [ "main" ] pull_request: branches: [ "main" ] diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index e076d0a0..9ba73a3a 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -41,7 +41,8 @@ from .suitedata import Suite, Scenario, Step from .tracestate import TraceState, TraceSnapShot from .steparguments import StepArgument, StepArguments -from .visualise.visualiser import Visualiser, TraceInfo, ScenarioInfo, ScenarioGraph +from .visualise.visualiser import Visualiser +from .visualise.models import TraceInfo, ScenarioInfo class SuiteProcessors: diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py new file mode 100644 index 00000000..b20b971c --- /dev/null +++ b/robotmbt/visualise/models.py @@ -0,0 +1,114 @@ +from robotmbt.modelspace import ModelSpace +from robotmbt.suitedata import Scenario +from robotmbt.tracestate import TraceState +import networkx as nx + +class ScenarioInfo: + """ + This contains all information we need from scenarios, abstracting away from the actual Scenario class: + - name + - src_id + """ + + def __init__(self, scenario: Scenario | str): + if isinstance(scenario, Scenario): + self.name = scenario.name + self.src_id = scenario.src_id + else: + self.name = scenario + self.src_id = None + + def __str__(self): + return f"Scen{self.src_id}: {self.name}" + + +class TraceInfo: + """ + This contains all information we need at any given step in trace exploration: + - trace: the strung together scenarios up until this point + - state: the model space + """ + + + def __init__(self, trace: TraceState, state: ModelSpace): + self.trace = [ScenarioInfo(s) for s in trace.get_trace()] + # TODO: actually use state + self.state = state + + +class ScenarioGraph: + """ + The scenario graph is the most basic representation of trace exploration. + It represents scenarios as nodes, and the trace as edges. + """ + def __init__(self): + # We use simplified IDs for nodes, and store the actual scenario info here + self.ids: dict[str, ScenarioInfo] = {} + + # The networkx graph is a directional graph + self.networkx = nx.DiGraph() + + # Stores the position (x, y) of the nodes + self.pos = {} + + # List of nodes which positions cannot be changed + self.fixed = [] + + # add the start node + self.networkx.add_node('start') + + def update_visualisation(self, info: TraceInfo): + """ + Update the visualisation with new trace information from another exploration step. + This will add nodes for all new scenarios in the provided trace, as well as edges for all pairs in the provided trace. + """ + for i in range(0, len(info.trace) - 1): + from_node = self.__get_or_create_id(info.trace[i]) + to_node = self.__get_or_create_id(info.trace[i + 1]) + + if from_node not in self.networkx.nodes: + self.networkx.add_node(from_node, text=self.ids[from_node].name) + if to_node not in self.networkx.nodes: + self.networkx.add_node(to_node, text=self.ids[to_node].name) + + if (from_node, to_node) not in self.networkx.edges: + self.networkx.add_edge(from_node, to_node) + + def __get_or_create_id(self, scenario: ScenarioInfo) -> str: + """ + Get the ID for a scenario that has been added before, or create and store a new one. + """ + for i in self.ids.keys(): + # TODO: decide how to deal with repeating scenarios, this merges repeated scenarios into a single scenario + if self.ids[i].src_id == scenario.src_id: + return i + + new_id = f"node{len(self.ids)}" + self.ids[new_id] = scenario + return new_id + + def set_starting_node(self, scenario: ScenarioInfo): + """ + Update the starting node. + """ + node = self.__get_or_create_id(scenario) + self.networkx.add_edge('start', node) + + def set_ending_node(self, scenario: ScenarioInfo): + """ + Update the end node. + """ + node = self.__get_or_create_id(scenario) + self.pos[node] = (len(self.networkx.nodes), 0) + self.fixed.append(node) + + def calculate_pos(self): + """ + Calculate the position (x, y) for all nodes in self.networkx + """ + self.pos['start'] = (0, len(self.networkx.nodes)) + self.fixed.append('start') + if not self.fixed: + self.pos = nx.spring_layout(self.networkx, seed=42) + else: + self.pos = nx.spring_layout(self.networkx, pos=self.pos, fixed=self.fixed, seed=42, method='energy', gravity=0.25) \ No newline at end of file diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py index f527789f..ee51d7b5 100644 --- a/robotmbt/visualise/visualiser.py +++ b/robotmbt/visualise/visualiser.py @@ -1,123 +1,10 @@ -from robotmbt.modelspace import ModelSpace -from robotmbt.suitedata import Scenario -from robotmbt.tracestate import TraceState +from .models import ScenarioGraph, TraceInfo, ScenarioInfo import networkx as nx import matplotlib.pyplot as plt #numpy #scipy -class ScenarioInfo: - """ - This contains all information we need from scenarios, abstracting away from the actual Scenario class: - - name - - src_id - """ - - def __init__(self, scenario: Scenario | str): - if isinstance(scenario, Scenario): - self.name = scenario.name - self.src_id = scenario.src_id - else: - self.name = scenario - self.src_id = None - - def __str__(self): - return f"Scen{self.src_id}: {self.name}" - - -class TraceInfo: - """ - This contains all information we need at any given step in trace exploration: - - trace: the strung together scenarios up until this point - - state: the model space - """ - - - def __init__(self, trace: TraceState, state: ModelSpace): - self.trace = [ScenarioInfo(s) for s in trace.get_trace()] - # TODO: actually use state - self.state = state - - -class ScenarioGraph: - """ - The scenario graph is the most basic representation of trace exploration. - It represents scenarios as nodes, and the trace as edges. - """ - def __init__(self): - # We use simplified IDs for nodes, and store the actual scenario info here - self.ids: dict[str, ScenarioInfo] = {} - - # The networkx graph is a directional graph - self.networkx = nx.DiGraph() - - # Stores the position (x, y) of the nodes - self.pos = {} - - # List of nodes which positions cannot be changed - self.fixed = [] - - # add the start node - self.networkx.add_node('start') - - def update_visualisation(self, info: TraceInfo): - """ - Update the visualisation with new trace information from another exploration step. - This will add nodes for all new scenarios in the provided trace, as well as edges for all pairs in the provided trace. - """ - for i in range(0, len(info.trace) - 1): - from_node = self.__get_or_create_id(info.trace[i]) - to_node = self.__get_or_create_id(info.trace[i + 1]) - - if from_node not in self.networkx.nodes: - self.networkx.add_node(from_node, text=self.ids[from_node].name) - if to_node not in self.networkx.nodes: - self.networkx.add_node(to_node, text=self.ids[to_node].name) - - if (from_node, to_node) not in self.networkx.edges: - self.networkx.add_edge(from_node, to_node) - - def __get_or_create_id(self, scenario: ScenarioInfo) -> str: - """ - Get the ID for a scenario that has been added before, or create and store a new one. - """ - for i in self.ids.keys(): - # TODO: decide how to deal with repeating scenarios, this merges repeated scenarios into a single scenario - if self.ids[i].src_id == scenario.src_id: - return i - - new_id = f"node{len(self.ids)}" - self.ids[new_id] = scenario - return new_id - - def set_starting_node(self, scenario: ScenarioInfo): - """ - Update the starting node. - """ - node = self.__get_or_create_id(scenario) - self.networkx.add_edge('start', node) - - def set_ending_node(self, scenario: ScenarioInfo): - """ - Update the end node. - """ - node = self.__get_or_create_id(scenario) - self.pos[node] = (len(self.networkx.nodes), 0) - self.fixed.append(node) - - def calculate_pos(self): - """ - Calculate the position (x, y) for all nodes in self.networkx - """ - self.pos['start'] = (0, len(self.networkx.nodes)) - self.fixed.append('start') - if not self.fixed: - self.pos = nx.spring_layout(self.networkx, seed=42) - else: - self.pos = nx.spring_layout(self.networkx, pos=self.pos, fixed=self.fixed, seed=42, method='energy', gravity=0.25) - - class Visualiser: """ The Visualiser class bridges the different concerns to provide a simple interface through which the graph can be updated, and retrieved in HTML format. From 53d6a7f52e4b12f48f03849644b47562940fa1c3 Mon Sep 17 00:00:00 2001 From: tychodub <93142605+tychodub@users.noreply.github.com> Date: Wed, 29 Oct 2025 10:28:26 +0100 Subject: [PATCH 062/131] Rollback self typing branch (#11) * commented Self typing annotations * changed Github actions python-version to 3.10 * change from spring layout to planar layout --------- Co-authored-by: jonathan <148167.jw@gmail.com> --- .github/workflows/python-app.yml | 2 +- robotmbt/modelspace.py | 4 ++-- robotmbt/steparguments.py | 4 ++-- robotmbt/substitutionmap.py | 7 ++++--- robotmbt/suitedata.py | 10 ++++++---- robotmbt/suitereplacer.py | 31 +++++++++++++++---------------- robotmbt/visualise/models.py | 18 +++++++++--------- 7 files changed, 39 insertions(+), 37 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 8ee45428..7fee3b4e 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -22,7 +22,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v3 with: - python-version: "3.13" + python-version: "3.10" - name: Install dependencies run: | python -m pip install --upgrade pip # upgrade pip to latest version diff --git a/robotmbt/modelspace.py b/robotmbt/modelspace.py index 8f5c3b80..f7e8489f 100644 --- a/robotmbt/modelspace.py +++ b/robotmbt/modelspace.py @@ -31,7 +31,6 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import copy -from typing import Self from .steparguments import StepArguments @@ -54,7 +53,8 @@ def __init__(self, reference_id=None): def __repr__(self): return self.ref_id if self.ref_id else super().__repr__() - def copy(self) -> Self: + def copy(self): + # -> Self return copy.deepcopy(self) def __eq__(self, other): diff --git a/robotmbt/steparguments.py b/robotmbt/steparguments.py index 2bd4451d..503481fc 100644 --- a/robotmbt/steparguments.py +++ b/robotmbt/steparguments.py @@ -32,7 +32,6 @@ from keyword import iskeyword import builtins -from typing import Self class StepArguments(list): @@ -93,7 +92,8 @@ def modified(self) -> bool: def codestring(self) -> str | None: return self._codestr - def copy(self) -> Self: + def copy(self): + # -> Self cp = StepArgument(self.arg.strip('${}'), self.value, self.kind) cp.org_value = self.org_value return cp diff --git a/robotmbt/substitutionmap.py b/robotmbt/substitutionmap.py index 1f5445bd..6cdbc736 100644 --- a/robotmbt/substitutionmap.py +++ b/robotmbt/substitutionmap.py @@ -31,7 +31,6 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import random -from typing import Self class SubstitutionMap: @@ -54,7 +53,8 @@ def __str__(self): src = self.solution or self.substitutions return ", ".join([f"{k} ⤝ {v}" for k, v in src.items()]) - def copy(self) -> Self: + def copy(self): + # -> Self new = SubstitutionMap() new.substitutions = {k: v.copy() for k, v in self.substitutions.items()} @@ -144,7 +144,8 @@ def __repr__(self): def __iter__(self): return iter(self.optionset) - def copy(self) -> Self: + def copy(self): + # -> Self return Constraint(self.optionset) def add_constraint(self, constraint): diff --git a/robotmbt/suitedata.py b/robotmbt/suitedata.py index d0619008..95c2b116 100644 --- a/robotmbt/suitedata.py +++ b/robotmbt/suitedata.py @@ -31,7 +31,6 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import copy -from typing import Self from robot.running.arguments.argumentvalidator import ArgumentValidator @@ -97,13 +96,15 @@ def steps_with_errors(self): # list[Step | None] + [s for s in self.steps if s.has_error()] + ([self.teardown] if self.teardown and self.teardown.has_error() else [])) - def copy(self) -> Self: + def copy(self): + # -> Self duplicate = copy.copy(self) duplicate.steps = [step.copy() for step in self.steps] duplicate.data_choices = self.data_choices.copy() return duplicate - def split_at_step(self, stepindex: int) -> tuple[Self, Self]: + def split_at_step(self, stepindex: int): + # -> tuple[Self, Self] """Returns 2 partial scenarios. With stepindex 0 the first part has no steps and all steps are in the last part. With @@ -168,7 +169,8 @@ def __str__(self): def __repr__(self): return f"Step: '{self}' with model info: {self.model_info}" - def copy(self) -> Self: + def copy(self): + # -> Self cp = Step(self.org_step, *self.org_pn_args, parent=self.parent, assign=self.assign) cp.gherkin_kw = self.gherkin_kw diff --git a/robotmbt/suitereplacer.py b/robotmbt/suitereplacer.py index ea0cb893..363a2ad4 100644 --- a/robotmbt/suitereplacer.py +++ b/robotmbt/suitereplacer.py @@ -4,7 +4,6 @@ import robot.running.model as rmodel from robot.api import logger from robot.api.deco import keyword -from typing import Self # BSD 3-Clause License # @@ -46,7 +45,7 @@ class SuiteReplacer: ROBOT_LISTENER_API_VERSION: int = 3 def __init__(self, processor: str = 'process_test_suite', processor_lib: str | None = None): - self.ROBOT_LIBRARY_LISTENER: Self = self + self.ROBOT_LIBRARY_LISTENER = self # : Self self.current_suite: Suite | None = None self.robot_suite: Suite | None = None self.processor_lib_name: str | None = processor_lib @@ -68,10 +67,10 @@ def processor_method(self): if not hasattr(self.processor_lib, self.processor_name): Robot.fail( f"Processor '{self.processor_name}' not available for model-based processor library {self.processor_lib_name}") - + self._processor_method = getattr( self._processor_lib, self.processor_name) - + return self._processor_method @keyword(name="Treat this test suite Model-based") @@ -127,22 +126,22 @@ def __process_robot_suite(self, in_suite: Suite, parent: Suite | None) -> Suite: if in_suite.setup and parent is not None: step_info = Step(in_suite.setup.name, * - in_suite.setup.args, parent=out_suite) + in_suite.setup.args, parent=out_suite) step_info.add_robot_dependent_data( Robot._namespace.get_runner(step_info.org_step).keyword) out_suite.setup = step_info - + if in_suite.teardown and parent is not None: step_info = Step(in_suite.teardown.name, * - in_suite.teardown.args, parent=out_suite) + in_suite.teardown.args, parent=out_suite) step_info.add_robot_dependent_data( Robot._namespace.get_runner(step_info.org_step).keyword) out_suite.teardown = step_info - + for st in in_suite.suites: out_suite.suites.append( self.__process_robot_suite(st, parent=out_suite)) - + for tc in in_suite.tests: scenario = Scenario(tc.name, parent=out_suite) if tc.setup: @@ -151,15 +150,15 @@ def __process_robot_suite(self, in_suite: Suite, parent: Suite | None) -> Suite: step_info.add_robot_dependent_data( Robot._namespace.get_runner(step_info.org_step).keyword) scenario.setup = step_info - + if tc.teardown: step_info = Step(tc.teardown.name, * - tc.teardown.args, parent=scenario) + tc.teardown.args, parent=scenario) step_info.add_robot_dependent_data( Robot._namespace.get_runner(step_info.org_step).keyword) scenario.teardown = step_info last_gwt = None - + for step_def in tc.body: if isinstance(step_def, rmodel.Keyword): step_info = Step(step_def.name, *step_def.args, parent=scenario, assign=step_def.assign, @@ -167,10 +166,10 @@ def __process_robot_suite(self, in_suite: Suite, parent: Suite | None) -> Suite: step_info.add_robot_dependent_data( Robot._namespace.get_runner(step_info.org_step).keyword) scenario.steps.append(step_info) - + if step_info.gherkin_kw: last_gwt = step_info.gherkin_kw - + elif isinstance(step_def, rmodel.Var): scenario.steps.append( Step('VAR', step_def.name, *step_def.value, parent=scenario)) @@ -178,9 +177,9 @@ def __process_robot_suite(self, in_suite: Suite, parent: Suite | None) -> Suite: unsupported_step = Step(str(step_def), parent=scenario) unsupported_step.model_info['error'] = f"Robot construct not supported" scenario.steps.append(unsupported_step) - + out_suite.scenarios.append(scenario) - + return out_suite def __clearTestSuite(self, suite: Suite): diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index b20b971c..1712e67e 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -3,6 +3,7 @@ from robotmbt.tracestate import TraceState import networkx as nx + class ScenarioInfo: """ This contains all information we need from scenarios, abstracting away from the actual Scenario class: @@ -29,7 +30,6 @@ class TraceInfo: - state: the model space """ - def __init__(self, trace: TraceState, state: ModelSpace): self.trace = [ScenarioInfo(s) for s in trace.get_trace()] # TODO: actually use state @@ -41,6 +41,7 @@ class ScenarioGraph: The scenario graph is the most basic representation of trace exploration. It represents scenarios as nodes, and the trace as edges. """ + def __init__(self): # We use simplified IDs for nodes, and store the actual scenario info here self.ids: dict[str, ScenarioInfo] = {} @@ -67,7 +68,8 @@ def update_visualisation(self, info: TraceInfo): to_node = self.__get_or_create_id(info.trace[i + 1]) if from_node not in self.networkx.nodes: - self.networkx.add_node(from_node, text=self.ids[from_node].name) + self.networkx.add_node( + from_node, text=self.ids[from_node].name) if to_node not in self.networkx.nodes: self.networkx.add_node(to_node, text=self.ids[to_node].name) @@ -99,16 +101,14 @@ def set_ending_node(self, scenario: ScenarioInfo): Update the end node. """ node = self.__get_or_create_id(scenario) - self.pos[node] = (len(self.networkx.nodes), 0) self.fixed.append(node) def calculate_pos(self): """ Calculate the position (x, y) for all nodes in self.networkx """ - self.pos['start'] = (0, len(self.networkx.nodes)) - self.fixed.append('start') - if not self.fixed: - self.pos = nx.spring_layout(self.networkx, seed=42) - else: - self.pos = nx.spring_layout(self.networkx, pos=self.pos, fixed=self.fixed, seed=42, method='energy', gravity=0.25) \ No newline at end of file + try: + self.pos = nx.planar_layout(self.networkx) + except nx.NetworkXException: + # if planar layout cannot find a graph without crossing edges + self.pos = nx.arf_layout(self.networkx, seed=42) From 69fa295a3c8bc46eb19e00973873fe5d631bb5b0 Mon Sep 17 00:00:00 2001 From: D Osinga <46380170+osingaatje@users.noreply.github.com> Date: Wed, 29 Oct 2025 11:27:06 +0100 Subject: [PATCH 063/131] Documentation (+gitignore) for setting up virtual environment. (#13) * add initial readme adjustments for pipenv * ignore pyenv pipfile * updated README - windows specific stuff and overall improvement --- .gitignore | 3 +++ README.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/.gitignore b/.gitignore index 2ff75fba..3ab9aefe 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,9 @@ results/ *.manifest *.spec +# Ignore pyenv Pipfile +Pipfile + # Installer logs pip-log.txt pip-delete-this-directory.txt diff --git a/README.md b/README.md index 6c3e9309..3eeb12de 100644 --- a/README.md +++ b/README.md @@ -212,3 +212,53 @@ If you have feedback, ideas, or want to get involved in coding, then check out t ## Disclaimer Please note that this library is in a premature state and hasn't reached its first official (1.0) release yet. Developments are ongoing within the context of the [TiCToC](https://tictoc.cs.ru.nl/) research project. Interface changes are still frequent, and no deprecation warnings are being issued yet. + + +## Development +### Python virtual environment +Installing the proper virtual environment can be done with the default `python -m venv ./.venv` command built into python. However, if you have another version of python on your system, this might break dependencies. + +#### Pipenv+Pyenv (verified on Windows and Linux) +For the optimal experience (at least on Linux), we suggest installing the following packages: +- [`pyenv`](https://github.com/pyenv/pyenv) (Linux/Mac) or [`pyenv-win`](https://github.com/pyenv-win/pyenv-win) (Windows) +- [`pipenv`](https://github.com/pypa/pipenv) + +Then, you can install a python virtual environment with: + +```bash +pipenv --python +``` +..where the python version can be found in the `pyproject.toml`. For example, for 3.10: `pipenv --python 3.10`. + +You might need to manually make the folder `.venv` by doing `mkdir .venv`. + +You can verify if the install went correctly with: +```bash +pipenv check +``` +This should return `Passed!` + +Errors related to minor versions (for example `3.10.0rc2` != `3.10.0`) can be ignored. + +Now activate the virtual environment by running +```bash +pipenv shell +``` + +..and you should have a virtual env! If you run +```bash +python --version +``` +..while in your virtual environment, it should show the `` from before. + + +### Installing dependencies +***NOTE: making sure that you are in the virtual environment***. + +It is recommended that you also include the optional depedencies for visualisation, e.g.: +```bash +pip install ".[visualization]" +``` + + + From cc77a1ca5f19dcdc21ae7ec890e019b265111672 Mon Sep 17 00:00:00 2001 From: Jonathan <61787386+JWillegers@users.noreply.github.com> Date: Wed, 5 Nov 2025 12:19:59 +0100 Subject: [PATCH 064/131] Generate html with bokeh (#15) * add bokeh as optional dependency * remove dependency on scipy * bokeh graph with arrows and a start on selfloops * add labels to vertices * restructure according to architecture * fixed self-loops; fixed arrowheads not being at boundary of vertex * embedded plot in html * added extra documentation * fixed edge_case where there is only 1 scenario * consistently use "node" in the code * remove draw_from_networkx and draw nodes in a for-loop * moved width/height/edge styling/... to constants * fixed zooming with tools * adding future support for having labels at edges * reorder imports, prepend private methods with underscore * fix requested changes (code comments) * test class traceInfo --------- Co-authored-by: Douwe Osinga --- pyproject.toml | 2 +- robotmbt/suiteprocessors.py | 48 ++++---- robotmbt/suitereplacer.py | 9 +- robotmbt/visualise/models.py | 22 ++-- robotmbt/visualise/visualiser.py | 202 +++++++++++++++++++++++++++++-- utest/test_visualise_models.py | 20 +++ 6 files changed, 255 insertions(+), 48 deletions(-) create mode 100644 utest/test_visualise_models.py diff --git a/pyproject.toml b/pyproject.toml index 617b906f..8073081d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,4 +30,4 @@ packages = ["robotmbt"] Homepage = "https://github.com/JFoederer/robotframeworkMBT" [project.optional-dependencies] -visualization = ["scipy","numpy","networkx","matplotlib"] +visualization = ["numpy","networkx","bokeh"] diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index 9ba73a3a..f6799691 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -108,7 +108,8 @@ def process_test_suite(self, in_suite: Suite, *, seed: any = 'new') -> Suite: "Direct trace not available. Allowing repetition of scenarios") self._try_to_reach_full_coverage(allow_duplicate_scenarios=True) if not self.tracestate.coverage_reached(): - logger.write(self.visualiser.generate_html(), html=True) + logger.write( + self.visualiser.generate_visualisation(), html=True) raise Exception("Unable to compose a consistent suite") self.out_suite.scenarios = self.tracestate.get_trace() @@ -152,7 +153,8 @@ def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool): self._report_tracestate_to_user() logger.debug( f"last state:\n{self.active_model.get_status_text()}") - self.visualiser.update_visualisation(TraceInfo(self.tracestate, self.active_model)) + self.visualiser.update_visualisation( + TraceInfo(self.tracestate, self.active_model)) def __last_candidate_changed_nothing(self) -> bool: if len(self.tracestate) < 2: @@ -398,11 +400,11 @@ def _generate_scenario_variant(self, scenario: Scenario, model: ModelSpace) -> S for expr in step.model_info['MOD']: modded_arg, constraint = self._parse_modifier_expression( expr, step.args) - + if step.args[modded_arg].kind != StepArgument.EMBEDDED: raise ValueError( "Modifers are currently only supported for embedded arguments.") - + org_example = step.args[modded_arg].org_value if step.gherkin_kw == 'then': constraint = None # No new constraints are processed for then-steps @@ -410,31 +412,31 @@ def _generate_scenario_variant(self, scenario: Scenario, model: ModelSpace) -> S # if a then-step signals the first use of an example value, it is considered a new definition subs.substitute(org_example, [org_example]) continue - + if not constraint and org_example not in subs.substitutions: raise ValueError( f"No options to choose from at first assignment to {org_example}") - + if constraint and constraint != '.*': options = m.process_expression( constraint, step.args) if options == 'exec': raise ValueError( f"Invalid constraint for argument substitution: {expr}") - + if not options: raise ValueError( f"Constraint on modifer did not yield any options: {expr}") - + if not is_list_like(options): raise ValueError( f"Constraint on modifer did not yield a set of options: {expr}") - + else: options = None - + subs.substitute(org_example, options) - + except Exception as err: logger.debug(f"Unable to insert scenario {scenario.src_id}, {scenario.name}, due to modifier\n" f" In step {step}: {err}") @@ -451,9 +453,9 @@ def _generate_scenario_variant(self, scenario: Scenario, model: ModelSpace) -> S if subs.solution: logger.debug( f"Example variant generated with argument substitution: {subs}") - + scenario.data_choices = subs - + for step in scenario.steps: if 'MOD' in step.model_info: for expr in step.model_info['MOD']: @@ -461,7 +463,7 @@ def _generate_scenario_variant(self, scenario: Scenario, model: ModelSpace) -> S expr, step.args) org_example = step.args[modded_arg].org_value step.args[modded_arg].value = subs.solution[org_example] - + return scenario @staticmethod @@ -471,13 +473,13 @@ def _parse_modifier_expression(expression: str, args: StepArguments) -> tuple[st if expression.casefold().startswith(var.arg.casefold()): assignment_expr = expression.replace( var.arg, '', 1).strip() - + if not assignment_expr.startswith('=') or assignment_expr.startswith('=='): break # not an assignment - + constraint = assignment_expr.replace('=', '', 1).strip() return var.arg, constraint - + raise ValueError(f"Invalid argument substitution: {expression}") def _report_tracestate_to_user(self): @@ -485,7 +487,7 @@ def _report_tracestate_to_user(self): for snapshot in self.tracestate: part = f".{snapshot.id.split('.')[1]}" if '.' in snapshot.id else "" user_trace += f"{snapshot.scenario.src_id}{part}, " - + user_trace = user_trace[:-2] + "]" if ',' in user_trace else "[]" reject_trace = [ self.scenarios[i].src_id for i in self.tracestate.tried] @@ -501,7 +503,7 @@ def _report_tracestate_wrapup(self): def _init_randomiser(seed: any): if isinstance(seed, str): seed = seed.strip() - + if str(seed).lower() == 'none': logger.debug( f"Using system's random seed for trace generation. This trace cannot be rerun. Use `seed=new` to generate a reusable seed.") @@ -524,7 +526,7 @@ def _generate_seed() -> str: for word in range(5): prior_choice = random.choice([vowels, consonants]) last_choice = random.choice([vowels, consonants]) - + # add first two letters string = random.choice(prior_choice) + random.choice(last_choice) for letter in range(random.randint(1, 4)): # add 1 to 4 more letters @@ -532,12 +534,12 @@ def _generate_seed() -> str: new_choice = consonants if prior_choice is vowels else vowels else: new_choice = random.choice([vowels, consonants]) - + prior_choice = last_choice last_choice = new_choice string += random.choice(new_choice) - + words.append(string) - + seed = '-'.join(words) return seed diff --git a/robotmbt/suitereplacer.py b/robotmbt/suitereplacer.py index 363a2ad4..837f40b0 100644 --- a/robotmbt/suitereplacer.py +++ b/robotmbt/suitereplacer.py @@ -102,7 +102,8 @@ def treat_model_based(self, **kwargs): # TODO: add flag using kwargs to disable this if isinstance(self.processor_lib, SuiteProcessors): - logger.write(self.processor_lib.visualiser.generate_html(), html=True) + logger.write( + self.processor_lib.visualiser.generate_visualisation(), html=True) @keyword("Set model-based options") def set_model_based_options(self, **kwargs): @@ -126,14 +127,14 @@ def __process_robot_suite(self, in_suite: Suite, parent: Suite | None) -> Suite: if in_suite.setup and parent is not None: step_info = Step(in_suite.setup.name, * - in_suite.setup.args, parent=out_suite) + in_suite.setup.args, parent=out_suite) step_info.add_robot_dependent_data( Robot._namespace.get_runner(step_info.org_step).keyword) out_suite.setup = step_info if in_suite.teardown and parent is not None: step_info = Step(in_suite.teardown.name, * - in_suite.teardown.args, parent=out_suite) + in_suite.teardown.args, parent=out_suite) step_info.add_robot_dependent_data( Robot._namespace.get_runner(step_info.org_step).keyword) out_suite.teardown = step_info @@ -153,7 +154,7 @@ def __process_robot_suite(self, in_suite: Suite, parent: Suite | None) -> Suite: if tc.teardown: step_info = Step(tc.teardown.name, * - tc.teardown.args, parent=scenario) + tc.teardown.args, parent=scenario) step_info.add_robot_dependent_data( Robot._namespace.get_runner(step_info.org_step).keyword) scenario.teardown = step_info diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index 1712e67e..551ccb43 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -56,7 +56,7 @@ def __init__(self): self.fixed = [] # add the start node - self.networkx.add_node('start') + self.networkx.add_node('start', label='start') def update_visualisation(self, info: TraceInfo): """ @@ -67,14 +67,12 @@ def update_visualisation(self, info: TraceInfo): from_node = self.__get_or_create_id(info.trace[i]) to_node = self.__get_or_create_id(info.trace[i + 1]) - if from_node not in self.networkx.nodes: - self.networkx.add_node( - from_node, text=self.ids[from_node].name) - if to_node not in self.networkx.nodes: - self.networkx.add_node(to_node, text=self.ids[to_node].name) + self.add_node(from_node) + self.add_node(to_node) if (from_node, to_node) not in self.networkx.edges: - self.networkx.add_edge(from_node, to_node) + self.networkx.add_edge( + from_node, to_node, label='') def __get_or_create_id(self, scenario: ScenarioInfo) -> str: """ @@ -89,12 +87,20 @@ def __get_or_create_id(self, scenario: ScenarioInfo) -> str: self.ids[new_id] = scenario return new_id + def add_node(self, node: str): + """ + Add node if it doesn't already exist + """ + if node not in self.networkx.nodes: + self.networkx.add_node(node, label=self.ids[node].name) + def set_starting_node(self, scenario: ScenarioInfo): """ Update the starting node. """ node = self.__get_or_create_id(scenario) - self.networkx.add_edge('start', node) + self.add_node(node) + self.networkx.add_edge('start', node, label='') def set_ending_node(self, scenario: ScenarioInfo): """ diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py index ee51d7b5..2c27455e 100644 --- a/robotmbt/visualise/visualiser.py +++ b/robotmbt/visualise/visualiser.py @@ -1,14 +1,28 @@ from .models import ScenarioGraph, TraceInfo, ScenarioInfo +from bokeh.palettes import Spectral4 +from bokeh.models import ( + Plot, Range1d, Circle, + Arrow, NormalHead, LabelSet, + Bezier, ColumnDataSource, ResetTool, + SaveTool, WheelZoomTool, PanTool +) +from bokeh.embed import file_html +from bokeh.resources import CDN +from math import sqrt +import html import networkx as nx -import matplotlib.pyplot as plt -#numpy -#scipy class Visualiser: """ - The Visualiser class bridges the different concerns to provide a simple interface through which the graph can be updated, and retrieved in HTML format. + The Visualiser class bridges the different concerns to provide + a simple interface through which the graph can be updated, + and retrieved in HTML format. """ + GRAPH_SIZE_PX: int = 600 # in px, needs to be equal for height and width otherwise calculations are wrong + GRAPH_PADDING_PERC: int = 15 # % + MAX_VERTEX_NAME_LEN: int = 20 # no. of characters + def __init__(self): self.graph = ScenarioGraph() @@ -21,14 +35,178 @@ def set_start(self, scenario: ScenarioInfo): def set_end(self, scenario: ScenarioInfo): self.graph.set_ending_node(scenario) - def generate_graph(self): - # temporary code for visualisation + def generate_visualisation(self) -> str: self.graph.calculate_pos() - nx.draw(self.graph.networkx, pos=self.graph.pos, with_labels=True, node_color="lightblue", node_size=600) - plt.show() + networkvisualiser = NetworkVisualiser(self.graph) + html_bokeh = networkvisualiser.generate_html() + return f"" + + +class NetworkVisualiser: + """ + Generate plot with Bokeh + """ + + EDGE_WIDTH: float = 2.0 + EDGE_ALPHA: float = 0.7 + EDGE_COLOUR: str | tuple[int, int, int] = ( + 12, 12, 12) # 'visual studio black' + + def __init__(self, graph: ScenarioGraph): + self.plot = None + self.graph = graph + self.labels = dict(x=[], y=[], label=[]) + + # graph customisation options + self.node_radius = 1.0 - # TODO: use a graph library to actually create a graph def generate_html(self) -> str: - self.generate_graph() - return f"" - # return f"

nodes: {self.graph.nodes}\nedges: {self.graph.edges}\nstart: {self.graph.start}\nend: {self.graph.end}\nids: {[f"{name}: {str(val)}" for (name, val) in self.graph.ids.items()]}

" + """ + Generate html file from networkx graph via Bokeh + """ + self._initialise_plot() + self._add_edges() + self._add_nodes() + label_source = ColumnDataSource(data=self.labels) + labels = LabelSet(x="x", y="y", text="label", source=label_source, + text_color=NetworkVisualiser.EDGE_COLOUR, + text_align="center") + + self.plot.add_layout(labels) + + return file_html(self.plot, CDN, "graph") + + def _initialise_plot(self): + """ + Define plot with width, height, x_range, y_range and enable tools. + x_range and y_range are padded. Plot needs to be a square + """ + padding: float = Visualiser.GRAPH_PADDING_PERC / 100 + + x_range, y_range = zip(*self.graph.pos.values()) + x_min = min(x_range) - padding * (max(x_range) - min(x_range)) + x_max = max(x_range) + padding * (max(x_range) - min(x_range)) + y_min = min(y_range) - padding * (max(y_range) - min(y_range)) + y_max = max(y_range) + padding * (max(y_range) - min(y_range)) + + # scale node radius based on range + nodes_range = max(x_max-x_min, y_max-y_min) + self.node_radius = nodes_range / 50 + + # create plot + x_range = Range1d(min(x_min, y_min), max(x_max, y_max)) + y_range = Range1d(min(x_min, y_min), max(x_max, y_max)) + + self.plot = Plot(width=Visualiser.GRAPH_SIZE_PX, + height=Visualiser.GRAPH_SIZE_PX, + x_range=x_range, + y_range=y_range) + + # add tools + self.plot.add_tools(ResetTool(), SaveTool(), + WheelZoomTool(), PanTool()) + + def _add_nodes(self): + """ + Add labels to the nodes in bokeh plot + """ + node_labels = nx.get_node_attributes(self.graph.networkx, "label") + for node in self.graph.networkx.nodes: + # prepare adding labels + self.labels['x'].append(self.graph.pos[node][0]) + self.labels['y'].append(self.graph.pos[node][1]+self.node_radius) + self.labels['label'].append(self._cap_name(node_labels[node])) + + # add node + bokeh_node = Circle(radius=self.node_radius, + fill_color=Spectral4[0], + x=self.graph.pos[node][0], + y=self.graph.pos[node][1]) + + self.plot.add_glyph(bokeh_node) + + def add_self_loop(self, x: float, y: float, label: str): + """ + Self-loop as a Bezier curve with arrow head + """ + loop = Bezier( + # starting point + x0=x + self.node_radius, + y0=y, + + # end point + x1=x, + y1=y - self.node_radius, + + # control points + cx0=x + 5*self.node_radius, + cy0=y, + cx1=x, + cy1=y - 5*self.node_radius, + + # styling + line_color=NetworkVisualiser.EDGE_COLOUR, + line_width=NetworkVisualiser.EDGE_WIDTH, + line_alpha=NetworkVisualiser.EDGE_ALPHA, + ) + self.plot.add_glyph(loop) + + # add arrow head + arrow = Arrow( + end=NormalHead(size=10, + line_color=NetworkVisualiser.EDGE_COLOUR, + fill_color=NetworkVisualiser.EDGE_COLOUR), + + # -0.01 to guarantee that arrow points upwards. + x_start=x, y_start=y-self.node_radius-0.01, + x_end=x, y_end=y-self.node_radius + ) + + # add edge label + self.labels['x'].append(x + self.node_radius) + self.labels['y'].append(y - 4*self.node_radius) + self.labels['label'].append(label) + + self.plot.add_layout(arrow) + + def _add_edges(self): + edge_labels = nx.get_edge_attributes(self.graph.networkx, "label") + for edge in self.graph.networkx.edges(): + x0, y0 = self.graph.pos[edge[0]] + x1, y1 = self.graph.pos[edge[1]] + if edge[0] == edge[1]: + self.add_self_loop( + x=x0, y=y0, label=self._cap_name(edge_labels[edge])) + + else: + # edge between 2 different nodes + dx = x1 - x0 + dy = y1 - y0 + + length = sqrt(dx**2 + dy**2) + + arrow = Arrow( + end=NormalHead( + size=10, + line_color=NetworkVisualiser.EDGE_COLOUR, + fill_color=NetworkVisualiser.EDGE_COLOUR), + + x_start=x0 + dx / length * self.node_radius, + y_start=y0 + dy / length * self.node_radius, + x_end=x1 - dx / length * self.node_radius, + y_end=y1 - dy / length * self.node_radius + ) + + self.plot.add_layout(arrow) + + # add edge label + self.labels['x'].append((x0+x1)/2) + self.labels['y'].append((y0+y1)/2) + self.labels['label'].append(self._cap_name(edge_labels[edge])) + + @staticmethod + def _cap_name(name: str) -> str: + if len(name) < Visualiser.MAX_VERTEX_NAME_LEN: + return name + + return f"{name[:(Visualiser.MAX_VERTEX_NAME_LEN-3)]}..." diff --git a/utest/test_visualise_models.py b/utest/test_visualise_models.py new file mode 100644 index 00000000..f9651f03 --- /dev/null +++ b/utest/test_visualise_models.py @@ -0,0 +1,20 @@ +import unittest +from robotmbt.tracestate import TraceState +from robotmbt.visualise.models import * + + +class TestVisualiseModels(unittest.TestCase): + def test_create_TraceInfo(self): + ts = TraceState(3) + candidates = [] + for scenario in range(3): + candidates.append(ts.next_candidate()) + ts.confirm_full_scenario(candidates[-1], scenario, {}) + ti = TraceInfo(trace=ts, state=None) + + self.assertEqual(0, ti.trace[0].name) + self.assertEqual(1, ti.trace[1].name) + self.assertEqual(2, ti.trace[2].name) + + # TODO change when state is implemented + self.assertIsNone(ti.state) From fdc02dffdcf5bb736d5d03b02212db1262969d55 Mon Sep 17 00:00:00 2001 From: Jonathan <61787386+JWillegers@users.noreply.github.com> Date: Wed, 12 Nov 2025 10:58:31 +0100 Subject: [PATCH 065/131] Unit test for models.py (#17) * removed self.fixed from ScenarioGraph added self.end_node in ScenarioGraph * test cases for most methods in models.py (missing: set_ending_node and calculate_pos) * added unit test for scenariograph.set_end_node() --- robotmbt/visualise/models.py | 21 ++--- utest/test_visualise_models.py | 158 ++++++++++++++++++++++++++++++++- 2 files changed, 166 insertions(+), 13 deletions(-) diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index 551ccb43..96f416e5 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -13,11 +13,13 @@ class ScenarioInfo: def __init__(self, scenario: Scenario | str): if isinstance(scenario, Scenario): + # default case self.name = scenario.name self.src_id = scenario.src_id else: + # unit tests self.name = scenario - self.src_id = None + self.src_id = scenario def __str__(self): return f"Scen{self.src_id}: {self.name}" @@ -52,20 +54,20 @@ def __init__(self): # Stores the position (x, y) of the nodes self.pos = {} - # List of nodes which positions cannot be changed - self.fixed = [] - # add the start node self.networkx.add_node('start', label='start') + # indicates last scenario of trace + self.end_node = 'start' + def update_visualisation(self, info: TraceInfo): """ Update the visualisation with new trace information from another exploration step. This will add nodes for all new scenarios in the provided trace, as well as edges for all pairs in the provided trace. """ for i in range(0, len(info.trace) - 1): - from_node = self.__get_or_create_id(info.trace[i]) - to_node = self.__get_or_create_id(info.trace[i + 1]) + from_node = self._get_or_create_id(info.trace[i]) + to_node = self._get_or_create_id(info.trace[i + 1]) self.add_node(from_node) self.add_node(to_node) @@ -74,7 +76,7 @@ def update_visualisation(self, info: TraceInfo): self.networkx.add_edge( from_node, to_node, label='') - def __get_or_create_id(self, scenario: ScenarioInfo) -> str: + def _get_or_create_id(self, scenario: ScenarioInfo) -> str: """ Get the ID for a scenario that has been added before, or create and store a new one. """ @@ -98,7 +100,7 @@ def set_starting_node(self, scenario: ScenarioInfo): """ Update the starting node. """ - node = self.__get_or_create_id(scenario) + node = self._get_or_create_id(scenario) self.add_node(node) self.networkx.add_edge('start', node, label='') @@ -106,8 +108,7 @@ def set_ending_node(self, scenario: ScenarioInfo): """ Update the end node. """ - node = self.__get_or_create_id(scenario) - self.fixed.append(node) + self.end_node = self._get_or_create_id(scenario) def calculate_pos(self): """ diff --git a/utest/test_visualise_models.py b/utest/test_visualise_models.py index f9651f03..a6f6ff7e 100644 --- a/utest/test_visualise_models.py +++ b/utest/test_visualise_models.py @@ -1,9 +1,34 @@ import unittest +import networkx as nx from robotmbt.tracestate import TraceState from robotmbt.visualise.models import * class TestVisualiseModels(unittest.TestCase): + """ + Contains tests for robotmbt/visualise/models.py + """ + + """ + Class: ScenarioInfo + """ + + def test_scenarioInfo_str(self): + si = ScenarioInfo('test') + self.assertEqual(si.name, 'test') + self.assertEqual(si.src_id, 'test') + + def test_scenarioInfo_Scenario(self): + s = Scenario('test') + s.src_id = 0 + si = ScenarioInfo(s) + self.assertEqual(si.name, 'test') + self.assertEqual(si.src_id, 0) + + """ + Class: TraceInfo + """ + def test_create_TraceInfo(self): ts = TraceState(3) candidates = [] @@ -12,9 +37,136 @@ def test_create_TraceInfo(self): ts.confirm_full_scenario(candidates[-1], scenario, {}) ti = TraceInfo(trace=ts, state=None) - self.assertEqual(0, ti.trace[0].name) - self.assertEqual(1, ti.trace[1].name) - self.assertEqual(2, ti.trace[2].name) + self.assertEqual(ti.trace[0].name, 0) + self.assertEqual(ti.trace[1].name, 1) + self.assertEqual(ti.trace[2].name, 2) # TODO change when state is implemented self.assertIsNone(ti.state) + + """ + Class: ScenarioGraph + """ + + def test_scenario_graph_init(self): + sg = ScenarioGraph() + self.assertIn('start', sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') + + def test_scenario_graph_ids_empty(self): + sg = ScenarioGraph() + si = ScenarioInfo('test') + id = sg._get_or_create_id(si) + self.assertEqual(id, 'node0') + + def test_scenario_graph_ids_duplicate_scenario(self): + sg = ScenarioGraph() + si = ScenarioInfo('test') + id0 = sg._get_or_create_id(si) + id1 = sg._get_or_create_id(si) + self.assertEqual(id0, id1) + + def test_scenario_graph_ids_different_scenarios(self): + sg = ScenarioGraph() + si0 = ScenarioInfo('test0') + si1 = ScenarioInfo('test1') + id0 = sg._get_or_create_id(si0) + id1 = sg._get_or_create_id(si1) + self.assertEqual(id0, 'node0') + self.assertEqual(id1, 'node1') + + def test_scenario_graph_add_new_node(self): + sg = ScenarioGraph() + sg.ids['test'] = ScenarioInfo('test') + sg.add_node('test') + self.assertIn('test', sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes['test']['label'], 'test') + + def test_scenario_graph_add_existing_node(self): + sg = ScenarioGraph() + sg.add_node('start') + self.assertIn('start', sg.networkx.nodes) + self.assertEqual(len(sg.networkx.nodes), 1) + + def test_scenario_graph_update_visualisation_nodes(self): + ts = TraceState(3) + candidates = [] + for scenario in range(3): + candidates.append(ts.next_candidate()) + ts.confirm_full_scenario(candidates[-1], scenario, {}) + ti = TraceInfo(trace=ts, state=None) + sg = ScenarioGraph() + sg.update_visualisation(ti) + + self.assertIn('node0', sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes['node0']['label'], 0) + self.assertIn('node1', sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes['node1']['label'], 1) + self.assertIn('node2', sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes['node2']['label'], 2) + + def test_scenario_graph_update_visualisation_edges(self): + ts = TraceState(3) + candidates = [] + for scenario in range(3): + candidates.append(ts.next_candidate()) + ts.confirm_full_scenario(candidates[-1], scenario, {}) + ti = TraceInfo(trace=ts, state=None) + sg = ScenarioGraph() + sg.update_visualisation(ti) + + self.assertIn(('node0', 'node1'), sg.networkx.edges) + self.assertIn(('node1', 'node2'), sg.networkx.edges) + + edge_labels = nx.get_edge_attributes(sg.networkx, "label") + self.assertEqual(edge_labels[('node0', 'node1')], '') + self.assertEqual(edge_labels[('node1', 'node2')], '') + + def test_scenario_graph_update_visualisation_single_node(self): + ts = TraceState(1) + ts.confirm_full_scenario(0, 'one', {}) + self.assertEqual(ts.get_trace(), ['one']) + ti = TraceInfo(trace=ts, state=None) + sg = ScenarioGraph() + sg.update_visualisation(ti) + + # expected behaviour: no nodes nor edges are added + self.assertEqual(len(sg.networkx.nodes), 1) + self.assertEqual(len(sg.networkx.edges), 0) + + def test_scenario_graph_set_starting_node_new_node(self): + sg = ScenarioGraph() + si = ScenarioInfo('test') + sg.set_starting_node(si) + id = sg._get_or_create_id(si) + # node + self.assertIn(id, sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes[id]['label'], 'test') + + # edge + self.assertIn(('start', id), sg.networkx.edges) + edge_labels = nx.get_edge_attributes(sg.networkx, "label") + self.assertEqual(edge_labels[('start', id)], '') + + def test_scenario_graph_set_starting_node_existing_node(self): + sg = ScenarioGraph() + si = ScenarioInfo('test') + id = sg._get_or_create_id(si) + sg.add_node(id) + self.assertIn(id, sg.networkx.nodes) + + sg.set_starting_node(si) + self.assertIn(('start', id), sg.networkx.edges) + edge_labels = nx.get_edge_attributes(sg.networkx, "label") + self.assertEqual(edge_labels[('start', id)], '') + + def test_scenario_graph_set_end_node(self): + sg = ScenarioGraph() + si = ScenarioInfo('test') + id = sg._get_or_create_id(si) + sg.set_ending_node(si) + self.assertEqual(sg.end_node, id) + + +if __name__ == '__main__': + unittest.main() From a6598d89d6c7703500e1272812f8603e70943087 Mon Sep 17 00:00:00 2001 From: D Osinga <46380170+osingaatje@users.noreply.github.com> Date: Thu, 13 Nov 2025 17:45:00 +0100 Subject: [PATCH 066/131] Acceptance testing (#18) * initial acceptance test PoC * added traceInfo generator, adjusted some constructors to work with Robot testing, first working PoC of Atest * found first bug with Acceptance Test * fix unit tests * refactored helper, removed unnecessary logging * added return type for * deleted useless testing file * inlined suite setup * reduced type of state to ModelSpace, fixed incorrect type hint * added warning for future state implementations * changed utest to reflect comments from PR pull #8 (acceptance testing) - TODO add model state Thomas/Tycho * fixed constructor TraceInfo to require ModelSpace * Revert accidental change --------- Co-authored-by: jonathan <148167.jw@gmail.com> Co-authored-by: Thomas --- atest/resources/helpers/__init__.py | 0 atest/resources/helpers/modelgenerator.py | 27 +++++++++++ atest/resources/visualisation.resource | 47 +++++++++++++++++++ .../GraphHasNodeCountEqualtoScenarios.robot | 11 +++++ robotmbt/suiteprocessors.py | 2 +- robotmbt/visualise/__init__.py | 0 robotmbt/visualise/models.py | 14 ++++-- robotmbt/visualise/visualiser.py | 7 ++- utest/test_visualise_models.py | 12 ++--- 9 files changed, 108 insertions(+), 12 deletions(-) create mode 100644 atest/resources/helpers/__init__.py create mode 100644 atest/resources/helpers/modelgenerator.py create mode 100644 atest/resources/visualisation.resource create mode 100644 atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/GraphHasNodeCountEqualtoScenarios.robot create mode 100644 robotmbt/visualise/__init__.py diff --git a/atest/resources/helpers/__init__.py b/atest/resources/helpers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/atest/resources/helpers/modelgenerator.py b/atest/resources/helpers/modelgenerator.py new file mode 100644 index 00000000..5a9e535b --- /dev/null +++ b/atest/resources/helpers/modelgenerator.py @@ -0,0 +1,27 @@ +import random +import string + +from robot.api.deco import keyword # type:ignore +from robotmbt.visualise.models import TraceInfo, ScenarioInfo, ModelSpace + +class ModelGenerator: + @keyword(name="Generate Trace Information") # type: ignore + def generate_trace_info(self, scenario_count :int) -> TraceInfo: + """Generates a list of unique random scenarios.""" + scenarios :list[ScenarioInfo] = ModelGenerator.generate_scenario_names(scenario_count) + + return TraceInfo(scenarios, ModelSpace()) + + @staticmethod + def generate_random_scenario_name(length :int=10) -> str: + """Generates a random scenario name.""" + return ''.join(random.choices(string.ascii_letters + string.digits, k=length)) + + @staticmethod + def generate_scenario_names(count :int) -> list[ScenarioInfo]: + """Generates a list of unique random scenarios.""" + scenarios :set[str] = set() + while len(scenarios) < count: + scenario = ModelGenerator.generate_random_scenario_name() + scenarios.add(scenario) + return [ScenarioInfo(s) for s in scenarios] diff --git a/atest/resources/visualisation.resource b/atest/resources/visualisation.resource new file mode 100644 index 00000000..79914241 --- /dev/null +++ b/atest/resources/visualisation.resource @@ -0,0 +1,47 @@ +*** Settings *** +Documentation Resource file for testing the visualisation of RobotMBT +Library atest.resources.helpers.modelgenerator.ModelGenerator +Library robotmbt.visualise.visualiser.Visualiser +Library Collections + + +*** Keywords *** +Test Suite ${suite} exists + [Documentation] Makes a test suite + ... :IN: suite = ${suite} + ... :OUT: None + Set Suite Variable ${suite} + +Test Suite ${suite} has ${count} scenarios + [Documentation] Makes a test suite + ... :IN: scenario_count = ${count}, suite = ${suite} + ... :OUT: None + # use customer ScenarioGenerator.py to generate some scenarios + + ${trace_info} = Generate Trace Information ${count} + Set Suite Variable ${trace_info} + +# WARNING: this only works for Scenario graphs, this needs to be adjusted to continually update the model when adding new state. +Graph ${graph} is generated based on Test Suite ${suite} + [Documentation] Generates the graph + ... :IN: graph = ${graph}, suite = ${suite} + ... :OUT: None + Variable Should Exist ${trace_info} + ${visualiser} = robotmbt.visualise.visualiser.Visualiser.Construct + Call Method ${visualiser} update_visualisation ${trace_info} + ${html} = Call Method ${visualiser} generate_visualisation + + Set Suite Variable ${visualiser} + Set Suite Variable ${html} + +Graph ${graph} contains ${number} vertices + [Documentation] Verifies that the graph contains the specified number of vertices. + ... :IN: graph = ${graph}, number = ${number} + ... :OUT: None + Variable Should Exist ${visualiser} + Variable Should Exist ${trace_info} + + ${vertex_count} = Get Length ${visualiser.graph.networkx.nodes} + Should Be Equal As Integers ${vertex_count} ${number} + + diff --git a/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/GraphHasNodeCountEqualtoScenarios.robot b/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/GraphHasNodeCountEqualtoScenarios.robot new file mode 100644 index 00000000..a4c19baa --- /dev/null +++ b/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/GraphHasNodeCountEqualtoScenarios.robot @@ -0,0 +1,11 @@ +*** Settings *** +Resource ../../../../resources/visualisation.resource +Library robotmbt processor=echo +Suite Setup Set Global Variable ${scen_count} ${2} + +*** Test Cases *** +Graph should contain vertex count equal to scenario count + 1 for scenario-graph + Given Test Suite s exists + Given Test Suite s has ${scen_count} scenarios + When Graph g is generated based on Test Suite s + Then Graph g contains ${scen_count + 1} vertices \ No newline at end of file diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index f6799691..5c8d003d 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -154,7 +154,7 @@ def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool): logger.debug( f"last state:\n{self.active_model.get_status_text()}") self.visualiser.update_visualisation( - TraceInfo(self.tracestate, self.active_model)) + TraceInfo.from_trace_state(self.tracestate, self.active_model)) def __last_candidate_changed_nothing(self) -> bool: if len(self.tracestate) < 2: diff --git a/robotmbt/visualise/__init__.py b/robotmbt/visualise/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index 96f416e5..f5e6ebc6 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -31,11 +31,17 @@ class TraceInfo: - trace: the strung together scenarios up until this point - state: the model space """ + @classmethod + def from_trace_state(cls, trace: TraceState, state: ModelSpace): + return cls([ScenarioInfo(t) for t in trace.get_trace()], state) - def __init__(self, trace: TraceState, state: ModelSpace): - self.trace = [ScenarioInfo(s) for s in trace.get_trace()] + def __init__(self, trace :list[ScenarioInfo], state :ModelSpace): + self.trace :list[ScenarioInfo] = trace # TODO: actually use state - self.state = state + self.state :ModelSpace = state + + def __repr__(self) -> str: + return f"TraceInfo(trace=[{[str(t) for t in self.trace]}], state={self.state})" class ScenarioGraph: @@ -82,7 +88,7 @@ def _get_or_create_id(self, scenario: ScenarioInfo) -> str: """ for i in self.ids.keys(): # TODO: decide how to deal with repeating scenarios, this merges repeated scenarios into a single scenario - if self.ids[i].src_id == scenario.src_id: + if self.ids[i].src_id == scenario.src_id and scenario.src_id is not None: return i new_id = f"node{len(self.ids)}" diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py index 2c27455e..f2faeb5f 100644 --- a/robotmbt/visualise/visualiser.py +++ b/robotmbt/visualise/visualiser.py @@ -1,4 +1,4 @@ -from .models import ScenarioGraph, TraceInfo, ScenarioInfo +from robotmbt.visualise.models import ScenarioGraph, TraceInfo, ScenarioInfo from bokeh.palettes import Spectral4 from bokeh.models import ( Plot, Range1d, Circle, @@ -23,6 +23,11 @@ class Visualiser: GRAPH_PADDING_PERC: int = 15 # % MAX_VERTEX_NAME_LEN: int = 20 # no. of characters + # glue method to let us construct Visualiser objects in Robot tests. + @classmethod + def construct(cls): + return cls() # just calls __init__, but without having underscores etc. + def __init__(self): self.graph = ScenarioGraph() diff --git a/utest/test_visualise_models.py b/utest/test_visualise_models.py index a6f6ff7e..f4db9cda 100644 --- a/utest/test_visualise_models.py +++ b/utest/test_visualise_models.py @@ -35,14 +35,14 @@ def test_create_TraceInfo(self): for scenario in range(3): candidates.append(ts.next_candidate()) ts.confirm_full_scenario(candidates[-1], scenario, {}) - ti = TraceInfo(trace=ts, state=None) + ti = TraceInfo.from_trace_state(trace=ts, state=ModelSpace()) self.assertEqual(ti.trace[0].name, 0) self.assertEqual(ti.trace[1].name, 1) self.assertEqual(ti.trace[2].name, 2) - # TODO change when state is implemented - self.assertIsNone(ti.state) + self.assertIsNotNone(ti.state) + # TODO: add state tests to this. """ Class: ScenarioGraph @@ -94,7 +94,7 @@ def test_scenario_graph_update_visualisation_nodes(self): for scenario in range(3): candidates.append(ts.next_candidate()) ts.confirm_full_scenario(candidates[-1], scenario, {}) - ti = TraceInfo(trace=ts, state=None) + ti = TraceInfo.from_trace_state(ts, ModelSpace()) sg = ScenarioGraph() sg.update_visualisation(ti) @@ -111,7 +111,7 @@ def test_scenario_graph_update_visualisation_edges(self): for scenario in range(3): candidates.append(ts.next_candidate()) ts.confirm_full_scenario(candidates[-1], scenario, {}) - ti = TraceInfo(trace=ts, state=None) + ti = TraceInfo.from_trace_state(ts, ModelSpace()) sg = ScenarioGraph() sg.update_visualisation(ti) @@ -126,7 +126,7 @@ def test_scenario_graph_update_visualisation_single_node(self): ts = TraceState(1) ts.confirm_full_scenario(0, 'one', {}) self.assertEqual(ts.get_trace(), ['one']) - ti = TraceInfo(trace=ts, state=None) + ti = TraceInfo.from_trace_state(ts, ModelSpace()) sg = ScenarioGraph() sg.update_visualisation(ti) From 8cf5f04201608a71e15e41c1f1c5ce393ef5c505 Mon Sep 17 00:00:00 2001 From: D Osinga <46380170+osingaatje@users.noreply.github.com> Date: Mon, 17 Nov 2025 11:21:46 +0100 Subject: [PATCH 067/131] Acceptance test - Vertex and Edge rules (#19) * initial acceptance test PoC * added traceInfo generator, adjusted some constructors to work with Robot testing, first working PoC of Atest * refactored helper, removed unnecessary logging * added edge representation acceptance test --- atest/resources/helpers/modelgenerator.py | 51 +++++++++++++++++-- atest/resources/visualisation.resource | 27 +++++++++- ...Scenarios.robot => M1a1_VertexCount.robot} | 0 .../M1a2_EdgeRepresentation.robot | 12 +++++ robotmbt/visualise/models.py | 24 +++++++++ 5 files changed, 109 insertions(+), 5 deletions(-) rename atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/{GraphHasNodeCountEqualtoScenarios.robot => M1a1_VertexCount.robot} (100%) create mode 100644 atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a2_EdgeRepresentation.robot diff --git a/atest/resources/helpers/modelgenerator.py b/atest/resources/helpers/modelgenerator.py index 5a9e535b..e94cca60 100644 --- a/atest/resources/helpers/modelgenerator.py +++ b/atest/resources/helpers/modelgenerator.py @@ -2,7 +2,7 @@ import string from robot.api.deco import keyword # type:ignore -from robotmbt.visualise.models import TraceInfo, ScenarioInfo, ModelSpace +from robotmbt.visualise.models import TraceInfo, ScenarioInfo, ScenarioGraph class ModelGenerator: @keyword(name="Generate Trace Information") # type: ignore @@ -10,7 +10,52 @@ def generate_trace_info(self, scenario_count :int) -> TraceInfo: """Generates a list of unique random scenarios.""" scenarios :list[ScenarioInfo] = ModelGenerator.generate_scenario_names(scenario_count) - return TraceInfo(scenarios, ModelSpace()) + return TraceInfo(scenarios, None) + + @keyword(name="Ensure Scenario Present") # type: ignore + def ensure_scenario_present(self, trace_info :TraceInfo, scenario_name :str) -> TraceInfo: + if trace_info.contains_scenario(scenario_name): + return trace_info + + trace_info.add_scenario(ScenarioInfo(scenario_name)) + return trace_info + + @keyword(name="Ensure Scenario Follows") # type: ignore + def ensure_scenario_follows(self, trace_info :TraceInfo, scen1 :str, scen2 :str) -> TraceInfo: + scen1_info :ScenarioInfo|None = trace_info.get_scenario(scen1) + scen2_info :ScenarioInfo|None = trace_info.get_scenario(scen2) + + if scen1_info is None or scen2_info is None: + raise Exception(f"Ensure Scenario Follows for scenarios that did not exist! scen1={scen1}, scen2={scen2}") + + # both scenarios apparently exist, now make sure that scenario2 follows after some appearance of scenario 1: + scen1_index :int = trace_info.trace.index(scen1_info) + scen2_index :int = trace_info.trace.index(scen2_info) + if scen2_index == scen1_index + 1: + return trace_info + + # if it doesn't follow, make it follow + trace_info.insert_trace_at(scen1_index, scen2_info) + return trace_info + + @keyword(name="Ensure Edge Exists") # type: ignore + def ensure_edge_exists(self, graph :ScenarioGraph, scen_name1 :str, scen_name2 :str): + # get node name based on scenario + nodename1 :str = "" + nodename2 :str = "" + for (nodename, label) in graph.networkx.nodes(data='label', default=None): + if label == scen_name1: + nodename1 = nodename + + if label == scen_name2: + nodename2 = nodename + + # now check the relation: + if (nodename1, nodename2) in graph.networkx.edges: # type: ignore + return # exists :) + + # make sure that it exists + graph.networkx.add_edge(nodename1, nodename2) @staticmethod def generate_random_scenario_name(length :int=10) -> str: @@ -24,4 +69,4 @@ def generate_scenario_names(count :int) -> list[ScenarioInfo]: while len(scenarios) < count: scenario = ModelGenerator.generate_random_scenario_name() scenarios.add(scenario) - return [ScenarioInfo(s) for s in scenarios] + return [ScenarioInfo(s) for s in scenarios] \ No newline at end of file diff --git a/atest/resources/visualisation.resource b/atest/resources/visualisation.resource index 79914241..c756abc7 100644 --- a/atest/resources/visualisation.resource +++ b/atest/resources/visualisation.resource @@ -11,17 +11,34 @@ Test Suite ${suite} exists ... :IN: suite = ${suite} ... :OUT: None Set Suite Variable ${suite} + ${trace_info} = Generate Trace Information ${0} + Set Suite Variable ${trace_info} # make empty trace info Test Suite ${suite} has ${count} scenarios [Documentation] Makes a test suite ... :IN: scenario_count = ${count}, suite = ${suite} ... :OUT: None - # use customer ScenarioGenerator.py to generate some scenarios - ${trace_info} = Generate Trace Information ${count} Set Suite Variable ${trace_info} # WARNING: this only works for Scenario graphs, this needs to be adjusted to continually update the model when adding new state. +Test Suite ${suite} contains scenario ${scenario_name} + [Documentation] Ensures test suite Suite has scenario with name=Scenario name + ... :IN: suite = ${suite}, scenario_name = ${scenario_namef} + ... :OUT: None + Variable Should Exist ${trace_info} + + ${trace_info} = Ensure Scenario Present ${trace_info} ${scenario_name} + Set Suite Variable ${trace_info} + +In Test Suite ${suite}, scenario ${s2} can be reached from ${s1} + [Documentation] Ensures a scenario s2 immediately follows s1 in a trace + ... :IN: suite = ${suite}, scenario2 = ${s2}, scenario1 = ${s1} + ... :OUT: None + + ${trace_info} = Ensure Scenario Follows ${trace_info} ${s1} ${s2} + Set Suite Variable ${trace_info} + Graph ${graph} is generated based on Test Suite ${suite} [Documentation] Generates the graph ... :IN: graph = ${graph}, suite = ${suite} @@ -44,4 +61,10 @@ Graph ${graph} contains ${number} vertices ${vertex_count} = Get Length ${visualiser.graph.networkx.nodes} Should Be Equal As Integers ${vertex_count} ${number} +Graph ${graph} shows an edge from ${scenname1} towards ${scenname2} + [Documentation] Verifies that a generated graph contains a certain edge + ... :IN: graph = ${graph}, scenario name 1 = ${scenname1}, scenario name 2 = ${scenname2} + ... :OUT: None + Variable Should Exist ${visualiser} + ${res} = Ensure Edge Exists ${visualiser.graph} ${scenname1} ${scenname2} \ No newline at end of file diff --git a/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/GraphHasNodeCountEqualtoScenarios.robot b/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a1_VertexCount.robot similarity index 100% rename from atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/GraphHasNodeCountEqualtoScenarios.robot rename to atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a1_VertexCount.robot diff --git a/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a2_EdgeRepresentation.robot b/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a2_EdgeRepresentation.robot new file mode 100644 index 00000000..7f56bc60 --- /dev/null +++ b/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a2_EdgeRepresentation.robot @@ -0,0 +1,12 @@ +*** Settings *** +Resource ../../../../resources/visualisation.resource +Library robotmbt processor=echo + +*** Test Cases *** +Graph should contain edge from vertex A to vertex B if B can be reached from A + Given Test Suite s exists + Given Test Suite s contains scenario Drive To Destination + Given Test Suite s contains scenario Arrive At Destination + Given In Test Suite s, scenario Arrive At Destination can be reached from Drive To Destination + When Graph g is generated based on Test Suite s + Then Graph g shows an edge from Drive To Destination towards Arrive At Destination diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index f5e6ebc6..1fb0625b 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -43,6 +43,30 @@ def __init__(self, trace :list[ScenarioInfo], state :ModelSpace): def __repr__(self) -> str: return f"TraceInfo(trace=[{[str(t) for t in self.trace]}], state={self.state})" + def contains_scenario(self, scen_name :str) -> bool: + for scen in self.trace: + if scen.name == scen_name: + return True + return False + + def add_scenario(self, scen :ScenarioInfo): + """ + Used in acceptance testing + """ + self.trace.append(scen) + + def get_scenario(self, scen_name :str) -> ScenarioInfo|None: + for scenario in self.trace: + if scenario.name == scen_name: + return scenario + return None + + def insert_trace_at(self, index :int, scen_info :ScenarioInfo): + if index < 0 or index >= len(self.trace): + raise IndexError(f"InsertTraceAt received invalid index ({index}) for length of list ({len(self.trace)})") + + self.trace.insert(index, scen_info) + class ScenarioGraph: """ From cbb2c49446d7a92163475b59b09571c2549278cd Mon Sep 17 00:00:00 2001 From: Jonathan <61787386+JWillegers@users.noreply.github.com> Date: Tue, 18 Nov 2025 09:27:30 +0100 Subject: [PATCH 068/131] Add subfolders to toml (#20) --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8073081d..505bbf4f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,8 +23,8 @@ dependencies = [ ] requires-python = ">=3.10" -[tool.setuptools] -packages = ["robotmbt"] +[tool.setuptools.packages.find] +include = ["robotmbt*"] [project.urls] Homepage = "https://github.com/JFoederer/robotframeworkMBT" From a1b9d0266ddbecf4dbcaa2af64bc7aca61d77200 Mon Sep 17 00:00:00 2001 From: D Osinga <46380170+osingaatje@users.noreply.github.com> Date: Tue, 18 Nov 2025 16:38:05 +0100 Subject: [PATCH 069/131] Node redesign (#16) * Refactor node rendering to use rectangles for scenarios * Improve edge connection points and arrow positioning * Redesign self-loops for rectangle nodes * Fix self-loop arrow alignment and improve visual consistency * Fix visualization issues according to reviwer's feedback * magic fix from Jonathan * Simplify self-loop logic and remove invalid edge cases * redo part of Jonathan fix - makes ERROR:bokeh.core.validation.check:E-1001 (BAD_COLUMN_NAME) disappear * remove duplicate code from edge calculation --------- Co-authored-by: Diogo Silva --- robotmbt/visualise/visualiser.py | 343 +++++++++++++++++++++++-------- 1 file changed, 259 insertions(+), 84 deletions(-) diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py index f2faeb5f..e18a5ded 100644 --- a/robotmbt/visualise/visualiser.py +++ b/robotmbt/visualise/visualiser.py @@ -1,10 +1,10 @@ from robotmbt.visualise.models import ScenarioGraph, TraceInfo, ScenarioInfo from bokeh.palettes import Spectral4 from bokeh.models import ( - Plot, Range1d, Circle, + Plot, Range1d, Circle, Rect, Arrow, NormalHead, LabelSet, Bezier, ColumnDataSource, ResetTool, - SaveTool, WheelZoomTool, PanTool + SaveTool, WheelZoomTool, PanTool, Text ) from bokeh.embed import file_html from bokeh.resources import CDN @@ -12,7 +12,6 @@ import html import networkx as nx - class Visualiser: """ The Visualiser class bridges the different concerns to provide @@ -46,7 +45,6 @@ def generate_visualisation(self) -> str: html_bokeh = networkvisualiser.generate_html() return f"" - class NetworkVisualiser: """ Generate plot with Bokeh @@ -54,31 +52,27 @@ class NetworkVisualiser: EDGE_WIDTH: float = 2.0 EDGE_ALPHA: float = 0.7 - EDGE_COLOUR: str | tuple[int, int, int] = ( - 12, 12, 12) # 'visual studio black' + EDGE_COLOUR: str | tuple[int, int, int] = (12, 12, 12) # 'visual studio black' + ARROWHEAD_SIZE: int = 6 # Consistent arrowhead size def __init__(self, graph: ScenarioGraph): self.plot = None self.graph = graph - self.labels = dict(x=[], y=[], label=[]) + self.node_props = {} # Store node properties for arrow calculations # graph customisation options self.node_radius = 1.0 + self.char_width = 0.1 + self.char_height = 0.1 + self.padding = 0.1 def generate_html(self) -> str: """ Generate html file from networkx graph via Bokeh """ self._initialise_plot() + self._add_nodes_with_labels() self._add_edges() - self._add_nodes() - label_source = ColumnDataSource(data=self.labels) - labels = LabelSet(x="x", y="y", text="label", source=label_source, - text_color=NetworkVisualiser.EDGE_COLOUR, - text_align="center") - - self.plot.add_layout(labels) - return file_html(self.plot, CDN, "graph") def _initialise_plot(self): @@ -96,7 +90,9 @@ def _initialise_plot(self): # scale node radius based on range nodes_range = max(x_max-x_min, y_max-y_min) - self.node_radius = nodes_range / 50 + self.node_radius = nodes_range / 150 + self.char_width = nodes_range / 150 + self.char_height = nodes_range / 150 # create plot x_range = Range1d(min(x_min, y_min), max(x_max, y_max)) @@ -111,107 +107,286 @@ def _initialise_plot(self): self.plot.add_tools(ResetTool(), SaveTool(), WheelZoomTool(), PanTool()) - def _add_nodes(self): + def _calculate_text_dimensions(self, text: str) -> tuple[float, float]: + """Calculate width and height needed for text based on actual text length""" + # Calculate width based on character count + text_length = len(text) + width = (text_length * self.char_width) + (2 * self.padding) + + # Reduced height for more compact rectangles + height = self.char_height + (self.padding) + + return width, height + + def _add_nodes_with_labels(self): """ - Add labels to the nodes in bokeh plot + Add nodes with text labels inside them """ node_labels = nx.get_node_attributes(self.graph.networkx, "label") + + # Create data sources for nodes and labels + circle_data = dict(x=[], y=[], radius=[], label=[]) + rect_data = dict(x=[], y=[], width=[], height=[], label=[]) + text_data = dict(x=[], y=[], text=[]) + for node in self.graph.networkx.nodes: - # prepare adding labels - self.labels['x'].append(self.graph.pos[node][0]) - self.labels['y'].append(self.graph.pos[node][1]+self.node_radius) - self.labels['label'].append(self._cap_name(node_labels[node])) - - # add node - bokeh_node = Circle(radius=self.node_radius, - fill_color=Spectral4[0], - x=self.graph.pos[node][0], - y=self.graph.pos[node][1]) - - self.plot.add_glyph(bokeh_node) - - def add_self_loop(self, x: float, y: float, label: str): + # Labels are always defined and cannot be lists + label = node_labels[node] + label = self._cap_name(label) + x, y = self.graph.pos[node] + + if node == 'start': + # For start node (circle), calculate radius based on text width + text_width, text_height = self._calculate_text_dimensions(label) + # Calculate radius from text dimensions + radius = (text_width / 2.5) + + circle_data['x'].append(x) + circle_data['y'].append(y) + circle_data['radius'].append(radius) + circle_data['label'].append(label) + + # Store node properties for arrow calculations + self.node_props[node] = {'type': 'circle', 'x': x, 'y': y, 'radius': radius, 'label': label} + + else: + # For scenario nodes (rectangles), calculate dimensions based on text + text_width, text_height = self._calculate_text_dimensions(label) + + rect_data['x'].append(x) + rect_data['y'].append(y) + rect_data['width'].append(text_width) + rect_data['height'].append(text_height) + rect_data['label'].append(label) + + # Store node properties for arrow calculations + self.node_props[node] = {'type': 'rect', 'x': x, 'y': y, 'width': text_width, 'height': text_height, 'label': label} + + # Add text for all nodes + text_data['x'].append(x) + text_data['y'].append(y) + text_data['text'].append(label) + + # Add circles for start node + if circle_data['x']: + circle_source = ColumnDataSource(circle_data) + circles = Circle(x='x', y='y', radius='radius', + fill_color=Spectral4[0]) + self.plot.add_glyph(circle_source, circles) + + # Add rectangles for scenario nodes + if rect_data['x']: + rect_source = ColumnDataSource(rect_data) + rectangles = Rect(x='x', y='y', width='width', height='height', + fill_color=Spectral4[0]) + self.plot.add_glyph(rect_source, rectangles) + + # Add text labels for all nodes + text_source = ColumnDataSource(text_data) + text_labels = Text(x='x', y='y', text='text', + text_align='center', text_baseline='middle', + text_color='white', text_font_size='9pt') + self.plot.add_glyph(text_source, text_labels) + + def _get_edge_points(self, start_node, end_node): + """Calculate edge start and end points at node borders""" + start_props = self.node_props.get(start_node) + end_props = self.node_props.get(end_node) + + # Node properties should always exist + if not start_props or not end_props: + raise ValueError(f"Node properties not found for nodes: {start_node}, {end_node}") + + # Calculate direction vector + dx = end_props['x'] - start_props['x'] + dy = end_props['y'] - start_props['y'] + distance = sqrt(dx*dx + dy*dy) + + # Self-loops are handled separately, distance should never be 0 + if distance == 0: + raise ValueError("Distance between different nodes should not be zero") + + # Normalize direction vector + dx /= distance + dy /= distance + + # Calculate start point at border + if start_props['type'] == 'circle': + start_x = start_props['x'] + dx * start_props['radius'] + start_y = start_props['y'] + dy * start_props['radius'] + else: + # Find where the line intersects the rectangle border + rect_width = start_props['width'] + rect_height = start_props['height'] + + # Calculate scaling factors for x and y directions + scale_x = rect_width / (2 * abs(dx)) if dx != 0 else float('inf') + scale_y = rect_height / (2 * abs(dy)) if dy != 0 else float('inf') + + # Use the smaller scale to ensure we hit the border + scale = min(scale_x, scale_y) + + start_x = start_props['x'] + dx * scale + start_y = start_props['y'] + dy * scale + + # Calculate end point at border (reverse direction) + # End nodes should never be circles for regular edges + if end_props['type'] == 'circle': + raise ValueError(f"End node should not be a circle for regular edges: {end_node}") + else: + rect_width = end_props['width'] + rect_height = end_props['height'] + + # Calculate scaling factors for x and y directions (reverse) + scale_x = rect_width / (2 * abs(dx)) if dx != 0 else float('inf') + scale_y = rect_height / (2 * abs(dy)) if dy != 0 else float('inf') + + # Use the smaller scale to ensure we hit the border + scale = min(scale_x, scale_y) + + end_x = end_props['x'] - dx * scale + end_y = end_props['y'] - dy * scale + + return start_x, start_y, end_x, end_y + + def add_self_loop(self, node_id: str): """ - Self-loop as a Bezier curve with arrow head + Circular arc that starts and ends at the top side of the rectangle + Start at 1/4 width, end at 3/4 width, with a circular arc above + The arc itself ends with the arrowhead pointing into the rectangle """ + # Get node properties directly by node ID + node_props = self.node_props.get(node_id) + + # Node properties should always exist + if node_props is None: + raise ValueError(f"Node properties not found for node: {node_id}") + + # Self-loops should only be for rectangle nodes (scenarios) + if node_props['type'] != 'rect': + raise ValueError(f"Self-loops should only be for rectangle nodes, got: {node_props['type']}") + + x, y = node_props['x'], node_props['y'] + width = node_props['width'] + height = node_props['height'] + + # Start: 1/4 width from left, top side + start_x = x - width/4 + start_y = y + height/2 + + # End: 3/4 width from left, top side + end_x = x + width/4 + end_y = y + height/2 + + # Arc height above the rectangle + arc_height = width * 0.4 + + # Control points for a circular arc above + control1_x = x - width/8 + control1_y = y + height/2 + arc_height + + control2_x = x + width/8 + control2_y = y + height/2 + arc_height + + # Create the Bezier curve (the main arc) with the same thickness as straight lines loop = Bezier( - # starting point - x0=x + self.node_radius, - y0=y, - - # end point - x1=x, - y1=y - self.node_radius, - - # control points - cx0=x + 5*self.node_radius, - cy0=y, - cx1=x, - cy1=y - 5*self.node_radius, - - # styling + x0=start_x, y0=start_y, + x1=end_x, y1=end_y, + cx0=control1_x, cy0=control1_y, + cx1=control2_x, cy1=control2_y, line_color=NetworkVisualiser.EDGE_COLOUR, line_width=NetworkVisualiser.EDGE_WIDTH, line_alpha=NetworkVisualiser.EDGE_ALPHA, ) self.plot.add_glyph(loop) - # add arrow head + # Calculate the tangent direction at the end of the Bezier curve + # For a cubic Bezier, the tangent at the end point is from the last control point to the end point + tangent_x = end_x - control2_x + tangent_y = end_y - control2_y + + # Normalize the tangent vector + tangent_length = sqrt(tangent_x**2 + tangent_y**2) + if tangent_length > 0: + tangent_x /= tangent_length + tangent_y /= tangent_length + + # Add just the arrowhead (NormalHead) at the end point, oriented along the tangent + arrowhead = NormalHead( + size=NetworkVisualiser.ARROWHEAD_SIZE, + line_color=NetworkVisualiser.EDGE_COLOUR, + fill_color=NetworkVisualiser.EDGE_COLOUR, + line_width=NetworkVisualiser.EDGE_WIDTH + ) + + # Create a standalone arrowhead at the end point + # Strategy: use a very short Arrow that's essentially just the head arrow = Arrow( - end=NormalHead(size=10, - line_color=NetworkVisualiser.EDGE_COLOUR, - fill_color=NetworkVisualiser.EDGE_COLOUR), - - # -0.01 to guarantee that arrow points upwards. - x_start=x, y_start=y-self.node_radius-0.01, - x_end=x, y_end=y-self.node_radius + end=arrowhead, + x_start=end_x - tangent_x * 0.001, # Almost zero length line + y_start=end_y - tangent_y * 0.001, + x_end=end_x, + y_end=end_y, + line_color=NetworkVisualiser.EDGE_COLOUR, + line_width=NetworkVisualiser.EDGE_WIDTH, + line_alpha=NetworkVisualiser.EDGE_ALPHA ) + self.plot.add_layout(arrow) - # add edge label - self.labels['x'].append(x + self.node_radius) - self.labels['y'].append(y - 4*self.node_radius) - self.labels['label'].append(label) + # Add edge label - positioned above the arc + label_x = x + label_y = y + height/2 + arc_height * 0.6 - self.plot.add_layout(arrow) + return label_x, label_y def _add_edges(self): edge_labels = nx.get_edge_attributes(self.graph.networkx, "label") + + # Create data sources for edges and edge labels + edge_text_data = dict(x=[], y=[], text=[]) + for edge in self.graph.networkx.edges(): - x0, y0 = self.graph.pos[edge[0]] - x1, y1 = self.graph.pos[edge[1]] + # Edge labels are always defined and cannot be lists + edge_label = edge_labels[edge] + edge_label = self._cap_name(edge_label) + edge_text_data['text'].append(edge_label) + if edge[0] == edge[1]: - self.add_self_loop( - x=x0, y=y0, label=self._cap_name(edge_labels[edge])) - + # Self-loop handled separately + label_x, label_y = self.add_self_loop(edge[0]) + edge_text_data['x'].append(label_x) + edge_text_data['y'].append(label_y) + else: - # edge between 2 different nodes - dx = x1 - x0 - dy = y1 - y0 - - length = sqrt(dx**2 + dy**2) - + # Calculate edge points at node borders + start_x, start_y, end_x, end_y = self._get_edge_points(edge[0], edge[1]) + + # Add arrow between the calculated points arrow = Arrow( end=NormalHead( - size=10, + size=NetworkVisualiser.ARROWHEAD_SIZE, line_color=NetworkVisualiser.EDGE_COLOUR, fill_color=NetworkVisualiser.EDGE_COLOUR), - - x_start=x0 + dx / length * self.node_radius, - y_start=y0 + dy / length * self.node_radius, - x_end=x1 - dx / length * self.node_radius, - y_end=y1 - dy / length * self.node_radius + x_start=start_x, y_start=start_y, + x_end=end_x, y_end=end_y ) - self.plot.add_layout(arrow) - # add edge label - self.labels['x'].append((x0+x1)/2) - self.labels['y'].append((y0+y1)/2) - self.labels['label'].append(self._cap_name(edge_labels[edge])) + # Collect edge label data (position at midpoint) + edge_text_data['x'].append((start_x + end_x) / 2) + edge_text_data['y'].append((start_y + end_y) / 2) + + # Add all edge labels at once + if edge_text_data['x']: + edge_text_source = ColumnDataSource(edge_text_data) + edge_labels_glyph = Text(x='x', y='y', text='text', + text_align='center', text_baseline='middle', + text_font_size='7pt') + self.plot.add_glyph(edge_text_source, edge_labels_glyph) @staticmethod def _cap_name(name: str) -> str: if len(name) < Visualiser.MAX_VERTEX_NAME_LEN: return name - return f"{name[:(Visualiser.MAX_VERTEX_NAME_LEN-3)]}..." + return f"{name[:(Visualiser.MAX_VERTEX_NAME_LEN-3)]}..." \ No newline at end of file From 4cee18570ff7bc107ee03931cfd22a3f9df3d0fa Mon Sep 17 00:00:00 2001 From: Thomas Kas Date: Wed, 19 Nov 2025 11:22:54 +0100 Subject: [PATCH 070/131] State graphs + graph switching + dependency checks (#21) * Update using final trace info internally * Add StateInfo abstraction * Implement StateGraph * cleaned up StateInfo.__init__ a little * Merge with main * Fixed oopsie * Implement abstract base class for graphs * Implement per-suite graph choice * Only generate graph if dependencies are installed * Don't run our tests without dependencies * Use empty string instead of None * doodoo * Merge and fix some issues * Run formatter * Fixed doc * Implement requested changes --------- Co-authored-by: tychodub --- atest/resources/helpers/modelgenerator.py | 58 ++--- atest/resources/visualisation.resource | 10 +- .../M1a1_VertexCount.robot | 2 +- .../M1a2_EdgeRepresentation.robot | 2 +- robotmbt/modelspace.py | 2 +- robotmbt/substitutionmap.py | 2 +- robotmbt/suitedata.py | 10 +- robotmbt/suiteprocessors.py | 40 +-- robotmbt/suitereplacer.py | 11 +- robotmbt/tracestate.py | 36 +-- robotmbt/visualise/models.py | 236 ++++++++++++++++-- robotmbt/visualise/visualiser.py | 173 ++++++------- run_tests.py | 12 +- utest/test_visualise_models.py | 74 +++--- 14 files changed, 439 insertions(+), 229 deletions(-) diff --git a/atest/resources/helpers/modelgenerator.py b/atest/resources/helpers/modelgenerator.py index e94cca60..18b8a1b8 100644 --- a/atest/resources/helpers/modelgenerator.py +++ b/atest/resources/helpers/modelgenerator.py @@ -1,72 +1,74 @@ import random import string -from robot.api.deco import keyword # type:ignore +from robot.api.deco import keyword # type:ignore +from robotmbt.modelspace import ModelSpace from robotmbt.visualise.models import TraceInfo, ScenarioInfo, ScenarioGraph -class ModelGenerator: - @keyword(name="Generate Trace Information") # type: ignore - def generate_trace_info(self, scenario_count :int) -> TraceInfo: + +class ModelGenerator: + @keyword(name="Generate Trace Information") # type: ignore + def generate_trace_info(self, scenario_count: int) -> TraceInfo: """Generates a list of unique random scenarios.""" - scenarios :list[ScenarioInfo] = ModelGenerator.generate_scenario_names(scenario_count) + scenarios: list[ScenarioInfo] = ModelGenerator.generate_scenario_names(scenario_count) - return TraceInfo(scenarios, None) + return TraceInfo(scenarios, ModelSpace()) - @keyword(name="Ensure Scenario Present") # type: ignore - def ensure_scenario_present(self, trace_info :TraceInfo, scenario_name :str) -> TraceInfo: + @keyword(name="Ensure Scenario Present") # type: ignore + def ensure_scenario_present(self, trace_info: TraceInfo, scenario_name: str) -> TraceInfo: if trace_info.contains_scenario(scenario_name): return trace_info trace_info.add_scenario(ScenarioInfo(scenario_name)) return trace_info - @keyword(name="Ensure Scenario Follows") # type: ignore - def ensure_scenario_follows(self, trace_info :TraceInfo, scen1 :str, scen2 :str) -> TraceInfo: - scen1_info :ScenarioInfo|None = trace_info.get_scenario(scen1) - scen2_info :ScenarioInfo|None = trace_info.get_scenario(scen2) + @keyword(name="Ensure Scenario Follows") # type: ignore + def ensure_scenario_follows(self, trace_info: TraceInfo, scen1: str, scen2: str) -> TraceInfo: + scen1_info: ScenarioInfo | None = trace_info.get_scenario(scen1) + scen2_info: ScenarioInfo | None = trace_info.get_scenario(scen2) if scen1_info is None or scen2_info is None: raise Exception(f"Ensure Scenario Follows for scenarios that did not exist! scen1={scen1}, scen2={scen2}") # both scenarios apparently exist, now make sure that scenario2 follows after some appearance of scenario 1: - scen1_index :int = trace_info.trace.index(scen1_info) - scen2_index :int = trace_info.trace.index(scen2_info) + scen1_index: int = trace_info.trace.index(scen1_info) + scen2_index: int = trace_info.trace.index(scen2_info) if scen2_index == scen1_index + 1: return trace_info - + # if it doesn't follow, make it follow trace_info.insert_trace_at(scen1_index, scen2_info) return trace_info - @keyword(name="Ensure Edge Exists") # type: ignore - def ensure_edge_exists(self, graph :ScenarioGraph, scen_name1 :str, scen_name2 :str): + @keyword(name="Ensure Edge Exists") # type: ignore + def ensure_edge_exists(self, graph: ScenarioGraph, scen_name1: str, scen_name2: str): # get node name based on scenario - nodename1 :str = "" - nodename2 :str = "" + nodename1: str = "" + nodename2: str = "" for (nodename, label) in graph.networkx.nodes(data='label', default=None): if label == scen_name1: nodename1 = nodename if label == scen_name2: nodename2 = nodename - + # now check the relation: - if (nodename1, nodename2) in graph.networkx.edges: # type: ignore - return # exists :) - + if (nodename1, nodename2) in graph.networkx.edges: # type: ignore + return # exists :) + # make sure that it exists graph.networkx.add_edge(nodename1, nodename2) @staticmethod - def generate_random_scenario_name(length :int=10) -> str: + def generate_random_scenario_name(length: int = 10) -> str: """Generates a random scenario name.""" return ''.join(random.choices(string.ascii_letters + string.digits, k=length)) - + @staticmethod - def generate_scenario_names(count :int) -> list[ScenarioInfo]: + def generate_scenario_names(count: int) -> list[ScenarioInfo]: """Generates a list of unique random scenarios.""" - scenarios :set[str] = set() + scenarios: set[str] = set() while len(scenarios) < count: scenario = ModelGenerator.generate_random_scenario_name() scenarios.add(scenario) - return [ScenarioInfo(s) for s in scenarios] \ No newline at end of file + return [ScenarioInfo(s) for s in scenarios] diff --git a/atest/resources/visualisation.resource b/atest/resources/visualisation.resource index c756abc7..0dc9af9e 100644 --- a/atest/resources/visualisation.resource +++ b/atest/resources/visualisation.resource @@ -1,7 +1,7 @@ *** Settings *** Documentation Resource file for testing the visualisation of RobotMBT Library atest.resources.helpers.modelgenerator.ModelGenerator -Library robotmbt.visualise.visualiser.Visualiser +Library robotmbt.visualise.visualiser.Visualiser scenario Library Collections @@ -24,7 +24,7 @@ Test Suite ${suite} has ${count} scenarios # WARNING: this only works for Scenario graphs, this needs to be adjusted to continually update the model when adding new state. Test Suite ${suite} contains scenario ${scenario_name} [Documentation] Ensures test suite Suite has scenario with name=Scenario name - ... :IN: suite = ${suite}, scenario_name = ${scenario_namef} + ... :IN: suite = ${suite}, scenario_name = ${scenario_name} ... :OUT: None Variable Should Exist ${trace_info} @@ -39,12 +39,12 @@ In Test Suite ${suite}, scenario ${s2} can be reached from ${s1} ${trace_info} = Ensure Scenario Follows ${trace_info} ${s1} ${s2} Set Suite Variable ${trace_info} -Graph ${graph} is generated based on Test Suite ${suite} +Graph ${graph} of type ${graph_type} is generated based on Test Suite ${suite} [Documentation] Generates the graph - ... :IN: graph = ${graph}, suite = ${suite} + ... :IN: graph = ${graph}, graph_type = ${graph_type}, suite = ${suite} ... :OUT: None Variable Should Exist ${trace_info} - ${visualiser} = robotmbt.visualise.visualiser.Visualiser.Construct + ${visualiser} = robotmbt.visualise.visualiser.Visualiser.Construct graph_type=${graph_type} Call Method ${visualiser} update_visualisation ${trace_info} ${html} = Call Method ${visualiser} generate_visualisation diff --git a/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a1_VertexCount.robot b/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a1_VertexCount.robot index a4c19baa..9224c2af 100644 --- a/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a1_VertexCount.robot +++ b/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a1_VertexCount.robot @@ -7,5 +7,5 @@ Suite Setup Set Global Variable ${scen_count} ${2} Graph should contain vertex count equal to scenario count + 1 for scenario-graph Given Test Suite s exists Given Test Suite s has ${scen_count} scenarios - When Graph g is generated based on Test Suite s + When Graph g of type scenario is generated based on Test Suite s Then Graph g contains ${scen_count + 1} vertices \ No newline at end of file diff --git a/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a2_EdgeRepresentation.robot b/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a2_EdgeRepresentation.robot index 7f56bc60..a8162950 100644 --- a/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a2_EdgeRepresentation.robot +++ b/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a2_EdgeRepresentation.robot @@ -8,5 +8,5 @@ Graph should contain edge from vertex A to vertex B if B can be reached from A Given Test Suite s contains scenario Drive To Destination Given Test Suite s contains scenario Arrive At Destination Given In Test Suite s, scenario Arrive At Destination can be reached from Drive To Destination - When Graph g is generated based on Test Suite s + When Graph g of type scenario is generated based on Test Suite s Then Graph g shows an edge from Drive To Destination towards Arrive At Destination diff --git a/robotmbt/modelspace.py b/robotmbt/modelspace.py index f7e8489f..f30c9012 100644 --- a/robotmbt/modelspace.py +++ b/robotmbt/modelspace.py @@ -44,7 +44,7 @@ def __init__(self, reference_id=None): self.ref_id: str = str(reference_id) self.std_attrs: list[str] = [] self.props: dict[str, RecursiveScope | ModelSpace] = dict() - + # For using literals without having to use quotes (abc='abc') self.values: dict[str, any] = dict() self.scenario_vars: list[RecursiveScope] = [] diff --git a/robotmbt/substitutionmap.py b/robotmbt/substitutionmap.py index 6cdbc736..b47ada71 100644 --- a/robotmbt/substitutionmap.py +++ b/robotmbt/substitutionmap.py @@ -151,7 +151,7 @@ def copy(self): def add_constraint(self, constraint): if constraint is None: return - + self.optionset = [opt for opt in self.optionset if opt in constraint] if not len(self.optionset): raise ValueError('No options left after adding constraint') diff --git a/robotmbt/suitedata.py b/robotmbt/suitedata.py index 95c2b116..2684ad41 100644 --- a/robotmbt/suitedata.py +++ b/robotmbt/suitedata.py @@ -248,7 +248,7 @@ def add_robot_dependent_data(self, robot_kw): self.args = StepArguments([StepArgument(*match, kind=StepArgument.EMBEDDED) for match in zip(robot_kw.embedded.args, robot_kw.embedded.parse_args(self.kw_wo_gherkin))]) - + self.args += self.__handle_non_embedded_arguments(robot_kw.args) self.signature = robot_kw.name self.model_info = self.__parse_model_info(robot_kw._doc) @@ -257,14 +257,14 @@ def add_robot_dependent_data(self, robot_kw): def __handle_non_embedded_arguments(self, robot_argspec) -> list[StepArgument]: result = [] - + p_args, n_args = robot_argspec.map([a for a in self.org_pn_args if '=' not in a or r'\=' in a], [a.split('=', 1) for a in self.org_pn_args if '=' in a and r'\=' not in a]) - + # for some reason .map() returns [None] instead of the empty list when there are no arguments if p_args == [None]: p_args = [] - + ArgumentValidator(robot_argspec).validate(p_args, n_args) robot_args = [a for a in robot_argspec] argument_names = list(robot_argspec.argument_names) @@ -312,7 +312,7 @@ def __parse_model_info(self, docu: str) -> dict[str, list[str]]: expressions = [e.strip() for e in elms[-1].split("|") if e] while lines and not lines[0].startswith(":"): expressions.extend([e.strip() - for e in lines.pop(0).split("|") if e]) + for e in lines.pop(0).split("|") if e]) model_info[key] = expressions if not model_info: raise ValueError("When present, *model info* cannot be empty") diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index 5c8d003d..e3c9ecac 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -41,15 +41,21 @@ from .suitedata import Suite, Scenario, Step from .tracestate import TraceState, TraceSnapShot from .steparguments import StepArgument, StepArguments -from .visualise.visualiser import Visualiser -from .visualise.models import TraceInfo, ScenarioInfo +try: + from .visualise.visualiser import Visualiser + from .visualise.models import TraceInfo + + VISUALISE = True +except ImportError: + Visualiser = None + TraceInfo = None + VISUALISE = False -class SuiteProcessors: - def __init__(self): - self.visualiser = Visualiser() - def echo(self, in_suite): +class SuiteProcessors: + @staticmethod + def echo(in_suite): return in_suite def flatten(self, in_suite: Suite) -> Suite: @@ -82,7 +88,7 @@ def flatten(self, in_suite: Suite) -> Suite: out_suite.suites = [] return out_suite - def process_test_suite(self, in_suite: Suite, *, seed: any = 'new') -> Suite: + def process_test_suite(self, in_suite: Suite, *, seed: any = 'new', graph: str = '') -> Suite: self.out_suite = Suite(in_suite.name) self.out_suite.filename = in_suite.filename self.out_suite.parent = in_suite.parent @@ -98,7 +104,12 @@ def process_test_suite(self, in_suite: Suite, *, seed: any = 'new') -> Suite: self._init_randomiser(seed) random.shuffle(self.scenarios) - self.visualiser = Visualiser() + self.visualiser = None + if graph != '' and VISUALISE: + self.visualiser = Visualiser(graph) + elif graph != '' and not VISUALISE: + logger.warn(f'Visualisation {graph} requested, but required dependencies are not installed.' + 'Install them with `pip install .[visualization]`.') # a short trace without the need for repeating scenarios is preferred self._try_to_reach_full_coverage(allow_duplicate_scenarios=False) @@ -108,15 +119,16 @@ def process_test_suite(self, in_suite: Suite, *, seed: any = 'new') -> Suite: "Direct trace not available. Allowing repetition of scenarios") self._try_to_reach_full_coverage(allow_duplicate_scenarios=True) if not self.tracestate.coverage_reached(): - logger.write( - self.visualiser.generate_visualisation(), html=True) + if self.visualiser is not None: + logger.write(self.visualiser.generate_visualisation(), html=True) raise Exception("Unable to compose a consistent suite") self.out_suite.scenarios = self.tracestate.get_trace() self._report_tracestate_wrapup() - self.visualiser.set_start(ScenarioInfo(self.tracestate.get_trace()[0])) - self.visualiser.set_end(ScenarioInfo(self.tracestate.get_trace()[-1])) + if self.visualiser is not None: + self.visualiser.set_final_trace(TraceInfo.from_trace_state(self.tracestate, self.active_model)) + logger.write(self.visualiser.generate_visualisation(), html=True) return self.out_suite @@ -153,8 +165,8 @@ def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool): self._report_tracestate_to_user() logger.debug( f"last state:\n{self.active_model.get_status_text()}") - self.visualiser.update_visualisation( - TraceInfo.from_trace_state(self.tracestate, self.active_model)) + if self.visualiser is not None: + self.visualiser.update_visualisation(TraceInfo.from_trace_state(self.tracestate, self.active_model)) def __last_candidate_changed_nothing(self) -> bool: if len(self.tracestate) < 2: diff --git a/robotmbt/suitereplacer.py b/robotmbt/suitereplacer.py index 837f40b0..de4a0ee8 100644 --- a/robotmbt/suitereplacer.py +++ b/robotmbt/suitereplacer.py @@ -100,11 +100,6 @@ def treat_model_based(self, **kwargs): self.__clearTestSuite(self.robot_suite) self.__generateRobotSuite(modelbased_suite, self.robot_suite) - # TODO: add flag using kwargs to disable this - if isinstance(self.processor_lib, SuiteProcessors): - logger.write( - self.processor_lib.visualiser.generate_visualisation(), html=True) - @keyword("Set model-based options") def set_model_based_options(self, **kwargs): """ @@ -127,14 +122,14 @@ def __process_robot_suite(self, in_suite: Suite, parent: Suite | None) -> Suite: if in_suite.setup and parent is not None: step_info = Step(in_suite.setup.name, * - in_suite.setup.args, parent=out_suite) + in_suite.setup.args, parent=out_suite) step_info.add_robot_dependent_data( Robot._namespace.get_runner(step_info.org_step).keyword) out_suite.setup = step_info if in_suite.teardown and parent is not None: step_info = Step(in_suite.teardown.name, * - in_suite.teardown.args, parent=out_suite) + in_suite.teardown.args, parent=out_suite) step_info.add_robot_dependent_data( Robot._namespace.get_runner(step_info.org_step).keyword) out_suite.teardown = step_info @@ -154,7 +149,7 @@ def __process_robot_suite(self, in_suite: Suite, parent: Suite | None) -> Suite: if tc.teardown: step_info = Step(tc.teardown.name, * - tc.teardown.args, parent=scenario) + tc.teardown.args, parent=scenario) step_info.add_robot_dependent_data( Robot._namespace.get_runner(step_info.org_step).keyword) scenario.teardown = step_info diff --git a/robotmbt/tracestate.py b/robotmbt/tracestate.py index 9e8dbb5f..362375d7 100644 --- a/robotmbt/tracestate.py +++ b/robotmbt/tracestate.py @@ -44,13 +44,13 @@ class TraceState: def __init__(self, n_scenarios: int): # coverage pool: True means scenario is in trace self._c_pool: list[bool] = [False] * n_scenarios - + # Keeps track of the scenarios already tried at each step in the trace self._tried: list[list[int]] = [[]] - + # Choice trace, when was which scenario inserted (e.g. ['1', '2.1', '3', '2.0']) self._trace: list[str] = [] - + # Keeps details for elements in trace self._snapshots: list[TraceSnapShot] = [] self._open_refinements: list[int] = [] @@ -80,14 +80,14 @@ def next_candidate(self, retry: bool = False) -> int | None: for i in range(len(self._c_pool)): if i not in self._tried[-1] and not self._is_refinement_active(i) and self.count(i) == 0: return i - + if not retry: return None - + for i in range(len(self._c_pool)): if i not in self._tried[-1] and not self._is_refinement_active(i): return i - + return None def count(self, index: int) -> int: @@ -98,13 +98,13 @@ def count(self, index: int) -> int: def highest_part(self, index: int) -> int: """Given the current trace and an index, returns the highest part number of an ongoing refinement for the related scenario. Returns 0 when there is no refinement active.""" - for i in range(1, len(self._trace)+1): + for i in range(1, len(self._trace) + 1): if self._trace[-i] == f'{index}': return 0 - + if self._trace[-i].startswith(f'{index}.'): return int(self._trace[-i].split('.')[1]) - + return 0 def _is_refinement_active(self, index: int) -> bool: @@ -113,9 +113,9 @@ def _is_refinement_active(self, index: int) -> bool: def find_scenarios_with_active_refinement(self) -> list[str | Scenario]: scenarios = [] for i in self._open_refinements: - index = -self._trace[::-1].index(f'{i}.1')-1 + index = -self._trace[::-1].index(f'{i}.1') - 1 scenarios.append(self._snapshots[index].scenario) - + return scenarios def reject_scenario(self, i_scenario: int): @@ -127,8 +127,8 @@ def confirm_full_scenario(self, index: int, scenario: str, model: dict[str, int] self._c_pool[index] = True c_drought = 0 else: - c_drought = self.coverage_drought+1 - + c_drought = self.coverage_drought + 1 + if self._is_refinement_active(index): id = f"{index}.0" self._open_refinements.pop() @@ -136,14 +136,14 @@ def confirm_full_scenario(self, index: int, scenario: str, model: dict[str, int] id = str(index) self._tried[-1].append(index) self._tried.append([]) - + self._trace.append(id) self._snapshots.append(TraceSnapShot(id, scenario, model, c_drought)) def push_partial_scenario(self, index: int, scenario: str, model: dict[str, int]): if self._is_refinement_active(index): - id = f"{index}.{self.highest_part(index)+1}" - + id = f"{index}.{self.highest_part(index) + 1}" + else: id = f"{index}.1" self._tried[-1].append(index) @@ -171,10 +171,10 @@ def rewind(self) -> TraceSnapShot | None: if self.count(index) == 0: self._c_pool[index] = False self._tried.pop() - + if id.endswith('.1'): self._open_refinements.pop() - + return self._snapshots[-1] if self._snapshots else None def __iter__(self): diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index 1fb0625b..d9c661fb 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -1,3 +1,4 @@ +from abc import ABC, abstractmethod from robotmbt.modelspace import ModelSpace from robotmbt.suitedata import Scenario from robotmbt.tracestate import TraceState @@ -22,7 +23,37 @@ def __init__(self, scenario: Scenario | str): self.src_id = scenario def __str__(self): - return f"Scen{self.src_id}: {self.name}" + return f"Scenario {self.src_id}: {self.name}" + + +class StateInfo: + """ + This contains all information we need from states, abstracting away from the actual ModelSpace class: + - domain + - properties + """ + + def __init__(self, state: ModelSpace): + self.domain = state.ref_id + self.properties = {} + for p in state.props: + self.properties[p] = {} + if p == 'scenario': + self.properties['scenario'] = dict(state.props['scenario']) + else: + for attr in dir(state.props[p]): + self.properties[p][attr] = getattr(state.props[p], attr) + + def __eq__(self, other): + return self.domain == other.domain and self.properties == other.properties + + def __str__(self): + res = "" + for p in self.properties: + res += f"{p}:\n" + for k, v in self.properties[p].items(): + res += f"\t{k}={v}\n" + return res class TraceInfo: @@ -31,44 +62,93 @@ class TraceInfo: - trace: the strung together scenarios up until this point - state: the model space """ + @classmethod def from_trace_state(cls, trace: TraceState, state: ModelSpace): return cls([ScenarioInfo(t) for t in trace.get_trace()], state) - def __init__(self, trace :list[ScenarioInfo], state :ModelSpace): - self.trace :list[ScenarioInfo] = trace - # TODO: actually use state - self.state :ModelSpace = state - + def __init__(self, trace: list[ScenarioInfo], state: ModelSpace): + self.trace: list[ScenarioInfo] = trace + self.state = StateInfo(state) + def __repr__(self) -> str: return f"TraceInfo(trace=[{[str(t) for t in self.trace]}], state={self.state})" - def contains_scenario(self, scen_name :str) -> bool: + def contains_scenario(self, scen_name: str) -> bool: for scen in self.trace: if scen.name == scen_name: return True return False - def add_scenario(self, scen :ScenarioInfo): + def add_scenario(self, scen: ScenarioInfo): """ Used in acceptance testing """ self.trace.append(scen) - def get_scenario(self, scen_name :str) -> ScenarioInfo|None: + def get_scenario(self, scen_name: str) -> ScenarioInfo | None: for scenario in self.trace: if scenario.name == scen_name: return scenario return None - def insert_trace_at(self, index :int, scen_info :ScenarioInfo): + def insert_trace_at(self, index: int, scen_info: ScenarioInfo): if index < 0 or index >= len(self.trace): raise IndexError(f"InsertTraceAt received invalid index ({index}) for length of list ({len(self.trace)})") self.trace.insert(index, scen_info) -class ScenarioGraph: +class AbstractGraph(ABC): + @abstractmethod + def update_visualisation(self, info: TraceInfo): + """ + Update the visualisation with new trace information from another exploration step. + """ + pass + + @abstractmethod + def set_final_trace(self, info: TraceInfo): + """ + Update the graph with information on the final trace. + """ + pass + + @abstractmethod + def calculate_pos(self): + """ + Calculate the position (x, y) for all nodes in self.networkx + """ + pass + + @property + @abstractmethod + def networkx(self) -> nx.DiGraph: + """ + We use networkx to store nodes and edges. + """ + pass + + @networkx.setter + @abstractmethod + def networkx(self, value: nx.DiGraph): + pass + + @property + @abstractmethod + def pos(self) -> dict: + """ + A dictionary with the positions of nodes. + """ + pass + + @pos.setter + @abstractmethod + def pos(self, value: dict): + pass + + +class ScenarioGraph(AbstractGraph): """ The scenario graph is the most basic representation of trace exploration. It represents scenarios as nodes, and the trace as edges. @@ -92,15 +172,14 @@ def __init__(self): def update_visualisation(self, info: TraceInfo): """ - Update the visualisation with new trace information from another exploration step. This will add nodes for all new scenarios in the provided trace, as well as edges for all pairs in the provided trace. """ for i in range(0, len(info.trace) - 1): from_node = self._get_or_create_id(info.trace[i]) to_node = self._get_or_create_id(info.trace[i + 1]) - self.add_node(from_node) - self.add_node(to_node) + self._add_node(from_node) + self._add_node(to_node) if (from_node, to_node) not in self.networkx.edges: self.networkx.add_edge( @@ -119,33 +198,150 @@ def _get_or_create_id(self, scenario: ScenarioInfo) -> str: self.ids[new_id] = scenario return new_id - def add_node(self, node: str): + def _add_node(self, node: str): """ - Add node if it doesn't already exist + Add node if it doesn't already exist. """ if node not in self.networkx.nodes: self.networkx.add_node(node, label=self.ids[node].name) - def set_starting_node(self, scenario: ScenarioInfo): + def _set_starting_node(self, scenario: ScenarioInfo): """ Update the starting node. """ node = self._get_or_create_id(scenario) - self.add_node(node) + self._add_node(node) self.networkx.add_edge('start', node, label='') - def set_ending_node(self, scenario: ScenarioInfo): + def _set_ending_node(self, scenario: ScenarioInfo): """ Update the end node. """ self.end_node = self._get_or_create_id(scenario) + def set_final_trace(self, info: TraceInfo): + """ + Update the graph with information on the final trace. + """ + self._set_starting_node(info.trace[0]) + self._set_ending_node(info.trace[-1]) + def calculate_pos(self): + try: + self.pos = nx.planar_layout(self.networkx) + except nx.NetworkXException: + # if planar layout cannot find a graph without crossing edges + self.pos = nx.arf_layout(self.networkx, seed=42) + + @property + def networkx(self) -> nx.DiGraph: + return self._networkx + + @networkx.setter + def networkx(self, value: nx.DiGraph): + self._networkx = value + + @property + def pos(self) -> dict: + return self._pos + + @pos.setter + def pos(self, value: dict): + self._pos = value + + +class StateGraph(AbstractGraph): + """ + The state graph is a more advanced representation of trace exploration, allowing you to see the internal state. + It represents states as nodes, and scenarios as edges. + """ + + def __init__(self): + # We use simplified IDs for nodes, and store the actual state info here + self.ids: dict[str, StateInfo] = {} + + # The networkx graph is a directional graph + self.networkx = nx.DiGraph() + + # Stores the position (x, y) of the nodes + self.pos = {} + + # add the start node + self.networkx.add_node('start', label='start') + + self.prev_state = StateInfo(ModelSpace()) + self.prev_trace_len = 0 + + def update_visualisation(self, info: TraceInfo): """ - Calculate the position (x, y) for all nodes in self.networkx + This will add nodes the newly reached state (if we did not roll back), as well as an edge from the previous to + the current state labeled with the scenario that took it there. """ + if len(info.trace) > 0: + scenario = info.trace[-1] + + from_node = self._get_or_create_id(self.prev_state) + if len(info.trace) == 1: + from_node = 'start' + to_node = self._get_or_create_id(info.state) + + self._add_node(from_node) + self._add_node(to_node) + + if self.prev_trace_len < len(info.trace): + if (from_node, to_node) not in self.networkx.edges: + self.networkx.add_edge(from_node, to_node, label=scenario.name) + + self.prev_state = info.state + self.prev_trace_len = len(info.trace) + + def _get_or_create_id(self, state: StateInfo) -> str: + """ + Get the ID for a state that has been added before, or create and store a new one. + """ + for i in self.ids.keys(): + if self.ids[i] == state: + return i + + new_id = f"node{len(self.ids)}" + self.ids[new_id] = state + return new_id + + def _add_node(self, node: str): + """ + Add node if it doesn't already exist. + """ + if node not in self.networkx.nodes: + self.networkx.add_node(node, label=str(self.ids[node])) + + def set_final_trace(self, info: TraceInfo): + self._set_ending_node(info.state) + + def _set_ending_node(self, state: StateInfo): + """ + Update the end node. + """ + self.end_node = self._get_or_create_id(state) + + def calculate_pos(self): try: self.pos = nx.planar_layout(self.networkx) except nx.NetworkXException: # if planar layout cannot find a graph without crossing edges self.pos = nx.arf_layout(self.networkx, seed=42) + + @property + def networkx(self) -> nx.DiGraph: + return self._networkx + + @networkx.setter + def networkx(self, value: nx.DiGraph): + self._networkx = value + + @property + def pos(self) -> dict: + return self._pos + + @pos.setter + def pos(self, value: dict): + self._pos = value diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py index e18a5ded..5e81eec1 100644 --- a/robotmbt/visualise/visualiser.py +++ b/robotmbt/visualise/visualiser.py @@ -1,8 +1,8 @@ -from robotmbt.visualise.models import ScenarioGraph, TraceInfo, ScenarioInfo +from robotmbt.visualise.models import AbstractGraph, ScenarioGraph, StateGraph, TraceInfo from bokeh.palettes import Spectral4 from bokeh.models import ( Plot, Range1d, Circle, Rect, - Arrow, NormalHead, LabelSet, + Arrow, NormalHead, Bezier, ColumnDataSource, ResetTool, SaveTool, WheelZoomTool, PanTool, Text ) @@ -12,6 +12,7 @@ import html import networkx as nx + class Visualiser: """ The Visualiser class bridges the different concerns to provide @@ -24,27 +25,29 @@ class Visualiser: # glue method to let us construct Visualiser objects in Robot tests. @classmethod - def construct(cls): - return cls() # just calls __init__, but without having underscores etc. + def construct(cls, graph_type: str): + return cls(graph_type) # just calls __init__, but without having underscores etc. - def __init__(self): - self.graph = ScenarioGraph() + def __init__(self, graph_type: str): + if graph_type == 'scenario': + self.graph: AbstractGraph = ScenarioGraph() + elif graph_type == 'state': + self.graph: AbstractGraph = StateGraph() + else: + raise ValueError(f"Unknown graph type: {graph_type}!") def update_visualisation(self, info: TraceInfo): self.graph.update_visualisation(info) - def set_start(self, scenario: ScenarioInfo): - self.graph.set_starting_node(scenario) - - def set_end(self, scenario: ScenarioInfo): - self.graph.set_ending_node(scenario) + def set_final_trace(self, info: TraceInfo): + self.graph.set_final_trace(info) def generate_visualisation(self) -> str: self.graph.calculate_pos() - networkvisualiser = NetworkVisualiser(self.graph) - html_bokeh = networkvisualiser.generate_html() + html_bokeh = NetworkVisualiser(self.graph).generate_html() return f"" + class NetworkVisualiser: """ Generate plot with Bokeh @@ -55,7 +58,7 @@ class NetworkVisualiser: EDGE_COLOUR: str | tuple[int, int, int] = (12, 12, 12) # 'visual studio black' ARROWHEAD_SIZE: int = 6 # Consistent arrowhead size - def __init__(self, graph: ScenarioGraph): + def __init__(self, graph: AbstractGraph): self.plot = None self.graph = graph self.node_props = {} # Store node properties for arrow calculations @@ -89,9 +92,9 @@ def _initialise_plot(self): y_max = max(y_range) + padding * (max(y_range) - min(y_range)) # scale node radius based on range - nodes_range = max(x_max-x_min, y_max-y_min) + nodes_range = max(x_max - x_min, y_max - y_min) self.node_radius = nodes_range / 150 - self.char_width = nodes_range / 150 + self.char_width = nodes_range / 150 self.char_height = nodes_range / 150 # create plot @@ -112,10 +115,10 @@ def _calculate_text_dimensions(self, text: str) -> tuple[float, float]: # Calculate width based on character count text_length = len(text) width = (text_length * self.char_width) + (2 * self.padding) - + # Reduced height for more compact rectangles - height = self.char_height + (self.padding) - + height = self.char_height + self.padding + return width, height def _add_nodes_with_labels(self): @@ -123,93 +126,94 @@ def _add_nodes_with_labels(self): Add nodes with text labels inside them """ node_labels = nx.get_node_attributes(self.graph.networkx, "label") - + # Create data sources for nodes and labels circle_data = dict(x=[], y=[], radius=[], label=[]) rect_data = dict(x=[], y=[], width=[], height=[], label=[]) text_data = dict(x=[], y=[], text=[]) - + for node in self.graph.networkx.nodes: # Labels are always defined and cannot be lists label = node_labels[node] label = self._cap_name(label) x, y = self.graph.pos[node] - + if node == 'start': # For start node (circle), calculate radius based on text width text_width, text_height = self._calculate_text_dimensions(label) # Calculate radius from text dimensions radius = (text_width / 2.5) - + circle_data['x'].append(x) circle_data['y'].append(y) circle_data['radius'].append(radius) circle_data['label'].append(label) - + # Store node properties for arrow calculations self.node_props[node] = {'type': 'circle', 'x': x, 'y': y, 'radius': radius, 'label': label} - + else: # For scenario nodes (rectangles), calculate dimensions based on text text_width, text_height = self._calculate_text_dimensions(label) - + rect_data['x'].append(x) rect_data['y'].append(y) rect_data['width'].append(text_width) rect_data['height'].append(text_height) rect_data['label'].append(label) - + # Store node properties for arrow calculations - self.node_props[node] = {'type': 'rect', 'x': x, 'y': y, 'width': text_width, 'height': text_height, 'label': label} - + self.node_props[node] = {'type': 'rect', 'x': x, 'y': y, 'width': text_width, 'height': text_height, + 'label': label} + # Add text for all nodes text_data['x'].append(x) text_data['y'].append(y) text_data['text'].append(label) - + # Add circles for start node if circle_data['x']: circle_source = ColumnDataSource(circle_data) - circles = Circle(x='x', y='y', radius='radius', - fill_color=Spectral4[0]) + circles = Circle(x='x', y='y', radius='radius', + fill_color=Spectral4[0]) self.plot.add_glyph(circle_source, circles) - + # Add rectangles for scenario nodes if rect_data['x']: rect_source = ColumnDataSource(rect_data) rectangles = Rect(x='x', y='y', width='width', height='height', - fill_color=Spectral4[0]) + fill_color=Spectral4[0]) self.plot.add_glyph(rect_source, rectangles) - + # Add text labels for all nodes text_source = ColumnDataSource(text_data) text_labels = Text(x='x', y='y', text='text', - text_align='center', text_baseline='middle', - text_color='white', text_font_size='9pt') + text_align='center', text_baseline='middle', + text_color='white', text_font_size='9pt') self.plot.add_glyph(text_source, text_labels) def _get_edge_points(self, start_node, end_node): """Calculate edge start and end points at node borders""" start_props = self.node_props.get(start_node) end_props = self.node_props.get(end_node) - + # Node properties should always exist if not start_props or not end_props: raise ValueError(f"Node properties not found for nodes: {start_node}, {end_node}") - + # Calculate direction vector dx = end_props['x'] - start_props['x'] dy = end_props['y'] - start_props['y'] - distance = sqrt(dx*dx + dy*dy) - + distance = sqrt(dx * dx + dy * dy) + # Self-loops are handled separately, distance should never be 0 if distance == 0: raise ValueError("Distance between different nodes should not be zero") - + # Normalize direction vector dx /= distance dy /= distance - + # Calculate start point at border if start_props['type'] == 'circle': start_x = start_props['x'] + dx * start_props['radius'] @@ -218,17 +222,17 @@ def _get_edge_points(self, start_node, end_node): # Find where the line intersects the rectangle border rect_width = start_props['width'] rect_height = start_props['height'] - + # Calculate scaling factors for x and y directions scale_x = rect_width / (2 * abs(dx)) if dx != 0 else float('inf') scale_y = rect_height / (2 * abs(dy)) if dy != 0 else float('inf') - + # Use the smaller scale to ensure we hit the border scale = min(scale_x, scale_y) - + start_x = start_props['x'] + dx * scale start_y = start_props['y'] + dy * scale - + # Calculate end point at border (reverse direction) # End nodes should never be circles for regular edges if end_props['type'] == 'circle': @@ -236,17 +240,17 @@ def _get_edge_points(self, start_node, end_node): else: rect_width = end_props['width'] rect_height = end_props['height'] - + # Calculate scaling factors for x and y directions (reverse) scale_x = rect_width / (2 * abs(dx)) if dx != 0 else float('inf') scale_y = rect_height / (2 * abs(dy)) if dy != 0 else float('inf') - + # Use the smaller scale to ensure we hit the border scale = min(scale_x, scale_y) - + end_x = end_props['x'] - dx * scale end_y = end_props['y'] - dy * scale - + return start_x, start_y, end_x, end_y def add_self_loop(self, node_id: str): @@ -257,37 +261,37 @@ def add_self_loop(self, node_id: str): """ # Get node properties directly by node ID node_props = self.node_props.get(node_id) - + # Node properties should always exist if node_props is None: raise ValueError(f"Node properties not found for node: {node_id}") - + # Self-loops should only be for rectangle nodes (scenarios) if node_props['type'] != 'rect': raise ValueError(f"Self-loops should only be for rectangle nodes, got: {node_props['type']}") - + x, y = node_props['x'], node_props['y'] width = node_props['width'] height = node_props['height'] - + # Start: 1/4 width from left, top side - start_x = x - width/4 - start_y = y + height/2 - + start_x = x - width / 4 + start_y = y + height / 2 + # End: 3/4 width from left, top side - end_x = x + width/4 - end_y = y + height/2 - + end_x = x + width / 4 + end_y = y + height / 2 + # Arc height above the rectangle arc_height = width * 0.4 - + # Control points for a circular arc above - control1_x = x - width/8 - control1_y = y + height/2 + arc_height - - control2_x = x + width/8 - control2_y = y + height/2 + arc_height - + control1_x = x - width / 8 + control1_y = y + height / 2 + arc_height + + control2_x = x + width / 8 + control2_y = y + height / 2 + arc_height + # Create the Bezier curve (the main arc) with the same thickness as straight lines loop = Bezier( x0=start_x, y0=start_y, @@ -304,13 +308,13 @@ def add_self_loop(self, node_id: str): # For a cubic Bezier, the tangent at the end point is from the last control point to the end point tangent_x = end_x - control2_x tangent_y = end_y - control2_y - + # Normalize the tangent vector - tangent_length = sqrt(tangent_x**2 + tangent_y**2) + tangent_length = sqrt(tangent_x ** 2 + tangent_y ** 2) if tangent_length > 0: tangent_x /= tangent_length tangent_y /= tangent_length - + # Add just the arrowhead (NormalHead) at the end point, oriented along the tangent arrowhead = NormalHead( size=NetworkVisualiser.ARROWHEAD_SIZE, @@ -318,7 +322,7 @@ def add_self_loop(self, node_id: str): fill_color=NetworkVisualiser.EDGE_COLOUR, line_width=NetworkVisualiser.EDGE_WIDTH ) - + # Create a standalone arrowhead at the end point # Strategy: use a very short Arrow that's essentially just the head arrow = Arrow( @@ -335,32 +339,32 @@ def add_self_loop(self, node_id: str): # Add edge label - positioned above the arc label_x = x - label_y = y + height/2 + arc_height * 0.6 + label_y = y + height / 2 + arc_height * 0.6 return label_x, label_y def _add_edges(self): edge_labels = nx.get_edge_attributes(self.graph.networkx, "label") - + # Create data sources for edges and edge labels edge_text_data = dict(x=[], y=[], text=[]) - + for edge in self.graph.networkx.edges(): # Edge labels are always defined and cannot be lists edge_label = edge_labels[edge] edge_label = self._cap_name(edge_label) edge_text_data['text'].append(edge_label) - + if edge[0] == edge[1]: # Self-loop handled separately label_x, label_y = self.add_self_loop(edge[0]) edge_text_data['x'].append(label_x) edge_text_data['y'].append(label_y) - + else: # Calculate edge points at node borders start_x, start_y, end_x, end_y = self._get_edge_points(edge[0], edge[1]) - + # Add arrow between the calculated points arrow = Arrow( end=NormalHead( @@ -375,18 +379,17 @@ def _add_edges(self): # Collect edge label data (position at midpoint) edge_text_data['x'].append((start_x + end_x) / 2) edge_text_data['y'].append((start_y + end_y) / 2) - + # Add all edge labels at once if edge_text_data['x']: edge_text_source = ColumnDataSource(edge_text_data) edge_labels_glyph = Text(x='x', y='y', text='text', - text_align='center', text_baseline='middle', - text_font_size='7pt') + text_align='center', text_baseline='middle', + text_font_size='7pt') self.plot.add_glyph(edge_text_source, edge_labels_glyph) - @staticmethod - def _cap_name(name: str) -> str: - if len(name) < Visualiser.MAX_VERTEX_NAME_LEN: + def _cap_name(self, name: str) -> str: + if len(name) < Visualiser.MAX_VERTEX_NAME_LEN or isinstance(self.graph, StateGraph): return name - return f"{name[:(Visualiser.MAX_VERTEX_NAME_LEN-3)]}..." \ No newline at end of file + return f"{name[:(Visualiser.MAX_VERTEX_NAME_LEN - 3)]}..." diff --git a/run_tests.py b/run_tests.py index 3289d173..3eb8bba0 100644 --- a/run_tests.py +++ b/run_tests.py @@ -27,8 +27,8 @@ if utest: utestrun = unittest.main(module=None, - argv=[__file__, 'discover', os.path.join(THIS_DIR, 'utest')], - exit=False) + argv=[__file__, 'discover', os.path.join(THIS_DIR, 'utest')], + exit=False) if not utestrun.result.wasSuccessful(): sys.exit(1) @@ -40,10 +40,10 @@ # Adding the robotframeworkMBT folder to the python path forces the development # version to be used instead of the one installed on your system. You will also # need to add this path to your IDE options when running from there. - exit_code :int = robot.run_cli(['--outputdir', OUTPUT_ROOT, # type: ignore - '--pythonpath', THIS_DIR] - + sys.argv[1:], exit=False) + exit_code: int = robot.run_cli(['--outputdir', OUTPUT_ROOT, # type: ignore + '--pythonpath', THIS_DIR] + + sys.argv[1:], exit=False) if utest: print(f"Also ran {utestrun.result.testsRun} unit tests") - + sys.exit(exit_code) diff --git a/utest/test_visualise_models.py b/utest/test_visualise_models.py index f4db9cda..f52e24e6 100644 --- a/utest/test_visualise_models.py +++ b/utest/test_visualise_models.py @@ -1,10 +1,12 @@ import unittest -import networkx as nx -from robotmbt.tracestate import TraceState -from robotmbt.visualise.models import * - - -class TestVisualiseModels(unittest.TestCase): +try: + from robotmbt.visualise.models import * + VISUALISE = True +except ImportError: + VISUALISE = False + +if VISUALISE: + class TestVisualiseModels(unittest.TestCase): """ Contains tests for robotmbt/visualise/models.py """ @@ -34,15 +36,15 @@ def test_create_TraceInfo(self): candidates = [] for scenario in range(3): candidates.append(ts.next_candidate()) - ts.confirm_full_scenario(candidates[-1], scenario, {}) + ts.confirm_full_scenario(candidates[-1], str(scenario), {}) ti = TraceInfo.from_trace_state(trace=ts, state=ModelSpace()) - self.assertEqual(ti.trace[0].name, 0) - self.assertEqual(ti.trace[1].name, 1) - self.assertEqual(ti.trace[2].name, 2) + self.assertEqual(ti.trace[0].name, str(0)) + self.assertEqual(ti.trace[1].name, str(1)) + self.assertEqual(ti.trace[2].name, str(2)) self.assertIsNotNone(ti.state) - # TODO: add state tests to this. + # TODO check state """ Class: ScenarioGraph @@ -56,8 +58,8 @@ def test_scenario_graph_init(self): def test_scenario_graph_ids_empty(self): sg = ScenarioGraph() si = ScenarioInfo('test') - id = sg._get_or_create_id(si) - self.assertEqual(id, 'node0') + node_id = sg._get_or_create_id(si) + self.assertEqual(node_id, 'node0') def test_scenario_graph_ids_duplicate_scenario(self): sg = ScenarioGraph() @@ -78,13 +80,13 @@ def test_scenario_graph_ids_different_scenarios(self): def test_scenario_graph_add_new_node(self): sg = ScenarioGraph() sg.ids['test'] = ScenarioInfo('test') - sg.add_node('test') + sg._add_node('test') self.assertIn('test', sg.networkx.nodes) self.assertEqual(sg.networkx.nodes['test']['label'], 'test') def test_scenario_graph_add_existing_node(self): sg = ScenarioGraph() - sg.add_node('start') + sg._add_node('start') self.assertIn('start', sg.networkx.nodes) self.assertEqual(len(sg.networkx.nodes), 1) @@ -93,25 +95,25 @@ def test_scenario_graph_update_visualisation_nodes(self): candidates = [] for scenario in range(3): candidates.append(ts.next_candidate()) - ts.confirm_full_scenario(candidates[-1], scenario, {}) + ts.confirm_full_scenario(candidates[-1], str(scenario), {}) ti = TraceInfo.from_trace_state(ts, ModelSpace()) sg = ScenarioGraph() sg.update_visualisation(ti) self.assertIn('node0', sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes['node0']['label'], 0) + self.assertEqual(sg.networkx.nodes['node0']['label'], str(0)) self.assertIn('node1', sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes['node1']['label'], 1) + self.assertEqual(sg.networkx.nodes['node1']['label'], str(1)) self.assertIn('node2', sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes['node2']['label'], 2) + self.assertEqual(sg.networkx.nodes['node2']['label'], str(2)) def test_scenario_graph_update_visualisation_edges(self): ts = TraceState(3) candidates = [] for scenario in range(3): candidates.append(ts.next_candidate()) - ts.confirm_full_scenario(candidates[-1], scenario, {}) - ti = TraceInfo.from_trace_state(ts, ModelSpace()) + ts.confirm_full_scenario(candidates[-1], str(scenario), {}) + ti = TraceInfo.from_trace_state(trace=ts, state=ModelSpace()) sg = ScenarioGraph() sg.update_visualisation(ti) @@ -137,35 +139,35 @@ def test_scenario_graph_update_visualisation_single_node(self): def test_scenario_graph_set_starting_node_new_node(self): sg = ScenarioGraph() si = ScenarioInfo('test') - sg.set_starting_node(si) - id = sg._get_or_create_id(si) + sg._set_starting_node(si) + node_id = sg._get_or_create_id(si) # node - self.assertIn(id, sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes[id]['label'], 'test') + self.assertIn(node_id, sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes[node_id]['label'], 'test') # edge - self.assertIn(('start', id), sg.networkx.edges) + self.assertIn(('start', node_id), sg.networkx.edges) edge_labels = nx.get_edge_attributes(sg.networkx, "label") - self.assertEqual(edge_labels[('start', id)], '') + self.assertEqual(edge_labels[('start', node_id)], '') def test_scenario_graph_set_starting_node_existing_node(self): sg = ScenarioGraph() si = ScenarioInfo('test') - id = sg._get_or_create_id(si) - sg.add_node(id) - self.assertIn(id, sg.networkx.nodes) + node_id = sg._get_or_create_id(si) + sg._add_node(node_id) + self.assertIn(node_id, sg.networkx.nodes) - sg.set_starting_node(si) - self.assertIn(('start', id), sg.networkx.edges) + sg._set_starting_node(si) + self.assertIn(('start', node_id), sg.networkx.edges) edge_labels = nx.get_edge_attributes(sg.networkx, "label") - self.assertEqual(edge_labels[('start', id)], '') + self.assertEqual(edge_labels[('start', node_id)], '') def test_scenario_graph_set_end_node(self): sg = ScenarioGraph() si = ScenarioInfo('test') - id = sg._get_or_create_id(si) - sg.set_ending_node(si) - self.assertEqual(sg.end_node, id) + node_id = sg._get_or_create_id(si) + sg._set_ending_node(si) + self.assertEqual(sg.end_node, node_id) if __name__ == '__main__': From 407a06f82cda845c971f8c77d93350f90099ddfc Mon Sep 17 00:00:00 2001 From: Jonathan <61787386+JWillegers@users.noreply.github.com> Date: Fri, 21 Nov 2025 11:41:25 +0100 Subject: [PATCH 071/131] Restructure (#22) * Seperate network_visualiser from visualiser * - renamed pos to graph_layout - moved graph_layout from models to network_visualiser * refactor file name to not contain underscore * taking the graphs classes out of models.py * split unit tests * added test for scenariograph.set_final_trace * add arguments to atests * correct orientation for bfs --- atest/resources/helpers/modelgenerator.py | 9 +- atest/resources/visualisation.resource | 2 +- .../01__then_to_given_linking.robot | 2 +- .../02__swapped_then_to_given_linking.robot | 2 +- ...single_when_to_given_step_refinement.robot | 2 +- .../05__composed_scenario.robot | 2 +- .../01__single_repetition.robot | 2 +- .../04__multi_repetition.robot | 2 +- .../05__paired_repetition.robot | 2 +- .../06__repetition_twin_with_tail.robot | 2 +- .../07__repetition_with_refinement.robot | 2 +- robotmbt/suiteprocessors.py | 9 +- robotmbt/visualise/graphs/__init__.py | 0 robotmbt/visualise/graphs/abstractgraph.py | 32 ++ robotmbt/visualise/graphs/scenariograph.py | 87 ++++ robotmbt/visualise/graphs/stategraph.py | 84 ++++ robotmbt/visualise/models.py | 257 +----------- robotmbt/visualise/networkvisualiser.py | 387 ++++++++++++++++++ robotmbt/visualise/visualiser.py | 376 +---------------- utest/test_visualise_models.py | 179 ++------ utest/test_visualise_scenariograph.py | 148 +++++++ utest/test_visualise_stategraph.py | 19 + 22 files changed, 822 insertions(+), 785 deletions(-) create mode 100644 robotmbt/visualise/graphs/__init__.py create mode 100644 robotmbt/visualise/graphs/abstractgraph.py create mode 100644 robotmbt/visualise/graphs/scenariograph.py create mode 100644 robotmbt/visualise/graphs/stategraph.py create mode 100644 robotmbt/visualise/networkvisualiser.py create mode 100644 utest/test_visualise_scenariograph.py create mode 100644 utest/test_visualise_stategraph.py diff --git a/atest/resources/helpers/modelgenerator.py b/atest/resources/helpers/modelgenerator.py index 18b8a1b8..58339df8 100644 --- a/atest/resources/helpers/modelgenerator.py +++ b/atest/resources/helpers/modelgenerator.py @@ -3,14 +3,16 @@ from robot.api.deco import keyword # type:ignore from robotmbt.modelspace import ModelSpace -from robotmbt.visualise.models import TraceInfo, ScenarioInfo, ScenarioGraph +from robotmbt.visualise.models import TraceInfo, ScenarioInfo +from robotmbt.visualise.graphs.scenariograph import ScenarioGraph class ModelGenerator: @keyword(name="Generate Trace Information") # type: ignore def generate_trace_info(self, scenario_count: int) -> TraceInfo: """Generates a list of unique random scenarios.""" - scenarios: list[ScenarioInfo] = ModelGenerator.generate_scenario_names(scenario_count) + scenarios: list[ScenarioInfo] = ModelGenerator.generate_scenario_names( + scenario_count) return TraceInfo(scenarios, ModelSpace()) @@ -28,7 +30,8 @@ def ensure_scenario_follows(self, trace_info: TraceInfo, scen1: str, scen2: str) scen2_info: ScenarioInfo | None = trace_info.get_scenario(scen2) if scen1_info is None or scen2_info is None: - raise Exception(f"Ensure Scenario Follows for scenarios that did not exist! scen1={scen1}, scen2={scen2}") + raise Exception( + f"Ensure Scenario Follows for scenarios that did not exist! scen1={scen1}, scen2={scen2}") # both scenarios apparently exist, now make sure that scenario2 follows after some appearance of scenario 1: scen1_index: int = trace_info.trace.index(scen1_info) diff --git a/atest/resources/visualisation.resource b/atest/resources/visualisation.resource index 0dc9af9e..2e883d26 100644 --- a/atest/resources/visualisation.resource +++ b/atest/resources/visualisation.resource @@ -40,7 +40,7 @@ In Test Suite ${suite}, scenario ${s2} can be reached from ${s1} Set Suite Variable ${trace_info} Graph ${graph} of type ${graph_type} is generated based on Test Suite ${suite} - [Documentation] Generates the graph + [Documentation] Generates the graph ... :IN: graph = ${graph}, graph_type = ${graph_type}, suite = ${suite} ... :OUT: None Variable Should Exist ${trace_info} diff --git a/atest/robotMBT tests/04__scenario_linking/01__then_to_given_linking.robot b/atest/robotMBT tests/04__scenario_linking/01__then_to_given_linking.robot index b0ecf40d..13075c71 100644 --- a/atest/robotMBT tests/04__scenario_linking/01__then_to_given_linking.robot +++ b/atest/robotMBT tests/04__scenario_linking/01__then_to_given_linking.robot @@ -4,7 +4,7 @@ Documentation This test suite demonstrates direct one-on-one linking of scen ... the trailing scenario. ... ... Note that this test suite would also pass when run in Robot Framework without additional processing. -Suite Setup Treat this test suite Model-based +Suite Setup Treat this test suite Model-based graph=scenario Resource ../../resources/birthday_cards_flat.resource Library robotmbt diff --git a/atest/robotMBT tests/04__scenario_linking/02__swapped_then_to_given_linking.robot b/atest/robotMBT tests/04__scenario_linking/02__swapped_then_to_given_linking.robot index c8619356..e587223e 100644 --- a/atest/robotMBT tests/04__scenario_linking/02__swapped_then_to_given_linking.robot +++ b/atest/robotMBT tests/04__scenario_linking/02__swapped_then_to_given_linking.robot @@ -5,7 +5,7 @@ Documentation This test suite demonstrates direct one-on-one linking of scen ... reverse order, this test suite would fail in a regular Robot Framework ... test run. It passes when the model figures out the dependency between ... the test cases and swaps their order. -Suite Setup Treat this test suite Model-based +Suite Setup Treat this test suite Model-based graph=scenario Resource ../../resources/birthday_cards_flat.resource Library robotmbt diff --git a/atest/robotMBT tests/04__scenario_linking/04__single_when_to_given_step_refinement.robot b/atest/robotMBT tests/04__scenario_linking/04__single_when_to_given_step_refinement.robot index 2fc8c87e..d2cef617 100644 --- a/atest/robotMBT tests/04__scenario_linking/04__single_when_to_given_step_refinement.robot +++ b/atest/robotMBT tests/04__scenario_linking/04__single_when_to_given_step_refinement.robot @@ -7,7 +7,7 @@ Documentation This suite demonstrates step refinement in its simplest form. ... the _WHEN_ step. For this to be successful, the _WHEN_ step from the ... high-level scenario must match the _GIVEN_ step of the refinement ... scenario. -Suite Setup Treat this test suite Model-based +Suite Setup Treat this test suite Model-based graph=scenario Resource ../../resources/birthday_cards_composed.resource Library robotmbt diff --git a/atest/robotMBT tests/04__scenario_linking/05__composed_scenario.robot b/atest/robotMBT tests/04__scenario_linking/05__composed_scenario.robot index a079c13b..2ed8d4a0 100644 --- a/atest/robotMBT tests/04__scenario_linking/05__composed_scenario.robot +++ b/atest/robotMBT tests/04__scenario_linking/05__composed_scenario.robot @@ -3,7 +3,7 @@ Documentation This is a composed scenario where there is a sequence of three ... scenarios on the highest level. The middle of these three scenarios ... has multiple steps that require refinement. One of those refinement ... steps needs refinement of it own, yielding double-layerd refinement. -Suite Setup Treat this test suite Model-based +Suite Setup Treat this test suite Model-based graph=scenario Resource ../../resources/birthday_cards_composed.resource Library robotmbt diff --git a/atest/robotMBT tests/05__repeating_scenarios/01__single_repetition.robot b/atest/robotMBT tests/05__repeating_scenarios/01__single_repetition.robot index 1f41fd95..401cc7ce 100644 --- a/atest/robotMBT tests/05__repeating_scenarios/01__single_repetition.robot +++ b/atest/robotMBT tests/05__repeating_scenarios/01__single_repetition.robot @@ -2,7 +2,7 @@ Documentation This suite consists of 3 scenarios. After inserting the leading ... scenario, the trailing scenario cannot be reached, unless the middle ... scenario is inserted twice. -Suite Setup Treat this test suite Model-based +Suite Setup Treat this test suite Model-based graph=scenario Resource ../../resources/birthday_cards_flat.resource Library robotmbt diff --git a/atest/robotMBT tests/05__repeating_scenarios/04__multi_repetition.robot b/atest/robotMBT tests/05__repeating_scenarios/04__multi_repetition.robot index a08567a8..88c8629c 100644 --- a/atest/robotMBT tests/05__repeating_scenarios/04__multi_repetition.robot +++ b/atest/robotMBT tests/05__repeating_scenarios/04__multi_repetition.robot @@ -1,7 +1,7 @@ *** Settings *** Documentation This suite requires a larger amount of repetitions to reach the final ... scenario. -Suite Setup Treat this test suite Model-based +Suite Setup Treat this test suite Model-based graph=scenario Resource ../../resources/birthday_cards_flat.resource Library robotmbt diff --git a/atest/robotMBT tests/05__repeating_scenarios/05__paired_repetition.robot b/atest/robotMBT tests/05__repeating_scenarios/05__paired_repetition.robot index 39f6c65c..5d9ac59d 100644 --- a/atest/robotMBT tests/05__repeating_scenarios/05__paired_repetition.robot +++ b/atest/robotMBT tests/05__repeating_scenarios/05__paired_repetition.robot @@ -2,7 +2,7 @@ Documentation This suite cannot be completed by repeating a single scenario. Two ... scenarios are linked in such a way that they must be repeated in ... pairs to reach the final scenario. -Suite Setup Treat this test suite Model-based +Suite Setup Treat this test suite Model-based graph=scenario Resource ../../resources/birthday_cards_flat.resource Library robotmbt diff --git a/atest/robotMBT tests/05__repeating_scenarios/06__repetition_twin_with_tail.robot b/atest/robotMBT tests/05__repeating_scenarios/06__repetition_twin_with_tail.robot index f9ec9211..51b479e2 100644 --- a/atest/robotMBT tests/05__repeating_scenarios/06__repetition_twin_with_tail.robot +++ b/atest/robotMBT tests/05__repeating_scenarios/06__repetition_twin_with_tail.robot @@ -3,7 +3,7 @@ Documentation This suite includes multiplicity on multiple ends. Most notabl ... - Two scenarios that are equaly valid to include in the repetition part ... - Two scenarios at the tail end that cannot be reached without the ... \ \ repetitions, but each has a different style entry condition. -Suite Setup Treat this test suite Model-based +Suite Setup Treat this test suite Model-based graph=scenario Resource ../../resources/birthday_cards_flat.resource Library robotmbt diff --git a/atest/robotMBT tests/05__repeating_scenarios/07__repetition_with_refinement.robot b/atest/robotMBT tests/05__repeating_scenarios/07__repetition_with_refinement.robot index 54169443..d7e552e6 100644 --- a/atest/robotMBT tests/05__repeating_scenarios/07__repetition_with_refinement.robot +++ b/atest/robotMBT tests/05__repeating_scenarios/07__repetition_with_refinement.robot @@ -2,7 +2,7 @@ Documentation This suite is similar to the Single repetition scenario, but ... with the difference that this time the repeated scenario has ... a step that requires refinement. -Suite Setup Treat this test suite Model-based +Suite Setup Treat this test suite Model-based graph=scenario Resource ../../resources/birthday_cards_composed.resource Library robotmbt diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index e3c9ecac..c41a4a5a 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -120,14 +120,16 @@ def process_test_suite(self, in_suite: Suite, *, seed: any = 'new', graph: str = self._try_to_reach_full_coverage(allow_duplicate_scenarios=True) if not self.tracestate.coverage_reached(): if self.visualiser is not None: - logger.write(self.visualiser.generate_visualisation(), html=True) + logger.write( + self.visualiser.generate_visualisation(), html=True) raise Exception("Unable to compose a consistent suite") self.out_suite.scenarios = self.tracestate.get_trace() self._report_tracestate_wrapup() if self.visualiser is not None: - self.visualiser.set_final_trace(TraceInfo.from_trace_state(self.tracestate, self.active_model)) + self.visualiser.set_final_trace( + TraceInfo.from_trace_state(self.tracestate, self.active_model)) logger.write(self.visualiser.generate_visualisation(), html=True) return self.out_suite @@ -166,7 +168,8 @@ def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool): logger.debug( f"last state:\n{self.active_model.get_status_text()}") if self.visualiser is not None: - self.visualiser.update_visualisation(TraceInfo.from_trace_state(self.tracestate, self.active_model)) + self.visualiser.update_visualisation( + TraceInfo.from_trace_state(self.tracestate, self.active_model)) def __last_candidate_changed_nothing(self) -> bool: if len(self.tracestate) < 2: diff --git a/robotmbt/visualise/graphs/__init__.py b/robotmbt/visualise/graphs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/robotmbt/visualise/graphs/abstractgraph.py b/robotmbt/visualise/graphs/abstractgraph.py new file mode 100644 index 00000000..a51a9443 --- /dev/null +++ b/robotmbt/visualise/graphs/abstractgraph.py @@ -0,0 +1,32 @@ +from abc import ABC, abstractmethod +from robotmbt.visualise.models import TraceInfo +import networkx as nx + + +class AbstractGraph(ABC): + @abstractmethod + def update_visualisation(self, info: TraceInfo): + """ + Update the visualisation with new trace information from another exploration step. + """ + pass + + @abstractmethod + def set_final_trace(self, info: TraceInfo): + """ + Update the graph with information on the final trace. + """ + pass + + @property + @abstractmethod + def networkx(self) -> nx.DiGraph: + """ + We use networkx to store nodes and edges. + """ + pass + + @networkx.setter + @abstractmethod + def networkx(self, value: nx.DiGraph): + pass diff --git a/robotmbt/visualise/graphs/scenariograph.py b/robotmbt/visualise/graphs/scenariograph.py new file mode 100644 index 00000000..fee43987 --- /dev/null +++ b/robotmbt/visualise/graphs/scenariograph.py @@ -0,0 +1,87 @@ +from robotmbt.visualise.graphs.abstractgraph import AbstractGraph +from robotmbt.visualise.models import TraceInfo, ScenarioInfo +import networkx as nx + + +class ScenarioGraph(AbstractGraph): + """ + The scenario graph is the most basic representation of trace exploration. + It represents scenarios as nodes, and the trace as edges. + """ + + def __init__(self): + # We use simplified IDs for nodes, and store the actual scenario info here + self.ids: dict[str, ScenarioInfo] = {} + + # The networkx graph is a directional graph + self.networkx = nx.DiGraph() + + # add the start node + self.networkx.add_node('start', label='start') + + # indicates last scenario of trace + self.end_node = 'start' + + def update_visualisation(self, info: TraceInfo): + """ + This will add nodes for all new scenarios in the provided trace, as well as edges for all pairs in the provided trace. + """ + for i in range(0, len(info.trace) - 1): + from_node = self._get_or_create_id(info.trace[i]) + to_node = self._get_or_create_id(info.trace[i + 1]) + + self._add_node(from_node) + self._add_node(to_node) + + if (from_node, to_node) not in self.networkx.edges: + self.networkx.add_edge( + from_node, to_node, label='') + + def _get_or_create_id(self, scenario: ScenarioInfo) -> str: + """ + Get the ID for a scenario that has been added before, or create and store a new one. + """ + for i in self.ids.keys(): + # TODO: decide how to deal with repeating scenarios, this merges repeated scenarios into a single scenario + if self.ids[i].src_id == scenario.src_id and scenario.src_id is not None: + return i + + new_id = f"node{len(self.ids)}" + self.ids[new_id] = scenario + return new_id + + def _add_node(self, node: str): + """ + Add node if it doesn't already exist. + """ + if node not in self.networkx.nodes: + self.networkx.add_node(node, label=self.ids[node].name) + + def _set_starting_node(self, scenario: ScenarioInfo): + """ + Update the starting node. + """ + node = self._get_or_create_id(scenario) + self._add_node(node) + self.networkx.add_edge('start', node, label='') + + def _set_ending_node(self, scenario: ScenarioInfo): + """ + Update the end node. + """ + self.end_node = self._get_or_create_id(scenario) + + def set_final_trace(self, info: TraceInfo): + """ + Update the graph with information on the final trace. + """ + self._set_starting_node(info.trace[0]) + self._set_ending_node(info.trace[-1]) + + @property + def networkx(self) -> nx.DiGraph: + return self._networkx + + @networkx.setter + def networkx(self, value: nx.DiGraph): + self._networkx = value diff --git a/robotmbt/visualise/graphs/stategraph.py b/robotmbt/visualise/graphs/stategraph.py new file mode 100644 index 00000000..d23ebcb1 --- /dev/null +++ b/robotmbt/visualise/graphs/stategraph.py @@ -0,0 +1,84 @@ +from robotmbt.visualise.graphs.abstractgraph import AbstractGraph +from robotmbt.visualise.models import TraceInfo, StateInfo +from robotmbt.modelspace import ModelSpace +import networkx as nx + + +class StateGraph(AbstractGraph): + """ + The state graph is a more advanced representation of trace exploration, allowing you to see the internal state. + It represents states as nodes, and scenarios as edges. + """ + + def __init__(self): + # We use simplified IDs for nodes, and store the actual state info here + self.ids: dict[str, StateInfo] = {} + + # The networkx graph is a directional graph + self.networkx = nx.DiGraph() + + # add the start node + self.networkx.add_node('start', label='start') + + self.prev_state = StateInfo(ModelSpace()) + self.prev_trace_len = 0 + + def update_visualisation(self, info: TraceInfo): + """ + This will add nodes the newly reached state (if we did not roll back), as well as an edge from the previous to + the current state labeled with the scenario that took it there. + """ + if len(info.trace) > 0: + scenario = info.trace[-1] + + from_node = self._get_or_create_id(self.prev_state) + if len(info.trace) == 1: + from_node = 'start' + to_node = self._get_or_create_id(info.state) + + self._add_node(from_node) + self._add_node(to_node) + + if self.prev_trace_len < len(info.trace): + if (from_node, to_node) not in self.networkx.edges: + self.networkx.add_edge( + from_node, to_node, label=scenario.name) + + self.prev_state = info.state + self.prev_trace_len = len(info.trace) + + def _get_or_create_id(self, state: StateInfo) -> str: + """ + Get the ID for a state that has been added before, or create and store a new one. + """ + for i in self.ids.keys(): + if self.ids[i] == state: + return i + + new_id = f"node{len(self.ids)}" + self.ids[new_id] = state + return new_id + + def _add_node(self, node: str): + """ + Add node if it doesn't already exist. + """ + if node not in self.networkx.nodes: + self.networkx.add_node(node, label=str(self.ids[node])) + + def set_final_trace(self, info: TraceInfo): + self._set_ending_node(info.state) + + def _set_ending_node(self, state: StateInfo): + """ + Update the end node. + """ + self.end_node = self._get_or_create_id(state) + + @property + def networkx(self) -> nx.DiGraph: + return self._networkx + + @networkx.setter + def networkx(self, value: nx.DiGraph): + self._networkx = value diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index d9c661fb..20327437 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -1,8 +1,6 @@ -from abc import ABC, abstractmethod from robotmbt.modelspace import ModelSpace from robotmbt.suitedata import Scenario from robotmbt.tracestate import TraceState -import networkx as nx class ScenarioInfo: @@ -75,8 +73,8 @@ def __repr__(self) -> str: return f"TraceInfo(trace=[{[str(t) for t in self.trace]}], state={self.state})" def contains_scenario(self, scen_name: str) -> bool: - for scen in self.trace: - if scen.name == scen_name: + for scenario in self.trace: + if scenario.name == scen_name: return True return False @@ -94,254 +92,7 @@ def get_scenario(self, scen_name: str) -> ScenarioInfo | None: def insert_trace_at(self, index: int, scen_info: ScenarioInfo): if index < 0 or index >= len(self.trace): - raise IndexError(f"InsertTraceAt received invalid index ({index}) for length of list ({len(self.trace)})") + raise IndexError( + f"InsertTraceAt received invalid index ({index}) for length of list ({len(self.trace)})") self.trace.insert(index, scen_info) - - -class AbstractGraph(ABC): - @abstractmethod - def update_visualisation(self, info: TraceInfo): - """ - Update the visualisation with new trace information from another exploration step. - """ - pass - - @abstractmethod - def set_final_trace(self, info: TraceInfo): - """ - Update the graph with information on the final trace. - """ - pass - - @abstractmethod - def calculate_pos(self): - """ - Calculate the position (x, y) for all nodes in self.networkx - """ - pass - - @property - @abstractmethod - def networkx(self) -> nx.DiGraph: - """ - We use networkx to store nodes and edges. - """ - pass - - @networkx.setter - @abstractmethod - def networkx(self, value: nx.DiGraph): - pass - - @property - @abstractmethod - def pos(self) -> dict: - """ - A dictionary with the positions of nodes. - """ - pass - - @pos.setter - @abstractmethod - def pos(self, value: dict): - pass - - -class ScenarioGraph(AbstractGraph): - """ - The scenario graph is the most basic representation of trace exploration. - It represents scenarios as nodes, and the trace as edges. - """ - - def __init__(self): - # We use simplified IDs for nodes, and store the actual scenario info here - self.ids: dict[str, ScenarioInfo] = {} - - # The networkx graph is a directional graph - self.networkx = nx.DiGraph() - - # Stores the position (x, y) of the nodes - self.pos = {} - - # add the start node - self.networkx.add_node('start', label='start') - - # indicates last scenario of trace - self.end_node = 'start' - - def update_visualisation(self, info: TraceInfo): - """ - This will add nodes for all new scenarios in the provided trace, as well as edges for all pairs in the provided trace. - """ - for i in range(0, len(info.trace) - 1): - from_node = self._get_or_create_id(info.trace[i]) - to_node = self._get_or_create_id(info.trace[i + 1]) - - self._add_node(from_node) - self._add_node(to_node) - - if (from_node, to_node) not in self.networkx.edges: - self.networkx.add_edge( - from_node, to_node, label='') - - def _get_or_create_id(self, scenario: ScenarioInfo) -> str: - """ - Get the ID for a scenario that has been added before, or create and store a new one. - """ - for i in self.ids.keys(): - # TODO: decide how to deal with repeating scenarios, this merges repeated scenarios into a single scenario - if self.ids[i].src_id == scenario.src_id and scenario.src_id is not None: - return i - - new_id = f"node{len(self.ids)}" - self.ids[new_id] = scenario - return new_id - - def _add_node(self, node: str): - """ - Add node if it doesn't already exist. - """ - if node not in self.networkx.nodes: - self.networkx.add_node(node, label=self.ids[node].name) - - def _set_starting_node(self, scenario: ScenarioInfo): - """ - Update the starting node. - """ - node = self._get_or_create_id(scenario) - self._add_node(node) - self.networkx.add_edge('start', node, label='') - - def _set_ending_node(self, scenario: ScenarioInfo): - """ - Update the end node. - """ - self.end_node = self._get_or_create_id(scenario) - - def set_final_trace(self, info: TraceInfo): - """ - Update the graph with information on the final trace. - """ - self._set_starting_node(info.trace[0]) - self._set_ending_node(info.trace[-1]) - - def calculate_pos(self): - try: - self.pos = nx.planar_layout(self.networkx) - except nx.NetworkXException: - # if planar layout cannot find a graph without crossing edges - self.pos = nx.arf_layout(self.networkx, seed=42) - - @property - def networkx(self) -> nx.DiGraph: - return self._networkx - - @networkx.setter - def networkx(self, value: nx.DiGraph): - self._networkx = value - - @property - def pos(self) -> dict: - return self._pos - - @pos.setter - def pos(self, value: dict): - self._pos = value - - -class StateGraph(AbstractGraph): - """ - The state graph is a more advanced representation of trace exploration, allowing you to see the internal state. - It represents states as nodes, and scenarios as edges. - """ - - def __init__(self): - # We use simplified IDs for nodes, and store the actual state info here - self.ids: dict[str, StateInfo] = {} - - # The networkx graph is a directional graph - self.networkx = nx.DiGraph() - - # Stores the position (x, y) of the nodes - self.pos = {} - - # add the start node - self.networkx.add_node('start', label='start') - - self.prev_state = StateInfo(ModelSpace()) - self.prev_trace_len = 0 - - def update_visualisation(self, info: TraceInfo): - """ - This will add nodes the newly reached state (if we did not roll back), as well as an edge from the previous to - the current state labeled with the scenario that took it there. - """ - if len(info.trace) > 0: - scenario = info.trace[-1] - - from_node = self._get_or_create_id(self.prev_state) - if len(info.trace) == 1: - from_node = 'start' - to_node = self._get_or_create_id(info.state) - - self._add_node(from_node) - self._add_node(to_node) - - if self.prev_trace_len < len(info.trace): - if (from_node, to_node) not in self.networkx.edges: - self.networkx.add_edge(from_node, to_node, label=scenario.name) - - self.prev_state = info.state - self.prev_trace_len = len(info.trace) - - def _get_or_create_id(self, state: StateInfo) -> str: - """ - Get the ID for a state that has been added before, or create and store a new one. - """ - for i in self.ids.keys(): - if self.ids[i] == state: - return i - - new_id = f"node{len(self.ids)}" - self.ids[new_id] = state - return new_id - - def _add_node(self, node: str): - """ - Add node if it doesn't already exist. - """ - if node not in self.networkx.nodes: - self.networkx.add_node(node, label=str(self.ids[node])) - - def set_final_trace(self, info: TraceInfo): - self._set_ending_node(info.state) - - def _set_ending_node(self, state: StateInfo): - """ - Update the end node. - """ - self.end_node = self._get_or_create_id(state) - - def calculate_pos(self): - try: - self.pos = nx.planar_layout(self.networkx) - except nx.NetworkXException: - # if planar layout cannot find a graph without crossing edges - self.pos = nx.arf_layout(self.networkx, seed=42) - - @property - def networkx(self) -> nx.DiGraph: - return self._networkx - - @networkx.setter - def networkx(self, value: nx.DiGraph): - self._networkx = value - - @property - def pos(self) -> dict: - return self._pos - - @pos.setter - def pos(self, value: dict): - self._pos = value diff --git a/robotmbt/visualise/networkvisualiser.py b/robotmbt/visualise/networkvisualiser.py new file mode 100644 index 00000000..2fc9acba --- /dev/null +++ b/robotmbt/visualise/networkvisualiser.py @@ -0,0 +1,387 @@ +from robotmbt.visualise.graphs.abstractgraph import AbstractGraph +from robotmbt.visualise.graphs.stategraph import StateGraph +from bokeh.palettes import Spectral4 +from bokeh.models import ( + Plot, Range1d, Circle, Rect, + Arrow, NormalHead, + Bezier, ColumnDataSource, ResetTool, + SaveTool, WheelZoomTool, PanTool, Text +) +from bokeh.embed import file_html +from bokeh.resources import CDN +from math import sqrt +import networkx as nx + + +class NetworkVisualiser: + """ + Generate plot with Bokeh + """ + + ARROWHEAD_SIZE: int = 6 # Consistent arrowhead size + EDGE_WIDTH: float = 2.0 + EDGE_ALPHA: float = 0.7 + EDGE_COLOUR: str | tuple[int, int, int] = ( + 12, 12, 12) # 'visual studio black' + GRAPH_PADDING_PERC: int = 15 # % + # in px, needs to be equal for height and width otherwise calculations are wrong + GRAPH_SIZE_PX: int = 600 + MAX_VERTEX_NAME_LEN: int = 20 # no. of characters + + def __init__(self, graph: AbstractGraph): + self.plot = None + self.graph = graph + self.node_props = {} # Store node properties for arrow calculations + self.graph_layout = {} + + # graph customisation options + self.node_radius = 1.0 + self.char_width = 0.1 + self.char_height = 0.1 + self.padding = 0.1 + + def generate_html(self) -> str: + """ + Generate html file from networkx graph via Bokeh + """ + self._calculate_graph_layout() + self._initialise_plot() + self._add_nodes_with_labels() + self._add_edges() + return file_html(self.plot, CDN, "graph") + + def _initialise_plot(self): + """ + Define plot with width, height, x_range, y_range and enable tools. + x_range and y_range are padded. Plot needs to be a square + """ + padding: float = self.GRAPH_PADDING_PERC / 100 + + x_range, y_range = zip(*self.graph_layout.values()) + x_min = min(x_range) - padding * (max(x_range) - min(x_range)) + x_max = max(x_range) + padding * (max(x_range) - min(x_range)) + y_min = min(y_range) - padding * (max(y_range) - min(y_range)) + y_max = max(y_range) + padding * (max(y_range) - min(y_range)) + + # scale node radius based on range + nodes_range = max(x_max - x_min, y_max - y_min) + self.node_radius = nodes_range / 150 + self.char_width = nodes_range / 150 + self.char_height = nodes_range / 150 + + # create plot + x_range = Range1d(min(x_min, y_min), max(x_max, y_max)) + y_range = Range1d(min(x_min, y_min), max(x_max, y_max)) + + self.plot = Plot(width=self.GRAPH_SIZE_PX, + height=self.GRAPH_SIZE_PX, + x_range=x_range, + y_range=y_range) + + # add tools + self.plot.add_tools(ResetTool(), SaveTool(), + WheelZoomTool(), PanTool()) + + def _calculate_text_dimensions(self, text: str) -> tuple[float, float]: + """Calculate width and height needed for text based on actual text length""" + # Calculate width based on character count + text_length = len(text) + width = (text_length * self.char_width) + (2 * self.padding) + + # Reduced height for more compact rectangles + height = self.char_height + self.padding + + return width, height + + def _add_nodes_with_labels(self): + """ + Add nodes with text labels inside them + """ + node_labels = nx.get_node_attributes(self.graph.networkx, "label") + + # Create data sources for nodes and labels + circle_data = dict(x=[], y=[], radius=[], label=[]) + rect_data = dict(x=[], y=[], width=[], height=[], label=[]) + text_data = dict(x=[], y=[], text=[]) + + for node in self.graph.networkx.nodes: + # Labels are always defined and cannot be lists + label = node_labels[node] + label = self._cap_name(label) + x, y = self.graph_layout[node] + + if node == 'start': + # For start node (circle), calculate radius based on text width + text_width, text_height = self._calculate_text_dimensions( + label) + # Calculate radius from text dimensions + radius = (text_width / 2.5) + + circle_data['x'].append(x) + circle_data['y'].append(y) + circle_data['radius'].append(radius) + circle_data['label'].append(label) + + # Store node properties for arrow calculations + self.node_props[node] = { + 'type': 'circle', 'x': x, 'y': y, 'radius': radius, 'label': label} + + else: + # For scenario nodes (rectangles), calculate dimensions based on text + text_width, text_height = self._calculate_text_dimensions( + label) + + rect_data['x'].append(x) + rect_data['y'].append(y) + rect_data['width'].append(text_width) + rect_data['height'].append(text_height) + rect_data['label'].append(label) + + # Store node properties for arrow calculations + self.node_props[node] = {'type': 'rect', 'x': x, 'y': y, 'width': text_width, 'height': text_height, + 'label': label} + + # Add text for all nodes + text_data['x'].append(x) + text_data['y'].append(y) + text_data['text'].append(label) + + # Add circles for start node + if circle_data['x']: + circle_source = ColumnDataSource(circle_data) + circles = Circle(x='x', y='y', radius='radius', + fill_color=Spectral4[0]) + self.plot.add_glyph(circle_source, circles) + + # Add rectangles for scenario nodes + if rect_data['x']: + rect_source = ColumnDataSource(rect_data) + rectangles = Rect(x='x', y='y', width='width', height='height', + fill_color=Spectral4[0]) + self.plot.add_glyph(rect_source, rectangles) + + # Add text labels for all nodes + text_source = ColumnDataSource(text_data) + text_labels = Text(x='x', y='y', text='text', + text_align='center', text_baseline='middle', + text_color='white', text_font_size='9pt') + self.plot.add_glyph(text_source, text_labels) + + def _get_edge_points(self, start_node, end_node): + """Calculate edge start and end points at node borders""" + start_props = self.node_props.get(start_node) + end_props = self.node_props.get(end_node) + + # Node properties should always exist + if not start_props or not end_props: + raise ValueError( + f"Node properties not found for nodes: {start_node}, {end_node}") + + # Calculate direction vector + dx = end_props['x'] - start_props['x'] + dy = end_props['y'] - start_props['y'] + distance = sqrt(dx * dx + dy * dy) + + # Self-loops are handled separately, distance should never be 0 + if distance == 0: + raise ValueError( + "Distance between different nodes should not be zero") + + # Normalize direction vector + dx /= distance + dy /= distance + + # Calculate start point at border + if start_props['type'] == 'circle': + start_x = start_props['x'] + dx * start_props['radius'] + start_y = start_props['y'] + dy * start_props['radius'] + else: + # Find where the line intersects the rectangle border + rect_width = start_props['width'] + rect_height = start_props['height'] + + # Calculate scaling factors for x and y directions + scale_x = rect_width / (2 * abs(dx)) if dx != 0 else float('inf') + scale_y = rect_height / (2 * abs(dy)) if dy != 0 else float('inf') + + # Use the smaller scale to ensure we hit the border + scale = min(scale_x, scale_y) + + start_x = start_props['x'] + dx * scale + start_y = start_props['y'] + dy * scale + + # Calculate end point at border (reverse direction) + # End nodes should never be circles for regular edges + if end_props['type'] == 'circle': + raise ValueError( + f"End node should not be a circle for regular edges: {end_node}") + else: + rect_width = end_props['width'] + rect_height = end_props['height'] + + # Calculate scaling factors for x and y directions (reverse) + scale_x = rect_width / (2 * abs(dx)) if dx != 0 else float('inf') + scale_y = rect_height / (2 * abs(dy)) if dy != 0 else float('inf') + + # Use the smaller scale to ensure we hit the border + scale = min(scale_x, scale_y) + + end_x = end_props['x'] - dx * scale + end_y = end_props['y'] - dy * scale + + return start_x, start_y, end_x, end_y + + def add_self_loop(self, node_id: str): + """ + Circular arc that starts and ends at the top side of the rectangle + Start at 1/4 width, end at 3/4 width, with a circular arc above + The arc itself ends with the arrowhead pointing into the rectangle + """ + # Get node properties directly by node ID + node_props = self.node_props.get(node_id) + + # Node properties should always exist + if node_props is None: + raise ValueError(f"Node properties not found for node: {node_id}") + + # Self-loops should only be for rectangle nodes (scenarios) + if node_props['type'] != 'rect': + raise ValueError( + f"Self-loops should only be for rectangle nodes, got: {node_props['type']}") + + x, y = node_props['x'], node_props['y'] + width = node_props['width'] + height = node_props['height'] + + # Start: 1/4 width from left, top side + start_x = x - width / 4 + start_y = y + height / 2 + + # End: 3/4 width from left, top side + end_x = x + width / 4 + end_y = y + height / 2 + + # Arc height above the rectangle + arc_height = width * 0.4 + + # Control points for a circular arc above + control1_x = x - width / 8 + control1_y = y + height / 2 + arc_height + + control2_x = x + width / 8 + control2_y = y + height / 2 + arc_height + + # Create the Bezier curve (the main arc) with the same thickness as straight lines + loop = Bezier( + x0=start_x, y0=start_y, + x1=end_x, y1=end_y, + cx0=control1_x, cy0=control1_y, + cx1=control2_x, cy1=control2_y, + line_color=NetworkVisualiser.EDGE_COLOUR, + line_width=NetworkVisualiser.EDGE_WIDTH, + line_alpha=NetworkVisualiser.EDGE_ALPHA, + ) + self.plot.add_glyph(loop) + + # Calculate the tangent direction at the end of the Bezier curve + # For a cubic Bezier, the tangent at the end point is from the last control point to the end point + tangent_x = end_x - control2_x + tangent_y = end_y - control2_y + + # Normalize the tangent vector + tangent_length = sqrt(tangent_x ** 2 + tangent_y ** 2) + if tangent_length > 0: + tangent_x /= tangent_length + tangent_y /= tangent_length + + # Add just the arrowhead (NormalHead) at the end point, oriented along the tangent + arrowhead = NormalHead( + size=NetworkVisualiser.ARROWHEAD_SIZE, + line_color=NetworkVisualiser.EDGE_COLOUR, + fill_color=NetworkVisualiser.EDGE_COLOUR, + line_width=NetworkVisualiser.EDGE_WIDTH + ) + + # Create a standalone arrowhead at the end point + # Strategy: use a very short Arrow that's essentially just the head + arrow = Arrow( + end=arrowhead, + x_start=end_x - tangent_x * 0.001, # Almost zero length line + y_start=end_y - tangent_y * 0.001, + x_end=end_x, + y_end=end_y, + line_color=NetworkVisualiser.EDGE_COLOUR, + line_width=NetworkVisualiser.EDGE_WIDTH, + line_alpha=NetworkVisualiser.EDGE_ALPHA + ) + self.plot.add_layout(arrow) + + # Add edge label - positioned above the arc + label_x = x + label_y = y + height / 2 + arc_height * 0.6 + + return label_x, label_y + + def _add_edges(self): + edge_labels = nx.get_edge_attributes(self.graph.networkx, "label") + + # Create data sources for edges and edge labels + edge_text_data = dict(x=[], y=[], text=[]) + + for edge in self.graph.networkx.edges(): + # Edge labels are always defined and cannot be lists + edge_label = edge_labels[edge] + edge_label = self._cap_name(edge_label) + edge_text_data['text'].append(edge_label) + + if edge[0] == edge[1]: + # Self-loop handled separately + label_x, label_y = self.add_self_loop(edge[0]) + edge_text_data['x'].append(label_x) + edge_text_data['y'].append(label_y) + + else: + # Calculate edge points at node borders + start_x, start_y, end_x, end_y = self._get_edge_points( + edge[0], edge[1]) + + # Add arrow between the calculated points + arrow = Arrow( + end=NormalHead( + size=NetworkVisualiser.ARROWHEAD_SIZE, + line_color=NetworkVisualiser.EDGE_COLOUR, + fill_color=NetworkVisualiser.EDGE_COLOUR), + x_start=start_x, y_start=start_y, + x_end=end_x, y_end=end_y + ) + self.plot.add_layout(arrow) + + # Collect edge label data (position at midpoint) + edge_text_data['x'].append((start_x + end_x) / 2) + edge_text_data['y'].append((start_y + end_y) / 2) + + # Add all edge labels at once + if edge_text_data['x']: + edge_text_source = ColumnDataSource(edge_text_data) + edge_labels_glyph = Text(x='x', y='y', text='text', + text_align='center', text_baseline='middle', + text_font_size='7pt') + self.plot.add_glyph(edge_text_source, edge_labels_glyph) + + def _cap_name(self, name: str) -> str: + if len(name) < self.MAX_VERTEX_NAME_LEN or isinstance(self.graph, StateGraph): + return name + + return f"{name[:(self.MAX_VERTEX_NAME_LEN - 3)]}..." + + def _calculate_graph_layout(self): + try: + self.graph_layout = nx.bfs_layout( + self.graph.networkx, 'start', align='horizontal') + # horizontal mirror + for node in self.graph_layout: + self.graph_layout[node] = (self.graph_layout[node][0], + -1 * self.graph_layout[node][1]) + except nx.NetworkXException: + # if planar layout cannot find a graph without crossing edges + self.graph_layout = nx.arf_layout(self.graph.networkx, seed=42) diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py index 5e81eec1..2bd69cea 100644 --- a/robotmbt/visualise/visualiser.py +++ b/robotmbt/visualise/visualiser.py @@ -1,16 +1,9 @@ -from robotmbt.visualise.models import AbstractGraph, ScenarioGraph, StateGraph, TraceInfo -from bokeh.palettes import Spectral4 -from bokeh.models import ( - Plot, Range1d, Circle, Rect, - Arrow, NormalHead, - Bezier, ColumnDataSource, ResetTool, - SaveTool, WheelZoomTool, PanTool, Text -) -from bokeh.embed import file_html -from bokeh.resources import CDN -from math import sqrt +from robotmbt.visualise.networkvisualiser import NetworkVisualiser +from robotmbt.visualise.graphs.abstractgraph import AbstractGraph +from robotmbt.visualise.graphs.scenariograph import ScenarioGraph +from robotmbt.visualise.graphs.stategraph import StateGraph +from robotmbt.visualise.models import TraceInfo import html -import networkx as nx class Visualiser: @@ -19,14 +12,12 @@ class Visualiser: a simple interface through which the graph can be updated, and retrieved in HTML format. """ - GRAPH_SIZE_PX: int = 600 # in px, needs to be equal for height and width otherwise calculations are wrong - GRAPH_PADDING_PERC: int = 15 # % - MAX_VERTEX_NAME_LEN: int = 20 # no. of characters # glue method to let us construct Visualiser objects in Robot tests. @classmethod def construct(cls, graph_type: str): - return cls(graph_type) # just calls __init__, but without having underscores etc. + # just calls __init__, but without having underscores etc. + return cls(graph_type) def __init__(self, graph_type: str): if graph_type == 'scenario': @@ -43,353 +34,8 @@ def set_final_trace(self, info: TraceInfo): self.graph.set_final_trace(info) def generate_visualisation(self) -> str: - self.graph.calculate_pos() html_bokeh = NetworkVisualiser(self.graph).generate_html() - return f"" - - -class NetworkVisualiser: - """ - Generate plot with Bokeh - """ - - EDGE_WIDTH: float = 2.0 - EDGE_ALPHA: float = 0.7 - EDGE_COLOUR: str | tuple[int, int, int] = (12, 12, 12) # 'visual studio black' - ARROWHEAD_SIZE: int = 6 # Consistent arrowhead size - - def __init__(self, graph: AbstractGraph): - self.plot = None - self.graph = graph - self.node_props = {} # Store node properties for arrow calculations - - # graph customisation options - self.node_radius = 1.0 - self.char_width = 0.1 - self.char_height = 0.1 - self.padding = 0.1 - - def generate_html(self) -> str: - """ - Generate html file from networkx graph via Bokeh - """ - self._initialise_plot() - self._add_nodes_with_labels() - self._add_edges() - return file_html(self.plot, CDN, "graph") - - def _initialise_plot(self): - """ - Define plot with width, height, x_range, y_range and enable tools. - x_range and y_range are padded. Plot needs to be a square - """ - padding: float = Visualiser.GRAPH_PADDING_PERC / 100 - - x_range, y_range = zip(*self.graph.pos.values()) - x_min = min(x_range) - padding * (max(x_range) - min(x_range)) - x_max = max(x_range) + padding * (max(x_range) - min(x_range)) - y_min = min(y_range) - padding * (max(y_range) - min(y_range)) - y_max = max(y_range) + padding * (max(y_range) - min(y_range)) - - # scale node radius based on range - nodes_range = max(x_max - x_min, y_max - y_min) - self.node_radius = nodes_range / 150 - self.char_width = nodes_range / 150 - self.char_height = nodes_range / 150 - - # create plot - x_range = Range1d(min(x_min, y_min), max(x_max, y_max)) - y_range = Range1d(min(x_min, y_min), max(x_max, y_max)) - - self.plot = Plot(width=Visualiser.GRAPH_SIZE_PX, - height=Visualiser.GRAPH_SIZE_PX, - x_range=x_range, - y_range=y_range) - - # add tools - self.plot.add_tools(ResetTool(), SaveTool(), - WheelZoomTool(), PanTool()) - - def _calculate_text_dimensions(self, text: str) -> tuple[float, float]: - """Calculate width and height needed for text based on actual text length""" - # Calculate width based on character count - text_length = len(text) - width = (text_length * self.char_width) + (2 * self.padding) - - # Reduced height for more compact rectangles - height = self.char_height + self.padding - - return width, height - - def _add_nodes_with_labels(self): - """ - Add nodes with text labels inside them - """ - node_labels = nx.get_node_attributes(self.graph.networkx, "label") - - # Create data sources for nodes and labels - circle_data = dict(x=[], y=[], radius=[], label=[]) - rect_data = dict(x=[], y=[], width=[], height=[], label=[]) - text_data = dict(x=[], y=[], text=[]) - - for node in self.graph.networkx.nodes: - # Labels are always defined and cannot be lists - label = node_labels[node] - label = self._cap_name(label) - x, y = self.graph.pos[node] - - if node == 'start': - # For start node (circle), calculate radius based on text width - text_width, text_height = self._calculate_text_dimensions(label) - # Calculate radius from text dimensions - radius = (text_width / 2.5) - - circle_data['x'].append(x) - circle_data['y'].append(y) - circle_data['radius'].append(radius) - circle_data['label'].append(label) - - # Store node properties for arrow calculations - self.node_props[node] = {'type': 'circle', 'x': x, 'y': y, 'radius': radius, 'label': label} - - else: - # For scenario nodes (rectangles), calculate dimensions based on text - text_width, text_height = self._calculate_text_dimensions(label) - - rect_data['x'].append(x) - rect_data['y'].append(y) - rect_data['width'].append(text_width) - rect_data['height'].append(text_height) - rect_data['label'].append(label) - - # Store node properties for arrow calculations - self.node_props[node] = {'type': 'rect', 'x': x, 'y': y, 'width': text_width, 'height': text_height, - 'label': label} - - # Add text for all nodes - text_data['x'].append(x) - text_data['y'].append(y) - text_data['text'].append(label) - - # Add circles for start node - if circle_data['x']: - circle_source = ColumnDataSource(circle_data) - circles = Circle(x='x', y='y', radius='radius', - fill_color=Spectral4[0]) - self.plot.add_glyph(circle_source, circles) - - # Add rectangles for scenario nodes - if rect_data['x']: - rect_source = ColumnDataSource(rect_data) - rectangles = Rect(x='x', y='y', width='width', height='height', - fill_color=Spectral4[0]) - self.plot.add_glyph(rect_source, rectangles) - - # Add text labels for all nodes - text_source = ColumnDataSource(text_data) - text_labels = Text(x='x', y='y', text='text', - text_align='center', text_baseline='middle', - text_color='white', text_font_size='9pt') - self.plot.add_glyph(text_source, text_labels) - - def _get_edge_points(self, start_node, end_node): - """Calculate edge start and end points at node borders""" - start_props = self.node_props.get(start_node) - end_props = self.node_props.get(end_node) - - # Node properties should always exist - if not start_props or not end_props: - raise ValueError(f"Node properties not found for nodes: {start_node}, {end_node}") - - # Calculate direction vector - dx = end_props['x'] - start_props['x'] - dy = end_props['y'] - start_props['y'] - distance = sqrt(dx * dx + dy * dy) - - # Self-loops are handled separately, distance should never be 0 - if distance == 0: - raise ValueError("Distance between different nodes should not be zero") - - # Normalize direction vector - dx /= distance - dy /= distance - - # Calculate start point at border - if start_props['type'] == 'circle': - start_x = start_props['x'] + dx * start_props['radius'] - start_y = start_props['y'] + dy * start_props['radius'] - else: - # Find where the line intersects the rectangle border - rect_width = start_props['width'] - rect_height = start_props['height'] - - # Calculate scaling factors for x and y directions - scale_x = rect_width / (2 * abs(dx)) if dx != 0 else float('inf') - scale_y = rect_height / (2 * abs(dy)) if dy != 0 else float('inf') - - # Use the smaller scale to ensure we hit the border - scale = min(scale_x, scale_y) - - start_x = start_props['x'] + dx * scale - start_y = start_props['y'] + dy * scale - - # Calculate end point at border (reverse direction) - # End nodes should never be circles for regular edges - if end_props['type'] == 'circle': - raise ValueError(f"End node should not be a circle for regular edges: {end_node}") - else: - rect_width = end_props['width'] - rect_height = end_props['height'] - - # Calculate scaling factors for x and y directions (reverse) - scale_x = rect_width / (2 * abs(dx)) if dx != 0 else float('inf') - scale_y = rect_height / (2 * abs(dy)) if dy != 0 else float('inf') - - # Use the smaller scale to ensure we hit the border - scale = min(scale_x, scale_y) - - end_x = end_props['x'] - dx * scale - end_y = end_props['y'] - dy * scale - - return start_x, start_y, end_x, end_y - - def add_self_loop(self, node_id: str): - """ - Circular arc that starts and ends at the top side of the rectangle - Start at 1/4 width, end at 3/4 width, with a circular arc above - The arc itself ends with the arrowhead pointing into the rectangle - """ - # Get node properties directly by node ID - node_props = self.node_props.get(node_id) - - # Node properties should always exist - if node_props is None: - raise ValueError(f"Node properties not found for node: {node_id}") - - # Self-loops should only be for rectangle nodes (scenarios) - if node_props['type'] != 'rect': - raise ValueError(f"Self-loops should only be for rectangle nodes, got: {node_props['type']}") - - x, y = node_props['x'], node_props['y'] - width = node_props['width'] - height = node_props['height'] - - # Start: 1/4 width from left, top side - start_x = x - width / 4 - start_y = y + height / 2 - - # End: 3/4 width from left, top side - end_x = x + width / 4 - end_y = y + height / 2 - - # Arc height above the rectangle - arc_height = width * 0.4 - - # Control points for a circular arc above - control1_x = x - width / 8 - control1_y = y + height / 2 + arc_height - - control2_x = x + width / 8 - control2_y = y + height / 2 + arc_height - - # Create the Bezier curve (the main arc) with the same thickness as straight lines - loop = Bezier( - x0=start_x, y0=start_y, - x1=end_x, y1=end_y, - cx0=control1_x, cy0=control1_y, - cx1=control2_x, cy1=control2_y, - line_color=NetworkVisualiser.EDGE_COLOUR, - line_width=NetworkVisualiser.EDGE_WIDTH, - line_alpha=NetworkVisualiser.EDGE_ALPHA, - ) - self.plot.add_glyph(loop) - - # Calculate the tangent direction at the end of the Bezier curve - # For a cubic Bezier, the tangent at the end point is from the last control point to the end point - tangent_x = end_x - control2_x - tangent_y = end_y - control2_y - - # Normalize the tangent vector - tangent_length = sqrt(tangent_x ** 2 + tangent_y ** 2) - if tangent_length > 0: - tangent_x /= tangent_length - tangent_y /= tangent_length - - # Add just the arrowhead (NormalHead) at the end point, oriented along the tangent - arrowhead = NormalHead( - size=NetworkVisualiser.ARROWHEAD_SIZE, - line_color=NetworkVisualiser.EDGE_COLOUR, - fill_color=NetworkVisualiser.EDGE_COLOUR, - line_width=NetworkVisualiser.EDGE_WIDTH - ) - - # Create a standalone arrowhead at the end point - # Strategy: use a very short Arrow that's essentially just the head - arrow = Arrow( - end=arrowhead, - x_start=end_x - tangent_x * 0.001, # Almost zero length line - y_start=end_y - tangent_y * 0.001, - x_end=end_x, - y_end=end_y, - line_color=NetworkVisualiser.EDGE_COLOUR, - line_width=NetworkVisualiser.EDGE_WIDTH, - line_alpha=NetworkVisualiser.EDGE_ALPHA - ) - self.plot.add_layout(arrow) - - # Add edge label - positioned above the arc - label_x = x - label_y = y + height / 2 + arc_height * 0.6 - - return label_x, label_y - - def _add_edges(self): - edge_labels = nx.get_edge_attributes(self.graph.networkx, "label") - - # Create data sources for edges and edge labels - edge_text_data = dict(x=[], y=[], text=[]) - - for edge in self.graph.networkx.edges(): - # Edge labels are always defined and cannot be lists - edge_label = edge_labels[edge] - edge_label = self._cap_name(edge_label) - edge_text_data['text'].append(edge_label) - - if edge[0] == edge[1]: - # Self-loop handled separately - label_x, label_y = self.add_self_loop(edge[0]) - edge_text_data['x'].append(label_x) - edge_text_data['y'].append(label_y) - - else: - # Calculate edge points at node borders - start_x, start_y, end_x, end_y = self._get_edge_points(edge[0], edge[1]) - - # Add arrow between the calculated points - arrow = Arrow( - end=NormalHead( - size=NetworkVisualiser.ARROWHEAD_SIZE, - line_color=NetworkVisualiser.EDGE_COLOUR, - fill_color=NetworkVisualiser.EDGE_COLOUR), - x_start=start_x, y_start=start_y, - x_end=end_x, y_end=end_y - ) - self.plot.add_layout(arrow) - - # Collect edge label data (position at midpoint) - edge_text_data['x'].append((start_x + end_x) / 2) - edge_text_data['y'].append((start_y + end_y) / 2) - - # Add all edge labels at once - if edge_text_data['x']: - edge_text_source = ColumnDataSource(edge_text_data) - edge_labels_glyph = Text(x='x', y='y', text='text', - text_align='center', text_baseline='middle', - text_font_size='7pt') - self.plot.add_glyph(edge_text_source, edge_labels_glyph) - - def _cap_name(self, name: str) -> str: - if len(name) < Visualiser.MAX_VERTEX_NAME_LEN or isinstance(self.graph, StateGraph): - return name - - return f"{name[:(Visualiser.MAX_VERTEX_NAME_LEN - 3)]}..." + return f"" diff --git a/utest/test_visualise_models.py b/utest/test_visualise_models.py index f52e24e6..7fbc59ab 100644 --- a/utest/test_visualise_models.py +++ b/utest/test_visualise_models.py @@ -6,168 +6,45 @@ VISUALISE = False if VISUALISE: - class TestVisualiseModels(unittest.TestCase): - """ - Contains tests for robotmbt/visualise/models.py - """ + class TestVisualiseModels(unittest.TestCase): + """ + Contains tests for robotmbt/visualise/models.py + """ - """ + """ Class: ScenarioInfo """ - def test_scenarioInfo_str(self): - si = ScenarioInfo('test') - self.assertEqual(si.name, 'test') - self.assertEqual(si.src_id, 'test') + def test_scenarioInfo_str(self): + si = ScenarioInfo('test') + self.assertEqual(si.name, 'test') + self.assertEqual(si.src_id, 'test') - def test_scenarioInfo_Scenario(self): - s = Scenario('test') - s.src_id = 0 - si = ScenarioInfo(s) - self.assertEqual(si.name, 'test') - self.assertEqual(si.src_id, 0) + def test_scenarioInfo_Scenario(self): + s = Scenario('test') + s.src_id = 0 + si = ScenarioInfo(s) + self.assertEqual(si.name, 'test') + self.assertEqual(si.src_id, 0) - """ + """ Class: TraceInfo """ - def test_create_TraceInfo(self): - ts = TraceState(3) - candidates = [] - for scenario in range(3): - candidates.append(ts.next_candidate()) - ts.confirm_full_scenario(candidates[-1], str(scenario), {}) - ti = TraceInfo.from_trace_state(trace=ts, state=ModelSpace()) - - self.assertEqual(ti.trace[0].name, str(0)) - self.assertEqual(ti.trace[1].name, str(1)) - self.assertEqual(ti.trace[2].name, str(2)) - - self.assertIsNotNone(ti.state) - # TODO check state - - """ - Class: ScenarioGraph - """ - - def test_scenario_graph_init(self): - sg = ScenarioGraph() - self.assertIn('start', sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes['start']['label'], 'start') - - def test_scenario_graph_ids_empty(self): - sg = ScenarioGraph() - si = ScenarioInfo('test') - node_id = sg._get_or_create_id(si) - self.assertEqual(node_id, 'node0') - - def test_scenario_graph_ids_duplicate_scenario(self): - sg = ScenarioGraph() - si = ScenarioInfo('test') - id0 = sg._get_or_create_id(si) - id1 = sg._get_or_create_id(si) - self.assertEqual(id0, id1) - - def test_scenario_graph_ids_different_scenarios(self): - sg = ScenarioGraph() - si0 = ScenarioInfo('test0') - si1 = ScenarioInfo('test1') - id0 = sg._get_or_create_id(si0) - id1 = sg._get_or_create_id(si1) - self.assertEqual(id0, 'node0') - self.assertEqual(id1, 'node1') - - def test_scenario_graph_add_new_node(self): - sg = ScenarioGraph() - sg.ids['test'] = ScenarioInfo('test') - sg._add_node('test') - self.assertIn('test', sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes['test']['label'], 'test') - - def test_scenario_graph_add_existing_node(self): - sg = ScenarioGraph() - sg._add_node('start') - self.assertIn('start', sg.networkx.nodes) - self.assertEqual(len(sg.networkx.nodes), 1) - - def test_scenario_graph_update_visualisation_nodes(self): - ts = TraceState(3) - candidates = [] - for scenario in range(3): - candidates.append(ts.next_candidate()) - ts.confirm_full_scenario(candidates[-1], str(scenario), {}) - ti = TraceInfo.from_trace_state(ts, ModelSpace()) - sg = ScenarioGraph() - sg.update_visualisation(ti) - - self.assertIn('node0', sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes['node0']['label'], str(0)) - self.assertIn('node1', sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes['node1']['label'], str(1)) - self.assertIn('node2', sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes['node2']['label'], str(2)) - - def test_scenario_graph_update_visualisation_edges(self): - ts = TraceState(3) - candidates = [] - for scenario in range(3): - candidates.append(ts.next_candidate()) - ts.confirm_full_scenario(candidates[-1], str(scenario), {}) - ti = TraceInfo.from_trace_state(trace=ts, state=ModelSpace()) - sg = ScenarioGraph() - sg.update_visualisation(ti) - - self.assertIn(('node0', 'node1'), sg.networkx.edges) - self.assertIn(('node1', 'node2'), sg.networkx.edges) - - edge_labels = nx.get_edge_attributes(sg.networkx, "label") - self.assertEqual(edge_labels[('node0', 'node1')], '') - self.assertEqual(edge_labels[('node1', 'node2')], '') - - def test_scenario_graph_update_visualisation_single_node(self): - ts = TraceState(1) - ts.confirm_full_scenario(0, 'one', {}) - self.assertEqual(ts.get_trace(), ['one']) - ti = TraceInfo.from_trace_state(ts, ModelSpace()) - sg = ScenarioGraph() - sg.update_visualisation(ti) - - # expected behaviour: no nodes nor edges are added - self.assertEqual(len(sg.networkx.nodes), 1) - self.assertEqual(len(sg.networkx.edges), 0) - - def test_scenario_graph_set_starting_node_new_node(self): - sg = ScenarioGraph() - si = ScenarioInfo('test') - sg._set_starting_node(si) - node_id = sg._get_or_create_id(si) - # node - self.assertIn(node_id, sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes[node_id]['label'], 'test') - - # edge - self.assertIn(('start', node_id), sg.networkx.edges) - edge_labels = nx.get_edge_attributes(sg.networkx, "label") - self.assertEqual(edge_labels[('start', node_id)], '') - - def test_scenario_graph_set_starting_node_existing_node(self): - sg = ScenarioGraph() - si = ScenarioInfo('test') - node_id = sg._get_or_create_id(si) - sg._add_node(node_id) - self.assertIn(node_id, sg.networkx.nodes) + def test_create_TraceInfo(self): + ts = TraceState(3) + candidates = [] + for scenario in range(3): + candidates.append(ts.next_candidate()) + ts.confirm_full_scenario(candidates[-1], str(scenario), {}) + ti = TraceInfo.from_trace_state(trace=ts, state=ModelSpace()) - sg._set_starting_node(si) - self.assertIn(('start', node_id), sg.networkx.edges) - edge_labels = nx.get_edge_attributes(sg.networkx, "label") - self.assertEqual(edge_labels[('start', node_id)], '') + self.assertEqual(ti.trace[0].name, str(0)) + self.assertEqual(ti.trace[1].name, str(1)) + self.assertEqual(ti.trace[2].name, str(2)) - def test_scenario_graph_set_end_node(self): - sg = ScenarioGraph() - si = ScenarioInfo('test') - node_id = sg._get_or_create_id(si) - sg._set_ending_node(si) - self.assertEqual(sg.end_node, node_id) + self.assertIsNotNone(ti.state) + # TODO check state if __name__ == '__main__': diff --git a/utest/test_visualise_scenariograph.py b/utest/test_visualise_scenariograph.py new file mode 100644 index 00000000..bcac393c --- /dev/null +++ b/utest/test_visualise_scenariograph.py @@ -0,0 +1,148 @@ +import unittest +import networkx as nx +from robotmbt.tracestate import TraceState +try: + from robotmbt.visualise.graphs.scenariograph import ScenarioGraph + from robotmbt.visualise.models import TraceInfo, ScenarioInfo, ModelSpace + VISUALISE = True +except ImportError: + VISUALISE = False + +if VISUALISE: + class TestVisualiseScenarioGraph(unittest.TestCase): + def test_scenario_graph_init(self): + sg = ScenarioGraph() + self.assertIn('start', sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') + + def test_scenario_graph_ids_empty(self): + sg = ScenarioGraph() + si = ScenarioInfo('test') + node_id = sg._get_or_create_id(si) + self.assertEqual(node_id, 'node0') + + def test_scenario_graph_ids_duplicate_scenario(self): + sg = ScenarioGraph() + si = ScenarioInfo('test') + id0 = sg._get_or_create_id(si) + id1 = sg._get_or_create_id(si) + self.assertEqual(id0, id1) + + def test_scenario_graph_ids_different_scenarios(self): + sg = ScenarioGraph() + si0 = ScenarioInfo('test0') + si1 = ScenarioInfo('test1') + id0 = sg._get_or_create_id(si0) + id1 = sg._get_or_create_id(si1) + self.assertEqual(id0, 'node0') + self.assertEqual(id1, 'node1') + + def test_scenario_graph_add_new_node(self): + sg = ScenarioGraph() + sg.ids['test'] = ScenarioInfo('test') + sg._add_node('test') + self.assertIn('test', sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes['test']['label'], 'test') + + def test_scenario_graph_add_existing_node(self): + sg = ScenarioGraph() + sg._add_node('start') + self.assertIn('start', sg.networkx.nodes) + self.assertEqual(len(sg.networkx.nodes), 1) + + def test_scenario_graph_update_visualisation_nodes(self): + ts = TraceState(3) + candidates = [] + for scenario in range(3): + candidates.append(ts.next_candidate()) + ts.confirm_full_scenario(candidates[-1], str(scenario), {}) + ti = TraceInfo.from_trace_state(ts, ModelSpace()) + sg = ScenarioGraph() + sg.update_visualisation(ti) + + self.assertIn('node0', sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes['node0']['label'], str(0)) + self.assertIn('node1', sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes['node1']['label'], str(1)) + self.assertIn('node2', sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes['node2']['label'], str(2)) + + def test_scenario_graph_update_visualisation_edges(self): + ts = TraceState(3) + candidates = [] + for scenario in range(3): + candidates.append(ts.next_candidate()) + ts.confirm_full_scenario(candidates[-1], str(scenario), {}) + ti = TraceInfo.from_trace_state(trace=ts, state=ModelSpace()) + sg = ScenarioGraph() + sg.update_visualisation(ti) + + self.assertIn(('node0', 'node1'), sg.networkx.edges) + self.assertIn(('node1', 'node2'), sg.networkx.edges) + + edge_labels = nx.get_edge_attributes(sg.networkx, "label") + self.assertEqual(edge_labels[('node0', 'node1')], '') + self.assertEqual(edge_labels[('node1', 'node2')], '') + + def test_scenario_graph_update_visualisation_single_node(self): + ts = TraceState(1) + ts.confirm_full_scenario(0, 'one', {}) + self.assertEqual(ts.get_trace(), ['one']) + ti = TraceInfo.from_trace_state(ts, ModelSpace()) + sg = ScenarioGraph() + sg.update_visualisation(ti) + + # expected behaviour: no nodes nor edges are added + self.assertEqual(len(sg.networkx.nodes), 1) + self.assertEqual(len(sg.networkx.edges), 0) + + def test_scenario_graph_set_starting_node_new_node(self): + sg = ScenarioGraph() + si = ScenarioInfo('test') + sg._set_starting_node(si) + node_id = sg._get_or_create_id(si) + # node + self.assertIn(node_id, sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes[node_id]['label'], 'test') + + # edge + self.assertIn(('start', node_id), sg.networkx.edges) + edge_labels = nx.get_edge_attributes(sg.networkx, "label") + self.assertEqual(edge_labels[('start', node_id)], '') + + def test_scenario_graph_set_starting_node_existing_node(self): + sg = ScenarioGraph() + si = ScenarioInfo('test') + node_id = sg._get_or_create_id(si) + sg._add_node(node_id) + self.assertIn(node_id, sg.networkx.nodes) + + sg._set_starting_node(si) + self.assertIn(('start', node_id), sg.networkx.edges) + edge_labels = nx.get_edge_attributes(sg.networkx, "label") + self.assertEqual(edge_labels[('start', node_id)], '') + + def test_scenario_graph_set_end_node(self): + sg = ScenarioGraph() + si = ScenarioInfo('test') + node_id = sg._get_or_create_id(si) + sg._set_ending_node(si) + self.assertEqual(sg.end_node, node_id) + + def test_scenario_graph_set_final_trace(self): + ts = TraceState(3) + candidates = [] + for scenario in range(3): + candidates.append(ts.next_candidate()) + ts.confirm_full_scenario(candidates[-1], str(scenario), {}) + ti = TraceInfo.from_trace_state(trace=ts, state=ModelSpace()) + sg = ScenarioGraph() + sg.update_visualisation(ti) + sg.set_final_trace(ti) + # test start node + self.assertIn(('start', 'node0'), sg.networkx.edges) + # test end node + self.assertEqual(sg.end_node, 'node2') + + if __name__ == '__main__': + unittest.main() diff --git a/utest/test_visualise_stategraph.py b/utest/test_visualise_stategraph.py new file mode 100644 index 00000000..c9d30a4e --- /dev/null +++ b/utest/test_visualise_stategraph.py @@ -0,0 +1,19 @@ +import unittest +import networkx as nx +try: + from robotmbt.visualise.graphs.stategraph import StateGraph + from robotmbt.visualise.models import * + VISUALISE = True +except ImportError: + VISUALISE = False + +if VISUALISE: + class TestVisualiseStateGraph(unittest.TestCase): + def test_state_graph_init(self): + sg = StateGraph() + self.assertIn('start', sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') + + +if __name__ == '__main__': + unittest.main() From a570e10cbf7b4beec7619b8896debce26033781d Mon Sep 17 00:00:00 2001 From: D Osinga <46380170+osingaatje@users.noreply.github.com> Date: Mon, 24 Nov 2025 17:07:23 +0100 Subject: [PATCH 072/131] Updated design files according to implementation (#23) --- .../RobotMBT-extensionplan-REDUCED.svg | 822 +++++++++++ DESIGN/Render/2025-11-24/RobotMBT-current.svg | 1307 +++++++++++++++++ .../2025-11-24/RobotMBT-extensionplan.svg | 589 ++++++++ DESIGN/VPP/RobotMBT.vpp | Bin 1179648 -> 1179648 bytes 4 files changed, 2718 insertions(+) create mode 100644 DESIGN/Render/2025-09-25/RobotMBT-extensionplan-REDUCED.svg create mode 100644 DESIGN/Render/2025-11-24/RobotMBT-current.svg create mode 100644 DESIGN/Render/2025-11-24/RobotMBT-extensionplan.svg diff --git a/DESIGN/Render/2025-09-25/RobotMBT-extensionplan-REDUCED.svg b/DESIGN/Render/2025-09-25/RobotMBT-extensionplan-REDUCED.svg new file mode 100644 index 00000000..6ff0daec --- /dev/null +++ b/DESIGN/Render/2025-09-25/RobotMBT-extensionplan-REDUCED.svg @@ -0,0 +1,822 @@ + + +-tracestate : TraceState+flatten(in_suite : Suite)+process_test_suite(in_suite : Suite, seed : str)-_try_to_reach_full_coverage(allow_duplicate_scenarios : bool)-_try_to_fit_in_scenario(index, candidate, retry_flag)-_report_tracestate_wrapup()SuiteProcessors+scenarios : dict [str,Scenario]+trace : TraceState+modelspace : ModelSpaceTraceInfo+update_visualisation(Suite, TraceInfo)Visualiser+generate_interactive(GraphInstance) : void+generate_html(GraphInstance) : strNetworkVisualiserGUIButtonGUIProgressBar+pre_process_trace(TraceState) : IGraphTraceTransfomer+nodes+edgesIGraph...+run_tests(args)RunTests+traceUpdate(Suite, TraceState, ModelSpace)TraceGathererScenarioGraphStateGraphNetworkX gui-componentssuiteprocessors.pyvisualisersvisualiser-processingmodels1. constructs2. notifies ^calls ^ uponupdate<<use>>uses < to generate GUIuses > to generatedata structures<<use>><<use>><<use>><<use>><<use>><<use>>Powered ByVisual Paradigm Community Edition diff --git a/DESIGN/Render/2025-11-24/RobotMBT-current.svg b/DESIGN/Render/2025-11-24/RobotMBT-current.svg new file mode 100644 index 00000000..f28924f5 --- /dev/null +++ b/DESIGN/Render/2025-11-24/RobotMBT-current.svg @@ -0,0 +1,1307 @@ + + ++ref_id : str+standard_attrs : list[?]+properties : dict[?,?]+values : dict[?,?]+scenario_variables : list[?]+add_prop(name : str)+del_prop(name : str)+new_scenario_scope()+end_scenario_scope()+process_expression(expr : str) : objectModelSpacethe current +ROBOT_LIBRARY_LISTENER : self+current_suite+robot_suite+processor_lib_name : str+__processor_lib+__processor_method+processor_options : dict[?,?]+__generateRobotSuite()+__init__(processor : str, processor_lib : Library|None)SuiteReplacer+EMBEDDED+POSITIONAL+VAR_POS+NAMED+FREE_NAMED+arg() : str+value() : any+modified() : bool+codestring() : strStepArgumentStepArguments<<primitive>>list+substitutions : dict [any,Constraint]+solutions : dict [any,any]+substitute(example_value, constraint)+solve() : dict [any,any]SubstitutionMap+optionset : list [str]+removed_stack : list [str]+__init__(constraint : list [str])ConstraintPlaceholder+name : str+filename : str+parent : ?+suites : list[?]+scenarios : list [Scenario]+setup : Step|None+teardown : Step|NoneSuite+name : str+parent : Scenario|None+setup : Step|None+teardown : Step|None+steps : list [Step]+src_id : ?|None+data_choices : dict[?,?]Scenario+org_step : str+org_pn_args : args+parent : Scenario+assign : ?+gherkin_kw [given,when,then]+signature+args : StepArguments+detached : bool+model_info : dict[?,?]Step-__init__(outer : Scope)-__getattr__(attr : str)-__setattr__(attr : str, value : obj)-__iter__()-__bool__()RecursiveScopeDefines -tracestate : TraceState+flatten(in_suite : Suite)+process_test_suite(in_suite : Suite, seed : str)-_try_to_reach_full_coverage(allow_duplicate_scenarios : bool)-_try_to_fit_in_scenario(index, candidate, retry_flag)SuiteProcessors-_c_pool : list [bool]-_tried : list [list[?]-_trace : list [str]-_snapshots : list[?]-_open_refinements : list[?]+model() : ?+tried() : tuple[?]+coverage_reached() : bool+coverage_drought() : int+next_candidate(retry : bool)+count(index : str)+highest_part(index : str)+find_scenarios_with_active_refinement() : list[?]TraceState+id : ?+inserted_scenario : ?+model_state : ?+drought : intTraceSnapShot1*1*1*1*suiteprocessors.pysuitedata.pymodelspace.pysteparguments.pysubstitutionmap.pysuitereplacer.pytracestate.py<<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>>"substitutions"Powered ByVisual Paradigm Community Edition diff --git a/DESIGN/Render/2025-11-24/RobotMBT-extensionplan.svg b/DESIGN/Render/2025-11-24/RobotMBT-extensionplan.svg new file mode 100644 index 00000000..788f9179 --- /dev/null +++ b/DESIGN/Render/2025-11-24/RobotMBT-extensionplan.svg @@ -0,0 +1,589 @@ + + +-tracestate : TraceState+flatten(in_suite : Suite)+process_test_suite(in_suite : Suite, seed : str)-_try_to_reach_full_coverage(allow_duplicate_scenarios : bool)-_try_to_fit_in_scenario(index, candidate, retry_flag)-_report_tracestate_wrapup()SuiteProcessors+trace : TraceState+state : StateInfoTraceInfo+update_visualisation(Suite, TraceInfo)+set_final_trace(info : TraceInfo)+generate_visualisation() : strVisualiser+generate_html(AbstractGraph) : strNetworkVisualiser+networkx : DiGraph+update_visualisation(info : TraceInfo)+set_final_trace(info : TraceInfo)AbstractGraphScenarioGraphStateGraph+name : str+src_id : str|NoneScenarioInfo+domain : str+properties : dictStateInfoBokehNetworkXsuiteprocessors.pyvisualisersmodels<<use>><<use>><<use>><<use>><<use>><<use>>1. constructs ^2. calls ^ with TraceInfouses < to generate HTML<<use>>Powered ByVisual Paradigm Community Edition diff --git a/DESIGN/VPP/RobotMBT.vpp b/DESIGN/VPP/RobotMBT.vpp index 735e8c10bce86c2129fcf0623e00692d8d732c29..3c0016343fb517aaf905ef654da22155d9a6c744 100644 GIT binary patch delta 88715 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 delta 136716 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 + """ + self.hover.tooltips = tooltips + else: + self.hover = None + # Add the nodes to the graph self._add_nodes(nodes) @@ -112,7 +129,7 @@ def _add_nodes(self, nodes: list[Node]): """ # The ColumnDataSources to store our nodes and edges in Bokeh's format node_source: ColumnDataSource = ColumnDataSource( - {'id': [], 'x': [], 'y': [], 'w': [], 'h': [], 'color': []}) + {'id': [], 'x': [], 'y': [], 'w': [], 'h': [], 'color': [], 'description': []}) node_label_source: ColumnDataSource = ColumnDataSource( {'id': [], 'x': [], 'y': [], 'label': []}) @@ -122,7 +139,10 @@ def _add_nodes(self, nodes: list[Node]): # Add the glyphs for nodes and their labels node_glyph = Rect(x='x', y='y', width='w', height='h', fill_color='color') - self.plot.add_glyph(node_source, node_glyph) + node_glyph_renderer = self.plot.add_glyph(node_source, node_glyph) + + if self.hover is not None: + self.hover.renderers = [node_glyph_renderer] node_label_glyph = Text(x='x', y='y', text='label', text_align='left', text_baseline='middle', text_font_size=f'{MAJOR_FONT_SIZE}pt', text_font=value("Courier New")) @@ -221,7 +241,8 @@ def _create_layout(self) -> tuple[list[Node], list[Edge]]: label = self.networkx.nodes[node_id]['label'] (x, y) = v.view.xy (w, h) = _calculate_dimensions(label) - ns.append(Node(node_id, label, x, -y, w, h)) + description = self.networkx.nodes[node_id]['description'] + ns.append(Node(node_id, label, x, -y, w, h, description)) es = [] for e in g.C[0].sE: @@ -250,6 +271,9 @@ def _add_features(self, suite_name: str): FullscreenTool(), ZoomInTool(factor=0.4), ZoomOutTool(factor=0.4)) self.plot.toolbar.active_scroll = wheel_zoom + if self.hover: + self.plot.add_tools(self.hover) + # Specify the default range - these values represent the aspect ratio of the actual view in the window self.plot.x_range = Range1d(-INNER_WINDOW_WIDTH / 2, INNER_WINDOW_WIDTH / 2) self.plot.y_range = Range1d(-INNER_WINDOW_HEIGHT, 0) @@ -556,6 +580,7 @@ def _add_node_to_sources(node: Node, final_trace: list[str], node_source: Column node_source.data['h'].append(node.height) node_source.data['color'].append( FINAL_TRACE_NODE_COLOR if node.node_id in final_trace else OTHER_NODE_COLOR) + node_source.data['description'].append(node.description) node_label_source.data['id'].append(node.node_id) node_label_source.data['x'].append(node.x - node.width / 2 + HORIZONTAL_PADDING_WITHIN_NODES) From 30ae3c2e8807ab90c80e102c10b6d33d101e17e1 Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 23 Jan 2026 17:14:41 +0100 Subject: [PATCH 109/131] Removed design files and graph types for PR --- DESIGN/Render/2025-09-25/RobotMBT-current.svg | 1322 ----------- .../RobotMBT-extensionplan-REDUCED.svg | 822 ------- .../2025-09-25/RobotMBT-extensionplan.svg | 2037 ----------------- .../2025-10-15/RobotMBT-extensionplan.svg | 1474 ------------ DESIGN/Render/2025-11-24/RobotMBT-current.svg | 1307 ----------- .../2025-11-24/RobotMBT-extensionplan.svg | 589 ----- .../2025-12-03/RobotMBT-extensionplan.svg | 669 ------ .../2025-12-08/RobotMBT-extensionplan.png | Bin 367375 -> 0 bytes .../2025-12-08/RobotMBT-extensionplan.svg | 653 ------ DESIGN/VPP/RobotMBT.vpp | Bin 1179648 -> 0 bytes atest/resources/helpers/modelgenerator.py | 2 +- robotmbt/visualise/graphs/deltavaluegraph.py | 56 - robotmbt/visualise/graphs/reducedSDVgraph.py | 108 - .../visualise/graphs/scenariostategraph.py | 50 - robotmbt/visualise/graphs/stategraph.py | 49 - robotmbt/visualise/visualiser.py | 8 - 16 files changed, 1 insertion(+), 9145 deletions(-) delete mode 100644 DESIGN/Render/2025-09-25/RobotMBT-current.svg delete mode 100644 DESIGN/Render/2025-09-25/RobotMBT-extensionplan-REDUCED.svg delete mode 100644 DESIGN/Render/2025-09-25/RobotMBT-extensionplan.svg delete mode 100644 DESIGN/Render/2025-10-15/RobotMBT-extensionplan.svg delete mode 100644 DESIGN/Render/2025-11-24/RobotMBT-current.svg delete mode 100644 DESIGN/Render/2025-11-24/RobotMBT-extensionplan.svg delete mode 100644 DESIGN/Render/2025-12-03/RobotMBT-extensionplan.svg delete mode 100644 DESIGN/Render/2025-12-08/RobotMBT-extensionplan.png delete mode 100644 DESIGN/Render/2025-12-08/RobotMBT-extensionplan.svg delete mode 100644 DESIGN/VPP/RobotMBT.vpp delete mode 100644 robotmbt/visualise/graphs/deltavaluegraph.py delete mode 100644 robotmbt/visualise/graphs/reducedSDVgraph.py delete mode 100644 robotmbt/visualise/graphs/scenariostategraph.py delete mode 100644 robotmbt/visualise/graphs/stategraph.py diff --git a/DESIGN/Render/2025-09-25/RobotMBT-current.svg b/DESIGN/Render/2025-09-25/RobotMBT-current.svg deleted file mode 100644 index 7e9e5c83..00000000 --- a/DESIGN/Render/2025-09-25/RobotMBT-current.svg +++ /dev/null @@ -1,1322 +0,0 @@ - - -+ref_id : str+standard_attrs : list[?]+properties : dict[?,?]+values : dict[?,?]+scenario_variables : list[?]+add_prop(name : str)+del_prop(name : str)+new_scenario_scope()+end_scenario_scope()+process_expression(expr : str) : objectModelSpacethe current +ROBOT_LIBRARY_LISTENER : self+current_suite+robot_suite+processor_lib_name : str+__processor_lib+__processor_method+processor_options : dict[?,?]+__generateRobotSuite()+__init__(processor : str, processor_lib : Library|None)SuiteReplacer+EMBEDDED+POSITIONAL+VAR_POS+NAMED+FREE_NAMED+arg() : str+value() : any+modified() : bool+codestring() : strStepArgumentStepArguments<<primitive>>list+substitutions : dict [any,Constraint]+solutions : dict [any,any]+substitute(example_value, constraint)+solve() : dict [any,any]SubstitutionMap+optionset : list [str]+removed_stack : list [str]+__init__(constraint : list [str])ConstraintPlaceholder+name : str+filename : str+parent : ?+suites : list[?]+scenarios : list [Scenario]+setup : Step|None+teardown : Step|NoneSuite+name : str+parent : Scenario|None+setup : Step|None+teardown : Step|None+steps : list [Step]+src_id : ?|None+data_choices : dict[?,?]Scenario+org_step : str+org_pn_args : args+parent : Scenario+assign : ?+gherkin_kw [given,when,then]+signature+args : StepArguments+detached : bool+model_info : dict[?,?]Step-__init__(outer : Scope)-__getattr__(attr : str)-__setattr__(attr : str, value : obj)-__iter__()-__bool__()RecursiveScopeDefines -tracestate : TraceState+flatten(in_suite : Suite)+process_test_suite(in_suite : Suite, seed : str)-_try_to_reach_full_coverage(allow_duplicate_scenarios : bool)-_try_to_fit_in_scenario(index, candidate, retry_flag)SuiteProcessors-_c_pool : list [bool]-_tried : list [list[?]-_trace : list [str]-_snapshots : list[?]-_open_refinements : list[?]+model() : ?+tried() : tuple[?]+coverage_reached() : bool+coverage_drought() : int+next_candidate(retry : bool)+count(index : str)+highest_part(index : str)+find_scenarios_with_active_refinement() : list[?]TraceState+id : ?+inserted_scenario : ?+model_state : ?+drought : intTraceSnapShot1*1*1*1*suiteprocessors.pysuitedata.pymodelspace.pysteparguments.pysubstitutionmap.pysuitereplacer.pytracestate.py<<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>>"substitutions"Powered ByVisual Paradigm Community Edition diff --git a/DESIGN/Render/2025-09-25/RobotMBT-extensionplan-REDUCED.svg b/DESIGN/Render/2025-09-25/RobotMBT-extensionplan-REDUCED.svg deleted file mode 100644 index 6ff0daec..00000000 --- a/DESIGN/Render/2025-09-25/RobotMBT-extensionplan-REDUCED.svg +++ /dev/null @@ -1,822 +0,0 @@ - - --tracestate : TraceState+flatten(in_suite : Suite)+process_test_suite(in_suite : Suite, seed : str)-_try_to_reach_full_coverage(allow_duplicate_scenarios : bool)-_try_to_fit_in_scenario(index, candidate, retry_flag)-_report_tracestate_wrapup()SuiteProcessors+scenarios : dict [str,Scenario]+trace : TraceState+modelspace : ModelSpaceTraceInfo+update_visualisation(Suite, TraceInfo)Visualiser+generate_interactive(GraphInstance) : void+generate_html(GraphInstance) : strNetworkVisualiserGUIButtonGUIProgressBar+pre_process_trace(TraceState) : IGraphTraceTransfomer+nodes+edgesIGraph...+run_tests(args)RunTests+traceUpdate(Suite, TraceState, ModelSpace)TraceGathererScenarioGraphStateGraphNetworkX gui-componentssuiteprocessors.pyvisualisersvisualiser-processingmodels1. constructs2. notifies ^calls ^ uponupdate<<use>>uses < to generate GUIuses > to generatedata structures<<use>><<use>><<use>><<use>><<use>><<use>>Powered ByVisual Paradigm Community Edition diff --git a/DESIGN/Render/2025-09-25/RobotMBT-extensionplan.svg b/DESIGN/Render/2025-09-25/RobotMBT-extensionplan.svg deleted file mode 100644 index 1c4ee17e..00000000 --- a/DESIGN/Render/2025-09-25/RobotMBT-extensionplan.svg +++ /dev/null @@ -1,2037 +0,0 @@ - - -+ref_id : str+standard_attrs : list[?]+properties : dict[?,?]+values : dict[?,?]+scenario_variables : list[?]+add_prop(name : str)+del_prop(name : str)+new_scenario_scope()+end_scenario_scope()+process_expression(expr : str) : objectModelSpacethe current +ROBOT_LIBRARY_LISTENER : self+current_suite+robot_suite+processor_lib_name : str+__processor_lib+__processor_method+processor_options : dict[?,?]+__generateRobotSuite()+__init__(processor : str, processor_lib : Library|None)SuiteReplacer+EMBEDDED+POSITIONAL+VAR_POS+NAMED+FREE_NAMED+arg() : str+value() : any+modified() : bool+codestring() : strStepArgumentStepArguments<<primitive>>list+substitutions : dict [any,Constraint]+solutions : dict [any,any]+substitute(example_value, constraint)+solve() : dict [any,any]SubstitutionMap+optionset : list [str]+removed_stack : list [str]+__init__(constraint : list [str])ConstraintPlaceholder+name : str+filename : str+parent : ?+suites : list[?]+scenarios : list [Scenario]+setup : Step|None+teardown : Step|NoneSuite+name : str+parent : Scenario|None+setup : Step|None+teardown : Step|None+steps : list [Step]+src_id : ?|None+data_choices : dict[?,?]Scenario+org_step : str+org_pn_args : args+parent : Scenario+assign : ?+gherkin_kw [given,when,then]+signature+args : StepArguments+detached : bool+model_info : dict[?,?]Step-__init__(outer : Scope)-__getattr__(attr : str)-__setattr__(attr : str, value : obj)-__iter__()-__bool__()RecursiveScopeDefines -tracestate : TraceState+flatten(in_suite : Suite)+process_test_suite(in_suite : Suite, seed : str)-_try_to_reach_full_coverage(allow_duplicate_scenarios : bool)-_try_to_fit_in_scenario(index, candidate, retry_flag)-_report_tracestate_wrapup()SuiteProcessors-_c_pool : list [bool]-_tried : list [list[?]-_trace : list [str]-_snapshots : list[?]-_open_refinements : list[?]+model() : ?+tried() : tuple[?]+coverage_reached() : bool+coverage_drought() : int+next_candidate(retry : bool)+count(index : str)+highest_part(index : str)+find_scenarios_with_active_refinement() : list[?]TraceState+id : ?+inserted_scenario : ?+model_state : ?+drought : intTraceSnapShot+scenarios : dict [str,Scenario]+trace : TraceState+todo other stuffVisualisationInfo+generate_visualisation(flags)Visualiser+generate_interactive(TraceNetwork) : void+generate_html(TraceNetwork) : strNetworkVisualiserGUIButtonGUIProgressBar+pre_process_trace(TraceState) : TraceNetworkVisualPreProcessor+scenarios : dict [str,Scenario]+scenarioOrder : dict [Scenario,list Scenario]TraceNetwork...+run_tests(args)RunTestsRobot+get_trace_state() : VisualisationInfoTracePreparerdependencies to all the other 1*1*1*1*modelsvisualiser-processingvisualisersgui-componentssuiteprocessors.pysuitedata.pymodelspace.pysteparguments.pysubstitutionmap.pysuitereplacer.pytracestate.py<<use>><<use>>produces ><<use>>Hooks into the methods of Robot<<use>><<use>>uses < to generate GUIuses > to generatedata structures<<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>>"substitutions"Powered ByVisual Paradigm Community Edition diff --git a/DESIGN/Render/2025-10-15/RobotMBT-extensionplan.svg b/DESIGN/Render/2025-10-15/RobotMBT-extensionplan.svg deleted file mode 100644 index c74ab783..00000000 --- a/DESIGN/Render/2025-10-15/RobotMBT-extensionplan.svg +++ /dev/null @@ -1,1474 +0,0 @@ - - -+ref_id : str+standard_attrs : list[?]+properties : dict[?,?]+values : dict[?,?]+scenario_variables : list[?]+add_prop(name : str)+del_prop(name : str)+new_scenario_scope()+end_scenario_scope()+process_expression(expr : str) : objectModelSpacethe current +name : str+filename : str+parent : ?+suites : list[?]+scenarios : list [Scenario]+setup : Step|None+teardown : Step|NoneSuite+name : str+parent : Scenario|None+setup : Step|None+teardown : Step|None+steps : list [Step]+src_id : ?|None+data_choices : dict[?,?]Scenario+org_step : str+org_pn_args : args+parent : Scenario+assign : ?+gherkin_kw [given,when,then]+signature+args : StepArguments+detached : bool+model_info : dict[?,?]Step-__init__(outer : Scope)-__getattr__(attr : str)-__setattr__(attr : str, value : obj)-__iter__()-__bool__()RecursiveScopeDefines -tracestate : TraceState+flatten(in_suite : Suite)+process_test_suite(in_suite : Suite, seed : str)-_try_to_reach_full_coverage(allow_duplicate_scenarios : bool)-_try_to_fit_in_scenario(index, candidate, retry_flag)-_report_tracestate_wrapup()SuiteProcessors-_c_pool : list [bool]-_tried : list [list[?]-_trace : list [str]-_snapshots : list[?]-_open_refinements : list[?]+model() : ?+tried() : tuple[?]+coverage_reached() : bool+coverage_drought() : int+next_candidate(retry : bool)+count(index : str)+highest_part(index : str)+find_scenarios_with_active_refinement() : list[?]TraceState+id : ?+inserted_scenario : ?+model_state : ?+drought : intTraceSnapShot+scenarios : dict [str,Scenario]+trace : TraceState+modelspace : ModelSpaceTraceInfo+update_visualisation(Suite, TraceInfo)Visualiser+generate_interactive(GraphInstance) : void+generate_html(GraphInstance) : strNetworkVisualiserGUIButtonGUIProgressBar+pre_process_trace(TraceState) : IGraphTraceTransfomer+nodes+edgesIGraph...+run_tests(args)RunTestsRobot+traceUpdate(Suite, TraceState, ModelSpace)TraceGathererScenarioGraphStateGraphNetworkX 1*1*gui-componentssuiteprocessors.pysuitedata.pymodelspace.pytracestate.pyvisualisersvisualiser-processingmodels1. constructs2. notifies ^calls ^ uponupdate<<use>><<use>><<use>><<use>><<use>><<use>>uses < to generate GUIuses > to generatedata structures<<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>>Powered ByVisual Paradigm Community Edition diff --git a/DESIGN/Render/2025-11-24/RobotMBT-current.svg b/DESIGN/Render/2025-11-24/RobotMBT-current.svg deleted file mode 100644 index f28924f5..00000000 --- a/DESIGN/Render/2025-11-24/RobotMBT-current.svg +++ /dev/null @@ -1,1307 +0,0 @@ - - -+ref_id : str+standard_attrs : list[?]+properties : dict[?,?]+values : dict[?,?]+scenario_variables : list[?]+add_prop(name : str)+del_prop(name : str)+new_scenario_scope()+end_scenario_scope()+process_expression(expr : str) : objectModelSpacethe current +ROBOT_LIBRARY_LISTENER : self+current_suite+robot_suite+processor_lib_name : str+__processor_lib+__processor_method+processor_options : dict[?,?]+__generateRobotSuite()+__init__(processor : str, processor_lib : Library|None)SuiteReplacer+EMBEDDED+POSITIONAL+VAR_POS+NAMED+FREE_NAMED+arg() : str+value() : any+modified() : bool+codestring() : strStepArgumentStepArguments<<primitive>>list+substitutions : dict [any,Constraint]+solutions : dict [any,any]+substitute(example_value, constraint)+solve() : dict [any,any]SubstitutionMap+optionset : list [str]+removed_stack : list [str]+__init__(constraint : list [str])ConstraintPlaceholder+name : str+filename : str+parent : ?+suites : list[?]+scenarios : list [Scenario]+setup : Step|None+teardown : Step|NoneSuite+name : str+parent : Scenario|None+setup : Step|None+teardown : Step|None+steps : list [Step]+src_id : ?|None+data_choices : dict[?,?]Scenario+org_step : str+org_pn_args : args+parent : Scenario+assign : ?+gherkin_kw [given,when,then]+signature+args : StepArguments+detached : bool+model_info : dict[?,?]Step-__init__(outer : Scope)-__getattr__(attr : str)-__setattr__(attr : str, value : obj)-__iter__()-__bool__()RecursiveScopeDefines -tracestate : TraceState+flatten(in_suite : Suite)+process_test_suite(in_suite : Suite, seed : str)-_try_to_reach_full_coverage(allow_duplicate_scenarios : bool)-_try_to_fit_in_scenario(index, candidate, retry_flag)SuiteProcessors-_c_pool : list [bool]-_tried : list [list[?]-_trace : list [str]-_snapshots : list[?]-_open_refinements : list[?]+model() : ?+tried() : tuple[?]+coverage_reached() : bool+coverage_drought() : int+next_candidate(retry : bool)+count(index : str)+highest_part(index : str)+find_scenarios_with_active_refinement() : list[?]TraceState+id : ?+inserted_scenario : ?+model_state : ?+drought : intTraceSnapShot1*1*1*1*suiteprocessors.pysuitedata.pymodelspace.pysteparguments.pysubstitutionmap.pysuitereplacer.pytracestate.py<<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>><<use>>"substitutions"Powered ByVisual Paradigm Community Edition diff --git a/DESIGN/Render/2025-11-24/RobotMBT-extensionplan.svg b/DESIGN/Render/2025-11-24/RobotMBT-extensionplan.svg deleted file mode 100644 index 788f9179..00000000 --- a/DESIGN/Render/2025-11-24/RobotMBT-extensionplan.svg +++ /dev/null @@ -1,589 +0,0 @@ - - --tracestate : TraceState+flatten(in_suite : Suite)+process_test_suite(in_suite : Suite, seed : str)-_try_to_reach_full_coverage(allow_duplicate_scenarios : bool)-_try_to_fit_in_scenario(index, candidate, retry_flag)-_report_tracestate_wrapup()SuiteProcessors+trace : TraceState+state : StateInfoTraceInfo+update_visualisation(Suite, TraceInfo)+set_final_trace(info : TraceInfo)+generate_visualisation() : strVisualiser+generate_html(AbstractGraph) : strNetworkVisualiser+networkx : DiGraph+update_visualisation(info : TraceInfo)+set_final_trace(info : TraceInfo)AbstractGraphScenarioGraphStateGraph+name : str+src_id : str|NoneScenarioInfo+domain : str+properties : dictStateInfoBokehNetworkXsuiteprocessors.pyvisualisersmodels<<use>><<use>><<use>><<use>><<use>><<use>>1. constructs ^2. calls ^ with TraceInfouses < to generate HTML<<use>>Powered ByVisual Paradigm Community Edition diff --git a/DESIGN/Render/2025-12-03/RobotMBT-extensionplan.svg b/DESIGN/Render/2025-12-03/RobotMBT-extensionplan.svg deleted file mode 100644 index 5cf4f71c..00000000 --- a/DESIGN/Render/2025-12-03/RobotMBT-extensionplan.svg +++ /dev/null @@ -1,669 +0,0 @@ - - --tracestate : TraceState+flatten(in_suite : Suite)+process_test_suite(in_suite : Suite, seed : str)-_try_to_reach_full_coverage(allow_duplicate_scenarios : bool)-_try_to_fit_in_scenario(index, candidate, retry_flag)-_report_tracestate_wrapup()SuiteProcessors+trace : TraceState+state : StateInfo+update_trace(ScenarioInfo, StateInfo)+encountered_scenarios() : set [ScenarioInfo]+encountered_states() : set [StateInfo]+encountered_scenariostatepairs()TraceInfo+update_trace(TraceState, ModelSpace)+generate_visualisation() : strVisualiser+generate_html(AbstractGraph) : strNetworkVisualiser+networkx : DiGraph+update_visualisation(info : TraceInfo)+get_final_trace() : list [str]+set_final_trace(info : TraceInfo)+select_node_info() : NodeInfo+select_edge_info() : NodeInfo+create_node_label(NodeInfo) : str+create_edge_label(NodeInfo) : strAbstractGraphScenarioGraphStateGraph+name : str+src_id : str|NoneScenarioInfo+domain : str+properties : dictStateInfoBokehNetworkXScenarioStateGraphsuiteprocessors.pyvisualisersmodels<<use>><<use>><<use>><<use>><<use>><<use>>1. constructs ^2. calls ^ with TraceInfouses < to generate HTML<<use>>Powered ByVisual Paradigm Community Edition diff --git a/DESIGN/Render/2025-12-08/RobotMBT-extensionplan.png b/DESIGN/Render/2025-12-08/RobotMBT-extensionplan.png deleted file mode 100644 index 60dc22be70f11878d02b8887fd99e357d524d208..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 367375 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�D)3#IFY**py&=0e6LEezPYCnUg9qJo+u6XX2 zi!3Q=YFz3W&W=4<0=)Ve-+IjKsww)0QC#6vrFW^}@!iIxK0D}qYXXnm$Gv*RW>R`@ z%w;I-tRCQgG2((6bTu2$(vc7J4fhTs??;;+oi}H5{Xmx#qEiyK1aGf{BNa4b8eamZ zymr!Sa_NAe=umV`nUj{?HjW(RJ)i&YJMXWBQq2;zhw{5@sA&Xw=Yjm3K9D}07cD9@ zk^}T?QX(s`k=ZgbCatq){O0;|ZxI8w@cL|z+}2AfW6Nb7OBD-@pa4de=IOY2UF||z zdhJG1y+p0ApZk-=n4?50M~!$;T?&krRW%299+H&8w>QxR5%XnavXR6ZieZN zDg^8ahiY8t2UBP6UT9#puj{m~tlUyLw z$kX3kp(2?iIT@9%qsWZ-_kHsp=(7yJkV&blWQVW$DEsKW`-z4lqP@@C?ysgvZ)9Nn zWfqBwGHcP>eZxNmkpDpN{zyJO2I;!kJqE(j(Nexy89lk)+ah{73f$Q>0Ivf0oa@bX z-$Z7w%$?EONwOW2d4v1KS55rx>-A9b9=Um>k2LxwyOF5^BA|!FO=fCylu_nP=EiBo z=UN}(H3A)oT>&4h5Qu%h#Xr|&0Nt{J-uEDyODt%JKHN!)<|Vo;A&sA5xxVD2B=_)U zvSrx)B(;m-ITn^Tmq_3W(6VTR>b#!@m^2AQ#_iH+=$2US%;j8=&WxpkO^M0Y*^D)8 zB3B}X+9KqpZ08W_U7K2>1i1kFDcm&*;rjB|)1u&NUFY`uktv3X5`;s$%1m^J*aU+rZ%u#; zA{VxV`166`irYcPKnlTxvC3dsQV4e<{nvcj&%Xk=EI&$S zA<@fBnV8f4HnlV_n1hsUuT$gTn2i7-c(Auw3dL15Y;7I=X~aK`?($5!(K#f#MMv`* z=}$f~>UWhZuv*{<++BP03F1yAe(6IJpiz>|zT7?5HN#YU81|haH0rjn=FurlTfkAs zxd?-y?$FL#km@xq^*+ zd!@%L-pd$hGXZ3WvSrs5=&F~rBQdGLO+Mdfv@}MRhr)X94qEL&@=7>6Ni$v{ThzH& zv^##Lvq>nYF!fDTG;yfsXxiu zBE7&LtnfMZHowTv7mnN++Sx^r!@L^__5MEc_r!Att(Le4tyzR@bg%A-U-oN5c`n_2 za^pbm?c@B3Z-A^$znCC>YqteO&P^%1m}OpA6f4dx1G?ImAUQ^5iJPEq%>H5^mlQR( ziKN6S&<;I%rh_vIn`dOq)M_WRggA~)yY)EJAPr>5@OPZ%0DoWHgG#a+;nooX@_yas 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 CRHdE( diff --git a/DESIGN/Render/2025-12-08/RobotMBT-extensionplan.svg b/DESIGN/Render/2025-12-08/RobotMBT-extensionplan.svg deleted file mode 100644 index 93fd55d5..00000000 --- a/DESIGN/Render/2025-12-08/RobotMBT-extensionplan.svg +++ /dev/null @@ -1,653 +0,0 @@ - - --tracestate : TraceState+flatten(in_suite : Suite)+process_test_suite(in_suite : Suite, seed : str, graph_type : str)-_try_to_reach_full_coverage(allow_duplicate_scenarios : bool)-_try_to_fit_in_scenario(index, candidate, retry_flag)-_report_tracestate_wrapup()SuiteProcessors+curr_trace : list [tuple[ScenarioInfo,StateInfo]+all_traces : list [list[tuple[ScenarioInfo,StateInfo]+update_trace(ScenarioInfo, StateInfo)+encountered_scenarios() : set [ScenarioInfo]+encountered_states() : set [StateInfo]+encountered_scenariostatepairs()TraceInfo+update_trace(TraceState, ModelSpace)+generate_visualisation() : strVisualiser+generate_html(AbstractGraph) : strNetworkVisualiser+networkx : DiGraph+update_visualisation(info : TraceInfo)+get_final_trace() : list [str]+select_node_info() : NodeInfo+select_edge_info() : EdgeInfo+create_node_label(NodeInfo) : str+create_edge_label(EdgeInfo) : strAbstractGraphScenarioGraphStateGraph+name : str+src_id : str|NoneScenarioInfo+domain : str+properties : dictStateInfoBokehNetworkXScenarioStateGraphsuiteprocessors.pyvisualisersmodels<<use>><<use>><<use>><<use>><<use>><<use>>calls ^ upon updateuses < to generate HTML<<use>>Powered ByVisual Paradigm Community Edition diff --git a/DESIGN/VPP/RobotMBT.vpp b/DESIGN/VPP/RobotMBT.vpp deleted file mode 100644 index 39865f25b73eecee289245338f1e2655c2286910..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1179648 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 i9V+0#M9oGR>jbfE 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/}C)_Kgc68SJe!6DfkC$_PE%^AM(LQ6|^Xty= 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 TraceInfo: with open(filepath, 'r') as f: string = f.read() decoded_instance: TraceInfo = jsonpickle.decode(string) # type: ignore - visualiser = Visualiser('state', trace_info=decoded_instance) + visualiser = Visualiser('scenario', trace_info=decoded_instance) return visualiser.trace_info @keyword(name='Check File Exists') # type:ignore diff --git a/robotmbt/visualise/graphs/deltavaluegraph.py b/robotmbt/visualise/graphs/deltavaluegraph.py deleted file mode 100644 index 8dfebe1f..00000000 --- a/robotmbt/visualise/graphs/deltavaluegraph.py +++ /dev/null @@ -1,56 +0,0 @@ -from robotmbt.visualise.graphs.abstractgraph import AbstractGraph -from robotmbt.visualise.models import StateInfo, ScenarioInfo -from robotmbt.modelspace import ModelSpace - - -class DeltaValueGraph(AbstractGraph[set[tuple[str, str]], ScenarioInfo]): - """ - The state graph is a more advanced representation of trace exploration, allowing you to see the internal state. - It represents states as nodes, and scenarios as edges. - """ - - @staticmethod - def select_node_info(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) -> set[tuple[str, str]]: - if index == 0: - return StateInfo(ModelSpace()).difference(trace[0][1]) - else: - return trace[index-1][1].difference(trace[index][1]) - - @staticmethod - def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> ScenarioInfo: - return pair[0] - - @staticmethod - def create_node_description(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) -> str: - return str(trace[index][1]).replace('\n', '
') - - @staticmethod - def create_node_label(info: set[tuple[str, str]]) -> str: - res = "" - for assignment in info: - res += "\n"+assignment[0]+":"+assignment[1] - return f"{res}" - - @staticmethod - def create_edge_label(info: ScenarioInfo) -> str: - return info.name - - @staticmethod - def get_legend_info_final_trace_node() -> str: - return "Execution State Update (in final trace)" - - @staticmethod - def get_legend_info_other_node() -> str: - return "Execution State Update (backtracked)" - - @staticmethod - def get_legend_info_final_trace_edge() -> str: - return "Executed Scenario (in final trace)" - - @staticmethod - def get_legend_info_other_edge() -> str: - return "Executed Scenario (backtracked)" - - @staticmethod - def get_tooltip_name() -> str: - return "Full state" diff --git a/robotmbt/visualise/graphs/reducedSDVgraph.py b/robotmbt/visualise/graphs/reducedSDVgraph.py deleted file mode 100644 index 15c639e0..00000000 --- a/robotmbt/visualise/graphs/reducedSDVgraph.py +++ /dev/null @@ -1,108 +0,0 @@ -import networkx - -from robotmbt.modelspace import ModelSpace -from robotmbt.visualise.graphs.abstractgraph import AbstractGraph -from robotmbt.visualise.graphs.scenariodeltavaluegraph import ScenarioDeltaValueGraph -from robotmbt.visualise.models import ScenarioInfo, StateInfo, TraceInfo - - -class ReducedSDVGraph(AbstractGraph[tuple[ScenarioInfo, set[tuple[str, str]]], None]): - """ - The reduced Scenario-delta-Value graph keeps track of both the scenarios and state updates encountered. - It is produced by taking the Scenario-delta-Value graph and merging nodes that have the same Scenario associated, - an edge between them and the same incoming/outgoing edges except for at most one incoming or outgoing edge per node. - Visually: ... -> node0 -> node1 -> node2 -> ... - get merged (if they have the same scenario's and incoming/outgoing edges that are not visually represented) - """ - - def chain_equiv(self, node1, node2) -> bool: - context = self.networkx - if not node1 == 'start' and not node2 == 'start' and self.ids[node1][0] == self.ids[node2][0] and \ - (networkx.has_path(context, node1, node2) or networkx.has_path(context, node2, node1)): - return len(set(context.in_edges(node1)) ^ set(context.in_edges(node2))) <= 2 and \ - len(set(context.out_edges(node1)) ^ set(context.out_edges(node2))) <= 2 - else: - return False - - @staticmethod - def _generate_equiv_class_label(equiv_class, old_labels): - if len(equiv_class) == 1: - return old_labels[set(equiv_class).pop()] - else: - return "(merged: " + str(len(equiv_class)) + ")\n" + old_labels[set(equiv_class).pop()] - - def __init__(self, info: TraceInfo): - super().__init__(info) - old_labels = networkx.get_node_attributes(self.networkx, "label") - self.networkx = networkx.quotient_graph(self.networkx, lambda x, y: self.chain_equiv(x, y), - node_data=lambda equiv_class: { - 'label': self._generate_equiv_class_label(equiv_class, old_labels)}, - edge_data=lambda x, y: {'label': ''}) - # TODO make generated label more obvious to be equivalence class - nodes = self.networkx.nodes - - new_networkx: networkx.DiGraph = networkx.DiGraph() - - for node_id in self.networkx.nodes: - new_id: tuple[str, ...] = tuple(sorted(node_id)) - new_networkx.add_node(new_id) - new_networkx.nodes[new_id]['label'] = self.networkx.nodes[node_id]['label'] - - for (from_id, to_id) in self.networkx.edges: - new_from_id: tuple[str, ...] = tuple(sorted(from_id)) - new_to_id: tuple[str, ...] = tuple(sorted(to_id)) - new_networkx.add_edge(new_from_id, new_to_id) - new_networkx.edges[(new_from_id, new_to_id)]['label'] = self.networkx.edges[(from_id, to_id)]['label'] - - for i in range(len(self.final_trace)): - current_node = self.final_trace[i] - for new_node in nodes: - if current_node in new_node: - self.final_trace[i] = tuple(sorted(new_node)) - - self.networkx = new_networkx - self.start_node: tuple[str, ...] = tuple(['start']) - - @staticmethod - def select_node_info(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) \ - -> tuple[ScenarioInfo, set[tuple[str, str]]]: - if index == 0: - return trace[0][0], StateInfo(ModelSpace()).difference(trace[0][1]) - else: - return trace[index][0], trace[index - 1][1].difference(trace[index][1]) - - @staticmethod - def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> None: - return None - - @staticmethod - def create_node_description(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) -> str: - return str(trace[index][1]).replace('\n', '
') - - @staticmethod - def create_node_label(info: tuple[ScenarioInfo, set[tuple[str, str]]]) -> str: - return ScenarioDeltaValueGraph.create_node_label(info) - - @staticmethod - def create_edge_label(info: None) -> str: - return '' - - @staticmethod - def get_legend_info_final_trace_node() -> str: - return "Executed Scenario w/ Changes in Execution State (in final trace)" - - @staticmethod - def get_legend_info_other_node() -> str: - return "Executed Scenario w/ Changes in Execution State (backtracked)" - - @staticmethod - def get_legend_info_final_trace_edge() -> str: - return "Execution Flow (final trace)" - - @staticmethod - def get_legend_info_other_edge() -> str: - return "Execution Flow (backtracked)" - - @staticmethod - def get_tooltip_name() -> str: - return "Full state" diff --git a/robotmbt/visualise/graphs/scenariostategraph.py b/robotmbt/visualise/graphs/scenariostategraph.py deleted file mode 100644 index aebc7db2..00000000 --- a/robotmbt/visualise/graphs/scenariostategraph.py +++ /dev/null @@ -1,50 +0,0 @@ -from robotmbt.visualise.graphs.abstractgraph import AbstractGraph -from robotmbt.visualise.models import ScenarioInfo, StateInfo - - -class ScenarioStateGraph(AbstractGraph[tuple[ScenarioInfo, StateInfo], None]): - """ - The scenario-State graph keeps track of both the scenarios and states encountered. - Its nodes are scenarios together with the state after the scenario has run. - Its edges represent steps in the trace. - """ - - @staticmethod - def select_node_info(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) -> tuple[ScenarioInfo, StateInfo]: - return trace[index] - - @staticmethod - def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> None: - return None - - @staticmethod - def create_node_description(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) -> str: - return '' - - @staticmethod - def create_node_label(info: tuple[ScenarioInfo, StateInfo]) -> str: - return f"{info[0].name}\n\n{str(info[1])}" - - @staticmethod - def create_edge_label(info: None) -> str: - return '' - - @staticmethod - def get_legend_info_final_trace_node() -> str: - return "Executed Scenario w/ Execution State (in final trace)" - - @staticmethod - def get_legend_info_other_node() -> str: - return "Executed Scenario w/ Execution State (backtracked)" - - @staticmethod - def get_legend_info_final_trace_edge() -> str: - return "Execution Flow (final trace)" - - @staticmethod - def get_legend_info_other_edge() -> str: - return "Execution Flow (backtracked)" - - @staticmethod - def get_tooltip_name() -> str: - return "" diff --git a/robotmbt/visualise/graphs/stategraph.py b/robotmbt/visualise/graphs/stategraph.py deleted file mode 100644 index aab07ba5..00000000 --- a/robotmbt/visualise/graphs/stategraph.py +++ /dev/null @@ -1,49 +0,0 @@ -from robotmbt.visualise.graphs.abstractgraph import AbstractGraph -from robotmbt.visualise.models import StateInfo, ScenarioInfo - - -class StateGraph(AbstractGraph[StateInfo, ScenarioInfo]): - """ - The state graph is a more advanced representation of trace exploration, allowing you to see the internal state. - It represents states as nodes, and scenarios as edges. - """ - - @staticmethod - def select_node_info(pairs: list[tuple[ScenarioInfo, StateInfo]], index: int) -> StateInfo: - return pairs[index][1] - - @staticmethod - def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> ScenarioInfo: - return pair[0] - - @staticmethod - def create_node_description(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) -> str: - return '' - - @staticmethod - def create_node_label(info: StateInfo) -> str: - return str(info) - - @staticmethod - def create_edge_label(info: ScenarioInfo) -> str: - return info.name - - @staticmethod - def get_legend_info_final_trace_node() -> str: - return "Execution State (in final trace)" - - @staticmethod - def get_legend_info_other_node() -> str: - return "Execution State (backtracked)" - - @staticmethod - def get_legend_info_final_trace_edge() -> str: - return "Executed Scenario (in final trace)" - - @staticmethod - def get_legend_info_other_edge() -> str: - return "Executed Scenario (backtracked)" - - @staticmethod - def get_tooltip_name() -> str: - return "" diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py index efb1bf6e..bff8358a 100644 --- a/robotmbt/visualise/visualiser.py +++ b/robotmbt/visualise/visualiser.py @@ -1,23 +1,15 @@ from robotmbt.modelspace import ModelSpace from robotmbt.tracestate import TraceState from robotmbt.visualise import networkvisualiser -from robotmbt.visualise.graphs.deltavaluegraph import DeltaValueGraph -from robotmbt.visualise.graphs.reducedSDVgraph import ReducedSDVGraph from robotmbt.visualise.graphs.scenariodeltavaluegraph import ScenarioDeltaValueGraph from robotmbt.visualise.graphs.abstractgraph import AbstractGraph from robotmbt.visualise.graphs.scenariograph import ScenarioGraph -from robotmbt.visualise.graphs.stategraph import StateGraph -from robotmbt.visualise.graphs.scenariostategraph import ScenarioStateGraph from robotmbt.visualise.models import TraceInfo, StateInfo, ScenarioInfo import html GRAPHS = { 'scenario': ScenarioGraph, - 'state': StateGraph, - 'scenario-state': ScenarioStateGraph, - 'delta-value': DeltaValueGraph, 'scenario-delta-value': ScenarioDeltaValueGraph, - 'reduced-sdv': ReducedSDVGraph, } From 20edb860bdec420a0da3ce8a3739fe4308554b12 Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 23 Jan 2026 17:20:48 +0100 Subject: [PATCH 110/131] Remove unused methods --- robotmbt/visualise/models.py | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index 366a6e52..92ee8d2d 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -211,33 +211,6 @@ def _pop(self, n: int): self.current_trace.pop() self.pushed = False - def encountered_scenarios(self) -> set[ScenarioInfo]: - res = set() - - for trace in self.all_traces: - for (scenario, state) in trace: - res.add(scenario) - - return res - - def encountered_states(self) -> set[StateInfo]: - res = set() - - for trace in self.all_traces: - for (scenario, state) in trace: - res.add(state) - - return res - - def encountered_scenario_state_pairs(self) -> set[tuple[ScenarioInfo, StateInfo]]: - res = set() - - for trace in self.all_traces: - for (scenario, state) in trace: - res.add((scenario, state)) - - return res - def __repr__(self) -> str: return f"TraceInfo(traces=[{[f'[{[self.stringify_pair(pair) for pair in trace]}]' for trace in self.all_traces]}], current=[{[self.stringify_pair(pair) for pair in self.current_trace]}])" From 3733ba2c2a27fd1e379a2c24db729728858c4221 Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 23 Jan 2026 17:26:21 +0100 Subject: [PATCH 111/131] Reduce update visualisation calls --- robotmbt/suiteprocessors.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index 31377b4b..531d777a 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -150,23 +150,19 @@ def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool) -> TraceS tracestate = TraceState(self.shuffled) while not tracestate.coverage_reached(): candidate_id = tracestate.next_candidate(retry=allow_duplicate_scenarios) - self.__update_visualisation(tracestate) if candidate_id is None: # No more candidates remaining for this level if not tracestate.can_rewind(): break tail = modeller.rewind(tracestate) logger.debug(f"Having to roll back up to {tail.scenario.name if tail else 'the beginning'}") self._report_tracestate_to_user(tracestate) - self.__update_visualisation(tracestate) else: candidate = self._select_scenario_variant(candidate_id, tracestate) if not candidate: # No valid variant available in the current state tracestate.reject_scenario(candidate_id) - self.__update_visualisation(tracestate) continue previous_len = len(tracestate) modeller.try_to_fit_in_scenario(candidate, tracestate) - self.__update_visualisation(tracestate) self._report_tracestate_to_user(tracestate) if len(tracestate) > previous_len: logger.debug(f"last state:\n{tracestate.model.get_status_text()}") @@ -174,14 +170,13 @@ def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool) -> TraceS if self.__last_candidate_changed_nothing(tracestate): logger.debug("Repeated scenario did not change the model's state. Stop trying.") modeller.rewind(tracestate) - self.__update_visualisation(tracestate) elif tracestate.coverage_drought > self.DROUGHT_LIMIT: logger.debug(f"Went too long without new coverage (>{self.DROUGHT_LIMIT}x). " "Roll back to last coverage increase and try something else.") modeller.rewind(tracestate, drought_recovery=True) - self.__update_visualisation(tracestate) self._report_tracestate_to_user(tracestate) logger.debug(f"last state:\n{tracestate.model.get_status_text()}") + self.__update_visualisation(tracestate) return tracestate def __update_visualisation(self, tracestate: TraceState): From 547b6774bddba6aaf6d3e64f7bba931c745ed3c8 Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 23 Jan 2026 17:34:08 +0100 Subject: [PATCH 112/131] Remove whitespace changes --- robotmbt/substitutionmap.py | 3 --- robotmbt/suitedata.py | 1 - robotmbt/suiteprocessors.py | 6 ------ robotmbt/suitereplacer.py | 8 -------- robotmbt/tracestate.py | 2 -- 5 files changed, 20 deletions(-) diff --git a/robotmbt/substitutionmap.py b/robotmbt/substitutionmap.py index f2494ec4..d5bc66a1 100644 --- a/robotmbt/substitutionmap.py +++ b/robotmbt/substitutionmap.py @@ -70,14 +70,12 @@ def solve(self) -> dict[str, str]: substitutions = self.copy().substitutions unsolved_subs = list(substitutions) subs_stack = [] - while unsolved_subs: unsolved_subs.sort(key=lambda i: len(substitutions[i].optionset)) example_value = unsolved_subs[0] solution[example_value] = random.choice(substitutions[example_value].optionset) subs_stack.append(example_value) others_list = [] - try: # exclude the choice from all others for other in [e for e in substitutions if e != example_value]: @@ -110,7 +108,6 @@ def solve(self) -> dict[str, str]: except ValueError: # next level must also be rolled back example_value = last_item - self.solution = solution return solution diff --git a/robotmbt/suitedata.py b/robotmbt/suitedata.py index 885f2022..01f79734 100644 --- a/robotmbt/suitedata.py +++ b/robotmbt/suitedata.py @@ -296,7 +296,6 @@ def __parse_model_info(self, docu: str) -> dict[str, list[str]]: if "" in lines: lines = lines[:lines.index("")] format_msg = "*model info* expected format: :: |" - while lines: line = lines.pop(0) if not line.startswith(":"): diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index 531d777a..d702eabe 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -73,7 +73,6 @@ def flatten(self, in_suite: Suite) -> Suite: if scenario.teardown: scenario.steps.append(scenario.teardown) scenario.teardown = None - out_suite.scenarios = [] for suite in in_suite.suites: subsuite = self.flatten(suite) @@ -83,7 +82,6 @@ def flatten(self, in_suite: Suite) -> Suite: if subsuite.teardown: scenario.steps.append(subsuite.teardown) out_suite.scenarios.extend(subsuite.scenarios) - out_suite.scenarios.extend(outer_scenarios) out_suite.suites = [] return out_suite @@ -245,7 +243,6 @@ def _report_tracestate_wrapup(tracestate: TraceState): def _init_randomiser(seed: str | int | bytes | bytearray): if isinstance(seed, str): seed = seed.strip() - if str(seed).lower() == 'none': logger.info( "Using system's random seed for trace generation. This trace cannot be rerun. Use `seed=new` to generate a reusable seed.") @@ -274,12 +271,9 @@ def _generate_seed() -> str: new_choice = consonants if prior_choice is vowels else vowels else: new_choice = random.choice([vowels, consonants]) - prior_choice = last_choice last_choice = new_choice string += random.choice(new_choice) - words.append(string) - seed = '-'.join(words) return seed diff --git a/robotmbt/suitereplacer.py b/robotmbt/suitereplacer.py index 2f0debb8..faa3fe81 100644 --- a/robotmbt/suitereplacer.py +++ b/robotmbt/suitereplacer.py @@ -117,12 +117,10 @@ def __process_robot_suite(self, in_suite: robot.model.TestSuite, parent: Suite | step_info = Step(in_suite.setup.name, *in_suite.setup.args, parent=out_suite) step_info.add_robot_dependent_data(Robot._namespace.get_runner(step_info.org_step).keyword) out_suite.setup = step_info - if in_suite.teardown and parent is not None: step_info = Step(in_suite.teardown.name, *in_suite.teardown.args, parent=out_suite) step_info.add_robot_dependent_data(Robot._namespace.get_runner(step_info.org_step).keyword) out_suite.teardown = step_info - for st in in_suite.suites: out_suite.suites.append(self.__process_robot_suite(st, parent=out_suite)) for tc in in_suite.tests: @@ -131,32 +129,26 @@ def __process_robot_suite(self, in_suite: robot.model.TestSuite, parent: Suite | step_info = Step(tc.setup.name, *tc.setup.args, parent=scenario) step_info.add_robot_dependent_data(Robot._namespace.get_runner(step_info.org_step).keyword) scenario.setup = step_info - if tc.teardown: step_info = Step(tc.teardown.name, *tc.teardown.args, parent=scenario) step_info.add_robot_dependent_data(Robot._namespace.get_runner(step_info.org_step).keyword) scenario.teardown = step_info last_gwt = None - for step_def in tc.body: if isinstance(step_def, rmodel.Keyword): step_info = Step(step_def.name, *step_def.args, parent=scenario, assign=step_def.assign, prev_gherkin_kw=last_gwt) step_info.add_robot_dependent_data(Robot._namespace.get_runner(step_info.org_step).keyword) scenario.steps.append(step_info) - if step_info.gherkin_kw: last_gwt = step_info.gherkin_kw - elif isinstance(step_def, rmodel.Var): scenario.steps.append(Step('VAR', step_def.name, *step_def.value, parent=scenario)) else: unsupported_step = Step(str(step_def), parent=scenario) unsupported_step.model_info['error'] = f"Robot construct not supported" scenario.steps.append(unsupported_step) - out_suite.scenarios.append(scenario) - return out_suite def __clearTestSuite(self, suite: robot.model.TestSuite): diff --git a/robotmbt/tracestate.py b/robotmbt/tracestate.py index 4161662e..21f1ed6c 100644 --- a/robotmbt/tracestate.py +++ b/robotmbt/tracestate.py @@ -90,13 +90,11 @@ def next_candidate(self, retry: bool = False): for i in self.c_pool: if i not in self._tried[-1] and not self.is_refinement_active(i) and self.count(i) == 0: return i - if not retry: return None for i in self.c_pool: if i not in self._tried[-1] and not self.is_refinement_active(i): return i - return None def count(self, index: int) -> int: From 925af54bdfe0281b0a561aa8018289035bf3164c Mon Sep 17 00:00:00 2001 From: Thomas Date: Sun, 25 Jan 2026 14:34:35 +0100 Subject: [PATCH 113/131] Fix warn on missing dependencies bug --- robotmbt/suiteprocessors.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index d702eabe..25ebdcb8 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -128,9 +128,12 @@ def _run_test_suite(self, seed: str | int | bytes | bytearray, graph: str, suite self.visualiser = None logger.warn(f'Could not initialise visualiser due to error!\n{e}') - elif (not graph or not export_dir) and not visualisation_deps_present: + elif graph and not visualisation_deps_present: logger.warn(f'Visualisation {graph} requested, but required dependencies are not installed. ' 'Refer to the README on how to install these dependencies. ') + elif export_dir and not visualisation_deps_present: + logger.warn(f'Visualization export to {export_dir} requested, but required dependencies are not installed. ' + 'Refer to the README on how to install these dependencies. ') # a short trace without the need for repeating scenarios is preferred tracestate = self._try_to_reach_full_coverage(allow_duplicate_scenarios=False) From 07d163947add2d712d27d2f6dfc07254547d8d41 Mon Sep 17 00:00:00 2001 From: Thomas Date: Sun, 25 Jan 2026 14:51:31 +0100 Subject: [PATCH 114/131] Move visualisation atests to a single outer suite --- .../01__visualisation_representations}/01__setup.robot | 0 .../01__visualisation_representations}/02__scenario.robot | 0 .../03__scenario-delta-value.robot | 0 .../01__visualisation_representations}/__init__.robot | 2 +- .../02__visualisation_execution_path}/01_repetition.robot | 2 +- .../03__visualisation_export}/01__export to JSON.robot | 2 +- 6 files changed, 3 insertions(+), 3 deletions(-) rename atest/robotMBT tests/{10__visualisation_representations => 10__visualisation/01__visualisation_representations}/01__setup.robot (100%) rename atest/robotMBT tests/{10__visualisation_representations => 10__visualisation/01__visualisation_representations}/02__scenario.robot (100%) rename atest/robotMBT tests/{10__visualisation_representations => 10__visualisation/01__visualisation_representations}/03__scenario-delta-value.robot (100%) rename atest/robotMBT tests/{10__visualisation_representations => 10__visualisation/01__visualisation_representations}/__init__.robot (81%) rename atest/robotMBT tests/{11__visualisation_execution_path => 10__visualisation/02__visualisation_execution_path}/01_repetition.robot (94%) rename atest/robotMBT tests/{12__visualisation_export => 10__visualisation/03__visualisation_export}/01__export to JSON.robot (95%) diff --git a/atest/robotMBT tests/10__visualisation_representations/01__setup.robot b/atest/robotMBT tests/10__visualisation/01__visualisation_representations/01__setup.robot similarity index 100% rename from atest/robotMBT tests/10__visualisation_representations/01__setup.robot rename to atest/robotMBT tests/10__visualisation/01__visualisation_representations/01__setup.robot diff --git a/atest/robotMBT tests/10__visualisation_representations/02__scenario.robot b/atest/robotMBT tests/10__visualisation/01__visualisation_representations/02__scenario.robot similarity index 100% rename from atest/robotMBT tests/10__visualisation_representations/02__scenario.robot rename to atest/robotMBT tests/10__visualisation/01__visualisation_representations/02__scenario.robot diff --git a/atest/robotMBT tests/10__visualisation_representations/03__scenario-delta-value.robot b/atest/robotMBT tests/10__visualisation/01__visualisation_representations/03__scenario-delta-value.robot similarity index 100% rename from atest/robotMBT tests/10__visualisation_representations/03__scenario-delta-value.robot rename to atest/robotMBT tests/10__visualisation/01__visualisation_representations/03__scenario-delta-value.robot diff --git a/atest/robotMBT tests/10__visualisation_representations/__init__.robot b/atest/robotMBT tests/10__visualisation/01__visualisation_representations/__init__.robot similarity index 81% rename from atest/robotMBT tests/10__visualisation_representations/__init__.robot rename to atest/robotMBT tests/10__visualisation/01__visualisation_representations/__init__.robot index 79850615..6d745c41 100644 --- a/atest/robotMBT tests/10__visualisation_representations/__init__.robot +++ b/atest/robotMBT tests/10__visualisation/01__visualisation_representations/__init__.robot @@ -1,7 +1,7 @@ *** Settings *** Documentation Test correctness all graph representations Suite Setup Enter test suite -Resource ../../resources/visualisation.resource +Resource ../../../resources/visualisation.resource Library robotmbt processor=flatten diff --git a/atest/robotMBT tests/11__visualisation_execution_path/01_repetition.robot b/atest/robotMBT tests/10__visualisation/02__visualisation_execution_path/01_repetition.robot similarity index 94% rename from atest/robotMBT tests/11__visualisation_execution_path/01_repetition.robot rename to atest/robotMBT tests/10__visualisation/02__visualisation_execution_path/01_repetition.robot index fd1e2959..2105a4d2 100644 --- a/atest/robotMBT tests/11__visualisation_execution_path/01_repetition.robot +++ b/atest/robotMBT tests/10__visualisation/02__visualisation_execution_path/01_repetition.robot @@ -1,7 +1,7 @@ *** Settings *** Documentation Suite Setup Enter test suite -Resource ../../resources/visualisation.resource +Resource ../../../resources/visualisation.resource Library robotmbt *** Keywords *** diff --git a/atest/robotMBT tests/12__visualisation_export/01__export to JSON.robot b/atest/robotMBT tests/10__visualisation/03__visualisation_export/01__export to JSON.robot similarity index 95% rename from atest/robotMBT tests/12__visualisation_export/01__export to JSON.robot rename to atest/robotMBT tests/10__visualisation/03__visualisation_export/01__export to JSON.robot index e845a759..3edd5dbf 100644 --- a/atest/robotMBT tests/12__visualisation_export/01__export to JSON.robot +++ b/atest/robotMBT tests/10__visualisation/03__visualisation_export/01__export to JSON.robot @@ -3,7 +3,7 @@ Documentation Export and import a test suite from and to JSON ... and check that the imported suite equals the ... exported suite. Suite Setup Enter test suite -Resource ../../resources/visualisation.resource +Resource ../../../resources/visualisation.resource Library robotmbt *** Keywords *** From 285462f479c84ae781c0aacbeb3ee518cf824a92 Mon Sep 17 00:00:00 2001 From: Thomas Date: Sun, 25 Jan 2026 15:33:47 +0100 Subject: [PATCH 115/131] Make sure we capture all tracestate updates --- robotmbt/suiteprocessors.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index 25ebdcb8..e33f542a 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -161,9 +161,11 @@ def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool) -> TraceS candidate = self._select_scenario_variant(candidate_id, tracestate) if not candidate: # No valid variant available in the current state tracestate.reject_scenario(candidate_id) + self.__update_visualisation(tracestate) continue previous_len = len(tracestate) modeller.try_to_fit_in_scenario(candidate, tracestate) + self.__update_visualisation(tracestate) self._report_tracestate_to_user(tracestate) if len(tracestate) > previous_len: logger.debug(f"last state:\n{tracestate.model.get_status_text()}") @@ -178,6 +180,7 @@ def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool) -> TraceS self._report_tracestate_to_user(tracestate) logger.debug(f"last state:\n{tracestate.model.get_status_text()}") self.__update_visualisation(tracestate) + self.__update_visualisation(tracestate) return tracestate def __update_visualisation(self, tracestate: TraceState): From 7980ae23702b170b4a03e706cb68de213d7cc168 Mon Sep 17 00:00:00 2001 From: tychodub Date: Mon, 26 Jan 2026 11:14:15 +0100 Subject: [PATCH 116/131] added documentation to public methods of models.py --- robotmbt/visualise/models.py | 58 +++++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index 92ee8d2d..a221247d 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -179,6 +179,15 @@ def __init__(self): self.pushed: bool = False def update_trace(self, scenario: ScenarioInfo | None, state: StateInfo, length: int): + """ + Updates TraceInfo instance with the information that a scenario has run resulting in the given state as the nth + scenario of the trace, where n is the value of the length parameter. If length is greater than the previous + length of the trace to be updated, adds the given scenario/state to the trace. If length is smaller than the + previous length of the trace, roll back the trace until the step indicated by length. + scenario: the scenario that has run. + state: the state after scenario has run. + length: the step in the trace the scenario occurred in. + """ if length > self.previous_length: # New state - push self._push(scenario, state, length - self.previous_length) @@ -211,6 +220,43 @@ def _pop(self, n: int): self.current_trace.pop() self.pushed = False + def encountered_scenarios(self) -> set[ScenarioInfo]: + """ + returns: a set of all scenarios encountered in the current trace. + """ + res = set() + + for trace in self.all_traces: + for (scenario, state) in trace: + res.add(scenario) + + return res + + def encountered_states(self) -> set[StateInfo]: + """ + returns: a set of all state encountered in the current trace. + """ + res = set() + + for trace in self.all_traces: + for (scenario, state) in trace: + res.add(state) + + return res + + def encountered_scenario_state_pairs(self) -> set[tuple[ScenarioInfo, StateInfo]]: + """ + returns: a set of all pairs of scenario and state in the current trace. A pair is formed by a scenario and the + state after the scenario was run. + """ + res = set() + + for trace in self.all_traces: + for (scenario, state) in trace: + res.add((scenario, state)) + + return res + def __repr__(self) -> str: return f"TraceInfo(traces=[{[f'[{[self.stringify_pair(pair) for pair in trace]}]' for trace in self.all_traces]}], current=[{[self.stringify_pair(pair) for pair in self.current_trace]}])" @@ -250,10 +296,20 @@ def export_graph(self, suite_name: str, dir: str = '', atest: bool = False) -> s return None def import_graph(self, file_path: str): - with open(f"{file_path}", "r") as f: + """ + Imports a JSON encoding of a graph and reconstructs the graph from it. The reconstructed graph overrides the + current graph instance this method is called on. + file_path: the relative path to the graph JSON. + """ + with open(file_path, "r") as f: string = f.read() self = jsonpickle.decode(string) @staticmethod def stringify_pair(pair: tuple[ScenarioInfo, StateInfo]) -> str: + """ + Takes a pair of a scenario and a state and returns a string describing both. + pair: a tuple consisting of a scenario and a state. + returns: formatted string based on the given scenario and state. + """ return f"Scenario={pair[0].name}, State={pair[1]}" From 65f62b8905899e850f4acae8598dfe853328dbe8 Mon Sep 17 00:00:00 2001 From: tychodub Date: Mon, 26 Jan 2026 11:26:07 +0100 Subject: [PATCH 117/131] removed methods that were supposed to be removed --- robotmbt/visualise/models.py | 37 ------------------------------------ 1 file changed, 37 deletions(-) diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index a221247d..636332f7 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -220,43 +220,6 @@ def _pop(self, n: int): self.current_trace.pop() self.pushed = False - def encountered_scenarios(self) -> set[ScenarioInfo]: - """ - returns: a set of all scenarios encountered in the current trace. - """ - res = set() - - for trace in self.all_traces: - for (scenario, state) in trace: - res.add(scenario) - - return res - - def encountered_states(self) -> set[StateInfo]: - """ - returns: a set of all state encountered in the current trace. - """ - res = set() - - for trace in self.all_traces: - for (scenario, state) in trace: - res.add(state) - - return res - - def encountered_scenario_state_pairs(self) -> set[tuple[ScenarioInfo, StateInfo]]: - """ - returns: a set of all pairs of scenario and state in the current trace. A pair is formed by a scenario and the - state after the scenario was run. - """ - res = set() - - for trace in self.all_traces: - for (scenario, state) in trace: - res.add((scenario, state)) - - return res - def __repr__(self) -> str: return f"TraceInfo(traces=[{[f'[{[self.stringify_pair(pair) for pair in trace]}]' for trace in self.all_traces]}], current=[{[self.stringify_pair(pair) for pair in self.current_trace]}])" From d42c6959d9d326411730272fca612fa6c6d9be1b Mon Sep 17 00:00:00 2001 From: jonathan <148167.jw@gmail.com> Date: Tue, 27 Jan 2026 11:53:07 +0100 Subject: [PATCH 118/131] fixed importing graphs --- robotmbt/suiteprocessors.py | 9 +++++---- robotmbt/visualise/models.py | 7 +------ 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index d702eabe..adaec832 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -45,7 +45,7 @@ try: from .visualise.visualiser import Visualiser from .visualise.models import TraceInfo - + import jsonpickle visualisation_deps_present = True except ImportError: Visualiser = None @@ -104,9 +104,10 @@ def process_test_suite(self, in_suite: Suite, *, seed: str | int | bytes | bytea return self.out_suite - def _load_graph(self, graph: str, suite_name: str, from_json: str): - traceinfo = TraceInfo() - traceinfo = traceinfo.import_graph(from_json) + def _load_graph(self, graph: str, suite_name: str, file_path: str): + with open(f"{file_path}", "r") as f: + string = f.read() + traceinfo = jsonpickle.decode(string) self.visualiser = Visualiser(graph, suite_name, trace_info=traceinfo) def _run_test_suite(self, seed: str | int | bytes | bytearray, graph: str, suite_name: str, export_dir: str): diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index 92ee8d2d..606a9821 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -249,11 +249,6 @@ def export_graph(self, suite_name: str, dir: str = '', atest: bool = False) -> s f.write(encoded_instance) return None - def import_graph(self, file_path: str): - with open(f"{file_path}", "r") as f: - string = f.read() - self = jsonpickle.decode(string) - @staticmethod def stringify_pair(pair: tuple[ScenarioInfo, StateInfo]) -> str: - return f"Scenario={pair[0].name}, State={pair[1]}" + return f"Scenario={pair[0].name}, State={pair[1]}" \ No newline at end of file From 93139838c35ba85dda0436352da1379c095c3755 Mon Sep 17 00:00:00 2001 From: jonathan <148167.jw@gmail.com> Date: Tue, 27 Jan 2026 12:01:39 +0100 Subject: [PATCH 119/131] update export atest to use the _load_graph function --- atest/resources/helpers/modelgenerator.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/atest/resources/helpers/modelgenerator.py b/atest/resources/helpers/modelgenerator.py index 0793067e..cbf6bbb4 100644 --- a/atest/resources/helpers/modelgenerator.py +++ b/atest/resources/helpers/modelgenerator.py @@ -1,5 +1,6 @@ from robot.api.deco import keyword # type:ignore import os +from robotmbt.suiteprocessors import SuiteProcessors visualisation_deps_present = True try: @@ -77,11 +78,9 @@ def export_graph(self, suite: str, trace_info: TraceInfo) -> str: @keyword(name='Import Graph') # type:ignore def import_graph(self, filepath: str) -> TraceInfo: - with open(filepath, 'r') as f: - string = f.read() - decoded_instance: TraceInfo = jsonpickle.decode(string) # type: ignore - visualiser = Visualiser('scenario', trace_info=decoded_instance) - return visualiser.trace_info + suiteprocessor = SuiteProcessors() + suiteprocessor._load_graph('scenario', 'atest', filepath) + return suiteprocessor.visualiser.trace_info @keyword(name='Check File Exists') # type:ignore def check_file_exists(self, filepath: str) -> bool: From 714dd6d8d81ed8d62d0a50b29096344654c1a9da Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Wed, 28 Jan 2026 10:28:34 +0100 Subject: [PATCH 120/131] fix model = ModelSpace() bug --- robotmbt/visualise/visualiser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py index bff8358a..c7222e2c 100644 --- a/robotmbt/visualise/visualiser.py +++ b/robotmbt/visualise/visualiser.py @@ -60,7 +60,7 @@ def update_trace(self, trace: TraceState): scenario = snap.scenario model = snap.model if model is None: - model = ModelSpace + model = ModelSpace() self.trace_info.update_trace(ScenarioInfo(scenario), StateInfo(model), prev + i + 1) # Snapshots have been removed From fbc869e29b0f4867aad30cb4d210cb328e2646bd Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Wed, 28 Jan 2026 10:42:19 +0100 Subject: [PATCH 121/131] removed code snippet entirely because the ModelSpace cannot be None --- robotmbt/visualise/visualiser.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py index c7222e2c..609cc716 100644 --- a/robotmbt/visualise/visualiser.py +++ b/robotmbt/visualise/visualiser.py @@ -59,8 +59,6 @@ def update_trace(self, trace: TraceState): snap = trace._snapshots[prev + i] scenario = snap.scenario model = snap.model - if model is None: - model = ModelSpace() self.trace_info.update_trace(ScenarioInfo(scenario), StateInfo(model), prev + i + 1) # Snapshots have been removed From 88fd99a40224682a93810b81beb81c4e23ccf12d Mon Sep 17 00:00:00 2001 From: jonathan <148167.jw@gmail.com> Date: Wed, 28 Jan 2026 11:01:16 +0100 Subject: [PATCH 122/131] add licenses to files --- atest/resources/helpers/__init__.py | 29 ++++++++++++++ atest/resources/helpers/modelgenerator.py | 31 ++++++++++++++- robotmbt/visualise/__init__.py | 29 ++++++++++++++ robotmbt/visualise/graphs/__init__.py | 29 ++++++++++++++ robotmbt/visualise/graphs/abstractgraph.py | 30 ++++++++++++++ .../graphs/scenariodeltavaluegraph.py | 30 ++++++++++++++ robotmbt/visualise/graphs/scenariograph.py | 30 ++++++++++++++ robotmbt/visualise/models.py | 30 ++++++++++++++ robotmbt/visualise/networkvisualiser.py | 39 +++++++++++++++++-- robotmbt/visualise/visualiser.py | 30 ++++++++++++++ utest/test_visualise_models.py | 30 ++++++++++++++ 11 files changed, 333 insertions(+), 4 deletions(-) diff --git a/atest/resources/helpers/__init__.py b/atest/resources/helpers/__init__.py index e69de29b..2b180b05 100644 --- a/atest/resources/helpers/__init__.py +++ b/atest/resources/helpers/__init__.py @@ -0,0 +1,29 @@ +# BSD 3-Clause License +# +# Copyright (c) 2026, T.B. Dubbeling, J. Foederer, T.S. Kas, D.R. Osinga, D.F. Serra e Silva, J.C. Willegers +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/atest/resources/helpers/modelgenerator.py b/atest/resources/helpers/modelgenerator.py index cbf6bbb4..18b938fa 100644 --- a/atest/resources/helpers/modelgenerator.py +++ b/atest/resources/helpers/modelgenerator.py @@ -1,10 +1,39 @@ +# BSD 3-Clause License +# +# Copyright (c) 2026, T.B. Dubbeling, J. Foederer, T.S. Kas, D.R. Osinga, D.F. Serra e Silva, J.C. Willegers +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + from robot.api.deco import keyword # type:ignore import os from robotmbt.suiteprocessors import SuiteProcessors visualisation_deps_present = True try: - import jsonpickle # type: ignore import networkx as nx from robotmbt.visualise.models import TraceInfo, ScenarioInfo, StateInfo from robotmbt.visualise.visualiser import Visualiser diff --git a/robotmbt/visualise/__init__.py b/robotmbt/visualise/__init__.py index e69de29b..2b180b05 100644 --- a/robotmbt/visualise/__init__.py +++ b/robotmbt/visualise/__init__.py @@ -0,0 +1,29 @@ +# BSD 3-Clause License +# +# Copyright (c) 2026, T.B. Dubbeling, J. Foederer, T.S. Kas, D.R. Osinga, D.F. Serra e Silva, J.C. Willegers +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/robotmbt/visualise/graphs/__init__.py b/robotmbt/visualise/graphs/__init__.py index e69de29b..2b180b05 100644 --- a/robotmbt/visualise/graphs/__init__.py +++ b/robotmbt/visualise/graphs/__init__.py @@ -0,0 +1,29 @@ +# BSD 3-Clause License +# +# Copyright (c) 2026, T.B. Dubbeling, J. Foederer, T.S. Kas, D.R. Osinga, D.F. Serra e Silva, J.C. Willegers +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/robotmbt/visualise/graphs/abstractgraph.py b/robotmbt/visualise/graphs/abstractgraph.py index ebebe37d..49aac2dc 100644 --- a/robotmbt/visualise/graphs/abstractgraph.py +++ b/robotmbt/visualise/graphs/abstractgraph.py @@ -1,3 +1,33 @@ +# BSD 3-Clause License +# +# Copyright (c) 2026, T.B. Dubbeling, J. Foederer, T.S. Kas, D.R. Osinga, D.F. Serra e Silva, J.C. Willegers +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + from abc import ABC, abstractmethod from typing import Generic, TypeVar diff --git a/robotmbt/visualise/graphs/scenariodeltavaluegraph.py b/robotmbt/visualise/graphs/scenariodeltavaluegraph.py index a9d822ae..f78baebf 100644 --- a/robotmbt/visualise/graphs/scenariodeltavaluegraph.py +++ b/robotmbt/visualise/graphs/scenariodeltavaluegraph.py @@ -1,3 +1,33 @@ +# BSD 3-Clause License +# +# Copyright (c) 2026, T.B. Dubbeling, J. Foederer, T.S. Kas, D.R. Osinga, D.F. Serra e Silva, J.C. Willegers +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + from robotmbt.modelspace import ModelSpace from robotmbt.visualise.graphs.abstractgraph import AbstractGraph diff --git a/robotmbt/visualise/graphs/scenariograph.py b/robotmbt/visualise/graphs/scenariograph.py index 4d1442c9..dee8c726 100644 --- a/robotmbt/visualise/graphs/scenariograph.py +++ b/robotmbt/visualise/graphs/scenariograph.py @@ -1,3 +1,33 @@ +# BSD 3-Clause License +# +# Copyright (c) 2026, T.B. Dubbeling, J. Foederer, T.S. Kas, D.R. Osinga, D.F. Serra e Silva, J.C. Willegers +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + from robotmbt.visualise.graphs.abstractgraph import AbstractGraph from robotmbt.visualise.models import ScenarioInfo, StateInfo diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index a54f3925..d8a5adae 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -1,3 +1,33 @@ +# BSD 3-Clause License +# +# Copyright (c) 2026, T.B. Dubbeling, J. Foederer, T.S. Kas, D.R. Osinga, D.F. Serra e Silva, J.C. Willegers +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + from typing import Any from robot.api import logger diff --git a/robotmbt/visualise/networkvisualiser.py b/robotmbt/visualise/networkvisualiser.py index 6c33f31f..af85e117 100644 --- a/robotmbt/visualise/networkvisualiser.py +++ b/robotmbt/visualise/networkvisualiser.py @@ -1,3 +1,33 @@ +# BSD 3-Clause License +# +# Copyright (c) 2026, T.B. Dubbeling, J. Foederer, T.S. Kas, D.R. Osinga, D.F. Serra e Silva, J.C. Willegers +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + from bokeh.core.enums import PlaceType, LegendLocationType from bokeh.core.property.vectorization import value from bokeh.embed import file_html @@ -226,10 +256,13 @@ def _create_layout(self) -> tuple[list[Node], list[Edge]]: sugiyama = SugiyamaLayout(g.C[0]) # Set specific margins as these values worked best in user-testing - sugiyama.xspace = 10 + # Space between nodes + sugiyama.xspace = 10 sugiyama.yspace = 15 - sugiyama.dw = 2 - sugiyama.dh = 2 + # Default width for nodes with no set width (in this case only for edge routing) + sugiyama.dw = 2 + # Default height for nodes with no set height (in this case only for edge routing) + sugiyama.dh = 2 sugiyama.init_all(roots=[start], inverted_edges=flips) sugiyama.draw() diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py index 609cc716..e5a7fdc3 100644 --- a/robotmbt/visualise/visualiser.py +++ b/robotmbt/visualise/visualiser.py @@ -1,3 +1,33 @@ +# BSD 3-Clause License +# +# Copyright (c) 2026, T.B. Dubbeling, J. Foederer, T.S. Kas, D.R. Osinga, D.F. Serra e Silva, J.C. Willegers +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + from robotmbt.modelspace import ModelSpace from robotmbt.tracestate import TraceState from robotmbt.visualise import networkvisualiser diff --git a/utest/test_visualise_models.py b/utest/test_visualise_models.py index 4ec9941f..e15b95a3 100644 --- a/utest/test_visualise_models.py +++ b/utest/test_visualise_models.py @@ -1,3 +1,33 @@ +# BSD 3-Clause License +# +# Copyright (c) 2026, T.B. Dubbeling, J. Foederer, T.S. Kas, D.R. Osinga, D.F. Serra e Silva, J.C. Willegers +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + import unittest try: From 0e9347c623d6f72a6c1a3e91b6c18c460f18f13a Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Wed, 28 Jan 2026 11:25:39 +0100 Subject: [PATCH 123/131] condensed docstrings in abstractgraph.py --- robotmbt/visualise/graphs/abstractgraph.py | 47 +++++----------------- 1 file changed, 10 insertions(+), 37 deletions(-) diff --git a/robotmbt/visualise/graphs/abstractgraph.py b/robotmbt/visualise/graphs/abstractgraph.py index 49aac2dc..8081a6c1 100644 --- a/robotmbt/visualise/graphs/abstractgraph.py +++ b/robotmbt/visualise/graphs/abstractgraph.py @@ -138,76 +138,49 @@ def _add_edge(self, from_node: str, to_node: str, label: str): @staticmethod @abstractmethod def select_node_info(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) -> NodeInfo: - """ - Select the info to use to compare nodes and generate their labels for a specific graph type. - """ - pass + """Select the info to use to compare nodes and generate their labels for a specific graph type.""" @staticmethod @abstractmethod def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> EdgeInfo: - """ - Select the info to use to generate the label for each edge for a specific graph type. - """ - pass + """Select the info to use to generate the label for each edge for a specific graph type.""" @staticmethod @abstractmethod def create_node_description(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) -> str: - """ - Create the description to be shown in a tooltip for a node given the full trace and its index. - """ - pass + """Create the description to be shown in a tooltip for a node given the full trace and its index.""" @staticmethod @abstractmethod def create_node_label(info: NodeInfo) -> str: - """ - Create the label for a node given its chosen information. - """ - pass + """Create the label for a node given its chosen information.""" @staticmethod @abstractmethod def create_edge_label(info: EdgeInfo) -> str: - """ - Create the label for an edge given its chosen information. - """ - pass + """Create the label for an edge given its chosen information.""" @staticmethod @abstractmethod def get_legend_info_final_trace_node() -> str: - """ - Get the information to include in the legend for nodes that appear in the final trace. - """ - pass + """Get the information to include in the legend for nodes that appear in the final trace.""" @staticmethod @abstractmethod def get_legend_info_other_node() -> str: - """ - Get the information to include in the legend for nodes that do not appear in the final trace. - """ - pass + """Get the information to include in the legend for nodes that do not appear in the final trace.""" @staticmethod @abstractmethod def get_legend_info_final_trace_edge() -> str: - """ - Get the information to include in the legend for edges that appear in the final trace. - """ - pass + """Get the information to include in the legend for edges that appear in the final trace.""" @staticmethod @abstractmethod def get_legend_info_other_edge() -> str: - """ - Get the information to include in the legend for edges that do not appear in the final trace. - """ - pass + """Get the information to include in the legend for edges that do not appear in the final trace.""" @staticmethod @abstractmethod def get_tooltip_name() -> str: - pass + """Get the text that will be displayed in the tooltip.""" From 2a2f5417393b9a86bb7bbb485981cfba2ef367f8 Mon Sep 17 00:00:00 2001 From: jonathan <148167.jw@gmail.com> Date: Wed, 28 Jan 2026 11:30:38 +0100 Subject: [PATCH 124/131] remove unnecessary string format --- robotmbt/suiteprocessors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index ca063c52..94164f0d 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -110,7 +110,7 @@ def _load_graph(self, graph: str, suite_name: str, file_path: str): current graph instance this method is called on. file_path: the relative path to the graph JSON. """ - with open(f"{file_path}", "r") as f: + with open(file_path, "r") as f: string = f.read() traceinfo = jsonpickle.decode(string) self.visualiser = Visualiser(graph, suite_name, trace_info=traceinfo) From da0669bea86f3935da5f17a59f435d1f378c7c1a Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Wed, 28 Jan 2026 11:43:50 +0100 Subject: [PATCH 125/131] remove unused Any type import --- robotmbt/suiteprocessors.py | 1 - 1 file changed, 1 deletion(-) diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index 94164f0d..88dd7c0f 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -32,7 +32,6 @@ import copy import random -from typing import Any from robot.api import logger From 4136653abee31753b853698a617f3af3ba54803e Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Wed, 28 Jan 2026 12:07:26 +0100 Subject: [PATCH 126/131] refactored Contributing and Readme --- CONTRIBUTING.md | 21 ++++----------------- README.md | 5 +---- 2 files changed, 5 insertions(+), 21 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 80bd72d8..1da21949 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,7 +33,6 @@ When reporting a defect, be precise and concise in your description. Write in wa Note that all information in the issue tracker is public. *Do not include any confidential information there*. Be sure to add information about: - - The applicable version(s) of RobotMBT (use `pip list` and check for `robotframework-mbt`) - Your Robot Framework version (use `pip list` and check for `robotframework`) - Your Python version (check using `python --version`) @@ -119,21 +118,9 @@ Researchers have suggested that longer lines are better suited for cases when th Extending the functionality of the visualizer with new graph types can result in better insights into created tests. The visualizer makes use of an abstract graph class that makes it easy to create new graph types. -To create a new graph type, create an instance of AbstractGraph, instantiating the following methods: -- select_node_info: select the information you want to use to identify different nodes from all ScenarioInfo/StateInfo pairs that make up the different exploration steps. This info is also used to label nodes. Its return type has to match the first type argument passed to AbstractGraph. -- select_edge_info: ditto but for edges, which is also used for labeling. Its return type has to match the second type argument passed to AbstractGraph. -- create_node_description: create a description for a node to be shown in a tooltip (if enabled). -- create_node_label: turn the selected information into a label for a node. -- create_edge_label: ditto but for edges. -- get_legend_info_final_trace_node: return the text you want to appear in the legend for nodes that appear in the final trace. -- get_legend_info_other_node: ditto but for nodes that have been backtracked. -- get_legend_info_final_trace_edge: ditto but for edges that appear in the final trace. -- get_legend_info_other_edge: ditto but for edges that have backtracked. -- get_tooltip_name: the title of a tooltip that appears when hovering over nodes. Setting to an empty string disables the tooltip. - -Please create a new file for each graph type under `/robotmbt/visualise/graphs/`. +To create a new graph type, create an instance of `robotmbt/visualise/graphs/AbstractGraph`, instantiating the abstract methods. Please place the graph under `robotmbt/visualise/graphs/`. -NOTE: when manually altering the networkx field, ensure its ids remain as a serializable and hashable type when the constructor finishes. +**NOTE**: when manually altering the `networkx` field, ensure its IDs remain as a serializable and hashable type when the constructor finishes. As an example, we show the implementation of the scenario graph below. In this graph type, nodes represent scenarios encountered in exploration, and edges show the flow between these scenarios. It does not enable tooltips. @@ -186,9 +173,9 @@ Simply add your class to the `GRAPHS` dictionary in `robotmbt/visualise/visualis ```python GRAPHS = { - [...] + ... 'scenario': ScenarioGraph, - [...] + ... } ``` diff --git a/README.md b/README.md index 2d4c132f..e508fe9f 100644 --- a/README.md +++ b/README.md @@ -205,9 +205,6 @@ If you want to set configuration options for use in multiple test suites without Tip: [Robot dictionaries](https://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#dictionary-variable) (`&{ }`) can be used to group related options and pass them as one set. -## Contributing - -If you have feedback, ideas, or want to get involved in coding, then check out the [Contribution guidelines](https://github.com/JFoederer/robotframeworkMBT/blob/main/CONTRIBUTING.md). ### Graphs @@ -227,7 +224,7 @@ Once the test suite has run, a graph will be included in the test's log, under t It is possible to extract the exploration data after the library has found a covering trace. To enable this feature, set the following argument to true: ``` -Treat this test suite Model-based graph=[type] export_graph_data=[directory] +Treat this test suite Model-based export_graph_data=[directory] ``` A JSON file named after the test suite will be created containing said information. From 7659eb81a7d1905a49c33bcf6ec5b50ba7ed363f Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Wed, 28 Jan 2026 12:15:16 +0100 Subject: [PATCH 127/131] refactored README (arguments <> instead of [] + added more nice markdown formatting) --- README.md | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index e508fe9f..48f4bbf9 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ The recommended installation method is using [pip](http://pip-installer.org) After installation include `robotmbt` as library in your robot file to get access to the new functionality. To run your test suite model-based, use the __Treat this test suite model-based__ keyword as suite setup. Check the _How to model_ section to learn how to make your scenarios suitable for running model-based. -``` +```robotframework *** Settings *** Library robotmbt Suite Setup Treat this test suite model-based @@ -50,7 +50,8 @@ Modelling can be done directly from [Robot framework](https://robotframework.org Consider these two scenarios: -``` +```robotframework +*** Test Cases *** Buying a postcard When you buy a new postcard then you have a blank postcard @@ -63,7 +64,8 @@ Preparing for a birthday party Mapping the dependencies between scenarios is done by annotating the steps with modelling info. Modelling info is added to the documentation of the step as shown below. Regular documentation can still be added, as long as `*model info*` starts on a new line and a white line is included after the last `:OUT:` expression. -``` +```robotframework +*** Keywords *** you buy a new postcard [Documentation] *model info* ... :IN: None @@ -117,7 +119,8 @@ All example scenarios naturally contain data. This information is embedded in th #### Step argument modifiers -``` +```robotframework +*** Test Cases *** Personalising a birthday card Given there is a birthday card when Johan writes their name on the birthday card @@ -126,7 +129,8 @@ Personalising a birthday card The above scenario uses the name `Johan` to create a concrete example. But now suppose that from a testing perspective `Johan` and `Frederique` are part of the same equivalence class. Then the step `Frederique writes their name on the birthday card` would yield an equally valid scenario. This can be achieved by adding a modifier (`:MOD:`) to the model info of the step. The format of a modifier is a Robot argument to which you assign a list of options. The modifier updates the argument value to a randomly chosen value from the specified options. -``` +```robotframework +*** Keywords *** ${person} writes their name on the birthday card [Documentation] *model info* ... :MOD: ${person}= [Johan, Frederique] @@ -138,7 +142,8 @@ ${person} writes their name on the birthday card When constructing examples, they often express relations between multiple actors, where each actor can appear in multiple steps. This makes it important to know how modifiers behave when there are multiple modifiers in a scenario. -``` +```robotframework +*** Test Cases *** Addressing a birthday card Given Tannaz is having their birthday and Johan has a birthday card @@ -148,7 +153,8 @@ Addressing a birthday card Have a look at the when-step above. We will assume the model already contains a domain term with two properties: `birthday.celebrant = Tannaz` and `birthday.guests = [Johan, Frederique]`. -``` +```robotframework +*** Keywords *** ${sender} writes the address of ${receiver} on the birthday card [Documentation] *model info* ... :MOD: ${sender}= birthday.guests @@ -175,7 +181,7 @@ It is not possible to add new options to an existing example value. Any constrai It is possible for a step to keep the same options. The special `.*` notation lets you keep the available options as-is. Preceding steps must then supply the possible options. Some steps can, or must, deal with multiple independent sets of options that must not be mixed, because the expected results should differ. Suppose you have a set of valid and invalid passwords. You might be reluctant to include the superset of these as options to an authentication step. Instead, you can use `:MOD: ${password}= .*` as the modifier for that step. Like in the when-step for this scenario: -``` +```robotframework Given 'secret' is too weak a password When user tries to update their password to 'secret' then the password is rejected @@ -193,7 +199,7 @@ For now, variable data considers strict equivalence classes only. This means tha By default, trace generation is random. The random seed used for the trace is logged by _Treat this test suite model-based_. This seed can be used to rerun the same trace, if no external random factors influence the test run. To activate the seed, pass it as argument: -``` +```robotframework Treat this test suite model-based seed=eag-etou-cxi-leamv-jsi ``` @@ -210,12 +216,11 @@ Tip: [Robot dictionaries](https://robotframework.org/robotframework/latest/Robot By default, no graphs are generated for test-runs. For development purposes, having a visual representation of the test-suite you are working on can be very useful. To have robotmbt generate a graph, ensure you have installed the optional dependencies (`pip install .[visualization]`) and pass the type as an argument: -``` -Treat this test suite Model-based graph=[type] +```robotframework +Treat this test suite Model-based graph= ``` -Here, `[type]` can be any of the supported graph types. Currently, the types included are: -- `scenario-delta-value` +Here, `` can be any of the supported graph types, which can be seen in `robotmbt/visualise/visualiser.py`. Once the test suite has run, a graph will be included in the test's log, under the suite's `Treat this test suite Model-based` setup header. @@ -224,7 +229,7 @@ Once the test suite has run, a graph will be included in the test's log, under t It is possible to extract the exploration data after the library has found a covering trace. To enable this feature, set the following argument to true: ``` -Treat this test suite Model-based export_graph_data=[directory] +Treat this test suite Model-based export_graph_data= ``` A JSON file named after the test suite will be created containing said information. @@ -233,8 +238,8 @@ A JSON file named after the test suite will be created containing said informati It is possible to skip running the exploration step and produce a graph (e.g. of another type) from previously exported data. -``` -Treat this test suite Model-based graph=[type] import_graph_data=[directory+file_name.json] +```robotframework +Treat this test suite Model-based graph= import_graph_data=.json ``` A graph will be created from the imported data. From e172999225f8d23c8c84ecea058420c506e50d54 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Wed, 28 Jan 2026 12:16:06 +0100 Subject: [PATCH 128/131] removed duplicate 'option management' --- README.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/README.md b/README.md index 48f4bbf9..adc65d53 100644 --- a/README.md +++ b/README.md @@ -205,12 +205,6 @@ Treat this test suite model-based seed=eag-etou-cxi-leamv-jsi Using `seed=new` will force generation of a new reusable seed and is identical to omitting the seed argument. To completely bypass seed generation and use the system's random source, use `seed=None`. This has even more variation but does not produce a reusable seed. -### Option management - -If you want to set configuration options for use in multiple test suites without having to repeat them, the keywords __Set model-based options__ and __Update model-based options__ can be used to configure RobotMBT library options. _Set_ takes the provided options and discards any previously set options. _Update_ allows you to modify existing options or add new ones. Reset all options by calling _Set_ without arguments. Direct options provided to __Treat this test suite model-based__ take precedence over library options and affect only the current test suite. - -Tip: [Robot dictionaries](https://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#dictionary-variable) (`&{ }`) can be used to group related options and pass them as one set. - ### Graphs From 89a04f930d5917aee5770e96cfbdae07bb297765 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Wed, 28 Jan 2026 12:17:41 +0100 Subject: [PATCH 129/131] highlighting in README --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index adc65d53..9fd5b542 100644 --- a/README.md +++ b/README.md @@ -182,9 +182,11 @@ It is not possible to add new options to an existing example value. Any constrai It is possible for a step to keep the same options. The special `.*` notation lets you keep the available options as-is. Preceding steps must then supply the possible options. Some steps can, or must, deal with multiple independent sets of options that must not be mixed, because the expected results should differ. Suppose you have a set of valid and invalid passwords. You might be reluctant to include the superset of these as options to an authentication step. Instead, you can use `:MOD: ${password}= .*` as the modifier for that step. Like in the when-step for this scenario: ```robotframework -Given 'secret' is too weak a password -When user tries to update their password to 'secret' -then the password is rejected +*** Test Cases *** +Reject password + Given 'secret' is too weak a password + When user tries to update their password to 'secret' + then the password is rejected ``` In a then-step, modifiers behave slightly different. In then-steps no new option constraints are accepted for an argument. Its value must already have been determined during the given- and when-steps. In other words, regardless of the actual modifier, the expression behaves as if it were `.*`. The exception to this is when a then-step signals the first use of a new example value. In that case the argument value from the original scenario text is used. @@ -222,7 +224,7 @@ Once the test suite has run, a graph will be included in the test's log, under t It is possible to extract the exploration data after the library has found a covering trace. To enable this feature, set the following argument to true: -``` +```robotframework Treat this test suite Model-based export_graph_data= ``` From 4109ea5f3d41d0bdd0e9a2392f5b170cf85594ac Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Wed, 28 Jan 2026 12:33:49 +0100 Subject: [PATCH 130/131] fix docstring mistake --- robotmbt/visualise/graphs/abstractgraph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/robotmbt/visualise/graphs/abstractgraph.py b/robotmbt/visualise/graphs/abstractgraph.py index 8081a6c1..4214d0db 100644 --- a/robotmbt/visualise/graphs/abstractgraph.py +++ b/robotmbt/visualise/graphs/abstractgraph.py @@ -183,4 +183,4 @@ def get_legend_info_other_edge() -> str: @staticmethod @abstractmethod def get_tooltip_name() -> str: - """Get the text that will be displayed in the tooltip.""" + """Get the name/title of the tooltip.""" From 2b2d7537dca6eb061cb7b33e161d1007adcec8d9 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Fri, 30 Jan 2026 10:00:42 +0100 Subject: [PATCH 131/131] autopep8 formatting --- atest/resources/helpers/__init__.py | 2 +- robotmbt/visualise/__init__.py | 2 +- robotmbt/visualise/graphs/__init__.py | 2 +- robotmbt/visualise/models.py | 2 +- robotmbt/visualise/networkvisualiser.py | 8 ++++---- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/atest/resources/helpers/__init__.py b/atest/resources/helpers/__init__.py index 2b180b05..c7ec90cd 100644 --- a/atest/resources/helpers/__init__.py +++ b/atest/resources/helpers/__init__.py @@ -26,4 +26,4 @@ # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/robotmbt/visualise/__init__.py b/robotmbt/visualise/__init__.py index 2b180b05..c7ec90cd 100644 --- a/robotmbt/visualise/__init__.py +++ b/robotmbt/visualise/__init__.py @@ -26,4 +26,4 @@ # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/robotmbt/visualise/graphs/__init__.py b/robotmbt/visualise/graphs/__init__.py index 2b180b05..c7ec90cd 100644 --- a/robotmbt/visualise/graphs/__init__.py +++ b/robotmbt/visualise/graphs/__init__.py @@ -26,4 +26,4 @@ # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index d8a5adae..fcf94276 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -295,4 +295,4 @@ def stringify_pair(pair: tuple[ScenarioInfo, StateInfo]) -> str: pair: a tuple consisting of a scenario and a state. returns: formatted string based on the given scenario and state. """ - return f"Scenario={pair[0].name}, State={pair[1]}" \ No newline at end of file + return f"Scenario={pair[0].name}, State={pair[1]}" diff --git a/robotmbt/visualise/networkvisualiser.py b/robotmbt/visualise/networkvisualiser.py index af85e117..648f3d89 100644 --- a/robotmbt/visualise/networkvisualiser.py +++ b/robotmbt/visualise/networkvisualiser.py @@ -257,12 +257,12 @@ def _create_layout(self) -> tuple[list[Node], list[Edge]]: # Set specific margins as these values worked best in user-testing # Space between nodes - sugiyama.xspace = 10 + sugiyama.xspace = 10 sugiyama.yspace = 15 # Default width for nodes with no set width (in this case only for edge routing) - sugiyama.dw = 2 - # Default height for nodes with no set height (in this case only for edge routing) - sugiyama.dh = 2 + sugiyama.dw = 2 + # Default height for nodes with no set height (in this case only for edge routing) + sugiyama.dh = 2 sugiyama.init_all(roots=[start], inverted_edges=flips) sugiyama.draw()

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%0AX!PO2Whe%2ng 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 StUsc Date: Wed, 26 Nov 2025 11:04:48 +0100 Subject: [PATCH 073/131] Arguments apply locally (#24) --- robotmbt/suitereplacer.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/robotmbt/suitereplacer.py b/robotmbt/suitereplacer.py index de4a0ee8..3eb9e08e 100644 --- a/robotmbt/suitereplacer.py +++ b/robotmbt/suitereplacer.py @@ -83,19 +83,21 @@ def treat_model_based(self, **kwargs): model info that is included in the test steps, the test cases are modifed, mixed and matched to create unique traces and achieve more test coverage quicker. - Any arguments are handled as if using keyword `Update model-based options` + Any arguments are handled only locally. To apply arguments to subsequent suites as well, + use `Set model-based options` or `Update model-based options`. """ self.robot_suite = self.current_suite logger.info( f"Analysing Robot test suite '{self.robot_suite.name}' for model-based execution.") - self.update_model_based_options(**kwargs) + temp = self.processor_options.copy() + temp.update(kwargs) master_suite = self.__process_robot_suite( self.robot_suite, parent=None) modelbased_suite = self.processor_method( - master_suite, **self.processor_options) + master_suite, **temp) self.__clearTestSuite(self.robot_suite) self.__generateRobotSuite(modelbased_suite, self.robot_suite) From b49d79d2984974df2fae85ae1e0b8880a472c435 Mon Sep 17 00:00:00 2001 From: Jonathan <61787386+JWillegers@users.noreply.github.com> Date: Wed, 26 Nov 2025 11:05:12 +0100 Subject: [PATCH 074/131] fix line indent (#25) --- utest/test_visualise_scenariograph.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/utest/test_visualise_scenariograph.py b/utest/test_visualise_scenariograph.py index bcac393c..6af361d7 100644 --- a/utest/test_visualise_scenariograph.py +++ b/utest/test_visualise_scenariograph.py @@ -144,5 +144,5 @@ def test_scenario_graph_set_final_trace(self): # test end node self.assertEqual(sg.end_node, 'node2') - if __name__ == '__main__': - unittest.main() +if __name__ == '__main__': + unittest.main() From 8f4f55724fadc683e001aad034db6075c22bf269 Mon Sep 17 00:00:00 2001 From: tychodub <93142605+tychodub@users.noreply.github.com> Date: Thu, 27 Nov 2025 15:59:55 +0100 Subject: [PATCH 075/131] Scenario state (#26) * implemented scenario-state (state after scenario has run variant) graph representation * removed hardcoded starting node id by keeping track of starting node info * added unit tests for scenariostategraph.py and some supporting code for the unit tests * renamed unit tests because I forgot initially * implemented suggestions by Jonathan (moved function into scenariostategraph as static method, de-indented 'if main' in test_visualise_scenariostategraph.py and moved is not None check outside for-loop) * removed end_node from scenariostategraph.py * moved is not None check outside of loop in scenariograph.py --- robotmbt/visualise/graphs/scenariograph.py | 9 +- .../visualise/graphs/scenariostategraph.py | 104 ++++++++++++ robotmbt/visualise/visualiser.py | 3 + utest/test_visualise_scenariostategraph.py | 157 ++++++++++++++++++ 4 files changed, 269 insertions(+), 4 deletions(-) create mode 100644 robotmbt/visualise/graphs/scenariostategraph.py create mode 100644 utest/test_visualise_scenariostategraph.py diff --git a/robotmbt/visualise/graphs/scenariograph.py b/robotmbt/visualise/graphs/scenariograph.py index fee43987..c2e59315 100644 --- a/robotmbt/visualise/graphs/scenariograph.py +++ b/robotmbt/visualise/graphs/scenariograph.py @@ -41,10 +41,11 @@ def _get_or_create_id(self, scenario: ScenarioInfo) -> str: """ Get the ID for a scenario that has been added before, or create and store a new one. """ - for i in self.ids.keys(): - # TODO: decide how to deal with repeating scenarios, this merges repeated scenarios into a single scenario - if self.ids[i].src_id == scenario.src_id and scenario.src_id is not None: - return i + if scenario.src_id is not None: + for i in self.ids.keys(): + # TODO: decide how to deal with repeating scenarios, this merges repeated scenarios into a single scenario + if self.ids[i].src_id == scenario.src_id: + return i new_id = f"node{len(self.ids)}" self.ids[new_id] = scenario diff --git a/robotmbt/visualise/graphs/scenariostategraph.py b/robotmbt/visualise/graphs/scenariostategraph.py new file mode 100644 index 00000000..6a2d6020 --- /dev/null +++ b/robotmbt/visualise/graphs/scenariostategraph.py @@ -0,0 +1,104 @@ +import networkx as nx + +from robotmbt.modelspace import ModelSpace +from robotmbt.visualise.graphs.abstractgraph import AbstractGraph +from robotmbt.visualise.models import TraceInfo, ScenarioInfo, StateInfo + + +class ScenarioStateGraph(AbstractGraph): + """ + The scenario-State graph keeps track of both the scenarios and states encountered. + Its nodes are scenarios together with the state after the scenario has run. + Its edges represent steps in the trace. + """ + + def __init__(self): + # We use simplified IDs for nodes, and store the actual scenario info here + self.ids: dict[str, tuple[ScenarioInfo, StateInfo]] = {} + + # The networkx graph is a directional graph + self.networkx = nx.DiGraph() + + # add the start node + self.networkx.add_node('start', label='start') + + self.start_scenario: ScenarioInfo | None = None + self.start_state: StateInfo | None = None + + self.prev_state = StateInfo(ModelSpace()) + self.prev_state_len = 0 + + def update_visualisation(self, info: TraceInfo): + """ + This will add nodes for all new scenarios in the provided trace, as well as edges for all pairs in the provided trace. + """ + if len(info.trace) == 1: + self.start_scenario = info.trace[0] + self.start_state = info.state + + for i in range(0, len(info.trace) - 1): + from_node = self._get_or_create_id(info.trace[i], self.prev_state) + to_node = self._get_or_create_id(info.trace[i + 1], info.state) + + self._add_node(from_node) + self._add_node(to_node) + + if (from_node, to_node) not in self.networkx.edges: + self.networkx.add_edge( + from_node, to_node, label='') + + self.prev_state = info.state + self.prev_state_len = len(info.trace) + + def _get_or_create_id(self, scenario: ScenarioInfo, state: StateInfo) -> str: + """ + Get the ID for a scenario that has been added before, or create and store a new one. + """ + if scenario.src_id is not None: + for i in self.ids.keys(): + if self.ids[i][0].src_id == scenario.src_id and self.ids[i][1] == state: + return i + + new_id = f"node{len(self.ids)}" + self.ids[new_id] = (scenario, state) + return new_id + + @staticmethod + def _gen_label(scenario: ScenarioInfo, state: StateInfo) -> str: + """ + Creates the label for a node in a Scenario-State Graph from the scenario and state associated to it. + """ + return scenario.name + "\n\r" + str(state) + + def _add_node(self, node: str): + """ + Add node if it doesn't already exist. + """ + if node not in self.networkx.nodes: + self.networkx.add_node(node, label=self._gen_label(self.ids[node][0], self.ids[node][1])) + + def _set_starting_node(self, scenario: ScenarioInfo, state: StateInfo): + """ + Update the starting node. + """ + node = self._get_or_create_id(scenario, state) + self._add_node(node) + self.networkx.add_edge('start', node, label='') + + def set_final_trace(self, info: TraceInfo): + """ + Update the graph with information on the final trace. + """ + if self.start_scenario is None: + self.start_scenario = info.trace[0] + self.start_state = info.state # fallback if a trace with multiple nodes instantly materializes + first_node = self.ids[self._get_or_create_id(self.start_scenario, self.start_state)] + self._set_starting_node(first_node[0], first_node[1]) + + @property + def networkx(self) -> nx.DiGraph: + return self._networkx + + @networkx.setter + def networkx(self, value: nx.DiGraph): + self._networkx = value diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py index 2bd69cea..fd8eea53 100644 --- a/robotmbt/visualise/visualiser.py +++ b/robotmbt/visualise/visualiser.py @@ -2,6 +2,7 @@ from robotmbt.visualise.graphs.abstractgraph import AbstractGraph from robotmbt.visualise.graphs.scenariograph import ScenarioGraph from robotmbt.visualise.graphs.stategraph import StateGraph +from robotmbt.visualise.graphs.scenariostategraph import ScenarioStateGraph from robotmbt.visualise.models import TraceInfo import html @@ -24,6 +25,8 @@ def __init__(self, graph_type: str): self.graph: AbstractGraph = ScenarioGraph() elif graph_type == 'state': self.graph: AbstractGraph = StateGraph() + elif graph_type == 'scenario-state': + self.graph: AbstractGraph = ScenarioStateGraph() else: raise ValueError(f"Unknown graph type: {graph_type}!") diff --git a/utest/test_visualise_scenariostategraph.py b/utest/test_visualise_scenariostategraph.py new file mode 100644 index 00000000..5c8d5fe3 --- /dev/null +++ b/utest/test_visualise_scenariostategraph.py @@ -0,0 +1,157 @@ +import unittest +import networkx as nx +from robotmbt.tracestate import TraceState +try: + from robotmbt.visualise.graphs.scenariostategraph import ScenarioStateGraph + from robotmbt.visualise.models import TraceInfo, ScenarioInfo, ModelSpace, StateInfo + + VISUALISE = True +except ImportError: + VISUALISE = False + +if VISUALISE: + class TestVisualiseScenarioGraph(unittest.TestCase): + def test_scenario_state_graph_init(self): + stg = ScenarioStateGraph() + self.assertIn('start', stg.networkx.nodes) + self.assertEqual(stg.networkx.nodes['start']['label'], 'start') + + def test_scenario_state_graph_ids_empty(self): + stg = ScenarioStateGraph() + si = ScenarioInfo('test') + node_id = stg._get_or_create_id(si, StateInfo(ModelSpace())) + self.assertEqual(node_id, 'node0') + + def test_scenario_state_graph_ids_duplicate_scenario(self): + stg = ScenarioStateGraph() + si = ScenarioInfo('test') + sti = StateInfo(ModelSpace()) + id0 = stg._get_or_create_id(si, sti) + id1 = stg._get_or_create_id(si, sti) + self.assertEqual(id0, id1) + + def test_scenario_state_graph_ids_different_scenarios(self): + stg = ScenarioStateGraph() + si0 = ScenarioInfo('test0') + si1 = ScenarioInfo('test1') + sti = StateInfo(ModelSpace()) + id0 = stg._get_or_create_id(si0, sti) + id1 = stg._get_or_create_id(si1, sti) + self.assertEqual(id0, 'node0') + self.assertEqual(id1, 'node1') + + def test_scenario_state_graph_ids_different_states(self): + stg = ScenarioStateGraph() + si = ScenarioInfo('test0') + sti0 = StateInfo(ModelSpace("state0")) + sti1 = StateInfo(ModelSpace("state1")) + id0 = stg._get_or_create_id(si, sti0) + id1 = stg._get_or_create_id(si, sti1) + self.assertEqual(id0, 'node0') + self.assertEqual(id1, 'node1') + + def test_scenario_state_graph_add_new_node(self): + stg = ScenarioStateGraph() + stg.ids['test'] = (ScenarioInfo('test'), StateInfo(ModelSpace())) + stg._add_node('test') + self.assertIn('test', stg.networkx.nodes) + self.assertEqual(stg.networkx.nodes['test']['label'], + ScenarioStateGraph._gen_label(ScenarioInfo('test'), StateInfo(ModelSpace()))) + + def test_scenario_state_graph_add_existing_node(self): + stg = ScenarioStateGraph() + stg._add_node('start') + self.assertIn('start', stg.networkx.nodes) + self.assertEqual(len(stg.networkx.nodes), 1) + + def test_scenario_state_graph_update_visualisation_nodes(self): + ts = TraceState(3) + candidates = [] + for scenario in range(3): + candidates.append(ts.next_candidate()) + ts.confirm_full_scenario(candidates[-1], str(scenario), {}) + ti = TraceInfo.from_trace_state(ts, ModelSpace()) + stg = ScenarioStateGraph() + stg.update_visualisation(ti) + + self.assertIn('node0', stg.networkx.nodes) + self.assertEqual(stg.networkx.nodes['node0']['label'], + ScenarioStateGraph._gen_label(ScenarioInfo(str(0)), StateInfo(ModelSpace()))) + self.assertIn('node1', stg.networkx.nodes) + self.assertEqual(stg.networkx.nodes['node1']['label'], + ScenarioStateGraph._gen_label(ScenarioInfo(str(1)), StateInfo(ModelSpace()))) + self.assertIn('node2', stg.networkx.nodes) + self.assertEqual(stg.networkx.nodes['node2']['label'], + ScenarioStateGraph._gen_label(ScenarioInfo(str(2)), StateInfo(ModelSpace()))) + + def test_scenario_state_graph_update_visualisation_edges(self): + ts = TraceState(3) + candidates = [] + for scenario in range(3): + candidates.append(ts.next_candidate()) + ts.confirm_full_scenario(candidates[-1], str(scenario), {}) + ti = TraceInfo.from_trace_state(trace=ts, state=ModelSpace()) + stg = ScenarioStateGraph() + stg.update_visualisation(ti) + + self.assertIn(('node0', 'node1'), stg.networkx.edges) + self.assertIn(('node1', 'node2'), stg.networkx.edges) + + edge_labels = nx.get_edge_attributes(stg.networkx, "label") + self.assertEqual(edge_labels[('node0', 'node1')], '') + self.assertEqual(edge_labels[('node1', 'node2')], '') + + def test_scenario_state_graph_update_visualisation_single_node(self): + ts = TraceState(1) + ts.confirm_full_scenario(0, 'one', {}) + self.assertEqual(ts.get_trace(), ['one']) + ti = TraceInfo.from_trace_state(ts, ModelSpace()) + stg = ScenarioStateGraph() + stg.update_visualisation(ti) + + # expected behaviour: no nodes nor edges are added + self.assertEqual(len(stg.networkx.nodes), 1) + self.assertEqual(len(stg.networkx.edges), 0) + + def test_scenario_state_graph_set_starting_node_new_node(self): + stg = ScenarioStateGraph() + si = ScenarioInfo('test') + stg._set_starting_node(si, StateInfo(ModelSpace())) + node_id = stg._get_or_create_id(si, StateInfo(ModelSpace())) + # node + self.assertIn(node_id, stg.networkx.nodes) + self.assertEqual(stg.networkx.nodes[node_id]['label'], + ScenarioStateGraph._gen_label(si, StateInfo(ModelSpace()))) + + # edge + self.assertIn(('start', node_id), stg.networkx.edges) + edge_labels = nx.get_edge_attributes(stg.networkx, "label") + self.assertEqual(edge_labels[('start', node_id)], '') + + def test_scenario_state_graph_set_starting_node_existing_node(self): + stg = ScenarioStateGraph() + si = ScenarioInfo('test') + node_id = stg._get_or_create_id(si, StateInfo(ModelSpace())) + stg._add_node(node_id) + self.assertIn(node_id, stg.networkx.nodes) + + stg._set_starting_node(si, StateInfo(ModelSpace())) + self.assertIn(('start', node_id), stg.networkx.edges) + edge_labels = nx.get_edge_attributes(stg.networkx, "label") + self.assertEqual(edge_labels[('start', node_id)], '') + + def test_scenario_state_graph_set_final_trace(self): + ts = TraceState(3) + candidates = [] + for scenario in range(3): + candidates.append(ts.next_candidate()) + ts.confirm_full_scenario(candidates[-1], str(scenario), {}) + ti = TraceInfo.from_trace_state(trace=ts, state=ModelSpace()) + stg = ScenarioStateGraph() + stg.update_visualisation(ti) + stg.set_final_trace(ti) + # test start node + self.assertIn(('start', 'node0'), stg.networkx.edges) + +if __name__ == '__main__': + unittest.main() From 48c04374fe46e5f1d8e76beba00052dcda66c1ed Mon Sep 17 00:00:00 2001 From: Diogo Silva Date: Fri, 28 Nov 2025 12:18:14 +0100 Subject: [PATCH 076/131] Path Highlighting (#27) * Implement Feature Path Highlighting in Scenario and State Graphs * Implement State Graph Path highlighting with Stack * Clean-up of unused features, move some logic, fix empty state entry bug * Formatting * Formatting * Remove unused return value * Merge with main, fix some bugs, alter ScenarioStateGraph to be in line with the rest and fix up some of its unit tests * Add short explanation --------- Co-authored-by: Thomas --- robotmbt/visualise/graphs/abstractgraph.py | 8 ++ robotmbt/visualise/graphs/scenariograph.py | 34 ++----- .../visualise/graphs/scenariostategraph.py | 66 +++++++------ robotmbt/visualise/graphs/stategraph.py | 54 +++++++---- robotmbt/visualise/models.py | 17 +++- robotmbt/visualise/networkvisualiser.py | 95 +++++++++++++++---- utest/test_visualise_scenariograph.py | 80 +++++++--------- utest/test_visualise_scenariostategraph.py | 94 ++++++++---------- 8 files changed, 249 insertions(+), 199 deletions(-) diff --git a/robotmbt/visualise/graphs/abstractgraph.py b/robotmbt/visualise/graphs/abstractgraph.py index a51a9443..e61cbdf5 100644 --- a/robotmbt/visualise/graphs/abstractgraph.py +++ b/robotmbt/visualise/graphs/abstractgraph.py @@ -18,6 +18,14 @@ def set_final_trace(self, info: TraceInfo): """ pass + @abstractmethod + def get_final_trace(self) -> list[str]: + """ + Get the final trace as ordered node ids. + Edges are subsequent entries in the list. + """ + pass + @property @abstractmethod def networkx(self) -> nx.DiGraph: diff --git a/robotmbt/visualise/graphs/scenariograph.py b/robotmbt/visualise/graphs/scenariograph.py index c2e59315..c4c5168f 100644 --- a/robotmbt/visualise/graphs/scenariograph.py +++ b/robotmbt/visualise/graphs/scenariograph.py @@ -18,9 +18,7 @@ def __init__(self): # add the start node self.networkx.add_node('start', label='start') - - # indicates last scenario of trace - self.end_node = 'start' + self.final_trace: list[str] = ['start'] def update_visualisation(self, info: TraceInfo): """ @@ -37,6 +35,15 @@ def update_visualisation(self, info: TraceInfo): self.networkx.add_edge( from_node, to_node, label='') + if i == 0 and ('start', from_node) not in self.networkx.edges: + self.networkx.add_edge('start', from_node, label='') + + def set_final_trace(self, info: TraceInfo): + self.final_trace.extend(map(lambda s: self._get_or_create_id(s), info.trace)) + + def get_final_trace(self) -> list[str]: + return self.final_trace + def _get_or_create_id(self, scenario: ScenarioInfo) -> str: """ Get the ID for a scenario that has been added before, or create and store a new one. @@ -58,27 +65,6 @@ def _add_node(self, node: str): if node not in self.networkx.nodes: self.networkx.add_node(node, label=self.ids[node].name) - def _set_starting_node(self, scenario: ScenarioInfo): - """ - Update the starting node. - """ - node = self._get_or_create_id(scenario) - self._add_node(node) - self.networkx.add_edge('start', node, label='') - - def _set_ending_node(self, scenario: ScenarioInfo): - """ - Update the end node. - """ - self.end_node = self._get_or_create_id(scenario) - - def set_final_trace(self, info: TraceInfo): - """ - Update the graph with information on the final trace. - """ - self._set_starting_node(info.trace[0]) - self._set_ending_node(info.trace[-1]) - @property def networkx(self) -> nx.DiGraph: return self._networkx diff --git a/robotmbt/visualise/graphs/scenariostategraph.py b/robotmbt/visualise/graphs/scenariostategraph.py index 6a2d6020..157ba049 100644 --- a/robotmbt/visualise/graphs/scenariostategraph.py +++ b/robotmbt/visualise/graphs/scenariostategraph.py @@ -22,33 +22,55 @@ def __init__(self): # add the start node self.networkx.add_node('start', label='start') - self.start_scenario: ScenarioInfo | None = None - self.start_state: StateInfo | None = None - self.prev_state = StateInfo(ModelSpace()) - self.prev_state_len = 0 + self.prev_trace_len = 0 + + # Stack to track the current execution path + self.node_stack: list[str] = ['start'] def update_visualisation(self, info: TraceInfo): """ - This will add nodes for all new scenarios in the provided trace, as well as edges for all pairs in the provided trace. + This will add nodes the newly reached scenario/state pair, as well as an edge from the previous to + the current scenario/state pair. """ + if len(info.trace) == 0: + self.prev_trace_len = len(info.trace) + self.prev_state = info.state + return + if len(info.trace) == 1: - self.start_scenario = info.trace[0] - self.start_state = info.state + from_node = 'start' + else: + from_node = self._get_or_create_id(info.trace[-2], self.prev_state) + to_node = self._get_or_create_id(info.trace[-1], info.state) - for i in range(0, len(info.trace) - 1): - from_node = self._get_or_create_id(info.trace[i], self.prev_state) - to_node = self._get_or_create_id(info.trace[i + 1], info.state) + if self.prev_trace_len < len(info.trace): + # New state added - add to stack + self.node_stack.append(to_node) self._add_node(from_node) self._add_node(to_node) if (from_node, to_node) not in self.networkx.edges: - self.networkx.add_edge( - from_node, to_node, label='') + self.networkx.add_edge(from_node, to_node, label='') + + elif self.prev_trace_len > len(info.trace): + # States removed - remove from stack + pop_count = self.prev_trace_len - len(info.trace) + for _ in range(pop_count): + if len(self.node_stack) > 1: # Always keep 'start' + self.node_stack.pop() self.prev_state = info.state - self.prev_state_len = len(info.trace) + self.prev_trace_len = len(info.trace) + + def set_final_trace(self, info: TraceInfo): + # We already have the final trace in state_stack, so we don't need to do anything + pass + + def get_final_trace(self) -> list[str]: + # The final trace is simply the state stack we've been keeping track of + return self.node_stack def _get_or_create_id(self, scenario: ScenarioInfo, state: StateInfo) -> str: """ @@ -77,24 +99,6 @@ def _add_node(self, node: str): if node not in self.networkx.nodes: self.networkx.add_node(node, label=self._gen_label(self.ids[node][0], self.ids[node][1])) - def _set_starting_node(self, scenario: ScenarioInfo, state: StateInfo): - """ - Update the starting node. - """ - node = self._get_or_create_id(scenario, state) - self._add_node(node) - self.networkx.add_edge('start', node, label='') - - def set_final_trace(self, info: TraceInfo): - """ - Update the graph with information on the final trace. - """ - if self.start_scenario is None: - self.start_scenario = info.trace[0] - self.start_state = info.state # fallback if a trace with multiple nodes instantly materializes - first_node = self.ids[self._get_or_create_id(self.start_scenario, self.start_state)] - self._set_starting_node(first_node[0], first_node[1]) - @property def networkx(self) -> nx.DiGraph: return self._networkx diff --git a/robotmbt/visualise/graphs/stategraph.py b/robotmbt/visualise/graphs/stategraph.py index d23ebcb1..7be5d8b9 100644 --- a/robotmbt/visualise/graphs/stategraph.py +++ b/robotmbt/visualise/graphs/stategraph.py @@ -23,30 +23,55 @@ def __init__(self): self.prev_state = StateInfo(ModelSpace()) self.prev_trace_len = 0 + # Stack to track the current execution path + self.node_stack: list[str] = ['start'] + def update_visualisation(self, info: TraceInfo): """ This will add nodes the newly reached state (if we did not roll back), as well as an edge from the previous to the current state labeled with the scenario that took it there. """ - if len(info.trace) > 0: - scenario = info.trace[-1] + if len(info.trace) == 0: + self.prev_trace_len = len(info.trace) + self.prev_state = info.state + return + + scenario = info.trace[-1] + + from_node = self._get_or_create_id(self.prev_state) + if len(info.trace) == 1: + from_node = 'start' + to_node = self._get_or_create_id(info.state) - from_node = self._get_or_create_id(self.prev_state) - if len(info.trace) == 1: - from_node = 'start' - to_node = self._get_or_create_id(info.state) + if self.prev_trace_len < len(info.trace): + # New state added - add to stack + self.node_stack.append(to_node) self._add_node(from_node) self._add_node(to_node) - if self.prev_trace_len < len(info.trace): - if (from_node, to_node) not in self.networkx.edges: - self.networkx.add_edge( - from_node, to_node, label=scenario.name) + if (from_node, to_node) not in self.networkx.edges: + self.networkx.add_edge( + from_node, to_node, label=scenario.name) + + elif self.prev_trace_len > len(info.trace): + # States removed - remove from stack + pop_count = self.prev_trace_len - len(info.trace) + for _ in range(pop_count): + if len(self.node_stack) > 1: # Always keep 'start' + self.node_stack.pop() self.prev_state = info.state self.prev_trace_len = len(info.trace) + def set_final_trace(self, info: TraceInfo): + # We already have the final trace in state_stack, so we don't need to do anything + pass + + def get_final_trace(self) -> list[str]: + # The final trace is simply the state stack we've been keeping track of + return self.node_stack + def _get_or_create_id(self, state: StateInfo) -> str: """ Get the ID for a state that has been added before, or create and store a new one. @@ -66,15 +91,6 @@ def _add_node(self, node: str): if node not in self.networkx.nodes: self.networkx.add_node(node, label=str(self.ids[node])) - def set_final_trace(self, info: TraceInfo): - self._set_ending_node(info.state) - - def _set_ending_node(self, state: StateInfo): - """ - Update the end node. - """ - self.end_node = self._get_or_create_id(state) - @property def networkx(self) -> nx.DiGraph: return self._networkx diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index 20327437..5ce13658 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -33,14 +33,23 @@ class StateInfo: def __init__(self, state: ModelSpace): self.domain = state.ref_id - self.properties = {} + + # Extract all attributes/properties stored in the model space and store them in the temp dict + # Similar in workings to ModelSpace's get_status_text + temp = {} for p in state.props: - self.properties[p] = {} + temp[p] = {} if p == 'scenario': - self.properties['scenario'] = dict(state.props['scenario']) + temp['scenario'] = dict(state.props['scenario']) else: for attr in dir(state.props[p]): - self.properties[p][attr] = getattr(state.props[p], attr) + temp[p][attr] = getattr(state.props[p], attr) + + # Filter empty entries + self.properties = {} + for p in temp.keys(): + if len(temp[p]) > 0: + self.properties[p] = temp[p].copy() def __eq__(self, other): return self.domain == other.domain and self.properties == other.properties diff --git a/robotmbt/visualise/networkvisualiser.py b/robotmbt/visualise/networkvisualiser.py index 2fc9acba..837daaa8 100644 --- a/robotmbt/visualise/networkvisualiser.py +++ b/robotmbt/visualise/networkvisualiser.py @@ -1,4 +1,5 @@ from robotmbt.visualise.graphs.abstractgraph import AbstractGraph +from robotmbt.visualise.graphs.scenariostategraph import ScenarioStateGraph from robotmbt.visualise.graphs.stategraph import StateGraph from bokeh.palettes import Spectral4 from bokeh.models import ( @@ -28,6 +29,20 @@ class NetworkVisualiser: GRAPH_SIZE_PX: int = 600 MAX_VERTEX_NAME_LEN: int = 20 # no. of characters + # Colors and styles for executed vs unexecuted elements + EXECUTED_NODE_COLOR = Spectral4[0] # Bright blue + UNEXECUTED_NODE_COLOR = '#D3D3D3' # Light gray + EXECUTED_TEXT_COLOR = 'white' + UNEXECUTED_TEXT_COLOR = '#A9A9A9' # Dark gray + EXECUTED_EDGE_COLOR = (12, 12, 12) # Black + UNEXECUTED_EDGE_COLOR = '#808080' # Gray + EXECUTED_EDGE_WIDTH = 2.5 + UNEXECUTED_EDGE_WIDTH = 1.2 + EXECUTED_EDGE_ALPHA = 0.7 + UNEXECUTED_EDGE_ALPHA = 0.3 + EXECUTED_LABEL_COLOR = 'black' + UNEXECUTED_LABEL_COLOR = '#A9A9A9' + def __init__(self, graph: AbstractGraph): self.plot = None self.graph = graph @@ -40,6 +55,15 @@ def __init__(self, graph: AbstractGraph): self.char_height = 0.1 self.padding = 0.1 + # Get executed elements for visual differentiation + final_trace = graph.get_final_trace() + self.executed_nodes = set(final_trace) + self.executed_edges = set() + for i in range(0, len(final_trace) - 1): + from_node = final_trace[i] + to_node = final_trace[i + 1] + self.executed_edges.add((from_node, to_node)) + def generate_html(self) -> str: """ Generate html file from networkx graph via Bokeh @@ -100,9 +124,9 @@ def _add_nodes_with_labels(self): node_labels = nx.get_node_attributes(self.graph.networkx, "label") # Create data sources for nodes and labels - circle_data = dict(x=[], y=[], radius=[], label=[]) - rect_data = dict(x=[], y=[], width=[], height=[], label=[]) - text_data = dict(x=[], y=[], text=[]) + circle_data = dict(x=[], y=[], radius=[], label=[], color=[], text_color=[]) + rect_data = dict(x=[], y=[], width=[], height=[], label=[], color=[], text_color=[]) + text_data = dict(x=[], y=[], text=[], text_color=[]) for node in self.graph.networkx.nodes: # Labels are always defined and cannot be lists @@ -110,6 +134,11 @@ def _add_nodes_with_labels(self): label = self._cap_name(label) x, y = self.graph_layout[node] + # Determine if node is executed + is_executed = node in self.executed_nodes + node_color = self.EXECUTED_NODE_COLOR if is_executed else self.UNEXECUTED_NODE_COLOR + text_color = self.EXECUTED_TEXT_COLOR if is_executed else self.UNEXECUTED_TEXT_COLOR + if node == 'start': # For start node (circle), calculate radius based on text width text_width, text_height = self._calculate_text_dimensions( @@ -121,6 +150,8 @@ def _add_nodes_with_labels(self): circle_data['y'].append(y) circle_data['radius'].append(radius) circle_data['label'].append(label) + circle_data['color'].append(node_color) + circle_data['text_color'].append(text_color) # Store node properties for arrow calculations self.node_props[node] = { @@ -136,6 +167,8 @@ def _add_nodes_with_labels(self): rect_data['width'].append(text_width) rect_data['height'].append(text_height) rect_data['label'].append(label) + rect_data['color'].append(node_color) + rect_data['text_color'].append(text_color) # Store node properties for arrow calculations self.node_props[node] = {'type': 'rect', 'x': x, 'y': y, 'width': text_width, 'height': text_height, @@ -145,26 +178,27 @@ def _add_nodes_with_labels(self): text_data['x'].append(x) text_data['y'].append(y) text_data['text'].append(label) + text_data['text_color'].append(text_color) # Add circles for start node if circle_data['x']: circle_source = ColumnDataSource(circle_data) circles = Circle(x='x', y='y', radius='radius', - fill_color=Spectral4[0]) + fill_color='color', line_color='color') self.plot.add_glyph(circle_source, circles) # Add rectangles for scenario nodes if rect_data['x']: rect_source = ColumnDataSource(rect_data) rectangles = Rect(x='x', y='y', width='width', height='height', - fill_color=Spectral4[0]) + fill_color='color', line_color='color') self.plot.add_glyph(rect_source, rectangles) # Add text labels for all nodes text_source = ColumnDataSource(text_data) text_labels = Text(x='x', y='y', text='text', text_align='center', text_baseline='middle', - text_color='white', text_font_size='9pt') + text_color='text_color', text_font_size='9pt') self.plot.add_glyph(text_source, text_labels) def _get_edge_points(self, start_node, end_node): @@ -271,15 +305,21 @@ def add_self_loop(self, node_id: str): control2_x = x + width / 8 control2_y = y + height / 2 + arc_height + # Determine if edge is executed + is_executed = (node_id, node_id) in self.executed_edges + edge_color = self.EXECUTED_EDGE_COLOR if is_executed else self.UNEXECUTED_EDGE_COLOR + edge_width = self.EXECUTED_EDGE_WIDTH if is_executed else self.UNEXECUTED_EDGE_WIDTH + edge_alpha = self.EXECUTED_EDGE_ALPHA if is_executed else self.UNEXECUTED_EDGE_ALPHA + # Create the Bezier curve (the main arc) with the same thickness as straight lines loop = Bezier( x0=start_x, y0=start_y, x1=end_x, y1=end_y, cx0=control1_x, cy0=control1_y, cx1=control2_x, cy1=control2_y, - line_color=NetworkVisualiser.EDGE_COLOUR, - line_width=NetworkVisualiser.EDGE_WIDTH, - line_alpha=NetworkVisualiser.EDGE_ALPHA, + line_color=edge_color, + line_width=edge_width, + line_alpha=edge_alpha, ) self.plot.add_glyph(loop) @@ -297,9 +337,9 @@ def add_self_loop(self, node_id: str): # Add just the arrowhead (NormalHead) at the end point, oriented along the tangent arrowhead = NormalHead( size=NetworkVisualiser.ARROWHEAD_SIZE, - line_color=NetworkVisualiser.EDGE_COLOUR, - fill_color=NetworkVisualiser.EDGE_COLOUR, - line_width=NetworkVisualiser.EDGE_WIDTH + line_color=edge_color, + fill_color=edge_color, + line_width=edge_width ) # Create a standalone arrowhead at the end point @@ -310,9 +350,9 @@ def add_self_loop(self, node_id: str): y_start=end_y - tangent_y * 0.001, x_end=end_x, y_end=end_y, - line_color=NetworkVisualiser.EDGE_COLOUR, - line_width=NetworkVisualiser.EDGE_WIDTH, - line_alpha=NetworkVisualiser.EDGE_ALPHA + line_color=edge_color, + line_width=edge_width, + line_alpha=edge_alpha ) self.plot.add_layout(arrow) @@ -326,13 +366,22 @@ def _add_edges(self): edge_labels = nx.get_edge_attributes(self.graph.networkx, "label") # Create data sources for edges and edge labels - edge_text_data = dict(x=[], y=[], text=[]) + edge_text_data = dict(x=[], y=[], text=[], text_color=[]) for edge in self.graph.networkx.edges(): # Edge labels are always defined and cannot be lists edge_label = edge_labels[edge] edge_label = self._cap_name(edge_label) + + # Determine if edge is executed + is_executed = edge in self.executed_edges + edge_color = self.EXECUTED_EDGE_COLOR if is_executed else self.UNEXECUTED_EDGE_COLOR + edge_width = self.EXECUTED_EDGE_WIDTH if is_executed else self.UNEXECUTED_EDGE_WIDTH + edge_alpha = self.EXECUTED_EDGE_ALPHA if is_executed else self.UNEXECUTED_EDGE_ALPHA + label_color = self.EXECUTED_LABEL_COLOR if is_executed else self.UNEXECUTED_LABEL_COLOR + edge_text_data['text'].append(edge_label) + edge_text_data['text_color'].append(label_color) if edge[0] == edge[1]: # Self-loop handled separately @@ -349,10 +398,14 @@ def _add_edges(self): arrow = Arrow( end=NormalHead( size=NetworkVisualiser.ARROWHEAD_SIZE, - line_color=NetworkVisualiser.EDGE_COLOUR, - fill_color=NetworkVisualiser.EDGE_COLOUR), + line_color=edge_color, + fill_color=edge_color, + line_width=edge_width), x_start=start_x, y_start=start_y, - x_end=end_x, y_end=end_y + x_end=end_x, y_end=end_y, + line_color=edge_color, + line_width=edge_width, + line_alpha=edge_alpha ) self.plot.add_layout(arrow) @@ -365,11 +418,11 @@ def _add_edges(self): edge_text_source = ColumnDataSource(edge_text_data) edge_labels_glyph = Text(x='x', y='y', text='text', text_align='center', text_baseline='middle', - text_font_size='7pt') + text_color='text_color', text_font_size='7pt') self.plot.add_glyph(edge_text_source, edge_labels_glyph) def _cap_name(self, name: str) -> str: - if len(name) < self.MAX_VERTEX_NAME_LEN or isinstance(self.graph, StateGraph): + if len(name) < self.MAX_VERTEX_NAME_LEN or isinstance(self.graph, StateGraph) or isinstance(self.graph, ScenarioStateGraph): return name return f"{name[:(self.MAX_VERTEX_NAME_LEN - 3)]}..." diff --git a/utest/test_visualise_scenariograph.py b/utest/test_visualise_scenariograph.py index 6af361d7..57f20226 100644 --- a/utest/test_visualise_scenariograph.py +++ b/utest/test_visualise_scenariograph.py @@ -1,9 +1,11 @@ import unittest import networkx as nx from robotmbt.tracestate import TraceState + try: from robotmbt.visualise.graphs.scenariograph import ScenarioGraph from robotmbt.visualise.models import TraceInfo, ScenarioInfo, ModelSpace + VISUALISE = True except ImportError: VISUALISE = False @@ -30,12 +32,24 @@ def test_scenario_graph_ids_duplicate_scenario(self): def test_scenario_graph_ids_different_scenarios(self): sg = ScenarioGraph() - si0 = ScenarioInfo('test0') - si1 = ScenarioInfo('test1') - id0 = sg._get_or_create_id(si0) - id1 = sg._get_or_create_id(si1) - self.assertEqual(id0, 'node0') - self.assertEqual(id1, 'node1') + si00 = ScenarioInfo('test0') + si01 = ScenarioInfo('test0') + si10 = ScenarioInfo('test1') + si11 = ScenarioInfo('test1') + id00 = sg._get_or_create_id(si00) + id01 = sg._get_or_create_id(si01) + id10 = sg._get_or_create_id(si10) + id11 = sg._get_or_create_id(si11) + self.assertEqual(id00, 'node0') + self.assertEqual(id01, 'node0') + self.assertEqual(id00, id01) + self.assertEqual(id10, 'node1') + self.assertEqual(id11, 'node1') + self.assertEqual(id10, id11) + self.assertNotEqual(id00, id10) + self.assertNotEqual(id00, id11) + self.assertNotEqual(id01, id10) + self.assertNotEqual(id01, id11) def test_scenario_graph_add_new_node(self): sg = ScenarioGraph() @@ -60,6 +74,8 @@ def test_scenario_graph_update_visualisation_nodes(self): sg = ScenarioGraph() sg.update_visualisation(ti) + self.assertIn('start', sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') self.assertIn('node0', sg.networkx.nodes) self.assertEqual(sg.networkx.nodes['node0']['label'], str(0)) self.assertIn('node1', sg.networkx.nodes) @@ -73,14 +89,16 @@ def test_scenario_graph_update_visualisation_edges(self): for scenario in range(3): candidates.append(ts.next_candidate()) ts.confirm_full_scenario(candidates[-1], str(scenario), {}) - ti = TraceInfo.from_trace_state(trace=ts, state=ModelSpace()) + ti = TraceInfo.from_trace_state(ts, ModelSpace()) sg = ScenarioGraph() sg.update_visualisation(ti) + self.assertIn(('start', 'node0'), sg.networkx.edges) self.assertIn(('node0', 'node1'), sg.networkx.edges) self.assertIn(('node1', 'node2'), sg.networkx.edges) edge_labels = nx.get_edge_attributes(sg.networkx, "label") + self.assertEqual(edge_labels[('start', 'node0')], '') self.assertEqual(edge_labels[('node0', 'node1')], '') self.assertEqual(edge_labels[('node1', 'node2')], '') @@ -96,53 +114,23 @@ def test_scenario_graph_update_visualisation_single_node(self): self.assertEqual(len(sg.networkx.nodes), 1) self.assertEqual(len(sg.networkx.edges), 0) - def test_scenario_graph_set_starting_node_new_node(self): - sg = ScenarioGraph() - si = ScenarioInfo('test') - sg._set_starting_node(si) - node_id = sg._get_or_create_id(si) - # node - self.assertIn(node_id, sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes[node_id]['label'], 'test') - - # edge - self.assertIn(('start', node_id), sg.networkx.edges) - edge_labels = nx.get_edge_attributes(sg.networkx, "label") - self.assertEqual(edge_labels[('start', node_id)], '') - - def test_scenario_graph_set_starting_node_existing_node(self): - sg = ScenarioGraph() - si = ScenarioInfo('test') - node_id = sg._get_or_create_id(si) - sg._add_node(node_id) - self.assertIn(node_id, sg.networkx.nodes) - - sg._set_starting_node(si) - self.assertIn(('start', node_id), sg.networkx.edges) - edge_labels = nx.get_edge_attributes(sg.networkx, "label") - self.assertEqual(edge_labels[('start', node_id)], '') - - def test_scenario_graph_set_end_node(self): - sg = ScenarioGraph() - si = ScenarioInfo('test') - node_id = sg._get_or_create_id(si) - sg._set_ending_node(si) - self.assertEqual(sg.end_node, node_id) - - def test_scenario_graph_set_final_trace(self): + def test_scenario_graph_get_final_trace(self): ts = TraceState(3) candidates = [] for scenario in range(3): candidates.append(ts.next_candidate()) ts.confirm_full_scenario(candidates[-1], str(scenario), {}) - ti = TraceInfo.from_trace_state(trace=ts, state=ModelSpace()) + ti = TraceInfo.from_trace_state(ts, ModelSpace()) sg = ScenarioGraph() sg.update_visualisation(ti) sg.set_final_trace(ti) - # test start node - self.assertIn(('start', 'node0'), sg.networkx.edges) - # test end node - self.assertEqual(sg.end_node, 'node2') + trace = sg.get_final_trace() + # confirm they are proper ids + for node in trace: + self.assertIn(node, sg.networkx.nodes) + # confirm the edges exist + for i in range(0, len(trace) - 1): + self.assertIn((trace[i], trace[i + 1]), sg.networkx.edges) if __name__ == '__main__': unittest.main() diff --git a/utest/test_visualise_scenariostategraph.py b/utest/test_visualise_scenariostategraph.py index 5c8d5fe3..45ea8154 100644 --- a/utest/test_visualise_scenariostategraph.py +++ b/utest/test_visualise_scenariostategraph.py @@ -1,6 +1,7 @@ import unittest import networkx as nx from robotmbt.tracestate import TraceState + try: from robotmbt.visualise.graphs.scenariostategraph import ScenarioStateGraph from robotmbt.visualise.models import TraceInfo, ScenarioInfo, ModelSpace, StateInfo @@ -13,48 +14,70 @@ class TestVisualiseScenarioGraph(unittest.TestCase): def test_scenario_state_graph_init(self): stg = ScenarioStateGraph() + self.assertIn('start', stg.networkx.nodes) self.assertEqual(stg.networkx.nodes['start']['label'], 'start') + self.assertEqual(len(stg.networkx.nodes), 1) + self.assertEqual(len(stg.networkx.edges), 0) def test_scenario_state_graph_ids_empty(self): stg = ScenarioStateGraph() + si = ScenarioInfo('test') node_id = stg._get_or_create_id(si, StateInfo(ModelSpace())) + self.assertEqual(node_id, 'node0') def test_scenario_state_graph_ids_duplicate_scenario(self): stg = ScenarioStateGraph() + si = ScenarioInfo('test') sti = StateInfo(ModelSpace()) + id0 = stg._get_or_create_id(si, sti) id1 = stg._get_or_create_id(si, sti) + self.assertEqual(id0, id1) def test_scenario_state_graph_ids_different_scenarios(self): stg = ScenarioStateGraph() + si0 = ScenarioInfo('test0') si1 = ScenarioInfo('test1') sti = StateInfo(ModelSpace()) + id0 = stg._get_or_create_id(si0, sti) id1 = stg._get_or_create_id(si1, sti) + self.assertEqual(id0, 'node0') self.assertEqual(id1, 'node1') def test_scenario_state_graph_ids_different_states(self): stg = ScenarioStateGraph() + si = ScenarioInfo('test0') sti0 = StateInfo(ModelSpace("state0")) sti1 = StateInfo(ModelSpace("state1")) + id0 = stg._get_or_create_id(si, sti0) id1 = stg._get_or_create_id(si, sti1) + self.assertEqual(id0, 'node0') self.assertEqual(id1, 'node1') def test_scenario_state_graph_add_new_node(self): stg = ScenarioStateGraph() + + self.assertIn('start', stg.networkx.nodes) + self.assertNotIn('test', stg.networkx.nodes) + self.assertEqual(len(stg.networkx.nodes), 1) + stg.ids['test'] = (ScenarioInfo('test'), StateInfo(ModelSpace())) stg._add_node('test') + + self.assertIn('start', stg.networkx.nodes) self.assertIn('test', stg.networkx.nodes) + self.assertEqual(len(stg.networkx.nodes), 2) self.assertEqual(stg.networkx.nodes['test']['label'], ScenarioStateGraph._gen_label(ScenarioInfo('test'), StateInfo(ModelSpace()))) @@ -65,34 +88,37 @@ def test_scenario_state_graph_add_existing_node(self): self.assertEqual(len(stg.networkx.nodes), 1) def test_scenario_state_graph_update_visualisation_nodes(self): + stg = ScenarioStateGraph() + ts = TraceState(3) candidates = [] for scenario in range(3): candidates.append(ts.next_candidate()) ts.confirm_full_scenario(candidates[-1], str(scenario), {}) - ti = TraceInfo.from_trace_state(ts, ModelSpace()) - stg = ScenarioStateGraph() - stg.update_visualisation(ti) + ti = TraceInfo.from_trace_state(ts, ModelSpace()) + stg.update_visualisation(ti) self.assertIn('node0', stg.networkx.nodes) + self.assertIn('node1', stg.networkx.nodes) + self.assertIn('node2', stg.networkx.nodes) + self.assertEqual(stg.networkx.nodes['node0']['label'], ScenarioStateGraph._gen_label(ScenarioInfo(str(0)), StateInfo(ModelSpace()))) - self.assertIn('node1', stg.networkx.nodes) self.assertEqual(stg.networkx.nodes['node1']['label'], ScenarioStateGraph._gen_label(ScenarioInfo(str(1)), StateInfo(ModelSpace()))) - self.assertIn('node2', stg.networkx.nodes) self.assertEqual(stg.networkx.nodes['node2']['label'], ScenarioStateGraph._gen_label(ScenarioInfo(str(2)), StateInfo(ModelSpace()))) def test_scenario_state_graph_update_visualisation_edges(self): + stg = ScenarioStateGraph() + ts = TraceState(3) candidates = [] for scenario in range(3): candidates.append(ts.next_candidate()) ts.confirm_full_scenario(candidates[-1], str(scenario), {}) - ti = TraceInfo.from_trace_state(trace=ts, state=ModelSpace()) - stg = ScenarioStateGraph() - stg.update_visualisation(ti) + ti = TraceInfo.from_trace_state(trace=ts, state=ModelSpace()) + stg.update_visualisation(ti) self.assertIn(('node0', 'node1'), stg.networkx.edges) self.assertIn(('node1', 'node2'), stg.networkx.edges) @@ -102,56 +128,16 @@ def test_scenario_state_graph_update_visualisation_edges(self): self.assertEqual(edge_labels[('node1', 'node2')], '') def test_scenario_state_graph_update_visualisation_single_node(self): - ts = TraceState(1) - ts.confirm_full_scenario(0, 'one', {}) - self.assertEqual(ts.get_trace(), ['one']) - ti = TraceInfo.from_trace_state(ts, ModelSpace()) - stg = ScenarioStateGraph() - stg.update_visualisation(ti) - - # expected behaviour: no nodes nor edges are added - self.assertEqual(len(stg.networkx.nodes), 1) - self.assertEqual(len(stg.networkx.edges), 0) - - def test_scenario_state_graph_set_starting_node_new_node(self): stg = ScenarioStateGraph() - si = ScenarioInfo('test') - stg._set_starting_node(si, StateInfo(ModelSpace())) - node_id = stg._get_or_create_id(si, StateInfo(ModelSpace())) - # node - self.assertIn(node_id, stg.networkx.nodes) - self.assertEqual(stg.networkx.nodes[node_id]['label'], - ScenarioStateGraph._gen_label(si, StateInfo(ModelSpace()))) - # edge - self.assertIn(('start', node_id), stg.networkx.edges) - edge_labels = nx.get_edge_attributes(stg.networkx, "label") - self.assertEqual(edge_labels[('start', node_id)], '') - - def test_scenario_state_graph_set_starting_node_existing_node(self): - stg = ScenarioStateGraph() - si = ScenarioInfo('test') - node_id = stg._get_or_create_id(si, StateInfo(ModelSpace())) - stg._add_node(node_id) - self.assertIn(node_id, stg.networkx.nodes) + ti = TraceInfo([ScenarioInfo('one')], ModelSpace()) + stg.update_visualisation(ti) - stg._set_starting_node(si, StateInfo(ModelSpace())) - self.assertIn(('start', node_id), stg.networkx.edges) - edge_labels = nx.get_edge_attributes(stg.networkx, "label") - self.assertEqual(edge_labels[('start', node_id)], '') + # expected behaviour: only start and added node and their edge + self.assertEqual(len(stg.networkx.nodes), 2) + self.assertEqual(len(stg.networkx.edges), 1) - def test_scenario_state_graph_set_final_trace(self): - ts = TraceState(3) - candidates = [] - for scenario in range(3): - candidates.append(ts.next_candidate()) - ts.confirm_full_scenario(candidates[-1], str(scenario), {}) - ti = TraceInfo.from_trace_state(trace=ts, state=ModelSpace()) - stg = ScenarioStateGraph() - stg.update_visualisation(ti) - stg.set_final_trace(ti) - # test start node - self.assertIn(('start', 'node0'), stg.networkx.edges) + # TODO: improve existing tests and add tests for set_final_trace/get_final_trace, _gen_label if __name__ == '__main__': unittest.main() From 48a60e1f7f0f14d3c3c758f2715ccdc3fcc4b91f Mon Sep 17 00:00:00 2001 From: Thomas Kas Date: Tue, 2 Dec 2025 16:43:50 +0100 Subject: [PATCH 077/131] Unit tests and bug fixes (#28) * Implement Feature Path Highlighting in Scenario and State Graphs * Implement State Graph Path highlighting with Stack * Clean-up of unused features, move some logic, fix empty state entry bug * Formatting * Formatting * Remove unused return value * Fix bug * StateInfo unit tests * StateGraph unit tests * Merge with main, fix some bugs, alter ScenarioStateGraph to be in line with the rest and fix up some of its unit tests * Add short explanation * Test self-loops * Minor tweaks * Remove trailing newline * Fix nuking of stack * Better formatting of state info * Spam __update_visualisation all over the place for refinement * Fix nuking in scenario-state graph * Warn instead of crashing * Sanity check * Do add edge with start node * Expand tests * Merge mistake * Fix oopsie * Move helper to StateInfo * Protected helper * Forgot this one * added graph getter for resources --------- Co-authored-by: Diogo Silva Co-authored-by: Douwe Osinga --- atest/resources/helpers/modelgenerator.py | 17 +- robotmbt/suiteprocessors.py | 22 +- robotmbt/visualise/graphs/scenariograph.py | 12 +- .../visualise/graphs/scenariostategraph.py | 37 +- robotmbt/visualise/graphs/stategraph.py | 39 +- robotmbt/visualise/models.py | 23 +- robotmbt/visualise/networkvisualiser.py | 4 +- utest/test_visualise_models.py | 51 ++- utest/test_visualise_scenariograph.py | 282 +++++++++--- utest/test_visualise_scenariostategraph.py | 414 +++++++++++++++--- utest/test_visualise_stategraph.py | 318 +++++++++++++- 11 files changed, 1033 insertions(+), 186 deletions(-) diff --git a/atest/resources/helpers/modelgenerator.py b/atest/resources/helpers/modelgenerator.py index 58339df8..b5837aeb 100644 --- a/atest/resources/helpers/modelgenerator.py +++ b/atest/resources/helpers/modelgenerator.py @@ -3,18 +3,31 @@ from robot.api.deco import keyword # type:ignore from robotmbt.modelspace import ModelSpace -from robotmbt.visualise.models import TraceInfo, ScenarioInfo +from robotmbt.visualise.models import TraceInfo, ScenarioInfo, StateInfo from robotmbt.visualise.graphs.scenariograph import ScenarioGraph +from robotmbt.visualise.graphs.abstractgraph import AbstractGraph +from robotmbt.visualise.graphs.stategraph import StateGraph class ModelGenerator: + @keyword(name="Create Graph") # type: ignore + def create_graph(self, graph_type :str) -> AbstractGraph: + match graph_type: + case "scenario": + return ScenarioGraph() + case "state": + return StateGraph() + case _: + raise Exception(f"Trying to create unknown graph type {graph_type}") + + @keyword(name="Generate Trace Information") # type: ignore def generate_trace_info(self, scenario_count: int) -> TraceInfo: """Generates a list of unique random scenarios.""" scenarios: list[ScenarioInfo] = ModelGenerator.generate_scenario_names( scenario_count) - return TraceInfo(scenarios, ModelSpace()) + return TraceInfo(scenarios, StateInfo(ModelSpace())) @keyword(name="Ensure Scenario Present") # type: ignore def ensure_scenario_present(self, trace_info: TraceInfo, scenario_name: str) -> TraceInfo: diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index c41a4a5a..208fb8fe 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -153,6 +153,9 @@ def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool): self.active_model.new_scenario_scope() inserted = self._try_to_fit_in_scenario(i_candidate, self._scenario_with_repeat_counter(i_candidate), retry_flag=allow_duplicate_scenarios) + + self.__update_visualisation() + if inserted: self.DROUGHT_LIMIT = 50 if self.__last_candidate_changed_nothing(): @@ -167,9 +170,12 @@ def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool): self._report_tracestate_to_user() logger.debug( f"last state:\n{self.active_model.get_status_text()}") - if self.visualiser is not None: - self.visualiser.update_visualisation( - TraceInfo.from_trace_state(self.tracestate, self.active_model)) + self.__update_visualisation() + + def __update_visualisation(self): + if self.visualiser is not None: + self.visualiser.update_visualisation( + TraceInfo.from_trace_state(self.tracestate, self.active_model)) def __last_candidate_changed_nothing(self) -> bool: if len(self.tracestate) < 2: @@ -223,6 +229,7 @@ def _try_to_fit_in_scenario(self, index: int, candidate: Scenario, retry_flag: b f"Inserted scenario {confirmed_candidate.src_id}, {confirmed_candidate.name}") self._report_tracestate_to_user() logger.debug(f"last state:\n{self.active_model.get_status_text()}") + self.__update_visualisation() return True part1, part2 = self._split_candidate_if_refinement_needed( @@ -243,12 +250,15 @@ def _try_to_fit_in_scenario(self, index: int, candidate: Scenario, retry_flag: b "Refinement needed, but there are no scenarios left") self._rewind() self._report_tracestate_to_user() + self.__update_visualisation() return False while i_refine is not None: + self.__update_visualisation() self.active_model.new_scenario_scope() m_inserted = self._try_to_fit_in_scenario(i_refine, self._scenario_with_repeat_counter(i_refine), retry_flag) + self.__update_visualisation() if m_inserted: insert_valid_here = True try: @@ -265,6 +275,7 @@ def _try_to_fit_in_scenario(self, index: int, candidate: Scenario, retry_flag: b m_finished = self._try_to_fit_in_scenario( index, part2, retry_flag) if m_finished: + self.__update_visualisation() return True else: logger.debug( @@ -276,15 +287,20 @@ def _try_to_fit_in_scenario(self, index: int, candidate: Scenario, retry_flag: b self._rewind() self._report_tracestate_to_user() + self.__update_visualisation() i_refine = self.tracestate.next_candidate(retry=retry_flag) + self.__update_visualisation() + self._rewind() self._report_tracestate_to_user() + self.__update_visualisation() return False self.active_model.end_scenario_scope() self.tracestate.reject_scenario(index) self._report_tracestate_to_user() + self.__update_visualisation() return False def _rewind(self, drought_recovery: bool = False) -> TraceSnapShot | None: diff --git a/robotmbt/visualise/graphs/scenariograph.py b/robotmbt/visualise/graphs/scenariograph.py index c4c5168f..cddc6a48 100644 --- a/robotmbt/visualise/graphs/scenariograph.py +++ b/robotmbt/visualise/graphs/scenariograph.py @@ -32,11 +32,15 @@ def update_visualisation(self, info: TraceInfo): self._add_node(to_node) if (from_node, to_node) not in self.networkx.edges: - self.networkx.add_edge( - from_node, to_node, label='') + self.networkx.add_edge(from_node, to_node, label='') - if i == 0 and ('start', from_node) not in self.networkx.edges: - self.networkx.add_edge('start', from_node, label='') + if len(info.trace) > 0: + first_id = self._get_or_create_id(info.trace[0]) + + self._add_node(first_id) + + if ('start', first_id) not in self.networkx.edges: + self.networkx.add_edge('start', first_id, label='') def set_final_trace(self, info: TraceInfo): self.final_trace.extend(map(lambda s: self._get_or_create_id(s), info.trace)) diff --git a/robotmbt/visualise/graphs/scenariostategraph.py b/robotmbt/visualise/graphs/scenariostategraph.py index 157ba049..bf500178 100644 --- a/robotmbt/visualise/graphs/scenariostategraph.py +++ b/robotmbt/visualise/graphs/scenariostategraph.py @@ -1,6 +1,6 @@ import networkx as nx +from robot.api import logger -from robotmbt.modelspace import ModelSpace from robotmbt.visualise.graphs.abstractgraph import AbstractGraph from robotmbt.visualise.models import TraceInfo, ScenarioInfo, StateInfo @@ -22,7 +22,6 @@ def __init__(self): # add the start node self.networkx.add_node('start', label='start') - self.prev_state = StateInfo(ModelSpace()) self.prev_trace_len = 0 # Stack to track the current execution path @@ -33,26 +32,17 @@ def update_visualisation(self, info: TraceInfo): This will add nodes the newly reached scenario/state pair, as well as an edge from the previous to the current scenario/state pair. """ - if len(info.trace) == 0: - self.prev_trace_len = len(info.trace) - self.prev_state = info.state - return - - if len(info.trace) == 1: - from_node = 'start' - else: - from_node = self._get_or_create_id(info.trace[-2], self.prev_state) - to_node = self._get_or_create_id(info.trace[-1], info.state) - if self.prev_trace_len < len(info.trace): # New state added - add to stack - self.node_stack.append(to_node) - - self._add_node(from_node) - self._add_node(to_node) + push_count = len(info.trace) - self.prev_trace_len + for i in range(push_count): + node = self._get_or_create_id(info.trace[-push_count + i], info.state) + self.node_stack.append(node) + self._add_node(self.node_stack[-2]) + self._add_node(self.node_stack[-1]) - if (from_node, to_node) not in self.networkx.edges: - self.networkx.add_edge(from_node, to_node, label='') + if (self.node_stack[-2], self.node_stack[-1]) not in self.networkx.edges: + self.networkx.add_edge(self.node_stack[-2], self.node_stack[-1], label='') elif self.prev_trace_len > len(info.trace): # States removed - remove from stack @@ -60,13 +50,16 @@ def update_visualisation(self, info: TraceInfo): for _ in range(pop_count): if len(self.node_stack) > 1: # Always keep 'start' self.node_stack.pop() + else: + logger.warn("Tried to rollback more than was previously added to the stack!") - self.prev_state = info.state self.prev_trace_len = len(info.trace) def set_final_trace(self, info: TraceInfo): # We already have the final trace in state_stack, so we don't need to do anything - pass + # But do a sanity check + if self.prev_trace_len != len(info.trace): + logger.warn("Final trace was of a different length than our stack was based on!") def get_final_trace(self) -> list[str]: # The final trace is simply the state stack we've been keeping track of @@ -90,7 +83,7 @@ def _gen_label(scenario: ScenarioInfo, state: StateInfo) -> str: """ Creates the label for a node in a Scenario-State Graph from the scenario and state associated to it. """ - return scenario.name + "\n\r" + str(state) + return scenario.name + "\n\n" + str(state) def _add_node(self, node: str): """ diff --git a/robotmbt/visualise/graphs/stategraph.py b/robotmbt/visualise/graphs/stategraph.py index 7be5d8b9..e61bb32e 100644 --- a/robotmbt/visualise/graphs/stategraph.py +++ b/robotmbt/visualise/graphs/stategraph.py @@ -1,6 +1,7 @@ +from robot.api import logger + from robotmbt.visualise.graphs.abstractgraph import AbstractGraph from robotmbt.visualise.models import TraceInfo, StateInfo -from robotmbt.modelspace import ModelSpace import networkx as nx @@ -20,7 +21,7 @@ def __init__(self): # add the start node self.networkx.add_node('start', label='start') - self.prev_state = StateInfo(ModelSpace()) + # To check if we've backtracked self.prev_trace_len = 0 # Stack to track the current execution path @@ -31,28 +32,19 @@ def update_visualisation(self, info: TraceInfo): This will add nodes the newly reached state (if we did not roll back), as well as an edge from the previous to the current state labeled with the scenario that took it there. """ - if len(info.trace) == 0: - self.prev_trace_len = len(info.trace) - self.prev_state = info.state - return - - scenario = info.trace[-1] - - from_node = self._get_or_create_id(self.prev_state) - if len(info.trace) == 1: - from_node = 'start' - to_node = self._get_or_create_id(info.state) + node = self._get_or_create_id(info.state) if self.prev_trace_len < len(info.trace): # New state added - add to stack - self.node_stack.append(to_node) - - self._add_node(from_node) - self._add_node(to_node) + push_count = len(info.trace) - self.prev_trace_len + for i in range(push_count): + self.node_stack.append(node) + self._add_node(self.node_stack[-2]) + self._add_node(self.node_stack[-1]) - if (from_node, to_node) not in self.networkx.edges: - self.networkx.add_edge( - from_node, to_node, label=scenario.name) + if (self.node_stack[-2], self.node_stack[-1]) not in self.networkx.edges: + self.networkx.add_edge( + self.node_stack[-2], self.node_stack[-1], label=info.trace[-push_count + i].name) elif self.prev_trace_len > len(info.trace): # States removed - remove from stack @@ -60,13 +52,16 @@ def update_visualisation(self, info: TraceInfo): for _ in range(pop_count): if len(self.node_stack) > 1: # Always keep 'start' self.node_stack.pop() + else: + logger.warn("Tried to rollback more than was previously added to the stack!") - self.prev_state = info.state self.prev_trace_len = len(info.trace) def set_final_trace(self, info: TraceInfo): # We already have the final trace in state_stack, so we don't need to do anything - pass + # But do a sanity check + if self.prev_trace_len != len(info.trace): + logger.warn("Final trace was of a different length than our stack was based on!") def get_final_trace(self) -> list[str]: # The final trace is simply the state stack we've been keeping track of diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index 5ce13658..f6afd14b 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -1,3 +1,5 @@ +from typing import Any + from robotmbt.modelspace import ModelSpace from robotmbt.suitedata import Scenario from robotmbt.tracestate import TraceState @@ -31,6 +33,15 @@ class StateInfo: - properties """ + @classmethod + def _create_state_with_prop(cls, name: str, attrs: list[tuple[str, Any]]): + space = ModelSpace() + prop = ModelSpace() + for (key, val) in attrs: + prop.__setattr__(key, val) + space.props[name] = prop + return cls(space) + def __init__(self, state: ModelSpace): self.domain = state.ref_id @@ -57,9 +68,11 @@ def __eq__(self, other): def __str__(self): res = "" for p in self.properties: - res += f"{p}:\n" + if res != "": + res += "\n\n" + res += f"{p}:" for k, v in self.properties[p].items(): - res += f"\t{k}={v}\n" + res += f"\n\t{k}={v}" return res @@ -72,11 +85,11 @@ class TraceInfo: @classmethod def from_trace_state(cls, trace: TraceState, state: ModelSpace): - return cls([ScenarioInfo(t) for t in trace.get_trace()], state) + return cls([ScenarioInfo(t) for t in trace.get_trace()], StateInfo(state)) - def __init__(self, trace: list[ScenarioInfo], state: ModelSpace): + def __init__(self, trace: list[ScenarioInfo], state: StateInfo): self.trace: list[ScenarioInfo] = trace - self.state = StateInfo(state) + self.state = state def __repr__(self) -> str: return f"TraceInfo(trace=[{[str(t) for t in self.trace]}], state={self.state})" diff --git a/robotmbt/visualise/networkvisualiser.py b/robotmbt/visualise/networkvisualiser.py index 837daaa8..5e4b6f71 100644 --- a/robotmbt/visualise/networkvisualiser.py +++ b/robotmbt/visualise/networkvisualiser.py @@ -1,6 +1,6 @@ from robotmbt.visualise.graphs.abstractgraph import AbstractGraph -from robotmbt.visualise.graphs.scenariostategraph import ScenarioStateGraph from robotmbt.visualise.graphs.stategraph import StateGraph +from robotmbt.visualise.graphs.scenariostategraph import ScenarioStateGraph from bokeh.palettes import Spectral4 from bokeh.models import ( Plot, Range1d, Circle, Rect, @@ -32,7 +32,7 @@ class NetworkVisualiser: # Colors and styles for executed vs unexecuted elements EXECUTED_NODE_COLOR = Spectral4[0] # Bright blue UNEXECUTED_NODE_COLOR = '#D3D3D3' # Light gray - EXECUTED_TEXT_COLOR = 'white' + EXECUTED_TEXT_COLOR = '#C8C8C8' UNEXECUTED_TEXT_COLOR = '#A9A9A9' # Dark gray EXECUTED_EDGE_COLOR = (12, 12, 12) # Black UNEXECUTED_EDGE_COLOR = '#808080' # Gray diff --git a/utest/test_visualise_models.py b/utest/test_visualise_models.py index 7fbc59ab..8bca3ffb 100644 --- a/utest/test_visualise_models.py +++ b/utest/test_visualise_models.py @@ -1,6 +1,8 @@ import unittest + try: from robotmbt.visualise.models import * + VISUALISE = True except ImportError: VISUALISE = False @@ -12,8 +14,8 @@ class TestVisualiseModels(unittest.TestCase): """ """ - Class: ScenarioInfo - """ + Class: ScenarioInfo + """ def test_scenarioInfo_str(self): si = ScenarioInfo('test') @@ -28,10 +30,45 @@ def test_scenarioInfo_Scenario(self): self.assertEqual(si.src_id, 0) """ - Class: TraceInfo - """ + Class: StateInfo + """ + + def test_stateInfo_empty(self): + s = StateInfo(ModelSpace()) + self.assertEqual(str(s), '') + + def test_stateInfo_prop_empty(self): + space = ModelSpace() + space.props['prop1'] = ModelSpace() + s = StateInfo(space) + self.assertEqual(str(s), '') + + def test_stateInfo_prop_val(self): + space = ModelSpace() + prop1 = ModelSpace() + prop1.value = 1 + space.props['prop1'] = prop1 + s = StateInfo(space) + self.assertTrue('prop1:' in str(s)) + self.assertTrue('value=1' in str(s)) + + def test_stateInfo_prop_val_empty(self): + space = ModelSpace() + prop1 = ModelSpace() + prop1.value = 1 + prop2 = ModelSpace() + space.props['prop1'] = prop1 + space.props['prop2'] = prop2 + s = StateInfo(space) + self.assertTrue('prop1:' in str(s)) + self.assertTrue('value=1' in str(s)) + self.assertFalse('prop2:' in str(s)) + + """ + Class: TraceInfo + """ - def test_create_TraceInfo(self): + def test_traceInfo(self): ts = TraceState(3) candidates = [] for scenario in range(3): @@ -43,9 +80,7 @@ def test_create_TraceInfo(self): self.assertEqual(ti.trace[1].name, str(1)) self.assertEqual(ti.trace[2].name, str(2)) - self.assertIsNotNone(ti.state) - # TODO check state - + self.assertEqual(str(ti.state), '') if __name__ == '__main__': unittest.main() diff --git a/utest/test_visualise_scenariograph.py b/utest/test_visualise_scenariograph.py index 57f20226..5cbafe05 100644 --- a/utest/test_visualise_scenariograph.py +++ b/utest/test_visualise_scenariograph.py @@ -1,10 +1,8 @@ import unittest -import networkx as nx -from robotmbt.tracestate import TraceState try: from robotmbt.visualise.graphs.scenariograph import ScenarioGraph - from robotmbt.visualise.models import TraceInfo, ScenarioInfo, ModelSpace + from robotmbt.visualise.models import * VISUALISE = True except ImportError: @@ -14,38 +12,54 @@ class TestVisualiseScenarioGraph(unittest.TestCase): def test_scenario_graph_init(self): sg = ScenarioGraph() + + self.assertEqual(len(sg.networkx.nodes), 1) + self.assertEqual(len(sg.networkx.edges), 0) + self.assertIn('start', sg.networkx.nodes) self.assertEqual(sg.networkx.nodes['start']['label'], 'start') def test_scenario_graph_ids_empty(self): sg = ScenarioGraph() - si = ScenarioInfo('test') - node_id = sg._get_or_create_id(si) + + s = ScenarioInfo('test') + + node_id = sg._get_or_create_id(s) + self.assertEqual(node_id, 'node0') def test_scenario_graph_ids_duplicate_scenario(self): sg = ScenarioGraph() - si = ScenarioInfo('test') - id0 = sg._get_or_create_id(si) - id1 = sg._get_or_create_id(si) + + s0 = ScenarioInfo('test') + s1 = ScenarioInfo('test') + + id0 = sg._get_or_create_id(s0) + id1 = sg._get_or_create_id(s1) + self.assertEqual(id0, id1) def test_scenario_graph_ids_different_scenarios(self): sg = ScenarioGraph() + si00 = ScenarioInfo('test0') si01 = ScenarioInfo('test0') si10 = ScenarioInfo('test1') si11 = ScenarioInfo('test1') + id00 = sg._get_or_create_id(si00) id01 = sg._get_or_create_id(si01) id10 = sg._get_or_create_id(si10) id11 = sg._get_or_create_id(si11) + self.assertEqual(id00, 'node0') self.assertEqual(id01, 'node0') self.assertEqual(id00, id01) + self.assertEqual(id10, 'node1') self.assertEqual(id11, 'node1') self.assertEqual(id10, id11) + self.assertNotEqual(id00, id10) self.assertNotEqual(id00, id11) self.assertNotEqual(id01, id10) @@ -53,84 +67,254 @@ def test_scenario_graph_ids_different_scenarios(self): def test_scenario_graph_add_new_node(self): sg = ScenarioGraph() + sg.ids['test'] = ScenarioInfo('test') sg._add_node('test') + + self.assertEqual(len(sg.networkx.nodes), 2) + + self.assertIn('start', sg.networkx.nodes) self.assertIn('test', sg.networkx.nodes) + + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') self.assertEqual(sg.networkx.nodes['test']['label'], 'test') def test_scenario_graph_add_existing_node(self): sg = ScenarioGraph() - sg._add_node('start') + + self.assertEqual(len(sg.networkx.nodes), 1) self.assertIn('start', sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') + + sg._add_node('start') + self.assertEqual(len(sg.networkx.nodes), 1) + self.assertIn('start', sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') - def test_scenario_graph_update_visualisation_nodes(self): - ts = TraceState(3) - candidates = [] - for scenario in range(3): - candidates.append(ts.next_candidate()) - ts.confirm_full_scenario(candidates[-1], str(scenario), {}) - ti = TraceInfo.from_trace_state(ts, ModelSpace()) + def test_scenario_graph_update_nodes(self): sg = ScenarioGraph() - sg.update_visualisation(ti) + + scenario0 = ScenarioInfo('0') + scenario1 = ScenarioInfo('1') + scenario2 = ScenarioInfo('2') + + self.assertEqual(len(sg.networkx.nodes), 1) + self.assertEqual(len(sg.networkx.edges), 0) + + self.assertIn('start', sg.networkx.nodes) + + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') + + sg.update_visualisation(TraceInfo([scenario0], StateInfo(ModelSpace()))) + + self.assertEqual(len(sg.networkx.nodes), 2) + self.assertEqual(len(sg.networkx.edges), 1) + + self.assertIn('start', sg.networkx.nodes) + self.assertIn('node0', sg.networkx.nodes) + + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') + self.assertEqual(sg.networkx.nodes['node0']['label'], '0') + + sg.update_visualisation(TraceInfo([scenario0, scenario1], StateInfo(ModelSpace()))) + + self.assertEqual(len(sg.networkx.nodes), 3) + self.assertEqual(len(sg.networkx.edges), 2) self.assertIn('start', sg.networkx.nodes) + self.assertIn('node0', sg.networkx.nodes) + self.assertIn('node1', sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') + self.assertEqual(sg.networkx.nodes['node0']['label'], '0') + self.assertEqual(sg.networkx.nodes['node1']['label'], '1') + + sg.update_visualisation(TraceInfo([scenario0, scenario1, scenario2], StateInfo(ModelSpace()))) + + self.assertEqual(len(sg.networkx.nodes), 4) + self.assertEqual(len(sg.networkx.edges), 3) + + self.assertIn('start', sg.networkx.nodes) self.assertIn('node0', sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes['node0']['label'], str(0)) self.assertIn('node1', sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes['node1']['label'], str(1)) self.assertIn('node2', sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes['node2']['label'], str(2)) - - def test_scenario_graph_update_visualisation_edges(self): - ts = TraceState(3) - candidates = [] - for scenario in range(3): - candidates.append(ts.next_candidate()) - ts.confirm_full_scenario(candidates[-1], str(scenario), {}) - ti = TraceInfo.from_trace_state(ts, ModelSpace()) + + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') + self.assertEqual(sg.networkx.nodes['node0']['label'], '0') + self.assertEqual(sg.networkx.nodes['node1']['label'], '1') + self.assertEqual(sg.networkx.nodes['node2']['label'], '2') + + def test_scenario_graph_update_edges(self): sg = ScenarioGraph() - sg.update_visualisation(ti) + + scenario0 = ScenarioInfo('0') + scenario1 = ScenarioInfo('1') + scenario2 = ScenarioInfo('2') + + self.assertEqual(len(sg.networkx.nodes), 1) + self.assertEqual(len(sg.networkx.edges), 0) + + sg.update_visualisation(TraceInfo([scenario0], StateInfo(ModelSpace()))) + + self.assertEqual(len(sg.networkx.nodes), 2) + self.assertEqual(len(sg.networkx.edges), 1) + + self.assertIn(('start', 'node0'), sg.networkx.edges) + + self.assertEqual(sg.networkx.edges[('start', 'node0')]['label'], '') + + sg.update_visualisation(TraceInfo([scenario0, scenario1], StateInfo(ModelSpace()))) + + self.assertEqual(len(sg.networkx.nodes), 3) + self.assertEqual(len(sg.networkx.edges), 2) + + self.assertIn(('start', 'node0'), sg.networkx.edges) + self.assertIn(('node0', 'node1'), sg.networkx.edges) + + self.assertEqual(sg.networkx.edges[('start', 'node0')]['label'], '') + self.assertEqual(sg.networkx.edges[('node0', 'node1')]['label'], '') + + sg.update_visualisation(TraceInfo([scenario0, scenario1, scenario2], StateInfo(ModelSpace()))) + + self.assertEqual(len(sg.networkx.nodes), 4) + self.assertEqual(len(sg.networkx.edges), 3) self.assertIn(('start', 'node0'), sg.networkx.edges) self.assertIn(('node0', 'node1'), sg.networkx.edges) self.assertIn(('node1', 'node2'), sg.networkx.edges) - edge_labels = nx.get_edge_attributes(sg.networkx, "label") - self.assertEqual(edge_labels[('start', 'node0')], '') - self.assertEqual(edge_labels[('node0', 'node1')], '') - self.assertEqual(edge_labels[('node1', 'node2')], '') + self.assertEqual(sg.networkx.edges[('start', 'node0')]['label'], '') + self.assertEqual(sg.networkx.edges[('node0', 'node1')]['label'], '') + self.assertEqual(sg.networkx.edges[('node1', 'node2')]['label'], '') - def test_scenario_graph_update_visualisation_single_node(self): - ts = TraceState(1) - ts.confirm_full_scenario(0, 'one', {}) - self.assertEqual(ts.get_trace(), ['one']) - ti = TraceInfo.from_trace_state(ts, ModelSpace()) + def test_scenario_graph_update_single_node(self): sg = ScenarioGraph() - sg.update_visualisation(ti) - # expected behaviour: no nodes nor edges are added + scenario = ScenarioInfo('test') + self.assertEqual(len(sg.networkx.nodes), 1) self.assertEqual(len(sg.networkx.edges), 0) - def test_scenario_graph_get_final_trace(self): - ts = TraceState(3) - candidates = [] - for scenario in range(3): - candidates.append(ts.next_candidate()) - ts.confirm_full_scenario(candidates[-1], str(scenario), {}) - ti = TraceInfo.from_trace_state(ts, ModelSpace()) + sg.update_visualisation(TraceInfo([scenario], StateInfo(ModelSpace()))) + + self.assertEqual(len(sg.networkx.nodes), 2) + self.assertEqual(len(sg.networkx.edges), 1) + + self.assertIn('start', sg.networkx.nodes) + self.assertIn('node0', sg.networkx.nodes) + + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') + self.assertEqual(sg.networkx.nodes['node0']['label'], 'test') + + self.assertIn(('start', 'node0'), sg.networkx.edges) + + self.assertEqual(sg.networkx.edges[('start', 'node0')]['label'], '') + + def test_scenario_graph_update_backtrack(self): sg = ScenarioGraph() - sg.update_visualisation(ti) - sg.set_final_trace(ti) + + scenario0 = ScenarioInfo('0') + scenario1 = ScenarioInfo('1') + scenario2 = ScenarioInfo('2') + scenario3 = ScenarioInfo('3') + + self.assertEqual(len(sg.networkx.nodes), 1) + self.assertEqual(len(sg.networkx.edges), 0) + + sg.update_visualisation(TraceInfo([scenario0], StateInfo(ModelSpace()))) + + self.assertEqual(len(sg.networkx.nodes), 2) + self.assertEqual(len(sg.networkx.edges), 1) + + sg.update_visualisation(TraceInfo([scenario0, scenario2], StateInfo(ModelSpace()))) + + self.assertEqual(len(sg.networkx.nodes), 3) + self.assertEqual(len(sg.networkx.edges), 2) + + sg.update_visualisation(TraceInfo([scenario0, scenario2, scenario1], StateInfo(ModelSpace()))) + + self.assertEqual(len(sg.networkx.nodes), 4) + self.assertEqual(len(sg.networkx.edges), 3) + + sg.update_visualisation(TraceInfo([scenario0, scenario2], StateInfo(ModelSpace()))) + + self.assertEqual(len(sg.networkx.nodes), 4) + self.assertEqual(len(sg.networkx.edges), 3) + + sg.update_visualisation(TraceInfo([scenario0], StateInfo(ModelSpace()))) + + self.assertEqual(len(sg.networkx.nodes), 4) + self.assertEqual(len(sg.networkx.edges), 3) + + sg.update_visualisation(TraceInfo([scenario0, scenario3], StateInfo(ModelSpace()))) + + self.assertEqual(len(sg.networkx.nodes), 5) + self.assertEqual(len(sg.networkx.edges), 4) + + sg.update_visualisation(TraceInfo([scenario0, scenario3, scenario1], StateInfo(ModelSpace()))) + + self.assertEqual(len(sg.networkx.nodes), 5) + self.assertEqual(len(sg.networkx.edges), 5) + + sg.update_visualisation(TraceInfo([scenario0, scenario3, scenario1, scenario2], StateInfo(ModelSpace()))) + + self.assertEqual(len(sg.networkx.nodes), 5) + self.assertEqual(len(sg.networkx.edges), 6) + + def test_scenario_graph_final_trace_normal(self): + sg = ScenarioGraph() + + scenario0 = ScenarioInfo('0') + scenario1 = ScenarioInfo('1') + scenario2 = ScenarioInfo('2') + + sg.update_visualisation(TraceInfo([scenario0], StateInfo(ModelSpace()))) + sg.update_visualisation(TraceInfo([scenario0, scenario1], StateInfo(ModelSpace()))) + sg.update_visualisation(TraceInfo([scenario0, scenario1, scenario2], StateInfo(ModelSpace()))) + + sg.set_final_trace(TraceInfo([scenario0, scenario1, scenario2], StateInfo(ModelSpace()))) + + trace = sg.get_final_trace() + + # confirm they are proper ids + for node in trace: + self.assertIn(node, sg.networkx.nodes) + + # confirm the edges exist + for i in range(0, len(trace) - 1): + self.assertIn((trace[i], trace[i + 1]), sg.networkx.edges) + + self.assertEqual(trace, ['start', 'node0', 'node1', 'node2']) + + def test_scenario_graph_final_trace_backtrack(self): + sg = ScenarioGraph() + + scenario0 = ScenarioInfo('0') + scenario1 = ScenarioInfo('1') + scenario2 = ScenarioInfo('2') + + sg.update_visualisation(TraceInfo([scenario0], StateInfo(ModelSpace()))) + sg.update_visualisation(TraceInfo([scenario0, scenario2], StateInfo(ModelSpace()))) + sg.update_visualisation(TraceInfo([scenario0, scenario2, scenario1], StateInfo(ModelSpace()))) + sg.update_visualisation(TraceInfo([scenario0, scenario2], StateInfo(ModelSpace()))) + sg.update_visualisation(TraceInfo([scenario0], StateInfo(ModelSpace()))) + sg.update_visualisation(TraceInfo([scenario0, scenario1], StateInfo(ModelSpace()))) + sg.update_visualisation(TraceInfo([scenario0, scenario1, scenario2], StateInfo(ModelSpace()))) + + sg.set_final_trace(TraceInfo([scenario0, scenario1, scenario2], StateInfo(ModelSpace()))) + trace = sg.get_final_trace() + # confirm they are proper ids for node in trace: self.assertIn(node, sg.networkx.nodes) + # confirm the edges exist for i in range(0, len(trace) - 1): self.assertIn((trace[i], trace[i + 1]), sg.networkx.edges) + self.assertEqual(trace, ['start', 'node0', 'node2', 'node1']) + if __name__ == '__main__': unittest.main() diff --git a/utest/test_visualise_scenariostategraph.py b/utest/test_visualise_scenariostategraph.py index 45ea8154..b56d8620 100644 --- a/utest/test_visualise_scenariostategraph.py +++ b/utest/test_visualise_scenariostategraph.py @@ -1,10 +1,9 @@ import unittest -import networkx as nx -from robotmbt.tracestate import TraceState +from typing import Any try: from robotmbt.visualise.graphs.scenariostategraph import ScenarioStateGraph - from robotmbt.visualise.models import TraceInfo, ScenarioInfo, ModelSpace, StateInfo + from robotmbt.visualise.models import * VISUALISE = True except ImportError: @@ -15,129 +14,408 @@ class TestVisualiseScenarioGraph(unittest.TestCase): def test_scenario_state_graph_init(self): stg = ScenarioStateGraph() - self.assertIn('start', stg.networkx.nodes) - self.assertEqual(stg.networkx.nodes['start']['label'], 'start') self.assertEqual(len(stg.networkx.nodes), 1) self.assertEqual(len(stg.networkx.edges), 0) + self.assertIn('start', stg.networkx.nodes) + self.assertEqual(stg.networkx.nodes['start']['label'], 'start') + def test_scenario_state_graph_ids_empty(self): stg = ScenarioStateGraph() - si = ScenarioInfo('test') - node_id = stg._get_or_create_id(si, StateInfo(ModelSpace())) + scenario = ScenarioInfo('test') + state = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + + node_id = stg._get_or_create_id(scenario, state) self.assertEqual(node_id, 'node0') def test_scenario_state_graph_ids_duplicate_scenario(self): stg = ScenarioStateGraph() - si = ScenarioInfo('test') - sti = StateInfo(ModelSpace()) + s0 = ScenarioInfo('test') + s1 = ScenarioInfo('test') + st0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + st1 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - id0 = stg._get_or_create_id(si, sti) - id1 = stg._get_or_create_id(si, sti) + id0 = stg._get_or_create_id(s0, st0) + id1 = stg._get_or_create_id(s1, st1) self.assertEqual(id0, id1) def test_scenario_state_graph_ids_different_scenarios(self): stg = ScenarioStateGraph() - si0 = ScenarioInfo('test0') - si1 = ScenarioInfo('test1') - sti = StateInfo(ModelSpace()) + s00 = ScenarioInfo('test0') + s01 = ScenarioInfo('test0') + s10 = ScenarioInfo('test1') + s11 = ScenarioInfo('test1') + + state = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + + id00 = stg._get_or_create_id(s00, state) + id01 = stg._get_or_create_id(s01, state) + id10 = stg._get_or_create_id(s10, state) + id11 = stg._get_or_create_id(s11, state) - id0 = stg._get_or_create_id(si0, sti) - id1 = stg._get_or_create_id(si1, sti) + self.assertEqual(id00, 'node0') + self.assertEqual(id01, 'node0') + self.assertEqual(id00, id01) - self.assertEqual(id0, 'node0') - self.assertEqual(id1, 'node1') + self.assertEqual(id10, 'node1') + self.assertEqual(id11, 'node1') + self.assertEqual(id10, id11) + + self.assertNotEqual(id00, id10) + self.assertNotEqual(id00, id11) + self.assertNotEqual(id01, id10) + self.assertNotEqual(id01, id11) def test_scenario_state_graph_ids_different_states(self): stg = ScenarioStateGraph() - si = ScenarioInfo('test0') - sti0 = StateInfo(ModelSpace("state0")) - sti1 = StateInfo(ModelSpace("state1")) + scenario = ScenarioInfo('test') + + s00 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + s01 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + s10 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) + s11 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) + + id00 = stg._get_or_create_id(scenario, s00) + id01 = stg._get_or_create_id(scenario, s01) + id10 = stg._get_or_create_id(scenario, s10) + id11 = stg._get_or_create_id(scenario, s11) + + self.assertEqual(id00, 'node0') + self.assertEqual(id01, 'node0') + self.assertEqual(id00, id01) - id0 = stg._get_or_create_id(si, sti0) - id1 = stg._get_or_create_id(si, sti1) + self.assertEqual(id10, 'node1') + self.assertEqual(id11, 'node1') + self.assertEqual(id10, id11) + + self.assertNotEqual(id00, id10) + self.assertNotEqual(id00, id11) + self.assertNotEqual(id01, id10) + self.assertNotEqual(id01, id11) + + def test_scenario_state_graph_ids_different_scenario_state(self): + stg = ScenarioStateGraph() - self.assertEqual(id0, 'node0') - self.assertEqual(id1, 'node1') + s00 = ScenarioInfo('test0') + s01 = ScenarioInfo('test1') + s10 = ScenarioInfo('test0') + s11 = ScenarioInfo('test1') + + st00 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + st01 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + st10 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) + st11 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) + + id00 = stg._get_or_create_id(s00, st00) + id01 = stg._get_or_create_id(s01, st01) + id10 = stg._get_or_create_id(s10, st10) + id11 = stg._get_or_create_id(s11, st11) + + self.assertEqual(id00, 'node0') + self.assertEqual(id01, 'node1') + self.assertEqual(id10, 'node2') + self.assertEqual(id11, 'node3') + + self.assertNotEqual(id00, id01) + self.assertNotEqual(id00, id10) + self.assertNotEqual(id00, id11) + self.assertNotEqual(id01, id10) + self.assertNotEqual(id01, id11) + self.assertNotEqual(id10, id11) def test_scenario_state_graph_add_new_node(self): stg = ScenarioStateGraph() + self.assertEqual(len(stg.networkx.nodes), 1) + self.assertIn('start', stg.networkx.nodes) self.assertNotIn('test', stg.networkx.nodes) - self.assertEqual(len(stg.networkx.nodes), 1) - stg.ids['test'] = (ScenarioInfo('test'), StateInfo(ModelSpace())) + scenario = ScenarioInfo('test') + state = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + + stg.ids['test'] = (scenario, state) stg._add_node('test') + self.assertEqual(len(stg.networkx.nodes), 2) + self.assertIn('start', stg.networkx.nodes) self.assertIn('test', stg.networkx.nodes) - self.assertEqual(len(stg.networkx.nodes), 2) - self.assertEqual(stg.networkx.nodes['test']['label'], - ScenarioStateGraph._gen_label(ScenarioInfo('test'), StateInfo(ModelSpace()))) + + self.assertEqual(stg.networkx.nodes['start']['label'], 'start') + self.assertIn('test', stg.networkx.nodes['test']['label']) + self.assertIn('prop:', stg.networkx.nodes['test']['label']) + self.assertIn('value=some_value', stg.networkx.nodes['test']['label']) def test_scenario_state_graph_add_existing_node(self): stg = ScenarioStateGraph() - stg._add_node('start') + + self.assertEqual(len(stg.networkx.nodes), 1) self.assertIn('start', stg.networkx.nodes) + self.assertEqual(stg.networkx.nodes['start']['label'], 'start') + + stg._add_node('start') + self.assertEqual(len(stg.networkx.nodes), 1) + self.assertIn('start', stg.networkx.nodes) + self.assertEqual(stg.networkx.nodes['start']['label'], 'start') - def test_scenario_state_graph_update_visualisation_nodes(self): + def test_scenario_state_graph_update_single(self): stg = ScenarioStateGraph() - ts = TraceState(3) - candidates = [] - for scenario in range(3): - candidates.append(ts.next_candidate()) - ts.confirm_full_scenario(candidates[-1], str(scenario), {}) - ti = TraceInfo.from_trace_state(ts, ModelSpace()) - stg.update_visualisation(ti) - - self.assertIn('node0', stg.networkx.nodes) - self.assertIn('node1', stg.networkx.nodes) - self.assertIn('node2', stg.networkx.nodes) - - self.assertEqual(stg.networkx.nodes['node0']['label'], - ScenarioStateGraph._gen_label(ScenarioInfo(str(0)), StateInfo(ModelSpace()))) - self.assertEqual(stg.networkx.nodes['node1']['label'], - ScenarioStateGraph._gen_label(ScenarioInfo(str(1)), StateInfo(ModelSpace()))) - self.assertEqual(stg.networkx.nodes['node2']['label'], - ScenarioStateGraph._gen_label(ScenarioInfo(str(2)), StateInfo(ModelSpace()))) - - def test_scenario_state_graph_update_visualisation_edges(self): + scenario = ScenarioInfo('1') + + space = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + + stg.update_visualisation(TraceInfo([scenario], space)) + + self.assertEqual(len(stg.networkx.nodes), 2) + self.assertEqual(len(stg.networkx.edges), 1) + + self.assertEqual(stg.networkx.nodes['start']['label'], 'start') + + self.assertIn('1', stg.networkx.nodes['node0']['label']) + self.assertIn('prop:', stg.networkx.nodes['node0']['label']) + self.assertIn('value=some_value', stg.networkx.nodes['node0']['label']) + + self.assertIn(('start', 'node0'), stg.networkx.edges) + self.assertEqual(stg.networkx.edges[('start', 'node0')]['label'], '') + + def test_scenario_state_graph_update_multi_loop(self): stg = ScenarioStateGraph() - ts = TraceState(3) - candidates = [] - for scenario in range(3): - candidates.append(ts.next_candidate()) - ts.confirm_full_scenario(candidates[-1], str(scenario), {}) - ti = TraceInfo.from_trace_state(trace=ts, state=ModelSpace()) - stg.update_visualisation(ti) + scenario1 = ScenarioInfo('1') + scenario2 = ScenarioInfo('2') + + space1 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + space2 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) + + ti1 = TraceInfo([scenario1], space1) + ti2 = TraceInfo([scenario1, scenario2], space2) + ti3 = TraceInfo([scenario1, scenario2, scenario1], space1) + + self.assertEqual(len(stg.networkx.nodes), 1) + self.assertEqual(len(stg.networkx.edges), 0) + + stg.update_visualisation(ti1) + + self.assertEqual(len(stg.networkx.nodes), 2) + self.assertEqual(len(stg.networkx.edges), 1) + + stg.update_visualisation(ti2) + + self.assertEqual(len(stg.networkx.nodes), 3) + self.assertEqual(len(stg.networkx.edges), 2) + + stg.update_visualisation(ti3) + + self.assertEqual(len(stg.networkx.nodes), 3) + self.assertEqual(len(stg.networkx.edges), 3) + + self.assertEqual(stg.networkx.nodes['start']['label'], 'start') + + self.assertIn('1', stg.networkx.nodes['node0']['label']) + self.assertIn('2', stg.networkx.nodes['node1']['label']) + self.assertIn('prop:', stg.networkx.nodes['node0']['label']) + self.assertIn('prop:', stg.networkx.nodes['node1']['label']) + self.assertIn('value=some_value', stg.networkx.nodes['node0']['label']) + self.assertIn('value=another_value', stg.networkx.nodes['node1']['label']) + self.assertIn(('start', 'node0'), stg.networkx.edges) self.assertIn(('node0', 'node1'), stg.networkx.edges) - self.assertIn(('node1', 'node2'), stg.networkx.edges) + self.assertIn(('node1', 'node0'), stg.networkx.edges) - edge_labels = nx.get_edge_attributes(stg.networkx, "label") - self.assertEqual(edge_labels[('node0', 'node1')], '') - self.assertEqual(edge_labels[('node1', 'node2')], '') + self.assertEqual(stg.networkx.edges[('start', 'node0')]['label'], '') + self.assertEqual(stg.networkx.edges[('node0', 'node1')]['label'], '') + self.assertEqual(stg.networkx.edges[('node1', 'node0')]['label'], '') - def test_scenario_state_graph_update_visualisation_single_node(self): + def test_scenario_state_graph_update_self_loop(self): stg = ScenarioStateGraph() - ti = TraceInfo([ScenarioInfo('one')], ModelSpace()) - stg.update_visualisation(ti) + scenario = ScenarioInfo('1') + + space = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + + ti1 = TraceInfo([scenario], space) + ti2 = TraceInfo([scenario, scenario], space) + + self.assertEqual(len(stg.networkx.nodes), 1) + self.assertEqual(len(stg.networkx.edges), 0) + + stg.update_visualisation(ti1) - # expected behaviour: only start and added node and their edge self.assertEqual(len(stg.networkx.nodes), 2) self.assertEqual(len(stg.networkx.edges), 1) - # TODO: improve existing tests and add tests for set_final_trace/get_final_trace, _gen_label + stg.update_visualisation(ti2) + + self.assertEqual(len(stg.networkx.nodes), 2) + self.assertEqual(len(stg.networkx.edges), 2) + + self.assertEqual(stg.networkx.nodes['start']['label'], 'start') + + self.assertIn('1', stg.networkx.nodes['node0']['label']) + self.assertIn('prop:', stg.networkx.nodes['node0']['label']) + self.assertIn('value=some_value', stg.networkx.nodes['node0']['label']) + + self.assertIn(('start', 'node0'), stg.networkx.edges) + self.assertIn(('node0', 'node0'), stg.networkx.edges) + + self.assertEqual(stg.networkx.edges[('start', 'node0')]['label'], '') + self.assertEqual(stg.networkx.edges[('node0', 'node0')]['label'], '') + + def test_scenario_state_graph_update_backtrack(self): + sg = ScenarioStateGraph() + + scenario0 = ScenarioInfo('0') + scenario1 = ScenarioInfo('1') + scenario2 = ScenarioInfo('2') + + space0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + space1 = StateInfo._create_state_with_prop("prop", [("value", "other_value")]) + space2 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) + space3 = StateInfo._create_state_with_prop("prop", [("value", "more_value")]) + space4 = StateInfo._create_state_with_prop("prop", [("value", "yet_another_value")]) + + self.assertEqual(len(sg.networkx.nodes), 1) + self.assertEqual(len(sg.networkx.edges), 0) + + sg.update_visualisation(TraceInfo([scenario0], space0)) + + self.assertEqual(len(sg.networkx.nodes), 2) + self.assertEqual(len(sg.networkx.edges), 1) + + sg.update_visualisation(TraceInfo([scenario0, scenario1], space1)) + + self.assertEqual(len(sg.networkx.nodes), 3) + self.assertEqual(len(sg.networkx.edges), 2) + + sg.update_visualisation(TraceInfo([scenario0, scenario1, scenario2], space2)) + + self.assertEqual(len(sg.networkx.nodes), 4) + self.assertEqual(len(sg.networkx.edges), 3) + + sg.update_visualisation(TraceInfo([scenario0, scenario1], space1)) + + self.assertEqual(len(sg.networkx.nodes), 4) + self.assertEqual(len(sg.networkx.edges), 3) + + sg.update_visualisation(TraceInfo([scenario0], space0)) + + self.assertEqual(len(sg.networkx.nodes), 4) + self.assertEqual(len(sg.networkx.edges), 3) + + sg.update_visualisation(TraceInfo([scenario0, scenario2], space3)) + + self.assertEqual(len(sg.networkx.nodes), 5) + self.assertEqual(len(sg.networkx.edges), 4) + + sg.update_visualisation(TraceInfo([scenario0, scenario2, scenario1], space4)) + + self.assertEqual(len(sg.networkx.nodes), 6) + self.assertEqual(len(sg.networkx.edges), 5) + + def test_scenario_state_graph_final_trace_normal(self): + sg = ScenarioStateGraph() + + scenario0 = ScenarioInfo('0') + scenario1 = ScenarioInfo('1') + scenario2 = ScenarioInfo('2') + + space0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + space1 = StateInfo._create_state_with_prop("prop", [("value", "other_value")]) + space2 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) + + sg.update_visualisation(TraceInfo([scenario0], space0)) + sg.update_visualisation(TraceInfo([scenario0, scenario1], space1)) + sg.update_visualisation(TraceInfo([scenario0, scenario1, scenario2], space2)) + + sg.set_final_trace(TraceInfo([scenario0, scenario1, scenario2], space2)) + + trace = sg.get_final_trace() + + for node in trace: + self.assertIn(node, sg.networkx.nodes) + + for i in range(0, len(trace) - 1): + self.assertIn((trace[i], trace[i + 1]), sg.networkx.edges) + + self.assertEqual(trace, ['start', 'node0', 'node1', 'node2']) + + def test_scenario_state_graph_final_trace_backtrack(self): + sg = ScenarioStateGraph() + + scenario0 = ScenarioInfo('0') + scenario1 = ScenarioInfo('1') + scenario2 = ScenarioInfo('2') + + space0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + space1 = StateInfo._create_state_with_prop("prop", [("value", "other_value")]) + space2 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) + space3 = StateInfo._create_state_with_prop("prop", [("value", "more_value")]) + space4 = StateInfo._create_state_with_prop("prop", [("value", "yet_another_value")]) + + sg.update_visualisation(TraceInfo([scenario0], space0)) + sg.update_visualisation(TraceInfo([scenario0, scenario1], space1)) + sg.update_visualisation(TraceInfo([scenario0, scenario1, scenario2], space2)) + sg.update_visualisation(TraceInfo([scenario0, scenario1], space1)) + sg.update_visualisation(TraceInfo([scenario0], space0)) + sg.update_visualisation(TraceInfo([scenario0, scenario2], space3)) + sg.update_visualisation(TraceInfo([scenario0, scenario2, scenario1], space4)) + + sg.set_final_trace(TraceInfo([scenario0, scenario2, scenario1], space4)) + + trace = sg.get_final_trace() + + for node in trace: + self.assertIn(node, sg.networkx.nodes) + + for i in range(0, len(trace) - 1): + self.assertIn((trace[i], trace[i + 1]), sg.networkx.edges) + + self.assertEqual(trace, ['start', 'node0', 'node3', 'node4']) + + def test_scenario_state_graph_gen_label(self): + sg = ScenarioStateGraph() + + scenario0 = ScenarioInfo('0') + scenario1 = ScenarioInfo('1') + + space0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + space1 = StateInfo._create_state_with_prop("prop", [("value", "other_value")]) + + label00 = sg._gen_label(scenario0, space0) + label01 = sg._gen_label(scenario0, space1) + label10 = sg._gen_label(scenario1, space0) + label11 = sg._gen_label(scenario1, space1) + + self.assertNotEqual(label00, label01) + self.assertNotEqual(label00, label10) + self.assertNotEqual(label00, label11) + self.assertNotEqual(label01, label10) + self.assertNotEqual(label01, label11) + self.assertNotEqual(label10, label11) + + self.assertIn('0', label00) + self.assertIn('0', label01) + self.assertIn('1', label10) + self.assertIn('1', label11) + + self.assertIn('prop:', label00) + self.assertIn('prop:', label01) + self.assertIn('prop:', label10) + self.assertIn('prop:', label11) + + self.assertIn('value=some_value', label00) + self.assertIn('value=other_value', label01) + self.assertIn('value=some_value', label10) + self.assertIn('value=other_value', label11) if __name__ == '__main__': unittest.main() diff --git a/utest/test_visualise_stategraph.py b/utest/test_visualise_stategraph.py index c9d30a4e..08bb6e98 100644 --- a/utest/test_visualise_stategraph.py +++ b/utest/test_visualise_stategraph.py @@ -1,8 +1,9 @@ import unittest -import networkx as nx + try: from robotmbt.visualise.graphs.stategraph import StateGraph from robotmbt.visualise.models import * + VISUALISE = True except ImportError: VISUALISE = False @@ -11,9 +12,324 @@ class TestVisualiseStateGraph(unittest.TestCase): def test_state_graph_init(self): sg = StateGraph() + + self.assertEqual(len(sg.networkx.nodes), 1) + self.assertEqual(len(sg.networkx.edges), 0) + + self.assertIn('start', sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') + + def test_state_graph_ids_empty(self): + sg = StateGraph() + + si = StateInfo(ModelSpace()) + + node_id = sg._get_or_create_id(si) + + self.assertEqual(node_id, 'node0') + + def test_state_graph_ids_duplicate_state(self): + sg = StateGraph() + + s0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + s1 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + + id0 = sg._get_or_create_id(s0) + id1 = sg._get_or_create_id(s1) + + self.assertEqual(id0, id1) + + def test_state_graph_ids_different_states(self): + sg = StateGraph() + + s00 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + s01 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + s10 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) + s11 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) + + id00 = sg._get_or_create_id(s00) + id01 = sg._get_or_create_id(s01) + id10 = sg._get_or_create_id(s10) + id11 = sg._get_or_create_id(s11) + + self.assertEqual(id00, 'node0') + self.assertEqual(id01, 'node0') + self.assertEqual(id00, id01) + + self.assertEqual(id10, 'node1') + self.assertEqual(id11, 'node1') + self.assertEqual(id10, id11) + + self.assertNotEqual(id00, id10) + self.assertNotEqual(id00, id11) + self.assertNotEqual(id01, id10) + self.assertNotEqual(id01, id11) + + def test_state_graph_add_new_node(self): + sg = StateGraph() + + self.assertEqual(len(sg.networkx.nodes), 1) + + self.assertIn('start', sg.networkx.nodes) + self.assertNotIn('test', sg.networkx.nodes) + + sg.ids['test'] = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + sg._add_node('test') + + self.assertEqual(len(sg.networkx.nodes), 2) + self.assertIn('start', sg.networkx.nodes) + self.assertIn('test', sg.networkx.nodes) + + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') + self.assertIn('prop:', sg.networkx.nodes['test']['label']) + self.assertIn('value=some_value', sg.networkx.nodes['test']['label']) + + def test_state_graph_add_existing_node(self): + sg = StateGraph() + + self.assertEqual(len(sg.networkx.nodes), 1) + self.assertIn('start', sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') + + sg._add_node('start') + + self.assertEqual(len(sg.networkx.nodes), 1) + self.assertIn('start', sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') + + def test_state_graph_update_single(self): + sg = StateGraph() + + scenario = ScenarioInfo('1') + space = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + sg.update_visualisation(TraceInfo([scenario], space)) + + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') + + self.assertIn('prop:', sg.networkx.nodes['node0']['label']) + self.assertIn('value=some_value', sg.networkx.nodes['node0']['label']) + + self.assertIn(('start', 'node0'), sg.networkx.edges) + self.assertEqual(sg.networkx.edges[('start', 'node0')]['label'], '1') + + def test_state_graph_update_multi(self): + sg = StateGraph() + + scenario1 = ScenarioInfo('1') + scenario2 = ScenarioInfo('2') + scenario3 = ScenarioInfo('3') + + space1 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + space2 = StateInfo._create_state_with_prop("prop", [("value", "other_value")]) + space3 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) + + ti1 = TraceInfo([scenario1], space1) + ti2 = TraceInfo([scenario1, scenario2], space2) + ti3 = TraceInfo([scenario1, scenario2, scenario3], space3) + + sg.update_visualisation(ti1) + sg.update_visualisation(ti2) + sg.update_visualisation(ti3) + + self.assertEqual(len(sg.networkx.nodes), 4) + self.assertEqual(len(sg.networkx.edges), 3) + + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') + self.assertIn('prop:', sg.networkx.nodes['node0']['label']) + self.assertIn('prop:', sg.networkx.nodes['node1']['label']) + self.assertIn('prop:', sg.networkx.nodes['node2']['label']) + self.assertIn('value=some_value', sg.networkx.nodes['node0']['label']) + self.assertIn('value=other_value', sg.networkx.nodes['node1']['label']) + self.assertIn('value=another_value', sg.networkx.nodes['node2']['label']) + + self.assertIn(('start', 'node0'), sg.networkx.edges) + self.assertIn(('node0', 'node1'), sg.networkx.edges) + self.assertIn(('node1', 'node2'), sg.networkx.edges) + + self.assertEqual(sg.networkx.edges[('start', 'node0')]['label'], '1') + self.assertEqual(sg.networkx.edges[('node0', 'node1')]['label'], '2') + self.assertEqual(sg.networkx.edges[('node1', 'node2')]['label'], '3') + + def test_state_graph_update_multi_loop(self): + sg = StateGraph() + + scenario0 = ScenarioInfo('0') + scenario1 = ScenarioInfo('1') + scenario2 = ScenarioInfo('2') + + space0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + space1 = StateInfo._create_state_with_prop("prop", [("value", "other_value")]) + + ti1 = TraceInfo([scenario0], space0) + ti2 = TraceInfo([scenario0, scenario1], space1) + ti3 = TraceInfo([scenario0, scenario1, scenario2], space0) + + sg.update_visualisation(ti1) + sg.update_visualisation(ti2) + sg.update_visualisation(ti3) + + self.assertEqual(len(sg.networkx.nodes), 3) + self.assertEqual(len(sg.networkx.edges), 3) + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') + self.assertIn('prop:', sg.networkx.nodes['node0']['label']) + self.assertIn('prop:', sg.networkx.nodes['node1']['label']) + self.assertIn('value=some_value', sg.networkx.nodes['node0']['label']) + self.assertIn('value=other_value', sg.networkx.nodes['node1']['label']) + + self.assertIn(('start', 'node0'), sg.networkx.edges) + self.assertIn(('node0', 'node1'), sg.networkx.edges) + self.assertIn(('node1', 'node0'), sg.networkx.edges) + + self.assertEqual(sg.networkx.edges[('start', 'node0')]['label'], '0') + self.assertEqual(sg.networkx.edges[('node0', 'node1')]['label'], '1') + self.assertEqual(sg.networkx.edges[('node1', 'node0')]['label'], '2') + + def test_state_graph_update_self_loop(self): + sg = StateGraph() + + scenario1 = ScenarioInfo('1') + scenario2 = ScenarioInfo('2') + + space = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + + ti1 = TraceInfo([scenario1], space) + ti2 = TraceInfo([scenario1, scenario2], space) + + self.assertEqual(len(sg.networkx.nodes), 1) + self.assertEqual(len(sg.networkx.edges), 0) + + sg.update_visualisation(ti1) + + self.assertEqual(len(sg.networkx.nodes), 2) + self.assertEqual(len(sg.networkx.edges), 1) + + sg.update_visualisation(ti2) + + self.assertEqual(len(sg.networkx.nodes), 2) + self.assertEqual(len(sg.networkx.edges), 2) + + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') + self.assertIn('prop:', sg.networkx.nodes['node0']['label']) + self.assertIn('value=some_value', sg.networkx.nodes['node0']['label']) + + self.assertIn(('start', 'node0'), sg.networkx.edges) + self.assertIn(('node0', 'node0'), sg.networkx.edges) + + self.assertEqual(sg.networkx.edges[('start', 'node0')]['label'], '1') + self.assertEqual(sg.networkx.edges[('node0', 'node0')]['label'], '2') + + def test_state_graph_update_backtrack(self): + sg = StateGraph() + + scenario0 = ScenarioInfo('0') + scenario1 = ScenarioInfo('1') + scenario2 = ScenarioInfo('2') + + space0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + space1 = StateInfo._create_state_with_prop("prop", [("value", "other_value")]) + space2 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) + space3 = StateInfo._create_state_with_prop("prop", [("value", "more_value")]) + space4 = StateInfo._create_state_with_prop("prop", [("value", "yet_another_value")]) + + self.assertEqual(len(sg.networkx.nodes), 1) + self.assertEqual(len(sg.networkx.edges), 0) + + sg.update_visualisation(TraceInfo([scenario0], space0)) + + self.assertEqual(len(sg.networkx.nodes), 2) + self.assertEqual(len(sg.networkx.edges), 1) + + sg.update_visualisation(TraceInfo([scenario0, scenario1], space1)) + + self.assertEqual(len(sg.networkx.nodes), 3) + self.assertEqual(len(sg.networkx.edges), 2) + + sg.update_visualisation(TraceInfo([scenario0, scenario1, scenario2], space2)) + + self.assertEqual(len(sg.networkx.nodes), 4) + self.assertEqual(len(sg.networkx.edges), 3) + + sg.update_visualisation(TraceInfo([scenario0, scenario1], space1)) + + self.assertEqual(len(sg.networkx.nodes), 4) + self.assertEqual(len(sg.networkx.edges), 3) + + sg.update_visualisation(TraceInfo([scenario0], space0)) + + self.assertEqual(len(sg.networkx.nodes), 4) + self.assertEqual(len(sg.networkx.edges), 3) + + sg.update_visualisation(TraceInfo([scenario0, scenario2], space3)) + + self.assertEqual(len(sg.networkx.nodes), 5) + self.assertEqual(len(sg.networkx.edges), 4) + + sg.update_visualisation(TraceInfo([scenario0, scenario2, scenario1], space4)) + + self.assertEqual(len(sg.networkx.nodes), 6) + self.assertEqual(len(sg.networkx.edges), 5) + + def test_state_graph_final_trace_normal(self): + sg = StateGraph() + + scenario0 = ScenarioInfo('0') + scenario1 = ScenarioInfo('1') + scenario2 = ScenarioInfo('2') + + space0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + space1 = StateInfo._create_state_with_prop("prop", [("value", "other_value")]) + space2 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) + + sg.update_visualisation(TraceInfo([scenario0], space0)) + sg.update_visualisation(TraceInfo([scenario0, scenario1], space1)) + sg.update_visualisation(TraceInfo([scenario0, scenario1, scenario2], space2)) + + sg.set_final_trace(TraceInfo([scenario0, scenario1, scenario2], space2)) + + trace = sg.get_final_trace() + + for node in trace: + self.assertIn(node, sg.networkx.nodes) + + for i in range(0, len(trace) - 1): + self.assertIn((trace[i], trace[i + 1]), sg.networkx.edges) + + self.assertEqual(trace, ['start', 'node0', 'node1', 'node2']) + + def test_state_graph_final_trace_backtrack(self): + sg = StateGraph() + + scenario0 = ScenarioInfo('0') + scenario1 = ScenarioInfo('1') + scenario2 = ScenarioInfo('2') + + space0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + space1 = StateInfo._create_state_with_prop("prop", [("value", "other_value")]) + space2 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) + space3 = StateInfo._create_state_with_prop("prop", [("value", "more_value")]) + space4 = StateInfo._create_state_with_prop("prop", [("value", "yet_another_value")]) + + sg.update_visualisation(TraceInfo([scenario0], space0)) + sg.update_visualisation(TraceInfo([scenario0, scenario1], space1)) + sg.update_visualisation(TraceInfo([scenario0, scenario1, scenario2], space2)) + sg.update_visualisation(TraceInfo([scenario0, scenario1], space1)) + sg.update_visualisation(TraceInfo([scenario0], space0)) + sg.update_visualisation(TraceInfo([scenario0, scenario2], space3)) + sg.update_visualisation(TraceInfo([scenario0, scenario2, scenario1], space4)) + + sg.set_final_trace(TraceInfo([scenario0, scenario2, scenario1], space4)) + + trace = sg.get_final_trace() + + for node in trace: + self.assertIn(node, sg.networkx.nodes) + + for i in range(0, len(trace) - 1): + self.assertIn((trace[i], trace[i + 1]), sg.networkx.edges) + self.assertEqual(trace, ['start', 'node0', 'node3', 'node4']) if __name__ == '__main__': unittest.main() From c8b3e841aee121ac27a999bf6e37767553cdf84e Mon Sep 17 00:00:00 2001 From: Thomas Kas Date: Tue, 2 Dec 2025 20:18:18 +0100 Subject: [PATCH 078/131] Sync fork (#30) * handle optional argument's default values * optional arguments stay hidden when not mentioned * fix argument text for POSITIONAL_OR_NAMED cases Now keeps the notation as used in the scenario (with or without the argument's name) in the generated keyword arguments. * fix failure on empty varargs * workaround for embedded argument name mismatch Accept that names of embedded arguments in the @keyword decorator must match their Python argument counterparts. * support step modifiers for non-embedded arguments * handling vararg modifiers * add tests for step modifier with free named arguments * add error message for vararg and free named arg modifiers * docu update for step modifiers on non-embedded arguments * promote random seed logging to info level Logging the seed at info level allows to run without debug logging for smaller log files, without losing the ability to rerun an interesting trace. * promote random seed logging to info level * make tests independent * Remove embedded-arguments-only restriction for modifiers * bump version to 0.10 * Implement Feature Path Highlighting in Scenario and State Graphs * Implement State Graph Path highlighting with Stack * Clean-up of unused features, move some logic, fix empty state entry bug * Formatting * Formatting * Remove unused return value * Fix bug * StateInfo unit tests * StateGraph unit tests * Merge with main, fix some bugs, alter ScenarioStateGraph to be in line with the rest and fix up some of its unit tests * Add short explanation * Test self-loops * Minor tweaks * Remove trailing newline * Fix nuking of stack * Better formatting of state info * Spam __update_visualisation all over the place for refinement * Fix nuking in scenario-state graph * Warn instead of crashing * Sanity check * Do add edge with start node * Expand tests * Merge mistake * Fix oopsie * Format and fix type * Move helper to StateInfo * Protected helper * Forgot this one * Fix nuked graphs * Implement feedback --------- Co-authored-by: JFoederer <32476108+JFoederer@users.noreply.github.com> Co-authored-by: Diogo Silva --- .../01__generating_random_traces/traces.py | 4 +- robotmbt/steparguments.py | 14 +- robotmbt/suitedata.py | 96 +++++---- robotmbt/suiteprocessors.py | 183 ++++++++---------- robotmbt/tracestate.py | 2 +- robotmbt/version.py | 2 +- utest/test_steparguments.py | 25 ++- utest/test_suitedata.py | 88 ++++----- 8 files changed, 199 insertions(+), 215 deletions(-) diff --git a/atest/robotMBT tests/07__processor_options/random_seeds/01__generating_random_traces/traces.py b/atest/robotMBT tests/07__processor_options/random_seeds/01__generating_random_traces/traces.py index 8736b866..e9322442 100644 --- a/atest/robotMBT tests/07__processor_options/random_seeds/01__generating_random_traces/traces.py +++ b/atest/robotMBT tests/07__processor_options/random_seeds/01__generating_random_traces/traces.py @@ -1,14 +1,12 @@ from robot.api.deco import keyword - class traces: ROBOT_LIBRARY_SCOPE = 'GLOBAL' - def reset_traces(self): self.traces = {} @keyword("Trace '${trace}', scenario number ${test_id} is executed") - def add_test(self, trace, test_id: str): + def add_test(self, trace, test_id:str): """*model info* :IN: None :OUT: None diff --git a/robotmbt/steparguments.py b/robotmbt/steparguments.py index 503481fc..42489581 100644 --- a/robotmbt/steparguments.py +++ b/robotmbt/steparguments.py @@ -57,19 +57,23 @@ def modified(self) -> bool: class StepArgument: + # kind list EMBEDDED = 'EMBEDDED' POSITIONAL = 'POSITIONAL' VAR_POS = 'VAR_POS' NAMED = 'NAMED' FREE_NAMED = 'FREE_NAMED' - def __init__(self, arg_name: str, value: any, kind: str | None = None): + def __init__(self, arg_name: str, value: any, kind: str | None = None, is_default: bool = False): self.name: str = arg_name self.org_value: any = value - self.kind: str | None = kind + self.kind: str | None = kind # one of the values from the kind list self._value: any = None self._codestr: str | None = None self.value: any = value + self.is_default: bool = is_default # indicates that the argument was not + # filled in from the scenario. This argment's value is taken + # from the keyword's default as provided by Robot. @property def arg(self) -> str: @@ -83,6 +87,7 @@ def value(self) -> any: def value(self, value: any): self._value = value self._codestr = self.make_codestring(value) + self.is_default = False @property def modified(self) -> bool: @@ -94,10 +99,13 @@ def codestring(self) -> str | None: def copy(self): # -> Self - cp = StepArgument(self.arg.strip('${}'), self.value, self.kind) + cp = StepArgument(self.arg.strip('${}'), self.value, self.kind, self.is_default) cp.org_value = self.org_value return cp + def __str__(self): + return f"{self.name}={self.value}" + @staticmethod def make_codestring(text: any) -> str: codestr = str(text) diff --git a/robotmbt/suitedata.py b/robotmbt/suitedata.py index 2684ad41..bfa41c74 100644 --- a/robotmbt/suitedata.py +++ b/robotmbt/suitedata.py @@ -33,6 +33,7 @@ import copy from robot.running.arguments.argumentvalidator import ArgumentValidator +import robot.utils.notset from .steparguments import StepArgument, StepArguments from .substitutionmap import SubstitutionMap @@ -71,13 +72,17 @@ def steps_with_errors(self): class Scenario: def __init__(self, name: str, parent=None): self.name: str = name + # Parent scenario for easy searching, processing and referencing - self.parent: Suite | None = parent # after steps and scenarios have been potentially moved around + self.parent: Suite | None = parent + # Can be a single step or None, may also be a str in tests self.setup: Step | None = None + # Can be a single step or None, may also be a str in tests self.teardown: Step | None = None + self.steps: list[Step] = [] self.src_id: int | None = None self.data_choices: dict | SubstitutionMap = {} # may be Dummy type in a test @@ -110,8 +115,7 @@ def split_at_step(self, stepindex: int): With stepindex 0 the first part has no steps and all steps are in the last part. With stepindex 1 the first step is in the first part, the other in the last part, and so on. """ - assert stepindex <= len( - self.steps), "Split index out of range. Not enough steps in scenario." + assert stepindex <= len(self.steps), "Split index out of range. Not enough steps in scenario." front = self.copy() front.teardown = None front.steps = self.steps[:stepindex] @@ -125,43 +129,35 @@ class Step: def __init__(self, steptext: str, *args, parent: Suite | Scenario, assign: tuple[str] = (), prev_gherkin_kw: str | None = None): # first keyword cell of the Robot line, including step_kw, - self.org_step: str = steptext - # excluding positional args, excluding variable assignment. + self.org_step: str = steptext # positional and named arguments as parsed from Robot text ('posA' , 'posB', 'named1=namedA') self.org_pn_args = args - # Parent scenario for easy searching and processing. self.parent: Suite | Scenario = parent - # For when a keyword's return value is assigned to a variable. - self.assign: tuple[str] = assign - # Taken directly from Robot. + self.assign: tuple[str] = assign + # 'given', 'when', 'then' or None for non-bdd keywords. self.gherkin_kw: str | None = self.step_kw \ if str(self.step_kw).lower() in ['given', 'when', 'then', 'none'] \ else prev_gherkin_kw - - # 'given', 'when', 'then' or None for non-bdd keywords. # Robot keyword with its embedded arguments in ${...} notation. self.signature: str | None = None - # embedded arguments list of StepArgument objects. self.args: StepArguments = StepArguments() - # Decouples StepArguments from the step text (refinement use case) self.detached: bool = False - # Modelling information is available as a dictionary. # TODO: Maybe use a data structure for this instead of a dict with specific keys. - self.model_info: dict[str, str | list[str]] = dict() - # The standard format of `model_info` is dict(IN=[], OUT=[]) and can + # The standard format is dict(IN=[], OUT=[]) and can # optionally contain an error field. # IN and OUT are lists of Python evaluatable expressions. # The `new vocab` form can be used to create new domain objects. # The `vocab.attribute` form can then be used to express relations # between properties from the domain vocabulaire. # Custom processors can define their own attributes. + self.model_info: dict[str, str | list[str]] = dict() def __str__(self): return self.keyword @@ -171,8 +167,7 @@ def __repr__(self): def copy(self): # -> Self - cp = Step(self.org_step, *self.org_pn_args, - parent=self.parent, assign=self.assign) + cp = Step(self.org_step, *self.org_pn_args, parent=self.parent, assign=self.assign) cp.gherkin_kw = self.gherkin_kw cp.signature = self.signature cp.args = StepArguments(self.args) @@ -205,6 +200,8 @@ def posnom_args_str(self) -> tuple[any]: return self.org_pn_args result: list[any] = [] for arg in self.args: + if arg.is_default: + continue if arg.kind == arg.POSITIONAL: result.append(arg.value) elif arg.kind == arg.VAR_POS: @@ -238,9 +235,7 @@ def kw_wo_gherkin(self) -> str: return self.keyword.replace(self.step_kw, '', 1).strip() if self.step_kw else self.keyword def add_robot_dependent_data(self, robot_kw): - """ - robot_kw must be Robot Framework's keyword object from Robot's runner context - """ + """robot_kw must be Robot Framework's keyword object from Robot's runner context""" try: if robot_kw.error: raise ValueError(robot_kw.error) @@ -257,39 +252,57 @@ def add_robot_dependent_data(self, robot_kw): def __handle_non_embedded_arguments(self, robot_argspec) -> list[StepArgument]: result = [] + p_args = [a for a in self.org_pn_args if '=' not in a or r'\=' in a] + n_args = [a.split('=', 1) for a in self.org_pn_args if '=' in a and r'\=' not in a] + self.__validate_arguments(robot_argspec, p_args, n_args) - p_args, n_args = robot_argspec.map([a for a in self.org_pn_args if '=' not in a or r'\=' in a], - [a.split('=', 1) for a in self.org_pn_args if '=' in a and r'\=' not in a]) - - # for some reason .map() returns [None] instead of the empty list when there are no arguments - if p_args == [None]: - p_args = [] - - ArgumentValidator(robot_argspec).validate(p_args, n_args) robot_args = [a for a in robot_argspec] - argument_names = list(robot_argspec.argument_names) + argument_names = [a for a in robot_argspec.argument_names if a not in robot_argspec.embedded] for arg in robot_argspec: - if arg.kind != arg.POSITIONAL_ONLY and arg.kind != arg.POSITIONAL_OR_NAMED: + if not p_args or (arg.kind != arg.POSITIONAL_ONLY and arg.kind != arg.POSITIONAL_OR_NAMED): break - result += [StepArgument(argument_names.pop(0), - p_args.pop(0), kind=StepArgument.POSITIONAL)] + result.append(StepArgument(argument_names.pop(0), p_args.pop(0), kind=StepArgument.POSITIONAL)) robot_args.pop(0) - if not p_args: - break if p_args and robot_args[0].kind == robot_args[0].VAR_POSITIONAL: - result += [StepArgument(argument_names.pop(0), - p_args, kind=StepArgument.VAR_POS)] + result.append(StepArgument(argument_names.pop(0), p_args, kind=StepArgument.VAR_POS)) free = {} for name, value in n_args: if name in argument_names: - result += [StepArgument(name, value, kind=StepArgument.NAMED)] + result.append(StepArgument(name, value, kind=StepArgument.NAMED)) + argument_names.remove(name) else: free[name] = value if free: - result += [StepArgument(argument_names[-1], - free, kind=StepArgument.FREE_NAMED)] + result.append(StepArgument(argument_names.pop(-1), free, kind=StepArgument.FREE_NAMED)) + for unmentioned_arg in argument_names: + arg = next(arg for arg in robot_args if arg.name == unmentioned_arg) + default_value = arg.default + if default_value is robot.utils.notset.NOT_SET: + if arg.kind == arg.VAR_POSITIONAL: + default_value = [] + elif arg.kind == arg.VAR_NAMED: + default_value = {} + else: + # This can happen when using library keywords that specify embedded arguments in the @keyword decorator + # but use different names in the method signature. Robot Framework implementation is incomplete for this + # aspect and differs between library and user keywords. + assert False, f"No default argument expected to be needed for '{unmentioned_arg}' here" + result.append(StepArgument(unmentioned_arg, default_value, kind=StepArgument.NAMED, is_default=True)) return result + @staticmethod + def __validate_arguments(spec, positionals, nameds): + # Robot uses a slightly different mapping for positional and named arguments. + # We keep the notation from the scenario (with or without the argument's name). + # Robot's mapping favours positional when possible, even when the name is used + # in the keyword call. The validator is sensitive to these differences. + p, n = spec.map(positionals, nameds) + # for some reason .map() returns [None] instead of the empty list when there are no arguments + if p == [None]: + p = [] + # Use the Robot mechanism for validation to yield familiar error messages + ArgumentValidator(spec).validate(p, n) + def __parse_model_info(self, docu: str) -> dict[str, list[str]]: model_info = dict() mi_index = docu.find("*model info*") @@ -311,8 +324,7 @@ def __parse_model_info(self, docu: str) -> dict[str, list[str]]: key = elms[1].strip() expressions = [e.strip() for e in elms[-1].split("|") if e] while lines and not lines[0].startswith(":"): - expressions.extend([e.strip() - for e in lines.pop(0).split("|") if e]) + expressions.extend([e.strip() for e in lines.pop(0).split("|") if e]) model_info[key] = expressions if not model_info: raise ValueError("When present, *model info* cannot be empty") diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index 208fb8fe..2b4aa3c3 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -115,22 +115,16 @@ def process_test_suite(self, in_suite: Suite, *, seed: any = 'new', graph: str = self._try_to_reach_full_coverage(allow_duplicate_scenarios=False) if not self.tracestate.coverage_reached(): - logger.debug( - "Direct trace not available. Allowing repetition of scenarios") + logger.debug("Direct trace not available. Allowing repetition of scenarios") self._try_to_reach_full_coverage(allow_duplicate_scenarios=True) if not self.tracestate.coverage_reached(): - if self.visualiser is not None: - logger.write( - self.visualiser.generate_visualisation(), html=True) + self.__write_visualisation() raise Exception("Unable to compose a consistent suite") self.out_suite.scenarios = self.tracestate.get_trace() self._report_tracestate_wrapup() - if self.visualiser is not None: - self.visualiser.set_final_trace( - TraceInfo.from_trace_state(self.tracestate, self.active_model)) - logger.write(self.visualiser.generate_visualisation(), html=True) + self.__write_visualisation() return self.out_suite @@ -138,9 +132,7 @@ def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool): self.tracestate = TraceState(len(self.scenarios)) self.active_model = ModelSpace() while not self.tracestate.coverage_reached(): - i_candidate = self.tracestate.next_candidate( - retry=allow_duplicate_scenarios) - + i_candidate = self.tracestate.next_candidate(retry=allow_duplicate_scenarios) if i_candidate is None: if not self.tracestate.can_rewind(): break @@ -159,8 +151,7 @@ def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool): if inserted: self.DROUGHT_LIMIT = 50 if self.__last_candidate_changed_nothing(): - logger.debug( - "Repeated scenario did not change the model's state. Stop trying.") + logger.debug("Repeated scenario did not change the model's state. Stop trying.") self._rewind() elif self.tracestate.coverage_drought > self.DROUGHT_LIMIT: @@ -177,6 +168,10 @@ def __update_visualisation(self): self.visualiser.update_visualisation( TraceInfo.from_trace_state(self.tracestate, self.active_model)) + def __write_visualisation(self): + if self.visualiser is not None: + logger.info(self.visualiser.generate_visualisation(), html=True) + def __last_candidate_changed_nothing(self) -> bool: if len(self.tracestate) < 2: return False @@ -208,8 +203,7 @@ def _fail_on_step_errors(suite: Suite): raise Exception(err_msg) def _try_to_fit_in_scenario(self, index: int, candidate: Scenario, retry_flag: bool) -> bool: - candidate = self._generate_scenario_variant( - candidate, self.active_model) + candidate = self._generate_scenario_variant(candidate, self.active_model) if not candidate: self.active_model.end_scenario_scope() @@ -217,24 +211,19 @@ def _try_to_fit_in_scenario(self, index: int, candidate: Scenario, retry_flag: b self._report_tracestate_to_user() return False - confirmed_candidate, new_model = self._process_scenario( - candidate, self.active_model) + confirmed_candidate, new_model = self._process_scenario(candidate, self.active_model) if confirmed_candidate: self.active_model = new_model self.active_model.end_scenario_scope() - self.tracestate.confirm_full_scenario( - index, confirmed_candidate, self.active_model) - logger.debug( - f"Inserted scenario {confirmed_candidate.src_id}, {confirmed_candidate.name}") + self.tracestate.confirm_full_scenario(index, confirmed_candidate, self.active_model) + logger.debug(f"Inserted scenario {confirmed_candidate.src_id}, {confirmed_candidate.name}") self._report_tracestate_to_user() logger.debug(f"last state:\n{self.active_model.get_status_text()}") self.__update_visualisation() return True - part1, part2 = self._split_candidate_if_refinement_needed( - candidate, self.active_model) - + part1, part2 = self._split_candidate_if_refinement_needed(candidate, self.active_model) if part2: exit_conditions = part2.steps[1].model_info['OUT'] part1.name = f"{part1.name} (part {self.tracestate.highest_part(index) + 1})" @@ -246,8 +235,7 @@ def _try_to_fit_in_scenario(self, index: int, candidate: Scenario, retry_flag: b i_refine = self.tracestate.next_candidate(retry=retry_flag) if i_refine is None: - logger.debug( - "Refinement needed, but there are no scenarios left") + logger.debug("Refinement needed, but there are no scenarios left") self._rewind() self._report_tracestate_to_user() self.__update_visualisation() @@ -272,18 +260,14 @@ def _try_to_fit_in_scenario(self, index: int, candidate: Scenario, retry_flag: b insert_valid_here = False if insert_valid_here: - m_finished = self._try_to_fit_in_scenario( - index, part2, retry_flag) + m_finished = self._try_to_fit_in_scenario(index, part2, retry_flag) if m_finished: self.__update_visualisation() return True else: - logger.debug( - f"Scenario did not meet refinement conditions {exit_conditions}") - logger.debug( - f"last state:\n{self.active_model.get_status_text()}") - logger.debug( - f"Reconsidering {self.scenarios[i_refine].name}, scenario excluded") + logger.debug(f"Scenario did not meet refinement conditions {exit_conditions}") + logger.debug(f"last state:\n{self.active_model.get_status_text()}") + logger.debug(f"Reconsidering {self.scenarios[i_refine].name}, scenario excluded") self._rewind() self._report_tracestate_to_user() @@ -336,8 +320,7 @@ def _split_candidate_if_refinement_needed(scenario: Scenario, model: ModelSpace) try: if m.process_expression(expr, step.args) is False: if step.gherkin_kw in ['when', None]: - logger.debug( - f"Refinement needed for scenario: {scenario.name}\nat step: {step}") + logger.debug(f"Refinement needed for scenario: {scenario.name}\nat step: {step}") refine_here = True else: return no_split @@ -346,22 +329,17 @@ def _split_candidate_if_refinement_needed(scenario: Scenario, model: ModelSpace) return no_split if refine_here: - front, back = scenario.split_at_step( - scenario.steps.index(step)) + front, back = scenario.split_at_step(scenario.steps.index(step)) remaining_steps = '\n\t'.join( [step.full_keyword, '- ' * 35] + [s.full_keyword for s in back.steps[1:]]) - remaining_steps = SuiteProcessors.escape_robot_vars( - remaining_steps) - edge_step = Step( - 'Log', f"Refinement follows for step:\n\t{remaining_steps}", parent=scenario) + remaining_steps = SuiteProcessors.escape_robot_vars(remaining_steps) + edge_step = Step('Log', f"Refinement follows for step:\n\t{remaining_steps}", parent=scenario) edge_step.gherkin_kw = step.gherkin_kw - edge_step.model_info = dict( - IN=step.model_info['IN'], OUT=[]) + edge_step.model_info = dict(IN=step.model_info['IN'], OUT=[]) edge_step.detached = True edge_step.args = StepArguments(step.args) front.steps.append(edge_step) - back.steps.insert( - 0, Step('Log', f"Refinement ready, completing step", parent=scenario)) + back.steps.insert(0, Step('Log', f"Refinement ready, completing step", parent=scenario)) back.steps[1] = back.steps[1].copy() back.steps[1].model_info['IN'] = [] return (front, back) @@ -381,8 +359,7 @@ def _process_scenario(scenario: Scenario, model: ModelSpace) -> tuple[Scenario, scenario = scenario.copy() for step in scenario.steps: if 'error' in step.model_info: - logger.debug( - f"Error in scenario {scenario.name} at step {step}: {step.model_info['error']}") + logger.debug(f"Error in scenario {scenario.name} at step {step}: {step.model_info['error']}") return None, None for expr in SuiteProcessors._relevant_expressions(step): @@ -425,49 +402,53 @@ def _generate_scenario_variant(self, scenario: Scenario, model: ModelSpace) -> S # collect set of constraints subs = SubstitutionMap() - try: # TODO: look into refactoring this... interestingly structured code. + try: for step in scenario.steps: if 'MOD' in step.model_info: for expr in step.model_info['MOD']: - modded_arg, constraint = self._parse_modifier_expression( - expr, step.args) - - if step.args[modded_arg].kind != StepArgument.EMBEDDED: - raise ValueError( - "Modifers are currently only supported for embedded arguments.") - - org_example = step.args[modded_arg].org_value - if step.gherkin_kw == 'then': - constraint = None # No new constraints are processed for then-steps - if org_example not in subs.substitutions: - # if a then-step signals the first use of an example value, it is considered a new definition - subs.substitute(org_example, [org_example]) - continue - - if not constraint and org_example not in subs.substitutions: - raise ValueError( - f"No options to choose from at first assignment to {org_example}") - - if constraint and constraint != '.*': - options = m.process_expression( - constraint, step.args) - if options == 'exec': - raise ValueError( - f"Invalid constraint for argument substitution: {expr}") - - if not options: - raise ValueError( - f"Constraint on modifer did not yield any options: {expr}") - - if not is_list_like(options): - raise ValueError( - f"Constraint on modifer did not yield a set of options: {expr}") - + modded_arg, constraint = self._parse_modifier_expression(expr, step.args) + if step.args[modded_arg].is_default: + continue + if step.args[modded_arg].kind in [StepArgument.EMBEDDED, StepArgument.POSITIONAL, + StepArgument.NAMED]: + org_example = step.args[modded_arg].org_value + if step.gherkin_kw == 'then': + constraint = None # No new constraints are processed for then-steps + if org_example not in subs.substitutions: + # if a then-step signals the first use of an example value, it is considered a new definition + subs.substitute(org_example, [org_example]) + continue + if not constraint and org_example not in subs.substitutions: + raise ValueError(f"No options to choose from at first assignment to {org_example}") + if constraint and constraint != '.*': + options = m.process_expression(constraint, step.args) + if options == 'exec': + raise ValueError(f"Invalid constraint for argument substitution: {expr}") + if not options: + raise ValueError(f"Constraint on modifer did not yield any options: {expr}") + if not is_list_like(options): + raise ValueError(f"Constraint on modifer did not yield a set of options: {expr}") + else: + options = None + subs.substitute(org_example, options) + elif step.args[modded_arg].kind == StepArgument.VAR_POS: + if step.args[modded_arg].value: + modded_varargs = m.process_expression(constraint, step.args) + if not is_list_like(modded_varargs): + raise ValueError(f"Modifying varargs must yield a list of arguments") + # Varargs are not added to the substitution map, but are used directly as-is. A modifier can + # change the number of arguments in the list, making it impossible to decide which values to + # match and which to drop and/or duplicate. + step.args[modded_arg].value = modded_varargs + elif step.args[modded_arg].kind == StepArgument.FREE_NAMED: + if step.args[modded_arg].value: + modded_free_args = m.process_expression(constraint, step.args) + if not isinstance(modded_free_args, dict): + raise ValueError("Modifying free named arguments must yield a dict") + # Similar to varargs, modified free named arguments are used directly as-is. + step.args[modded_arg].value = modded_free_args else: - options = None - - subs.substitute(org_example, options) - + raise AssertionError(f"Unknown argument kind for {modded_arg}") except Exception as err: logger.debug(f"Unable to insert scenario {scenario.src_id}, {scenario.name}, due to modifier\n" f" In step {step}: {err}") @@ -482,19 +463,19 @@ def _generate_scenario_variant(self, scenario: Scenario, model: ModelSpace) -> S # Update scenario with generated values if subs.solution: - logger.debug( - f"Example variant generated with argument substitution: {subs}") - + logger.debug(f"Example variant generated with argument substitution: {subs}") scenario.data_choices = subs for step in scenario.steps: if 'MOD' in step.model_info: for expr in step.model_info['MOD']: - modded_arg, _ = self._parse_modifier_expression( - expr, step.args) + modded_arg, _ = self._parse_modifier_expression(expr, step.args) + if step.args[modded_arg].is_default: + continue org_example = step.args[modded_arg].org_value - step.args[modded_arg].value = subs.solution[org_example] - + if step.args[modded_arg].kind in [StepArgument.EMBEDDED, StepArgument.POSITIONAL, + StepArgument.NAMED]: + step.args[modded_arg].value = subs.solution[org_example] return scenario @staticmethod @@ -502,8 +483,7 @@ def _parse_modifier_expression(expression: str, args: StepArguments) -> tuple[st if expression.startswith('${'): for var in args: if expression.casefold().startswith(var.arg.casefold()): - assignment_expr = expression.replace( - var.arg, '', 1).strip() + assignment_expr = expression.replace(var.arg, '', 1).strip() if not assignment_expr.startswith('=') or assignment_expr.startswith('=='): break # not an assignment @@ -520,8 +500,7 @@ def _report_tracestate_to_user(self): user_trace += f"{snapshot.scenario.src_id}{part}, " user_trace = user_trace[:-2] + "]" if ',' in user_trace else "[]" - reject_trace = [ - self.scenarios[i].src_id for i in self.tracestate.tried] + reject_trace = [self.scenarios[i].src_id for i in self.tracestate.tried] logger.debug(f"Trace: {user_trace} Reject: {reject_trace}") def _report_tracestate_wrapup(self): @@ -536,14 +515,14 @@ def _init_randomiser(seed: any): seed = seed.strip() if str(seed).lower() == 'none': - logger.debug( + logger.info( f"Using system's random seed for trace generation. This trace cannot be rerun. Use `seed=new` to generate a reusable seed.") elif str(seed).lower() == 'new': new_seed = SuiteProcessors._generate_seed() - logger.debug(f"seed={new_seed} (use seed to rerun this trace)") + logger.info(f"seed={new_seed} (use seed to rerun this trace)") random.seed(new_seed) else: - logger.debug(f"seed={seed} (as provided)") + logger.info(f"seed={seed} (as provided)") random.seed(seed) @staticmethod diff --git a/robotmbt/tracestate.py b/robotmbt/tracestate.py index 362375d7..689c4971 100644 --- a/robotmbt/tracestate.py +++ b/robotmbt/tracestate.py @@ -61,7 +61,7 @@ def model(self) -> dict[str, int] | None: return self._snapshots[-1].model if self._trace else None @property - def tried(self) -> tuple[int]: + def tried(self) -> tuple[int, ...]: """returns the indices that were rejected or previously inserted at the current position""" return tuple(self._tried[-1]) diff --git a/robotmbt/version.py b/robotmbt/version.py index 58e1a598..a5bdcd62 100644 --- a/robotmbt/version.py +++ b/robotmbt/version.py @@ -1 +1 @@ -VERSION: str = '0.9.0' +VERSION: str = '0.10.0' diff --git a/utest/test_steparguments.py b/utest/test_steparguments.py index 5c73903f..da1bdce5 100644 --- a/utest/test_steparguments.py +++ b/utest/test_steparguments.py @@ -31,7 +31,7 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import unittest -from robotmbt.steparguments import StepArgument, StepArguments, ArgKind +from robotmbt.steparguments import StepArgument, StepArguments class TestStepArgument(unittest.TestCase): @@ -94,7 +94,7 @@ def test_is_default_property(self): self.assertFalse(arg2.is_default) def test_copies_are_the_same(self): - arg1 = StepArgument('foo', 7, kind=ArgKind.NAMED, is_default=True) + arg1 = StepArgument('foo', 7, kind=StepArgument.NAMED, is_default=True) arg2 = arg1.copy() self.assertEqual(arg1.arg, arg2.arg) self.assertEqual(arg1.value, arg2.value) @@ -107,7 +107,7 @@ def test_copies_are_the_same(self): self.assertEqual(arg2.arg, '${foo}') self.assertEqual(arg2.value, 8) self.assertEqual(arg2.org_value, 7) - self.assertEqual(arg2.kind, ArgKind.NAMED) + self.assertEqual(arg2.kind, StepArgument.NAMED) self.assertEqual(arg2.is_default, False) def test_original_value_is_kept_when_copying(self): @@ -118,11 +118,11 @@ def test_original_value_is_kept_when_copying(self): self.assertEqual(arg2.value, 8) def test_copies_are_independent(self): - arg1 = StepArgument('foo', 7, ArgKind.POSITIONAL) + arg1 = StepArgument('foo', 7, StepArgument.POSITIONAL) arg1.value = 8 arg2 = arg1.copy() arg2.value = 13 - arg2.kind = ArgKind.NAMED + arg2.kind = StepArgument.NAMED self.assertEqual(arg2.value, 13) self.assertEqual(arg1.value, 8) self.assertEqual(arg1.org_value, arg2.org_value) @@ -160,11 +160,11 @@ def test_spaces_and_underscores_are_interchangable(self): self.assertEqual(arg1.codestring, arg2.codestring) def test_other_values_become_unique_identifiers(self): - valuelist = ['bar', 'foo bar', 'foo2bar', '${bar}', # strings - ' ', '\t', '\n', ' ', ' \n', '\a', # whitespace/non-printable - '#', '+-', '-+', '"', "'", 'パイ', # special characters - max, 'elif', 'import', 'new', 'del', # reserved words - lambda x: x/2, self, unittest.TestCase] # functions and objects + valuelist = ['bar', 'foo bar', 'foo2bar', '${bar}', # strings + ' ', '\t', '\n', ' ', ' \n', '\a', # whitespace/non-printable + '#', '+-', '-+', '"', "'", 'パイ', # special characters + max, 'elif', 'import', 'new', 'del', # reserved words + lambda x: x/2, self, unittest.TestCase] # functions and objects argsset = set() for v in valuelist: arg = StepArgument('foo', v) @@ -235,10 +235,10 @@ def test_arguments_can_be_replaced_in_any_string(self): argset = StepArguments([StepArgument('foo1', 'bar1'), StepArgument('foo2', 'bar2')]) self.assertEqual(argset.fill_in_args("\t${foo1} and ${foo2}@#$%s $$$$${foo2}${foo1}}"), - "\tbar1 and bar2@#$%s $$$$bar2bar1}") + "\tbar1 and bar2@#$%s $$$$bar2bar1}") def test_can_use_robot_arguments_in_code_fragments(self): - args = StepArguments([StepArgument('foo1', '3bar'), # 3bar needs to be converted to a valid identifier + args = StepArguments([StepArgument('foo1', '3bar'), # 3bar needs to be converted to a valid identifier StepArgument('foo2', '3bar')]) assignment = "${foo1} = 'magic'" lc = locals() @@ -290,6 +290,5 @@ def test_set_is_modified_if_any_arg_is_modified(self): argset['${foo2}'].value = 'bar3' self.assertTrue(argset.modified) - if __name__ == '__main__': unittest.main() diff --git a/utest/test_suitedata.py b/utest/test_suitedata.py index 2623703a..a8bc1025 100644 --- a/utest/test_suitedata.py +++ b/utest/test_suitedata.py @@ -33,7 +33,6 @@ import unittest from unittest.mock import patch -from enum import Enum, auto from types import SimpleNamespace from robotmbt.suitedata import Suite, Scenario, Step @@ -79,7 +78,7 @@ def test_error_in_suite_setup_is_detected(self): def test_error_in_scenario_is_detected(self): self.topsuite.scenarios[0].steps[1].model_info = dict(error='oops') self.assertIs(self.topsuite.has_error(), True) - errorsteps = self.topsuite.steps_with_errors() + errorsteps = self.topsuite.steps_with_errors() self.assertEqual(len(errorsteps), 1) self.assertEqual(errorsteps[0].model_info['error'], 'oops') @@ -106,7 +105,7 @@ def test_error_in_subsuite_setup_is_detected(self): def test_error_in_subsuite_scenario_is_detected(self): self.topsuite.suites[0].scenarios[0].steps[1].model_info = dict(error='oops') self.assertIs(self.topsuite.has_error(), True) - errorsteps = self.topsuite.steps_with_errors() + errorsteps = self.topsuite.steps_with_errors() self.assertEqual(len(errorsteps), 1) self.assertEqual(errorsteps[0].model_info['error'], 'oops') @@ -157,7 +156,7 @@ def test_multiple_errors_are_listed(self): errorsteps = self.topsuite.steps_with_errors() self.assertEqual(len(errorsteps), 4) self.assertEqual(set([e.model_info['error'] for e in errorsteps]), - {'setup oops', 'scenario oops', 'sub scenario oops', 'sub teardown oops'}) + {'setup oops','scenario oops', 'sub scenario oops', 'sub teardown oops'}) class TestScenarios(unittest.TestCase): @@ -174,7 +173,7 @@ def test_longname_without_parent_is_just_the_name(self): self.assertEqual(self.scenario.longname, self.scenario.name) def test_longname_with_parent_includes_both_names(self): - def p(): return None # Create an object to assign the name attribute to + p = lambda:None # Create an object to assign the name attribute to p.longname = 'long' scenario = Scenario('name', p) self.assertEqual(scenario.longname, 'long.name') @@ -186,12 +185,12 @@ def test_no_errors_when_ok(self): def test_step_errors_are_reported(self): self.scenario.steps[0].model_info = dict(error='oops') self.assertIs(self.scenario.has_error(), True) - errorsteps = self.scenario.steps_with_errors() + errorsteps = self.scenario.steps_with_errors() self.assertEqual(len(errorsteps), 1) self.assertEqual(errorsteps[0].model_info['error'], 'oops') self.scenario.steps[1].model_info = dict(error='oh ow') self.assertIs(self.scenario.has_error(), True) - errorsteps = self.scenario.steps_with_errors() + errorsteps = self.scenario.steps_with_errors() self.assertEqual(len(errorsteps), 2) self.assertEqual([s.model_info['error'] for s in errorsteps], ['oops', 'oh ow']) @@ -223,7 +222,7 @@ def test_combined_errors(self): self.scenario.steps[0].model_info = dict(error='oops in scenario 1') self.scenario.steps[7].model_info = dict(error='oops in scenario 2') self.assertIs(self.scenario.has_error(), True) - errorsteps = self.scenario.steps_with_errors() + errorsteps = self.scenario.steps_with_errors() self.assertEqual(len(errorsteps), 4) self.assertEqual([e.model_info['error'] for e in self.scenario.steps_with_errors()], @@ -285,26 +284,16 @@ def test_copies_are_independent(self): def test_exteranally_determined_attributes_are_copied_along(self): self.scenario.src_id = 7 - - class SubstitutionMap: + class Dummy: def copy(self): return 'dummy' - self.scenario.data_choices = SubstitutionMap() + self.scenario.data_choices = Dummy() dup = self.scenario.copy() self.assertEqual(dup.src_id, self.scenario.src_id) self.assertEqual(dup.data_choices, 'dummy') -class ArgKind(Enum): - EMBEDDED = auto() - POSITIONAL = auto() - VAR_POS = auto() - NAMED = auto() - FREE_NAMED = auto() - - @patch('robotmbt.suitedata.ArgumentValidator') -@patch('robotmbt.suitedata.ArgKind', new=ArgKind) class TestSteps(unittest.TestCase): def setUp(self): self.steps = self.create_steps() @@ -315,15 +304,15 @@ def create_steps(parent=None): Gg1 = Step('Given step Gg1', parent=parent) Ga1 = Step('and step Ga1', parent=parent) Gb1 = Step('but step Gb1', parent=parent) - Gg1.gherkin_kw = Ga1.gherkin_kw = Gb1.gherkin_kw = 'given' + Gg1.gherkin_kw= Ga1.gherkin_kw= Gb1.gherkin_kw= 'given' Ww1 = Step('When step Ww1', parent=parent) Wa1 = Step('and step Wa1', parent=parent) Wb1 = Step('BUT step Wb1', parent=parent) - Ww1.gherkin_kw = Wa1.gherkin_kw = Wb1.gherkin_kw = 'when' + Ww1.gherkin_kw= Wa1.gherkin_kw= Wb1.gherkin_kw= 'when' Tt1 = Step('Then step Tt1', parent=parent) Ta1 = Step('And step Ta1', parent=parent) Tb1 = Step('but step Tb1', parent=parent) - Tt1.gherkin_kw = Ta1.gherkin_kw = Tb1.gherkin_kw = 'then' + Tt1.gherkin_kw= Ta1.gherkin_kw= Tb1.gherkin_kw= 'then' return [Kw1, Gg1, Ga1, Gb1, Ww1, Wa1, Wb1, Tt1, Ta1, Tb1] def test_full_names(self, mock): @@ -348,8 +337,8 @@ def test_gherkin_keywords(self, mock): def test_gherkin_keywords_are_lower_case(self, mock): source = [None, 'given', 'Given', 'GIVEN', - 'wHEN', 'wHEn', 'WHEn', - 'TheN', 'theN', 'thEN'] + 'wHEN' , 'wHEn', 'WHEn', + 'TheN' , 'theN', 'thEN'] expected = [None] + 3*['given'] + 3*['when'] + 3*['then'] for s, gkw in zip(self.steps, source): s.gherkin_kw = gkw @@ -358,8 +347,8 @@ def test_gherkin_keywords_are_lower_case(self, mock): def test_step_keywords_are_kept_as_is(self, mock): expected = [None, 'Given', 'and', 'but', - 'When', 'and', 'BUT', - 'Then', 'And', 'but'] + 'When' , 'and', 'BUT', + 'Then' , 'And', 'but'] for s, e in zip(self.steps, expected): self.assertEqual(s.step_kw, e) @@ -407,37 +396,33 @@ def test_return_value_multi_assignment_is_part_of_the_full_keyword_text(self, mo def test_argument_with_default_is_omitted_from_keyword_when_not_mentioned_named(self, mock): step = Step(RobotKwStub.STEPTEXT, 'posA', 'pos2=posB', parent=None) - step.args = StubStepArguments( - [StubArgument(name='pos1', value='posA', is_default=False, kind=ArgKind.POSITIONAL), - StubArgument(name='pos2', value='posB', is_default=False, kind=ArgKind.NAMED), - StubArgument(name='named1', value='namedA', is_default=True, kind=ArgKind.NAMED)]) + step.args = StubStepArguments([StubArgument(name='pos1', value='posA', is_default=False, kind='POSITIONAL'), + StubArgument(name='pos2', value='posB', is_default=False, kind='NAMED'), + StubArgument(name='named1', value='namedA', is_default=True, kind='NAMED')]) self.assertTupleEqual(step.posnom_args_str, ('posA', 'pos2=posB')) self.assertEqual(step.full_keyword, f"{RobotKwStub.STEPTEXT} posA pos2=posB") def test_argument_with_default_is_omitted_from_keyword_when_not_mentioned_positional(self, mock): step = Step(RobotKwStub.STEPTEXT, 'posA', 'posB', parent=None) - step.args = StubStepArguments( - [StubArgument(name='pos1', value='posA', is_default=False, kind=ArgKind.POSITIONAL), - StubArgument(name='pos2', value='posB', is_default=False, kind=ArgKind.POSITIONAL), - StubArgument(name='named1', value='namedA', is_default=True, kind=ArgKind.POSITIONAL)]) + step.args = StubStepArguments([StubArgument(name='pos1', value='posA', is_default=False, kind='POSITIONAL'), + StubArgument(name='pos2', value='posB', is_default=False, kind='POSITIONAL'), + StubArgument(name='named1', value='namedA', is_default=True, kind='POSITIONAL')]) self.assertTupleEqual(step.posnom_args_str, ('posA', 'posB')) self.assertEqual(step.full_keyword, f"{RobotKwStub.STEPTEXT} posA posB") def test_argument_with_default_is_included_in_keyword_when_mentioned_named(self, mock): step = Step(RobotKwStub.STEPTEXT, 'posA', 'pos2=posB', 'named1=namedA', parent=None) - step.args = StubStepArguments( - [StubArgument(name='pos1', value='posA', is_default=False, kind=ArgKind.POSITIONAL), - StubArgument(name='pos2', value='posB', is_default=False, kind=ArgKind.NAMED), - StubArgument(name='named1', value='namedA', is_default=False, kind=ArgKind.NAMED)]) + step.args = StubStepArguments([StubArgument(name='pos1', value='posA', is_default=False, kind='POSITIONAL'), + StubArgument(name='pos2', value='posB', is_default=False, kind='NAMED'), + StubArgument(name='named1', value='namedA', is_default=False, kind='NAMED')]) self.assertTupleEqual(step.posnom_args_str, ('posA', 'pos2=posB', 'named1=namedA')) self.assertEqual(step.full_keyword, f"{RobotKwStub.STEPTEXT} posA pos2=posB named1=namedA") def test_argument_with_default_is_included_in_keyword_when_mentioned_positional(self, mock): step = Step(RobotKwStub.STEPTEXT, 'posA', 'posB', 'namedA', parent=None) - step.args = StubStepArguments( - [StubArgument(name='pos1', value='posA', is_default=False, kind=ArgKind.POSITIONAL), - StubArgument(name='pos2', value='posB', is_default=False, kind=ArgKind.POSITIONAL), - StubArgument(name='named1', value='namedA', is_default=False, kind=ArgKind.POSITIONAL)]) + step.args = StubStepArguments([StubArgument(name='pos1', value='posA', is_default=False, kind='POSITIONAL'), + StubArgument(name='pos2', value='posB', is_default=False, kind='POSITIONAL'), + StubArgument(name='named1', value='namedA', is_default=False, kind='POSITIONAL')]) self.assertTupleEqual(step.posnom_args_str, ('posA', 'posB', 'namedA')) self.assertEqual(step.full_keyword, f"{RobotKwStub.STEPTEXT} posA posB namedA") @@ -461,7 +446,7 @@ def test_model_info_is_loaded(self, mock): :OUT: expr3 | expr4 """ step.add_robot_dependent_data(kw) - self.assertEqual(step.model_info, dict(IN=['expr1', 'expr2'], + self.assertEqual(step.model_info, dict( IN=['expr1', 'expr2'], OUT=['expr3', 'expr4'])) def test_model_info_errors_are_reported(self, mock): @@ -476,27 +461,30 @@ def test_model_info_errors_are_reported(self, mock): class RobotKwStub: STEPTEXT = "Given step with foo_value and bar_value as arguments" - def __init__(self): self.name = "step with ${foo} and ${bar} as arguments" self._doc = "*model info*\n:IN: None\n:OUT: None" self.args = self.argstub() self.error = False self.embedded = SimpleNamespace(args=['${foo}', '${bar}'], - parse_args=lambda _: ['foo_value', 'bar_value']) + parse_args= lambda _: ['foo_value', 'bar_value']) class argstub: argument_names = [] - def map(x, y, z): return ([], []) - def __iter__(_): return iter([]) + map = lambda x,y,z: ([], []) + __iter__ = lambda _: iter([]) class StubStepArguments(list): - modified = True # trigger modified status to get arguments processed, rather then just echoed + modified = True # trigger modified status to get arguments processed, rather then just echoed class StubArgument(SimpleNamespace): - pass + EMBEDDED = 'EMBEDDED' + POSITIONAL = 'POSITIONAL' + VAR_POS = 'VAR_POS' + NAMED = 'NAMED' + FREE_NAMED = 'FREE_NAMED' if __name__ == '__main__': From a2fa358321f01588762fc6288e4256d93c1250ac Mon Sep 17 00:00:00 2001 From: Thomas Kas Date: Mon, 8 Dec 2025 10:17:09 +0100 Subject: [PATCH 079/131] Rework trace info and fix bugs (#32) * Clean-up of unused features, move some logic, fix empty state entry bug * Merge with main, fix some bugs, alter ScenarioStateGraph to be in line with the rest and fix up some of its unit tests * Merge mistake * Start of rework * Rework TraceInfo, extract common graph logic * Fix unit tests * Bug fixes and minor changes * Fix nuked graphs * Remove outdate acceptance tests * Generics * Proper type * Fix bug in Johan's code * More sanity, implement feedback --- atest/resources/helpers/__init__.py | 0 atest/resources/helpers/modelgenerator.py | 90 ------- atest/resources/visualisation.resource | 70 ------ .../M1a1_VertexCount.robot | 11 - .../M1a2_EdgeRepresentation.robot | 12 - robotmbt/suiteprocessors.py | 7 +- robotmbt/tracestate.py | 13 +- robotmbt/visualise/graphs/abstractgraph.py | 98 ++++++-- robotmbt/visualise/graphs/scenariograph.py | 81 ++----- .../visualise/graphs/scenariostategraph.py | 101 +------- robotmbt/visualise/graphs/stategraph.py | 98 ++------ robotmbt/visualise/models.py | 121 +++++++--- robotmbt/visualise/networkvisualiser.py | 1 + robotmbt/visualise/visualiser.py | 32 +-- utest/test_visualise_models.py | 84 ++++++- utest/test_visualise_scenariograph.py | 172 ++++---------- utest/test_visualise_scenariostategraph.py | 222 ++++++------------ utest/test_visualise_stategraph.py | 155 ++++++------ 18 files changed, 504 insertions(+), 864 deletions(-) delete mode 100644 atest/resources/helpers/__init__.py delete mode 100644 atest/resources/helpers/modelgenerator.py delete mode 100644 atest/resources/visualisation.resource delete mode 100644 atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a1_VertexCount.robot delete mode 100644 atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a2_EdgeRepresentation.robot diff --git a/atest/resources/helpers/__init__.py b/atest/resources/helpers/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/atest/resources/helpers/modelgenerator.py b/atest/resources/helpers/modelgenerator.py deleted file mode 100644 index b5837aeb..00000000 --- a/atest/resources/helpers/modelgenerator.py +++ /dev/null @@ -1,90 +0,0 @@ -import random -import string - -from robot.api.deco import keyword # type:ignore -from robotmbt.modelspace import ModelSpace -from robotmbt.visualise.models import TraceInfo, ScenarioInfo, StateInfo -from robotmbt.visualise.graphs.scenariograph import ScenarioGraph -from robotmbt.visualise.graphs.abstractgraph import AbstractGraph -from robotmbt.visualise.graphs.stategraph import StateGraph - - -class ModelGenerator: - @keyword(name="Create Graph") # type: ignore - def create_graph(self, graph_type :str) -> AbstractGraph: - match graph_type: - case "scenario": - return ScenarioGraph() - case "state": - return StateGraph() - case _: - raise Exception(f"Trying to create unknown graph type {graph_type}") - - - @keyword(name="Generate Trace Information") # type: ignore - def generate_trace_info(self, scenario_count: int) -> TraceInfo: - """Generates a list of unique random scenarios.""" - scenarios: list[ScenarioInfo] = ModelGenerator.generate_scenario_names( - scenario_count) - - return TraceInfo(scenarios, StateInfo(ModelSpace())) - - @keyword(name="Ensure Scenario Present") # type: ignore - def ensure_scenario_present(self, trace_info: TraceInfo, scenario_name: str) -> TraceInfo: - if trace_info.contains_scenario(scenario_name): - return trace_info - - trace_info.add_scenario(ScenarioInfo(scenario_name)) - return trace_info - - @keyword(name="Ensure Scenario Follows") # type: ignore - def ensure_scenario_follows(self, trace_info: TraceInfo, scen1: str, scen2: str) -> TraceInfo: - scen1_info: ScenarioInfo | None = trace_info.get_scenario(scen1) - scen2_info: ScenarioInfo | None = trace_info.get_scenario(scen2) - - if scen1_info is None or scen2_info is None: - raise Exception( - f"Ensure Scenario Follows for scenarios that did not exist! scen1={scen1}, scen2={scen2}") - - # both scenarios apparently exist, now make sure that scenario2 follows after some appearance of scenario 1: - scen1_index: int = trace_info.trace.index(scen1_info) - scen2_index: int = trace_info.trace.index(scen2_info) - if scen2_index == scen1_index + 1: - return trace_info - - # if it doesn't follow, make it follow - trace_info.insert_trace_at(scen1_index, scen2_info) - return trace_info - - @keyword(name="Ensure Edge Exists") # type: ignore - def ensure_edge_exists(self, graph: ScenarioGraph, scen_name1: str, scen_name2: str): - # get node name based on scenario - nodename1: str = "" - nodename2: str = "" - for (nodename, label) in graph.networkx.nodes(data='label', default=None): - if label == scen_name1: - nodename1 = nodename - - if label == scen_name2: - nodename2 = nodename - - # now check the relation: - if (nodename1, nodename2) in graph.networkx.edges: # type: ignore - return # exists :) - - # make sure that it exists - graph.networkx.add_edge(nodename1, nodename2) - - @staticmethod - def generate_random_scenario_name(length: int = 10) -> str: - """Generates a random scenario name.""" - return ''.join(random.choices(string.ascii_letters + string.digits, k=length)) - - @staticmethod - def generate_scenario_names(count: int) -> list[ScenarioInfo]: - """Generates a list of unique random scenarios.""" - scenarios: set[str] = set() - while len(scenarios) < count: - scenario = ModelGenerator.generate_random_scenario_name() - scenarios.add(scenario) - return [ScenarioInfo(s) for s in scenarios] diff --git a/atest/resources/visualisation.resource b/atest/resources/visualisation.resource deleted file mode 100644 index 2e883d26..00000000 --- a/atest/resources/visualisation.resource +++ /dev/null @@ -1,70 +0,0 @@ -*** Settings *** -Documentation Resource file for testing the visualisation of RobotMBT -Library atest.resources.helpers.modelgenerator.ModelGenerator -Library robotmbt.visualise.visualiser.Visualiser scenario -Library Collections - - -*** Keywords *** -Test Suite ${suite} exists - [Documentation] Makes a test suite - ... :IN: suite = ${suite} - ... :OUT: None - Set Suite Variable ${suite} - ${trace_info} = Generate Trace Information ${0} - Set Suite Variable ${trace_info} # make empty trace info - -Test Suite ${suite} has ${count} scenarios - [Documentation] Makes a test suite - ... :IN: scenario_count = ${count}, suite = ${suite} - ... :OUT: None - ${trace_info} = Generate Trace Information ${count} - Set Suite Variable ${trace_info} - -# WARNING: this only works for Scenario graphs, this needs to be adjusted to continually update the model when adding new state. -Test Suite ${suite} contains scenario ${scenario_name} - [Documentation] Ensures test suite Suite has scenario with name=Scenario name - ... :IN: suite = ${suite}, scenario_name = ${scenario_name} - ... :OUT: None - Variable Should Exist ${trace_info} - - ${trace_info} = Ensure Scenario Present ${trace_info} ${scenario_name} - Set Suite Variable ${trace_info} - -In Test Suite ${suite}, scenario ${s2} can be reached from ${s1} - [Documentation] Ensures a scenario s2 immediately follows s1 in a trace - ... :IN: suite = ${suite}, scenario2 = ${s2}, scenario1 = ${s1} - ... :OUT: None - - ${trace_info} = Ensure Scenario Follows ${trace_info} ${s1} ${s2} - Set Suite Variable ${trace_info} - -Graph ${graph} of type ${graph_type} is generated based on Test Suite ${suite} - [Documentation] Generates the graph - ... :IN: graph = ${graph}, graph_type = ${graph_type}, suite = ${suite} - ... :OUT: None - Variable Should Exist ${trace_info} - ${visualiser} = robotmbt.visualise.visualiser.Visualiser.Construct graph_type=${graph_type} - Call Method ${visualiser} update_visualisation ${trace_info} - ${html} = Call Method ${visualiser} generate_visualisation - - Set Suite Variable ${visualiser} - Set Suite Variable ${html} - -Graph ${graph} contains ${number} vertices - [Documentation] Verifies that the graph contains the specified number of vertices. - ... :IN: graph = ${graph}, number = ${number} - ... :OUT: None - Variable Should Exist ${visualiser} - Variable Should Exist ${trace_info} - - ${vertex_count} = Get Length ${visualiser.graph.networkx.nodes} - Should Be Equal As Integers ${vertex_count} ${number} - -Graph ${graph} shows an edge from ${scenname1} towards ${scenname2} - [Documentation] Verifies that a generated graph contains a certain edge - ... :IN: graph = ${graph}, scenario name 1 = ${scenname1}, scenario name 2 = ${scenname2} - ... :OUT: None - Variable Should Exist ${visualiser} - - ${res} = Ensure Edge Exists ${visualiser.graph} ${scenname1} ${scenname2} \ No newline at end of file diff --git a/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a1_VertexCount.robot b/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a1_VertexCount.robot deleted file mode 100644 index 9224c2af..00000000 --- a/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a1_VertexCount.robot +++ /dev/null @@ -1,11 +0,0 @@ -*** Settings *** -Resource ../../../../resources/visualisation.resource -Library robotmbt processor=echo -Suite Setup Set Global Variable ${scen_count} ${2} - -*** Test Cases *** -Graph should contain vertex count equal to scenario count + 1 for scenario-graph - Given Test Suite s exists - Given Test Suite s has ${scen_count} scenarios - When Graph g of type scenario is generated based on Test Suite s - Then Graph g contains ${scen_count + 1} vertices \ No newline at end of file diff --git a/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a2_EdgeRepresentation.robot b/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a2_EdgeRepresentation.robot deleted file mode 100644 index a8162950..00000000 --- a/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a2_EdgeRepresentation.robot +++ /dev/null @@ -1,12 +0,0 @@ -*** Settings *** -Resource ../../../../resources/visualisation.resource -Library robotmbt processor=echo - -*** Test Cases *** -Graph should contain edge from vertex A to vertex B if B can be reached from A - Given Test Suite s exists - Given Test Suite s contains scenario Drive To Destination - Given Test Suite s contains scenario Arrive At Destination - Given In Test Suite s, scenario Arrive At Destination can be reached from Drive To Destination - When Graph g of type scenario is generated based on Test Suite s - Then Graph g shows an edge from Drive To Destination towards Arrive At Destination diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index 2b4aa3c3..9887d9c0 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -165,8 +165,11 @@ def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool): def __update_visualisation(self): if self.visualiser is not None: - self.visualiser.update_visualisation( - TraceInfo.from_trace_state(self.tracestate, self.active_model)) + self.visualiser.update_trace(self.tracestate, self.active_model) + + def __write_visualisation(self): + if self.visualiser is not None: + logger.info(self.visualiser.generate_visualisation(), html=True) def __write_visualisation(self): if self.visualiser is not None: diff --git a/robotmbt/tracestate.py b/robotmbt/tracestate.py index 689c4971..03941059 100644 --- a/robotmbt/tracestate.py +++ b/robotmbt/tracestate.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +from robotmbt.modelspace import ModelSpace from robotmbt.suitedata import Scenario @@ -33,10 +34,10 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. class TraceSnapShot: - def __init__(self, id: str, inserted_scenario: str | Scenario, model_state: dict[str, int], drought: int = 0): + def __init__(self, id: str, inserted_scenario: str | Scenario, model_state: ModelSpace, drought: int = 0): self.id: str = id self.scenario: str | Scenario = inserted_scenario - self.model: dict[str, int] = model_state.copy() + self.model: ModelSpace = model_state.copy() self.coverage_drought: int = drought @@ -56,9 +57,9 @@ def __init__(self, n_scenarios: int): self._open_refinements: list[int] = [] @property - def model(self) -> dict[str, int] | None: + def model(self) -> ModelSpace | None: """returns the model as it is at the end of the current trace""" - return self._snapshots[-1].model if self._trace else None + return self._snapshots[-1].model.copy() if self._trace else None @property def tried(self) -> tuple[int, ...]: @@ -122,7 +123,7 @@ def reject_scenario(self, i_scenario: int): """Trying a scenario excludes it from further cadidacy on this level""" self._tried[-1].append(i_scenario) - def confirm_full_scenario(self, index: int, scenario: str, model: dict[str, int]): + def confirm_full_scenario(self, index: int, scenario: str, model: ModelSpace): if not self._c_pool[index]: self._c_pool[index] = True c_drought = 0 @@ -140,7 +141,7 @@ def confirm_full_scenario(self, index: int, scenario: str, model: dict[str, int] self._trace.append(id) self._snapshots.append(TraceSnapShot(id, scenario, model, c_drought)) - def push_partial_scenario(self, index: int, scenario: str, model: dict[str, int]): + def push_partial_scenario(self, index: int, scenario: str, model: ModelSpace): if self._is_refinement_active(index): id = f"{index}.{self.highest_part(index) + 1}" diff --git a/robotmbt/visualise/graphs/abstractgraph.py b/robotmbt/visualise/graphs/abstractgraph.py index e61cbdf5..b9c6dcc3 100644 --- a/robotmbt/visualise/graphs/abstractgraph.py +++ b/robotmbt/visualise/graphs/abstractgraph.py @@ -1,40 +1,106 @@ from abc import ABC, abstractmethod -from robotmbt.visualise.models import TraceInfo +from typing import Generic, TypeVar + +from robotmbt.visualise.models import TraceInfo, ScenarioInfo, StateInfo import networkx as nx -class AbstractGraph(ABC): - @abstractmethod - def update_visualisation(self, info: TraceInfo): +NodeInfo = TypeVar('NodeInfo') +EdgeInfo = TypeVar('EdgeInfo') + + +class AbstractGraph(ABC, Generic[NodeInfo, EdgeInfo]): + def __init__(self, info: TraceInfo): + # The underlying storage - a NetworkX DiGraph + self.networkx: nx.DiGraph = nx.DiGraph() + + # Keep track of node IDs + self.ids: dict[str, NodeInfo] = {} + + # Add the start node + self.networkx.add_node('start', label='start') + + # Add nodes and edges for all traces + for trace in info.all_traces: + for i in range(len(trace)): + if i > 0: + from_node = self._get_or_create_id(self.select_node_info(trace[i - 1])) + else: + from_node = 'start' + to_node = self._get_or_create_id(self.select_node_info(trace[i])) + self._add_node(from_node) + self._add_node(to_node) + self.networkx.add_edge(from_node, to_node, + label=self.create_edge_label(self.select_edge_info(trace[i]))) + + # Set the final trace and add any missing nodes/edges + self.final_trace = ['start'] + for i in range(len(info.current_trace)): + if i > 0: + from_node = self._get_or_create_id(self.select_node_info(info.current_trace[i - 1])) + else: + from_node = 'start' + to_node = self._get_or_create_id(self.select_node_info(info.current_trace[i])) + self.final_trace.append(to_node) + self._add_node(from_node) + self._add_node(to_node) + self.networkx.add_edge(from_node, to_node, + label=self.create_edge_label(self.select_edge_info(info.current_trace[i]))) + + def get_final_trace(self) -> list[str]: """ - Update the visualisation with new trace information from another exploration step. + Get the final trace as ordered node ids. + Edges are subsequent entries in the list. """ - pass + return self.final_trace + def _get_or_create_id(self, info: NodeInfo) -> str: + """ + Get the ID for a state that has been added before, or create and store a new one. + """ + for i in self.ids.keys(): + if self.ids[i] == info: + return i + + new_id = f"node{len(self.ids)}" + self.ids[new_id] = info + return new_id + + def _add_node(self, node: str): + """ + Add node if it doesn't already exist. + """ + if node not in self.networkx.nodes: + self.networkx.add_node(node, label=self.create_node_label(self.ids[node])) + + @staticmethod @abstractmethod - def set_final_trace(self, info: TraceInfo): + def select_node_info(pair: tuple[ScenarioInfo, StateInfo]) -> NodeInfo: """ - Update the graph with information on the final trace. + Select the info to use to compare nodes and generate their labels for a specific graph type. """ pass + @staticmethod @abstractmethod - def get_final_trace(self) -> list[str]: + def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> EdgeInfo: """ - Get the final trace as ordered node ids. - Edges are subsequent entries in the list. + Select the info to use to generate the label for each edge for a specific graph type. """ pass - @property + @staticmethod @abstractmethod - def networkx(self) -> nx.DiGraph: + def create_node_label(info: NodeInfo) -> str: """ - We use networkx to store nodes and edges. + Create the label for a node given its chosen information. """ pass - @networkx.setter + @staticmethod @abstractmethod - def networkx(self, value: nx.DiGraph): + def create_edge_label(info: EdgeInfo) -> str: + """ + Create the label for an edge given its chosen information. + """ pass diff --git a/robotmbt/visualise/graphs/scenariograph.py b/robotmbt/visualise/graphs/scenariograph.py index cddc6a48..0c17ac9a 100644 --- a/robotmbt/visualise/graphs/scenariograph.py +++ b/robotmbt/visualise/graphs/scenariograph.py @@ -1,78 +1,25 @@ from robotmbt.visualise.graphs.abstractgraph import AbstractGraph -from robotmbt.visualise.models import TraceInfo, ScenarioInfo -import networkx as nx +from robotmbt.visualise.models import ScenarioInfo, StateInfo -class ScenarioGraph(AbstractGraph): +class ScenarioGraph(AbstractGraph[ScenarioInfo, None]): """ The scenario graph is the most basic representation of trace exploration. It represents scenarios as nodes, and the trace as edges. """ - def __init__(self): - # We use simplified IDs for nodes, and store the actual scenario info here - self.ids: dict[str, ScenarioInfo] = {} + @staticmethod + def select_node_info(pair: tuple[ScenarioInfo, StateInfo]) -> ScenarioInfo: + return pair[0] - # The networkx graph is a directional graph - self.networkx = nx.DiGraph() + @staticmethod + def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> None: + return None - # add the start node - self.networkx.add_node('start', label='start') - self.final_trace: list[str] = ['start'] + @staticmethod + def create_node_label(info: ScenarioInfo) -> str: + return info.name - def update_visualisation(self, info: TraceInfo): - """ - This will add nodes for all new scenarios in the provided trace, as well as edges for all pairs in the provided trace. - """ - for i in range(0, len(info.trace) - 1): - from_node = self._get_or_create_id(info.trace[i]) - to_node = self._get_or_create_id(info.trace[i + 1]) - - self._add_node(from_node) - self._add_node(to_node) - - if (from_node, to_node) not in self.networkx.edges: - self.networkx.add_edge(from_node, to_node, label='') - - if len(info.trace) > 0: - first_id = self._get_or_create_id(info.trace[0]) - - self._add_node(first_id) - - if ('start', first_id) not in self.networkx.edges: - self.networkx.add_edge('start', first_id, label='') - - def set_final_trace(self, info: TraceInfo): - self.final_trace.extend(map(lambda s: self._get_or_create_id(s), info.trace)) - - def get_final_trace(self) -> list[str]: - return self.final_trace - - def _get_or_create_id(self, scenario: ScenarioInfo) -> str: - """ - Get the ID for a scenario that has been added before, or create and store a new one. - """ - if scenario.src_id is not None: - for i in self.ids.keys(): - # TODO: decide how to deal with repeating scenarios, this merges repeated scenarios into a single scenario - if self.ids[i].src_id == scenario.src_id: - return i - - new_id = f"node{len(self.ids)}" - self.ids[new_id] = scenario - return new_id - - def _add_node(self, node: str): - """ - Add node if it doesn't already exist. - """ - if node not in self.networkx.nodes: - self.networkx.add_node(node, label=self.ids[node].name) - - @property - def networkx(self) -> nx.DiGraph: - return self._networkx - - @networkx.setter - def networkx(self, value: nx.DiGraph): - self._networkx = value + @staticmethod + def create_edge_label(info: None) -> str: + return '' diff --git a/robotmbt/visualise/graphs/scenariostategraph.py b/robotmbt/visualise/graphs/scenariostategraph.py index bf500178..442c6600 100644 --- a/robotmbt/visualise/graphs/scenariostategraph.py +++ b/robotmbt/visualise/graphs/scenariostategraph.py @@ -1,101 +1,26 @@ -import networkx as nx -from robot.api import logger - from robotmbt.visualise.graphs.abstractgraph import AbstractGraph -from robotmbt.visualise.models import TraceInfo, ScenarioInfo, StateInfo +from robotmbt.visualise.models import ScenarioInfo, StateInfo -class ScenarioStateGraph(AbstractGraph): +class ScenarioStateGraph(AbstractGraph[tuple[ScenarioInfo, StateInfo], None]): """ The scenario-State graph keeps track of both the scenarios and states encountered. Its nodes are scenarios together with the state after the scenario has run. Its edges represent steps in the trace. """ - def __init__(self): - # We use simplified IDs for nodes, and store the actual scenario info here - self.ids: dict[str, tuple[ScenarioInfo, StateInfo]] = {} - - # The networkx graph is a directional graph - self.networkx = nx.DiGraph() - - # add the start node - self.networkx.add_node('start', label='start') - - self.prev_trace_len = 0 - - # Stack to track the current execution path - self.node_stack: list[str] = ['start'] - - def update_visualisation(self, info: TraceInfo): - """ - This will add nodes the newly reached scenario/state pair, as well as an edge from the previous to - the current scenario/state pair. - """ - if self.prev_trace_len < len(info.trace): - # New state added - add to stack - push_count = len(info.trace) - self.prev_trace_len - for i in range(push_count): - node = self._get_or_create_id(info.trace[-push_count + i], info.state) - self.node_stack.append(node) - self._add_node(self.node_stack[-2]) - self._add_node(self.node_stack[-1]) - - if (self.node_stack[-2], self.node_stack[-1]) not in self.networkx.edges: - self.networkx.add_edge(self.node_stack[-2], self.node_stack[-1], label='') - - elif self.prev_trace_len > len(info.trace): - # States removed - remove from stack - pop_count = self.prev_trace_len - len(info.trace) - for _ in range(pop_count): - if len(self.node_stack) > 1: # Always keep 'start' - self.node_stack.pop() - else: - logger.warn("Tried to rollback more than was previously added to the stack!") - - self.prev_trace_len = len(info.trace) - - def set_final_trace(self, info: TraceInfo): - # We already have the final trace in state_stack, so we don't need to do anything - # But do a sanity check - if self.prev_trace_len != len(info.trace): - logger.warn("Final trace was of a different length than our stack was based on!") - - def get_final_trace(self) -> list[str]: - # The final trace is simply the state stack we've been keeping track of - return self.node_stack - - def _get_or_create_id(self, scenario: ScenarioInfo, state: StateInfo) -> str: - """ - Get the ID for a scenario that has been added before, or create and store a new one. - """ - if scenario.src_id is not None: - for i in self.ids.keys(): - if self.ids[i][0].src_id == scenario.src_id and self.ids[i][1] == state: - return i - - new_id = f"node{len(self.ids)}" - self.ids[new_id] = (scenario, state) - return new_id - @staticmethod - def _gen_label(scenario: ScenarioInfo, state: StateInfo) -> str: - """ - Creates the label for a node in a Scenario-State Graph from the scenario and state associated to it. - """ - return scenario.name + "\n\n" + str(state) + def select_node_info(pair: tuple[ScenarioInfo, StateInfo]) -> tuple[ScenarioInfo, StateInfo]: + return pair - def _add_node(self, node: str): - """ - Add node if it doesn't already exist. - """ - if node not in self.networkx.nodes: - self.networkx.add_node(node, label=self._gen_label(self.ids[node][0], self.ids[node][1])) + @staticmethod + def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> None: + return None - @property - def networkx(self) -> nx.DiGraph: - return self._networkx + @staticmethod + def create_node_label(info: tuple[ScenarioInfo, StateInfo]) -> str: + return f"{info[0].name}\n\n{str(info[1])}" - @networkx.setter - def networkx(self, value: nx.DiGraph): - self._networkx = value + @staticmethod + def create_edge_label(info: None) -> str: + return '' diff --git a/robotmbt/visualise/graphs/stategraph.py b/robotmbt/visualise/graphs/stategraph.py index e61bb32e..bc9b57b7 100644 --- a/robotmbt/visualise/graphs/stategraph.py +++ b/robotmbt/visualise/graphs/stategraph.py @@ -1,95 +1,25 @@ -from robot.api import logger - from robotmbt.visualise.graphs.abstractgraph import AbstractGraph -from robotmbt.visualise.models import TraceInfo, StateInfo -import networkx as nx +from robotmbt.visualise.models import StateInfo, ScenarioInfo -class StateGraph(AbstractGraph): +class StateGraph(AbstractGraph[StateInfo, ScenarioInfo]): """ The state graph is a more advanced representation of trace exploration, allowing you to see the internal state. It represents states as nodes, and scenarios as edges. """ - def __init__(self): - # We use simplified IDs for nodes, and store the actual state info here - self.ids: dict[str, StateInfo] = {} - - # The networkx graph is a directional graph - self.networkx = nx.DiGraph() - - # add the start node - self.networkx.add_node('start', label='start') - - # To check if we've backtracked - self.prev_trace_len = 0 - - # Stack to track the current execution path - self.node_stack: list[str] = ['start'] - - def update_visualisation(self, info: TraceInfo): - """ - This will add nodes the newly reached state (if we did not roll back), as well as an edge from the previous to - the current state labeled with the scenario that took it there. - """ - node = self._get_or_create_id(info.state) - - if self.prev_trace_len < len(info.trace): - # New state added - add to stack - push_count = len(info.trace) - self.prev_trace_len - for i in range(push_count): - self.node_stack.append(node) - self._add_node(self.node_stack[-2]) - self._add_node(self.node_stack[-1]) - - if (self.node_stack[-2], self.node_stack[-1]) not in self.networkx.edges: - self.networkx.add_edge( - self.node_stack[-2], self.node_stack[-1], label=info.trace[-push_count + i].name) - - elif self.prev_trace_len > len(info.trace): - # States removed - remove from stack - pop_count = self.prev_trace_len - len(info.trace) - for _ in range(pop_count): - if len(self.node_stack) > 1: # Always keep 'start' - self.node_stack.pop() - else: - logger.warn("Tried to rollback more than was previously added to the stack!") - - self.prev_trace_len = len(info.trace) - - def set_final_trace(self, info: TraceInfo): - # We already have the final trace in state_stack, so we don't need to do anything - # But do a sanity check - if self.prev_trace_len != len(info.trace): - logger.warn("Final trace was of a different length than our stack was based on!") - - def get_final_trace(self) -> list[str]: - # The final trace is simply the state stack we've been keeping track of - return self.node_stack - - def _get_or_create_id(self, state: StateInfo) -> str: - """ - Get the ID for a state that has been added before, or create and store a new one. - """ - for i in self.ids.keys(): - if self.ids[i] == state: - return i - - new_id = f"node{len(self.ids)}" - self.ids[new_id] = state - return new_id + @staticmethod + def select_node_info(pair: tuple[ScenarioInfo, StateInfo]) -> StateInfo: + return pair[1] - def _add_node(self, node: str): - """ - Add node if it doesn't already exist. - """ - if node not in self.networkx.nodes: - self.networkx.add_node(node, label=str(self.ids[node])) + @staticmethod + def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> ScenarioInfo: + return pair[0] - @property - def networkx(self) -> nx.DiGraph: - return self._networkx + @staticmethod + def create_node_label(info: StateInfo) -> str: + return str(info) - @networkx.setter - def networkx(self, value: nx.DiGraph): - self._networkx = value + @staticmethod + def create_edge_label(info: ScenarioInfo) -> str: + return info.name diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index f6afd14b..98c3dd4b 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -1,8 +1,9 @@ from typing import Any +from robot.api import logger + from robotmbt.modelspace import ModelSpace from robotmbt.suitedata import Scenario -from robotmbt.tracestate import TraceState class ScenarioInfo: @@ -25,6 +26,9 @@ def __init__(self, scenario: Scenario | str): def __str__(self): return f"Scenario {self.src_id}: {self.name}" + def __eq__(self, other): + return self.src_id == other.src_id + class StateInfo: """ @@ -78,43 +82,88 @@ def __str__(self): class TraceInfo: """ - This contains all information we need at any given step in trace exploration: - - trace: the strung together scenarios up until this point - - state: the model space + This keeps track of all information we need from all steps in trace exploration: + - current_trace: the trace currently being built up, a list of scenario/state pairs in order of execution + - all_traces: all valid traces encountered in trace exploration, up until the point they could not go any further + - previous_length: used to identify backtracking """ - @classmethod - def from_trace_state(cls, trace: TraceState, state: ModelSpace): - return cls([ScenarioInfo(t) for t in trace.get_trace()], StateInfo(state)) + def __init__(self): + self.current_trace: list[tuple[ScenarioInfo, StateInfo]] = [] + self.all_traces: list[list[tuple[ScenarioInfo, StateInfo]]] = [] + self.previous_length: int = 0 + self.pushed: bool = False + + def update_trace(self, scenario: ScenarioInfo | None, state: StateInfo, length: int): + if length > self.previous_length: + # New state - push + self._push(scenario, state, length - self.previous_length) + self.previous_length = length + elif length < self.previous_length: + # Backtrack - pop + self._pop(self.previous_length - length) + self.previous_length = length + + # Sanity check + if len(self.current_trace) > 0: + self._sanity_check(scenario, state, 'popping') + else: + # No change - sanity check + if len(self.current_trace) > 0: + self._sanity_check(scenario, state, 'nothing') + + def _push(self, scenario: ScenarioInfo, state: StateInfo, n: int): + if n > 1: + logger.warn(f"Pushing multiple scenario/state pairs at once to trace info ({n})! Some info might be lost!") + for _ in range(n): + self.current_trace.append((scenario, state)) + self.pushed = True + + def _pop(self, n: int): + if self.pushed: + self.all_traces.append(self.current_trace.copy()) + for _ in range(n): + self.current_trace.pop() + self.pushed = False + + def encountered_scenarios(self) -> set[ScenarioInfo]: + res = set() + + for trace in self.all_traces: + for (scenario, state) in trace: + res.add(scenario) + + return res - def __init__(self, trace: list[ScenarioInfo], state: StateInfo): - self.trace: list[ScenarioInfo] = trace - self.state = state + def encountered_states(self) -> set[StateInfo]: + res = set() + + for trace in self.all_traces: + for (scenario, state) in trace: + res.add(state) + + return res + + def encountered_scenario_state_pairs(self) -> set[tuple[ScenarioInfo, StateInfo]]: + res = set() + + for trace in self.all_traces: + for (scenario, state) in trace: + res.add((scenario, state)) + + return res def __repr__(self) -> str: - return f"TraceInfo(trace=[{[str(t) for t in self.trace]}], state={self.state})" - - def contains_scenario(self, scen_name: str) -> bool: - for scenario in self.trace: - if scenario.name == scen_name: - return True - return False - - def add_scenario(self, scen: ScenarioInfo): - """ - Used in acceptance testing - """ - self.trace.append(scen) - - def get_scenario(self, scen_name: str) -> ScenarioInfo | None: - for scenario in self.trace: - if scenario.name == scen_name: - return scenario - return None - - def insert_trace_at(self, index: int, scen_info: ScenarioInfo): - if index < 0 or index >= len(self.trace): - raise IndexError( - f"InsertTraceAt received invalid index ({index}) for length of list ({len(self.trace)})") - - self.trace.insert(index, scen_info) + return f"TraceInfo(traces=[{[f'[{[self.stringify_pair(pair) for pair in trace]}]' for trace in self.all_traces]}], current=[{[self.stringify_pair(pair) for pair in self.current_trace]}])" + + def _sanity_check(self, scen: ScenarioInfo, state: StateInfo, after: str): + (prev_scen, prev_state) = self.current_trace[-1] + if prev_scen != scen: + logger.warn( + f'TraceInfo got out of sync after {after}\nExpected scenario: {prev_scen}\nActual scenario: {scen}') + if prev_state != state: + logger.warn(f'TraceInfo got out of sync after {after}\nExpected state: {prev_state}\nActual state: {state}') + + @staticmethod + def stringify_pair(pair: tuple[ScenarioInfo, StateInfo]) -> str: + return f"Scenario={pair[0].name}, State={pair[1]}" diff --git a/robotmbt/visualise/networkvisualiser.py b/robotmbt/visualise/networkvisualiser.py index 5e4b6f71..6f76e669 100644 --- a/robotmbt/visualise/networkvisualiser.py +++ b/robotmbt/visualise/networkvisualiser.py @@ -1,4 +1,5 @@ from robotmbt.visualise.graphs.abstractgraph import AbstractGraph +from robotmbt.visualise.graphs.scenariostategraph import ScenarioStateGraph from robotmbt.visualise.graphs.stategraph import StateGraph from robotmbt.visualise.graphs.scenariostategraph import ScenarioStateGraph from bokeh.palettes import Spectral4 diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py index fd8eea53..838a2229 100644 --- a/robotmbt/visualise/visualiser.py +++ b/robotmbt/visualise/visualiser.py @@ -1,9 +1,11 @@ +from robotmbt.modelspace import ModelSpace +from robotmbt.tracestate import TraceState from robotmbt.visualise.networkvisualiser import NetworkVisualiser from robotmbt.visualise.graphs.abstractgraph import AbstractGraph from robotmbt.visualise.graphs.scenariograph import ScenarioGraph from robotmbt.visualise.graphs.stategraph import StateGraph from robotmbt.visualise.graphs.scenariostategraph import ScenarioStateGraph -from robotmbt.visualise.models import TraceInfo +from robotmbt.visualise.models import TraceInfo, StateInfo, ScenarioInfo import html @@ -21,23 +23,25 @@ def construct(cls, graph_type: str): return cls(graph_type) def __init__(self, graph_type: str): - if graph_type == 'scenario': - self.graph: AbstractGraph = ScenarioGraph() - elif graph_type == 'state': - self.graph: AbstractGraph = StateGraph() - elif graph_type == 'scenario-state': - self.graph: AbstractGraph = ScenarioStateGraph() - else: + if graph_type != 'scenario' and graph_type != 'state' and graph_type != 'scenario-state': raise ValueError(f"Unknown graph type: {graph_type}!") + self.graph_type: str = graph_type + self.trace_info: TraceInfo = TraceInfo() - def update_visualisation(self, info: TraceInfo): - self.graph.update_visualisation(info) - - def set_final_trace(self, info: TraceInfo): - self.graph.set_final_trace(info) + def update_trace(self, trace: TraceState, state: ModelSpace): + if len(trace.get_trace()) > 0: + self.trace_info.update_trace(ScenarioInfo(trace.get_trace()[-1]), StateInfo(state), len(trace.get_trace())) + else: + self.trace_info.update_trace(None, StateInfo(state), 0) def generate_visualisation(self) -> str: - html_bokeh = NetworkVisualiser(self.graph).generate_html() + if self.graph_type == 'scenario': + graph: AbstractGraph = ScenarioGraph(self.trace_info) + elif self.graph_type == 'state': + graph: AbstractGraph = StateGraph(self.trace_info) + else: + graph: AbstractGraph = ScenarioStateGraph(self.trace_info) + html_bokeh = NetworkVisualiser(graph).generate_html() return f"" + + vis = networkvisualiser.NetworkVisualiser(graph, self.suite_name) + html_bokeh = vis.generate_html() + + graph_size = networkvisualiser.NetworkVisualiser.GRAPH_SIZE_PX + + return f'' \ No newline at end of file From 7a884fa6b67716fab3df08d2c4ca717b6b6e56c9 Mon Sep 17 00:00:00 2001 From: Jonathan <61787386+JWillegers@users.noreply.github.com> Date: Wed, 17 Dec 2025 12:02:56 +0100 Subject: [PATCH 084/131] stategraph labeling: 1 edge with multiple scenarios (#39) --- robotmbt/visualise/graphs/abstractgraph.py | 42 +++++++++++++++++----- utest/test_visualise_abstractgraph.py | 39 ++++++++++++++++++++ 2 files changed, 72 insertions(+), 9 deletions(-) create mode 100644 utest/test_visualise_abstractgraph.py diff --git a/robotmbt/visualise/graphs/abstractgraph.py b/robotmbt/visualise/graphs/abstractgraph.py index 9fef9e7e..05048206 100644 --- a/robotmbt/visualise/graphs/abstractgraph.py +++ b/robotmbt/visualise/graphs/abstractgraph.py @@ -24,28 +24,32 @@ def __init__(self, info: TraceInfo): for trace in info.all_traces: for i in range(len(trace)): if i > 0: - from_node = self._get_or_create_id(self.select_node_info(trace, i - 1)) + from_node = self._get_or_create_id( + self.select_node_info(trace, i - 1)) else: from_node = 'start' - to_node = self._get_or_create_id(self.select_node_info(trace, i)) + to_node = self._get_or_create_id( + self.select_node_info(trace, i)) self._add_node(from_node) self._add_node(to_node) - self.networkx.add_edge(from_node, to_node, - label=self.create_edge_label(self.select_edge_info(trace[i]))) + self._add_edge(from_node, to_node, + self.create_edge_label(self.select_edge_info(trace[i]))) # Set the final trace and add any missing nodes/edges self.final_trace = ['start'] for i in range(len(info.current_trace)): if i > 0: - from_node = self._get_or_create_id(self.select_node_info(info.current_trace, i - 1)) + from_node = self._get_or_create_id( + self.select_node_info(info.current_trace, i - 1)) else: from_node = 'start' - to_node = self._get_or_create_id(self.select_node_info(info.current_trace, i)) + to_node = self._get_or_create_id( + self.select_node_info(info.current_trace, i)) self.final_trace.append(to_node) self._add_node(from_node) self._add_node(to_node) - self.networkx.add_edge(from_node, to_node, - label=self.create_edge_label(self.select_edge_info(info.current_trace[i]))) + self._add_edge(from_node, to_node, + self.create_edge_label(self.select_edge_info(info.current_trace[i]))) def get_final_trace(self) -> list[str]: """ @@ -71,7 +75,27 @@ def _add_node(self, node: str): Add node if it doesn't already exist. """ if node not in self.networkx.nodes: - self.networkx.add_node(node, label=self.create_node_label(self.ids[node])) + self.networkx.add_node( + node, label=self.create_node_label(self.ids[node])) + + def _add_edge(self, from_node: str, to_node: str, label: str): + """ + Add edge if it doesn't already exist. + If edge exists, update the label information + """ + if (from_node, to_node) in self.networkx.edges: + if label == '': + return + old_label = nx.get_edge_attributes(self.networkx, 'label')[ + (from_node, to_node)] + if label in old_label.split('\n'): + return + new_label = old_label + '\n' + label + attr = {(from_node, to_node): {'label': new_label}} + nx.set_edge_attributes(self.networkx, attr) + else: + self.networkx.add_edge( + from_node, to_node, label=label) @staticmethod @abstractmethod diff --git a/utest/test_visualise_abstractgraph.py b/utest/test_visualise_abstractgraph.py new file mode 100644 index 00000000..7d966511 --- /dev/null +++ b/utest/test_visualise_abstractgraph.py @@ -0,0 +1,39 @@ +import unittest + +try: + import networkx as nx + from robotmbt.visualise.graphs.stategraph import StateGraph + from robotmbt.visualise.models import * + + VISUALISE = True +except ImportError: + VISUALISE = False + +if VISUALISE: + class TestVisualiseAbstractGraph(unittest.TestCase): + def test_abstract_graph_add_edge_labels_for_state_graph_self_loop(self): + """ + Testing the case where on an edge "scenario2" occurs twice + Without the "(rep x)" present + """ + info = TraceInfo() + + scenario1 = ScenarioInfo('scenario1') + scenario2 = ScenarioInfo('scenario2') + scenario3 = ScenarioInfo('scenario3') + + space1 = StateInfo._create_state_with_prop( + "prop", [("value", "some_value")]) + + info.update_trace(scenario1, space1, 1) + info.update_trace(scenario2, space1, 2) + info.update_trace(scenario3, space1, 3) + info.update_trace(scenario2, space1, 4) + + sg = StateGraph(info) + labels = nx.get_edge_attributes(sg.networkx, 'label') + self.assertEqual(labels[('node0', 'node0')], + 'scenario2\nscenario3') + +if __name__ == '__main__': + unittest.main() From 47613ee33892d1e32e816167361ec58c37fff3f2 Mon Sep 17 00:00:00 2001 From: tychodub <93142605+tychodub@users.noreply.github.com> Date: Wed, 17 Dec 2025 12:13:56 +0100 Subject: [PATCH 085/131] Major fix reducedSDV (#40) * changed part of abstractgraph interface for delta graph development * implemented scenariodeltavaluegraph * implemented reduced-scenariodeltavaluegraph, but should still discuss how to label equivalence classes (currently just picks a representative). Fixed part of vertex info being indicated as edge info for scenariodeltavaluegraph. Trace highlighting is still broken for reducedSDV * removed redundant code in __init__ of reducedSDVgraph and added code to update final_trace to make trace highlighting work * initial test commit for SDVGraphs * documentation for SDV graphs * added TODO comment * implemented comments from Douwe (added comments and reset graph setting in suiteprocessors.py) * fixed reducedSDV graph to have a properly functioning equivalence relation --- robotmbt/visualise/graphs/reducedSDVgraph.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/robotmbt/visualise/graphs/reducedSDVgraph.py b/robotmbt/visualise/graphs/reducedSDVgraph.py index 1667392f..4568db64 100644 --- a/robotmbt/visualise/graphs/reducedSDVgraph.py +++ b/robotmbt/visualise/graphs/reducedSDVgraph.py @@ -18,8 +18,9 @@ class ReducedSDVGraph(AbstractGraph[tuple[ScenarioInfo, set[tuple[str, str]]], N def chain_equiv(self, node1, node2) -> bool: context = self.networkx if not node1 == 'start' and not node2 == 'start' and self.ids[node1][0] == self.ids[node2][0] and \ - (context.has_edge(node1, node2) or context.has_edge(node2, node1)): - return len(set(context.edges(node1)) ^ set(context.edges(node2))) <= 2 + (networkx.has_path(context, node1, node2) or networkx.has_path(context, node2, node1)): + return len(set(context.in_edges(node1)) ^ set(context.in_edges(node2))) <= 2 and \ + len(set(context.out_edges(node1)) ^ set(context.out_edges(node2))) <= 2 else: return False From 06c0123b0f65cc8df6fe8ef63a6fefcec99fb8ed Mon Sep 17 00:00:00 2001 From: tychodub <93142605+tychodub@users.noreply.github.com> Date: Wed, 17 Dec 2025 14:10:16 +0100 Subject: [PATCH 086/131] Start node property (#41) * implemented scenariodeltavaluegraph * implemented reduced-scenariodeltavaluegraph, but should still discuss how to label equivalence classes (currently just picks a representative). Fixed part of vertex info being indicated as edge info for scenariodeltavaluegraph. Trace highlighting is still broken for reducedSDV * implemented comments from Douwe (added comments and reset graph setting in suiteprocessors.py) * fixed reducedSDV graph to have a properly functioning equivalence relation * fixed layout for reducedSDV graphs --- robotmbt/visualise/graphs/abstractgraph.py | 1 + robotmbt/visualise/graphs/reducedSDVgraph.py | 4 +++- robotmbt/visualise/networkvisualiser.py | 4 ++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/robotmbt/visualise/graphs/abstractgraph.py b/robotmbt/visualise/graphs/abstractgraph.py index 05048206..870f020d 100644 --- a/robotmbt/visualise/graphs/abstractgraph.py +++ b/robotmbt/visualise/graphs/abstractgraph.py @@ -19,6 +19,7 @@ def __init__(self, info: TraceInfo): # Add the start node self.networkx.add_node('start', label='start') + self.start_node = 'start' # Add nodes and edges for all traces for trace in info.all_traces: diff --git a/robotmbt/visualise/graphs/reducedSDVgraph.py b/robotmbt/visualise/graphs/reducedSDVgraph.py index 4568db64..eb9c6c8a 100644 --- a/robotmbt/visualise/graphs/reducedSDVgraph.py +++ b/robotmbt/visualise/graphs/reducedSDVgraph.py @@ -1,10 +1,11 @@ import networkx -from robotmbt.modelspace import ModelSpace +from robotmbt.modelspace import ModelSpace from robotmbt.visualise.graphs.abstractgraph import AbstractGraph from robotmbt.visualise.graphs.scenariodeltavaluegraph import ScenarioDeltaValueGraph from robotmbt.visualise.models import ScenarioInfo, StateInfo, TraceInfo + # TODO add tests for this graph representation class ReducedSDVGraph(AbstractGraph[tuple[ScenarioInfo, set[tuple[str, str]]], None]): """ @@ -38,6 +39,7 @@ def __init__(self, info: TraceInfo): for new_node in nodes: if current_node in new_node: self.final_trace[i] = new_node + self.start_node = frozenset(['start']) @staticmethod def select_node_info(pairs: list[tuple[ScenarioInfo, StateInfo]], index: int) \ diff --git a/robotmbt/visualise/networkvisualiser.py b/robotmbt/visualise/networkvisualiser.py index 49ed2373..2e6cabc9 100644 --- a/robotmbt/visualise/networkvisualiser.py +++ b/robotmbt/visualise/networkvisualiser.py @@ -147,7 +147,7 @@ def _add_nodes_with_labels(self): node_color = self.EXECUTED_NODE_COLOR if is_executed else self.UNEXECUTED_NODE_COLOR text_color = self.EXECUTED_TEXT_COLOR if is_executed else self.UNEXECUTED_TEXT_COLOR - if node == 'start': + if node == self.graph.start_node: # For start node (circle), calculate radius based on text width text_width, text_height = self._calculate_text_dimensions( label) @@ -440,7 +440,7 @@ def _cap_name(self, name: str) -> str: def _calculate_graph_layout(self): try: self.graph_layout = nx.bfs_layout( - self.graph.networkx, 'start', align='horizontal') + self.graph.networkx, self.graph.start_node, align='horizontal') # horizontal mirror for node in self.graph_layout: self.graph_layout[node] = (self.graph_layout[node][0], From a9f7e8b812e2756826d230734340542e724817af Mon Sep 17 00:00:00 2001 From: Jonathan <61787386+JWillegers@users.noreply.github.com> Date: Sat, 27 Dec 2025 19:21:03 +0100 Subject: [PATCH 087/131] Export json (#44) * export and import to/from json * export option in suiteprocessors * moved import from json from visualiser.py to suiteprocessors.py * [WIP] atest for importing * remove json file * revert gitignore to make json folder fully ignored * [WIP] generate test suite * complete atest for importing/exporting * renaming file and folder of .robot file * made test run with robotmbt and generate graph * updated test suite * fix line length * fixing comments not related to atest * deleting "JSON" as it is not specific to JSON * Added docstring and made a keyword more generic * Added documentation about why the usage of mkstemp instead of temporary file --- .gitignore | 3 + atest/resources/helpers/__init__.py | 0 atest/resources/helpers/modelgenerator.py | 84 +++++++++++ atest/resources/visualisation.resource | 134 ++++++++++++++++++ .../C.2.3__export to JSON.robot | 35 +++++ pyproject.toml | 2 +- robotmbt/suiteprocessors.py | 127 +++++++++++------ robotmbt/visualise/models.py | 40 +++++- robotmbt/visualise/visualiser.py | 22 ++- 9 files changed, 395 insertions(+), 52 deletions(-) create mode 100644 atest/resources/helpers/__init__.py create mode 100644 atest/resources/helpers/modelgenerator.py create mode 100644 atest/resources/visualisation.resource create mode 100644 atest/robotMBT tests/10__visualisation_tests/C.2.3__export to JSON.robot diff --git a/.gitignore b/.gitignore index 3ab9aefe..ce6f945a 100644 --- a/.gitignore +++ b/.gitignore @@ -160,3 +160,6 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. .idea/ + +# json documents for graphs +json/ \ No newline at end of file diff --git a/atest/resources/helpers/__init__.py b/atest/resources/helpers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/atest/resources/helpers/modelgenerator.py b/atest/resources/helpers/modelgenerator.py new file mode 100644 index 00000000..83270285 --- /dev/null +++ b/atest/resources/helpers/modelgenerator.py @@ -0,0 +1,84 @@ +import jsonpickle +from robot.api.deco import keyword # type:ignore +from robotmbt.visualise.models import TraceInfo, ScenarioInfo, StateInfo +from robotmbt.visualise.visualiser import Visualiser +import os + + +class ModelGenerator: + @keyword(name='Generate Trace Information') # type:ignore + def generate_trace_information(self) -> TraceInfo: + return TraceInfo() + + @keyword(name='Current Trace Contains') # type:ignore + def current_trace_contains(self, trace_info: TraceInfo, scenario_name: str, state_str: str) -> TraceInfo: + ''' + State should be of format + "name: key=value" + ''' + scenario = ScenarioInfo(scenario_name) + s = state_str.split(': ') + key, item = s[1].split('=') + state = StateInfo._create_state_with_prop(s[0], [(key, item)]) + trace_info.update_trace(scenario, state, trace_info.previous_length+1) + + return trace_info + + @keyword(name='All Traces Contains List') # type:ignore + def all_traces_contains_list(self, trace_info: TraceInfo) -> TraceInfo: + trace_info.all_traces.append([]) + return trace_info + + @keyword(name='All Traces Contains') # type:ignore + def all_traces_contains(self, trace_info: TraceInfo, scenario_name: str, state_str: str) -> TraceInfo: + ''' + State should be of format + "name: key=value" + ''' + scenario = ScenarioInfo(scenario_name) + s = state_str.split(': ') + key, item = s[1].split('=') + state = StateInfo._create_state_with_prop(s[0], [(key, item)]) + + trace_info.all_traces[0].append((scenario, state)) + + return trace_info + + @keyword(name='Export Graph') # type:ignore + def export_to_json(self, suite:str, trace_info: TraceInfo) -> str: + return trace_info.export_graph(suite, True) + + @keyword(name='Import JSON File') # type:ignore + def import_json_file(self, filepath: str) -> TraceInfo: + with open(filepath, 'r') as f: + string = f.read() + decoded_instance = jsonpickle.decode(string) + visualiser = Visualiser('state', trace_info=decoded_instance) + return visualiser.trace_info + + @keyword(name='Check File Exists') # type:ignore + def check_file_exists(self, filepath: str) -> str: + ''' + Checks if file exists + + Returns string for .resource error message in case values are not equal + Expected != result + ''' + return 'file exists' if os.path.exists(filepath) else 'file does not exist' + + @keyword(name='Compare Trace Info') # type:ignore + def compare_trace_info(self, t1: TraceInfo, t2: TraceInfo) -> str: + ''' + Checks if current trace and all traces of t1 and t2 are equal + + Returns string for .resource error message in case values are not equal + Expected != result + ''' + succes = 'imported model equals exported model' + fail = 'imported models differs from exported model' + return succes if repr(t1) == repr(t2) else fail + + @keyword(name='Delete File') # type:ignore + def delete_json_file(self, filepath: str): + os.remove(filepath) + diff --git a/atest/resources/visualisation.resource b/atest/resources/visualisation.resource new file mode 100644 index 00000000..1e99eac1 --- /dev/null +++ b/atest/resources/visualisation.resource @@ -0,0 +1,134 @@ +*** Settings *** +Documentation Resource file for testing the visualisation of RobotMBT +Library atest.resources.helpers.modelgenerator.ModelGenerator +Library Collections + + +*** Keywords *** +test suite ${suite} + [Documentation] *model info* + ... :IN: None + ... :OUT: new suite + Set Suite Variable ${suite} + +test suite ${suite} has trace info ${trace} + [Documentation] *model info* + ... :IN: suite + ... :OUT: new trace_info + Variable Should Exist ${suite} + + ${trace_info} = Generate Trace Information + Set Suite Variable ${trace_info} + +trace info ${trace} has current trace ${current} + [Documentation] *model info* + ... :IN: trace_info + ... :OUT: trace_info.current_trace=[] + Variable Should Exist ${trace_info} + +current trace ${current_trace} has a tuple 'scenario ${scenario}, state ${state}' + [Documentation] *model info* + ... :IN: trace_info.current_trace + ... :OUT: trace_info.current_trace.append((${scenario}, ${state})) + Variable Should Exist ${trace_info} + + ${trace_info} = Current Trace Contains ${trace_info} ${scenario} ${state} + Set Suite Variable ${trace_info} + +trace info ${trace} has all traces ${all} + [Documentation] *model info* + ... :IN: trace_info + ... :OUT: trace_info.all_traces=[] + Variable Should Exist ${trace_info} + +all traces ${all} has list ${list} + [Documentation] *model info* + ... :IN: trace_info + ... :OUT: trace_info.all_traces.append([]) + Variable Should Exist ${trace_info} + + ${trace_info} = All Traces Contains List ${trace_info} + Set Suite Variable ${trace_info} + +list ${list} has a tuple 'scenario ${scenario}, state ${state}' + [Documentation] *model info* + ... :IN: trace_info.all_traces + ... :OUT: trace_info.all_traces.append((${scenario}, ${state})) + Variable Should Exist ${trace_info} + + ${trace_info} = All Traces Contains ${trace_info} ${scenario} ${state} + Set Suite Variable ${trace_info} + +test suite ${suite} contains trace info ${ti} + [Documentation] *model info* + ... :IN: suite, trace_info + ... :OUT: suite, trace_info + Variable Should Exist ${suite} + Variable Should Exist ${trace_info} + +test suite ${suite} is exported to json + [Documentation] *model info* + ... :IN: suite, trace_info + ... :OUT: new filepath + Variable Should Exist ${suite} + Variable Should Exist ${trace_info} + + ${filepath} = Export Graph ${suite} ${trace_info} + Set Suite Variable ${filepath} + +the file ${filename} exists + [Documentation] *model info* + ... :IN: suite, filepath + ... :OUT: suite, filepath + Variable Should Exist ${filepath} + + ${result} = Check File Exists ${filepath} + Should Be Equal ${result} file exists + +${filename} is imported + [Documentation] *model info* + ... :IN: suite, filepath + ... :OUT: new new_trace_info + Variable Should Exist ${filepath} + + ${new_trace_info} = Import JSON File ${filepath} + Set Suite Variable ${new_trace_info} + +trace info from ${filename} is the same as trace info ${trace} + [Documentation] *model info* + ... :IN: new_trace_info, trace_info + ... :OUT: new flag_cleanup + Variable Should Exist ${trace_info} + Variable Should Exist ${new_trace_info} + + ${result} = Compare Trace Info ${trace_info} ${new_trace_info} + Should Be Equal ${result} imported model equals exported model + +${filename} is deleted + [Documentation] *model info* + ... :IN: filepath + ... :OUT: filepath + Variable Should Exist ${filepath} + + Delete File ${filepath} + +${filename} does not exist + [Documentation] *model info* + ... :IN: filepath + ... :OUT: filepath + Variable Should Exist ${filepath} + + ${result} = Check File Exists ${filepath} + Should Be Equal ${result} file does not exist + +flag cleanup is set + [Documentation] *model info* + ... :IN: suite + ... :OUT: suite + Set Suite Variable ${flag_cleanup} True + +flag cleanup has been set + [Documentation] *model info* + ... :IN: flag_cleanup + ... :OUT: flag_cleanup + Variable Should Exist ${flag_cleanup} diff --git a/atest/robotMBT tests/10__visualisation_tests/C.2.3__export to JSON.robot b/atest/robotMBT tests/10__visualisation_tests/C.2.3__export to JSON.robot new file mode 100644 index 00000000..bf3e309a --- /dev/null +++ b/atest/robotMBT tests/10__visualisation_tests/C.2.3__export to JSON.robot @@ -0,0 +1,35 @@ +*** Settings *** +Documentation Export and import a test suite from and to JSON +... and check that the imported suite equals the +... exported suite. +Suite Setup Treat this test suite Model-based graph=scenario +Resource ../../resources/visualisation.resource +Library robotmbt + +*** Test Cases *** +Create test suite + Given test suite s + Then test suite s has trace info t + and trace info t has current trace c + and current trace c has a tuple 'scenario i, state p: v=1' + and current trace c has a tuple 'scenario j, state p: v=2' + and trace info t has all traces a + and all traces a has list l + and list l has a tuple 'scenario i, state p: v=2' + +Export test suite to json file + Given test suite s contains trace info t + When test suite s is exported to json + Then the file s.json exists + +Load json file into robotmbt + Given the file s.json exists + When s.json is imported + Then trace info from s.json is the same as trace info t + and flag cleanup is set + +Cleanup + Given the file s.json exists + and flag cleanup has been set + When s.json is deleted + Then s.json does not exist \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 505bbf4f..9af8d807 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,4 +30,4 @@ include = ["robotmbt*"] Homepage = "https://github.com/JFoederer/robotframeworkMBT" [project.optional-dependencies] -visualization = ["numpy","networkx","bokeh"] +visualization = ["numpy","networkx","bokeh","jsonpickle"] diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index 89be9dd7..73192b5d 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -88,25 +88,43 @@ def flatten(self, in_suite: Suite) -> Suite: out_suite.suites = [] return out_suite - def process_test_suite(self, in_suite: Suite, *, seed: any = 'new', graph: str = '') -> Suite: + def process_test_suite(self, in_suite: Suite, *, seed: any = 'new', graph: str = '', + to_json: bool = False, from_json: str = 'false') -> Suite: self.out_suite = Suite(in_suite.name) self.out_suite.filename = in_suite.filename self.out_suite.parent = in_suite.parent self._fail_on_step_errors(in_suite) self.flat_suite = self.flatten(in_suite) + if from_json != 'false': + self._load_graph(graph, in_suite.name, from_json) + + else: + self._run_test_suite(seed, graph, in_suite.name, to_json) + + self.__write_visualisation() + + return self.out_suite + + def _load_graph(self, graph:str, suite_name: str, from_json: str): + traceinfo = TraceInfo() + traceinfo = traceinfo.import_graph(from_json) + self.visualiser = Visualiser( + graph, suite_name, trace_info=traceinfo) + + def _run_test_suite(self, seed: any, graph: str, suite_name: str, to_json: bool): for id, scenario in enumerate(self.flat_suite.scenarios, start=1): scenario.src_id = id self.scenarios = self.flat_suite.scenarios[:] logger.debug("Use these numbers to reference scenarios from traces\n\t" + - "\n\t".join([f"{s.src_id}: {s.name}" for s in self.scenarios])) + "\n\t".join([f"{s.src_id}: {s.name}" for s in self.scenarios])) self._init_randomiser(seed) random.shuffle(self.scenarios) self.visualiser = None if graph != '' and VISUALISE: - self.visualiser = Visualiser(graph, in_suite.name) # Pass suite name + self.visualiser = Visualiser(graph, suite_name, to_json) # Pass suite name elif graph != '' and not VISUALISE: logger.warn(f'Visualisation {graph} requested, but required dependencies are not installed.' 'Install them with `pip install .[visualization]`.') @@ -124,15 +142,12 @@ def process_test_suite(self, in_suite: Suite, *, seed: any = 'new', graph: str = self.out_suite.scenarios = self.tracestate.get_trace() self._report_tracestate_wrapup() - self.__write_visualisation() - - return self.out_suite - def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool): self.tracestate = TraceState(len(self.scenarios)) self.active_model = ModelSpace() while not self.tracestate.coverage_reached(): - i_candidate = self.tracestate.next_candidate(retry=allow_duplicate_scenarios) + i_candidate = self.tracestate.next_candidate( + retry=allow_duplicate_scenarios) if i_candidate is None: if not self.tracestate.can_rewind(): break @@ -151,7 +166,8 @@ def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool): if inserted: self.DROUGHT_LIMIT = 50 if self.__last_candidate_changed_nothing(): - logger.debug("Repeated scenario did not change the model's state. Stop trying.") + logger.debug( + "Repeated scenario did not change the model's state. Stop trying.") self._rewind() elif self.tracestate.coverage_drought > self.DROUGHT_LIMIT: @@ -202,7 +218,8 @@ def _fail_on_step_errors(suite: Suite): raise Exception(err_msg) def _try_to_fit_in_scenario(self, index: int, candidate: Scenario, retry_flag: bool) -> bool: - candidate = self._generate_scenario_variant(candidate, self.active_model) + candidate = self._generate_scenario_variant( + candidate, self.active_model) if not candidate: self.active_model.end_scenario_scope() @@ -210,19 +227,23 @@ def _try_to_fit_in_scenario(self, index: int, candidate: Scenario, retry_flag: b self._report_tracestate_to_user() return False - confirmed_candidate, new_model = self._process_scenario(candidate, self.active_model) + confirmed_candidate, new_model = self._process_scenario( + candidate, self.active_model) if confirmed_candidate: self.active_model = new_model self.active_model.end_scenario_scope() - self.tracestate.confirm_full_scenario(index, confirmed_candidate, self.active_model) - logger.debug(f"Inserted scenario {confirmed_candidate.src_id}, {confirmed_candidate.name}") + self.tracestate.confirm_full_scenario( + index, confirmed_candidate, self.active_model) + logger.debug( + f"Inserted scenario {confirmed_candidate.src_id}, {confirmed_candidate.name}") self._report_tracestate_to_user() logger.debug(f"last state:\n{self.active_model.get_status_text()}") self.__update_visualisation() return True - part1, part2 = self._split_candidate_if_refinement_needed(candidate, self.active_model) + part1, part2 = self._split_candidate_if_refinement_needed( + candidate, self.active_model) if part2: exit_conditions = part2.steps[1].model_info['OUT'] part1.name = f"{part1.name} (part {self.tracestate.highest_part(index) + 1})" @@ -234,7 +255,8 @@ def _try_to_fit_in_scenario(self, index: int, candidate: Scenario, retry_flag: b i_refine = self.tracestate.next_candidate(retry=retry_flag) if i_refine is None: - logger.debug("Refinement needed, but there are no scenarios left") + logger.debug( + "Refinement needed, but there are no scenarios left") self._rewind() self._report_tracestate_to_user() self.__update_visualisation() @@ -259,14 +281,18 @@ def _try_to_fit_in_scenario(self, index: int, candidate: Scenario, retry_flag: b insert_valid_here = False if insert_valid_here: - m_finished = self._try_to_fit_in_scenario(index, part2, retry_flag) + m_finished = self._try_to_fit_in_scenario( + index, part2, retry_flag) if m_finished: self.__update_visualisation() return True else: - logger.debug(f"Scenario did not meet refinement conditions {exit_conditions}") - logger.debug(f"last state:\n{self.active_model.get_status_text()}") - logger.debug(f"Reconsidering {self.scenarios[i_refine].name}, scenario excluded") + logger.debug( + f"Scenario did not meet refinement conditions {exit_conditions}") + logger.debug( + f"last state:\n{self.active_model.get_status_text()}") + logger.debug( + f"Reconsidering {self.scenarios[i_refine].name}, scenario excluded") self._rewind() self._report_tracestate_to_user() @@ -319,7 +345,8 @@ def _split_candidate_if_refinement_needed(scenario: Scenario, model: ModelSpace) try: if m.process_expression(expr, step.args) is False: if step.gherkin_kw in ['when', None]: - logger.debug(f"Refinement needed for scenario: {scenario.name}\nat step: {step}") + logger.debug( + f"Refinement needed for scenario: {scenario.name}\nat step: {step}") refine_here = True else: return no_split @@ -328,17 +355,22 @@ def _split_candidate_if_refinement_needed(scenario: Scenario, model: ModelSpace) return no_split if refine_here: - front, back = scenario.split_at_step(scenario.steps.index(step)) + front, back = scenario.split_at_step( + scenario.steps.index(step)) remaining_steps = '\n\t'.join( [step.full_keyword, '- ' * 35] + [s.full_keyword for s in back.steps[1:]]) - remaining_steps = SuiteProcessors.escape_robot_vars(remaining_steps) - edge_step = Step('Log', f"Refinement follows for step:\n\t{remaining_steps}", parent=scenario) + remaining_steps = SuiteProcessors.escape_robot_vars( + remaining_steps) + edge_step = Step( + 'Log', f"Refinement follows for step:\n\t{remaining_steps}", parent=scenario) edge_step.gherkin_kw = step.gherkin_kw - edge_step.model_info = dict(IN=step.model_info['IN'], OUT=[]) + edge_step.model_info = dict( + IN=step.model_info['IN'], OUT=[]) edge_step.detached = True edge_step.args = StepArguments(step.args) front.steps.append(edge_step) - back.steps.insert(0, Step('Log', f"Refinement ready, completing step", parent=scenario)) + back.steps.insert( + 0, Step('Log', f"Refinement ready, completing step", parent=scenario)) back.steps[1] = back.steps[1].copy() back.steps[1].model_info['IN'] = [] return (front, back) @@ -358,7 +390,8 @@ def _process_scenario(scenario: Scenario, model: ModelSpace) -> tuple[Scenario, scenario = scenario.copy() for step in scenario.steps: if 'error' in step.model_info: - logger.debug(f"Error in scenario {scenario.name} at step {step}: {step.model_info['error']}") + logger.debug( + f"Error in scenario {scenario.name} at step {step}: {step.model_info['error']}") return None, None for expr in SuiteProcessors._relevant_expressions(step): @@ -405,7 +438,8 @@ def _generate_scenario_variant(self, scenario: Scenario, model: ModelSpace) -> S for step in scenario.steps: if 'MOD' in step.model_info: for expr in step.model_info['MOD']: - modded_arg, constraint = self._parse_modifier_expression(expr, step.args) + modded_arg, constraint = self._parse_modifier_expression( + expr, step.args) if step.args[modded_arg].is_default: continue if step.args[modded_arg].kind in [StepArgument.EMBEDDED, StepArgument.POSITIONAL, @@ -418,36 +452,46 @@ def _generate_scenario_variant(self, scenario: Scenario, model: ModelSpace) -> S subs.substitute(org_example, [org_example]) continue if not constraint and org_example not in subs.substitutions: - raise ValueError(f"No options to choose from at first assignment to {org_example}") + raise ValueError( + f"No options to choose from at first assignment to {org_example}") if constraint and constraint != '.*': - options = m.process_expression(constraint, step.args) + options = m.process_expression( + constraint, step.args) if options == 'exec': - raise ValueError(f"Invalid constraint for argument substitution: {expr}") + raise ValueError( + f"Invalid constraint for argument substitution: {expr}") if not options: - raise ValueError(f"Constraint on modifer did not yield any options: {expr}") + raise ValueError( + f"Constraint on modifer did not yield any options: {expr}") if not is_list_like(options): - raise ValueError(f"Constraint on modifer did not yield a set of options: {expr}") + raise ValueError( + f"Constraint on modifer did not yield a set of options: {expr}") else: options = None subs.substitute(org_example, options) elif step.args[modded_arg].kind == StepArgument.VAR_POS: if step.args[modded_arg].value: - modded_varargs = m.process_expression(constraint, step.args) + modded_varargs = m.process_expression( + constraint, step.args) if not is_list_like(modded_varargs): - raise ValueError(f"Modifying varargs must yield a list of arguments") + raise ValueError( + f"Modifying varargs must yield a list of arguments") # Varargs are not added to the substitution map, but are used directly as-is. A modifier can # change the number of arguments in the list, making it impossible to decide which values to # match and which to drop and/or duplicate. step.args[modded_arg].value = modded_varargs elif step.args[modded_arg].kind == StepArgument.FREE_NAMED: if step.args[modded_arg].value: - modded_free_args = m.process_expression(constraint, step.args) + modded_free_args = m.process_expression( + constraint, step.args) if not isinstance(modded_free_args, dict): - raise ValueError("Modifying free named arguments must yield a dict") + raise ValueError( + "Modifying free named arguments must yield a dict") # Similar to varargs, modified free named arguments are used directly as-is. step.args[modded_arg].value = modded_free_args else: - raise AssertionError(f"Unknown argument kind for {modded_arg}") + raise AssertionError( + f"Unknown argument kind for {modded_arg}") except Exception as err: logger.debug(f"Unable to insert scenario {scenario.src_id}, {scenario.name}, due to modifier\n" f" In step {step}: {err}") @@ -462,13 +506,15 @@ def _generate_scenario_variant(self, scenario: Scenario, model: ModelSpace) -> S # Update scenario with generated values if subs.solution: - logger.debug(f"Example variant generated with argument substitution: {subs}") + logger.debug( + f"Example variant generated with argument substitution: {subs}") scenario.data_choices = subs for step in scenario.steps: if 'MOD' in step.model_info: for expr in step.model_info['MOD']: - modded_arg, _ = self._parse_modifier_expression(expr, step.args) + modded_arg, _ = self._parse_modifier_expression( + expr, step.args) if step.args[modded_arg].is_default: continue org_example = step.args[modded_arg].org_value @@ -482,7 +528,8 @@ def _parse_modifier_expression(expression: str, args: StepArguments) -> tuple[st if expression.startswith('${'): for var in args: if expression.casefold().startswith(var.arg.casefold()): - assignment_expr = expression.replace(var.arg, '', 1).strip() + assignment_expr = expression.replace( + var.arg, '', 1).strip() if not assignment_expr.startswith('=') or assignment_expr.startswith('=='): break # not an assignment diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index 1fb075b8..457f07fa 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -5,6 +5,10 @@ from robotmbt.modelspace import ModelSpace from robotmbt.suitedata import Scenario +import jsonpickle +import tempfile +import os + class ScenarioInfo: """ @@ -53,10 +57,10 @@ def difference(self, new_state) -> set[tuple[str, str]]: right: dict[str, dict | str] = new_state.properties.copy() for key in right.keys(): right[key] = str(right[key]) - temp: set[tuple[str, str]] = set(right.items()) - set(left.items()) # type inference goes doodoo here + # type inference goes doodoo here + temp: set[tuple[str, str]] = set(right.items()) - set(left.items()) return temp - def __init__(self, state: ModelSpace): self.domain = state.ref_id @@ -104,6 +108,7 @@ def __init__(self): self.all_traces: list[list[tuple[ScenarioInfo, StateInfo]]] = [] self.previous_length: int = 0 self.pushed: bool = False + self.path = "json/" def update_trace(self, scenario: ScenarioInfo | None, state: StateInfo, length: int): if length > self.previous_length: @@ -125,7 +130,8 @@ def update_trace(self, scenario: ScenarioInfo | None, state: StateInfo, length: def _push(self, scenario: ScenarioInfo, state: StateInfo, n: int): if n > 1: - logger.warn(f"Pushing multiple scenario/state pairs at once to trace info ({n})! Some info might be lost!") + logger.warn( + f"Pushing multiple scenario/state pairs at once to trace info ({n})! Some info might be lost!") for _ in range(n): self.current_trace.append((scenario, state)) self.pushed = True @@ -173,7 +179,33 @@ def _sanity_check(self, scen: ScenarioInfo, state: StateInfo, after: str): logger.warn( f'TraceInfo got out of sync after {after}\nExpected scenario: {prev_scen}\nActual scenario: {scen}') if prev_state != state: - logger.warn(f'TraceInfo got out of sync after {after}\nExpected state: {prev_state}\nActual state: {state}') + logger.warn( + f'TraceInfo got out of sync after {after}\nExpected state: {prev_state}\nActual state: {state}') + + def export_graph(self, suite_name: str, atest: bool = False) -> str | None: + encoded_instance = jsonpickle.encode(self) + name = suite_name.lower().replace(' ', '_') + if atest: + ''' + temporary file to not accidentaly overwrite an existing file + mkstemp() is not ideal but given Python's limitations this is the easiest solution + as temporary file, a different method, is problamatic on Windows + https://stackoverflow.com/a/57015383 + ''' + fd, path = tempfile.mkstemp() + with os.fdopen(fd, "w") as f: + f.write(encoded_instance) + return path + + with open(f"{self.path}{name}.json", "w") as f: + f.write(encoded_instance) + return None + + def import_graph(self, file_name: str): + with open(f"{self.path}{file_name}.json", "r") as f: + string = f.read() + self = jsonpickle.decode(string) + @staticmethod def stringify_pair(pair: tuple[ScenarioInfo, StateInfo]) -> str: diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py index b50f086b..f5c69c0f 100644 --- a/robotmbt/visualise/visualiser.py +++ b/robotmbt/visualise/visualiser.py @@ -24,22 +24,30 @@ def construct(cls, graph_type: str): # just calls __init__, but without having underscores etc. return cls(graph_type) - def __init__(self, graph_type: str, suite_name: str = ""): + def __init__(self, graph_type: str, suite_name: str = "", export: bool = False, trace_info: TraceInfo = None): if graph_type != 'scenario' and graph_type != 'state' and graph_type != 'scenario-state' \ and graph_type != 'scenario-delta-value' and graph_type != 'reduced-sdv': raise ValueError(f"Unknown graph type: {graph_type}!") self.graph_type: str = graph_type - self.trace_info: TraceInfo = TraceInfo() + if trace_info == None: + self.trace_info: TraceInfo = TraceInfo() + else: + self.trace_info = trace_info self.suite_name = suite_name + self.export = export def update_trace(self, trace: TraceState, state: ModelSpace): if len(trace.get_trace()) > 0: - self.trace_info.update_trace(ScenarioInfo(trace.get_trace()[-1]), StateInfo(state), len(trace.get_trace())) + self.trace_info.update_trace(ScenarioInfo( + trace.get_trace()[-1]), StateInfo(state), len(trace.get_trace())) else: self.trace_info.update_trace(None, StateInfo(state), 0) def generate_visualisation(self) -> str: + if self.export: + self.trace_info.export_graph(self.suite_name) + if self.graph_type == 'scenario': graph: AbstractGraph = ScenarioGraph(self.trace_info) elif self.graph_type == 'state': @@ -50,10 +58,10 @@ def generate_visualisation(self) -> str: graph: AbstractGraph = ReducedSDVGraph(self.trace_info) else: graph: AbstractGraph = ScenarioStateGraph(self.trace_info) - + vis = networkvisualiser.NetworkVisualiser(graph, self.suite_name) html_bokeh = vis.generate_html() - + graph_size = networkvisualiser.NetworkVisualiser.GRAPH_SIZE_PX - - return f'' \ No newline at end of file + + return f'' From 8562230dbc22aecedead47174612e124fffb9a41 Mon Sep 17 00:00:00 2001 From: Thomas Kas Date: Sat, 3 Jan 2026 15:46:25 +0100 Subject: [PATCH 088/131] Sugiyama layout (#45) * Make HTML generation static * Begin visualisation rework - nodes * Move some code * Import full screen and title from Diogo * Sugiyama layout * Rescale when zooming * Draw edges * Align edge labels center * Make HTML generation static * Begin visualisation rework - nodes * Move some code * Import full screen and title from Diogo * Sugiyama layout * Rescale when zooming * Draw edges * Align edge labels center * Fix minor merge issues * Format delta value the same as state * Inline mini static method * Make HTML generation static * Begin visualisation rework - nodes * Move some code * Import full screen and title from Diogo * Sugiyama layout * Rescale when zooming * Draw edges * Align edge labels center * Make HTML generation static * Begin visualisation rework - nodes * Move some code * Import full screen and title from Diogo * Sugiyama layout * Rescale when zooming * Draw edges * Align edge labels center * Fix minor merge issues * Format delta value the same as state * Inline mini static method * Fix fails * Remove unused dependency * Remove static method * Remove static method * Connect edges at the edge of nodes * Format * Remove unused constants * They weren't unused * Now they are * Extract functions * Self-loops * Extract constants * Scale properly on full-screen * Scale arrows as well * Color edges * Documentation * Add legend * Single comment * Change xrange based on aspect ratio * Floor font size to make it stay within nodes * Simplify resize callback * Include seed under graph title * Sort items to ensure equal ordering * Make HTML generation static * Begin visualisation rework - nodes * Move some code * Import full screen and title from Diogo * Sugiyama layout * Rescale when zooming * Draw edges * Align edge labels center * Make HTML generation static * Begin visualisation rework - nodes * Move some code * Import full screen and title from Diogo * Sugiyama layout * Rescale when zooming * Draw edges * Align edge labels center * Fix minor merge issues * Format delta value the same as state * Inline mini static method * Begin visualisation rework - nodes * Fix minor merge issues * Fix fails * Remove unused dependency * Remove static method * Remove static method * Connect edges at the edge of nodes * Format * Remove unused constants * They weren't unused * Now they are * Extract functions * Self-loops * Extract constants * Scale properly on full-screen * Scale arrows as well * Color edges * Documentation * Add legend * Single comment * Change xrange based on aspect ratio * Floor font size to make it stay within nodes * Simplify resize callback * Include seed under graph title * Sort items to ensure equal ordering --- pyproject.toml | 2 +- robotmbt/suiteprocessors.py | 13 +- robotmbt/visualise/graphs/abstractgraph.py | 32 + robotmbt/visualise/graphs/reducedSDVgraph.py | 18 +- .../graphs/scenariodeltavaluegraph.py | 27 +- robotmbt/visualise/graphs/scenariograph.py | 16 + .../visualise/graphs/scenariostategraph.py | 16 + robotmbt/visualise/graphs/stategraph.py | 16 + robotmbt/visualise/models.py | 16 +- robotmbt/visualise/networkvisualiser.py | 1017 ++++++++++------- robotmbt/visualise/visualiser.py | 12 +- 11 files changed, 740 insertions(+), 445 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9af8d807..bc5c7e73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,4 +30,4 @@ include = ["robotmbt*"] Homepage = "https://github.com/JFoederer/robotframeworkMBT" [project.optional-dependencies] -visualization = ["numpy","networkx","bokeh","jsonpickle"] +visualization = ["networkx", "bokeh", "grandalf", "jsonpickle"] diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index 73192b5d..be08ee94 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -105,13 +105,13 @@ def process_test_suite(self, in_suite: Suite, *, seed: any = 'new', graph: str = self.__write_visualisation() return self.out_suite - + def _load_graph(self, graph:str, suite_name: str, from_json: str): traceinfo = TraceInfo() traceinfo = traceinfo.import_graph(from_json) self.visualiser = Visualiser( graph, suite_name, trace_info=traceinfo) - + def _run_test_suite(self, seed: any, graph: str, suite_name: str, to_json: bool): for id, scenario in enumerate(self.flat_suite.scenarios, start=1): scenario.src_id = id @@ -119,12 +119,12 @@ def _run_test_suite(self, seed: any, graph: str, suite_name: str, to_json: bool) logger.debug("Use these numbers to reference scenarios from traces\n\t" + "\n\t".join([f"{s.src_id}: {s.name}" for s in self.scenarios])) - self._init_randomiser(seed) + init_seed = self._init_randomiser(seed) random.shuffle(self.scenarios) self.visualiser = None if graph != '' and VISUALISE: - self.visualiser = Visualiser(graph, suite_name, to_json) # Pass suite name + self.visualiser = Visualiser(graph, suite_name, init_seed, to_json) # Pass suite name elif graph != '' and not VISUALISE: logger.warn(f'Visualisation {graph} requested, but required dependencies are not installed.' 'Install them with `pip install .[visualization]`.') @@ -556,20 +556,23 @@ def _report_tracestate_wrapup(self): logger.debug(f"model\n{step.model.get_status_text()}\n") @staticmethod - def _init_randomiser(seed: any): + def _init_randomiser(seed: any) -> str: if isinstance(seed, str): seed = seed.strip() if str(seed).lower() == 'none': logger.info( f"Using system's random seed for trace generation. This trace cannot be rerun. Use `seed=new` to generate a reusable seed.") + return "" elif str(seed).lower() == 'new': new_seed = SuiteProcessors._generate_seed() logger.info(f"seed={new_seed} (use seed to rerun this trace)") random.seed(new_seed) + return new_seed else: logger.info(f"seed={seed} (as provided)") random.seed(seed) + return seed @staticmethod def _generate_seed() -> str: diff --git a/robotmbt/visualise/graphs/abstractgraph.py b/robotmbt/visualise/graphs/abstractgraph.py index 870f020d..ba69a157 100644 --- a/robotmbt/visualise/graphs/abstractgraph.py +++ b/robotmbt/visualise/graphs/abstractgraph.py @@ -129,3 +129,35 @@ def create_edge_label(info: EdgeInfo) -> str: Create the label for an edge given its chosen information. """ pass + + @staticmethod + @abstractmethod + def get_legend_info_final_trace_node() -> str: + """ + Get the information to include in the legend for nodes that appear in the final trace. + """ + pass + + @staticmethod + @abstractmethod + def get_legend_info_other_node() -> str: + """ + Get the information to include in the legend for nodes that do not appear in the final trace. + """ + pass + + @staticmethod + @abstractmethod + def get_legend_info_final_trace_edge() -> str: + """ + Get the information to include in the legend for edges that appear in the final trace. + """ + pass + + @staticmethod + @abstractmethod + def get_legend_info_other_edge() -> str: + """ + Get the information to include in the legend for edges that do not appear in the final trace. + """ + pass diff --git a/robotmbt/visualise/graphs/reducedSDVgraph.py b/robotmbt/visualise/graphs/reducedSDVgraph.py index eb9c6c8a..c9ea89b3 100644 --- a/robotmbt/visualise/graphs/reducedSDVgraph.py +++ b/robotmbt/visualise/graphs/reducedSDVgraph.py @@ -55,8 +55,24 @@ def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> None: @staticmethod def create_node_label(info: tuple[ScenarioInfo, set[tuple[str, str]]]) -> str: - return f"{info[0].name}\n{ScenarioDeltaValueGraph.assignment_rep(info[1])}" + return ScenarioDeltaValueGraph.create_node_label(info) @staticmethod def create_edge_label(info: None) -> str: return '' + + @staticmethod + def get_legend_info_final_trace_node() -> str: + return "Executed Scenario w/ Changes in Execution State (in final trace)" + + @staticmethod + def get_legend_info_other_node() -> str: + return "Executed Scenario w/ Changes in Execution State (backtracked)" + + @staticmethod + def get_legend_info_final_trace_edge() -> str: + return "Execution Flow (final trace)" + + @staticmethod + def get_legend_info_other_edge() -> str: + return "Execution Flow (backtracked)" diff --git a/robotmbt/visualise/graphs/scenariodeltavaluegraph.py b/robotmbt/visualise/graphs/scenariodeltavaluegraph.py index 6681c874..6534386c 100644 --- a/robotmbt/visualise/graphs/scenariodeltavaluegraph.py +++ b/robotmbt/visualise/graphs/scenariodeltavaluegraph.py @@ -11,13 +11,6 @@ class ScenarioDeltaValueGraph(AbstractGraph[tuple[ScenarioInfo, set[tuple[str, s Its edges represent steps in the trace. """ - @staticmethod - def assignment_rep(delta: set[tuple[str, str]]) -> str: - res = "" - for assignment in delta: - res += "\n"+assignment[0]+" = "+assignment[1]+"," - return res - @staticmethod def select_node_info(pairs: list[tuple[ScenarioInfo, StateInfo]], index: int) \ -> tuple[ScenarioInfo, set[tuple[str, str]]]: @@ -32,9 +25,27 @@ def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> None: @staticmethod def create_node_label(info: tuple[ScenarioInfo, set[tuple[str, str]]]) -> str: - return f"{info[0].name}\n{ScenarioDeltaValueGraph.assignment_rep(info[1])}" + res = "" + for assignment in info[1]: + res += "\n\n"+assignment[0]+":"+assignment[1] + return f"{info[0].name}{res}" @staticmethod def create_edge_label(info: None) -> str: return '' + @staticmethod + def get_legend_info_final_trace_node() -> str: + return "Executed Scenario w/ Changes in Execution State (in final trace)" + + @staticmethod + def get_legend_info_other_node() -> str: + return "Executed Scenario w/ Changes in Execution State (backtracked)" + + @staticmethod + def get_legend_info_final_trace_edge() -> str: + return "Execution Flow (final trace)" + + @staticmethod + def get_legend_info_other_edge() -> str: + return "Execution Flow (backtracked)" diff --git a/robotmbt/visualise/graphs/scenariograph.py b/robotmbt/visualise/graphs/scenariograph.py index bafc6ee9..f36a0911 100644 --- a/robotmbt/visualise/graphs/scenariograph.py +++ b/robotmbt/visualise/graphs/scenariograph.py @@ -23,3 +23,19 @@ def create_node_label(info: ScenarioInfo) -> str: @staticmethod def create_edge_label(info: None) -> str: return '' + + @staticmethod + def get_legend_info_final_trace_node() -> str: + return "Executed Scenario (in final trace)" + + @staticmethod + def get_legend_info_other_node() -> str: + return "Executed Scenario (backtracked)" + + @staticmethod + def get_legend_info_final_trace_edge() -> str: + return "Execution Flow (final trace)" + + @staticmethod + def get_legend_info_other_edge() -> str: + return "Execution Flow (backtracked)" diff --git a/robotmbt/visualise/graphs/scenariostategraph.py b/robotmbt/visualise/graphs/scenariostategraph.py index 4a4aaedd..460a1634 100644 --- a/robotmbt/visualise/graphs/scenariostategraph.py +++ b/robotmbt/visualise/graphs/scenariostategraph.py @@ -24,3 +24,19 @@ def create_node_label(info: tuple[ScenarioInfo, StateInfo]) -> str: @staticmethod def create_edge_label(info: None) -> str: return '' + + @staticmethod + def get_legend_info_final_trace_node() -> str: + return "Executed Scenario w/ Execution State (in final trace)" + + @staticmethod + def get_legend_info_other_node() -> str: + return "Executed Scenario w/ Execution State (backtracked)" + + @staticmethod + def get_legend_info_final_trace_edge() -> str: + return "Execution Flow (final trace)" + + @staticmethod + def get_legend_info_other_edge() -> str: + return "Execution Flow (backtracked)" diff --git a/robotmbt/visualise/graphs/stategraph.py b/robotmbt/visualise/graphs/stategraph.py index a39274fc..61d4455f 100644 --- a/robotmbt/visualise/graphs/stategraph.py +++ b/robotmbt/visualise/graphs/stategraph.py @@ -23,3 +23,19 @@ def create_node_label(info: StateInfo) -> str: @staticmethod def create_edge_label(info: ScenarioInfo) -> str: return info.name + + @staticmethod + def get_legend_info_final_trace_node() -> str: + return "Execution State (in final trace)" + + @staticmethod + def get_legend_info_other_node() -> str: + return "Execution State (backtracked)" + + @staticmethod + def get_legend_info_final_trace_edge() -> str: + return "Executed Scenario (in final trace)" + + @staticmethod + def get_legend_info_other_edge() -> str: + return "Executed Scenario (backtracked)" diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index 457f07fa..a95a2272 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -53,10 +53,16 @@ def _create_state_with_prop(cls, name: str, attrs: list[tuple[str, Any]]): def difference(self, new_state) -> set[tuple[str, str]]: left: dict[str, dict | str] = self.properties.copy() for key in left.keys(): - left[key] = str(left[key]) + res = "" + for k, v in sorted(left[key].items()): + res += f"\n\t{k}={v}" + left[key] = res right: dict[str, dict | str] = new_state.properties.copy() for key in right.keys(): - right[key] = str(right[key]) + res = "" + for k, v in sorted(right[key].items()): + res += f"\n\t{k}={v}" + right[key] = res # type inference goes doodoo here temp: set[tuple[str, str]] = set(right.items()) - set(left.items()) return temp @@ -196,16 +202,16 @@ def export_graph(self, suite_name: str, atest: bool = False) -> str | None: with os.fdopen(fd, "w") as f: f.write(encoded_instance) return path - + with open(f"{self.path}{name}.json", "w") as f: f.write(encoded_instance) return None - + def import_graph(self, file_name: str): with open(f"{self.path}{file_name}.json", "r") as f: string = f.read() self = jsonpickle.decode(string) - + @staticmethod def stringify_pair(pair: tuple[ScenarioInfo, StateInfo]) -> str: diff --git a/robotmbt/visualise/networkvisualiser.py b/robotmbt/visualise/networkvisualiser.py index 2e6cabc9..4beedea5 100644 --- a/robotmbt/visualise/networkvisualiser.py +++ b/robotmbt/visualise/networkvisualiser.py @@ -1,450 +1,631 @@ -from robotmbt.visualise.graphs.abstractgraph import AbstractGraph -from robotmbt.visualise.graphs.reducedSDVgraph import ReducedSDVGraph -from robotmbt.visualise.graphs.scenariodeltavaluegraph import ScenarioDeltaValueGraph -from robotmbt.visualise.graphs.scenariostategraph import ScenarioStateGraph -from robotmbt.visualise.graphs.stategraph import StateGraph -from robotmbt.visualise.graphs.scenariostategraph import ScenarioStateGraph -from bokeh.palettes import Spectral4 -from bokeh.models import ( - Plot, Range1d, Circle, Rect, - Arrow, NormalHead, - Bezier, ColumnDataSource, ResetTool, - SaveTool, WheelZoomTool, PanTool, Text, - FullscreenTool, Title -) +from bokeh.core.enums import PlaceType, LegendLocationType +from bokeh.core.property.vectorization import value from bokeh.embed import file_html -from bokeh.resources import CDN -from math import sqrt -import networkx as nx +from bokeh.models import ColumnDataSource, Rect, Text, ResetTool, SaveTool, WheelZoomTool, PanTool, Plot, Range1d, \ + Title, FullscreenTool, CustomJS, Segment, Arrow, NormalHead, Bezier, Legend + +from grandalf.graphs import Vertex as GVertex, Edge as GEdge, Graph as GGraph +from grandalf.layouts import SugiyamaLayout + +from networkx import DiGraph + +from robotmbt.visualise.graphs.abstractgraph import AbstractGraph + +# Padding within the nodes between the borders and inner text +HORIZONTAL_PADDING_WITHIN_NODES: int = 5 +VERTICAL_PADDING_WITHIN_NODES: int = 5 + +# Colors for different parts of the graph +FINAL_TRACE_NODE_COLOR: str = '#CCCC00' +OTHER_NODE_COLOR: str = '#999989' +FINAL_TRACE_EDGE_COLOR: str = '#444422' +OTHER_EDGE_COLOR: str = '#BBBBAA' + +# Legend placement +# Alignment within graph ('center' is in the middle, 'top_right' is the top right, etc.) +LEGEND_LOCATION: LegendLocationType | tuple[float, float] = 'top_right' +# Where it appears relative to graph ('center' is within, 'below' is below, etc.) +LEGEND_PLACE: PlaceType = 'center' + +# Dimensions of the plot in the window +INNER_WINDOW_WIDTH: int = 720 +INNER_WINDOW_HEIGHT: int = 480 +OUTER_WINDOW_WIDTH: int = INNER_WINDOW_WIDTH + (280 if LEGEND_PLACE == 'left' or LEGEND_PLACE == 'right' else 30) +OUTER_WINDOW_HEIGHT: int = INNER_WINDOW_HEIGHT + (150 if LEGEND_PLACE == 'below' or LEGEND_PLACE == 'center' else 30) + +# Font sizes +MAJOR_FONT_SIZE: int = 16 +MINOR_FONT_SIZE: int = 8 + + +class Node: + """ + Contains the information we need to add a node to the graph. + """ + + def __init__(self, node_id: str, label: str, x: int, y: int, width: float, height: float): + self.node_id = node_id + self.label = label + self.x = x + self.y = y + self.width = width + self.height = height + + +class Edge: + """ + Contains the information we need to add an edge to the graph. + """ + + def __init__(self, from_node: str, to_node: str, label: str, points: list[tuple[float, float]]): + self.from_node = from_node + self.to_node = to_node + self.label = label + self.points = points + class NetworkVisualiser: """ - Generate plot with Bokeh + A container for a Bokeh graph, which can be created from any abstract graph. """ - ARROWHEAD_SIZE: int = 6 # Consistent arrowhead size - EDGE_WIDTH: float = 2.0 - EDGE_ALPHA: float = 0.7 - EDGE_COLOUR: str | tuple[int, int, int] = ( - 12, 12, 12) # 'visual studio black' - GRAPH_PADDING_PERC: int = 15 # % - # in px, needs to be equal for height and width otherwise calculations are wrong - GRAPH_SIZE_PX: int = 600 - MAX_VERTEX_NAME_LEN: int = 20 # no. of characters - - # Colors and styles for executed vs unexecuted elements - EXECUTED_NODE_COLOR = Spectral4[0] # Bright blue - UNEXECUTED_NODE_COLOR = '#D3D3D3' # Light gray - EXECUTED_TEXT_COLOR = '#C8C8C8' - UNEXECUTED_TEXT_COLOR = '#A9A9A9' # Dark gray - EXECUTED_EDGE_COLOR = (12, 12, 12) # Black - UNEXECUTED_EDGE_COLOR = '#808080' # Gray - EXECUTED_EDGE_WIDTH = 2.5 - UNEXECUTED_EDGE_WIDTH = 1.2 - EXECUTED_EDGE_ALPHA = 0.7 - UNEXECUTED_EDGE_ALPHA = 0.3 - EXECUTED_LABEL_COLOR = 'black' - UNEXECUTED_LABEL_COLOR = '#A9A9A9' - - def __init__(self, graph: AbstractGraph, suite_name: str = ""): - self.plot = None - self.graph = graph - self.suite_name = suite_name - self.node_props = {} # Store node properties for arrow calculations - self.graph_layout = {} - - # graph customisation options - self.node_radius = 1.0 - self.char_width = 0.1 - self.char_height = 0.1 - self.padding = 0.1 - - # Get executed elements for visual differentiation - final_trace = graph.get_final_trace() - self.executed_nodes = set(final_trace) - self.executed_edges = set() - for i in range(0, len(final_trace) - 1): - from_node = final_trace[i] - to_node = final_trace[i + 1] - self.executed_edges.add((from_node, to_node)) - - def generate_html(self) -> str: + def __init__(self, graph: AbstractGraph, suite_name: str, seed: str): + # Extract what we need from the graph + self.networkx: DiGraph = graph.networkx + self.final_trace = graph.get_final_trace() + self.start = graph.start_node + + # Set up a Bokeh figure + self.plot = Plot(width=OUTER_WINDOW_WIDTH, height=OUTER_WINDOW_HEIGHT) + + # Create Sugiyama layout + nodes, edges = self._create_layout() + + # Keep track of arrows in the graph for scaling + self.arrows = [] + + # Add the nodes to the graph + self._add_nodes(nodes) + + # Add the edges to the graph + self._add_edges(nodes, edges) + + # Add a legend to the graph + self._add_legend(graph) + + # Add our features to the graph (e.g. tools) + self._add_features(suite_name, seed) + + def generate_html(self): """ - Generate html file from networkx graph via Bokeh + Generate HTML for the Bokeh graph. """ - self._calculate_graph_layout() - self._initialise_plot() - self._add_nodes_with_labels() - self._add_edges() - return file_html(self.plot, CDN, "graph") + return file_html(self.plot, 'inline', "graph") - def _initialise_plot(self): + def _add_nodes(self, nodes: list[Node]): """ - Define plot with width, height, x_range, y_range and enable tools. - x_range and y_range are padded. Plot needs to be a square + Add the nodes to the graph in the form of Rect and Text glyphs. """ - padding: float = self.GRAPH_PADDING_PERC / 100 - - x_range, y_range = zip(*self.graph_layout.values()) - x_min = min(x_range) - padding * (max(x_range) - min(x_range)) - x_max = max(x_range) + padding * (max(x_range) - min(x_range)) - y_min = min(y_range) - padding * (max(y_range) - min(y_range)) - y_max = max(y_range) + padding * (max(y_range) - min(y_range)) - - # scale node radius based on range - nodes_range = max(x_max - x_min, y_max - y_min) - self.node_radius = nodes_range / 150 - self.char_width = nodes_range / 150 - self.char_height = nodes_range / 150 + # The ColumnDataSources to store our nodes and edges in Bokeh's format + node_source: ColumnDataSource = ColumnDataSource( + {'id': [], 'x': [], 'y': [], 'w': [], 'h': [], 'color': []}) + node_label_source: ColumnDataSource = ColumnDataSource( + {'id': [], 'x': [], 'y': [], 'label': []}) + + # Add all nodes to the column data sources + for node in nodes: + _add_node_to_sources(node, self.final_trace, node_source, node_label_source) + + # Add the glyphs for nodes and their labels + node_glyph = Rect(x='x', y='y', width='w', height='h', fill_color='color') + self.plot.add_glyph(node_source, node_glyph) + + node_label_glyph = Text(x='x', y='y', text='label', text_align='left', text_baseline='middle', + text_font_size=f'{MAJOR_FONT_SIZE}pt', text_font=value("Courier New")) + node_label_glyph.tags = [f"scalable_text{MAJOR_FONT_SIZE}"] + self.plot.add_glyph(node_label_source, node_label_glyph) + + def _add_edges(self, nodes: list[Node], edges: list[Edge]): + """ + Add the edges to the graph in the form of Arrow layouts and Segment, Bezier, and Text glyphs. + """ + # The ColumnDataSources to store our edges in Bokeh's format + edge_part_source: ColumnDataSource = ColumnDataSource( + {'from': [], 'to': [], 'start_x': [], 'start_y': [], 'end_x': [], 'end_y': [], 'color': []}) + edge_arrow_source: ColumnDataSource = ColumnDataSource( + {'from': [], 'to': [], 'start_x': [], 'start_y': [], 'end_x': [], 'end_y': [], 'color': []}) + edge_bezier_source: ColumnDataSource = ColumnDataSource( + {'from': [], 'to': [], 'start_x': [], 'start_y': [], 'end_x': [], 'end_y': [], 'control1_x': [], + 'control1_y': [], 'control2_x': [], 'control2_y': [], 'color': []}) + edge_label_source: ColumnDataSource = ColumnDataSource({'from': [], 'to': [], 'x': [], 'y': [], 'label': []}) + + for edge in edges: + _add_edge_to_sources(nodes, edge, self.final_trace, edge_part_source, edge_arrow_source, edge_bezier_source, + edge_label_source) + + # Add the glyphs for edges and their labels + edge_part_glyph = Segment(x0='start_x', y0='start_y', x1='end_x', y1='end_y', line_color='color') + self.plot.add_glyph(edge_part_source, edge_part_glyph) + + arrow_layout = Arrow( + end=NormalHead(size=10, fill_color='color', line_color='color'), + x_start='start_x', y_start='start_y', + x_end='end_x', y_end='end_y', line_color='color', + source=edge_arrow_source + ) + self.plot.add_layout(arrow_layout) + self.arrows.append(arrow_layout) - # create plot - x_range = Range1d(min(x_min, y_min), max(x_max, y_max)) - y_range = Range1d(min(x_min, y_min), max(x_max, y_max)) + edge_bezier_glyph = Bezier(x0='start_x', y0='start_y', x1='end_x', y1='end_y', cx0='control1_x', + cy0='control1_y', cx1='control2_x', cy1='control2_y', line_color='color') + self.plot.add_glyph(edge_bezier_source, edge_bezier_glyph) - self.plot = Plot(width=self.GRAPH_SIZE_PX, - height=self.GRAPH_SIZE_PX, - x_range=x_range, - y_range=y_range) + edge_label_glyph = Text(x='x', y='y', text='label', text_align='center', text_baseline='middle', + text_font_size=f'{MINOR_FONT_SIZE}pt', text_font=value("Courier New")) + edge_label_glyph.tags = [f"scalable_text{MINOR_FONT_SIZE}"] + self.plot.add_glyph(edge_label_source, edge_label_glyph) - # add title - self.plot.add_layout(Title(text=self.suite_name, align="center"), "above") + def _create_layout(self) -> tuple[list[Node], list[Edge]]: + """ + Create the Sugiyama layout using grandalf. + """ + # Containers to convert networkx nodes/edges to the proper format. + vertices = [] + edges = [] + flips = [] + + # Extract nodes from networkx and put them in the proper format to be used by grandalf. + start = None + for node_id in self.networkx.nodes: + v = GVertex(node_id) + w, h = _calculate_dimensions(self.networkx.nodes[node_id]['label']) + v.view = NodeView(w, h) + vertices.append(v) + if node_id == self.start: + start = v + + # Calculate which edges need to be flipped to make the graph acyclic. + flip = _flip_edges([e for e in self.networkx.edges]) + + # Extract edges from networkx and put them in the proper format to be used by grandalf. + for (from_id, to_id) in self.networkx.edges: + from_node = _find_node(vertices, from_id) + to_node = _find_node(vertices, to_id) + e = GEdge(from_node, to_node) + e.view = EdgeView() + edges.append(e) + if (from_id, to_id) in flip: + flips.append(e) + + # Feed the info to grandalf and get the layout. + g = GGraph(vertices, edges) + + sugiyama = SugiyamaLayout(g.C[0]) + sugiyama.init_all(roots=[start], inverted_edges=flips) + sugiyama.draw() + + # Extract the information we need from the nodes and edges and return them in our format. + ns = [] + for v in g.C[0].sV: + node_id = v.data + label = self.networkx.nodes[node_id]['label'] + (x, y) = v.view.xy + (w, h) = _calculate_dimensions(label) + ns.append(Node(node_id, label, x, y, w, h)) + + es = [] + for e in g.C[0].sE: + from_id = e.v[0].data + to_id = e.v[1].data + label = self.networkx.edges[(from_id, to_id)]['label'] + points = e.view.points + es.append(Edge(from_id, to_id, label, points)) + + return ns, es + + def _add_features(self, suite_name: str, seed: str): + """ + Add our features to the graph such as tools, titles, and JavaScript callbacks. + """ + if seed != "": + self.plot.add_layout(Title(text="seed=" + seed, align="center", text_color="#999999"), "above") + self.plot.add_layout(Title(text=suite_name, align="center"), "above") - # add tools + # Add the different tools self.plot.add_tools(ResetTool(), SaveTool(), WheelZoomTool(), PanTool(), FullscreenTool()) - def _calculate_text_dimensions(self, text: str) -> tuple[float, float]: - """Calculate width and height needed for text based on actual text length""" - # Calculate width based on character count - text_length = len(text) - width = (text_length * self.char_width) + (2 * self.padding) + # Specify the default range - these values represent the aspect ratio of the actual view in the window + self.plot.x_range = Range1d(-INNER_WINDOW_WIDTH / 2, INNER_WINDOW_WIDTH / 2) + self.plot.y_range = Range1d(-INNER_WINDOW_HEIGHT + (0 if seed == '' else 20), 0) + self.plot.x_range.tags = [{"initial_span": INNER_WINDOW_WIDTH}] + self.plot.y_range.tags = [{"initial_span": INNER_WINDOW_HEIGHT - (0 if seed == '' else 20)}] + + # A JS callback to scale text and arrows, and change aspect ratio. + resize_cb = CustomJS(args=dict(xr=self.plot.x_range, yr=self.plot.y_range, plot=self.plot, arrows=self.arrows), + code=f""" + // Initialize initial scale tag + if (!plot.tags || plot.tags.length === 0) {{ + plot.tags = [{{ + initial_scale: plot.inner_height / (yr.end - yr.start) + }}] + }} + + // Calculate current x and y span + const xspan = xr.end - xr.start; + const yspan = yr.end - yr.start; + + // Calculate inner aspect ratio and span aspect ratio + const inner_aspect = plot.inner_width / plot.inner_height; + const span_aspect = xspan / yspan; + + // Let span aspect ratio match inner aspect ratio if needed + if (Math.abs(inner_aspect - span_aspect) > 0.05) {{ + const xmid = xr.start + xspan / 2; + const new_xspan = yspan * inner_aspect; + xr.start = xmid - new_xspan / 2; + xr.end = xmid + new_xspan / 2; + }} + + // Calculate scale factor + const scale = (plot.inner_height / yspan) / plot.tags[0].initial_scale + + // Scale text + for (const r of plot.renderers) {{ + if (!r.glyph || !r.glyph.tags) continue + + if (r.glyph.tags.includes("scalable_text{MAJOR_FONT_SIZE}")) {{ + r.glyph.text_font_size = Math.floor({MAJOR_FONT_SIZE} * scale) + "pt" + }} + + if (r.glyph.tags.includes("scalable_text{MINOR_FONT_SIZE}")) {{ + r.glyph.text_font_size = Math.floor({MINOR_FONT_SIZE} * scale) + "pt" + }} + }} + + // Scale arrows + for (const a of arrows) {{ + if (!a.properties) continue; + if (!a.properties.end) continue; + if (!a.properties.end._value) continue; + if (!a.properties.end._value.properties) continue; + if (!a.properties.end._value.properties.size) continue; + if (!a.properties.end._value.properties.size._value) continue; + if (!a.properties.end._value.properties.size._value.value) continue; + if (a._base_end_size == null) + a._base_end_size = a.properties.end._value.properties.size._value.value; + a.properties.end._value.properties.size._value.value = a._base_end_size * scale; + a.change.emit(); + }}""") + + # Add the callback to the values that change when zooming/resizing. + self.plot.x_range.js_on_change("start", resize_cb) + self.plot.x_range.js_on_change("end", resize_cb) + self.plot.y_range.js_on_change("start", resize_cb) + self.plot.y_range.js_on_change("end", resize_cb) + self.plot.js_on_change("inner_width", resize_cb) + self.plot.js_on_change("inner_height", resize_cb) + + def _add_legend(self, graph: AbstractGraph): + """ + Adds a legend to the graph with the node/edge information from the given graph. + """ + empty_source = ColumnDataSource({'_': [0]}) - # Reduced height for more compact rectangles - height = self.char_height + self.padding + final_trace_node_glyph = Rect(x='_', y='_', width='_', height='_', fill_color=FINAL_TRACE_NODE_COLOR) + final_trace_node = self.plot.add_glyph(empty_source, final_trace_node_glyph) - return width, height + other_node_glyph = Rect(x='_', y='_', width='_', height='_', fill_color=OTHER_NODE_COLOR) + other_node = self.plot.add_glyph(empty_source, other_node_glyph) - def _add_nodes_with_labels(self): - """ - Add nodes with text labels inside them - """ - node_labels = nx.get_node_attributes(self.graph.networkx, "label") - - # Create data sources for nodes and labels - circle_data = dict(x=[], y=[], radius=[], label=[], color=[], text_color=[]) - rect_data = dict(x=[], y=[], width=[], height=[], label=[], color=[], text_color=[]) - text_data = dict(x=[], y=[], text=[], text_color=[]) - - for node in self.graph.networkx.nodes: - # Labels are always defined and cannot be lists - label = node_labels[node] - label = self._cap_name(label) - x, y = self.graph_layout[node] - - # Determine if node is executed - is_executed = node in self.executed_nodes - node_color = self.EXECUTED_NODE_COLOR if is_executed else self.UNEXECUTED_NODE_COLOR - text_color = self.EXECUTED_TEXT_COLOR if is_executed else self.UNEXECUTED_TEXT_COLOR - - if node == self.graph.start_node: - # For start node (circle), calculate radius based on text width - text_width, text_height = self._calculate_text_dimensions( - label) - # Calculate radius from text dimensions - radius = (text_width / 2.5) - - circle_data['x'].append(x) - circle_data['y'].append(y) - circle_data['radius'].append(radius) - circle_data['label'].append(label) - circle_data['color'].append(node_color) - circle_data['text_color'].append(text_color) - - # Store node properties for arrow calculations - self.node_props[node] = { - 'type': 'circle', 'x': x, 'y': y, 'radius': radius, 'label': label} - - else: - # For scenario nodes (rectangles), calculate dimensions based on text - text_width, text_height = self._calculate_text_dimensions( - label) - - rect_data['x'].append(x) - rect_data['y'].append(y) - rect_data['width'].append(text_width) - rect_data['height'].append(text_height) - rect_data['label'].append(label) - rect_data['color'].append(node_color) - rect_data['text_color'].append(text_color) - - # Store node properties for arrow calculations - self.node_props[node] = {'type': 'rect', 'x': x, 'y': y, 'width': text_width, 'height': text_height, - 'label': label} - - # Add text for all nodes - text_data['x'].append(x) - text_data['y'].append(y) - text_data['text'].append(label) - text_data['text_color'].append(text_color) - - # Add circles for start node - if circle_data['x']: - circle_source = ColumnDataSource(circle_data) - circles = Circle(x='x', y='y', radius='radius', - fill_color='color', line_color='color') - self.plot.add_glyph(circle_source, circles) - - # Add rectangles for scenario nodes - if rect_data['x']: - rect_source = ColumnDataSource(rect_data) - rectangles = Rect(x='x', y='y', width='width', height='height', - fill_color='color', line_color='color') - self.plot.add_glyph(rect_source, rectangles) - - # Add text labels for all nodes - text_source = ColumnDataSource(text_data) - text_labels = Text(x='x', y='y', text='text', - text_align='center', text_baseline='middle', - text_color='text_color', text_font_size='9pt') - self.plot.add_glyph(text_source, text_labels) - - def _get_edge_points(self, start_node, end_node): - """Calculate edge start and end points at node borders""" - start_props = self.node_props.get(start_node) - end_props = self.node_props.get(end_node) - - # Node properties should always exist - if not start_props or not end_props: - raise ValueError( - f"Node properties not found for nodes: {start_node}, {end_node}") - - # Calculate direction vector - dx = end_props['x'] - start_props['x'] - dy = end_props['y'] - start_props['y'] - distance = sqrt(dx * dx + dy * dy) - - # Self-loops are handled separately, distance should never be 0 - if distance == 0: - raise ValueError( - "Distance between different nodes should not be zero") - - # Normalize direction vector - dx /= distance - dy /= distance - - # Calculate start point at border - if start_props['type'] == 'circle': - start_x = start_props['x'] + dx * start_props['radius'] - start_y = start_props['y'] + dy * start_props['radius'] - else: - # Find where the line intersects the rectangle border - rect_width = start_props['width'] - rect_height = start_props['height'] + final_trace_edge_glyph = Segment( + x0='_', x1='_', + y0='_', y1='_', line_color=FINAL_TRACE_EDGE_COLOR + ) + final_trace_edge = self.plot.add_glyph(empty_source, final_trace_edge_glyph) - # Calculate scaling factors for x and y directions - scale_x = rect_width / (2 * abs(dx)) if dx != 0 else float('inf') - scale_y = rect_height / (2 * abs(dy)) if dy != 0 else float('inf') + other_edge_glyph = Segment( + x0='_', x1='_', + y0='_', y1='_', line_color=OTHER_EDGE_COLOR + ) + other_edge = self.plot.add_glyph(empty_source, other_edge_glyph) - # Use the smaller scale to ensure we hit the border - scale = min(scale_x, scale_y) + legend = Legend(items=[(graph.get_legend_info_final_trace_node(), [final_trace_node]), + (graph.get_legend_info_other_node(), [other_node]), + (graph.get_legend_info_final_trace_edge(), [final_trace_edge]), + (graph.get_legend_info_other_edge(), [other_edge])], + location=LEGEND_LOCATION, orientation="vertical") + self.plot.add_layout(legend, LEGEND_PLACE) - start_x = start_props['x'] + dx * scale - start_y = start_props['y'] + dy * scale - # Calculate end point at border (reverse direction) - # End nodes should never be circles for regular edges - if end_props['type'] == 'circle': - raise ValueError( - f"End node should not be a circle for regular edges: {end_node}") - else: - rect_width = end_props['width'] - rect_height = end_props['height'] +class NodeView: + """ + A view of a node in the format that grandalf expects. + """ - # Calculate scaling factors for x and y directions (reverse) - scale_x = rect_width / (2 * abs(dx)) if dx != 0 else float('inf') - scale_y = rect_height / (2 * abs(dy)) if dy != 0 else float('inf') + def __init__(self, width: float, height: float): + self.w, self.h = width, height + self.xy = (0, 0) - # Use the smaller scale to ensure we hit the border - scale = min(scale_x, scale_y) - end_x = end_props['x'] - dx * scale - end_y = end_props['y'] - dy * scale +class EdgeView: + """ + A view of an edge in the format that grandalf expects. + """ - return start_x, start_y, end_x, end_y + def __init__(self): + self.points = [] - def add_self_loop(self, node_id: str): - """ - Circular arc that starts and ends at the top side of the rectangle - Start at 1/4 width, end at 3/4 width, with a circular arc above - The arc itself ends with the arrowhead pointing into the rectangle - """ - # Get node properties directly by node ID - node_props = self.node_props.get(node_id) - - # Node properties should always exist - if node_props is None: - raise ValueError(f"Node properties not found for node: {node_id}") - - # Self-loops should only be for rectangle nodes (scenarios) - if node_props['type'] != 'rect': - raise ValueError( - f"Self-loops should only be for rectangle nodes, got: {node_props['type']}") - - x, y = node_props['x'], node_props['y'] - width = node_props['width'] - height = node_props['height'] - - # Start: 1/4 width from left, top side - start_x = x - width / 4 - start_y = y + height / 2 - - # End: 3/4 width from left, top side - end_x = x + width / 4 - end_y = y + height / 2 - - # Arc height above the rectangle - arc_height = width * 0.4 - - # Control points for a circular arc above - control1_x = x - width / 8 - control1_y = y + height / 2 + arc_height - - control2_x = x + width / 8 - control2_y = y + height / 2 + arc_height - - # Determine if edge is executed - is_executed = (node_id, node_id) in self.executed_edges - edge_color = self.EXECUTED_EDGE_COLOR if is_executed else self.UNEXECUTED_EDGE_COLOR - edge_width = self.EXECUTED_EDGE_WIDTH if is_executed else self.UNEXECUTED_EDGE_WIDTH - edge_alpha = self.EXECUTED_EDGE_ALPHA if is_executed else self.UNEXECUTED_EDGE_ALPHA - - # Create the Bezier curve (the main arc) with the same thickness as straight lines - loop = Bezier( - x0=start_x, y0=start_y, - x1=end_x, y1=end_y, - cx0=control1_x, cy0=control1_y, - cx1=control2_x, cy1=control2_y, - line_color=edge_color, - line_width=edge_width, - line_alpha=edge_alpha, - ) - self.plot.add_glyph(loop) - - # Calculate the tangent direction at the end of the Bezier curve - # For a cubic Bezier, the tangent at the end point is from the last control point to the end point - tangent_x = end_x - control2_x - tangent_y = end_y - control2_y - - # Normalize the tangent vector - tangent_length = sqrt(tangent_x ** 2 + tangent_y ** 2) - if tangent_length > 0: - tangent_x /= tangent_length - tangent_y /= tangent_length - - # Add just the arrowhead (NormalHead) at the end point, oriented along the tangent - arrowhead = NormalHead( - size=NetworkVisualiser.ARROWHEAD_SIZE, - line_color=edge_color, - fill_color=edge_color, - line_width=edge_width - ) + def setpath(self, points: list[tuple[float, float]]): + self.points = points - # Create a standalone arrowhead at the end point - # Strategy: use a very short Arrow that's essentially just the head - arrow = Arrow( - end=arrowhead, - x_start=end_x - tangent_x * 0.001, # Almost zero length line - y_start=end_y - tangent_y * 0.001, - x_end=end_x, - y_end=end_y, - line_color=edge_color, - line_width=edge_width, - line_alpha=edge_alpha - ) - self.plot.add_layout(arrow) - - # Add edge label - positioned above the arc - label_x = x - label_y = y + height / 2 + arc_height * 0.6 - - return label_x, label_y - - def _add_edges(self): - edge_labels = nx.get_edge_attributes(self.graph.networkx, "label") - - # Create data sources for edges and edge labels - edge_text_data = dict(x=[], y=[], text=[], text_color=[]) - - for edge in self.graph.networkx.edges(): - # Edge labels are always defined and cannot be lists - edge_label = edge_labels[edge] - edge_label = self._cap_name(edge_label) - - # Determine if edge is executed - is_executed = edge in self.executed_edges - edge_color = self.EXECUTED_EDGE_COLOR if is_executed else self.UNEXECUTED_EDGE_COLOR - edge_width = self.EXECUTED_EDGE_WIDTH if is_executed else self.UNEXECUTED_EDGE_WIDTH - edge_alpha = self.EXECUTED_EDGE_ALPHA if is_executed else self.UNEXECUTED_EDGE_ALPHA - label_color = self.EXECUTED_LABEL_COLOR if is_executed else self.UNEXECUTED_LABEL_COLOR - - edge_text_data['text'].append(edge_label) - edge_text_data['text_color'].append(label_color) - - if edge[0] == edge[1]: - # Self-loop handled separately - label_x, label_y = self.add_self_loop(edge[0]) - edge_text_data['x'].append(label_x) - edge_text_data['y'].append(label_y) - - else: - # Calculate edge points at node borders - start_x, start_y, end_x, end_y = self._get_edge_points( - edge[0], edge[1]) - - # Add arrow between the calculated points - arrow = Arrow( - end=NormalHead( - size=NetworkVisualiser.ARROWHEAD_SIZE, - line_color=edge_color, - fill_color=edge_color, - line_width=edge_width), - x_start=start_x, y_start=start_y, - x_end=end_x, y_end=end_y, - line_color=edge_color, - line_width=edge_width, - line_alpha=edge_alpha - ) - self.plot.add_layout(arrow) - - # Collect edge label data (position at midpoint) - edge_text_data['x'].append((start_x + end_x) / 2) - edge_text_data['y'].append((start_y + end_y) / 2) - - # Add all edge labels at once - if edge_text_data['x']: - edge_text_source = ColumnDataSource(edge_text_data) - edge_labels_glyph = Text(x='x', y='y', text='text', - text_align='center', text_baseline='middle', - text_color='text_color', text_font_size='7pt') - self.plot.add_glyph(edge_text_source, edge_labels_glyph) - - def _cap_name(self, name: str) -> str: - if len(name) < self.MAX_VERTEX_NAME_LEN or isinstance(self.graph, StateGraph) \ - or isinstance(self.graph, ScenarioStateGraph) or isinstance(self.graph, ScenarioDeltaValueGraph)\ - or isinstance(self.graph, ReducedSDVGraph): - return name - - return f"{name[:(self.MAX_VERTEX_NAME_LEN - 3)]}..." - - def _calculate_graph_layout(self): - try: - self.graph_layout = nx.bfs_layout( - self.graph.networkx, self.graph.start_node, align='horizontal') - # horizontal mirror - for node in self.graph_layout: - self.graph_layout[node] = (self.graph_layout[node][0], - -1 * self.graph_layout[node][1]) - except nx.NetworkXException: - # if planar layout cannot find a graph without crossing edges - self.graph_layout = nx.arf_layout(self.graph.networkx, seed=42) + +def _find_node(nodes: list[GVertex], node_id: str): + """ + Find a node given its id in a list of grandalf nodes. + """ + for node in nodes: + if node.data == node_id: + return node + return None + + +def _get_connection_coordinates(nodes: list[Node], node_id: str) -> list[tuple[float, float]]: + """ + Get the coordinates where edges can connect to a node given its id. + These places are the middle of the left, right, top, and bottom edge, as well as the corners of the node. + """ + start_possibilities = [] + + # Get node from list + node = None + for n in nodes: + if n.node_id == node_id: + node = n + break + + # Left + start_possibilities.append((node.x - node.width / 2, node.y)) + # Right + start_possibilities.append((node.x + node.width / 2, node.y)) + # Bottom + start_possibilities.append((node.x, node.y - node.height / 2)) + # Top + start_possibilities.append((node.x, node.y + node.height / 2)) + # Left bottom + start_possibilities.append((node.x - node.width / 2, node.y - node.height / 2)) + # Left top + start_possibilities.append((node.x - node.width / 2, node.y + node.height / 2)) + # Right bottom + start_possibilities.append((node.x + node.width / 2, node.y - node.height / 2)) + # Right top + start_possibilities.append((node.x + node.width / 2, node.y + node.height / 2)) + + return start_possibilities + + +def _minimize_distance(from_pos: list[tuple[float, float]], to_pos: list[tuple[float, float]]) -> tuple[ + float, float, float, float]: + """ + Find a pair of positions that minimizes their distance. + """ + min_dist = -1 + fx, fy, tx, ty = 0, 0, 0, 0 + + # Calculate the distance between all permutations + for fp in from_pos: + for tp in to_pos: + distance = (fp[0] - tp[0]) ** 2 + (fp[1] - tp[1]) ** 2 + if min_dist == -1 or distance < min_dist: + min_dist = distance + fx, fy, tx, ty = fp[0], fp[1], tp[0], tp[1] + + # Return the permutation with the shortest distance + return fx, fy, tx, ty + + +def _add_edge_to_sources(nodes: list[Node], edge: Edge, final_trace: list[str], edge_part_source: ColumnDataSource, + edge_arrow_source: ColumnDataSource, edge_bezier_source: ColumnDataSource, + edge_label_source: ColumnDataSource): + """ + Add an edge between two nodes to the ColumnDataSources. + Contains all logic to set their color, find their attachment points, and do self-loops properly. + """ + in_final_trace = False + for i in range(len(final_trace) - 1): + if edge.from_node == final_trace[i] and edge.to_node == final_trace[i + 1]: + in_final_trace = True + break + + if edge.from_node == edge.to_node: + _add_self_loop_to_sources(nodes, edge, in_final_trace, edge_arrow_source, edge_bezier_source, edge_label_source) + return + + start_x, start_y = 0, 0 + end_x, end_y = 0, 0 + + if isinstance(edge.from_node, frozenset): + from_id = tuple(sorted(edge.from_node)) + else: + from_id = edge.from_node + + if isinstance(edge.to_node, frozenset): + to_id = tuple(sorted(edge.to_node)) + else: + to_id = edge.to_node + + # Add edges going through the calculated points + for i in range(len(edge.points) - 1): + start_x, start_y = edge.points[i] + end_x, end_y = edge.points[i + 1] + + # Collect possibilities where the edge can start and end + if i == 0: + from_possibilities = _get_connection_coordinates(nodes, edge.from_node) + else: + from_possibilities = [(start_x, start_y)] + + if i == len(edge.points) - 2: + to_possibilities = _get_connection_coordinates(nodes, edge.to_node) + else: + to_possibilities = [(end_x, end_y)] + + # Choose connection points that minimize edge length + start_x, start_y, end_x, end_y = _minimize_distance(from_possibilities, to_possibilities) + + if i < len(edge.points) - 2: + # Middle part of edge without arrow + edge_part_source.data['from'].append(from_id) + edge_part_source.data['to'].append(to_id) + edge_part_source.data['start_x'].append(start_x) + edge_part_source.data['start_y'].append(-start_y) + edge_part_source.data['end_x'].append(end_x) + edge_part_source.data['end_y'].append(-end_y) + edge_part_source.data['color'].append(FINAL_TRACE_EDGE_COLOR if in_final_trace else OTHER_EDGE_COLOR) + else: + # End of edge with arrow + edge_arrow_source.data['from'].append(from_id) + edge_arrow_source.data['to'].append(to_id) + edge_arrow_source.data['start_x'].append(start_x) + edge_arrow_source.data['start_y'].append(-start_y) + edge_arrow_source.data['end_x'].append(end_x) + edge_arrow_source.data['end_y'].append(-end_y) + edge_arrow_source.data['color'].append(FINAL_TRACE_EDGE_COLOR if in_final_trace else OTHER_EDGE_COLOR) + + # Add the label + edge_label_source.data['from'].append(from_id) + edge_label_source.data['to'].append(to_id) + edge_label_source.data['x'].append((start_x + end_x) / 2) + edge_label_source.data['y'].append(- (start_y + end_y) / 2) + edge_label_source.data['label'].append(edge.label) + + +def _add_self_loop_to_sources(nodes: list[Node], edge: Edge, in_final_trace: bool, edge_arrow_source: ColumnDataSource, + edge_bezier_source: ColumnDataSource, edge_label_source: ColumnDataSource): + """ + Add a self-loop edge for a node to the ColumnDataSources, consisting of a Beziér curve and an arrow. + """ + connection = _get_connection_coordinates(nodes, edge.from_node) + + if isinstance(edge.from_node, frozenset): + from_id = tuple(sorted(edge.from_node)) + else: + from_id = edge.from_node + + if isinstance(edge.to_node, frozenset): + to_id = tuple(sorted(edge.to_node)) + else: + to_id = edge.to_node + + right_x, right_y = connection[1] + + # Add the Bézier curve + edge_bezier_source.data['from'].append(from_id) + edge_bezier_source.data['to'].append(to_id) + edge_bezier_source.data['start_x'].append(right_x) + edge_bezier_source.data['start_y'].append(-right_y + 5) + edge_bezier_source.data['end_x'].append(right_x) + edge_bezier_source.data['end_y'].append(-right_y - 5) + edge_bezier_source.data['control1_x'].append(right_x + 25) + edge_bezier_source.data['control1_y'].append(-right_y + 25) + edge_bezier_source.data['control2_x'].append(right_x + 25) + edge_bezier_source.data['control2_y'].append(-right_y - 25) + edge_bezier_source.data['color'].append(FINAL_TRACE_EDGE_COLOR if in_final_trace else OTHER_EDGE_COLOR) + + # Add the arrow + edge_arrow_source.data['from'].append(from_id) + edge_arrow_source.data['to'].append(to_id) + edge_arrow_source.data['start_x'].append(right_x + 0.001) + edge_arrow_source.data['start_y'].append(-right_y - 5.001) + edge_arrow_source.data['end_x'].append(right_x) + edge_arrow_source.data['end_y'].append(-right_y - 5) + edge_arrow_source.data['color'].append(FINAL_TRACE_EDGE_COLOR if in_final_trace else OTHER_EDGE_COLOR) + + # Add the label + edge_label_source.data['from'].append(from_id) + edge_label_source.data['to'].append(to_id) + edge_label_source.data['x'].append(right_x + 25) + edge_label_source.data['y'].append(-right_y) + edge_label_source.data['label'].append(edge.label) + + +def _add_node_to_sources(node: Node, final_trace: list[str], node_source: ColumnDataSource, + node_label_source: ColumnDataSource): + """ + Add a node to the ColumnDataSources. + """ + if isinstance(node.node_id, frozenset): + node_id = tuple(sorted(node.node_id)) + else: + node_id = node.node_id + + node_source.data['id'].append(node_id) + node_source.data['x'].append(node.x) + node_source.data['y'].append(-node.y) + node_source.data['w'].append(node.width) + node_source.data['h'].append(node.height) + node_source.data['color'].append( + FINAL_TRACE_NODE_COLOR if node.node_id in final_trace else OTHER_NODE_COLOR) + + node_label_source.data['id'].append(node_id) + node_label_source.data['x'].append(node.x - node.width / 2 + HORIZONTAL_PADDING_WITHIN_NODES) + node_label_source.data['y'].append(-node.y) + node_label_source.data['label'].append(node.label) + + +def _calculate_dimensions(label: str) -> tuple[float, float]: + """ + Calculate a node's dimensions based on its label and the given font size constant. + Assumes the font is Courier New. + """ + lines = label.splitlines() + width = 0 + for line in lines: + width = max(width, len(line) * (MAJOR_FONT_SIZE / 3 + 5)) + height = len(lines) * (MAJOR_FONT_SIZE / 2 + 9) * 1.37 - 9 + return width + 2 * HORIZONTAL_PADDING_WITHIN_NODES, height + 2 * VERTICAL_PADDING_WITHIN_NODES + + +def _flip_edges(edges: list[tuple[str, str]]) -> list[tuple[str, str]]: + """ + Calculate which edges need to be flipped to make a graph acyclic. + """ + # Step 1: Build adjacency list from edges + adj = {} + for u, v in edges: + if u not in adj: + adj[u] = [] + adj[u].append(v) + + # Step 2: Helper function to detect cycles + def dfs(node, visited, rec_stack, cycle_edges): + visited[node] = True + rec_stack[node] = True + + if node in adj: + for neighbor in adj[node]: + edge = (node, neighbor) + + if not visited.get(neighbor, False): + if dfs(neighbor, visited, rec_stack, cycle_edges): + cycle_edges.append(edge) + elif rec_stack.get(neighbor, False): + # Found a cycle, add the edge to the cycle_edges list + cycle_edges.append(edge) + + rec_stack[node] = False + return False + + # Step 3: Detect cycles + visited = {} + rec_stack = {} + cycle_edges = [] + + for node in adj: + if not visited.get(node, False): + dfs(node, visited, rec_stack, cycle_edges) + + # Step 4: Return the list of edges that need to be flipped + # In this case, the cycle_edges are the ones that we need to "break" by flipping + return cycle_edges diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py index f5c69c0f..e9d66913 100644 --- a/robotmbt/visualise/visualiser.py +++ b/robotmbt/visualise/visualiser.py @@ -1,8 +1,8 @@ from robotmbt.modelspace import ModelSpace from robotmbt.tracestate import TraceState +from robotmbt.visualise import networkvisualiser from robotmbt.visualise.graphs.reducedSDVgraph import ReducedSDVGraph from robotmbt.visualise.graphs.scenariodeltavaluegraph import ScenarioDeltaValueGraph -from robotmbt.visualise import networkvisualiser from robotmbt.visualise.graphs.abstractgraph import AbstractGraph from robotmbt.visualise.graphs.scenariograph import ScenarioGraph from robotmbt.visualise.graphs.stategraph import StateGraph @@ -24,7 +24,7 @@ def construct(cls, graph_type: str): # just calls __init__, but without having underscores etc. return cls(graph_type) - def __init__(self, graph_type: str, suite_name: str = "", export: bool = False, trace_info: TraceInfo = None): + def __init__(self, graph_type: str, suite_name: str = "", seed: str = "", export: bool = False, trace_info: TraceInfo = None): if graph_type != 'scenario' and graph_type != 'state' and graph_type != 'scenario-state' \ and graph_type != 'scenario-delta-value' and graph_type != 'reduced-sdv': raise ValueError(f"Unknown graph type: {graph_type}!") @@ -36,6 +36,7 @@ def __init__(self, graph_type: str, suite_name: str = "", export: bool = False, self.trace_info = trace_info self.suite_name = suite_name self.export = export + self.seed = seed def update_trace(self, trace: TraceState, state: ModelSpace): if len(trace.get_trace()) > 0: @@ -59,9 +60,6 @@ def generate_visualisation(self) -> str: else: graph: AbstractGraph = ScenarioStateGraph(self.trace_info) - vis = networkvisualiser.NetworkVisualiser(graph, self.suite_name) - html_bokeh = vis.generate_html() - - graph_size = networkvisualiser.NetworkVisualiser.GRAPH_SIZE_PX + html_bokeh = networkvisualiser.NetworkVisualiser(graph, self.suite_name, self.seed).generate_html() - return f'' + return f'' \ No newline at end of file From 0faa5152bb086d248d58d4eeec4c554b962666aa Mon Sep 17 00:00:00 2001 From: tychodub <93142605+tychodub@users.noreply.github.com> Date: Mon, 5 Jan 2026 13:40:48 +0100 Subject: [PATCH 089/131] Delta value+improved delta (#46) * implemented scenariodeltavaluegraph * implemented reduced-scenariodeltavaluegraph, but should still discuss how to label equivalence classes (currently just picks a representative). Fixed part of vertex info being indicated as edge info for scenariodeltavaluegraph. Trace highlighting is still broken for reducedSDV * implemented comments from Douwe (added comments and reset graph setting in suiteprocessors.py) * fixed reducedSDV graph to have a properly functioning equivalence relation * added DeltaValueGraph and improved difference method so that nested unchanged info gets removed as well (as was always intended but not yet implemented) * forgot to reset the graph parameter in process_test_suite from testing * fixed difference sometimes giving an empty namespace because of incorrect handling of nested namespaces when they are the same as in the previous state --- robotmbt/visualise/graphs/deltavaluegraph.py | 48 ++++++++++++++++++++ robotmbt/visualise/models.py | 36 +++++++++------ robotmbt/visualise/visualiser.py | 6 ++- 3 files changed, 76 insertions(+), 14 deletions(-) create mode 100644 robotmbt/visualise/graphs/deltavaluegraph.py diff --git a/robotmbt/visualise/graphs/deltavaluegraph.py b/robotmbt/visualise/graphs/deltavaluegraph.py new file mode 100644 index 00000000..7a77aa7a --- /dev/null +++ b/robotmbt/visualise/graphs/deltavaluegraph.py @@ -0,0 +1,48 @@ +from robotmbt.visualise.graphs.abstractgraph import AbstractGraph +from robotmbt.visualise.models import StateInfo, ScenarioInfo +from robotmbt.modelspace import ModelSpace + + +class DeltaValueGraph(AbstractGraph[set[tuple[str, str]], ScenarioInfo]): + """ + The state graph is a more advanced representation of trace exploration, allowing you to see the internal state. + It represents states as nodes, and scenarios as edges. + """ + + @staticmethod + def select_node_info(pairs: list[tuple[ScenarioInfo, StateInfo]], index: int) -> set[tuple[str, str]]: + if index == 0: + return StateInfo(ModelSpace()).difference(pairs[0][1]) + else: + return pairs[index-1][1].difference(pairs[index][1]) + + @staticmethod + def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> ScenarioInfo: + return pair[0] + + @staticmethod + def create_node_label(info: set[tuple[str, str]]) -> str: + res = "" + for assignment in info: + res += "\n"+assignment[0]+":"+assignment[1] + return f"{res}" + + @staticmethod + def create_edge_label(info: ScenarioInfo) -> str: + return info.name + + @staticmethod + def get_legend_info_final_trace_node() -> str: + return "Execution State Update (in final trace)" + + @staticmethod + def get_legend_info_other_node() -> str: + return "Execution State Update (backtracked)" + + @staticmethod + def get_legend_info_final_trace_edge() -> str: + return "Executed Scenario (in final trace)" + + @staticmethod + def get_legend_info_other_edge() -> str: + return "Executed Scenario (backtracked)" diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index a95a2272..c533dc76 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -1,3 +1,4 @@ +import logging from typing import Any from robot.api import logger @@ -51,21 +52,30 @@ def _create_state_with_prop(cls, name: str, attrs: list[tuple[str, Any]]): return cls(space) def difference(self, new_state) -> set[tuple[str, str]]: - left: dict[str, dict | str] = self.properties.copy() - for key in left.keys(): + old: dict[str, dict | str] = self.properties.copy() + new: dict[str, dict | str] = new_state.properties.copy() + temp = StateInfo._dict_deep_diff(old, new) + for key in temp.keys(): res = "" - for k, v in sorted(left[key].items()): + for k, v in sorted(temp[key].items()): res += f"\n\t{k}={v}" - left[key] = res - right: dict[str, dict | str] = new_state.properties.copy() - for key in right.keys(): - res = "" - for k, v in sorted(right[key].items()): - res += f"\n\t{k}={v}" - right[key] = res - # type inference goes doodoo here - temp: set[tuple[str, str]] = set(right.items()) - set(left.items()) - return temp + temp[key] = res + return set(temp.items()) # type inference goes wacky here + + @staticmethod + def _dict_deep_diff(old_state: dict[str, any], new_state: dict[str, any]) -> dict[str, any]: + res = {} + for key in new_state.keys(): + if key not in old_state: + res[key] = new_state[key] + elif isinstance(old_state[key], dict): + diff = StateInfo._dict_deep_diff(old_state[key], new_state[key]) + if len(diff) != 0: + res[key] = diff + elif old_state[key] != new_state[key]: + res[key] = new_state[key] + return res + def __init__(self, state: ModelSpace): self.domain = state.ref_id diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py index e9d66913..faa9a2b9 100644 --- a/robotmbt/visualise/visualiser.py +++ b/robotmbt/visualise/visualiser.py @@ -1,6 +1,7 @@ from robotmbt.modelspace import ModelSpace from robotmbt.tracestate import TraceState from robotmbt.visualise import networkvisualiser +from robotmbt.visualise.graphs.deltavaluegraph import DeltaValueGraph from robotmbt.visualise.graphs.reducedSDVgraph import ReducedSDVGraph from robotmbt.visualise.graphs.scenariodeltavaluegraph import ScenarioDeltaValueGraph from robotmbt.visualise.graphs.abstractgraph import AbstractGraph @@ -26,7 +27,8 @@ def construct(cls, graph_type: str): def __init__(self, graph_type: str, suite_name: str = "", seed: str = "", export: bool = False, trace_info: TraceInfo = None): if graph_type != 'scenario' and graph_type != 'state' and graph_type != 'scenario-state' \ - and graph_type != 'scenario-delta-value' and graph_type != 'reduced-sdv': + and graph_type != 'scenario-delta-value' and graph_type != 'reduced-sdv' \ + and graph_type != 'delta-value': raise ValueError(f"Unknown graph type: {graph_type}!") self.graph_type: str = graph_type @@ -57,6 +59,8 @@ def generate_visualisation(self) -> str: graph: AbstractGraph = ScenarioDeltaValueGraph(self.trace_info) elif self.graph_type == 'reduced-sdv': graph: AbstractGraph = ReducedSDVGraph(self.trace_info) + elif self.graph_type == 'delta-value': + graph: AbstractGraph = DeltaValueGraph(self.trace_info) else: graph: AbstractGraph = ScenarioStateGraph(self.trace_info) From f294748b654f237a35cc085564de995c9faa72a9 Mon Sep 17 00:00:00 2001 From: tychodub <93142605+tychodub@users.noreply.github.com> Date: Wed, 7 Jan 2026 11:20:25 +0100 Subject: [PATCH 090/131] Reduced sdv label improvement (#47) * implemented scenariodeltavaluegraph * implemented reduced-scenariodeltavaluegraph, but should still discuss how to label equivalence classes (currently just picks a representative). Fixed part of vertex info being indicated as edge info for scenariodeltavaluegraph. Trace highlighting is still broken for reducedSDV * implemented comments from Douwe (added comments and reset graph setting in suiteprocessors.py) * fixed reducedSDV graph to have a properly functioning equivalence relation * added merged annotation to labels generated by reducedSDV for merged nodes --- robotmbt/visualise/graphs/reducedSDVgraph.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/robotmbt/visualise/graphs/reducedSDVgraph.py b/robotmbt/visualise/graphs/reducedSDVgraph.py index c9ea89b3..b7147bef 100644 --- a/robotmbt/visualise/graphs/reducedSDVgraph.py +++ b/robotmbt/visualise/graphs/reducedSDVgraph.py @@ -25,12 +25,19 @@ def chain_equiv(self, node1, node2) -> bool: else: return False + @staticmethod + def _generate_equiv_class_label(equiv_class, old_labels): + if len(equiv_class) == 1: + return old_labels[set(equiv_class).pop()] + else: + return "(merged: "+str(len(equiv_class))+")\n"+old_labels[set(equiv_class).pop()] + def __init__(self, info: TraceInfo): super().__init__(info) old_labels = networkx.get_node_attributes(self.networkx, "label") self.networkx = networkx.quotient_graph(self.networkx, lambda x, y: self.chain_equiv(x, y), node_data=lambda equiv_class: { - 'label': old_labels[set(equiv_class).pop()]}, + 'label': self._generate_equiv_class_label(equiv_class, old_labels)}, edge_data=lambda x, y: {'label': ''}) # TODO make generated label more obvious to be equivalence class nodes = self.networkx.nodes From 59b902a0156fbf22d3eff9ece5d4bde2ed60b4a2 Mon Sep 17 00:00:00 2001 From: Thomas Kas Date: Thu, 8 Jan 2026 14:14:19 +0100 Subject: [PATCH 091/131] Sync fork (#48) * handle optional argument's default values * optional arguments stay hidden when not mentioned * fix argument text for POSITIONAL_OR_NAMED cases Now keeps the notation as used in the scenario (with or without the argument's name) in the generated keyword arguments. * fix failure on empty varargs * workaround for embedded argument name mismatch Accept that names of embedded arguments in the @keyword decorator must match their Python argument counterparts. * support step modifiers for non-embedded arguments * handling vararg modifiers * add tests for step modifier with free named arguments * add error message for vararg and free named arg modifiers * docu update for step modifiers on non-embedded arguments * promote random seed logging to info level Logging the seed at info level allows to run without debug logging for smaller log files, without losing the ability to rerun an interesting trace. * promote random seed logging to info level * make tests independent * Remove embedded-arguments-only restriction for modifiers * bump version to 0.10 * New tests for keeping direct setting's effects local * keep direct model options local * How to use configuration options * added cicd * update keyword documentation * added error propagation to run_tests.py * removed commented out comment in run-tests.yml CI/CD, fixed 'pip install' * apply pep8 Python formatting at max 120 char/line * added cicd * added error propagation to run_tests.py * removed commented out comment in run-tests.yml CI/CD, fixed 'pip install' * format with autopep8 (default line length) * reformat with new max line length setting * Use consistent casing in naming RobotMBT * Add contribution guidelines * add workflow to check demo * added exit code propagation to demo * install local requirements.txt for demo * refactor comments and yaml syntax * adjusted contributing (style, formatting, sentence structure * Text clarifications CONTRIBUTING.md * fix md linting issues * access TraceSnapShot model as copy * use exit code to pass/fail autopep8 * test: intentional pep8 violation * undo pep8 violation * reuse scenario indexes for TraceState * keep original scenario list unshuffled * refactor processing to reach full coverage Open issue: rewind under refinement * delegate refinement stack to TraceState * more tests for refinement and rewinds * new test for data consistency in split-up scenarios * move scenario processing to its own file * remove redundant return value (model) * restore logging tweak * Typehints + formatting (#4) * added type hints to modelspace.py, steparguments.py, substitutionmap.py, version.py and __init__.py, a little bit of suitedata.py and to tracestate.py. Reordered classes in tracestate.py for this purpose. * added nearly all remaining type hints, including missing "Self" types from files typed in earlier commits. Not everything in suitereplacer.py is typed because of severe ambiguity issues * reformatted according to PEP8 (using in VSCode) --------- Co-authored-by: tychodub * fixed typing of get_trace in tracestate.py (#5) * Jetbrains gitignore (#7) * Create python-app.yml, for Github actions * Create python-app.yml, for Github actions Create python-app.yml, runs utests when pushing/merging pull-request into main * test run actual python file * use python latest version * use python version 3.13 * fix comment * actually return exit code that robot returns --------- Co-authored-by: Douwe Osinga * Visual Paradigm Architectural Designs (#8) * added ignore for VPP * added initial VPP * vpp * modified gitignore * added new diagram - extension plan * added initial renders * .vpp commit * added improved event-based design * changed design to accomodate different graph repr. better * vpp * Visualisation architecture (#9) * Implement basic scenario graph architecture (without actual visualisation) * Also output graph if coverage could not be reached * from visualiser.graph to networkx graph * added optional dependencies for visualization feature * rework scenariograph to use networkx * Update python-app.yml to install optional dependencies * moved documentation according to python * remove static variables --------- Co-authored-by: Thomas Co-authored-by: jonathan <148167.jw@gmail.com> Co-authored-by: tychodub Co-authored-by: Jonathan <61787386+JWillegers@users.noreply.github.com> * Restructure + disable check on main (#10) * Seperated ScenarioInfo, TraceInfo, and ScnearioGraph into seperate file according to architecture * Don't run automated testing on main * fixing imports * Rollback self typing branch (#11) * commented Self typing annotations * changed Github actions python-version to 3.10 * change from spring layout to planar layout --------- Co-authored-by: jonathan <148167.jw@gmail.com> * Documentation (+gitignore) for setting up virtual environment. (#13) * add initial readme adjustments for pipenv * ignore pyenv pipfile * updated README - windows specific stuff and overall improvement * Generate html with bokeh (#15) * add bokeh as optional dependency * remove dependency on scipy * bokeh graph with arrows and a start on selfloops * add labels to vertices * restructure according to architecture * fixed self-loops; fixed arrowheads not being at boundary of vertex * embedded plot in html * added extra documentation * fixed edge_case where there is only 1 scenario * consistently use "node" in the code * remove draw_from_networkx and draw nodes in a for-loop * moved width/height/edge styling/... to constants * fixed zooming with tools * adding future support for having labels at edges * reorder imports, prepend private methods with underscore * fix requested changes (code comments) * test class traceInfo --------- Co-authored-by: Douwe Osinga * Unit test for models.py (#17) * removed self.fixed from ScenarioGraph added self.end_node in ScenarioGraph * test cases for most methods in models.py (missing: set_ending_node and calculate_pos) * added unit test for scenariograph.set_end_node() * Acceptance testing (#18) * initial acceptance test PoC * added traceInfo generator, adjusted some constructors to work with Robot testing, first working PoC of Atest * found first bug with Acceptance Test * fix unit tests * refactored helper, removed unnecessary logging * added return type for * deleted useless testing file * inlined suite setup * reduced type of state to ModelSpace, fixed incorrect type hint * added warning for future state implementations * changed utest to reflect comments from PR pull #8 (acceptance testing) - TODO add model state Thomas/Tycho * fixed constructor TraceInfo to require ModelSpace * Revert accidental change --------- Co-authored-by: jonathan <148167.jw@gmail.com> Co-authored-by: Thomas * Acceptance test - Vertex and Edge rules (#19) * initial acceptance test PoC * added traceInfo generator, adjusted some constructors to work with Robot testing, first working PoC of Atest * refactored helper, removed unnecessary logging * added edge representation acceptance test * Add subfolders to toml (#20) * Node redesign (#16) * Refactor node rendering to use rectangles for scenarios * Improve edge connection points and arrow positioning * Redesign self-loops for rectangle nodes * Fix self-loop arrow alignment and improve visual consistency * Fix visualization issues according to reviwer's feedback * magic fix from Jonathan * Simplify self-loop logic and remove invalid edge cases * redo part of Jonathan fix - makes ERROR:bokeh.core.validation.check:E-1001 (BAD_COLUMN_NAME) disappear * remove duplicate code from edge calculation --------- Co-authored-by: Diogo Silva * State graphs + graph switching + dependency checks (#21) * Update using final trace info internally * Add StateInfo abstraction * Implement StateGraph * cleaned up StateInfo.__init__ a little * Merge with main * Fixed oopsie * Implement abstract base class for graphs * Implement per-suite graph choice * Only generate graph if dependencies are installed * Don't run our tests without dependencies * Use empty string instead of None * doodoo * Merge and fix some issues * Run formatter * Fixed doc * Implement requested changes --------- Co-authored-by: tychodub * Restructure (#22) * Seperate network_visualiser from visualiser * - renamed pos to graph_layout - moved graph_layout from models to network_visualiser * refactor file name to not contain underscore * taking the graphs classes out of models.py * split unit tests * added test for scenariograph.set_final_trace * add arguments to atests * correct orientation for bfs * Updated design files according to implementation (#23) * fix line indent (#25) * Scenario state (#26) * implemented scenario-state (state after scenario has run variant) graph representation * removed hardcoded starting node id by keeping track of starting node info * added unit tests for scenariostategraph.py and some supporting code for the unit tests * renamed unit tests because I forgot initially * implemented suggestions by Jonathan (moved function into scenariostategraph as static method, de-indented 'if main' in test_visualise_scenariostategraph.py and moved is not None check outside for-loop) * removed end_node from scenariostategraph.py * moved is not None check outside of loop in scenariograph.py * Path Highlighting (#27) * Implement Feature Path Highlighting in Scenario and State Graphs * Implement State Graph Path highlighting with Stack * Clean-up of unused features, move some logic, fix empty state entry bug * Formatting * Formatting * Remove unused return value * Merge with main, fix some bugs, alter ScenarioStateGraph to be in line with the rest and fix up some of its unit tests * Add short explanation --------- Co-authored-by: Thomas * Unit tests and bug fixes (#28) * Implement Feature Path Highlighting in Scenario and State Graphs * Implement State Graph Path highlighting with Stack * Clean-up of unused features, move some logic, fix empty state entry bug * Formatting * Formatting * Remove unused return value * Fix bug * StateInfo unit tests * StateGraph unit tests * Merge with main, fix some bugs, alter ScenarioStateGraph to be in line with the rest and fix up some of its unit tests * Add short explanation * Test self-loops * Minor tweaks * Remove trailing newline * Fix nuking of stack * Better formatting of state info * Spam __update_visualisation all over the place for refinement * Fix nuking in scenario-state graph * Warn instead of crashing * Sanity check * Do add edge with start node * Expand tests * Merge mistake * Fix oopsie * Move helper to StateInfo * Protected helper * Forgot this one * added graph getter for resources --------- Co-authored-by: Diogo Silva Co-authored-by: Douwe Osinga * Sync fork (#30) * handle optional argument's default values * optional arguments stay hidden when not mentioned * fix argument text for POSITIONAL_OR_NAMED cases Now keeps the notation as used in the scenario (with or without the argument's name) in the generated keyword arguments. * fix failure on empty varargs * workaround for embedded argument name mismatch Accept that names of embedded arguments in the @keyword decorator must match their Python argument counterparts. * support step modifiers for non-embedded arguments * handling vararg modifiers * add tests for step modifier with free named arguments * add error message for vararg and free named arg modifiers * docu update for step modifiers on non-embedded arguments * promote random seed logging to info level Logging the seed at info level allows to run without debug logging for smaller log files, without losing the ability to rerun an interesting trace. * promote random seed logging to info level * make tests independent * Remove embedded-arguments-only restriction for modifiers * bump version to 0.10 * Implement Feature Path Highlighting in Scenario and State Graphs * Implement State Graph Path highlighting with Stack * Clean-up of unused features, move some logic, fix empty state entry bug * Formatting * Formatting * Remove unused return value * Fix bug * StateInfo unit tests * StateGraph unit tests * Merge with main, fix some bugs, alter ScenarioStateGraph to be in line with the rest and fix up some of its unit tests * Add short explanation * Test self-loops * Minor tweaks * Remove trailing newline * Fix nuking of stack * Better formatting of state info * Spam __update_visualisation all over the place for refinement * Fix nuking in scenario-state graph * Warn instead of crashing * Sanity check * Do add edge with start node * Expand tests * Merge mistake * Fix oopsie * Format and fix type * Move helper to StateInfo * Protected helper * Forgot this one * Fix nuked graphs * Implement feedback --------- Co-authored-by: JFoederer <32476108+JFoederer@users.noreply.github.com> Co-authored-by: Diogo Silva * Rework trace info and fix bugs (#32) * Clean-up of unused features, move some logic, fix empty state entry bug * Merge with main, fix some bugs, alter ScenarioStateGraph to be in line with the rest and fix up some of its unit tests * Merge mistake * Start of rework * Rework TraceInfo, extract common graph logic * Fix unit tests * Bug fixes and minor changes * Fix nuked graphs * Remove outdate acceptance tests * Generics * Proper type * Fix bug in Johan's code * More sanity, implement feedback * Design improvements (#33) * updated design with rework from Thomas * refactored design - Thomas comments * removed pdf version of 2025-12-08 render * refactored design - Thomas comments * add 'graph_type' to 'process_test_suite' * AutoPEP8 check on PRs (#29) * added initial autopep8 CI/CD * different autopep8 now, lets see... * new autopep8 that actually should work * now one that also adds a commit that fixes it * fix try 2 * revert to just check * remove redundant stuff * added max-line-length 120 to autopep8 check * formatted code with autopep8 (max-line-length 120) * made formatting issues check also pass for purely whitespace output * Scenario Delta Value + Reduced Scenario Delta Value graph (#38) * changed part of abstractgraph interface for delta graph development * implemented scenariodeltavaluegraph * implemented reduced-scenariodeltavaluegraph, but should still discuss how to label equivalence classes (currently just picks a representative). Fixed part of vertex info being indicated as edge info for scenariodeltavaluegraph. Trace highlighting is still broken for reducedSDV * removed redundant code in __init__ of reducedSDVgraph and added code to update final_trace to make trace highlighting work * initial test commit for SDVGraphs * documentation for SDV graphs * added TODO comment * implemented comments from Douwe (added comments and reset graph setting in suiteprocessors.py) --------- Co-authored-by: tychodub * Full Screen + Graph Title (#37) * Make HTML generation static * Added a fullscreen button in the graph visualization toolbar * Added the suite name as the graph title * Fixed generate_html method to use NetworkVisualiser class * Changed generate_visualisation return to use GRAPH_SIZE_PX constant --------- Co-authored-by: Thomas Kas * stategraph labeling: 1 edge with multiple scenarios (#39) * Major fix reducedSDV (#40) * changed part of abstractgraph interface for delta graph development * implemented scenariodeltavaluegraph * implemented reduced-scenariodeltavaluegraph, but should still discuss how to label equivalence classes (currently just picks a representative). Fixed part of vertex info being indicated as edge info for scenariodeltavaluegraph. Trace highlighting is still broken for reducedSDV * removed redundant code in __init__ of reducedSDVgraph and added code to update final_trace to make trace highlighting work * initial test commit for SDVGraphs * documentation for SDV graphs * added TODO comment * implemented comments from Douwe (added comments and reset graph setting in suiteprocessors.py) * fixed reducedSDV graph to have a properly functioning equivalence relation * Start node property (#41) * implemented scenariodeltavaluegraph * implemented reduced-scenariodeltavaluegraph, but should still discuss how to label equivalence classes (currently just picks a representative). Fixed part of vertex info being indicated as edge info for scenariodeltavaluegraph. Trace highlighting is still broken for reducedSDV * implemented comments from Douwe (added comments and reset graph setting in suiteprocessors.py) * fixed reducedSDV graph to have a properly functioning equivalence relation * fixed layout for reducedSDV graphs * Export json (#44) * export and import to/from json * export option in suiteprocessors * moved import from json from visualiser.py to suiteprocessors.py * [WIP] atest for importing * remove json file * revert gitignore to make json folder fully ignored * [WIP] generate test suite * complete atest for importing/exporting * renaming file and folder of .robot file * made test run with robotmbt and generate graph * updated test suite * fix line length * fixing comments not related to atest * deleting "JSON" as it is not specific to JSON * Added docstring and made a keyword more generic * Added documentation about why the usage of mkstemp instead of temporary file * Sugiyama layout (#45) * Make HTML generation static * Begin visualisation rework - nodes * Move some code * Import full screen and title from Diogo * Sugiyama layout * Rescale when zooming * Draw edges * Align edge labels center * Make HTML generation static * Begin visualisation rework - nodes * Move some code * Import full screen and title from Diogo * Sugiyama layout * Rescale when zooming * Draw edges * Align edge labels center * Fix minor merge issues * Format delta value the same as state * Inline mini static method * Make HTML generation static * Begin visualisation rework - nodes * Move some code * Import full screen and title from Diogo * Sugiyama layout * Rescale when zooming * Draw edges * Align edge labels center * Make HTML generation static * Begin visualisation rework - nodes * Move some code * Import full screen and title from Diogo * Sugiyama layout * Rescale when zooming * Draw edges * Align edge labels center * Fix minor merge issues * Format delta value the same as state * Inline mini static method * Fix fails * Remove unused dependency * Remove static method * Remove static method * Connect edges at the edge of nodes * Format * Remove unused constants * They weren't unused * Now they are * Extract functions * Self-loops * Extract constants * Scale properly on full-screen * Scale arrows as well * Color edges * Documentation * Add legend * Single comment * Change xrange based on aspect ratio * Floor font size to make it stay within nodes * Simplify resize callback * Include seed under graph title * Sort items to ensure equal ordering * Make HTML generation static * Begin visualisation rework - nodes * Move some code * Import full screen and title from Diogo * Sugiyama layout * Rescale when zooming * Draw edges * Align edge labels center * Make HTML generation static * Begin visualisation rework - nodes * Move some code * Import full screen and title from Diogo * Sugiyama layout * Rescale when zooming * Draw edges * Align edge labels center * Fix minor merge issues * Format delta value the same as state * Inline mini static method * Begin visualisation rework - nodes * Fix minor merge issues * Fix fails * Remove unused dependency * Remove static method * Remove static method * Connect edges at the edge of nodes * Format * Remove unused constants * They weren't unused * Now they are * Extract functions * Self-loops * Extract constants * Scale properly on full-screen * Scale arrows as well * Color edges * Documentation * Add legend * Single comment * Change xrange based on aspect ratio * Floor font size to make it stay within nodes * Simplify resize callback * Include seed under graph title * Sort items to ensure equal ordering * fix initial errors because of wrongly resolved rebase conflicts * remove duplicate method * first try at re-establishing visualization * revert "first try at ..." * Proper types * Proper types, remove duplicate definition * Access models directly from snapshots * Types * Turn all errors produced by visualiser into warnings * Re-implement delta-value changes * Re-implement reduced SDV changes * Forgor change * Implement requested changes, fix typehints * Whoops --------- Co-authored-by: JFoederer <32476108+JFoederer@users.noreply.github.com> Co-authored-by: Douwe Osinga Co-authored-by: D Osinga <46380170+osingaatje@users.noreply.github.com> Co-authored-by: tychodub Co-authored-by: tychodub <93142605+tychodub@users.noreply.github.com> Co-authored-by: jonathan <148167.jw@gmail.com> Co-authored-by: Jonathan <61787386+JWillegers@users.noreply.github.com> Co-authored-by: Diogo Silva --- demo/Titanic/run_demo.py | 16 +- robotmbt/modeller.py | 14 +- robotmbt/modelspace.py | 5 +- robotmbt/steparguments.py | 23 +- robotmbt/substitutionmap.py | 4 +- robotmbt/suitedata.py | 44 +- robotmbt/suiteprocessors.py | 497 ++++--------------- robotmbt/suitereplacer.py | 55 +- robotmbt/tracestate.py | 159 +++--- robotmbt/visualise/graphs/reducedSDVgraph.py | 4 +- robotmbt/visualise/models.py | 1 - robotmbt/visualise/visualiser.py | 38 +- utest/test_steparguments.py | 8 +- utest/test_substitutionmap.py | 3 +- utest/test_suitedata.py | 13 + utest/test_tracestate.py | 326 ++++++------ utest/test_tracestate_refinement.py | 192 ++++--- 17 files changed, 612 insertions(+), 790 deletions(-) diff --git a/demo/Titanic/run_demo.py b/demo/Titanic/run_demo.py index d6936c06..37977ff8 100644 --- a/demo/Titanic/run_demo.py +++ b/demo/Titanic/run_demo.py @@ -21,10 +21,12 @@ # The base folder needs to be added to the python path to resolve the dependencies. You # will also need to add this path to your IDE options when running from there. - robot.run_cli(['--outputdir', OUTPUT_ROOT, - '--pythonpath', THIS_DIR, - '--exclude', HIT_MISS_TAG, - '--exclude', EXTENDED_TAG, - '--loglevel', 'DEBUG:INFO', - SCENARIO_FOLDER], - exit=False) + exitcode = robot.run_cli(['--outputdir', OUTPUT_ROOT, + '--pythonpath', THIS_DIR, + '--exclude', HIT_MISS_TAG, + '--exclude', EXTENDED_TAG, + '--loglevel', 'DEBUG:INFO', + SCENARIO_FOLDER], + exit=False) + + sys.exit(exitcode) diff --git a/robotmbt/modeller.py b/robotmbt/modeller.py index f3505b0f..3c9fd990 100644 --- a/robotmbt/modeller.py +++ b/robotmbt/modeller.py @@ -36,7 +36,7 @@ from robot.utils import is_list_like from .modelspace import ModelSpace -from .steparguments import StepArgument, StepArguments, ArgKind +from .steparguments import StepArgument, StepArguments from .substitutionmap import SubstitutionMap from .suitedata import Scenario, Step from .tracestate import TraceState, TraceSnapShot @@ -66,7 +66,7 @@ def try_to_fit_in_scenario(candidate: Scenario, tracestate: TraceState): tracestate.push_partial_scenario(inserted.src_id, inserted, model, remainder) -def process_scenario(scenario: Scenario, model: ModelSpace) -> tuple[Scenario, Scenario, dict[str, Any]]: +def process_scenario(scenario: Scenario, model: ModelSpace) -> tuple[Scenario | None, Scenario | None, dict[str, Any]]: for step in scenario.steps: if 'error' in step.model_info: return None, None, dict(fail_masg=f"Error in scenario {scenario.name} " @@ -162,7 +162,7 @@ def handle_refinement_exit(inserted_refinement: Scenario, tracestate: TraceState tracestate.push_partial_scenario(tail_inserted.src_id, tail_inserted, model, remainder) -def generate_scenario_variant(scenario: Scenario, model: ModelSpace) -> Scenario: +def generate_scenario_variant(scenario: Scenario, model: ModelSpace) -> Scenario | None: scenario = scenario.copy() # collect set of constraints subs = SubstitutionMap() @@ -172,7 +172,7 @@ def generate_scenario_variant(scenario: Scenario, model: ModelSpace) -> Scenario modded_arg, constraint = _parse_modifier_expression(expr, step.args) if step.args[modded_arg].is_default: continue - if step.args[modded_arg].kind in [ArgKind.EMBEDDED, ArgKind.POSITIONAL, ArgKind.NAMED]: + if step.args[modded_arg].kind in [StepArgument.EMBEDDED, StepArgument.POSITIONAL, StepArgument.NAMED]: org_example = step.args[modded_arg].org_value if step.gherkin_kw == 'then': constraint = None # No new constraints are processed for then-steps @@ -193,7 +193,7 @@ def generate_scenario_variant(scenario: Scenario, model: ModelSpace) -> Scenario else: options = None subs.substitute(org_example, options) - elif step.args[modded_arg].kind == ArgKind.VAR_POS: + elif step.args[modded_arg].kind == StepArgument.VAR_POS: if step.args[modded_arg].value: modded_varargs = model.process_expression(constraint, step.args) if not is_list_like(modded_varargs): @@ -202,7 +202,7 @@ def generate_scenario_variant(scenario: Scenario, model: ModelSpace) -> Scenario # change the number of arguments in the list, making it impossible to decide which values to # match and which to drop and/or duplicate. step.args[modded_arg].value = modded_varargs - elif step.args[modded_arg].kind == ArgKind.FREE_NAMED: + elif step.args[modded_arg].kind == StepArgument.FREE_NAMED: if step.args[modded_arg].value: modded_free_args = model.process_expression(constraint, step.args) if not isinstance(modded_free_args, dict): @@ -234,7 +234,7 @@ def generate_scenario_variant(scenario: Scenario, model: ModelSpace) -> Scenario if step.args[modded_arg].is_default: continue org_example = step.args[modded_arg].org_value - if step.args[modded_arg].kind in [ArgKind.EMBEDDED, ArgKind.POSITIONAL, ArgKind.NAMED]: + if step.args[modded_arg].kind in [StepArgument.EMBEDDED, StepArgument.POSITIONAL, StepArgument.NAMED]: step.args[modded_arg].value = subs.solution[org_example] return scenario diff --git a/robotmbt/modelspace.py b/robotmbt/modelspace.py index f30c9012..1504a7fd 100644 --- a/robotmbt/modelspace.py +++ b/robotmbt/modelspace.py @@ -31,6 +31,7 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import copy +from typing import Any from .steparguments import StepArguments @@ -46,7 +47,7 @@ def __init__(self, reference_id=None): self.props: dict[str, RecursiveScope | ModelSpace] = dict() # For using literals without having to use quotes (abc='abc') - self.values: dict[str, any] = dict() + self.values: dict[str, Any] = dict() self.scenario_vars: list[RecursiveScope] = [] self.std_attrs = dir(self) @@ -96,7 +97,7 @@ def end_scenario_scope(self): else: self.props.pop('scenario') - def process_expression(self, expression: str, step_args: StepArguments = StepArguments()) -> any: + def process_expression(self, expression: str, step_args: StepArguments = StepArguments()) -> Any: expr = step_args.fill_in_args(expression.strip(), as_code=True) if self._is_new_vocab_expression(expr): self.add_prop(self._vocab_term(expr)) diff --git a/robotmbt/steparguments.py b/robotmbt/steparguments.py index 42489581..06980151 100644 --- a/robotmbt/steparguments.py +++ b/robotmbt/steparguments.py @@ -32,6 +32,7 @@ from keyword import iskeyword import builtins +from typing import Any class StepArguments(list): @@ -64,27 +65,27 @@ class StepArgument: NAMED = 'NAMED' FREE_NAMED = 'FREE_NAMED' - def __init__(self, arg_name: str, value: any, kind: str | None = None, is_default: bool = False): + def __init__(self, arg_name: str, value: Any, kind: str | None = None, is_default: bool = False): self.name: str = arg_name - self.org_value: any = value + self.org_value: Any = value self.kind: str | None = kind # one of the values from the kind list - self._value: any = None + self._value: Any = None self._codestr: str | None = None - self.value: any = value - self.is_default: bool = is_default # indicates that the argument was not - # filled in from the scenario. This argment's value is taken - # from the keyword's default as provided by Robot. + self.value: Any = value + # is_default indicates that the argument was not filled in from the scenario. This + # argment's value is taken from the keyword's default as provided by Robot. + self.is_default: bool = is_default @property def arg(self) -> str: return "${%s}" % self.name @property - def value(self) -> any: + def value(self) -> Any: return self._value @value.setter - def value(self, value: any): + def value(self, value: Any): self._value = value self._codestr = self.make_codestring(value) self.is_default = False @@ -107,7 +108,7 @@ def __str__(self): return f"{self.name}={self.value}" @staticmethod - def make_codestring(text: any) -> str: + def make_codestring(text: Any) -> str: codestr = str(text) if codestr.title() in ['None', 'True', 'False']: return codestr.title() @@ -118,7 +119,7 @@ def make_codestring(text: any) -> str: return codestr @staticmethod - def make_identifier(s: any) -> str: + def make_identifier(s: Any) -> str: _s = str(s).replace(' ', '_') if _s.isidentifier(): return f"{_s}_" if iskeyword(_s) or _s in dir(builtins) else _s diff --git a/robotmbt/substitutionmap.py b/robotmbt/substitutionmap.py index b47ada71..ea7648af 100644 --- a/robotmbt/substitutionmap.py +++ b/robotmbt/substitutionmap.py @@ -56,9 +56,7 @@ def __str__(self): def copy(self): # -> Self new = SubstitutionMap() - new.substitutions = {k: v.copy() - for k, v in self.substitutions.items()} - + new.substitutions = {k: v.copy() for k, v in self.substitutions.items()} new.solution = self.solution.copy() return new diff --git a/robotmbt/suitedata.py b/robotmbt/suitedata.py index bfa41c74..11a796d9 100644 --- a/robotmbt/suitedata.py +++ b/robotmbt/suitedata.py @@ -31,6 +31,7 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import copy +from typing import Any from robot.running.arguments.argumentvalidator import ArgumentValidator import robot.utils.notset @@ -40,7 +41,7 @@ class Suite: - def __init__(self, name: str, parent=None): + def __init__(self, name: str, parent: Any = None): self.name: str = name self.filename: str = '' self.parent: Suite | None = parent @@ -62,30 +63,22 @@ def has_error(self) -> bool: # list[Step | str | None], Step needs to be moved up def steps_with_errors(self): return (([self.setup] if self.setup and self.setup.has_error() else []) - + [e for s in map(Suite.steps_with_errors, self.suites) - for e in s] - + [e for s in map(Scenario.steps_with_errors, - self.scenarios) for e in s] + + [e for s in map(Suite.steps_with_errors, self.suites) for e in s] + + [e for s in map(Scenario.steps_with_errors, self.scenarios) for e in s] + ([self.teardown] if self.teardown and self.teardown.has_error() else [])) class Scenario: - def __init__(self, name: str, parent=None): + def __init__(self, name: str, parent: Suite | None = None): self.name: str = name - - # Parent scenario for easy searching, processing and referencing + # Parent scenario is kept for easy searching, processing and referencing # after steps and scenarios have been potentially moved around self.parent: Suite | None = parent - - # Can be a single step or None, may also be a str in tests self.setup: Step | None = None - - # Can be a single step or None, may also be a str in tests self.teardown: Step | None = None - self.steps: list[Step] = [] self.src_id: int | None = None - self.data_choices: dict | SubstitutionMap = {} # may be Dummy type in a test + self.data_choices: dict | SubstitutionMap = {} # may be Dummy type in a test @property def longname(self) -> str: @@ -128,30 +121,33 @@ def split_at_step(self, stepindex: int): class Step: def __init__(self, steptext: str, *args, parent: Suite | Scenario, assign: tuple[str] = (), prev_gherkin_kw: str | None = None): - # first keyword cell of the Robot line, including step_kw, + # org_step is the first keyword cell of the Robot line, including step_kw, # excluding positional args, excluding variable assignment. self.org_step: str = steptext - # positional and named arguments as parsed from Robot text ('posA' , 'posB', 'named1=namedA') + + # org_pn_args are the positional and named arguments as parsed + # from the Robot text ('posA' , 'posB', 'named1=namedA') self.org_pn_args = args # Parent scenario for easy searching and processing. self.parent: Suite | Scenario = parent # For when a keyword's return value is assigned to a variable. # Taken directly from Robot. self.assign: tuple[str] = assign - # 'given', 'when', 'then' or None for non-bdd keywords. + + # gherkin_kw is one of 'given', 'when', 'then', or None for non-bdd keywords. self.gherkin_kw: str | None = self.step_kw \ if str(self.step_kw).lower() in ['given', 'when', 'then', 'none'] \ else prev_gherkin_kw + # Robot keyword with its embedded arguments in ${...} notation. self.signature: str | None = None # embedded arguments list of StepArgument objects. self.args: StepArguments = StepArguments() # Decouples StepArguments from the step text (refinement use case) self.detached: bool = False - # Modelling information is available as a dictionary. - # TODO: Maybe use a data structure for this instead of a dict with specific keys. - # The standard format is dict(IN=[], OUT=[]) and can - # optionally contain an error field. + + # model_info contains modelling information as a dictionary. The standard format is + # dict(IN=[], OUT=[]) and can optionally contain an error field. # IN and OUT are lists of Python evaluatable expressions. # The `new vocab` form can be used to create new domain objects. # The `vocab.attribute` form can then be used to express relations @@ -194,11 +190,11 @@ def keyword(self) -> str: return self.args.fill_in_args(s) @property - def posnom_args_str(self) -> tuple[any]: + def posnom_args_str(self) -> tuple[Any]: """A tuple with all arguments in Robot accepted text format ('posA' , 'posB', 'named1=namedA')""" if self.detached or not self.args.modified: return self.org_pn_args - result: list[any] = [] + result: list[Any] = [] for arg in self.args: if arg.is_default: continue @@ -246,7 +242,7 @@ def add_robot_dependent_data(self, robot_kw): self.args += self.__handle_non_embedded_arguments(robot_kw.args) self.signature = robot_kw.name - self.model_info = self.__parse_model_info(robot_kw._doc) + self.model_info: dict[str, list[str] | str] = self.__parse_model_info(robot_kw._doc) except Exception as ex: self.model_info['error'] = str(ex) diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index be08ee94..c8064434 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -32,15 +32,14 @@ import copy import random +from typing import Any from robot.api import logger -from robot.utils import is_list_like -from .substitutionmap import SubstitutionMap +from . import modeller from .modelspace import ModelSpace -from .suitedata import Suite, Scenario, Step -from .tracestate import TraceState, TraceSnapShot -from .steparguments import StepArgument, StepArguments +from .suitedata import Suite, Scenario +from .tracestate import TraceState try: from .visualise.visualiser import Visualiser @@ -88,7 +87,7 @@ def flatten(self, in_suite: Suite) -> Suite: out_suite.suites = [] return out_suite - def process_test_suite(self, in_suite: Suite, *, seed: any = 'new', graph: str = '', + def process_test_suite(self, in_suite: Suite, *, seed: Any = 'new', graph: str = '', to_json: bool = False, from_json: str = 'false') -> Suite: self.out_suite = Suite(in_suite.name) self.out_suite.filename = in_suite.filename @@ -109,10 +108,9 @@ def process_test_suite(self, in_suite: Suite, *, seed: any = 'new', graph: str = def _load_graph(self, graph:str, suite_name: str, from_json: str): traceinfo = TraceInfo() traceinfo = traceinfo.import_graph(from_json) - self.visualiser = Visualiser( - graph, suite_name, trace_info=traceinfo) + self.visualiser = Visualiser(graph, suite_name, trace_info=traceinfo) - def _run_test_suite(self, seed: any, graph: str, suite_name: str, to_json: bool): + def _run_test_suite(self, seed: Any, graph: str, suite_name: str, to_json: bool): for id, scenario in enumerate(self.flat_suite.scenarios, start=1): scenario.src_id = id self.scenarios = self.flat_suite.scenarios[:] @@ -120,88 +118,105 @@ def _run_test_suite(self, seed: any, graph: str, suite_name: str, to_json: bool) "\n\t".join([f"{s.src_id}: {s.name}" for s in self.scenarios])) init_seed = self._init_randomiser(seed) - random.shuffle(self.scenarios) + self.shuffled = [s.src_id for s in self.scenarios] + random.shuffle(self.shuffled) # Keep a single shuffle for all TraceStates (non-essential) self.visualiser = None if graph != '' and VISUALISE: - self.visualiser = Visualiser(graph, suite_name, init_seed, to_json) # Pass suite name + try: + self.visualiser = Visualiser(graph, suite_name, init_seed, to_json) + except Exception as e: + self.visualiser = None + logger.warn(f'Could not initialise visualiser due to error!\n{e}') + elif graph != '' and not VISUALISE: logger.warn(f'Visualisation {graph} requested, but required dependencies are not installed.' - 'Install them with `pip install .[visualization]`.') + 'Refer to the README on how to install these dependencies.') # a short trace without the need for repeating scenarios is preferred - self._try_to_reach_full_coverage(allow_duplicate_scenarios=False) + tracestate = self._try_to_reach_full_coverage(allow_duplicate_scenarios=False) - if not self.tracestate.coverage_reached(): + if not tracestate.coverage_reached(): logger.debug("Direct trace not available. Allowing repetition of scenarios") - self._try_to_reach_full_coverage(allow_duplicate_scenarios=True) - if not self.tracestate.coverage_reached(): - self.__write_visualisation() + tracestate = self._try_to_reach_full_coverage(allow_duplicate_scenarios=True) + if not tracestate.coverage_reached(): raise Exception("Unable to compose a consistent suite") - self.out_suite.scenarios = self.tracestate.get_trace() - self._report_tracestate_wrapup() - - def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool): - self.tracestate = TraceState(len(self.scenarios)) - self.active_model = ModelSpace() - while not self.tracestate.coverage_reached(): - i_candidate = self.tracestate.next_candidate( - retry=allow_duplicate_scenarios) - if i_candidate is None: - if not self.tracestate.can_rewind(): - break - tail = self._rewind() - logger.debug("Having to roll back up to " - f"{tail.scenario.name if tail else 'the beginning'}") - self._report_tracestate_to_user() + self.out_suite.scenarios = tracestate.get_trace() + def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool) -> TraceState: + tracestate = TraceState(self.shuffled) + while not tracestate.coverage_reached(): + candidate_id = tracestate.next_candidate(retry=allow_duplicate_scenarios) + self.__update_visualisation(tracestate) + if candidate_id is None: # No more candidates remaining for this level + if not tracestate.can_rewind(): + break + tail = modeller.rewind(tracestate) + logger.debug(f"Having to roll back up to {tail.scenario.name if tail else 'the beginning'}") + self._report_tracestate_to_user(tracestate) + self.__update_visualisation(tracestate) else: - self.active_model.new_scenario_scope() - inserted = self._try_to_fit_in_scenario(i_candidate, self._scenario_with_repeat_counter(i_candidate), - retry_flag=allow_duplicate_scenarios) - - self.__update_visualisation() - - if inserted: + candidate = self._select_scenario_variant(candidate_id, tracestate) + if not candidate: # No valid variant available in the current state + tracestate.reject_scenario(candidate_id) + self.__update_visualisation(tracestate) + continue + previous_len = len(tracestate) + modeller.try_to_fit_in_scenario(candidate, tracestate) + self.__update_visualisation(tracestate) + self._report_tracestate_to_user(tracestate) + if len(tracestate) > previous_len: + logger.debug(f"last state:\n{tracestate.model.get_status_text()}") self.DROUGHT_LIMIT = 50 - if self.__last_candidate_changed_nothing(): - logger.debug( - "Repeated scenario did not change the model's state. Stop trying.") - self._rewind() - - elif self.tracestate.coverage_drought > self.DROUGHT_LIMIT: + if self.__last_candidate_changed_nothing(tracestate): + logger.debug("Repeated scenario did not change the model's state. Stop trying.") + modeller.rewind(tracestate) + self.__update_visualisation(tracestate) + elif tracestate.coverage_drought > self.DROUGHT_LIMIT: logger.debug(f"Went too long without new coverage (>{self.DROUGHT_LIMIT}x). " "Roll back to last coverage increase and try something else.") - self._rewind(drought_recovery=True) - self._report_tracestate_to_user() - logger.debug( - f"last state:\n{self.active_model.get_status_text()}") - self.__update_visualisation() + modeller.rewind(tracestate, drought_recovery=True) + self.__update_visualisation(tracestate) + self._report_tracestate_to_user(tracestate) + logger.debug(f"last state:\n{tracestate.model.get_status_text()}") + return tracestate + - def __update_visualisation(self): + def __update_visualisation(self, tracestate: TraceState): if self.visualiser is not None: - self.visualiser.update_trace(self.tracestate, self.active_model) + try: + self.visualiser.update_trace(tracestate) + except Exception as e: + logger.warn(f'Could not update visualisation due to error!\n{e}') def __write_visualisation(self): if self.visualiser is not None: - logger.info(self.visualiser.generate_visualisation(), html=True) + try: + logger.info(self.visualiser.generate_visualisation(), html=True) + except Exception as e: + logger.warn(f'Could not generate visualisation due to error!\n{e}') - def __last_candidate_changed_nothing(self) -> bool: - if len(self.tracestate) < 2: + @staticmethod + def __last_candidate_changed_nothing(tracestate: TraceState) -> bool: + if len(tracestate) < 2: return False - - if self.tracestate[-1].id != self.tracestate[-2].id: + if tracestate[-1].id != tracestate[-2].id: return False + return tracestate[-1].model == tracestate[-2].model - return self.tracestate[-1].model == self.tracestate[-2].model - - def _scenario_with_repeat_counter(self, index: int) -> Scenario: - """Fetches the scenario by index and, if this scenario is already used in the trace, - adds a repetition counter to its name.""" - candidate = self.scenarios[index] - rep_count = self.tracestate.count(index) + def _select_scenario_variant(self, candidate_id: int, tracestate: TraceState) -> Scenario: + candidate = self._scenario_with_repeat_counter(candidate_id, tracestate) + candidate = modeller.generate_scenario_variant(candidate, tracestate.model or ModelSpace()) + return candidate + def _scenario_with_repeat_counter(self, index: int, tracestate: TraceState) -> Scenario: + """ + Fetches the scenario by index and, if this scenario is already + used in the trace, adds a repetition counter to its name. + """ + candidate = next(s for s in self.scenarios if s.src_id == index) + rep_count = tracestate.count(index) if rep_count: candidate = candidate.copy() candidate.name = f"{candidate.name} (rep {rep_count + 1})" @@ -217,346 +232,20 @@ def _fail_on_step_errors(suite: Suite): for s in error_list]) raise Exception(err_msg) - def _try_to_fit_in_scenario(self, index: int, candidate: Scenario, retry_flag: bool) -> bool: - candidate = self._generate_scenario_variant( - candidate, self.active_model) - - if not candidate: - self.active_model.end_scenario_scope() - self.tracestate.reject_scenario(index) - self._report_tracestate_to_user() - return False - - confirmed_candidate, new_model = self._process_scenario( - candidate, self.active_model) - - if confirmed_candidate: - self.active_model = new_model - self.active_model.end_scenario_scope() - self.tracestate.confirm_full_scenario( - index, confirmed_candidate, self.active_model) - logger.debug( - f"Inserted scenario {confirmed_candidate.src_id}, {confirmed_candidate.name}") - self._report_tracestate_to_user() - logger.debug(f"last state:\n{self.active_model.get_status_text()}") - self.__update_visualisation() - return True - - part1, part2 = self._split_candidate_if_refinement_needed( - candidate, self.active_model) - if part2: - exit_conditions = part2.steps[1].model_info['OUT'] - part1.name = f"{part1.name} (part {self.tracestate.highest_part(index) + 1})" - part1, new_model = self._process_scenario(part1, self.active_model) - self.tracestate.push_partial_scenario(index, part1, new_model) - self.active_model = new_model - self._report_tracestate_to_user() - logger.debug(f"last state:\n{self.active_model.get_status_text()}") - - i_refine = self.tracestate.next_candidate(retry=retry_flag) - if i_refine is None: - logger.debug( - "Refinement needed, but there are no scenarios left") - self._rewind() - self._report_tracestate_to_user() - self.__update_visualisation() - return False - - while i_refine is not None: - self.__update_visualisation() - self.active_model.new_scenario_scope() - m_inserted = self._try_to_fit_in_scenario(i_refine, self._scenario_with_repeat_counter(i_refine), - retry_flag) - self.__update_visualisation() - if m_inserted: - insert_valid_here = True - try: - # Check exit condition before finalizing refinement and inserting the tail part - model_scratchpad = self.active_model.copy() - for expr in exit_conditions: - if model_scratchpad.process_expression(expr, part2.steps[1].args) is False: - insert_valid_here = False - break - except Exception: - insert_valid_here = False - - if insert_valid_here: - m_finished = self._try_to_fit_in_scenario( - index, part2, retry_flag) - if m_finished: - self.__update_visualisation() - return True - else: - logger.debug( - f"Scenario did not meet refinement conditions {exit_conditions}") - logger.debug( - f"last state:\n{self.active_model.get_status_text()}") - logger.debug( - f"Reconsidering {self.scenarios[i_refine].name}, scenario excluded") - self._rewind() - self._report_tracestate_to_user() - - self.__update_visualisation() - i_refine = self.tracestate.next_candidate(retry=retry_flag) - - self.__update_visualisation() - - self._rewind() - self._report_tracestate_to_user() - self.__update_visualisation() - return False - - self.active_model.end_scenario_scope() - self.tracestate.reject_scenario(index) - self._report_tracestate_to_user() - self.__update_visualisation() - return False - - def _rewind(self, drought_recovery: bool = False) -> TraceSnapShot | None: - tail = self.tracestate.rewind() - - while drought_recovery and self.tracestate.coverage_drought: - tail = self.tracestate.rewind() - - self.active_model = self.tracestate.model or ModelSpace() - return tail - - @staticmethod - def _split_candidate_if_refinement_needed(scenario: Scenario, model: ModelSpace) \ - -> tuple[Scenario, Scenario | None]: - m = model.copy() - scenario = scenario.copy() - no_split = (scenario, None) - for step in scenario.steps: - if 'error' in step.model_info: - return no_split - - if step.gherkin_kw in ['given', 'when', None]: - for expr in step.model_info.get('IN', []): - try: - if m.process_expression(expr, step.args) is False: - return no_split - except Exception: - return no_split - - if step.gherkin_kw in ['when', 'then', None]: - for expr in step.model_info.get('OUT', []): - refine_here = False - try: - if m.process_expression(expr, step.args) is False: - if step.gherkin_kw in ['when', None]: - logger.debug( - f"Refinement needed for scenario: {scenario.name}\nat step: {step}") - refine_here = True - else: - return no_split - - except Exception: - return no_split - - if refine_here: - front, back = scenario.split_at_step( - scenario.steps.index(step)) - remaining_steps = '\n\t'.join( - [step.full_keyword, '- ' * 35] + [s.full_keyword for s in back.steps[1:]]) - remaining_steps = SuiteProcessors.escape_robot_vars( - remaining_steps) - edge_step = Step( - 'Log', f"Refinement follows for step:\n\t{remaining_steps}", parent=scenario) - edge_step.gherkin_kw = step.gherkin_kw - edge_step.model_info = dict( - IN=step.model_info['IN'], OUT=[]) - edge_step.detached = True - edge_step.args = StepArguments(step.args) - front.steps.append(edge_step) - back.steps.insert( - 0, Step('Log', f"Refinement ready, completing step", parent=scenario)) - back.steps[1] = back.steps[1].copy() - back.steps[1].model_info['IN'] = [] - return (front, back) - - return no_split - - @staticmethod - def escape_robot_vars(text: str) -> str: - for seq in ("${", "@{", "%{", "&{", "*{"): - text = text.replace(seq, "\\" + seq) - - return text - @staticmethod - def _process_scenario(scenario: Scenario, model: ModelSpace) -> tuple[Scenario, ModelSpace] | tuple[None, None]: - m = model.copy() - scenario = scenario.copy() - for step in scenario.steps: - if 'error' in step.model_info: - logger.debug( - f"Error in scenario {scenario.name} at step {step}: {step.model_info['error']}") - return None, None - - for expr in SuiteProcessors._relevant_expressions(step): - try: - if m.process_expression(expr, step.args) is False: - raise Exception(False) - except Exception as err: - logger.debug(f"Unable to insert scenario {scenario.src_id}, {scenario.name}, " - f"due to step '{step}': [{expr}] {err}") - return None, None - - return scenario, m + def _report_tracestate_to_user(tracestate: TraceState): + user_trace = f"[{', '.join(tracestate.id_trace)}]" + logger.debug(f"Trace: {user_trace} Reject: {list(tracestate.tried)}") @staticmethod - def _relevant_expressions(step: Step) -> list[str | list[str]]: - if step.gherkin_kw is None and not step.model_info: - return [] # model info is optional for action keywords - - expressions = [] - if 'IN' not in step.model_info or 'OUT' not in step.model_info: - raise Exception(f"Model info incomplete for step: {step}") - - if step.gherkin_kw in ['given', 'when', None]: - expressions += step.model_info['IN'] - - if step.gherkin_kw in ['when', 'then', None]: - expressions += step.model_info['OUT'] - - return expressions - - def _generate_scenario_variant(self, scenario: Scenario, model: ModelSpace) -> Scenario | None: - m = model.copy() - scenario = scenario.copy() - scenarios_in_refinement = self.tracestate.find_scenarios_with_active_refinement() - - # reuse previous solution for all parts in split-up scenario - for sir in scenarios_in_refinement: - if sir.src_id == scenario.src_id: - return scenario - - # collect set of constraints - subs = SubstitutionMap() - try: - for step in scenario.steps: - if 'MOD' in step.model_info: - for expr in step.model_info['MOD']: - modded_arg, constraint = self._parse_modifier_expression( - expr, step.args) - if step.args[modded_arg].is_default: - continue - if step.args[modded_arg].kind in [StepArgument.EMBEDDED, StepArgument.POSITIONAL, - StepArgument.NAMED]: - org_example = step.args[modded_arg].org_value - if step.gherkin_kw == 'then': - constraint = None # No new constraints are processed for then-steps - if org_example not in subs.substitutions: - # if a then-step signals the first use of an example value, it is considered a new definition - subs.substitute(org_example, [org_example]) - continue - if not constraint and org_example not in subs.substitutions: - raise ValueError( - f"No options to choose from at first assignment to {org_example}") - if constraint and constraint != '.*': - options = m.process_expression( - constraint, step.args) - if options == 'exec': - raise ValueError( - f"Invalid constraint for argument substitution: {expr}") - if not options: - raise ValueError( - f"Constraint on modifer did not yield any options: {expr}") - if not is_list_like(options): - raise ValueError( - f"Constraint on modifer did not yield a set of options: {expr}") - else: - options = None - subs.substitute(org_example, options) - elif step.args[modded_arg].kind == StepArgument.VAR_POS: - if step.args[modded_arg].value: - modded_varargs = m.process_expression( - constraint, step.args) - if not is_list_like(modded_varargs): - raise ValueError( - f"Modifying varargs must yield a list of arguments") - # Varargs are not added to the substitution map, but are used directly as-is. A modifier can - # change the number of arguments in the list, making it impossible to decide which values to - # match and which to drop and/or duplicate. - step.args[modded_arg].value = modded_varargs - elif step.args[modded_arg].kind == StepArgument.FREE_NAMED: - if step.args[modded_arg].value: - modded_free_args = m.process_expression( - constraint, step.args) - if not isinstance(modded_free_args, dict): - raise ValueError( - "Modifying free named arguments must yield a dict") - # Similar to varargs, modified free named arguments are used directly as-is. - step.args[modded_arg].value = modded_free_args - else: - raise AssertionError( - f"Unknown argument kind for {modded_arg}") - except Exception as err: - logger.debug(f"Unable to insert scenario {scenario.src_id}, {scenario.name}, due to modifier\n" - f" In step {step}: {err}") - return None - - try: - subs.solve() - except ValueError as err: - logger.debug( - f"Unable to insert scenario {scenario.src_id}, {scenario.name}, due to modifier\n {err}: {subs}") - return None - - # Update scenario with generated values - if subs.solution: - logger.debug( - f"Example variant generated with argument substitution: {subs}") - scenario.data_choices = subs - - for step in scenario.steps: - if 'MOD' in step.model_info: - for expr in step.model_info['MOD']: - modded_arg, _ = self._parse_modifier_expression( - expr, step.args) - if step.args[modded_arg].is_default: - continue - org_example = step.args[modded_arg].org_value - if step.args[modded_arg].kind in [StepArgument.EMBEDDED, StepArgument.POSITIONAL, - StepArgument.NAMED]: - step.args[modded_arg].value = subs.solution[org_example] - return scenario - - @staticmethod - def _parse_modifier_expression(expression: str, args: StepArguments) -> tuple[str, str]: - if expression.startswith('${'): - for var in args: - if expression.casefold().startswith(var.arg.casefold()): - assignment_expr = expression.replace( - var.arg, '', 1).strip() - - if not assignment_expr.startswith('=') or assignment_expr.startswith('=='): - break # not an assignment - - constraint = assignment_expr.replace('=', '', 1).strip() - return var.arg, constraint - - raise ValueError(f"Invalid argument substitution: {expression}") - - def _report_tracestate_to_user(self): - user_trace = "[" - for snapshot in self.tracestate: - part = f".{snapshot.id.split('.')[1]}" if '.' in snapshot.id else "" - user_trace += f"{snapshot.scenario.src_id}{part}, " - - user_trace = user_trace[:-2] + "]" if ',' in user_trace else "[]" - reject_trace = [self.scenarios[i].src_id for i in self.tracestate.tried] - logger.debug(f"Trace: {user_trace} Reject: {reject_trace}") - - def _report_tracestate_wrapup(self): + def _report_tracestate_wrapup(tracestate: TraceState): logger.info("Trace composed:") - for step in self.tracestate: - logger.info(step.scenario.name) - logger.debug(f"model\n{step.model.get_status_text()}\n") + for progression in tracestate: + logger.info(progression.scenario.name) + logger.debug(f"model\n{progression.model.get_status_text()}\n") @staticmethod - def _init_randomiser(seed: any) -> str: + def _init_randomiser(seed: Any) -> str: if isinstance(seed, str): seed = seed.strip() @@ -578,17 +267,15 @@ def _init_randomiser(seed: any) -> str: def _generate_seed() -> str: """Creates a random string of 5 words between 3 and 6 letters long""" vowels = ['a', 'e', 'i', 'o', 'u', 'y'] - consonants = ['b', 'c', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'm', 'n', 'p', 'q', 'r', 's', 't', 'v', 'w', 'x', - 'z'] + consonants = ['b', 'c', 'd', 'f', 'g', 'h', 'j', 'k', 'l', + 'm', 'n', 'p', 'q', 'r', 's', 't', 'v', 'w', 'x', 'z'] words = [] for word in range(5): prior_choice = random.choice([vowels, consonants]) last_choice = random.choice([vowels, consonants]) - - # add first two letters - string = random.choice(prior_choice) + random.choice(last_choice) - for letter in range(random.randint(1, 4)): # add 1 to 4 more letters + string = random.choice(prior_choice) + random.choice(last_choice) # add first two letters + for letter in range(random.randint(1, 4)): # add 1 to 4 more letters if prior_choice is last_choice: new_choice = consonants if prior_choice is vowels else vowels else: diff --git a/robotmbt/suitereplacer.py b/robotmbt/suitereplacer.py index 1ed38507..d44b83d2 100644 --- a/robotmbt/suitereplacer.py +++ b/robotmbt/suitereplacer.py @@ -1,9 +1,4 @@ # -*- coding: utf-8 -*- -from .suitedata import Suite, Scenario, Step -from .suiteprocessors import SuiteProcessors -import robot.running.model as rmodel -from robot.api import logger -from robot.api.deco import keyword # BSD 3-Clause License # @@ -35,6 +30,15 @@ # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +from typing import Any + +import robot.model + +from .suitedata import Suite, Scenario, Step +from .suiteprocessors import SuiteProcessors +import robot.running.model as rmodel +from robot.api import logger +from robot.api.deco import keyword from robot.libraries.BuiltIn import BuiltIn Robot = BuiltIn() @@ -46,13 +50,13 @@ class SuiteReplacer: def __init__(self, processor: str = 'process_test_suite', processor_lib: str | None = None): self.ROBOT_LIBRARY_LISTENER = self # : Self - self.current_suite: Suite | None = None - self.robot_suite: Suite | None = None + self.current_suite: robot.model.TestSuite | None = None + self.robot_suite: robot.model.TestSuite | None = None self.processor_lib_name: str | None = processor_lib self.processor_name: str = processor self._processor_lib: SuiteProcessors | None = None - self._processor_method = None - self.processor_options = {} + self._processor_method: Any = None + self.processor_options: dict[str, Any] = {} @property def processor_lib(self) -> SuiteProcessors: @@ -67,10 +71,7 @@ def processor_method(self): if not hasattr(self.processor_lib, self.processor_name): Robot.fail( f"Processor '{self.processor_name}' not available for model-based processor library {self.processor_lib_name}") - - self._processor_method = getattr( - self._processor_lib, self.processor_name) - + self._processor_method = getattr(self._processor_lib, self.processor_name) return self._processor_method @keyword(name="Treat this test suite Model-based") @@ -83,22 +84,18 @@ def treat_model_based(self, **kwargs): model info that is included in the test steps, the test cases are modifed, mixed and matched to create unique traces and achieve more test coverage quicker. - Any arguments are handled only locally. To apply arguments to subsequent suites as well, - use `Set model-based options` or `Update model-based options`. + Any arguments must be named arguments. They are passed on as options to the selected model-based + processor. If an option was already set on library level (See: `Set model-based options` and + `Update model-based options`, then these arguments take precedence over the library option and + affect only the current test suite. """ self.robot_suite = self.current_suite - logger.info( - f"Analysing Robot test suite '{self.robot_suite.name}' for model-based execution.") - - temp = self.processor_options.copy() - temp.update(kwargs) - master_suite = self.__process_robot_suite( - self.robot_suite, parent=None) - - modelbased_suite = self.processor_method( - master_suite, **temp) - + logger.info(f"Analysing Robot test suite '{self.robot_suite.name}' for model-based execution.") + local_settings = self.processor_options.copy() + local_settings.update(kwargs) + master_suite = self.__process_robot_suite(self.robot_suite, parent=None) + modelbased_suite = self.processor_method(master_suite, **local_settings) self.__clearTestSuite(self.robot_suite) self.__generateRobotSuite(modelbased_suite, self.robot_suite) @@ -118,7 +115,7 @@ def update_model_based_options(self, **kwargs): """ self.processor_options.update(kwargs) - def __process_robot_suite(self, in_suite: Suite, parent: Suite | None) -> Suite: + def __process_robot_suite(self, in_suite: robot.model.TestSuite, parent: Suite | None) -> Suite: out_suite = Suite(in_suite.name, parent) out_suite.filename = in_suite.source @@ -180,11 +177,11 @@ def __process_robot_suite(self, in_suite: Suite, parent: Suite | None) -> Suite: return out_suite - def __clearTestSuite(self, suite: Suite): + def __clearTestSuite(self, suite: robot.model.TestSuite): suite.tests.clear() suite.suites.clear() - def __generateRobotSuite(self, suite_model: Suite, target_suite): + def __generateRobotSuite(self, suite_model: Suite, target_suite: robot.model.TestSuite): for subsuite in suite_model.suites: new_suite = target_suite.suites.create(name=subsuite.name) new_suite.resource = target_suite.resource diff --git a/robotmbt/tracestate.py b/robotmbt/tracestate.py index 03941059..770b0b43 100644 --- a/robotmbt/tracestate.py +++ b/robotmbt/tracestate.py @@ -1,7 +1,4 @@ # -*- coding: utf-8 -*- -from robotmbt.modelspace import ModelSpace -from robotmbt.suitedata import Scenario - # BSD 3-Clause License # @@ -33,149 +30,161 @@ # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +from robotmbt.modelspace import ModelSpace +from robotmbt.suitedata import Scenario + class TraceSnapShot: - def __init__(self, id: str, inserted_scenario: str | Scenario, model_state: ModelSpace, drought: int = 0): + def __init__(self, id: str, inserted_scenario: Scenario | str, model_state: ModelSpace, remainder: Scenario | None = None, drought: int = 0): self.id: str = id self.scenario: str | Scenario = inserted_scenario - self.model: ModelSpace = model_state.copy() + self.remainder: Scenario | None = remainder + self._model: ModelSpace = model_state.copy() self.coverage_drought: int = drought + @property + def model(self): + return self._model.copy() -class TraceState: - def __init__(self, n_scenarios: int): - # coverage pool: True means scenario is in trace - self._c_pool: list[bool] = [False] * n_scenarios +class TraceState: + def __init__(self, scenario_indexes: list[int]): + self.c_pool = {index: 0 for index in scenario_indexes} + if len(self.c_pool) != len(scenario_indexes): + raise ValueError("Scenarios must be uniquely identifiable") + # Keeps track of the scenarios already tried at each step in the trace - self._tried: list[list[int]] = [[]] - - # Choice trace, when was which scenario inserted (e.g. ['1', '2.1', '3', '2.0']) - self._trace: list[str] = [] - + self._tried: list[list[int]] = [[]] + # Keeps details for elements in trace - self._snapshots: list[TraceSnapShot] = [] + self._snapshots: list[TraceSnapShot] = [] self._open_refinements: list[int] = [] @property def model(self) -> ModelSpace | None: """returns the model as it is at the end of the current trace""" - return self._snapshots[-1].model.copy() if self._trace else None + return self._snapshots[-1].model if self._snapshots else None @property def tried(self) -> tuple[int, ...]: """returns the indices that were rejected or previously inserted at the current position""" return tuple(self._tried[-1]) - def coverage_reached(self) -> bool: - return all(self._c_pool) - @property def coverage_drought(self) -> int: """Number of scenarios since last new coverage""" return self._snapshots[-1].coverage_drought if self._snapshots else 0 + @property + def id_trace(self): + return [snap.id for snap in self._snapshots] + + @property + def active_refinements(self): + return self._open_refinements[:] + + def coverage_reached(self): + return all(self.c_pool.values()) + def get_trace(self) -> list[str | Scenario]: return [snap.scenario for snap in self._snapshots] - def next_candidate(self, retry: bool = False) -> int | None: - for i in range(len(self._c_pool)): - if i not in self._tried[-1] and not self._is_refinement_active(i) and self.count(i) == 0: + def next_candidate(self, retry: bool=False): + for i in self.c_pool: + if i not in self._tried[-1] and not self.is_refinement_active(i) and self.count(i) == 0: return i if not retry: return None - - for i in range(len(self._c_pool)): - if i not in self._tried[-1] and not self._is_refinement_active(i): + for i in self.c_pool: + if i not in self._tried[-1] and not self.is_refinement_active(i): return i return None def count(self, index: int) -> int: - """Count the number of times the index is present in the trace. - unfinished partial scenarios are excluded.""" - return self._trace.count(str(index)) + self._trace.count(str(f"{index}.0")) + """ + Count the number of times the index is present in the trace. + unfinished partial scenarios are excluded. + """ + return self.c_pool[index] def highest_part(self, index: int) -> int: - """Given the current trace and an index, returns the highest part number of an ongoing - refinement for the related scenario. Returns 0 when there is no refinement active.""" - for i in range(1, len(self._trace) + 1): - if self._trace[-i] == f'{index}': + """ + Given the current trace and an index, returns the highest part number of an ongoing + refinement for the related scenario. Returns 0 when there is no refinement active. + """ + for i in range(1, len(self.id_trace)+1): + if self.id_trace[-i] == f'{index}': return 0 - - if self._trace[-i].startswith(f'{index}.'): - return int(self._trace[-i].split('.')[1]) - + if self.id_trace[-i].startswith(f'{index}.'): + return int(self.id_trace[-i].split('.')[1]) return 0 - def _is_refinement_active(self, index: int) -> bool: - return self.highest_part(index) != 0 - - def find_scenarios_with_active_refinement(self) -> list[str | Scenario]: - scenarios = [] - for i in self._open_refinements: - index = -self._trace[::-1].index(f'{i}.1') - 1 - scenarios.append(self._snapshots[index].scenario) + def is_refinement_active(self, index: int = None) -> bool: + """ + When called with an index, returns True if that scenario is currently being refined + When index is ommitted, return True if any refinement is active + """ + if index is None: + return self._open_refinements != [] + else: + return self.highest_part(index) != 0 - return scenarios + def get_remainder(self, index: int): + """ + When pushing a partial scenario, the remainder can be passed along for safe keeping. + This method retrieves the remainder for the last part that was pushed. + """ + last_part = self.highest_part(index) + index = -self.id_trace[::-1].index(f'{index}.{last_part}')-1 + return self._snapshots[index].remainder def reject_scenario(self, i_scenario: int): """Trying a scenario excludes it from further cadidacy on this level""" self._tried[-1].append(i_scenario) - def confirm_full_scenario(self, index: int, scenario: str, model: ModelSpace): - if not self._c_pool[index]: - self._c_pool[index] = True - c_drought = 0 - else: - c_drought = self.coverage_drought + 1 - - if self._is_refinement_active(index): + def confirm_full_scenario(self, index: int, scenario: Scenario | str, model: ModelSpace): + c_drought = 0 if self.c_pool[index] == 0 else self.coverage_drought+1 + self.c_pool[index] += 1 + if self.is_refinement_active(index): id = f"{index}.0" self._open_refinements.pop() else: id = str(index) self._tried[-1].append(index) self._tried.append([]) + self._snapshots.append(TraceSnapShot(id, scenario, model, drought=c_drought)) - self._trace.append(id) - self._snapshots.append(TraceSnapShot(id, scenario, model, c_drought)) - - def push_partial_scenario(self, index: int, scenario: str, model: ModelSpace): - if self._is_refinement_active(index): + def push_partial_scenario(self, index: int, scenario: Scenario | str, model: ModelSpace, remainder=None): + if self.is_refinement_active(index): id = f"{index}.{self.highest_part(index) + 1}" else: id = f"{index}.1" self._tried[-1].append(index) - self._tried.append([]) self._open_refinements.append(index) - self._trace.append(id) - self._snapshots.append(TraceSnapShot( - id, scenario, model, self.coverage_drought)) + self._tried.append([]) + self._snapshots.append(TraceSnapShot(id, scenario, model, remainder, self.coverage_drought)) def can_rewind(self) -> bool: - return len(self._trace) > 0 + return len(self._snapshots) > 0 def rewind(self) -> TraceSnapShot | None: - id = self._trace.pop() + id = self._snapshots[-1].id index = int(id.split('.')[0]) + self._snapshots.pop() if id.endswith('.0'): - self._snapshots.pop() + self.c_pool[index] -= 1 self._open_refinements.append(index) - while self._trace[-1] != f"{index}.1": + while self._snapshots[-1].id != f"{index}.1": self.rewind() return self.rewind() - self._snapshots.pop() - if '.' not in id or id.endswith('.1'): - if self.count(index) == 0: - self._c_pool[index] = False - self._tried.pop() - - if id.endswith('.1'): - self._open_refinements.pop() - + self._tried.pop() + if '.' not in id: + self.c_pool[index] -= 1 + if id.endswith('.1'): + self._open_refinements.pop() return self._snapshots[-1] if self._snapshots else None def __iter__(self): diff --git a/robotmbt/visualise/graphs/reducedSDVgraph.py b/robotmbt/visualise/graphs/reducedSDVgraph.py index b7147bef..f1662d9c 100644 --- a/robotmbt/visualise/graphs/reducedSDVgraph.py +++ b/robotmbt/visualise/graphs/reducedSDVgraph.py @@ -30,14 +30,14 @@ def _generate_equiv_class_label(equiv_class, old_labels): if len(equiv_class) == 1: return old_labels[set(equiv_class).pop()] else: - return "(merged: "+str(len(equiv_class))+")\n"+old_labels[set(equiv_class).pop()] + return "(merged: " + str(len(equiv_class)) + ")\n" + old_labels[set(equiv_class).pop()] def __init__(self, info: TraceInfo): super().__init__(info) old_labels = networkx.get_node_attributes(self.networkx, "label") self.networkx = networkx.quotient_graph(self.networkx, lambda x, y: self.chain_equiv(x, y), node_data=lambda equiv_class: { - 'label': self._generate_equiv_class_label(equiv_class, old_labels)}, + 'label': self._generate_equiv_class_label(equiv_class, old_labels)}, edge_data=lambda x, y: {'label': ''}) # TODO make generated label more obvious to be equivalence class nodes = self.networkx.nodes diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index c533dc76..3cb16790 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -1,4 +1,3 @@ -import logging from typing import Any from robot.api import logger diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py index faa9a2b9..af1b9cdb 100644 --- a/robotmbt/visualise/visualiser.py +++ b/robotmbt/visualise/visualiser.py @@ -25,7 +25,8 @@ def construct(cls, graph_type: str): # just calls __init__, but without having underscores etc. return cls(graph_type) - def __init__(self, graph_type: str, suite_name: str = "", seed: str = "", export: bool = False, trace_info: TraceInfo = None): + def __init__(self, graph_type: str, suite_name: str = "", seed: str = "", export: bool = False, + trace_info: TraceInfo = None): if graph_type != 'scenario' and graph_type != 'state' and graph_type != 'scenario-state' \ and graph_type != 'scenario-delta-value' and graph_type != 'reduced-sdv' \ and graph_type != 'delta-value': @@ -40,12 +41,35 @@ def __init__(self, graph_type: str, suite_name: str = "", seed: str = "", export self.export = export self.seed = seed - def update_trace(self, trace: TraceState, state: ModelSpace): - if len(trace.get_trace()) > 0: - self.trace_info.update_trace(ScenarioInfo( - trace.get_trace()[-1]), StateInfo(state), len(trace.get_trace())) + def update_trace(self, trace: TraceState): + """ + Uses the new snapshots from trace to update the trace info. + Multiple new snapshots can be pushed or popped at once. + """ + trace_len = len(trace._snapshots) + # We don't have any information + if trace_len == 0: + self.trace_info.update_trace(None, StateInfo(ModelSpace()), 0) + + # New snapshots have been pushed + elif trace_len > self.trace_info.previous_length: + prev = self.trace_info.previous_length + r = trace_len - prev + # Extract all snapshots that have been pushed and update our trace info with their scenario/model info + for i in range(r): + snap = trace._snapshots[prev + i] + scenario = snap.scenario + model = snap.model + if model is None: + model = ModelSpace + self.trace_info.update_trace(ScenarioInfo(scenario), StateInfo(model), prev + i + 1) + + # Snapshots have been removed else: - self.trace_info.update_trace(None, StateInfo(state), 0) + snap = trace._snapshots[-1] + scenario = snap.scenario + model = snap.model + self.trace_info.update_trace(ScenarioInfo(scenario), StateInfo(model), trace_len) def generate_visualisation(self) -> str: if self.export: @@ -66,4 +90,4 @@ def generate_visualisation(self) -> str: html_bokeh = networkvisualiser.NetworkVisualiser(graph, self.suite_name, self.seed).generate_html() - return f'' \ No newline at end of file + return f'' diff --git a/utest/test_steparguments.py b/utest/test_steparguments.py index 56dcdbf6..6c906ca6 100644 --- a/utest/test_steparguments.py +++ b/utest/test_steparguments.py @@ -160,10 +160,10 @@ def test_spaces_and_underscores_are_interchangable(self): self.assertEqual(arg1.codestring, arg2.codestring) def test_other_values_become_unique_identifiers(self): - valuelist = ['bar', 'foo bar', 'foo2bar', '${bar}', # strings - ' ', '\t', '\n', ' ', ' \n', '\a', # whitespace/non-printable - '#', '+-', '-+', '"', "'", 'パイ', # special characters - max, 'elif', 'import', 'new', 'del', # reserved words + valuelist = ['bar', 'foo bar', 'foo2bar', '${bar}', # strings + ' ', '\t', '\n', ' ', ' \n', '\a', # whitespace/non-printable + '#', '+-', '-+', '"', "'", 'パイ', # special characters + max, 'elif', 'import', 'new', 'del', # reserved words lambda x: x/2, self, unittest.TestCase] # functions and objects argsset = set() for v in valuelist: diff --git a/utest/test_substitutionmap.py b/utest/test_substitutionmap.py index 8c93171d..eb6df944 100644 --- a/utest/test_substitutionmap.py +++ b/utest/test_substitutionmap.py @@ -355,7 +355,8 @@ def test_adding_constraint_does_not_affect_undo_remove_stack(self): c.remove_option('four') c.add_constraint(['one', 'two']) self.assertCountEqual(c.optionset, ['two']) - c.undo_remove() # four was never in there, so isn't added, and three + # four was never in there, so isn't added, and three + c.undo_remove() # was removed by adding a constraint and is ignored. self.assertCountEqual(c.optionset, ['two']) c.undo_remove() diff --git a/utest/test_suitedata.py b/utest/test_suitedata.py index ef6e3866..ab9bb152 100644 --- a/utest/test_suitedata.py +++ b/utest/test_suitedata.py @@ -477,6 +477,19 @@ def map(x, y, z): return ([], []) def __iter__(_): return iter([]) +class StubStepArguments(list): + modified = True # trigger modified status to get arguments processed, rather then just echoed + + +class StubArgument(SimpleNamespace): + EMBEDDED = 'EMBEDDED' + POSITIONAL = 'POSITIONAL' + VAR_POS = 'VAR_POS' + NAMED = 'NAMED' + FREE_NAMED = 'FREE_NAMED' + + + class StubStepArguments(list): modified = True # trigger modified status to get arguments processed, rather then just echoed diff --git a/utest/test_tracestate.py b/utest/test_tracestate.py index 5bfb63a3..77c1462a 100644 --- a/utest/test_tracestate.py +++ b/utest/test_tracestate.py @@ -36,69 +36,72 @@ class TestTraceState(unittest.TestCase): def test_an_empty_tracestate_doesnt_do_so_much(self): - ts = TraceState(0) + ts = TraceState([]) self.assertIs(ts.next_candidate(), None) self.assertIs(ts.coverage_reached(), True) self.assertEqual(ts.get_trace(), []) self.assertIs(ts.can_rewind(), False) def test_completing_single_size_trace(self): - ts = TraceState(1) - self.assertEqual(ts.next_candidate(), 0) + ts = TraceState([1]) + self.assertEqual(ts.next_candidate(), 1) self.assertIs(ts.coverage_reached(), False) - ts.confirm_full_scenario(0, 'one', {}) + ts.confirm_full_scenario(1, 'one', {}) self.assertIs(ts.coverage_reached(), True) self.assertEqual(ts.get_trace(), ['one']) def test_confirming_excludes_scenario_from_candidacy(self): - ts = TraceState(1) - self.assertEqual(ts.next_candidate(), 0) - ts.confirm_full_scenario(0, 'one', {}) + ts = TraceState([1]) + self.assertEqual(ts.next_candidate(), 1) + ts.confirm_full_scenario(1, 'one', {}) self.assertIs(ts.next_candidate(), None) def test_trying_excludes_scenario_from_candidacy(self): - ts = TraceState(1) - self.assertEqual(ts.next_candidate(), 0) - ts.reject_scenario(0) + ts = TraceState([1]) + self.assertEqual(ts.next_candidate(), 1) + ts.reject_scenario(1) self.assertIs(ts.next_candidate(), None) def test_scenario_still_excluded_from_candidacy_after_rewind(self): - ts = TraceState(1) - self.assertEqual(ts.next_candidate(), 0) - ts.confirm_full_scenario(0, 'one', {}) + ts = TraceState([1]) + self.assertEqual(ts.next_candidate(), 1) + ts.confirm_full_scenario(1, 'one', {}) ts.rewind() self.assertIs(ts.next_candidate(), None) def test_candidates_come_in_order_when_accepted(self): - ts = TraceState(3) + ts = TraceState([10, 20, 30]) candidates = [] - for scenario in range(3): + for _ in range(3): candidates.append(ts.next_candidate()) - ts.confirm_full_scenario(candidates[-1], scenario, {}) + ts.confirm_full_scenario(candidates[-1], 'scenario', {}) candidates.append(ts.next_candidate()) - self.assertEqual(candidates, [0, 1, 2, None]) + self.assertEqual(candidates, [10, 20, 30, None]) + + def test_scenarios_must_be_uniquely_identifiable(self): + self.assertRaises(ValueError, TraceState, [1, 2, 3, 2]) def test_candidates_come_in_order_when_rejected(self): - ts = TraceState(3) + ts = TraceState([10, 20, 30]) candidates = [] for _ in range(3): candidates.append(ts.next_candidate()) ts.reject_scenario(candidates[-1]) candidates.append(ts.next_candidate()) - self.assertEqual(candidates, [0, 1, 2, None]) + self.assertEqual(candidates, [10, 20, 30, None]) def test_rejected_scenarios_are_candidates_for_new_positions(self): - ts = TraceState(3) + ts = TraceState([1, 2, 3]) candidates = [] - ts.reject_scenario(0) - for scenario in range(3): + ts.reject_scenario(1) + for _ in range(3): candidates.append(ts.next_candidate()) - ts.confirm_full_scenario(candidates[-1], scenario, {}) + ts.confirm_full_scenario(candidates[-1], 'scenario', {}) candidates.append(ts.next_candidate()) - self.assertEqual(candidates, [1, 0, 2, None]) + self.assertEqual(candidates, [2, 1, 3, None]) def test_previously_confirmed_scenarios_can_be_retried_if_no_new_candidates_exist(self): - ts = TraceState(3) + ts = TraceState(range(3)) first_candidate = ts.next_candidate(retry=True) ts.confirm_full_scenario(first_candidate, 'one', {}) ts.reject_scenario(ts.next_candidate(retry=True)) @@ -113,18 +116,18 @@ def test_previously_confirmed_scenarios_can_be_retried_if_no_new_candidates_exis self.assertEqual(ts.get_trace(), ['one', 'one', 'two', 'three']) def test_retry_can_continue_once_coverage_is_reached(self): - ts = TraceState(3) + ts = TraceState([1, 2, 3]) ts.confirm_full_scenario(ts.next_candidate(retry=True), 'one', {}) ts.confirm_full_scenario(ts.next_candidate(retry=True), 'two', {}) ts.confirm_full_scenario(ts.next_candidate(retry=True), 'three', {}) self.assertTrue(ts.coverage_reached()) - self.assertEqual(ts.next_candidate(retry=True), 0) - ts.reject_scenario(0) self.assertEqual(ts.next_candidate(retry=True), 1) + ts.reject_scenario(1) + self.assertEqual(ts.next_candidate(retry=True), 2) self.assertEqual(ts.next_candidate(retry=False), None) def test_count_scenario_repetitions(self): - ts = TraceState(2) + ts = TraceState([1, 2]) first = ts.next_candidate() self.assertEqual(ts.count(first), 0) ts.confirm_full_scenario(first, 'one', {}) @@ -133,8 +136,8 @@ def test_count_scenario_repetitions(self): self.assertEqual(ts.count(first), 2) def test_rewind_single_available_scenario(self): - ts = TraceState(1) - ts.confirm_full_scenario(0, 'one', {}) + ts = TraceState([1]) + ts.confirm_full_scenario(1, 'one', {}) self.assertIs(ts.coverage_reached(), True) self.assertIs(ts.can_rewind(), True) ts.rewind() @@ -144,38 +147,38 @@ def test_rewind_single_available_scenario(self): self.assertEqual(ts.get_trace(), []) def test_rewind_returns_none_after_rewinding_last_step(self): - ts = TraceState(1) - ts.confirm_full_scenario(0, 'one', {}) + ts = TraceState([1]) + ts.confirm_full_scenario(1, 'one', {}) self.assertIs(ts.rewind(), None) - def test_traces_can_have_multiple_sceanrios(self): - ts = TraceState(2) - ts.confirm_full_scenario(0, 'foo', dict(a=1)) + def test_traces_can_have_multiple_scenarios(self): + ts = TraceState([1, 2]) + ts.confirm_full_scenario(1, 'foo', dict(a=1)) self.assertIs(ts.coverage_reached(), False) - ts.confirm_full_scenario(1, 'bar', dict(b=2)) + ts.confirm_full_scenario(2, 'bar', dict(b=2)) self.assertIs(ts.coverage_reached(), True) self.assertEqual(ts.get_trace(), ['foo', 'bar']) def test_rewind_returns_snapshot_of_the_step_before(self): - ts = TraceState(2) - ts.confirm_full_scenario(0, 'foo', dict(a=1)) - ts.confirm_full_scenario(1, 'bar', dict(b=2)) + ts = TraceState([1, 2]) + ts.confirm_full_scenario(1, 'foo', dict(a=1)) + ts.confirm_full_scenario(2, 'bar', dict(b=2)) tail = ts.rewind() - self.assertEqual(tail.id, '0') + self.assertEqual(tail.id, '1') self.assertEqual(tail.scenario, 'foo') self.assertEqual(tail.model, dict(a=1)) def test_completing_size_three_trace(self): - ts = TraceState(3) - ts.confirm_full_scenario(ts.next_candidate(), 1, {}) - ts.confirm_full_scenario(ts.next_candidate(), 2, {}) + ts = TraceState(range(3)) + ts.confirm_full_scenario(ts.next_candidate(), 'one', {}) + ts.confirm_full_scenario(ts.next_candidate(), 'two', {}) self.assertIs(ts.coverage_reached(), False) - ts.confirm_full_scenario(ts.next_candidate(), 3, {}) + ts.confirm_full_scenario(ts.next_candidate(), 'three', {}) self.assertIs(ts.coverage_reached(), True) - self.assertEqual(ts.get_trace(), [1, 2, 3]) + self.assertEqual(ts.get_trace(), ['one', 'two', 'three']) def test_completing_size_three_trace_after_reject(self): - ts = TraceState(3) + ts = TraceState(range(3)) first = ts.next_candidate() ts.confirm_full_scenario(first, first, {}) rejected = ts.next_candidate() @@ -190,7 +193,7 @@ def test_completing_size_three_trace_after_reject(self): self.assertEqual(ts.get_trace(), [first, third, second]) def test_completing_size_three_trace_after_rewind(self): - ts = TraceState(3) + ts = TraceState(range(3)) first = ts.next_candidate() ts.confirm_full_scenario(first, first, {}) reject2 = ts.next_candidate() @@ -211,16 +214,16 @@ def test_completing_size_three_trace_after_rewind(self): self.assertEqual(ts.get_trace(), [retry_first, retry_second, retry_third]) def test_highest_part_when_index_not_present(self): - ts = TraceState(1) - self.assertEqual(ts.highest_part(0), 0) + ts = TraceState([1]) + self.assertEqual(ts.highest_part(1), 0) def test_highest_part_for_non_partial_sceanrio(self): - ts = TraceState(1) - ts.confirm_full_scenario(0, 'one', {}) - self.assertEqual(ts.highest_part(0), 0) + ts = TraceState([1]) + ts.confirm_full_scenario(1, 'one', {}) + self.assertEqual(ts.highest_part(1), 0) def test_model_property_takes_model_from_tail(self): - ts = TraceState(2) + ts = TraceState(range(2)) ts.confirm_full_scenario(ts.next_candidate(), 'one', dict(a=1)) ts.confirm_full_scenario(ts.next_candidate(), 'two', dict(b=2)) self.assertEqual(ts.model, dict(b=2)) @@ -228,35 +231,35 @@ def test_model_property_takes_model_from_tail(self): self.assertEqual(ts.model, dict(a=1)) def test_no_model_from_empty_trace(self): - ts = TraceState(1) + ts = TraceState([1]) self.assertIs(ts.model, None) - ts.confirm_full_scenario(0, 'one', {}) + ts.confirm_full_scenario(1, 'one', {}) self.assertIsNotNone(ts.model) ts.rewind() self.assertIs(ts.model, None) def test_tried_property_starts_empty(self): - ts = TraceState(1) + ts = TraceState([1]) self.assertEqual(ts.tried, ()) def test_rejected_scenarios_are_tried(self): - ts = TraceState(1) - ts.reject_scenario(0) - self.assertEqual(ts.tried, (0,)) + ts = TraceState([1]) + ts.reject_scenario(1) + self.assertEqual(ts.tried, (1,)) def test_confirmed_scenario_is_tried_and_triggers_next_step(self): - ts = TraceState(1) - ts.confirm_full_scenario(0, 'one', {}) + ts = TraceState([1]) + ts.confirm_full_scenario(1, 'one', {}) self.assertEqual(ts.tried, ()) ts.rewind() - self.assertEqual(ts.tried, (0,)) + self.assertEqual(ts.tried, (1,)) def test_can_iterate_over_tracestate_snapshots(self): - ts = TraceState(3) + ts = TraceState([1, 2, 3]) ts.confirm_full_scenario(ts.next_candidate(), 'one', dict(a=1)) ts.confirm_full_scenario(ts.next_candidate(), 'two', dict(b=2)) ts.confirm_full_scenario(ts.next_candidate(), 'three', dict(c=3)) - for act, exp in zip(ts, ['0', '1', '2']): + for act, exp in zip(ts, ['1', '2', '3']): self.assertEqual(act.id, exp) for act, exp in zip(ts, ['one', 'two', 'three']): self.assertEqual(act.scenario, exp) @@ -264,18 +267,18 @@ def test_can_iterate_over_tracestate_snapshots(self): self.assertEqual(act.model, exp) def test_can_index_tracestate_snapshots(self): - ts = TraceState(3) + ts = TraceState([1, 2, 3]) ts.confirm_full_scenario(ts.next_candidate(), 'one', dict(a=1)) ts.confirm_full_scenario(ts.next_candidate(), 'two', dict(b=2)) ts.confirm_full_scenario(ts.next_candidate(), 'three', dict(c=3)) - self.assertEqual(ts[0].id, '0') + self.assertEqual(ts[0].id, '1') self.assertEqual(ts[1].scenario, 'two') self.assertEqual(ts[2].model, dict(c=3)) self.assertEqual(ts[-1].scenario, 'three') - self.assertEqual([s.id for s in ts[1:]], ['1', '2']) + self.assertEqual([s.id for s in ts[1:]], ['2', '3']) def test_adding_coverage_prevents_drought(self): - ts = TraceState(3) + ts = TraceState(range(3)) ts.confirm_full_scenario(ts.next_candidate(), 'one', {}) self.assertEqual(ts.coverage_drought, 0) ts.confirm_full_scenario(ts.next_candidate(), 'two', {}) @@ -284,30 +287,30 @@ def test_adding_coverage_prevents_drought(self): self.assertEqual(ts.coverage_drought, 0) def test_repeated_scenarios_increases_drought(self): - ts = TraceState(2) - ts.confirm_full_scenario(0, 'one', {}) + ts = TraceState([1, 2]) + ts.confirm_full_scenario(1, 'one', {}) self.assertEqual(ts.coverage_drought, 0) - ts.confirm_full_scenario(0, 'one', {}) + ts.confirm_full_scenario(1, 'one', {}) self.assertEqual(ts.coverage_drought, 1) - ts.confirm_full_scenario(0, 'one', {}) + ts.confirm_full_scenario(1, 'one', {}) self.assertEqual(ts.coverage_drought, 2) def test_drought_is_reset_with_new_coverage(self): - ts = TraceState(2) - ts.confirm_full_scenario(0, 'one', {}) + ts = TraceState([1, 2]) + ts.confirm_full_scenario(1, 'one', {}) self.assertEqual(ts.coverage_drought, 0) - ts.confirm_full_scenario(0, 'one', {}) + ts.confirm_full_scenario(1, 'one', {}) self.assertEqual(ts.coverage_drought, 1) - ts.confirm_full_scenario(1, 'two', {}) + ts.confirm_full_scenario(2, 'two', {}) self.assertEqual(ts.coverage_drought, 0) def test_rewind_includes_drought_update(self): - ts = TraceState(2) - ts.confirm_full_scenario(0, 'one', {}) + ts = TraceState([1, 2]) + ts.confirm_full_scenario(1, 'one', {}) self.assertEqual(ts.coverage_drought, 0) - ts.confirm_full_scenario(0, 'one', {}) + ts.confirm_full_scenario(1, 'one', {}) self.assertEqual(ts.coverage_drought, 1) - ts.confirm_full_scenario(1, 'two', {}) + ts.confirm_full_scenario(2, 'two', {}) self.assertEqual(ts.coverage_drought, 0) ts.rewind() self.assertEqual(ts.coverage_drought, 1) @@ -317,40 +320,40 @@ def test_rewind_includes_drought_update(self): class TestPartialScenarios(unittest.TestCase): def test_push_partial_does_not_complete_coverage(self): - ts = TraceState(1) - ts.push_partial_scenario(0, 'part1', {}) + ts = TraceState([1]) + ts.push_partial_scenario(1, 'part1', {}) self.assertEqual(ts.get_trace(), ['part1']) self.assertIs(ts.coverage_reached(), False) def test_confirm_full_after_push_partial_completes_coverage(self): - ts = TraceState(1) - ts.push_partial_scenario(0, 'part1', {}) + ts = TraceState([1]) + ts.push_partial_scenario(1, 'part1', {}) self.assertIs(ts.coverage_reached(), False) - ts.push_partial_scenario(0, 'part2', {}) + ts.push_partial_scenario(1, 'part2', {}) self.assertIs(ts.coverage_reached(), False) - ts.confirm_full_scenario(0, 'remainder', {}) + ts.confirm_full_scenario(1, 'remainder', {}) self.assertEqual(ts.get_trace(), ['part1', 'part2', 'remainder']) self.assertIs(ts.coverage_reached(), True) def test_scenario_unavailble_once_pushed_partial(self): - ts = TraceState(1) + ts = TraceState([1]) candidate = ts.next_candidate() ts.push_partial_scenario(candidate, 'part1', {}) self.assertIs(ts.next_candidate(), None) def test_rewind_of_single_part(self): - ts = TraceState(1) - ts.push_partial_scenario(0, 'part1', {}) + ts = TraceState([1]) + ts.push_partial_scenario(1, 'part1', {}) self.assertEqual(ts.get_trace(), ['part1']) self.assertIs(ts.can_rewind(), True) ts.rewind() self.assertEqual(ts.get_trace(), []) def test_rewind_all_parts(self): - ts = TraceState(1) - ts.push_partial_scenario(0, 'part1', {}) + ts = TraceState([1]) + ts.push_partial_scenario(1, 'part1', {}) self.assertIs(ts.coverage_reached(), False) - ts.push_partial_scenario(0, 'part2', {}) + ts.push_partial_scenario(1, 'part2', {}) self.assertIs(ts.coverage_reached(), False) self.assertEqual(ts.get_trace(), ['part1', 'part2']) self.assertIs(ts.next_candidate(), None) @@ -363,84 +366,103 @@ def test_rewind_all_parts(self): self.assertIs(ts.can_rewind(), False) def test_partial_scenario_still_excluded_from_candidacy_after_rewind(self): - ts = TraceState(1) - self.assertEqual(ts.next_candidate(), 0) - ts.push_partial_scenario(0, 'part1', {}) + ts = TraceState([1]) + self.assertEqual(ts.next_candidate(), 1) + ts.push_partial_scenario(1, 'part1', {}) ts.rewind() self.assertIs(ts.next_candidate(), None) def test_rewind_to_partial_scenario(self): - ts = TraceState(1) - ts.push_partial_scenario(0, 'part1', dict(a=1)) - ts.push_partial_scenario(0, 'part2', dict(b=2)) + ts = TraceState([1]) + ts.push_partial_scenario(1, 'part1', dict(a=1)) + ts.push_partial_scenario(1, 'part2', dict(b=2)) snapshot = ts.rewind() - self.assertEqual(snapshot.id, '0.1') + self.assertEqual(snapshot.id, '1.1') self.assertEqual(snapshot.scenario, 'part1') self.assertEqual(snapshot.model, dict(a=1)) def test_rewind_last_part(self): - ts = TraceState(2) - ts.confirm_full_scenario(0, 'one', dict(a=1)) - ts.push_partial_scenario(1, 'part1', dict(b=2)) + ts = TraceState([1, 2]) + ts.confirm_full_scenario(1, 'one', dict(a=1)) + ts.push_partial_scenario(2, 'part1', dict(b=2)) snapshot = ts.rewind() self.assertEqual(ts.get_trace(), ['one']) - self.assertEqual(snapshot.id, '0') + self.assertEqual(snapshot.id, '1') self.assertEqual(snapshot.scenario, 'one') self.assertEqual(snapshot.model, dict(a=1)) def test_rewind_all_parts_of_completed_scenario_at_once(self): - ts = TraceState(1) - ts.push_partial_scenario(0, 'part1', dict(a=1)) - ts.push_partial_scenario(0, 'part2', dict(b=2)) - ts.confirm_full_scenario(0, 'remainder', {}) + ts = TraceState([1]) + ts.push_partial_scenario(1, 'part1', dict(a=1)) + ts.push_partial_scenario(1, 'part2', dict(b=2)) + ts.confirm_full_scenario(1, 'remainder', {}) tail = ts.rewind() self.assertEqual(ts.get_trace(), []) self.assertIs(ts.next_candidate(), None) self.assertIs(tail, None) + def test_tried_entries_after_rewind(self): + ts = TraceState([1, 2, 10, 11, 12, 20, 21]) + ts.push_partial_scenario(1, 'part1', {}) + ts.reject_scenario(10) + ts.reject_scenario(11) + ts.confirm_full_scenario(2, 'two', {}) + ts.push_partial_scenario(1, 'part2', {}) + ts.reject_scenario(20) + ts.reject_scenario(21) + self.assertEqual(ts.tried, (20, 21)) + ts.rewind() + self.assertEqual(ts.tried, ()) + ts.rewind() + self.assertEqual(ts.tried, (10, 11, 2)) + ts.reject_scenario(12) + self.assertEqual(ts.tried, (10, 11, 2, 12)) + ts.rewind() + self.assertEqual(ts.tried, (1,)) + def test_highest_part_after_first_part(self): - ts = TraceState(1) - ts.push_partial_scenario(0, 'part1', {}) - self.assertEqual(ts[-1].id, '0.1') - self.assertEqual(ts.highest_part(0), 1) + ts = TraceState([1]) + ts.push_partial_scenario(1, 'part1', {}) + self.assertEqual(ts[-1].id, '1.1') + self.assertEqual(ts.highest_part(1), 1) def test_highest_part_after_multiple_parts(self): - ts = TraceState(1) - ts.push_partial_scenario(0, 'part1', {}) - ts.push_partial_scenario(0, 'part2', {}) - self.assertEqual(ts[-1].id, '0.2') - self.assertEqual(ts.highest_part(0), 2) + ts = TraceState([1]) + ts.push_partial_scenario(1, 'part1', {}) + ts.push_partial_scenario(1, 'part2', {}) + self.assertEqual(ts[-1].id, '1.2') + self.assertEqual(ts.highest_part(1), 2) def test_highest_part_after_completing_multiple_parts(self): - ts = TraceState(1) - ts.push_partial_scenario(0, 'part1', {}) - ts.push_partial_scenario(0, 'part2', {}) - ts.confirm_full_scenario(0, 'remainder', {}) - self.assertEqual(ts[-1].id, '0.0') - self.assertEqual(ts.highest_part(0), 0) + ts = TraceState([1]) + ts.push_partial_scenario(1, 'part1', {}) + ts.push_partial_scenario(1, 'part2', {}) + ts.confirm_full_scenario(1, 'remainder', {}) + self.assertEqual(ts[-1].id, '1.0') + self.assertEqual(ts.highest_part(1), 0) def test_highest_part_after_partial_rewind(self): - ts = TraceState(1) - ts.push_partial_scenario(0, 'part1', {}) - ts.push_partial_scenario(0, 'part2', {}) - self.assertEqual(ts.highest_part(0), 2) + ts = TraceState([1]) + ts.push_partial_scenario(1, 'part1', {}) + ts.push_partial_scenario(1, 'part2', {}) + self.assertEqual(ts.highest_part(1), 2) ts.rewind() - self.assertEqual(ts.highest_part(0), 1) + self.assertEqual(ts.highest_part(1), 1) ts.rewind() - self.assertEqual(ts.highest_part(0), 0) + self.assertEqual(ts.highest_part(1), 0) def test_highest_part_is_0_when_no_refinement_is_ongoing(self): - ts = TraceState(1) - self.assertEqual(ts.highest_part(0), 0) - ts.push_partial_scenario(0, 'part1', {}) - ts.push_partial_scenario(0, 'part2', {}) - ts.confirm_full_scenario(0, 'remainder', {}) - self.assertEqual(ts.highest_part(0), 0) + ts = TraceState([1]) + self.assertEqual(ts.highest_part(1), 0) + ts.push_partial_scenario(1, 'part1', {}) + ts.push_partial_scenario(1, 'part2', {}) + ts.confirm_full_scenario(1, 'remainder', {}) + self.assertEqual(ts.highest_part(1), 0) ts.rewind() - self.assertEqual(ts.highest_part(0), 0) + self.assertEqual(ts.highest_part(1), 0) def test_count_scenario_repetitions_with_partials(self): - ts = TraceState(2) + ts = TraceState(range(2)) first = ts.next_candidate() self.assertEqual(ts.count(first), 0) ts.confirm_full_scenario(first, 'full', {}) @@ -458,36 +480,36 @@ def test_count_scenario_repetitions_with_partials(self): self.assertEqual(ts.count(second), 1) def test_partial_scenario_is_tried_without_finishing(self): - ts = TraceState(1) - ts.push_partial_scenario(0, 'part1', {}) + ts = TraceState([1]) + ts.push_partial_scenario(1, 'part1', {}) self.assertEqual(ts.tried, ()) ts.rewind() - self.assertEqual(ts.tried, (0,)) + self.assertEqual(ts.tried, (1,)) def test_get_last_snapshot_by_index(self): - ts = TraceState(1) - ts.push_partial_scenario(0, 'part1', dict(a=1)) - self.assertEqual(ts[-1].id, '0.1') + ts = TraceState([1]) + ts.push_partial_scenario(1, 'part1', dict(a=1)) + self.assertEqual(ts[-1].id, '1.1') self.assertEqual(ts[-1].scenario, 'part1') self.assertEqual(ts[-1].model, dict(a=1)) self.assertEqual(ts[-1].coverage_drought, 0) - ts.push_partial_scenario(0, 'part2', dict(b=2)) - ts.confirm_full_scenario(0, 'remainder', dict(c=3)) - self.assertEqual(ts[-1].id, '0.0') + ts.push_partial_scenario(1, 'part2', dict(b=2)) + ts.confirm_full_scenario(1, 'remainder', dict(c=3)) + self.assertEqual(ts[-1].id, '1.0') self.assertEqual(ts[-1].scenario, 'remainder') self.assertEqual(ts[-1].model, dict(c=3)) self.assertEqual(ts[-1].coverage_drought, 0) def test_only_completed_scenarios_affect_drought(self): - ts = TraceState(2) - ts.confirm_full_scenario(0, 'one full', {}) - ts.push_partial_scenario(0, 'one part1', {}) + ts = TraceState([1, 2]) + ts.confirm_full_scenario(1, 'one full', {}) + ts.push_partial_scenario(1, 'one part1', {}) self.assertEqual(ts.coverage_drought, 0) - ts.confirm_full_scenario(0, 'one remainder', {}) + ts.confirm_full_scenario(1, 'one remainder', {}) self.assertEqual(ts.coverage_drought, 1) - ts.push_partial_scenario(1, 'two part1', {}) + ts.push_partial_scenario(2, 'two part1', {}) self.assertEqual(ts.coverage_drought, 1) - ts.confirm_full_scenario(1, 'two remainder', {}) + ts.confirm_full_scenario(2, 'two remainder', {}) self.assertEqual(ts.coverage_drought, 0) diff --git a/utest/test_tracestate_refinement.py b/utest/test_tracestate_refinement.py index 2cbdca61..1b997e6b 100644 --- a/utest/test_tracestate_refinement.py +++ b/utest/test_tracestate_refinement.py @@ -36,7 +36,7 @@ class TestTraceStateRefinement(unittest.TestCase): def test_single_step_refinement(self): - ts = TraceState(2) + ts = TraceState(range(2)) candidate1 = ts.next_candidate() ts.push_partial_scenario(candidate1, 'T1.1', {}) candidate2 = ts.next_candidate() @@ -49,7 +49,7 @@ def test_single_step_refinement(self): self.assertEqual(ts.get_trace(), ['T1.1', 'B1', 'T1.0']) def test_rewind_step_with_refinement(self): - ts = TraceState(2) + ts = TraceState(range(2)) candidate1 = ts.next_candidate() ts.push_partial_scenario(candidate1, 'T1.1', {}) candidate2 = ts.next_candidate() @@ -62,7 +62,7 @@ def test_rewind_step_with_refinement(self): self.assertIs(ts.coverage_reached(), False) def test_rewind_refinement(self): - ts = TraceState(2) + ts = TraceState(range(2)) candidate1 = ts.next_candidate() ts.push_partial_scenario(candidate1, 'T1.1', {}) candidate2 = ts.next_candidate() @@ -74,7 +74,7 @@ def test_rewind_refinement(self): self.assertIs(ts.coverage_reached(), False) def test_refinement_at_two_steps(self): - ts = TraceState(3) + ts = TraceState(range(3)) outer = ts.next_candidate() ts.push_partial_scenario(outer, 'T1.1', {}) ts.confirm_full_scenario(ts.next_candidate(), 'B1', {}) @@ -88,7 +88,7 @@ def test_refinement_at_two_steps(self): self.assertEqual(ts.get_trace(), ['T1.1', 'B1', 'T1.2', 'B2', 'T1.0']) def test_rewind_to_swap_refinements(self): - ts = TraceState(3) + ts = TraceState(range(3)) outer = ts.next_candidate() ts.push_partial_scenario(outer, 'T1.1', {}) inner1 = ts.next_candidate() @@ -112,7 +112,7 @@ def test_rewind_to_swap_refinements(self): self.assertEqual(ts.get_trace(), ['T1.1', 'B2', 'T1.2', 'B1', 'T1.0']) def test_rewind_partial_scenario_to_before_outer(self): - ts = TraceState(4) + ts = TraceState(range(4)) head = ts.next_candidate() ts.confirm_full_scenario(head, 'HEAD', {}) outer = ts.next_candidate() @@ -130,7 +130,7 @@ def test_rewind_partial_scenario_to_before_outer(self): self.assertNotIn(ts.next_candidate(), [head, outer]) def test_rewind_scenario_with_double_refinement_as_one(self): - ts = TraceState(4) + ts = TraceState(range(4)) head = ts.next_candidate() ts.confirm_full_scenario(head, 'HEAD', {}) outer = ts.next_candidate() @@ -148,7 +148,7 @@ def test_rewind_scenario_with_double_refinement_as_one(self): self.assertNotEqual(ts.next_candidate(), [head, outer]) def test_nested_refinement(self): - ts = TraceState(3) + ts = TraceState(range(3)) top_level = ts.next_candidate() ts.push_partial_scenario(top_level, 'T1.1', {}) middle_level = ts.next_candidate() @@ -163,11 +163,11 @@ def test_nested_refinement(self): self.assertEqual(ts.get_trace(), ['T1.1', 'M1.1', 'B1', 'M1.0', 'T1.0']) def test_rewind_to_swap_nested_refinement(self): - ts = TraceState(3) + ts = TraceState(range(3)) top_level = ts.next_candidate() ts.push_partial_scenario(top_level, 'T1.1', {}) lower_level = ts.next_candidate() - ts.push_partial_scenario(lower_level, 'B1', {}) + ts.push_partial_scenario(lower_level, 'B1.1', {}) middle_level = ts.next_candidate() ts.reject_scenario(middle_level) self.assertIs(ts.next_candidate(), None) @@ -183,7 +183,7 @@ def test_rewind_to_swap_nested_refinement(self): self.assertEqual(ts.get_trace(), ['T1.1', 'M1.1', 'B1', 'M1.0', 'T1.0']) def test_rewind_nested_refinement_as_one(self): - ts = TraceState(4) + ts = TraceState(range(4)) ts.confirm_full_scenario(ts.next_candidate(), 'HEAD', {}) top_level = ts.next_candidate() ts.push_partial_scenario(top_level, 'T1.1', {}) @@ -199,7 +199,7 @@ def test_rewind_nested_refinement_as_one(self): self.assertEqual(ts.get_trace(), ['HEAD', 'T1.1']) def test_rewind_scenario_with_nested_refinement_as_one(self): - ts = TraceState(3) + ts = TraceState(range(3)) top_level = ts.next_candidate() ts.push_partial_scenario(top_level, 'T1.1', {}) middle_level = ts.next_candidate() @@ -215,7 +215,7 @@ def test_rewind_scenario_with_nested_refinement_as_one(self): self.assertEqual(ts.tried, (top_level,)) def test_highest_parts_from_refined_scenario(self): - ts = TraceState(4) + ts = TraceState(range(4)) top_level = ts.next_candidate() ts.push_partial_scenario(top_level, 'T1.1', {}) middle_level_1 = ts.next_candidate() @@ -245,7 +245,7 @@ def test_highest_parts_from_refined_scenario(self): 'T1.2', 'M2.1', 'M2.2', 'M2.3', 'M2.0', 'T1.0']) def test_refinement_can_resolve_drought(self): - ts = TraceState(2) + ts = TraceState(range(2)) candidate1 = ts.next_candidate() ts.confirm_full_scenario(candidate1, 'T1', {}) ts.confirm_full_scenario(candidate1, 'T1', {}) @@ -261,7 +261,7 @@ def test_refinement_can_resolve_drought(self): self.assertEqual(ts.coverage_drought, 2) def test_scenario_cannot_refine_itself(self): - ts = TraceState(2) + ts = TraceState(range(2)) candidate1 = ts.next_candidate() ts.push_partial_scenario(candidate1, 'T1.1', {}) candidate2 = ts.next_candidate() @@ -270,7 +270,7 @@ def test_scenario_cannot_refine_itself(self): self.assertIsNone(ts.next_candidate()) def test_scenario_cannot_refine_itself_with_repetition(self): - ts = TraceState(2) + ts = TraceState(range(2)) candidate1 = ts.next_candidate(retry=True) ts.push_partial_scenario(candidate1, 'T1.1', {}) candidate2 = ts.next_candidate(retry=True) @@ -279,66 +279,138 @@ def test_scenario_cannot_refine_itself_with_repetition(self): self.assertIsNone(ts.next_candidate(retry=True)) def test_initially_no_scenario_is_in_refinement(self): - ts = TraceState(1) - self.assertEqual(ts.find_scenarios_with_active_refinement(), []) + ts = TraceState([1]) + self.assertEqual(ts.active_refinements, []) def test_full_scenario_is_not_reported_as_refinement(self): - ts = TraceState(2) - ts.confirm_full_scenario(0, 'S1', {}) - self.assertEqual(ts.find_scenarios_with_active_refinement(), []) + ts = TraceState([1, 2]) + ts.confirm_full_scenario(1, 'S1', {}) + self.assertEqual(ts.active_refinements, []) def test_push_partial_opens_refinement(self): - ts = TraceState(4) - ts.push_partial_scenario(0, 'S1.1', {}) - self.assertEqual(ts.find_scenarios_with_active_refinement(), ['S1.1']) + ts = TraceState([1, 2]) + ts.push_partial_scenario(1, 'S1.1', {}) + self.assertEqual(ts.active_refinements, [1]) def test_nested_refinements_are_all_reported_as_in_refinement(self): - ts = TraceState(4) - ts.push_partial_scenario(0, 'T1.1', {}) - ts.push_partial_scenario(1, 'M1.1', {}) - ts.push_partial_scenario(2, 'B1.1', {}) - self.assertEqual(ts.find_scenarios_with_active_refinement(), ['T1.1', 'M1.1', 'B1.1']) + ts = TraceState([1, 2, 3, 4]) + ts.push_partial_scenario(1, 'T1.1', {}) + ts.push_partial_scenario(2, 'M1.1', {}) + ts.push_partial_scenario(3, 'B1.1', {}) + self.assertEqual(ts.active_refinements, [1, 2, 3]) def test_closing_refinement_removes_it_from_list(self): - ts = TraceState(4) - ts.push_partial_scenario(0, 'T1.1', {}) - ts.push_partial_scenario(1, 'M1.1', {}) - ts.push_partial_scenario(2, 'B1.1', {}) - self.assertEqual(ts.find_scenarios_with_active_refinement(), ['T1.1', 'M1.1', 'B1.1']) - ts.confirm_full_scenario(2, 'B1.0', {}) - self.assertEqual(ts.find_scenarios_with_active_refinement(), ['T1.1', 'M1.1']) + ts = TraceState([1, 2, 3, 4]) + ts.push_partial_scenario(1, 'T1.1', {}) + ts.push_partial_scenario(2, 'M1.1', {}) + ts.push_partial_scenario(3, 'B1.1', {}) + self.assertEqual(ts.active_refinements, [1, 2, 3]) + ts.confirm_full_scenario(3, 'B1.0', {}) + self.assertEqual(ts.active_refinements, [1, 2]) def test_multi_step_refinement_is_reported_only_once(self): - ts = TraceState(4) - ts.push_partial_scenario(0, 'T1.1', {}) - ts.push_partial_scenario(1, 'M1.1', {}) - ts.confirm_full_scenario(2, 'B1', {}) - ts.push_partial_scenario(1, 'M1.2', {}) - ts.confirm_full_scenario(3, 'B2', {}) - self.assertEqual(ts.find_scenarios_with_active_refinement(), ['T1.1', 'M1.1']) + ts = TraceState([1, 2, 3, 4]) + ts.push_partial_scenario(1, 'T1.1', {}) + ts.push_partial_scenario(2, 'M1.1', {}) + ts.confirm_full_scenario(3, 'B1', {}) + ts.push_partial_scenario(2, 'M1.2', {}) + ts.confirm_full_scenario(4, 'B2', {}) + self.assertEqual(ts.active_refinements, [1, 2]) def test_rewind_open_refinement_removes_it_from_list(self): - ts = TraceState(4) - ts.push_partial_scenario(0, 'T1.1', {}) - ts.push_partial_scenario(1, 'M1.1', {}) - ts.push_partial_scenario(2, 'B1.1', {}) - self.assertEqual(ts.find_scenarios_with_active_refinement(), ['T1.1', 'M1.1', 'B1.1']) + ts = TraceState([1, 2, 3, 4]) + ts.push_partial_scenario(1, 'T1.1', {}) + ts.push_partial_scenario(2, 'M1.1', {}) + ts.push_partial_scenario(3, 'B1.1', {}) + self.assertEqual(ts.active_refinements, [1, 2, 3]) ts.rewind() - self.assertEqual(ts.find_scenarios_with_active_refinement(), ['T1.1', 'M1.1']) + self.assertEqual(ts.active_refinements, [1, 2]) def test_rewind_finished_scenario_with_refinement_removes_enclosed_refinements(self): - ts = TraceState(5) - ts.confirm_full_scenario(0, 'T1', {}) - ts.push_partial_scenario(1, 'T2.1', {}) - ts.push_partial_scenario(2, 'M1.1', {}) - ts.push_partial_scenario(3, 'B1.1', {}) - ts.confirm_full_scenario(4, 'S1', {}) - self.assertEqual(ts.find_scenarios_with_active_refinement(), ['T2.1', 'M1.1', 'B1.1']) - ts.confirm_full_scenario(3, 'B1.0', {}) - ts.confirm_full_scenario(2, 'M1.0', {}) - self.assertEqual(ts.find_scenarios_with_active_refinement(), ['T2.1']) + ts = TraceState([1, 2, 3, 4, 5]) + ts.confirm_full_scenario(1, 'T1', {}) + ts.push_partial_scenario(2, 'T2.1', {}) + ts.push_partial_scenario(3, 'M1.1', {}) + ts.push_partial_scenario(4, 'B1.1', {}) + ts.confirm_full_scenario(5, 'S1', {}) + self.assertEqual(ts.active_refinements, [2, 3, 4]) + ts.confirm_full_scenario(4, 'B1.0', {}) + ts.confirm_full_scenario(3, 'M1.0', {}) + self.assertEqual(ts.active_refinements, [2]) ts.rewind() # Middle including its Bottom refinement - self.assertEqual(ts.find_scenarios_with_active_refinement(), ['T2.1']) + self.assertEqual(ts.active_refinements, [2]) + + def test_is_refinement_active_no_index(self): + ts = TraceState([1, 2, 3]) + self.assertFalse(ts.is_refinement_active()) + ts.confirm_full_scenario(1, 'one', {}) + self.assertFalse(ts.is_refinement_active()) + ts.push_partial_scenario(2, 'part1', {}) + self.assertTrue(ts.is_refinement_active()) + ts.confirm_full_scenario(3, 'refinment', {}) + self.assertTrue(ts.is_refinement_active()) + ts.push_partial_scenario(2, 'part2', {}) + self.assertTrue(ts.is_refinement_active()) + ts.confirm_full_scenario(2, 'remainder', {}) + self.assertFalse(ts.is_refinement_active()) + ts.rewind() + self.assertFalse(ts.is_refinement_active()) + ts.rewind() + self.assertFalse(ts.is_refinement_active()) + + def test_is_refinement_active_by_index(self): + ts = TraceState([1, 2, 3]) + self.assertFalse(ts.is_refinement_active(1)) + self.assertFalse(ts.is_refinement_active(2)) + ts.confirm_full_scenario(1, 'one', {}) + self.assertFalse(ts.is_refinement_active(1)) + self.assertFalse(ts.is_refinement_active(2)) + ts.push_partial_scenario(2, 'part1', {}) + self.assertFalse(ts.is_refinement_active(1)) + self.assertTrue(ts.is_refinement_active(2)) + self.assertFalse(ts.is_refinement_active(3)) + ts.push_partial_scenario(3, 'refinement head', {}) + self.assertFalse(ts.is_refinement_active(1)) + self.assertTrue(ts.is_refinement_active(2)) + self.assertTrue(ts.is_refinement_active(3)) + ts.confirm_full_scenario(3, 'refinement tail', {}) + self.assertFalse(ts.is_refinement_active(1)) + self.assertTrue(ts.is_refinement_active(2)) + self.assertFalse(ts.is_refinement_active(3)) + ts.push_partial_scenario(2, 'part2', {}) + self.assertFalse(ts.is_refinement_active(1)) + self.assertTrue(ts.is_refinement_active(2)) + ts.rewind() + self.assertFalse(ts.is_refinement_active(1)) + self.assertTrue(ts.is_refinement_active(2)) + ts.confirm_full_scenario(2, 'remainder', {}) + self.assertFalse(ts.is_refinement_active(1)) + self.assertFalse(ts.is_refinement_active(2)) + ts.rewind() + self.assertFalse(ts.is_refinement_active(1)) + self.assertFalse(ts.is_refinement_active(2)) + ts.rewind() + self.assertFalse(ts.is_refinement_active(1)) + self.assertFalse(ts.is_refinement_active(2)) + + def test_remainder_can_be_set_and_retrieved(self): + ts = TraceState([1, 2]) + ts.push_partial_scenario(1, 'one part1', {}, 'one part2') + ts.push_partial_scenario(2, 'two part1', {}, 'two parts 2+3') + self.assertEqual(ts.get_remainder(1), 'one part2') + self.assertEqual(ts.get_remainder(2), 'two parts 2+3') + ts.push_partial_scenario(2, 'two part2', {}, 'two part3') + self.assertEqual(ts.get_remainder(2), 'two part3') + ts.rewind() + self.assertEqual(ts.get_remainder(2), 'two parts 2+3') + ts.push_partial_scenario(2, 'two part2', {}, 'two part3B') + self.assertEqual(ts.get_remainder(2), 'two part3B') + ts.confirm_full_scenario(2, 'two', {}) + self.assertEqual(ts.get_remainder(1), 'one part2') + self.assertIsNone(ts.get_remainder(2)) + ts.confirm_full_scenario(1, 'one', {}) + self.assertIsNone(ts.get_remainder(1)) + self.assertIsNone(ts.get_remainder(2)) if __name__ == '__main__': From 872065d25669e8d66895a1d078400f879cdbc454 Mon Sep 17 00:00:00 2001 From: D Osinga <46380170+osingaatje@users.noreply.github.com> Date: Thu, 8 Jan 2026 15:53:08 +0100 Subject: [PATCH 092/131] added svg export back (#50) --- robotmbt/visualise/networkvisualiser.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/robotmbt/visualise/networkvisualiser.py b/robotmbt/visualise/networkvisualiser.py index 4beedea5..6238de30 100644 --- a/robotmbt/visualise/networkvisualiser.py +++ b/robotmbt/visualise/networkvisualiser.py @@ -77,6 +77,7 @@ def __init__(self, graph: AbstractGraph, suite_name: str, seed: str): # Set up a Bokeh figure self.plot = Plot(width=OUTER_WINDOW_WIDTH, height=OUTER_WINDOW_HEIGHT) + self.plot.output_backend = "svg" # Create Sugiyama layout nodes, edges = self._create_layout() @@ -244,7 +245,7 @@ def _add_features(self, suite_name: str, seed: str): # A JS callback to scale text and arrows, and change aspect ratio. resize_cb = CustomJS(args=dict(xr=self.plot.x_range, yr=self.plot.y_range, plot=self.plot, arrows=self.arrows), - code=f""" + code=f""" // Initialize initial scale tag if (!plot.tags || plot.tags.length === 0) {{ plot.tags = [{{ @@ -406,7 +407,7 @@ def _get_connection_coordinates(nodes: list[Node], node_id: str) -> list[tuple[f def _minimize_distance(from_pos: list[tuple[float, float]], to_pos: list[tuple[float, float]]) -> tuple[ - float, float, float, float]: + float, float, float, float]: """ Find a pair of positions that minimizes their distance. """ From 4fe4015ae9028585a3d30735fd1d60e8be85ead7 Mon Sep 17 00:00:00 2001 From: Thomas Kas Date: Fri, 9 Jan 2026 15:45:59 +0100 Subject: [PATCH 093/131] Minor tweaks (#49) * Scale arrows less aggressively * Split scenario names into separate lines --- robotmbt/visualise/models.py | 55 +++++++++++++++++++++++-- robotmbt/visualise/networkvisualiser.py | 2 +- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index 3cb16790..2089d443 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -9,6 +9,8 @@ import tempfile import os +DESIRED_NAME_LINE_LENGTH = 20 + class ScenarioInfo: """ @@ -20,7 +22,7 @@ class ScenarioInfo: def __init__(self, scenario: Scenario | str): if isinstance(scenario, Scenario): # default case - self.name = scenario.name + self.name = self._split_name(scenario.name) self.src_id = scenario.src_id else: # unit tests @@ -33,6 +35,55 @@ def __str__(self): def __eq__(self, other): return self.src_id == other.src_id + @staticmethod + def _split_name(name: str) -> str: + """ + Split a name into separate lines where each line is as close to the desired line length as possible. + """ + # Split into words + words = name.split(" ") + + # If any word is longer than the desired length, use that as the desired length instead + # (otherwise, we will always get a line (much) longer than the desired length, while the other lines will + # be constrained by the desired length) + desired_length = DESIRED_NAME_LINE_LENGTH + for i in words: + if len(i) > desired_length: + desired_length = len(i) + + res = "" + line = words[0] + for i in words[1:]: + # If the previous line was fully appended, simply take the current word as the new line + if line == '\n': + line += i + continue + + app_len = len(line + ' ' + i) + + # If the word fully fits into the line, simply append it + if app_len < desired_length: + line = line + ' ' + i + continue + + app_diff = abs(desired_length - app_len) + curr_diff = abs(desired_length - len(line)) + + # If the current line is closer to the desired length, use that + if curr_diff < app_diff: + res += line + line = '\n' + i + # If the current line with the new word is closer to the desired length, use that + else: + res += line + ' ' + i + line = '\n' + + # Append the final line if it wasn't empty + if line != '\n': + res += line + + return res + class StateInfo: """ @@ -75,7 +126,6 @@ def _dict_deep_diff(old_state: dict[str, any], new_state: dict[str, any]) -> dic res[key] = new_state[key] return res - def __init__(self, state: ModelSpace): self.domain = state.ref_id @@ -221,7 +271,6 @@ def import_graph(self, file_name: str): string = f.read() self = jsonpickle.decode(string) - @staticmethod def stringify_pair(pair: tuple[ScenarioInfo, StateInfo]) -> str: return f"Scenario={pair[0].name}, State={pair[1]}" diff --git a/robotmbt/visualise/networkvisualiser.py b/robotmbt/visualise/networkvisualiser.py index 6238de30..22acbed8 100644 --- a/robotmbt/visualise/networkvisualiser.py +++ b/robotmbt/visualise/networkvisualiser.py @@ -296,7 +296,7 @@ def _add_features(self, suite_name: str, seed: str): if (!a.properties.end._value.properties.size._value.value) continue; if (a._base_end_size == null) a._base_end_size = a.properties.end._value.properties.size._value.value; - a.properties.end._value.properties.size._value.value = a._base_end_size * scale; + a.properties.end._value.properties.size._value.value = a._base_end_size * Math.sqrt(scale); a.change.emit(); }}""") From c064a7c3c0e1daebf13d603a8fc72c5867047d9f Mon Sep 17 00:00:00 2001 From: D Osinga <46380170+osingaatje@users.noreply.github.com> Date: Mon, 12 Jan 2026 15:22:44 +0100 Subject: [PATCH 094/131] Add _get_graph() in Visualiser for atesting in the future (#52) * add _get_graph() in Visualiser for atesting in the future * one space - I hate Python with a passion --- robotmbt/visualise/visualiser.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py index af1b9cdb..757929b4 100644 --- a/robotmbt/visualise/visualiser.py +++ b/robotmbt/visualise/visualiser.py @@ -71,10 +71,7 @@ def update_trace(self, trace: TraceState): model = snap.model self.trace_info.update_trace(ScenarioInfo(scenario), StateInfo(model), trace_len) - def generate_visualisation(self) -> str: - if self.export: - self.trace_info.export_graph(self.suite_name) - + def _get_graph(self) -> AbstractGraph: if self.graph_type == 'scenario': graph: AbstractGraph = ScenarioGraph(self.trace_info) elif self.graph_type == 'state': @@ -88,6 +85,14 @@ def generate_visualisation(self) -> str: else: graph: AbstractGraph = ScenarioStateGraph(self.trace_info) + return graph + + def generate_visualisation(self) -> str: + if self.export: + self.trace_info.export_graph(self.suite_name) + + graph: AbstractGraph = self._get_graph() + html_bokeh = networkvisualiser.NetworkVisualiser(graph, self.suite_name, self.seed).generate_html() return f'' From e74e841d0c40fae190dd9c27f1d7df5c81bbf249 Mon Sep 17 00:00:00 2001 From: tychodub <93142605+tychodub@users.noreply.github.com> Date: Wed, 14 Jan 2026 10:37:36 +0100 Subject: [PATCH 095/131] Fix some spelling mistakes surrounding json export (#54) * implemented scenariodeltavaluegraph * implemented reduced-scenariodeltavaluegraph, but should still discuss how to label equivalence classes (currently just picks a representative). Fixed part of vertex info being indicated as edge info for scenariodeltavaluegraph. Trace highlighting is still broken for reducedSDV * implemented comments from Douwe (added comments and reset graph setting in suiteprocessors.py) * fixed reducedSDV graph to have a properly functioning equivalence relation * added merged annotation to labels generated by reducedSDV for merged nodes * fixed spelling mistakes export_graph documentation and applied PyCharm Pep8 formatting recommendations on modelgenerator.py --- atest/resources/helpers/modelgenerator.py | 39 +++++++++++------------ robotmbt/visualise/models.py | 4 +-- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/atest/resources/helpers/modelgenerator.py b/atest/resources/helpers/modelgenerator.py index 83270285..0123d05b 100644 --- a/atest/resources/helpers/modelgenerator.py +++ b/atest/resources/helpers/modelgenerator.py @@ -6,11 +6,11 @@ class ModelGenerator: - @keyword(name='Generate Trace Information') # type:ignore + @keyword(name='Generate Trace Information') # type:ignore def generate_trace_information(self) -> TraceInfo: return TraceInfo() - @keyword(name='Current Trace Contains') # type:ignore + @keyword(name='Current Trace Contains') # type:ignore def current_trace_contains(self, trace_info: TraceInfo, scenario_name: str, state_str: str) -> TraceInfo: ''' State should be of format @@ -20,16 +20,16 @@ def current_trace_contains(self, trace_info: TraceInfo, scenario_name: str, stat s = state_str.split(': ') key, item = s[1].split('=') state = StateInfo._create_state_with_prop(s[0], [(key, item)]) - trace_info.update_trace(scenario, state, trace_info.previous_length+1) + trace_info.update_trace(scenario, state, trace_info.previous_length + 1) return trace_info - - @keyword(name='All Traces Contains List') # type:ignore + + @keyword(name='All Traces Contains List') # type:ignore def all_traces_contains_list(self, trace_info: TraceInfo) -> TraceInfo: trace_info.all_traces.append([]) return trace_info - - @keyword(name='All Traces Contains') # type:ignore + + @keyword(name='All Traces Contains') # type:ignore def all_traces_contains(self, trace_info: TraceInfo, scenario_name: str, state_str: str) -> TraceInfo: ''' State should be of format @@ -39,16 +39,16 @@ def all_traces_contains(self, trace_info: TraceInfo, scenario_name: str, state_s s = state_str.split(': ') key, item = s[1].split('=') state = StateInfo._create_state_with_prop(s[0], [(key, item)]) - + trace_info.all_traces[0].append((scenario, state)) return trace_info - - @keyword(name='Export Graph') # type:ignore - def export_to_json(self, suite:str, trace_info: TraceInfo) -> str: + + @keyword(name='Export Graph') # type:ignore + def export_to_json(self, suite: str, trace_info: TraceInfo) -> str: return trace_info.export_graph(suite, True) - - @keyword(name='Import JSON File') # type:ignore + + @keyword(name='Import JSON File') # type:ignore def import_json_file(self, filepath: str) -> TraceInfo: with open(filepath, 'r') as f: string = f.read() @@ -56,7 +56,7 @@ def import_json_file(self, filepath: str) -> TraceInfo: visualiser = Visualiser('state', trace_info=decoded_instance) return visualiser.trace_info - @keyword(name='Check File Exists') # type:ignore + @keyword(name='Check File Exists') # type:ignore def check_file_exists(self, filepath: str) -> str: ''' Checks if file exists @@ -65,8 +65,8 @@ def check_file_exists(self, filepath: str) -> str: Expected != result ''' return 'file exists' if os.path.exists(filepath) else 'file does not exist' - - @keyword(name='Compare Trace Info') # type:ignore + + @keyword(name='Compare Trace Info') # type:ignore def compare_trace_info(self, t1: TraceInfo, t2: TraceInfo) -> str: ''' Checks if current trace and all traces of t1 and t2 are equal @@ -76,9 +76,8 @@ def compare_trace_info(self, t1: TraceInfo, t2: TraceInfo) -> str: ''' succes = 'imported model equals exported model' fail = 'imported models differs from exported model' - return succes if repr(t1) == repr(t2) else fail - - @keyword(name='Delete File') # type:ignore + return succes if repr(t1) == repr(t2) else fail + + @keyword(name='Delete File') # type:ignore def delete_json_file(self, filepath: str): os.remove(filepath) - diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index 2089d443..ffec518f 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -252,9 +252,9 @@ def export_graph(self, suite_name: str, atest: bool = False) -> str | None: name = suite_name.lower().replace(' ', '_') if atest: ''' - temporary file to not accidentaly overwrite an existing file + temporary file to not accidentally overwrite an existing file mkstemp() is not ideal but given Python's limitations this is the easiest solution - as temporary file, a different method, is problamatic on Windows + as temporary file, a different method, is problematic on Windows https://stackoverflow.com/a/57015383 ''' fd, path = tempfile.mkstemp() From a962a715fa63a5334cea7e7b59f608389d542a1d Mon Sep 17 00:00:00 2001 From: Thomas Kas Date: Wed, 14 Jan 2026 10:38:55 +0100 Subject: [PATCH 096/131] Documentation (#53) * Add graph creation documentation for contributors * Add documentation on enabling graphs and JSON importing/exporting --- CONTRIBUTING.md | 76 +++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 33 +++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c7559fe..daf829b1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -114,3 +114,79 @@ Researchers have suggested that longer lines are better suited for cases when th - Information that is useful for analysing failed tests is logged at debug-level. - Be careful not to make assumptions in what you log: Recheck log statements if your changes affect the context in which the code is run, and only report about what you know to be true. + +### Creating new graphs + +Extending the functionality of the visualizer with new graph types can result in better insights into created tests. The visualizer makes use of an abstract graph class that makes it easy to create new graph types. + +To create a new graph type, create an instance of AbstractGraph, instantiating the following methods: +- select_node_info: select the information you want to use to identify different nodes from all ScenarioInfo/StateInfo pairs that make up the different exploration steps. This info is also used to label nodes. Its return type has to match the first type used to instantiate AbstractGraph. +- select_edge_info: ditto but for edges, which is also used for labeling. Its type has to match the second type used to instantiate AbstractGraph. +- create_node_label: turn the selected information into a label for a node. +- create_edge_label: ditto but for edges. +- get_legend_info_final_trace_node: return the text you want to appear in the legend for nodes that appear in the final trace. +- get_legend_info_other_node: ditto but for nodes that have been backtracked. +- get_legend_info_final_trace_edge: ditto but for edges that appear in the final trace. +- get_legend_info_other_edge: ditto but for edges that have backtracked. + +It is recommended to create a new file for each graph type under `/robotmbt/visualise/graphs/`. + +A simple example is given below. In this graph type, nodes represent scenarios encountered in exploration, and edges show the flow between these scenarios. + +```python +class ScenarioGraph(AbstractGraph[ScenarioInfo, None]): + @staticmethod + def select_node_info(pairs: list[tuple[ScenarioInfo, StateInfo]], index: int) -> ScenarioInfo: + return pairs[index][0] + + @staticmethod + def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> None: + return None + + @staticmethod + def create_node_label(info: ScenarioInfo) -> str: + return info.name + + @staticmethod + def create_edge_label(info: None) -> str: + return '' + + @staticmethod + def get_legend_info_final_trace_node() -> str: + return "Executed Scenario (in final trace)" + + @staticmethod + def get_legend_info_other_node() -> str: + return "Executed Scenario (backtracked)" + + @staticmethod + def get_legend_info_final_trace_edge() -> str: + return "Execution Flow (final trace)" + + @staticmethod + def get_legend_info_other_edge() -> str: + return "Execution Flow (backtracked)" +``` + +Once you have created a new Graph class, you can direct the visualizer to use it when your type is selected. +Edit `/robotmbt/visualise/visualiser.py` to not reject your graph type in `__init__` and construct your graph in `generate_visualisation` like the others. For our example: +```python +def __init__(self, graph_type: str, suite_name: str = "", seed: str = "", export: bool = False, trace_info: TraceInfo = None): + if graph_type != 'scenario' and [...]: + raise ValueError(f"Unknown graph type: {graph_type}!") + + [...] +``` + +```python +def generate_visualisation(self) -> str: + [...] + + if self.graph_type == 'scenario': + graph: AbstractGraph = ScenarioGraph(self.trace_info) + + [...] +``` + +Now, when selecting your graph type (in our example `Treat this test suite Model-based graph=scenario`), your graph will get constructed! + diff --git a/README.md b/README.md index 3eeb12de..8ca95a89 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,39 @@ Treat this test suite model-based seed=eag-etou-cxi-leamv-jsi Using `seed=new` will force generation of a new reusable seed and is identical to omitting the seed argument. To completely bypass seed generation and use the system's random source, use `seed=None`. This has even more variation but does not produce a reusable seed. +### Graphs + +By default, no graphs are generated for test-runs. For development purposes, having a visual representation of the test-suite you are working on can be very useful. To have robotmbt generate a graph, ensure you have installed the optional dependencies (`pip install .[visualization]`) and pass the type as an argument: + +``` +Treat this test suite Model-based graph=[type] +``` + +Here, `[type]` can be any of the supported graph types. Currently, the types included are: +- `scenario-delta-value` + +Once the test suite has run, a graph will be included in the test's log, under the suite's `Treat this test suite Model-based` setup header. + +#### JSON exporting + +It is possible to extract the exploration data after the library has found a covering trace. To enable this feature, set the following argument to true: + +``` +Treat this test suite Model-based graph=[type] to_json=true +``` + +A JSON file named after the test suite will be created containing said information. + +#### JSON importing + +It is possible to skip running the exploration step and produce a graph (e.g. of another type) from previously exported data. This can be achieved by pointing the following argument to such a JSON file (just its name suffices, without the extension): + +``` +Treat this test suite Model-based graph=[type] from_json=[file_name] +``` + +A graph will be created from the imported data. + ### Option management If you want to set configuration options for use in multiple test suites without having to repeat them, the keywords __Set model-based options__ and __Update model-based options__ can be used to configure RobotMBT library options. _Set_ takes the provided options and discards any previously set options. _Update_ allows you to modify existing options or add new ones. Reset all options by calling _Set_ without arguments. Direct options provided to __Treat this test suite model-based__ take precedence over library options and affect only the current test suite. From 75ebaf3d2d199cc098c758b6f671dda7343c77f8 Mon Sep 17 00:00:00 2001 From: Jonathan <61787386+JWillegers@users.noreply.github.com> Date: Sun, 18 Jan 2026 17:49:57 +0100 Subject: [PATCH 097/131] Acceptance tests (must requirements) (#55) * refactor modelgenerator and visualisation.resource * in progress - TODO fix IN/OUT in .resource file. * :IN: :OUT: changes - waiting for Behaviour Document to update * add spaces to warning * vertex test for scenario-delta-value * Custom error messages * add domain to test cases * refactor modelgenerator and visualisation.resource * in progress - TODO fix IN/OUT in .resource file. * :IN: :OUT: changes - waiting for Behaviour Document to update * add spaces to warning * vertex test for scenario-delta-value * Custom error messages * add domain to test cases * delete unit tests that will be covered by atests * added dummy visualiser to visualise self-made graphs * fixed model generator and .resource file * added edge rules * partial update to export JSON atest * fully updated export to json atest * added vertical alignment test * refactor modelgenerator and visualisation.resource * in progress - TODO fix IN/OUT in .resource file. * :IN: :OUT: changes - waiting for Behaviour Document to update * add spaces to warning * vertex test for scenario-delta-value * Custom error messages * add domain to test cases * refactor modelgenerator and visualisation.resource * Convert reduced-sdv ids into tuples (#56) * Convert ids to string so we never worry about frozensets again * Add hashable ID requirement documentation * Remove unnecessary RawNodeInfo * Serializable, not hashable * Serializable AND hashable * minor changes (mostly name refactors) * consistency for graph _ has an edge from _ to _ * seperated get NodeID from get vertex y position * move where the y-axis inversion happens * removed: test suite s has a trace with 2 steps * fixed comments --------- Co-authored-by: Douwe Osinga Co-authored-by: Thomas Kas --- CONTRIBUTING.md | 2 + atest/resources/helpers/modelgenerator.py | 256 +++++++++++-- atest/resources/visualisation.resource | 215 +++++++---- .../0__setup.robot | 8 + .../1__scenario-delta-value.robot | 27 ++ .../__init__.robot | 5 + .../C.2.3__export to JSON.robot | 35 -- .../01__export to JSON.robot | 37 ++ robotmbt/suiteprocessors.py | 4 +- robotmbt/visualise/graphs/abstractgraph.py | 3 + robotmbt/visualise/graphs/reducedSDVgraph.py | 20 +- robotmbt/visualise/networkvisualiser.py | 93 ++--- utest/test_visualise_abstractgraph.py | 39 -- .../test_visualise_scenariodeltavaluegraph.py | 57 --- utest/test_visualise_scenariograph.py | 248 ------------- utest/test_visualise_scenariostategraph.py | 349 ------------------ utest/test_visualise_stategraph.py | 318 ---------------- 17 files changed, 507 insertions(+), 1209 deletions(-) create mode 100644 atest/robotMBT tests/10__visualisation_representations/0__setup.robot create mode 100644 atest/robotMBT tests/10__visualisation_representations/1__scenario-delta-value.robot create mode 100644 atest/robotMBT tests/10__visualisation_representations/__init__.robot delete mode 100644 atest/robotMBT tests/10__visualisation_tests/C.2.3__export to JSON.robot create mode 100644 atest/robotMBT tests/12__visualisation_export/01__export to JSON.robot delete mode 100644 utest/test_visualise_abstractgraph.py delete mode 100644 utest/test_visualise_scenariodeltavaluegraph.py delete mode 100644 utest/test_visualise_scenariograph.py delete mode 100644 utest/test_visualise_scenariostategraph.py delete mode 100644 utest/test_visualise_stategraph.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index daf829b1..f36b3751 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -131,6 +131,8 @@ To create a new graph type, create an instance of AbstractGraph, instantiating t It is recommended to create a new file for each graph type under `/robotmbt/visualise/graphs/`. +NOTE: when manually altering the networkx field, ensure its ids remain as a serializable and hashable type when the constructor finishes. + A simple example is given below. In this graph type, nodes represent scenarios encountered in exploration, and edges show the flow between these scenarios. ```python diff --git a/atest/resources/helpers/modelgenerator.py b/atest/resources/helpers/modelgenerator.py index 0123d05b..e18deed1 100644 --- a/atest/resources/helpers/modelgenerator.py +++ b/atest/resources/helpers/modelgenerator.py @@ -1,8 +1,11 @@ -import jsonpickle +import jsonpickle # type: ignore from robot.api.deco import keyword # type:ignore from robotmbt.visualise.models import TraceInfo, ScenarioInfo, StateInfo from robotmbt.visualise.visualiser import Visualiser +from robotmbt.visualise.graphs.abstractgraph import AbstractGraph import os +import networkx as nx +from robotmbt.visualise.networkvisualiser import NetworkVisualiser, Node class ModelGenerator: @@ -10,17 +13,15 @@ class ModelGenerator: def generate_trace_information(self) -> TraceInfo: return TraceInfo() - @keyword(name='Current Trace Contains') # type:ignore - def current_trace_contains(self, trace_info: TraceInfo, scenario_name: str, state_str: str) -> TraceInfo: + @keyword(name='The Algorithm Inserts') # type:ignore + def insert_trace_info(self, trace_info: TraceInfo, scenario_name: str, state_str: str | None = None) -> TraceInfo: ''' State should be of format - "name: key=value" + "name: key=value key2=value2 ..." ''' - scenario = ScenarioInfo(scenario_name) - s = state_str.split(': ') - key, item = s[1].split('=') - state = StateInfo._create_state_with_prop(s[0], [(key, item)]) - trace_info.update_trace(scenario, state, trace_info.previous_length + 1) + + (scen_info, state_info) = self.__convert_to_info_tuple(scenario_name, state_str) + trace_info.update_trace(scen_info, state_info, trace_info.previous_length+1) return trace_info @@ -30,54 +31,231 @@ def all_traces_contains_list(self, trace_info: TraceInfo) -> TraceInfo: return trace_info @keyword(name='All Traces Contains') # type:ignore - def all_traces_contains(self, trace_info: TraceInfo, scenario_name: str, state_str: str) -> TraceInfo: + def all_traces_contains(self, trace_info: TraceInfo, scenario_name: str, state_str: str | None) -> TraceInfo: ''' State should be of format - "name: key=value" + "scenario: key=value" ''' - scenario = ScenarioInfo(scenario_name) - s = state_str.split(': ') - key, item = s[1].split('=') - state = StateInfo._create_state_with_prop(s[0], [(key, item)]) + (scen_info, state_info) = self.__convert_to_info_tuple(scenario_name, state_str) - trace_info.all_traces[0].append((scenario, state)) + for trace in trace_info.all_traces: + trace.append((scen_info, state_info)) return trace_info + @keyword(name='Generate Graph') # type:ignore + def generate_graph(self, trace_info: TraceInfo, graph_type: str) -> AbstractGraph: + return Visualiser(graph_type=graph_type, trace_info=trace_info)._get_graph() + + @keyword(name="Generate Network Graph") + def generate_networkgraph(self, graph: AbstractGraph) -> NetworkVisualiser: + return NetworkVisualiser(graph=graph, suite_name="", seed="") + @keyword(name='Export Graph') # type:ignore - def export_to_json(self, suite: str, trace_info: TraceInfo) -> str: + def export_graph(self, suite: str, trace_info: TraceInfo) -> str: return trace_info.export_graph(suite, True) - @keyword(name='Import JSON File') # type:ignore - def import_json_file(self, filepath: str) -> TraceInfo: + @keyword(name='Import Graph') # type:ignore + def import_graph(self, filepath: str) -> TraceInfo: with open(filepath, 'r') as f: string = f.read() - decoded_instance = jsonpickle.decode(string) + decoded_instance: TraceInfo = jsonpickle.decode(string) # type: ignore visualiser = Visualiser('state', trace_info=decoded_instance) return visualiser.trace_info @keyword(name='Check File Exists') # type:ignore - def check_file_exists(self, filepath: str) -> str: - ''' - Checks if file exists - - Returns string for .resource error message in case values are not equal - Expected != result - ''' - return 'file exists' if os.path.exists(filepath) else 'file does not exist' + def check_file_exists(self, filepath: str) -> bool: + return os.path.exists(filepath) @keyword(name='Compare Trace Info') # type:ignore - def compare_trace_info(self, t1: TraceInfo, t2: TraceInfo) -> str: - ''' - Checks if current trace and all traces of t1 and t2 are equal - - Returns string for .resource error message in case values are not equal - Expected != result - ''' - succes = 'imported model equals exported model' - fail = 'imported models differs from exported model' - return succes if repr(t1) == repr(t2) else fail + def compare_trace_info(self, t1: TraceInfo, t2: TraceInfo) -> bool: + return repr(t1) == repr(t2) @keyword(name='Delete File') # type:ignore - def delete_json_file(self, filepath: str): + def delete_file(self, filepath: str): os.remove(filepath) + + @keyword(name='Graph Contains Vertex With No Text') # type:ignore + def graph_contains_no_text(self, graph: AbstractGraph, label: str) -> bool: + return label in graph.networkx.nodes() + + @keyword(name='Graph Contains Vertex With Text') # type:ignore + def graph_contains_vertex_with_text(self, graph: AbstractGraph, title: str, text: str | None = None) -> str | None: + """ + Returns the label of the complete node or None if it doesn't exist + """ + ATTRIBUTE = "label" + attr = nx.get_node_attributes(graph.networkx, ATTRIBUTE) + + (_, state_info) = self.__convert_to_info_tuple(title, text) + parts = state_info.properties[text.split(":")[0]] \ + if text is not None else [] + + for nodename, label in attr.items(): + if title in label: + if text is None: + # we sanitise because newlines in text go badly with eval() in Robot framework + return nodename + + count = 0 + for s in parts: + if f"{s}={parts[s]}" in label: + count += 1 + if count == len(parts): + # we sanitise because newlines in text go badly with eval() in Robot framework + return nodename + + return None + + @keyword(name="Vertices Are Connected") # type:ignore + def vertices_connected(self, graph: AbstractGraph, node_key1: str | None, node_key2: str | None) -> bool: + if node_key1 is None or node_key2 is None: + return False + return graph.networkx.has_edge(node_key1, node_key2) + + @keyword(name="Get NodeID") # type:ignore + def get_nodeid(self, graph: AbstractGraph, node_title:str) -> str | None: + return self.graph_contains_vertex_with_text(graph, node_title, text=None) + + @keyword(name="Get Vertex Y Position") # type:ignore + def get_y(self, network_vis: NetworkVisualiser, id: str) -> int | None: + try: + node: Node | None = network_vis.node_dict[id] + return node.y + except KeyError: + return None + + @keyword(name='Graph Contains Vertices Starting With') # type:ignore + def scen_graph_contains_vertices_starting_with(self, graph: AbstractGraph, vertices_str: str) -> bool | str: + ''' + vertices_str should be of format "'vertex1', 'vertex2'" etc + ''' + attr: dict[str, str] = nx.get_node_attributes(graph.networkx, "label") + + vertices = vertices_str.split("'") + for i in range(1, len(vertices), 2): + found = False + for _, label in attr.items(): + if label.startswith(vertices[i]): + found = True + break + + if not found: + return False + + return True + + @keyword(name='Backtrack') # type:ignore + def backtrack(self, trace_info: TraceInfo, steps: int) -> TraceInfo: + scenario, state = trace_info.current_trace[-steps - 1] + trace_info.update_trace(scenario, state, len(trace_info.current_trace) - steps) + return trace_info + + @keyword(name='Get Length Current Trace') # type:ignore + def get_length_current_trace(self, trace_info: TraceInfo) -> int: + return len(trace_info.current_trace) + + @keyword(name='Get Number of Backtracked Traces') # type:ignore + def get_number_of_backtracked_traces(self, trace_info: TraceInfo) -> int: + return len(trace_info.all_traces) + + # ============= # + # == HELPERS == # + # ============= # + + @staticmethod + def __convert_to_info_tuple(scenario_name: str, keyvaluestr: str | None) -> tuple[ScenarioInfo, StateInfo]: + """ + Format: + "domain1: key1=value1, key2=value2" + """ + scenario_name = scenario_name.strip() + if keyvaluestr is None: + return (ScenarioInfo(scenario_name), StateInfo._create_state_with_prop("", [])) + + keyvaluestr = keyvaluestr.strip() + + split_domain: list[str] = keyvaluestr.split(':') + domain = split_domain[0] + keyvaluestr = ":".join(split_domain[1:]) if len(split_domain) > 2 else split_domain[1] + + # contains ["key1", "value1 key2", "value2"]-like structure + split_eq: list[str] = keyvaluestr.split("=") + if len(split_eq) < 2: + raise ValueError( + "Please input a valid state information string of format \"scenario1: key1=value1 key2=value2\"" + ) + + keyvalues: list[tuple[str, str]] = [] + + prev_key = split_eq[0] + for index in range(1, len(split_eq)): + splits: list[str] = ModelGenerator.__split_top_level(split_eq[index]) + splits = [s.strip() for s in splits] + prev_value: str = " ".join(splits[:-1]) if len(splits) > 1 else splits[0] + new_key: str = splits[-1] + + keyvalues.append((prev_key, prev_value)) + prev_key = new_key + + return (ScenarioInfo(scenario_name), StateInfo._create_state_with_prop(domain, keyvalues)) + + @staticmethod + def __split_top_level(text): + parts = [] + buf = [] + + depth = 0 + string_char = None + escape = False + + i = 0 + n = len(text) + + while i < n: + ch = text[i] + + # Inside string + if string_char: + buf.append(ch) + if escape: + escape = False + elif ch == "\\": + escape = True + elif ch == string_char: + string_char = None + i += 1 + continue + + # Start of string + if ch in ("'", '"'): + string_char = ch + buf.append(ch) + i += 1 + continue + + # Nesting + if ch in "([{": + depth += 1 + buf.append(ch) + i += 1 + continue + + if ch in ")]}": + depth -= 1 + buf.append(ch) + i += 1 + continue + + # Split condition: ", " at top level + if ch == "," and i + 1 < n and text[i + 1] == " " and depth == 0: + parts.append("".join(buf)) + buf.clear() + i += 2 # skip ", " + continue + + buf.append(ch) + i += 1 + + parts.append("".join(buf)) + return parts diff --git a/atest/resources/visualisation.resource b/atest/resources/visualisation.resource index 1e99eac1..a20f2315 100644 --- a/atest/resources/visualisation.resource +++ b/atest/resources/visualisation.resource @@ -5,70 +5,160 @@ Library Collections *** Keywords *** -test suite ${suite} - [Documentation] *model info* - ... :IN: None - ... :OUT: new suite - Set Suite Variable ${suite} - test suite ${suite} has trace info ${trace} [Documentation] *model info* - ... :IN: suite - ... :OUT: new trace_info - Variable Should Exist ${suite} - - ${trace_info} = Generate Trace Information + ... :IN: new trace_info | trace_info.current_trace=0 | trace_info.all_traces=0 | trace_info.name = ${trace} + ... :OUT: None + ${trace_info} = Generate Trace Information Set Suite Variable ${trace_info} - -trace info ${trace} has current trace ${current} - [Documentation] *model info* - ... :IN: trace_info - ... :OUT: trace_info.current_trace=[] + +trace info ${trace} + [Documentation] *model info* + ... :IN: trace_info.name == ${trace} + ... :OUT: trace_info.name == ${trace} + No operation + +the algorithm inserts '${scenario_name}' with state "${state_str}" + [Documentation] *model info* + ... :IN: trace_info + ... :OUT: trace_info.current_trace+=1 Variable Should Exist ${trace_info} + ${trace_info} = The Algorithm Inserts ${trace_info} ${scenario_name} ${state_str} -current trace ${current_trace} has a tuple 'scenario ${scenario}, state ${state}' +test suite ${suite} has ${n} steps in its current trace [Documentation] *model info* - ... :IN: trace_info.current_trace - ... :OUT: trace_info.current_trace.append((${scenario}, ${state})) - Variable Should Exist ${trace_info} + ... :IN: trace_info.current_trace==${n} + ... :OUT: trace_info.current_trace==${n} + No operation - ${trace_info} = Current Trace Contains ${trace_info} ${scenario} ${state} - Set Suite Variable ${trace_info} +test suite ${suite} has ${n} total traces + [Documentation] *model info* + ... :IN: trace_info.current_trace | trace_info.all_traces==${n}-1 + ... :OUT: trace_info.current_trace | trace_info.all_traces==${n}-1 + No operation -trace info ${trace} has all traces ${all} - [Documentation] *model info* - ... :IN: trace_info - ... :OUT: trace_info.all_traces=[] +${graph_type} graph ${name} is generated + [Documentation] *model info* + ... :IN: trace_info + ... :OUT: graph.name=${name}, graph.type=${graph_type} Variable Should Exist ${trace_info} -all traces ${all} has list ${list} + ${graph} = Generate Graph ${trace_info} ${graph_type} + ${network_visualiser} = Generate Network Graph ${graph} + Set Suite Variable ${graph} + Set Suite Variable ${network_visualiser} + +graph ${name} contains vertex '${scenario}' [Documentation] *model info* - ... :IN: trace_info - ... :OUT: trace_info.all_traces.append([]) - Variable Should Exist ${trace_info} + ... :IN: graph.name=${name} + ... :OUT: graph.name=${name} + Variable Should Exist ${graph} - ${trace_info} = All Traces Contains List ${trace_info} - Set Suite Variable ${trace_info} + ${result} = Graph Contains Vertex With No Text ${graph} ${scenario} + Run Keyword If ${result} == False + ... Fail Fail: Graph does not contain '${scenario}' -list ${list} has a tuple 'scenario ${scenario}, state ${state}' +graph ${name} contains vertex '${scenario}' with text "${text}" [Documentation] *model info* - ... :IN: trace_info.all_traces - ... :OUT: trace_info.all_traces.append((${scenario}, ${state})) - Variable Should Exist ${trace_info} + ... :IN: graph.name=${name} + ... :OUT: graph.name=${name} + Variable Should Exist ${graph} - ${trace_info} = All Traces Contains ${trace_info} ${scenario} ${state} - Set Suite Variable ${trace_info} + ${result} = Graph Contains Vertex With Text ${graph} ${scenario} ${text} + Run Keyword If "${result}" == "None" + ... Fail Fail: Graph does not contain '${scenario}' with "${text}" -test suite ${suite} contains trace info ${ti} - [Documentation] *model info* - ... :IN: suite, trace_info - ... :OUT: suite, trace_info - Variable Should Exist ${suite} - Variable Should Exist ${trace_info} +graph ${name} does not contain vertex '${scenario}' with text "${text}" + [Documentation] *model info* + ... :IN: graph.name=${name} + ... :OUT: graph.name=${name} + Variable Should Exist ${graph} + + ${result} = Graph Contains Vertex With Text ${graph} ${scenario} ${text} + Run Keyword If "${result}" != "None" + ... Fail Fail: Graph contains '${scenario}' with "${text}" + +graph ${name} has an edge from '${start_title}' to '${end_title}' + [Documentation] *model_info* + ... :IN: graph.name=${name} + ... :OUT: graph.name=${name} + Variable Should Exist ${graph} + ${start_node} = Graph Contains Vertex With Text ${graph} ${start_title} + ${end_node} = Graph Contains Vertex With Text ${graph} ${end_title} + + Should Not Be Equal ${start_node} ${None} Start node with title '${start_title}' not found + Should Not Be Equal ${end_node} ${None} End node with title '${end_title}' not found + + ${result} = Vertices Are Connected ${graph} ${start_node} ${end_node} + Run Keyword If ${result} != True + ... Fail Fail: Edges '${start_title}' and '${end_title}' exist in graph but are not connected! + +graph ${name} does not have an edge from '${start_title}' to '${end_title}' + [Documentation] *model_info* + ... :IN: graph.name=${name} + ... :OUT: graph.name=${name} + Variable Should Exist ${graph} + ${start_node} = Graph Contains Vertex With Text ${graph} ${start_title} + ${end_node} = Graph Contains Vertex With Text ${graph} ${end_title} + + ${result} = Vertices Are Connected ${graph} ${start_node} ${end_node} + Run Keyword If ${result} == True + ... Fail Fail: Edges '${start_title}' and '${end_title}' exist in graph and are connected, but shouldn't be! + +graph ${name} has vertices ${vertices_string} + [Documentation] *model_info* + ... :IN: graph.name=${name} + ... :OUT: graph.name=${name} + Variable Should Exist ${graph} + ${result} = Graph Contains Vertices Starting With ${graph} ${vertices_string} + Run Keyword If ${result} != True + ... Fail Graph did not contain all vertices ${vertices_string} + +vertex '${start_vertex}' is placed above '${end_vertex}' + [Documentation] *model_info* + ... :IN: None + ... :OUT: None + Variable Should Exist ${graph} + Variable Should Exist ${network_visualiser} + ${node1} = Get NodeID ${graph} ${start_vertex} + + Run Keyword If "${node1}" == "None" + ... Fail Vertex '${start_vertex}' does not exist + + ${node2} = Get NodeID ${graph} ${end_vertex} + + Run Keyword If "${node2}" == "None" + ... Fail Vertex '${end_vertex}' does not exist + + ${result1} = Get Vertex Y position ${network_visualiser} ${node1} + ${result2} = Get Vertex Y position ${network_visualiser} ${node2} + + Run Keyword If ${result1} < ${result2} + ... Fail Vertex '${start_vertex}' is below '${end_vertex}' + +the algorithm backtracks by ${n} step(s) + [Documentation] *model info* + ... :IN: trace_info.current_trace >= ${n} + ... :OUT: trace_info.current_trace-=${n} | trace_info.all_traces+=1 + Variable Should Exist ${trace_info} + + ${a} = Get Length Current Trace ${trace_info} + ${b} = Get Number of Backtracked Traces ${trace_info} + + ${trace_info} = Backtrack ${trace_info} ${n} + + ${i} = Get Length Current Trace ${trace_info} + ${j} = Get Number of Backtracked Traces ${trace_info} + + Run Keyword If ${a} - ${n} != ${i} + ... Fail Fail: Backtracking did not shorten current trace correctly + + Run Keyword If ${b} + 1 != ${j} + ... Fail Fail: Backtracking did not add new trace to all traces test suite ${suite} is exported to json [Documentation] *model info* - ... :IN: suite, trace_info + ... :IN: trace_info.current_trace, trace_info.all_traces>0 ... :OUT: new filepath Variable Should Exist ${suite} Variable Should Exist ${trace_info} @@ -78,20 +168,21 @@ test suite ${suite} is exported to json the file ${filename} exists [Documentation] *model info* - ... :IN: suite, filepath - ... :OUT: suite, filepath + ... :IN: filepath + ... :OUT: filepath Variable Should Exist ${filepath} ${result} = Check File Exists ${filepath} - Should Be Equal ${result} file exists + Run Keyword If ${result} == False + ... Fail Fail: File does not exist ${filename} is imported [Documentation] *model info* - ... :IN: suite, filepath + ... :IN: filepath ... :OUT: new new_trace_info Variable Should Exist ${filepath} - ${new_trace_info} = Import JSON File ${filepath} + ${new_trace_info} = Import Graph ${filepath} Set Suite Variable ${new_trace_info} trace info from ${filename} is the same as trace info ${trace} @@ -102,7 +193,14 @@ trace info from ${filename} is the same as trace info ${trace} Variable Should Exist ${new_trace_info} ${result} = Compare Trace Info ${trace_info} ${new_trace_info} - Should Be Equal ${result} imported model equals exported model + Run Keyword If ${result} == False + ... Fail Fail: Exported and Imported trace info are not the same + +${file} has been imported + [Documentation] *model info* + ... :IN: flag_cleanup + ... :OUT: flag_cleanup + No operation ${filename} is deleted [Documentation] *model info* @@ -119,16 +217,5 @@ ${filename} does not exist Variable Should Exist ${filepath} ${result} = Check File Exists ${filepath} - Should Be Equal ${result} file does not exist - -flag cleanup is set - [Documentation] *model info* - ... :IN: suite - ... :OUT: suite - Set Suite Variable ${flag_cleanup} True - -flag cleanup has been set - [Documentation] *model info* - ... :IN: flag_cleanup - ... :OUT: flag_cleanup - Variable Should Exist ${flag_cleanup} + Run Keyword If ${result} == True + ... Fail Fail: File exist \ No newline at end of file diff --git a/atest/robotMBT tests/10__visualisation_representations/0__setup.robot b/atest/robotMBT tests/10__visualisation_representations/0__setup.robot new file mode 100644 index 00000000..2c2da0bf --- /dev/null +++ b/atest/robotMBT tests/10__visualisation_representations/0__setup.robot @@ -0,0 +1,8 @@ +*** Settings *** +Library robotmbt processor=flatten + +*** Test Cases *** +Feature Setup + Given test suite s has trace info t + Then the algorithm inserts 'A1' with state "attr: states = ['a1'], special='!'" + And the algorithm inserts 'A2' with state "attr: states = ['a1', 'a2'], special='!'" \ No newline at end of file diff --git a/atest/robotMBT tests/10__visualisation_representations/1__scenario-delta-value.robot b/atest/robotMBT tests/10__visualisation_representations/1__scenario-delta-value.robot new file mode 100644 index 00000000..4f7322ef --- /dev/null +++ b/atest/robotMBT tests/10__visualisation_representations/1__scenario-delta-value.robot @@ -0,0 +1,27 @@ +*** Settings *** +Library robotmbt processor=flatten + +*** Test Cases *** +Vertex Scenario-Delta-Value graph + Given trace info t + When scenario-delta-value graph g is generated + Then graph g contains vertex 'start' + And graph g contains vertex 'A1' with text "attr: states = ['a1'], special='!'" + And graph g contains vertex 'A2' with text "attr: states = ['a1', 'a2']" + And graph g does not contain vertex 'A2' with text "attr: states = ['a1', 'a2'], special='!'" + +Edge Scenario-Delta-Value graph + Given trace info t + When scenario-delta-value graph g is generated + Then graph g has an edge from 'start' to 'A1' + And graph g has an edge from 'A1' to 'A2' + And graph g does not have an edge from 'start' to 'A2' + And graph g does not have an edge from 'A2' to 'A1' + And graph g does not have an edge from 'A2' to 'start' + +Visual location of vertices scenario-delta-value + Given trace info t + When scenario-delta-value graph g is generated + Then graph g has vertices 'start', 'A1', 'A2' + And vertex 'start' is placed above 'A1' + And vertex 'A1' is placed above 'A2' diff --git a/atest/robotMBT tests/10__visualisation_representations/__init__.robot b/atest/robotMBT tests/10__visualisation_representations/__init__.robot new file mode 100644 index 00000000..05729ca8 --- /dev/null +++ b/atest/robotMBT tests/10__visualisation_representations/__init__.robot @@ -0,0 +1,5 @@ +*** Settings *** +Documentation Test correctness all graph representations +Suite Setup Treat this test suite Model-based +Resource ../../resources/visualisation.resource +Library robotmbt processor=flatten \ No newline at end of file diff --git a/atest/robotMBT tests/10__visualisation_tests/C.2.3__export to JSON.robot b/atest/robotMBT tests/10__visualisation_tests/C.2.3__export to JSON.robot deleted file mode 100644 index bf3e309a..00000000 --- a/atest/robotMBT tests/10__visualisation_tests/C.2.3__export to JSON.robot +++ /dev/null @@ -1,35 +0,0 @@ -*** Settings *** -Documentation Export and import a test suite from and to JSON -... and check that the imported suite equals the -... exported suite. -Suite Setup Treat this test suite Model-based graph=scenario -Resource ../../resources/visualisation.resource -Library robotmbt - -*** Test Cases *** -Create test suite - Given test suite s - Then test suite s has trace info t - and trace info t has current trace c - and current trace c has a tuple 'scenario i, state p: v=1' - and current trace c has a tuple 'scenario j, state p: v=2' - and trace info t has all traces a - and all traces a has list l - and list l has a tuple 'scenario i, state p: v=2' - -Export test suite to json file - Given test suite s contains trace info t - When test suite s is exported to json - Then the file s.json exists - -Load json file into robotmbt - Given the file s.json exists - When s.json is imported - Then trace info from s.json is the same as trace info t - and flag cleanup is set - -Cleanup - Given the file s.json exists - and flag cleanup has been set - When s.json is deleted - Then s.json does not exist \ No newline at end of file diff --git a/atest/robotMBT tests/12__visualisation_export/01__export to JSON.robot b/atest/robotMBT tests/12__visualisation_export/01__export to JSON.robot new file mode 100644 index 00000000..fd5157bb --- /dev/null +++ b/atest/robotMBT tests/12__visualisation_export/01__export to JSON.robot @@ -0,0 +1,37 @@ +*** Settings *** +Documentation Export and import a test suite from and to JSON +... and check that the imported suite equals the +... exported suite. +Suite Setup Treat this test suite Model-based graph=scenario-state +Resource ../../resources/visualisation.resource +Library robotmbt + +*** Test Cases *** +Setup + Given test suite s has trace info t + When the algorithm inserts 'A1' with state "attr: states = ['a1'], special='!'" + And the algorithm inserts 'A2' with state "attr: states = ['a1', 'a2'], special='!'" + Then test suite s has 2 steps in its current trace + +Backtrack and Insert + Given test suite s has 2 steps in its current trace + When the algorithm backtracks by 1 step(s) + And the algorithm inserts 'B1' with state "attr: states=['a1', 'b1'], special='!'" + Then test suite s has 2 total traces + And test suite s has 2 steps in its current trace + +Export test suite to json file + Given test suite s has 2 total traces + When test suite s is exported to json + Then the file s.json exists + +Load json file into robotmbt + Given the file s.json exists + When s.sjon is imported + Then trace info from s.json is the same as trace info t + +Clean-up + Given the file s.json exists + And s.json has been imported + When s.sjon is deleted + Then s.json does not exist \ No newline at end of file diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index c8064434..d97e7d37 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -130,8 +130,8 @@ def _run_test_suite(self, seed: Any, graph: str, suite_name: str, to_json: bool) logger.warn(f'Could not initialise visualiser due to error!\n{e}') elif graph != '' and not VISUALISE: - logger.warn(f'Visualisation {graph} requested, but required dependencies are not installed.' - 'Refer to the README on how to install these dependencies.') + logger.warn(f'Visualisation {graph} requested, but required dependencies are not installed. ' + 'Refer to the README on how to install these dependencies. ') # a short trace without the need for repeating scenarios is preferred tracestate = self._try_to_reach_full_coverage(allow_duplicate_scenarios=False) diff --git a/robotmbt/visualise/graphs/abstractgraph.py b/robotmbt/visualise/graphs/abstractgraph.py index ba69a157..28341bf2 100644 --- a/robotmbt/visualise/graphs/abstractgraph.py +++ b/robotmbt/visualise/graphs/abstractgraph.py @@ -11,6 +11,9 @@ class AbstractGraph(ABC, Generic[NodeInfo, EdgeInfo]): def __init__(self, info: TraceInfo): + """ + Note that networkx's ids have to be of a serializable and hashable type after construction. + """ # The underlying storage - a NetworkX DiGraph self.networkx: nx.DiGraph = nx.DiGraph() diff --git a/robotmbt/visualise/graphs/reducedSDVgraph.py b/robotmbt/visualise/graphs/reducedSDVgraph.py index f1662d9c..100702d6 100644 --- a/robotmbt/visualise/graphs/reducedSDVgraph.py +++ b/robotmbt/visualise/graphs/reducedSDVgraph.py @@ -41,12 +41,28 @@ def __init__(self, info: TraceInfo): edge_data=lambda x, y: {'label': ''}) # TODO make generated label more obvious to be equivalence class nodes = self.networkx.nodes + + new_networkx: networkx.DiGraph = networkx.DiGraph() + + for node_id in self.networkx.nodes: + new_id: tuple[str, ...] = tuple(sorted(node_id)) + new_networkx.add_node(new_id) + new_networkx.nodes[new_id]['label'] = self.networkx.nodes[node_id]['label'] + + for (from_id, to_id) in self.networkx.edges: + new_from_id: tuple[str, ...] = tuple(sorted(from_id)) + new_to_id: tuple[str, ...] = tuple(sorted(to_id)) + new_networkx.add_edge(new_from_id, new_to_id) + new_networkx.edges[(new_from_id, new_to_id)]['label'] = self.networkx.edges[(from_id, to_id)]['label'] + for i in range(len(self.final_trace)): current_node = self.final_trace[i] for new_node in nodes: if current_node in new_node: - self.final_trace[i] = new_node - self.start_node = frozenset(['start']) + self.final_trace[i] = tuple(sorted(new_node)) + + self.networkx = new_networkx + self.start_node: tuple[str, ...] = tuple(['start']) @staticmethod def select_node_info(pairs: list[tuple[ScenarioInfo, StateInfo]], index: int) \ diff --git a/robotmbt/visualise/networkvisualiser.py b/robotmbt/visualise/networkvisualiser.py index 22acbed8..715ff3cc 100644 --- a/robotmbt/visualise/networkvisualiser.py +++ b/robotmbt/visualise/networkvisualiser.py @@ -81,6 +81,9 @@ def __init__(self, graph: AbstractGraph, suite_name: str, seed: str): # Create Sugiyama layout nodes, edges = self._create_layout() + self.node_dict: dict[str, Node] = {} + for node in nodes: + self.node_dict[node.node_id] = node # Keep track of arrows in the graph for scaling self.arrows = [] @@ -212,14 +215,18 @@ def _create_layout(self) -> tuple[list[Node], list[Edge]]: label = self.networkx.nodes[node_id]['label'] (x, y) = v.view.xy (w, h) = _calculate_dimensions(label) - ns.append(Node(node_id, label, x, y, w, h)) + ns.append(Node(node_id, label, x, -y, w, h)) es = [] for e in g.C[0].sE: from_id = e.v[0].data to_id = e.v[1].data label = self.networkx.edges[(from_id, to_id)]['label'] - points = e.view.points + points = [] + # invert y axis + for p in e.view.points: + points.append((p[0], -p[1])) + es.append(Edge(from_id, to_id, label, points)) return ns, es @@ -446,16 +453,6 @@ def _add_edge_to_sources(nodes: list[Node], edge: Edge, final_trace: list[str], start_x, start_y = 0, 0 end_x, end_y = 0, 0 - if isinstance(edge.from_node, frozenset): - from_id = tuple(sorted(edge.from_node)) - else: - from_id = edge.from_node - - if isinstance(edge.to_node, frozenset): - to_id = tuple(sorted(edge.to_node)) - else: - to_id = edge.to_node - # Add edges going through the calculated points for i in range(len(edge.points) - 1): start_x, start_y = edge.points[i] @@ -477,28 +474,28 @@ def _add_edge_to_sources(nodes: list[Node], edge: Edge, final_trace: list[str], if i < len(edge.points) - 2: # Middle part of edge without arrow - edge_part_source.data['from'].append(from_id) - edge_part_source.data['to'].append(to_id) + edge_part_source.data['from'].append(edge.from_node) + edge_part_source.data['to'].append(edge.to_node) edge_part_source.data['start_x'].append(start_x) - edge_part_source.data['start_y'].append(-start_y) + edge_part_source.data['start_y'].append(start_y) edge_part_source.data['end_x'].append(end_x) - edge_part_source.data['end_y'].append(-end_y) + edge_part_source.data['end_y'].append(end_y) edge_part_source.data['color'].append(FINAL_TRACE_EDGE_COLOR if in_final_trace else OTHER_EDGE_COLOR) else: # End of edge with arrow - edge_arrow_source.data['from'].append(from_id) - edge_arrow_source.data['to'].append(to_id) + edge_arrow_source.data['from'].append(edge.from_node) + edge_arrow_source.data['to'].append(edge.to_node) edge_arrow_source.data['start_x'].append(start_x) - edge_arrow_source.data['start_y'].append(-start_y) + edge_arrow_source.data['start_y'].append(start_y) edge_arrow_source.data['end_x'].append(end_x) - edge_arrow_source.data['end_y'].append(-end_y) + edge_arrow_source.data['end_y'].append(end_y) edge_arrow_source.data['color'].append(FINAL_TRACE_EDGE_COLOR if in_final_trace else OTHER_EDGE_COLOR) # Add the label - edge_label_source.data['from'].append(from_id) - edge_label_source.data['to'].append(to_id) + edge_label_source.data['from'].append(edge.from_node) + edge_label_source.data['to'].append(edge.to_node) edge_label_source.data['x'].append((start_x + end_x) / 2) - edge_label_source.data['y'].append(- (start_y + end_y) / 2) + edge_label_source.data['y'].append((start_y + end_y) / 2) edge_label_source.data['label'].append(edge.label) @@ -509,45 +506,35 @@ def _add_self_loop_to_sources(nodes: list[Node], edge: Edge, in_final_trace: boo """ connection = _get_connection_coordinates(nodes, edge.from_node) - if isinstance(edge.from_node, frozenset): - from_id = tuple(sorted(edge.from_node)) - else: - from_id = edge.from_node - - if isinstance(edge.to_node, frozenset): - to_id = tuple(sorted(edge.to_node)) - else: - to_id = edge.to_node - right_x, right_y = connection[1] # Add the Bézier curve - edge_bezier_source.data['from'].append(from_id) - edge_bezier_source.data['to'].append(to_id) + edge_bezier_source.data['from'].append(edge.from_node) + edge_bezier_source.data['to'].append(edge.to_node) edge_bezier_source.data['start_x'].append(right_x) - edge_bezier_source.data['start_y'].append(-right_y + 5) + edge_bezier_source.data['start_y'].append(right_y + 5) edge_bezier_source.data['end_x'].append(right_x) - edge_bezier_source.data['end_y'].append(-right_y - 5) + edge_bezier_source.data['end_y'].append(right_y - 5) edge_bezier_source.data['control1_x'].append(right_x + 25) - edge_bezier_source.data['control1_y'].append(-right_y + 25) + edge_bezier_source.data['control1_y'].append(right_y + 25) edge_bezier_source.data['control2_x'].append(right_x + 25) - edge_bezier_source.data['control2_y'].append(-right_y - 25) + edge_bezier_source.data['control2_y'].append(right_y - 25) edge_bezier_source.data['color'].append(FINAL_TRACE_EDGE_COLOR if in_final_trace else OTHER_EDGE_COLOR) # Add the arrow - edge_arrow_source.data['from'].append(from_id) - edge_arrow_source.data['to'].append(to_id) + edge_arrow_source.data['from'].append(edge.from_node) + edge_arrow_source.data['to'].append(edge.to_node) edge_arrow_source.data['start_x'].append(right_x + 0.001) - edge_arrow_source.data['start_y'].append(-right_y - 5.001) + edge_arrow_source.data['start_y'].append(right_y - 5.001) edge_arrow_source.data['end_x'].append(right_x) - edge_arrow_source.data['end_y'].append(-right_y - 5) + edge_arrow_source.data['end_y'].append(right_y - 5) edge_arrow_source.data['color'].append(FINAL_TRACE_EDGE_COLOR if in_final_trace else OTHER_EDGE_COLOR) # Add the label - edge_label_source.data['from'].append(from_id) - edge_label_source.data['to'].append(to_id) + edge_label_source.data['from'].append(edge.from_node) + edge_label_source.data['to'].append(edge.to_node) edge_label_source.data['x'].append(right_x + 25) - edge_label_source.data['y'].append(-right_y) + edge_label_source.data['y'].append(right_y) edge_label_source.data['label'].append(edge.label) @@ -556,25 +543,19 @@ def _add_node_to_sources(node: Node, final_trace: list[str], node_source: Column """ Add a node to the ColumnDataSources. """ - if isinstance(node.node_id, frozenset): - node_id = tuple(sorted(node.node_id)) - else: - node_id = node.node_id - - node_source.data['id'].append(node_id) + node_source.data['id'].append(node.node_id) node_source.data['x'].append(node.x) - node_source.data['y'].append(-node.y) + node_source.data['y'].append(node.y) node_source.data['w'].append(node.width) node_source.data['h'].append(node.height) node_source.data['color'].append( FINAL_TRACE_NODE_COLOR if node.node_id in final_trace else OTHER_NODE_COLOR) - node_label_source.data['id'].append(node_id) + node_label_source.data['id'].append(node.node_id) node_label_source.data['x'].append(node.x - node.width / 2 + HORIZONTAL_PADDING_WITHIN_NODES) - node_label_source.data['y'].append(-node.y) + node_label_source.data['y'].append(node.y) node_label_source.data['label'].append(node.label) - def _calculate_dimensions(label: str) -> tuple[float, float]: """ Calculate a node's dimensions based on its label and the given font size constant. diff --git a/utest/test_visualise_abstractgraph.py b/utest/test_visualise_abstractgraph.py deleted file mode 100644 index 7d966511..00000000 --- a/utest/test_visualise_abstractgraph.py +++ /dev/null @@ -1,39 +0,0 @@ -import unittest - -try: - import networkx as nx - from robotmbt.visualise.graphs.stategraph import StateGraph - from robotmbt.visualise.models import * - - VISUALISE = True -except ImportError: - VISUALISE = False - -if VISUALISE: - class TestVisualiseAbstractGraph(unittest.TestCase): - def test_abstract_graph_add_edge_labels_for_state_graph_self_loop(self): - """ - Testing the case where on an edge "scenario2" occurs twice - Without the "(rep x)" present - """ - info = TraceInfo() - - scenario1 = ScenarioInfo('scenario1') - scenario2 = ScenarioInfo('scenario2') - scenario3 = ScenarioInfo('scenario3') - - space1 = StateInfo._create_state_with_prop( - "prop", [("value", "some_value")]) - - info.update_trace(scenario1, space1, 1) - info.update_trace(scenario2, space1, 2) - info.update_trace(scenario3, space1, 3) - info.update_trace(scenario2, space1, 4) - - sg = StateGraph(info) - labels = nx.get_edge_attributes(sg.networkx, 'label') - self.assertEqual(labels[('node0', 'node0')], - 'scenario2\nscenario3') - -if __name__ == '__main__': - unittest.main() diff --git a/utest/test_visualise_scenariodeltavaluegraph.py b/utest/test_visualise_scenariodeltavaluegraph.py deleted file mode 100644 index 395be7a7..00000000 --- a/utest/test_visualise_scenariodeltavaluegraph.py +++ /dev/null @@ -1,57 +0,0 @@ -import unittest - - -try: - from robotmbt.visualise.graphs.scenariodeltavaluegraph import ScenarioDeltaValueGraph - from robotmbt.visualise.models import * - - VISUALISE = True -except ImportError: - VISUALISE = False - -if VISUALISE: - class TestVisualiseScenarioDeltaValueGraph(unittest.TestCase): - def test_scenario_delta_value_graph_init(self): - info = TraceInfo() - stg = ScenarioDeltaValueGraph(info) - - self.assertEqual(len(stg.networkx.nodes), 1) - self.assertEqual(len(stg.networkx.edges), 0) - - self.assertIn('start', stg.networkx.nodes) - self.assertEqual(stg.networkx.nodes['start']['label'], 'start') - - def test_scenario_delta_value_graph_ids_empty(self): - info = TraceInfo() - stg = ScenarioDeltaValueGraph(info) - - scenario = ScenarioInfo('test') - - node_id = stg._get_or_create_id((scenario, set({'x': '1'}.items()))) - - self.assertEqual(node_id, 'node0') - - def test_scenario_delta_value_graph_ids_duplicate_scenario(self): - info = TraceInfo() - stg = ScenarioDeltaValueGraph(info) - - s0 = ScenarioInfo('test') - s1 = ScenarioInfo('test') - - id0 = stg._get_or_create_id((s0, set({'x': '1'}.items()))) - id1 = stg._get_or_create_id((s1, set({'x': '1'}.items()))) - - self.assertEqual(id0, id1) - - def test_scenario_delta_value_graph_merge_same_scenario_update(self): - info = TraceInfo() - sti = StateInfo._create_state_with_prop("prop", [("x", "1")]) - info.update_trace(ScenarioInfo("incr x"), sti, 1) - info.update_trace(ScenarioInfo("set y"), StateInfo._create_state_with_prop("prop", [("y", "True")]), 2) - info.update_trace(ScenarioInfo("incr x"), sti, 1) - info.update_trace(ScenarioInfo("incr x"), StateInfo._create_state_with_prop("prop", [("x", "2")]), 2) - info.update_trace(ScenarioInfo("set y"), StateInfo._create_state_with_prop("prop", [("y", "True")]), 3) - sdvg = ScenarioDeltaValueGraph(info) - self.assertEqual(len(sdvg.networkx.nodes), 4) - - # TODO add more tests diff --git a/utest/test_visualise_scenariograph.py b/utest/test_visualise_scenariograph.py deleted file mode 100644 index 0044878d..00000000 --- a/utest/test_visualise_scenariograph.py +++ /dev/null @@ -1,248 +0,0 @@ -import unittest - -try: - from robotmbt.visualise.graphs.scenariograph import ScenarioGraph - from robotmbt.visualise.models import * - - VISUALISE = True -except ImportError: - VISUALISE = False - -if VISUALISE: - class TestVisualiseScenarioGraph(unittest.TestCase): - def test_scenario_graph_init(self): - info = TraceInfo() - sg = ScenarioGraph(info) - - self.assertEqual(len(sg.networkx.nodes), 1) - self.assertEqual(len(sg.networkx.edges), 0) - - self.assertIn('start', sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes['start']['label'], 'start') - - def test_scenario_graph_ids_empty(self): - info = TraceInfo() - sg = ScenarioGraph(info) - - s = ScenarioInfo('test') - - node_id = sg._get_or_create_id(s) - - self.assertEqual(node_id, 'node0') - - def test_scenario_graph_ids_duplicate_scenario(self): - info = TraceInfo() - sg = ScenarioGraph(info) - - s0 = ScenarioInfo('test') - s1 = ScenarioInfo('test') - - id0 = sg._get_or_create_id(s0) - id1 = sg._get_or_create_id(s1) - - self.assertEqual(id0, id1) - - def test_scenario_graph_ids_different_scenarios(self): - info = TraceInfo() - sg = ScenarioGraph(info) - - si00 = ScenarioInfo('test0') - si01 = ScenarioInfo('test0') - si10 = ScenarioInfo('test1') - si11 = ScenarioInfo('test1') - - id00 = sg._get_or_create_id(si00) - id01 = sg._get_or_create_id(si01) - id10 = sg._get_or_create_id(si10) - id11 = sg._get_or_create_id(si11) - - self.assertEqual(id00, 'node0') - self.assertEqual(id01, 'node0') - self.assertEqual(id00, id01) - - self.assertEqual(id10, 'node1') - self.assertEqual(id11, 'node1') - self.assertEqual(id10, id11) - - self.assertNotEqual(id00, id10) - self.assertNotEqual(id00, id11) - self.assertNotEqual(id01, id10) - self.assertNotEqual(id01, id11) - - def test_scenario_graph_add_new_node(self): - info = TraceInfo() - sg = ScenarioGraph(info) - - sg.ids['test'] = ScenarioInfo('test') - sg._add_node('test') - - self.assertEqual(len(sg.networkx.nodes), 2) - - self.assertIn('start', sg.networkx.nodes) - self.assertIn('test', sg.networkx.nodes) - - self.assertEqual(sg.networkx.nodes['start']['label'], 'start') - self.assertEqual(sg.networkx.nodes['test']['label'], 'test') - - def test_scenario_graph_add_existing_node(self): - info = TraceInfo() - sg = ScenarioGraph(info) - - self.assertEqual(len(sg.networkx.nodes), 1) - self.assertIn('start', sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes['start']['label'], 'start') - - sg._add_node('start') - - self.assertEqual(len(sg.networkx.nodes), 1) - self.assertIn('start', sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes['start']['label'], 'start') - - def test_scenario_graph_update_nodes(self): - info = TraceInfo() - - scenario0 = ScenarioInfo('0') - scenario1 = ScenarioInfo('1') - scenario2 = ScenarioInfo('2') - - info.update_trace(scenario0, StateInfo(ModelSpace()), 1) - info.update_trace(scenario1, StateInfo(ModelSpace()), 2) - info.update_trace(scenario2, StateInfo(ModelSpace()), 3) - - sg = ScenarioGraph(info) - - self.assertEqual(len(sg.networkx.nodes), 4) - - self.assertIn('start', sg.networkx.nodes) - self.assertIn('node0', sg.networkx.nodes) - self.assertIn('node1', sg.networkx.nodes) - self.assertIn('node2', sg.networkx.nodes) - - self.assertEqual(sg.networkx.nodes['start']['label'], 'start') - self.assertEqual(sg.networkx.nodes['node0']['label'], '0') - self.assertEqual(sg.networkx.nodes['node1']['label'], '1') - self.assertEqual(sg.networkx.nodes['node2']['label'], '2') - - def test_scenario_graph_update_edges(self): - info = TraceInfo() - - scenario0 = ScenarioInfo('0') - scenario1 = ScenarioInfo('1') - scenario2 = ScenarioInfo('2') - - info.update_trace(scenario0, StateInfo(ModelSpace()), 1) - info.update_trace(scenario1, StateInfo(ModelSpace()), 2) - info.update_trace(scenario2, StateInfo(ModelSpace()), 3) - - sg = ScenarioGraph(info) - - self.assertEqual(len(sg.networkx.edges), 3) - - self.assertIn(('start', 'node0'), sg.networkx.edges) - self.assertIn(('node0', 'node1'), sg.networkx.edges) - self.assertIn(('node1', 'node2'), sg.networkx.edges) - - self.assertEqual(sg.networkx.edges[('start', 'node0')]['label'], '') - self.assertEqual(sg.networkx.edges[('node0', 'node1')]['label'], '') - self.assertEqual(sg.networkx.edges[('node1', 'node2')]['label'], '') - - def test_scenario_graph_update_single_node(self): - info = TraceInfo() - - scenario0 = ScenarioInfo('test') - - info.update_trace(scenario0, StateInfo(ModelSpace()), 1) - - sg = ScenarioGraph(info) - - self.assertEqual(len(sg.networkx.nodes), 2) - self.assertEqual(len(sg.networkx.edges), 1) - - self.assertIn('start', sg.networkx.nodes) - self.assertIn('node0', sg.networkx.nodes) - - self.assertEqual(sg.networkx.nodes['start']['label'], 'start') - self.assertEqual(sg.networkx.nodes['node0']['label'], 'test') - - self.assertIn(('start', 'node0'), sg.networkx.edges) - - self.assertEqual(sg.networkx.edges[('start', 'node0')]['label'], '') - - def test_scenario_graph_update_backtrack(self): - info = TraceInfo() - - scenario0 = ScenarioInfo('0') - scenario1 = ScenarioInfo('1') - scenario2 = ScenarioInfo('2') - scenario3 = ScenarioInfo('3') - - info.update_trace(scenario0, StateInfo(ModelSpace()), 1) - info.update_trace(scenario1, StateInfo(ModelSpace()), 2) - info.update_trace(scenario2, StateInfo(ModelSpace()), 3) - info.update_trace(scenario1, StateInfo(ModelSpace()), 2) - info.update_trace(scenario0, StateInfo(ModelSpace()), 1) - info.update_trace(scenario3, StateInfo(ModelSpace()), 2) - info.update_trace(scenario1, StateInfo(ModelSpace()), 3) - info.update_trace(scenario2, StateInfo(ModelSpace()), 4) - - sg = ScenarioGraph(info) - - self.assertEqual(len(sg.networkx.nodes), 5) - self.assertEqual(len(sg.networkx.edges), 5) - - def test_scenario_graph_final_trace_normal(self): - info = TraceInfo() - - scenario0 = ScenarioInfo('0') - scenario1 = ScenarioInfo('1') - scenario2 = ScenarioInfo('2') - - info.update_trace(scenario0, StateInfo(ModelSpace()), 1) - info.update_trace(scenario1, StateInfo(ModelSpace()), 2) - info.update_trace(scenario2, StateInfo(ModelSpace()), 3) - - sg = ScenarioGraph(info) - - trace = sg.get_final_trace() - - # confirm they are proper ids - for node in trace: - self.assertIn(node, sg.networkx.nodes) - - # confirm the edges exist - for i in range(0, len(trace) - 1): - self.assertIn((trace[i], trace[i + 1]), sg.networkx.edges) - - self.assertEqual(trace, ['start', 'node0', 'node1', 'node2']) - - def test_scenario_graph_final_trace_backtrack(self): - info = TraceInfo() - - scenario0 = ScenarioInfo('0') - scenario1 = ScenarioInfo('1') - scenario2 = ScenarioInfo('2') - - info.update_trace(scenario0, StateInfo(ModelSpace()), 1) - info.update_trace(scenario1, StateInfo(ModelSpace()), 2) - info.update_trace(scenario2, StateInfo(ModelSpace()), 3) - info.update_trace(scenario1, StateInfo(ModelSpace()), 2) - info.update_trace(scenario0, StateInfo(ModelSpace()), 1) - info.update_trace(scenario2, StateInfo(ModelSpace()), 2) - info.update_trace(scenario1, StateInfo(ModelSpace()), 3) - - sg = ScenarioGraph(info) - - trace = sg.get_final_trace() - - # confirm they are proper ids - for node in trace: - self.assertIn(node, sg.networkx.nodes) - - # confirm the edges exist - for i in range(0, len(trace) - 1): - self.assertIn((trace[i], trace[i + 1]), sg.networkx.edges) - - self.assertEqual(trace, ['start', 'node0', 'node2', 'node1']) - -if __name__ == '__main__': - unittest.main() diff --git a/utest/test_visualise_scenariostategraph.py b/utest/test_visualise_scenariostategraph.py deleted file mode 100644 index 9fd40dd7..00000000 --- a/utest/test_visualise_scenariostategraph.py +++ /dev/null @@ -1,349 +0,0 @@ -import unittest -from typing import Any - -try: - from robotmbt.visualise.graphs.scenariostategraph import ScenarioStateGraph - from robotmbt.visualise.models import * - - VISUALISE = True -except ImportError: - VISUALISE = False - -if VISUALISE: - class TestVisualiseScenarioGraph(unittest.TestCase): - def test_scenario_state_graph_init(self): - info = TraceInfo() - stg = ScenarioStateGraph(info) - - self.assertEqual(len(stg.networkx.nodes), 1) - self.assertEqual(len(stg.networkx.edges), 0) - - self.assertIn('start', stg.networkx.nodes) - self.assertEqual(stg.networkx.nodes['start']['label'], 'start') - - def test_scenario_state_graph_ids_empty(self): - info = TraceInfo() - stg = ScenarioStateGraph(info) - - scenario = ScenarioInfo('test') - state = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - - node_id = stg._get_or_create_id((scenario, state)) - - self.assertEqual(node_id, 'node0') - - def test_scenario_state_graph_ids_duplicate_scenario(self): - info = TraceInfo() - stg = ScenarioStateGraph(info) - - s0 = ScenarioInfo('test') - s1 = ScenarioInfo('test') - st0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - st1 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - - id0 = stg._get_or_create_id((s0, st0)) - id1 = stg._get_or_create_id((s1, st1)) - - self.assertEqual(id0, id1) - - def test_scenario_state_graph_ids_different_scenarios(self): - info = TraceInfo() - stg = ScenarioStateGraph(info) - - s00 = ScenarioInfo('test0') - s01 = ScenarioInfo('test0') - s10 = ScenarioInfo('test1') - s11 = ScenarioInfo('test1') - - state = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - - id00 = stg._get_or_create_id((s00, state)) - id01 = stg._get_or_create_id((s01, state)) - id10 = stg._get_or_create_id((s10, state)) - id11 = stg._get_or_create_id((s11, state)) - - self.assertEqual(id00, 'node0') - self.assertEqual(id01, 'node0') - self.assertEqual(id00, id01) - - self.assertEqual(id10, 'node1') - self.assertEqual(id11, 'node1') - self.assertEqual(id10, id11) - - self.assertNotEqual(id00, id10) - self.assertNotEqual(id00, id11) - self.assertNotEqual(id01, id10) - self.assertNotEqual(id01, id11) - - def test_scenario_state_graph_ids_different_states(self): - info = TraceInfo() - stg = ScenarioStateGraph(info) - - scenario = ScenarioInfo('test') - - s00 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - s01 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - s10 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) - s11 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) - - id00 = stg._get_or_create_id((scenario, s00)) - id01 = stg._get_or_create_id((scenario, s01)) - id10 = stg._get_or_create_id((scenario, s10)) - id11 = stg._get_or_create_id((scenario, s11)) - - self.assertEqual(id00, 'node0') - self.assertEqual(id01, 'node0') - self.assertEqual(id00, id01) - - self.assertEqual(id10, 'node1') - self.assertEqual(id11, 'node1') - self.assertEqual(id10, id11) - - self.assertNotEqual(id00, id10) - self.assertNotEqual(id00, id11) - self.assertNotEqual(id01, id10) - self.assertNotEqual(id01, id11) - - def test_scenario_state_graph_ids_different_scenario_state(self): - info = TraceInfo() - stg = ScenarioStateGraph(info) - - s00 = ScenarioInfo('test0') - s01 = ScenarioInfo('test1') - s10 = ScenarioInfo('test0') - s11 = ScenarioInfo('test1') - - st00 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - st01 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - st10 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) - st11 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) - - id00 = stg._get_or_create_id((s00, st00)) - id01 = stg._get_or_create_id((s01, st01)) - id10 = stg._get_or_create_id((s10, st10)) - id11 = stg._get_or_create_id((s11, st11)) - - self.assertEqual(id00, 'node0') - self.assertEqual(id01, 'node1') - self.assertEqual(id10, 'node2') - self.assertEqual(id11, 'node3') - - self.assertNotEqual(id00, id01) - self.assertNotEqual(id00, id10) - self.assertNotEqual(id00, id11) - self.assertNotEqual(id01, id10) - self.assertNotEqual(id01, id11) - self.assertNotEqual(id10, id11) - - def test_scenario_state_graph_add_new_node(self): - info = TraceInfo() - stg = ScenarioStateGraph(info) - - self.assertEqual(len(stg.networkx.nodes), 1) - - self.assertIn('start', stg.networkx.nodes) - self.assertNotIn('test', stg.networkx.nodes) - - scenario = ScenarioInfo('test') - state = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - - stg.ids['test'] = (scenario, state) - stg._add_node('test') - - self.assertEqual(len(stg.networkx.nodes), 2) - - self.assertIn('start', stg.networkx.nodes) - self.assertIn('test', stg.networkx.nodes) - - self.assertEqual(stg.networkx.nodes['start']['label'], 'start') - self.assertIn('test', stg.networkx.nodes['test']['label']) - self.assertIn('prop:', stg.networkx.nodes['test']['label']) - self.assertIn('value=some_value', stg.networkx.nodes['test']['label']) - - def test_scenario_state_graph_add_existing_node(self): - info = TraceInfo() - stg = ScenarioStateGraph(info) - - self.assertEqual(len(stg.networkx.nodes), 1) - self.assertIn('start', stg.networkx.nodes) - self.assertEqual(stg.networkx.nodes['start']['label'], 'start') - - stg._add_node('start') - - self.assertEqual(len(stg.networkx.nodes), 1) - self.assertIn('start', stg.networkx.nodes) - self.assertEqual(stg.networkx.nodes['start']['label'], 'start') - - def test_scenario_state_graph_update_single(self): - info = TraceInfo() - - scenario = ScenarioInfo('1') - - space = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - - info.update_trace(scenario, space, 1) - - stg = ScenarioStateGraph(info) - - self.assertEqual(len(stg.networkx.nodes), 2) - self.assertEqual(len(stg.networkx.edges), 1) - - self.assertEqual(stg.networkx.nodes['start']['label'], 'start') - - self.assertIn('1', stg.networkx.nodes['node0']['label']) - self.assertIn('prop:', stg.networkx.nodes['node0']['label']) - self.assertIn('value=some_value', stg.networkx.nodes['node0']['label']) - - self.assertIn(('start', 'node0'), stg.networkx.edges) - self.assertEqual(stg.networkx.edges[('start', 'node0')]['label'], '') - - def test_scenario_state_graph_update_multi_loop(self): - info = TraceInfo() - - scenario1 = ScenarioInfo('1') - scenario2 = ScenarioInfo('2') - - space1 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - space2 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) - - info.update_trace(scenario1, space1, 1) - info.update_trace(scenario2, space2, 2) - info.update_trace(scenario1, space1, 3) - - stg = ScenarioStateGraph(info) - - self.assertEqual(len(stg.networkx.nodes), 3) - self.assertEqual(len(stg.networkx.edges), 3) - - self.assertEqual(stg.networkx.nodes['start']['label'], 'start') - - self.assertIn('1', stg.networkx.nodes['node0']['label']) - self.assertIn('2', stg.networkx.nodes['node1']['label']) - self.assertIn('prop:', stg.networkx.nodes['node0']['label']) - self.assertIn('prop:', stg.networkx.nodes['node1']['label']) - self.assertIn('value=some_value', stg.networkx.nodes['node0']['label']) - self.assertIn('value=another_value', stg.networkx.nodes['node1']['label']) - - self.assertIn(('start', 'node0'), stg.networkx.edges) - self.assertIn(('node0', 'node1'), stg.networkx.edges) - self.assertIn(('node1', 'node0'), stg.networkx.edges) - - self.assertEqual(stg.networkx.edges[('start', 'node0')]['label'], '') - self.assertEqual(stg.networkx.edges[('node0', 'node1')]['label'], '') - self.assertEqual(stg.networkx.edges[('node1', 'node0')]['label'], '') - - def test_scenario_state_graph_update_self_loop(self): - info = TraceInfo() - - scenario = ScenarioInfo('1') - - space = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - - info.update_trace(scenario, space, 1) - info.update_trace(scenario, space, 2) - - stg = ScenarioStateGraph(info) - - self.assertEqual(len(stg.networkx.nodes), 2) - self.assertEqual(len(stg.networkx.edges), 2) - - self.assertEqual(stg.networkx.nodes['start']['label'], 'start') - - self.assertIn('1', stg.networkx.nodes['node0']['label']) - self.assertIn('prop:', stg.networkx.nodes['node0']['label']) - self.assertIn('value=some_value', stg.networkx.nodes['node0']['label']) - - self.assertIn(('start', 'node0'), stg.networkx.edges) - self.assertIn(('node0', 'node0'), stg.networkx.edges) - - self.assertEqual(stg.networkx.edges[('start', 'node0')]['label'], '') - self.assertEqual(stg.networkx.edges[('node0', 'node0')]['label'], '') - - def test_scenario_state_graph_update_backtrack(self): - info = TraceInfo() - - scenario0 = ScenarioInfo('0') - scenario1 = ScenarioInfo('1') - scenario2 = ScenarioInfo('2') - - space0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - space1 = StateInfo._create_state_with_prop("prop", [("value", "other_value")]) - space2 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) - space3 = StateInfo._create_state_with_prop("prop", [("value", "more_value")]) - space4 = StateInfo._create_state_with_prop("prop", [("value", "yet_another_value")]) - - info.update_trace(scenario0, space0, 1) - info.update_trace(scenario1, space1, 2) - info.update_trace(scenario2, space2, 3) - info.update_trace(scenario1, space1, 2) - info.update_trace(scenario0, space0, 1) - info.update_trace(scenario2, space3, 2) - info.update_trace(scenario1, space4, 3) - - stg = ScenarioStateGraph(info) - - self.assertEqual(len(stg.networkx.nodes), 6) - self.assertEqual(len(stg.networkx.edges), 5) - - def test_scenario_state_graph_final_trace_normal(self): - info = TraceInfo() - - scenario0 = ScenarioInfo('0') - scenario1 = ScenarioInfo('1') - scenario2 = ScenarioInfo('2') - - space0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - space1 = StateInfo._create_state_with_prop("prop", [("value", "other_value")]) - space2 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) - - info.update_trace(scenario0, space0, 1) - info.update_trace(scenario1, space1, 2) - info.update_trace(scenario2, space2, 3) - - stg = ScenarioStateGraph(info) - - trace = stg.get_final_trace() - - for node in trace: - self.assertIn(node, stg.networkx.nodes) - - for i in range(0, len(trace) - 1): - self.assertIn((trace[i], trace[i + 1]), stg.networkx.edges) - - self.assertEqual(trace, ['start', 'node0', 'node1', 'node2']) - - def test_scenario_state_graph_final_trace_backtrack(self): - info = TraceInfo() - - scenario0 = ScenarioInfo('0') - scenario1 = ScenarioInfo('1') - scenario2 = ScenarioInfo('2') - - space0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - space1 = StateInfo._create_state_with_prop("prop", [("value", "other_value")]) - space2 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) - space3 = StateInfo._create_state_with_prop("prop", [("value", "more_value")]) - space4 = StateInfo._create_state_with_prop("prop", [("value", "yet_another_value")]) - - info.update_trace(scenario0, space0, 1) - info.update_trace(scenario1, space1, 2) - info.update_trace(scenario2, space2, 3) - info.update_trace(scenario1, space1, 2) - info.update_trace(scenario0, space0, 1) - info.update_trace(scenario2, space3, 2) - info.update_trace(scenario1, space4, 3) - - stg = ScenarioStateGraph(info) - - trace = stg.get_final_trace() - - for node in trace: - self.assertIn(node, stg.networkx.nodes) - - for i in range(0, len(trace) - 1): - self.assertIn((trace[i], trace[i + 1]), stg.networkx.edges) - - self.assertEqual(trace, ['start', 'node0', 'node3', 'node4']) - -if __name__ == '__main__': - unittest.main() diff --git a/utest/test_visualise_stategraph.py b/utest/test_visualise_stategraph.py deleted file mode 100644 index 4bfdbe1e..00000000 --- a/utest/test_visualise_stategraph.py +++ /dev/null @@ -1,318 +0,0 @@ -import unittest - -try: - from robotmbt.visualise.graphs.stategraph import StateGraph - from robotmbt.visualise.models import * - - VISUALISE = True -except ImportError: - VISUALISE = False - -if VISUALISE: - class TestVisualiseStateGraph(unittest.TestCase): - def test_state_graph_init(self): - info = TraceInfo() - sg = StateGraph(info) - - self.assertEqual(len(sg.networkx.nodes), 1) - self.assertEqual(len(sg.networkx.edges), 0) - - self.assertIn('start', sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes['start']['label'], 'start') - - def test_state_graph_ids_empty(self): - info = TraceInfo() - sg = StateGraph(info) - - si = StateInfo(ModelSpace()) - - node_id = sg._get_or_create_id(si) - - self.assertEqual(node_id, 'node0') - - def test_state_graph_ids_duplicate_state(self): - info = TraceInfo() - sg = StateGraph(info) - - s0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - s1 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - - id0 = sg._get_or_create_id(s0) - id1 = sg._get_or_create_id(s1) - - self.assertEqual(id0, id1) - - def test_state_graph_ids_different_states(self): - info = TraceInfo() - sg = StateGraph(info) - - s00 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - s01 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - s10 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) - s11 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) - - id00 = sg._get_or_create_id(s00) - id01 = sg._get_or_create_id(s01) - id10 = sg._get_or_create_id(s10) - id11 = sg._get_or_create_id(s11) - - self.assertEqual(id00, 'node0') - self.assertEqual(id01, 'node0') - self.assertEqual(id00, id01) - - self.assertEqual(id10, 'node1') - self.assertEqual(id11, 'node1') - self.assertEqual(id10, id11) - - self.assertNotEqual(id00, id10) - self.assertNotEqual(id00, id11) - self.assertNotEqual(id01, id10) - self.assertNotEqual(id01, id11) - - def test_state_graph_add_new_node(self): - info = TraceInfo() - sg = StateGraph(info) - - self.assertEqual(len(sg.networkx.nodes), 1) - - self.assertIn('start', sg.networkx.nodes) - self.assertNotIn('test', sg.networkx.nodes) - - sg.ids['test'] = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - sg._add_node('test') - - self.assertEqual(len(sg.networkx.nodes), 2) - - self.assertIn('start', sg.networkx.nodes) - self.assertIn('test', sg.networkx.nodes) - - self.assertEqual(sg.networkx.nodes['start']['label'], 'start') - self.assertIn('prop:', sg.networkx.nodes['test']['label']) - self.assertIn('value=some_value', sg.networkx.nodes['test']['label']) - - def test_state_graph_add_existing_node(self): - info = TraceInfo() - sg = StateGraph(info) - - self.assertEqual(len(sg.networkx.nodes), 1) - self.assertIn('start', sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes['start']['label'], 'start') - - sg._add_node('start') - - self.assertEqual(len(sg.networkx.nodes), 1) - self.assertIn('start', sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes['start']['label'], 'start') - - def test_state_graph_update_single(self): - info = TraceInfo() - - scenario = ScenarioInfo('1') - space = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - info.update_trace(scenario, space, 1) - - sg = StateGraph(info) - - self.assertEqual(sg.networkx.nodes['start']['label'], 'start') - - self.assertIn('prop:', sg.networkx.nodes['node0']['label']) - self.assertIn('value=some_value', sg.networkx.nodes['node0']['label']) - - self.assertIn(('start', 'node0'), sg.networkx.edges) - self.assertEqual(sg.networkx.edges[('start', 'node0')]['label'], '1') - - def test_state_graph_update_multi(self): - info = TraceInfo() - - scenario1 = ScenarioInfo('1') - scenario2 = ScenarioInfo('2') - scenario3 = ScenarioInfo('3') - - space1 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - space2 = StateInfo._create_state_with_prop("prop", [("value", "other_value")]) - space3 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) - - info.update_trace(scenario1, space1, 1) - info.update_trace(scenario2, space2, 2) - info.update_trace(scenario3, space3, 3) - - sg = StateGraph(info) - - self.assertEqual(len(sg.networkx.nodes), 4) - self.assertEqual(len(sg.networkx.edges), 3) - - self.assertEqual(sg.networkx.nodes['start']['label'], 'start') - self.assertIn('prop:', sg.networkx.nodes['node0']['label']) - self.assertIn('prop:', sg.networkx.nodes['node1']['label']) - self.assertIn('prop:', sg.networkx.nodes['node2']['label']) - self.assertIn('value=some_value', sg.networkx.nodes['node0']['label']) - self.assertIn('value=other_value', sg.networkx.nodes['node1']['label']) - self.assertIn('value=another_value', sg.networkx.nodes['node2']['label']) - - self.assertIn(('start', 'node0'), sg.networkx.edges) - self.assertIn(('node0', 'node1'), sg.networkx.edges) - self.assertIn(('node1', 'node2'), sg.networkx.edges) - - self.assertEqual(sg.networkx.edges[('start', 'node0')]['label'], '1') - self.assertEqual(sg.networkx.edges[('node0', 'node1')]['label'], '2') - self.assertEqual(sg.networkx.edges[('node1', 'node2')]['label'], '3') - - def test_state_graph_update_multi_loop(self): - info = TraceInfo() - - scenario0 = ScenarioInfo('0') - scenario1 = ScenarioInfo('1') - scenario2 = ScenarioInfo('2') - - space0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - space1 = StateInfo._create_state_with_prop("prop", [("value", "other_value")]) - - info.update_trace(scenario0, space0, 1) - info.update_trace(scenario1, space1, 2) - info.update_trace(scenario2, space0, 3) - - sg = StateGraph(info) - - self.assertEqual(len(sg.networkx.nodes), 3) - self.assertEqual(len(sg.networkx.edges), 3) - - self.assertEqual(sg.networkx.nodes['start']['label'], 'start') - self.assertIn('prop:', sg.networkx.nodes['node0']['label']) - self.assertIn('prop:', sg.networkx.nodes['node1']['label']) - self.assertIn('value=some_value', sg.networkx.nodes['node0']['label']) - self.assertIn('value=other_value', sg.networkx.nodes['node1']['label']) - - self.assertIn(('start', 'node0'), sg.networkx.edges) - self.assertIn(('node0', 'node1'), sg.networkx.edges) - self.assertIn(('node1', 'node0'), sg.networkx.edges) - - self.assertEqual(sg.networkx.edges[('start', 'node0')]['label'], '0') - self.assertEqual(sg.networkx.edges[('node0', 'node1')]['label'], '1') - self.assertEqual(sg.networkx.edges[('node1', 'node0')]['label'], '2') - - def test_state_graph_update_self_loop(self): - info = TraceInfo() - - scenario1 = ScenarioInfo('1') - scenario2 = ScenarioInfo('2') - - space = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - - info.update_trace(scenario1, space, 1) - info.update_trace(scenario2, space, 2) - - sg = StateGraph(info) - - self.assertEqual(len(sg.networkx.nodes), 2) - self.assertEqual(len(sg.networkx.edges), 2) - - self.assertEqual(sg.networkx.nodes['start']['label'], 'start') - self.assertIn('prop:', sg.networkx.nodes['node0']['label']) - self.assertIn('value=some_value', sg.networkx.nodes['node0']['label']) - - self.assertIn(('start', 'node0'), sg.networkx.edges) - self.assertIn(('node0', 'node0'), sg.networkx.edges) - - self.assertEqual(sg.networkx.edges[('start', 'node0')]['label'], '1') - self.assertEqual(sg.networkx.edges[('node0', 'node0')]['label'], '2') - - def test_state_graph_update_backtrack(self): - info = TraceInfo() - - scenario0 = ScenarioInfo('0') - scenario1 = ScenarioInfo('1') - scenario2 = ScenarioInfo('2') - - space0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - space1 = StateInfo._create_state_with_prop("prop", [("value", "other_value")]) - space2 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) - space3 = StateInfo._create_state_with_prop("prop", [("value", "more_value")]) - space4 = StateInfo._create_state_with_prop("prop", [("value", "yet_another_value")]) - - info.update_trace(scenario0, space0, 1) - info.update_trace(scenario1, space1, 2) - info.update_trace(scenario2, space2, 3) - info.update_trace(scenario1, space1, 2) - info.update_trace(scenario0, space0, 1) - info.update_trace(scenario2, space3, 2) - info.update_trace(scenario1, space4, 3) - - sg = StateGraph(info) - - self.assertEqual(len(sg.networkx.nodes), 6) - self.assertEqual(len(sg.networkx.edges), 5) - - def test_state_graph_final_trace_normal(self): - info = TraceInfo() - - scenario0 = ScenarioInfo('0') - scenario1 = ScenarioInfo('1') - scenario2 = ScenarioInfo('2') - - space0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - space1 = StateInfo._create_state_with_prop("prop", [("value", "other_value")]) - space2 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) - - info.update_trace(scenario0, space0, 1) - info.update_trace(scenario1, space1, 2) - info.update_trace(scenario2, space2, 3) - - sg = StateGraph(info) - trace = sg.get_final_trace() - - for node in trace: - self.assertIn(node, sg.networkx.nodes) - - for i in range(0, len(trace) - 1): - self.assertIn((trace[i], trace[i + 1]), sg.networkx.edges) - if i > 0: - self.assertEqual(sg.networkx.edges[(trace[i], trace[i + 1])]['label'], str(i)) - - self.assertEqual(sg.networkx.nodes[trace[0]]['label'], 'start') - self.assertIn('prop:', sg.networkx.nodes[trace[1]]['label']) - self.assertIn('value=some_value', sg.networkx.nodes[trace[1]]['label']) - self.assertIn('prop:', sg.networkx.nodes[trace[2]]['label']) - self.assertIn('value=other_value', sg.networkx.nodes[trace[2]]['label']) - self.assertIn('prop:', sg.networkx.nodes[trace[3]]['label']) - self.assertIn('value=another_value', sg.networkx.nodes[trace[3]]['label']) - - def test_state_graph_final_trace_backtrack(self): - info = TraceInfo() - - scenario0 = ScenarioInfo('0') - scenario1 = ScenarioInfo('1') - scenario2 = ScenarioInfo('2') - - space0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - space1 = StateInfo._create_state_with_prop("prop", [("value", "other_value")]) - space2 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) - space3 = StateInfo._create_state_with_prop("prop", [("value", "more_value")]) - space4 = StateInfo._create_state_with_prop("prop", [("value", "yet_another_value")]) - - info.update_trace(scenario0, space0, 1) - info.update_trace(scenario1, space1, 2) - info.update_trace(scenario2, space2, 3) - info.update_trace(scenario1, space1, 2) - info.update_trace(scenario0, space0, 1) - info.update_trace(scenario2, space3, 2) - info.update_trace(scenario1, space4, 3) - - sg = StateGraph(info) - trace = sg.get_final_trace() - - for node in trace: - self.assertIn(node, sg.networkx.nodes) - - for i in range(0, len(trace) - 1): - self.assertIn((trace[i], trace[i + 1]), sg.networkx.edges) - - self.assertEqual(sg.networkx.nodes[trace[0]]['label'], 'start') - self.assertIn('prop:', sg.networkx.nodes[trace[1]]['label']) - self.assertIn('value=some_value', sg.networkx.nodes[trace[1]]['label']) - self.assertIn('prop:', sg.networkx.nodes[trace[2]]['label']) - self.assertIn('value=more_value', sg.networkx.nodes[trace[2]]['label']) - self.assertIn('prop:', sg.networkx.nodes[trace[3]]['label']) - self.assertIn('value=yet_another_value', sg.networkx.nodes[trace[3]]['label']) - -if __name__ == '__main__': - unittest.main() From 2b9547f749c8e9629e17a4536b699865ee903a56 Mon Sep 17 00:00:00 2001 From: Jonathan <61787386+JWillegers@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:05:31 +0100 Subject: [PATCH 098/131] Zoombox (#59) * add boxzoomtool; wheelzoomtool active by default --- robotmbt/visualise/networkvisualiser.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/robotmbt/visualise/networkvisualiser.py b/robotmbt/visualise/networkvisualiser.py index 715ff3cc..12af0791 100644 --- a/robotmbt/visualise/networkvisualiser.py +++ b/robotmbt/visualise/networkvisualiser.py @@ -2,7 +2,7 @@ from bokeh.core.property.vectorization import value from bokeh.embed import file_html from bokeh.models import ColumnDataSource, Rect, Text, ResetTool, SaveTool, WheelZoomTool, PanTool, Plot, Range1d, \ - Title, FullscreenTool, CustomJS, Segment, Arrow, NormalHead, Bezier, Legend + Title, FullscreenTool, CustomJS, Segment, Arrow, NormalHead, Bezier, Legend, BoxZoomTool from grandalf.graphs import Vertex as GVertex, Edge as GEdge, Graph as GGraph from grandalf.layouts import SugiyamaLayout @@ -240,9 +240,11 @@ def _add_features(self, suite_name: str, seed: str): self.plot.add_layout(Title(text=suite_name, align="center"), "above") # Add the different tools + wheel_zoom = WheelZoomTool() self.plot.add_tools(ResetTool(), SaveTool(), - WheelZoomTool(), PanTool(), - FullscreenTool()) + wheel_zoom, PanTool(), + FullscreenTool(), BoxZoomTool()) + self.plot.toolbar.active_scroll = wheel_zoom # Specify the default range - these values represent the aspect ratio of the actual view in the window self.plot.x_range = Range1d(-INNER_WINDOW_WIDTH / 2, INNER_WINDOW_WIDTH / 2) From 38c9255d19f5d825deb0cff35dbd12ddfd4d6a8e Mon Sep 17 00:00:00 2001 From: Jonathan <61787386+JWillegers@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:07:03 +0100 Subject: [PATCH 099/131] Atest/remaining (#58) * test cases scenario graph * renaming files to be consistent with other atests * repetition tests --- atest/resources/visualisation.resource | 53 ++++++++++++------- .../{0__setup.robot => 01__setup.robot} | 0 .../02__scenario.robot | 26 +++++++++ ...e.robot => 03__scenario-delta-value.robot} | 0 .../01_repetition.robot | 28 ++++++++++ .../01__export to JSON.robot | 2 +- 6 files changed, 88 insertions(+), 21 deletions(-) rename atest/robotMBT tests/10__visualisation_representations/{0__setup.robot => 01__setup.robot} (100%) create mode 100644 atest/robotMBT tests/10__visualisation_representations/02__scenario.robot rename atest/robotMBT tests/10__visualisation_representations/{1__scenario-delta-value.robot => 03__scenario-delta-value.robot} (100%) create mode 100644 atest/robotMBT tests/11__visualisation_execution_path/01_repetition.robot diff --git a/atest/resources/visualisation.resource b/atest/resources/visualisation.resource index a20f2315..b9985bb2 100644 --- a/atest/resources/visualisation.resource +++ b/atest/resources/visualisation.resource @@ -7,7 +7,8 @@ Library Collections *** Keywords *** test suite ${suite} has trace info ${trace} [Documentation] *model info* - ... :IN: new trace_info | trace_info.current_trace=0 | trace_info.all_traces=0 | trace_info.name = ${trace} + ... :IN: new trace_info | trace_info.current_trace=0 | trace_info.all_traces=0 | + ... trace_info.name = ${trace} | new graph ... :OUT: None ${trace_info} = Generate Trace Information Set Suite Variable ${trace_info} @@ -40,38 +41,50 @@ test suite ${suite} has ${n} total traces ${graph_type} graph ${name} is generated [Documentation] *model info* ... :IN: trace_info - ... :OUT: graph.name=${name}, graph.type=${graph_type} + ... :OUT: graph.name=${name} | graph.type=${graph_type} Variable Should Exist ${trace_info} ${graph} = Generate Graph ${trace_info} ${graph_type} ${network_visualiser} = Generate Network Graph ${graph} Set Suite Variable ${graph} - Set Suite Variable ${network_visualiser} + Set Suite Variable ${network_visualiser} graph ${name} contains vertex '${scenario}' [Documentation] *model info* - ... :IN: graph.name=${name} - ... :OUT: graph.name=${name} + ... :IN: graph.name==${name} + ... :OUT: graph.name==${name} Variable Should Exist ${graph} - ${result} = Graph Contains Vertex With No Text ${graph} ${scenario} + ${id} = Get NodeID ${graph} ${scenario} + ${result} = Graph Contains Vertex With No Text ${graph} ${id} Run Keyword If ${result} == False ... Fail Fail: Graph does not contain '${scenario}' graph ${name} contains vertex '${scenario}' with text "${text}" [Documentation] *model info* - ... :IN: graph.name=${name} - ... :OUT: graph.name=${name} + ... :IN: graph.name==${name} + ... :OUT: graph.name==${name} Variable Should Exist ${graph} ${result} = Graph Contains Vertex With Text ${graph} ${scenario} ${text} Run Keyword If "${result}" == "None" ... Fail Fail: Graph does not contain '${scenario}' with "${text}" +graph ${name} does not contain vertex '${scenario}' + [Documentation] *model info* + ... :IN: graph.name==${name} + ... :OUT: graph.name==${name} + Variable Should Exist ${graph} + + ${id} = Get NodeID ${graph} ${scenario} + ${result} = Graph Contains Vertex With No Text ${graph} ${id} + Run Keyword If "${result}" != "None" + ... Fail Fail: Graph contains '${scenario}'" + graph ${name} does not contain vertex '${scenario}' with text "${text}" [Documentation] *model info* - ... :IN: graph.name=${name} - ... :OUT: graph.name=${name} + ... :IN: graph.name==${name} + ... :OUT: graph.name==${name} Variable Should Exist ${graph} ${result} = Graph Contains Vertex With Text ${graph} ${scenario} ${text} @@ -79,9 +92,9 @@ graph ${name} does not contain vertex '${scenario}' with text "${text}" ... Fail Fail: Graph contains '${scenario}' with "${text}" graph ${name} has an edge from '${start_title}' to '${end_title}' - [Documentation] *model_info* - ... :IN: graph.name=${name} - ... :OUT: graph.name=${name} + [Documentation] *model info* + ... :IN: graph.name==${name} + ... :OUT: graph.name==${name} Variable Should Exist ${graph} ${start_node} = Graph Contains Vertex With Text ${graph} ${start_title} ${end_node} = Graph Contains Vertex With Text ${graph} ${end_title} @@ -94,9 +107,9 @@ graph ${name} has an edge from '${start_title}' to '${end_title}' ... Fail Fail: Edges '${start_title}' and '${end_title}' exist in graph but are not connected! graph ${name} does not have an edge from '${start_title}' to '${end_title}' - [Documentation] *model_info* - ... :IN: graph.name=${name} - ... :OUT: graph.name=${name} + [Documentation] *model info* + ... :IN: graph.name==${name} + ... :OUT: graph.name==${name} Variable Should Exist ${graph} ${start_node} = Graph Contains Vertex With Text ${graph} ${start_title} ${end_node} = Graph Contains Vertex With Text ${graph} ${end_title} @@ -106,16 +119,16 @@ graph ${name} does not have an edge from '${start_title}' to '${end_title}' ... Fail Fail: Edges '${start_title}' and '${end_title}' exist in graph and are connected, but shouldn't be! graph ${name} has vertices ${vertices_string} - [Documentation] *model_info* - ... :IN: graph.name=${name} - ... :OUT: graph.name=${name} + [Documentation] *model info* + ... :IN: graph.name==${name} + ... :OUT: graph.name==${name} Variable Should Exist ${graph} ${result} = Graph Contains Vertices Starting With ${graph} ${vertices_string} Run Keyword If ${result} != True ... Fail Graph did not contain all vertices ${vertices_string} vertex '${start_vertex}' is placed above '${end_vertex}' - [Documentation] *model_info* + [Documentation] *model info* ... :IN: None ... :OUT: None Variable Should Exist ${graph} diff --git a/atest/robotMBT tests/10__visualisation_representations/0__setup.robot b/atest/robotMBT tests/10__visualisation_representations/01__setup.robot similarity index 100% rename from atest/robotMBT tests/10__visualisation_representations/0__setup.robot rename to atest/robotMBT tests/10__visualisation_representations/01__setup.robot diff --git a/atest/robotMBT tests/10__visualisation_representations/02__scenario.robot b/atest/robotMBT tests/10__visualisation_representations/02__scenario.robot new file mode 100644 index 00000000..34d159e5 --- /dev/null +++ b/atest/robotMBT tests/10__visualisation_representations/02__scenario.robot @@ -0,0 +1,26 @@ +*** Settings *** +Library robotmbt processor=flatten + +*** Test Cases *** +Vertex Scenario graph + Given trace info t + When scenario graph g is generated + Then graph g contains vertex 'start' + And graph g contains vertex 'A1' + And graph g contains vertex 'A2' + +Edge Scenario graph + Given trace info t + When scenario graph g is generated + Then graph g has an edge from 'start' to 'A1' + And graph g has an edge from 'A1' to 'A2' + And graph g does not have an edge from 'start' to 'A2' + And graph g does not have an edge from 'A2' to 'A1' + And graph g does not have an edge from 'A2' to 'start' + +Visual location of vertices scenario + Given trace info t + When scenario graph g is generated + Then graph g has vertices 'start', 'A1', 'A2' + And vertex 'start' is placed above 'A1' + And vertex 'A1' is placed above 'A2' diff --git a/atest/robotMBT tests/10__visualisation_representations/1__scenario-delta-value.robot b/atest/robotMBT tests/10__visualisation_representations/03__scenario-delta-value.robot similarity index 100% rename from atest/robotMBT tests/10__visualisation_representations/1__scenario-delta-value.robot rename to atest/robotMBT tests/10__visualisation_representations/03__scenario-delta-value.robot diff --git a/atest/robotMBT tests/11__visualisation_execution_path/01_repetition.robot b/atest/robotMBT tests/11__visualisation_execution_path/01_repetition.robot new file mode 100644 index 00000000..0baee96b --- /dev/null +++ b/atest/robotMBT tests/11__visualisation_execution_path/01_repetition.robot @@ -0,0 +1,28 @@ +*** Settings *** +Documentation +Suite Setup Treat this test suite Model-based +Resource ../../resources/visualisation.resource +Library robotmbt + +*** Test Cases *** +Setup + Given test suite s has trace info t + Then the algorithm inserts 'A1' with state "attr: states=['a1'],special='!'" + And the algorithm inserts 'A1' with state "attr: states=['a1'],special='!'" + And the algorithm inserts 'B1' with state "attr: states=['a1','b1'], special='!'" + And the algorithm inserts 'B2' with state "attr: states=['a1','b1','b2'], special='!'" + And the algorithm inserts 'B1' with state "attr: states=['a1','b1','b2'], special='!'" + +Self-loop + Given trace info t + When scenario graph g is generated + Then graph g contains vertex 'A1' + And graph g has an edge from 'A1' to 'A1' + +Two-vertex loop + Given trace info t + When scenario graph g is generated + Then graph g contains vertex 'B1' + And graph g contains vertex 'B2' + And graph g has an edge from 'B1' to 'B2' + And graph g has an edge from 'B2' to 'B1' \ No newline at end of file diff --git a/atest/robotMBT tests/12__visualisation_export/01__export to JSON.robot b/atest/robotMBT tests/12__visualisation_export/01__export to JSON.robot index fd5157bb..ad70635e 100644 --- a/atest/robotMBT tests/12__visualisation_export/01__export to JSON.robot +++ b/atest/robotMBT tests/12__visualisation_export/01__export to JSON.robot @@ -2,7 +2,7 @@ Documentation Export and import a test suite from and to JSON ... and check that the imported suite equals the ... exported suite. -Suite Setup Treat this test suite Model-based graph=scenario-state +Suite Setup Treat this test suite Model-based Resource ../../resources/visualisation.resource Library robotmbt From 2bb82402da6e9e54a8cf722e73c199e6677bf95e Mon Sep 17 00:00:00 2001 From: Thomas Kas Date: Tue, 20 Jan 2026 15:04:16 +0100 Subject: [PATCH 100/131] Remove seed from graph title (#60) --- atest/resources/helpers/modelgenerator.py | 2 +- robotmbt/suiteprocessors.py | 11 ++++------- robotmbt/visualise/networkvisualiser.py | 12 +++++------- robotmbt/visualise/visualiser.py | 7 +++---- 4 files changed, 13 insertions(+), 19 deletions(-) diff --git a/atest/resources/helpers/modelgenerator.py b/atest/resources/helpers/modelgenerator.py index e18deed1..6741ca58 100644 --- a/atest/resources/helpers/modelgenerator.py +++ b/atest/resources/helpers/modelgenerator.py @@ -49,7 +49,7 @@ def generate_graph(self, trace_info: TraceInfo, graph_type: str) -> AbstractGrap @keyword(name="Generate Network Graph") def generate_networkgraph(self, graph: AbstractGraph) -> NetworkVisualiser: - return NetworkVisualiser(graph=graph, suite_name="", seed="") + return NetworkVisualiser(graph=graph, suite_name="") @keyword(name='Export Graph') # type:ignore def export_graph(self, suite: str, trace_info: TraceInfo) -> str: diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index d97e7d37..1edaec39 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -117,14 +117,14 @@ def _run_test_suite(self, seed: Any, graph: str, suite_name: str, to_json: bool) logger.debug("Use these numbers to reference scenarios from traces\n\t" + "\n\t".join([f"{s.src_id}: {s.name}" for s in self.scenarios])) - init_seed = self._init_randomiser(seed) + self._init_randomiser(seed) self.shuffled = [s.src_id for s in self.scenarios] random.shuffle(self.shuffled) # Keep a single shuffle for all TraceStates (non-essential) self.visualiser = None if graph != '' and VISUALISE: try: - self.visualiser = Visualiser(graph, suite_name, init_seed, to_json) + self.visualiser = Visualiser(graph, suite_name, to_json) except Exception as e: self.visualiser = None logger.warn(f'Could not initialise visualiser due to error!\n{e}') @@ -245,23 +245,20 @@ def _report_tracestate_wrapup(tracestate: TraceState): logger.debug(f"model\n{progression.model.get_status_text()}\n") @staticmethod - def _init_randomiser(seed: Any) -> str: + def _init_randomiser(seed: Any): if isinstance(seed, str): seed = seed.strip() if str(seed).lower() == 'none': logger.info( - f"Using system's random seed for trace generation. This trace cannot be rerun. Use `seed=new` to generate a reusable seed.") - return "" + "Using system's random seed for trace generation. This trace cannot be rerun. Use `seed=new` to generate a reusable seed.") elif str(seed).lower() == 'new': new_seed = SuiteProcessors._generate_seed() logger.info(f"seed={new_seed} (use seed to rerun this trace)") random.seed(new_seed) - return new_seed else: logger.info(f"seed={seed} (as provided)") random.seed(seed) - return seed @staticmethod def _generate_seed() -> str: diff --git a/robotmbt/visualise/networkvisualiser.py b/robotmbt/visualise/networkvisualiser.py index 12af0791..fd48670f 100644 --- a/robotmbt/visualise/networkvisualiser.py +++ b/robotmbt/visualise/networkvisualiser.py @@ -69,7 +69,7 @@ class NetworkVisualiser: A container for a Bokeh graph, which can be created from any abstract graph. """ - def __init__(self, graph: AbstractGraph, suite_name: str, seed: str): + def __init__(self, graph: AbstractGraph, suite_name: str): # Extract what we need from the graph self.networkx: DiGraph = graph.networkx self.final_trace = graph.get_final_trace() @@ -98,7 +98,7 @@ def __init__(self, graph: AbstractGraph, suite_name: str, seed: str): self._add_legend(graph) # Add our features to the graph (e.g. tools) - self._add_features(suite_name, seed) + self._add_features(suite_name) def generate_html(self): """ @@ -231,12 +231,10 @@ def _create_layout(self) -> tuple[list[Node], list[Edge]]: return ns, es - def _add_features(self, suite_name: str, seed: str): + def _add_features(self, suite_name: str): """ Add our features to the graph such as tools, titles, and JavaScript callbacks. """ - if seed != "": - self.plot.add_layout(Title(text="seed=" + seed, align="center", text_color="#999999"), "above") self.plot.add_layout(Title(text=suite_name, align="center"), "above") # Add the different tools @@ -248,9 +246,9 @@ def _add_features(self, suite_name: str, seed: str): # Specify the default range - these values represent the aspect ratio of the actual view in the window self.plot.x_range = Range1d(-INNER_WINDOW_WIDTH / 2, INNER_WINDOW_WIDTH / 2) - self.plot.y_range = Range1d(-INNER_WINDOW_HEIGHT + (0 if seed == '' else 20), 0) + self.plot.y_range = Range1d(-INNER_WINDOW_HEIGHT, 0) self.plot.x_range.tags = [{"initial_span": INNER_WINDOW_WIDTH}] - self.plot.y_range.tags = [{"initial_span": INNER_WINDOW_HEIGHT - (0 if seed == '' else 20)}] + self.plot.y_range.tags = [{"initial_span": INNER_WINDOW_HEIGHT}] # A JS callback to scale text and arrows, and change aspect ratio. resize_cb = CustomJS(args=dict(xr=self.plot.x_range, yr=self.plot.y_range, plot=self.plot, arrows=self.arrows), diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py index 757929b4..9069c370 100644 --- a/robotmbt/visualise/visualiser.py +++ b/robotmbt/visualise/visualiser.py @@ -25,7 +25,7 @@ def construct(cls, graph_type: str): # just calls __init__, but without having underscores etc. return cls(graph_type) - def __init__(self, graph_type: str, suite_name: str = "", seed: str = "", export: bool = False, + def __init__(self, graph_type: str, suite_name: str = "", export: bool = False, trace_info: TraceInfo = None): if graph_type != 'scenario' and graph_type != 'state' and graph_type != 'scenario-state' \ and graph_type != 'scenario-delta-value' and graph_type != 'reduced-sdv' \ @@ -33,13 +33,12 @@ def __init__(self, graph_type: str, suite_name: str = "", seed: str = "", export raise ValueError(f"Unknown graph type: {graph_type}!") self.graph_type: str = graph_type - if trace_info == None: + if trace_info is None: self.trace_info: TraceInfo = TraceInfo() else: self.trace_info = trace_info self.suite_name = suite_name self.export = export - self.seed = seed def update_trace(self, trace: TraceState): """ @@ -93,6 +92,6 @@ def generate_visualisation(self) -> str: graph: AbstractGraph = self._get_graph() - html_bokeh = networkvisualiser.NetworkVisualiser(graph, self.suite_name, self.seed).generate_html() + html_bokeh = networkvisualiser.NetworkVisualiser(graph, self.suite_name).generate_html() return f'' From 61793022f8c435d1b5c27be5aef310d1397ca398 Mon Sep 17 00:00:00 2001 From: D Osinga <46380170+osingaatje@users.noreply.github.com> Date: Tue, 20 Jan 2026 16:28:43 +0100 Subject: [PATCH 101/131] Requirements checks for visualisation atests (#61) * added explicit requirements check for each visualisation atest * changed to SKIP instead of FAIL * fixed rebase error * added explicit run for missing requirements * CI/CD rename + fixes * various CI/CD / naming fixes * merge nodeps test run into original test run * refactor naming * fully spell out abbrevations + capitalise 'Check dependencies' * fixed import problems - type warnings remain but it looks less indented * renamed visualisation dependency flags to have less warnings with Pylance --- .github/workflows/autopep8.yml | 2 +- .github/workflows/run-tests.yml | 22 +++++++----- atest/resources/helpers/modelgenerator.py | 36 ++++++++++++++----- atest/resources/visualisation.resource | 8 +++++ .../__init__.robot | 10 ++++-- .../01_repetition.robot | 8 ++++- .../01__export to JSON.robot | 7 +++- robotmbt/suiteprocessors.py | 13 ++++--- 8 files changed, 78 insertions(+), 28 deletions(-) diff --git a/.github/workflows/autopep8.yml b/.github/workflows/autopep8.yml index 14a62fa9..51b0c7ca 100644 --- a/.github/workflows/autopep8.yml +++ b/.github/workflows/autopep8.yml @@ -1,4 +1,4 @@ -name: autopep8 Check +name: autopep8 check on: [pull_request] diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index f989c58b..82a6d088 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -1,7 +1,7 @@ # This workflow will install Python dependencies, run tests and lint with a single version of Python # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python -name: Run Acceptance and Unit tests +name: Run Acceptance and Unit tests (with and without visualisation dependencies) on: # push: @@ -23,12 +23,18 @@ jobs: uses: actions/setup-python@v3 with: python-version: "3.10" - - name: Install dependencies + + - name: Install dependencies (without extra visualisation dependencies) run: | - python -m pip install --upgrade pip # upgrade pip to latest version - pip install ".[visualization]" # install PyProject.toml dependencies (including optional dependencies) - - name: Test with pytest - run: | - python run_tests.py - #pytest # test unit tests only + python -m pip install --upgrade pip # upgrade pip to latest version + pip install . # no additional [visualization] dependencies + + - name: Run tests (without visualisation dependencies) + run: python run_tests.py + + - name: Install extra visualisation dependencies + run: pip install ".[visualization]" # extra [visualization] dependencies in pyproject.toml + + - name: Run Tests (with visualisation dependencies) + run: python run_tests.py diff --git a/atest/resources/helpers/modelgenerator.py b/atest/resources/helpers/modelgenerator.py index 6741ca58..c35fdacc 100644 --- a/atest/resources/helpers/modelgenerator.py +++ b/atest/resources/helpers/modelgenerator.py @@ -1,14 +1,34 @@ -import jsonpickle # type: ignore from robot.api.deco import keyword # type:ignore -from robotmbt.visualise.models import TraceInfo, ScenarioInfo, StateInfo -from robotmbt.visualise.visualiser import Visualiser -from robotmbt.visualise.graphs.abstractgraph import AbstractGraph import os -import networkx as nx -from robotmbt.visualise.networkvisualiser import NetworkVisualiser, Node + +visualisation_deps_present = True +try: + import jsonpickle # type: ignore + import networkx as nx + from robotmbt.visualise.models import TraceInfo, ScenarioInfo, StateInfo + from robotmbt.visualise.visualiser import Visualiser + from robotmbt.visualise.graphs.abstractgraph import AbstractGraph + from robotmbt.visualise.networkvisualiser import NetworkVisualiser, Node + +except ImportError: + visualisation_deps_present = False + + jsonpickle = None + nx = None + TraceInfo = None + ScenarioInfo = None + StateInfo = None + Visualiser = None + AbstractGraph = None + NetworkVisualiser = None + Node = None class ModelGenerator: + @keyword(name='Requirements Present') # type: ignore + def check_requirements(self) -> bool: + return visualisation_deps_present + @keyword(name='Generate Trace Information') # type:ignore def generate_trace_information(self) -> TraceInfo: return TraceInfo() @@ -112,9 +132,9 @@ def vertices_connected(self, graph: AbstractGraph, node_key1: str | None, node_k if node_key1 is None or node_key2 is None: return False return graph.networkx.has_edge(node_key1, node_key2) - + @keyword(name="Get NodeID") # type:ignore - def get_nodeid(self, graph: AbstractGraph, node_title:str) -> str | None: + def get_nodeid(self, graph: AbstractGraph, node_title: str) -> str | None: return self.graph_contains_vertex_with_text(graph, node_title, text=None) @keyword(name="Get Vertex Y Position") # type:ignore diff --git a/atest/resources/visualisation.resource b/atest/resources/visualisation.resource index b9985bb2..9a3c01fa 100644 --- a/atest/resources/visualisation.resource +++ b/atest/resources/visualisation.resource @@ -5,6 +5,14 @@ Library Collections *** Keywords *** +Check requirements + [Documentation] *model info* + ... :IN: None + ... :OUT: None + ${result} = Requirements Present + Run Keyword If ${result} == ${False} + ... Skip Visualisation requirements not installed. Please read the README for information on how to do this. + test suite ${suite} has trace info ${trace} [Documentation] *model info* ... :IN: new trace_info | trace_info.current_trace=0 | trace_info.all_traces=0 | diff --git a/atest/robotMBT tests/10__visualisation_representations/__init__.robot b/atest/robotMBT tests/10__visualisation_representations/__init__.robot index 05729ca8..79850615 100644 --- a/atest/robotMBT tests/10__visualisation_representations/__init__.robot +++ b/atest/robotMBT tests/10__visualisation_representations/__init__.robot @@ -1,5 +1,11 @@ *** Settings *** Documentation Test correctness all graph representations -Suite Setup Treat this test suite Model-based +Suite Setup Enter test suite Resource ../../resources/visualisation.resource -Library robotmbt processor=flatten \ No newline at end of file +Library robotmbt processor=flatten + + +*** Keywords *** +Enter test suite + Check requirements + Treat this test suite Model-based \ No newline at end of file diff --git a/atest/robotMBT tests/11__visualisation_execution_path/01_repetition.robot b/atest/robotMBT tests/11__visualisation_execution_path/01_repetition.robot index 0baee96b..fd1e2959 100644 --- a/atest/robotMBT tests/11__visualisation_execution_path/01_repetition.robot +++ b/atest/robotMBT tests/11__visualisation_execution_path/01_repetition.robot @@ -1,10 +1,16 @@ *** Settings *** Documentation -Suite Setup Treat this test suite Model-based +Suite Setup Enter test suite Resource ../../resources/visualisation.resource Library robotmbt +*** Keywords *** +Enter test suite + Check requirements + Treat this test suite Model-based + *** Test Cases *** + Setup Given test suite s has trace info t Then the algorithm inserts 'A1' with state "attr: states=['a1'],special='!'" diff --git a/atest/robotMBT tests/12__visualisation_export/01__export to JSON.robot b/atest/robotMBT tests/12__visualisation_export/01__export to JSON.robot index ad70635e..e845a759 100644 --- a/atest/robotMBT tests/12__visualisation_export/01__export to JSON.robot +++ b/atest/robotMBT tests/12__visualisation_export/01__export to JSON.robot @@ -2,10 +2,15 @@ Documentation Export and import a test suite from and to JSON ... and check that the imported suite equals the ... exported suite. -Suite Setup Treat this test suite Model-based +Suite Setup Enter test suite Resource ../../resources/visualisation.resource Library robotmbt +*** Keywords *** +Enter test suite + Check requirements + Treat this test suite Model-based + *** Test Cases *** Setup Given test suite s has trace info t diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index 1edaec39..9f67c6b9 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -45,11 +45,11 @@ from .visualise.visualiser import Visualiser from .visualise.models import TraceInfo - VISUALISE = True + visualisation_deps_present = True except ImportError: Visualiser = None TraceInfo = None - VISUALISE = False + visualisation_deps_present = False class SuiteProcessors: @@ -105,7 +105,7 @@ def process_test_suite(self, in_suite: Suite, *, seed: Any = 'new', graph: str = return self.out_suite - def _load_graph(self, graph:str, suite_name: str, from_json: str): + def _load_graph(self, graph: str, suite_name: str, from_json: str): traceinfo = TraceInfo() traceinfo = traceinfo.import_graph(from_json) self.visualiser = Visualiser(graph, suite_name, trace_info=traceinfo) @@ -115,21 +115,21 @@ def _run_test_suite(self, seed: Any, graph: str, suite_name: str, to_json: bool) scenario.src_id = id self.scenarios = self.flat_suite.scenarios[:] logger.debug("Use these numbers to reference scenarios from traces\n\t" + - "\n\t".join([f"{s.src_id}: {s.name}" for s in self.scenarios])) + "\n\t".join([f"{s.src_id}: {s.name}" for s in self.scenarios])) self._init_randomiser(seed) self.shuffled = [s.src_id for s in self.scenarios] random.shuffle(self.shuffled) # Keep a single shuffle for all TraceStates (non-essential) self.visualiser = None - if graph != '' and VISUALISE: + if graph != '' and visualisation_deps_present: try: self.visualiser = Visualiser(graph, suite_name, to_json) except Exception as e: self.visualiser = None logger.warn(f'Could not initialise visualiser due to error!\n{e}') - elif graph != '' and not VISUALISE: + elif graph != '' and not visualisation_deps_present: logger.warn(f'Visualisation {graph} requested, but required dependencies are not installed. ' 'Refer to the README on how to install these dependencies. ') @@ -182,7 +182,6 @@ def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool) -> TraceS logger.debug(f"last state:\n{tracestate.model.get_status_text()}") return tracestate - def __update_visualisation(self, tracestate: TraceState): if self.visualiser is not None: try: From cf3e479ce09fca1c1932fb5174fc68e430f29ef6 Mon Sep 17 00:00:00 2001 From: Jonathan <61787386+JWillegers@users.noreply.github.com> Date: Wed, 21 Jan 2026 11:07:02 +0100 Subject: [PATCH 102/131] replace boxzoomtool for zoomintool and zoomouttool (#63) --- robotmbt/visualise/networkvisualiser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/robotmbt/visualise/networkvisualiser.py b/robotmbt/visualise/networkvisualiser.py index fd48670f..04f1f725 100644 --- a/robotmbt/visualise/networkvisualiser.py +++ b/robotmbt/visualise/networkvisualiser.py @@ -2,7 +2,7 @@ from bokeh.core.property.vectorization import value from bokeh.embed import file_html from bokeh.models import ColumnDataSource, Rect, Text, ResetTool, SaveTool, WheelZoomTool, PanTool, Plot, Range1d, \ - Title, FullscreenTool, CustomJS, Segment, Arrow, NormalHead, Bezier, Legend, BoxZoomTool + Title, FullscreenTool, CustomJS, Segment, Arrow, NormalHead, Bezier, Legend, ZoomInTool, ZoomOutTool from grandalf.graphs import Vertex as GVertex, Edge as GEdge, Graph as GGraph from grandalf.layouts import SugiyamaLayout @@ -241,7 +241,7 @@ def _add_features(self, suite_name: str): wheel_zoom = WheelZoomTool() self.plot.add_tools(ResetTool(), SaveTool(), wheel_zoom, PanTool(), - FullscreenTool(), BoxZoomTool()) + FullscreenTool(), ZoomInTool(factor=0.4), ZoomOutTool(factor=0.4)) self.plot.toolbar.active_scroll = wheel_zoom # Specify the default range - these values represent the aspect ratio of the actual view in the window From c267fd4537e470f01a443d40c37cd02b13b2209d Mon Sep 17 00:00:00 2001 From: Thomas Kas Date: Wed, 21 Jan 2026 11:12:09 +0100 Subject: [PATCH 103/131] Split name utests (#64) --- utest/test_visualise_models.py | 41 ++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/utest/test_visualise_models.py b/utest/test_visualise_models.py index d07958dc..4ec9941f 100644 --- a/utest/test_visualise_models.py +++ b/utest/test_visualise_models.py @@ -29,6 +29,47 @@ def test_scenarioInfo_Scenario(self): self.assertEqual(si.name, 'test') self.assertEqual(si.src_id, 0) + def test_split_name_empty_string(self): + result = ScenarioInfo._split_name("") + self.assertEqual(result, "") + self.assertNotIn('\n', result) + + def test_split_name_single_short_word(self): + result = ScenarioInfo._split_name("Hello") + self.assertEqual(result, "Hello") + self.assertNotIn('\n', result) + + def test_split_name_single_exact_length_word(self): + exact_20 = "abcdefghijklmnopqrst" + result = ScenarioInfo._split_name(exact_20) + self.assertEqual(result, exact_20) + self.assertNotIn('\n', result) + + def test_split_name_single_long_word(self): + name = "ThisIsAReallyLongNameWithoutAnySpacesAtAll" + result = ScenarioInfo._split_name(name) + self.assertEqual(result, name) + self.assertNotIn('\n', result) + + def test_split_name_two_words_short(self): + result = ScenarioInfo._split_name("Hello World") + self.assertEqual(result, "Hello World") + self.assertNotIn('\n', result) + + def test_split_name_two_words_exceeds_limit(self): + name = "Supercalifragilistic Hello" + result = ScenarioInfo._split_name(name) + + self.assertEqual(result.replace('\n', ' '), name) + self.assertIn('\n', result) + + def test_split_name_multiple_words_need_split(self): + name = "This is a very long scenario name that should be split" + result = ScenarioInfo._split_name(name) + + self.assertEqual(result.replace('\n', ' '), name) + self.assertIn('\n', result) + """ Class: StateInfo """ From bbf4afb970bf4ded7fb0c55972e45625b2033db5 Mon Sep 17 00:00:00 2001 From: Thomas Kas Date: Wed, 21 Jan 2026 11:14:23 +0100 Subject: [PATCH 104/131] Smaller margins (#62) * Smaller margins between vertices --- robotmbt/visualise/networkvisualiser.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/robotmbt/visualise/networkvisualiser.py b/robotmbt/visualise/networkvisualiser.py index 04f1f725..f2dd4ff8 100644 --- a/robotmbt/visualise/networkvisualiser.py +++ b/robotmbt/visualise/networkvisualiser.py @@ -203,8 +203,14 @@ def _create_layout(self) -> tuple[list[Node], list[Edge]]: # Feed the info to grandalf and get the layout. g = GGraph(vertices, edges) - sugiyama = SugiyamaLayout(g.C[0]) + + # Set specific margins as these values worked best in user-testing + sugiyama.xspace = 10 + sugiyama.yspace = 15 + sugiyama.dw = 2 + sugiyama.dh = 2 + sugiyama.init_all(roots=[start], inverted_edges=flips) sugiyama.draw() From 6c85c49e15e05201ec08237a84535b02c2b1fe65 Mon Sep 17 00:00:00 2001 From: Thomas Kas Date: Wed, 21 Jan 2026 11:31:56 +0100 Subject: [PATCH 105/131] Documentation (#65) * implemented scenariodeltavaluegraph * implemented reduced-scenariodeltavaluegraph, but should still discuss how to label equivalence classes (currently just picks a representative). Fixed part of vertex info being indicated as edge info for scenariodeltavaluegraph. Trace highlighting is still broken for reducedSDV * implemented comments from Douwe (added comments and reset graph setting in suiteprocessors.py) * fixed reducedSDV graph to have a properly functioning equivalence relation * added merged annotation to labels generated by reducedSDV for merged nodes * added documentation to StateInfo.difference * Reduce size --------- Co-authored-by: tychodub --- robotmbt/visualise/models.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index ffec518f..067c157f 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -102,6 +102,10 @@ def _create_state_with_prop(cls, name: str, attrs: list[tuple[str, Any]]): return cls(space) def difference(self, new_state) -> set[tuple[str, str]]: + """ + new_state: the new StateInfo to be compared to the self. + returns: a set of tuples with properties and their assignment. + """ old: dict[str, dict | str] = self.properties.copy() new: dict[str, dict | str] = new_state.properties.copy() temp = StateInfo._dict_deep_diff(old, new) From 66bf82d2808a8e7f983a498cd9ea7a9267dfad01 Mon Sep 17 00:00:00 2001 From: Jonathan <61787386+JWillegers@users.noreply.github.com> Date: Wed, 21 Jan 2026 12:46:14 +0100 Subject: [PATCH 106/131] Export path (#66) * change json from bool to providing path * rename to_json and from_json * export without needing to generate a graph * remove graph=... from existing atests * remove export from this test case --- CONTRIBUTING.md | 15 ++----- README.md | 6 +-- atest/resources/helpers/modelgenerator.py | 2 +- .../01__then_to_given_linking.robot | 2 +- .../02__swapped_then_to_given_linking.robot | 2 +- ...single_when_to_given_step_refinement.robot | 2 +- .../05__composed_scenario.robot | 2 +- .../01__single_repetition.robot | 2 +- .../04__multi_repetition.robot | 2 +- .../05__paired_repetition.robot | 2 +- .../06__repetition_twin_with_tail.robot | 2 +- .../07__repetition_with_refinement.robot | 2 +- robotmbt/suiteprocessors.py | 16 +++---- robotmbt/visualise/models.py | 20 ++++++--- robotmbt/visualise/visualiser.py | 45 ++++++++++--------- 15 files changed, 61 insertions(+), 61 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f36b3751..9969f32d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -171,21 +171,14 @@ class ScenarioGraph(AbstractGraph[ScenarioInfo, None]): ``` Once you have created a new Graph class, you can direct the visualizer to use it when your type is selected. -Edit `/robotmbt/visualise/visualiser.py` to not reject your graph type in `__init__` and construct your graph in `generate_visualisation` like the others. For our example: -```python -def __init__(self, graph_type: str, suite_name: str = "", seed: str = "", export: bool = False, trace_info: TraceInfo = None): - if graph_type != 'scenario' and [...]: - raise ValueError(f"Unknown graph type: {graph_type}!") - - [...] -``` +Edit `/robotmbt/visualise/visualiser.py` to construct your graph in `_get_graph` like the others. For our example: ```python -def generate_visualisation(self) -> str: +def _get_graph(self) -> AbstractGraph | None: [...] - if self.graph_type == 'scenario': - graph: AbstractGraph = ScenarioGraph(self.trace_info) + case 'scenario': + return ScenarioGraph(self.trace_info) [...] ``` diff --git a/README.md b/README.md index 8ca95a89..6f832f68 100644 --- a/README.md +++ b/README.md @@ -217,17 +217,17 @@ Once the test suite has run, a graph will be included in the test's log, under t It is possible to extract the exploration data after the library has found a covering trace. To enable this feature, set the following argument to true: ``` -Treat this test suite Model-based graph=[type] to_json=true +Treat this test suite Model-based graph=[type] export_graph_data=[directory] ``` A JSON file named after the test suite will be created containing said information. #### JSON importing -It is possible to skip running the exploration step and produce a graph (e.g. of another type) from previously exported data. This can be achieved by pointing the following argument to such a JSON file (just its name suffices, without the extension): +It is possible to skip running the exploration step and produce a graph (e.g. of another type) from previously exported data. ``` -Treat this test suite Model-based graph=[type] from_json=[file_name] +Treat this test suite Model-based graph=[type] import_graph_data=[directory+file_name.json] ``` A graph will be created from the imported data. diff --git a/atest/resources/helpers/modelgenerator.py b/atest/resources/helpers/modelgenerator.py index c35fdacc..41cd4d73 100644 --- a/atest/resources/helpers/modelgenerator.py +++ b/atest/resources/helpers/modelgenerator.py @@ -73,7 +73,7 @@ def generate_networkgraph(self, graph: AbstractGraph) -> NetworkVisualiser: @keyword(name='Export Graph') # type:ignore def export_graph(self, suite: str, trace_info: TraceInfo) -> str: - return trace_info.export_graph(suite, True) + return trace_info.export_graph(suite, atest=True) @keyword(name='Import Graph') # type:ignore def import_graph(self, filepath: str) -> TraceInfo: diff --git a/atest/robotMBT tests/04__scenario_linking/01__then_to_given_linking.robot b/atest/robotMBT tests/04__scenario_linking/01__then_to_given_linking.robot index 13075c71..b0ecf40d 100644 --- a/atest/robotMBT tests/04__scenario_linking/01__then_to_given_linking.robot +++ b/atest/robotMBT tests/04__scenario_linking/01__then_to_given_linking.robot @@ -4,7 +4,7 @@ Documentation This test suite demonstrates direct one-on-one linking of scen ... the trailing scenario. ... ... Note that this test suite would also pass when run in Robot Framework without additional processing. -Suite Setup Treat this test suite Model-based graph=scenario +Suite Setup Treat this test suite Model-based Resource ../../resources/birthday_cards_flat.resource Library robotmbt diff --git a/atest/robotMBT tests/04__scenario_linking/02__swapped_then_to_given_linking.robot b/atest/robotMBT tests/04__scenario_linking/02__swapped_then_to_given_linking.robot index e587223e..c8619356 100644 --- a/atest/robotMBT tests/04__scenario_linking/02__swapped_then_to_given_linking.robot +++ b/atest/robotMBT tests/04__scenario_linking/02__swapped_then_to_given_linking.robot @@ -5,7 +5,7 @@ Documentation This test suite demonstrates direct one-on-one linking of scen ... reverse order, this test suite would fail in a regular Robot Framework ... test run. It passes when the model figures out the dependency between ... the test cases and swaps their order. -Suite Setup Treat this test suite Model-based graph=scenario +Suite Setup Treat this test suite Model-based Resource ../../resources/birthday_cards_flat.resource Library robotmbt diff --git a/atest/robotMBT tests/04__scenario_linking/04__single_when_to_given_step_refinement.robot b/atest/robotMBT tests/04__scenario_linking/04__single_when_to_given_step_refinement.robot index d2cef617..2fc8c87e 100644 --- a/atest/robotMBT tests/04__scenario_linking/04__single_when_to_given_step_refinement.robot +++ b/atest/robotMBT tests/04__scenario_linking/04__single_when_to_given_step_refinement.robot @@ -7,7 +7,7 @@ Documentation This suite demonstrates step refinement in its simplest form. ... the _WHEN_ step. For this to be successful, the _WHEN_ step from the ... high-level scenario must match the _GIVEN_ step of the refinement ... scenario. -Suite Setup Treat this test suite Model-based graph=scenario +Suite Setup Treat this test suite Model-based Resource ../../resources/birthday_cards_composed.resource Library robotmbt diff --git a/atest/robotMBT tests/04__scenario_linking/05__composed_scenario.robot b/atest/robotMBT tests/04__scenario_linking/05__composed_scenario.robot index 2ed8d4a0..a079c13b 100644 --- a/atest/robotMBT tests/04__scenario_linking/05__composed_scenario.robot +++ b/atest/robotMBT tests/04__scenario_linking/05__composed_scenario.robot @@ -3,7 +3,7 @@ Documentation This is a composed scenario where there is a sequence of three ... scenarios on the highest level. The middle of these three scenarios ... has multiple steps that require refinement. One of those refinement ... steps needs refinement of it own, yielding double-layerd refinement. -Suite Setup Treat this test suite Model-based graph=scenario +Suite Setup Treat this test suite Model-based Resource ../../resources/birthday_cards_composed.resource Library robotmbt diff --git a/atest/robotMBT tests/05__repeating_scenarios/01__single_repetition.robot b/atest/robotMBT tests/05__repeating_scenarios/01__single_repetition.robot index 401cc7ce..1f41fd95 100644 --- a/atest/robotMBT tests/05__repeating_scenarios/01__single_repetition.robot +++ b/atest/robotMBT tests/05__repeating_scenarios/01__single_repetition.robot @@ -2,7 +2,7 @@ Documentation This suite consists of 3 scenarios. After inserting the leading ... scenario, the trailing scenario cannot be reached, unless the middle ... scenario is inserted twice. -Suite Setup Treat this test suite Model-based graph=scenario +Suite Setup Treat this test suite Model-based Resource ../../resources/birthday_cards_flat.resource Library robotmbt diff --git a/atest/robotMBT tests/05__repeating_scenarios/04__multi_repetition.robot b/atest/robotMBT tests/05__repeating_scenarios/04__multi_repetition.robot index 88c8629c..a08567a8 100644 --- a/atest/robotMBT tests/05__repeating_scenarios/04__multi_repetition.robot +++ b/atest/robotMBT tests/05__repeating_scenarios/04__multi_repetition.robot @@ -1,7 +1,7 @@ *** Settings *** Documentation This suite requires a larger amount of repetitions to reach the final ... scenario. -Suite Setup Treat this test suite Model-based graph=scenario +Suite Setup Treat this test suite Model-based Resource ../../resources/birthday_cards_flat.resource Library robotmbt diff --git a/atest/robotMBT tests/05__repeating_scenarios/05__paired_repetition.robot b/atest/robotMBT tests/05__repeating_scenarios/05__paired_repetition.robot index 5d9ac59d..39f6c65c 100644 --- a/atest/robotMBT tests/05__repeating_scenarios/05__paired_repetition.robot +++ b/atest/robotMBT tests/05__repeating_scenarios/05__paired_repetition.robot @@ -2,7 +2,7 @@ Documentation This suite cannot be completed by repeating a single scenario. Two ... scenarios are linked in such a way that they must be repeated in ... pairs to reach the final scenario. -Suite Setup Treat this test suite Model-based graph=scenario +Suite Setup Treat this test suite Model-based Resource ../../resources/birthday_cards_flat.resource Library robotmbt diff --git a/atest/robotMBT tests/05__repeating_scenarios/06__repetition_twin_with_tail.robot b/atest/robotMBT tests/05__repeating_scenarios/06__repetition_twin_with_tail.robot index 51b479e2..f9ec9211 100644 --- a/atest/robotMBT tests/05__repeating_scenarios/06__repetition_twin_with_tail.robot +++ b/atest/robotMBT tests/05__repeating_scenarios/06__repetition_twin_with_tail.robot @@ -3,7 +3,7 @@ Documentation This suite includes multiplicity on multiple ends. Most notabl ... - Two scenarios that are equaly valid to include in the repetition part ... - Two scenarios at the tail end that cannot be reached without the ... \ \ repetitions, but each has a different style entry condition. -Suite Setup Treat this test suite Model-based graph=scenario +Suite Setup Treat this test suite Model-based Resource ../../resources/birthday_cards_flat.resource Library robotmbt diff --git a/atest/robotMBT tests/05__repeating_scenarios/07__repetition_with_refinement.robot b/atest/robotMBT tests/05__repeating_scenarios/07__repetition_with_refinement.robot index d7e552e6..54169443 100644 --- a/atest/robotMBT tests/05__repeating_scenarios/07__repetition_with_refinement.robot +++ b/atest/robotMBT tests/05__repeating_scenarios/07__repetition_with_refinement.robot @@ -2,7 +2,7 @@ Documentation This suite is similar to the Single repetition scenario, but ... with the difference that this time the repeated scenario has ... a step that requires refinement. -Suite Setup Treat this test suite Model-based graph=scenario +Suite Setup Treat this test suite Model-based Resource ../../resources/birthday_cards_composed.resource Library robotmbt diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index 9f67c6b9..94b9c75b 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -88,18 +88,18 @@ def flatten(self, in_suite: Suite) -> Suite: return out_suite def process_test_suite(self, in_suite: Suite, *, seed: Any = 'new', graph: str = '', - to_json: bool = False, from_json: str = 'false') -> Suite: + export_graph_data: str = '', import_graph_data: str = '') -> Suite: self.out_suite = Suite(in_suite.name) self.out_suite.filename = in_suite.filename self.out_suite.parent = in_suite.parent self._fail_on_step_errors(in_suite) self.flat_suite = self.flatten(in_suite) - if from_json != 'false': - self._load_graph(graph, in_suite.name, from_json) + if import_graph_data != '': + self._load_graph(graph, in_suite.name, import_graph_data) else: - self._run_test_suite(seed, graph, in_suite.name, to_json) + self._run_test_suite(seed, graph, in_suite.name, export_graph_data) self.__write_visualisation() @@ -110,7 +110,7 @@ def _load_graph(self, graph: str, suite_name: str, from_json: str): traceinfo = traceinfo.import_graph(from_json) self.visualiser = Visualiser(graph, suite_name, trace_info=traceinfo) - def _run_test_suite(self, seed: Any, graph: str, suite_name: str, to_json: bool): + def _run_test_suite(self, seed: Any, graph: str, suite_name: str, export_dir: str): for id, scenario in enumerate(self.flat_suite.scenarios, start=1): scenario.src_id = id self.scenarios = self.flat_suite.scenarios[:] @@ -122,14 +122,14 @@ def _run_test_suite(self, seed: Any, graph: str, suite_name: str, to_json: bool) random.shuffle(self.shuffled) # Keep a single shuffle for all TraceStates (non-essential) self.visualiser = None - if graph != '' and visualisation_deps_present: + if visualisation_deps_present: try: - self.visualiser = Visualiser(graph, suite_name, to_json) + self.visualiser = Visualiser(graph, suite_name, export_dir) except Exception as e: self.visualiser = None logger.warn(f'Could not initialise visualiser due to error!\n{e}') - elif graph != '' and not visualisation_deps_present: + elif (not graph or not export_dir) and not visualisation_deps_present: logger.warn(f'Visualisation {graph} requested, but required dependencies are not installed. ' 'Refer to the README on how to install these dependencies. ') diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index 067c157f..366a6e52 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -177,7 +177,6 @@ def __init__(self): self.all_traces: list[list[tuple[ScenarioInfo, StateInfo]]] = [] self.previous_length: int = 0 self.pushed: bool = False - self.path = "json/" def update_trace(self, scenario: ScenarioInfo | None, state: StateInfo, length: int): if length > self.previous_length: @@ -251,7 +250,7 @@ def _sanity_check(self, scen: ScenarioInfo, state: StateInfo, after: str): logger.warn( f'TraceInfo got out of sync after {after}\nExpected state: {prev_state}\nActual state: {state}') - def export_graph(self, suite_name: str, atest: bool = False) -> str | None: + def export_graph(self, suite_name: str, dir: str = '', atest: bool = False) -> str | None: encoded_instance = jsonpickle.encode(self) name = suite_name.lower().replace(' ', '_') if atest: @@ -261,17 +260,24 @@ def export_graph(self, suite_name: str, atest: bool = False) -> str | None: as temporary file, a different method, is problematic on Windows https://stackoverflow.com/a/57015383 ''' - fd, path = tempfile.mkstemp() + fd, dir = tempfile.mkstemp() with os.fdopen(fd, "w") as f: f.write(encoded_instance) - return path + return dir - with open(f"{self.path}{name}.json", "w") as f: + if dir[-1] != '/': + dir += '/' + + # create folders if they do not exist + if not os.path.exists(dir): + os.makedirs(dir) + + with open(f"{dir}{name}.json", "w") as f: f.write(encoded_instance) return None - def import_graph(self, file_name: str): - with open(f"{self.path}{file_name}.json", "r") as f: + def import_graph(self, file_path: str): + with open(f"{file_path}", "r") as f: string = f.read() self = jsonpickle.decode(string) diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py index 9069c370..92d9e30c 100644 --- a/robotmbt/visualise/visualiser.py +++ b/robotmbt/visualise/visualiser.py @@ -25,12 +25,8 @@ def construct(cls, graph_type: str): # just calls __init__, but without having underscores etc. return cls(graph_type) - def __init__(self, graph_type: str, suite_name: str = "", export: bool = False, - trace_info: TraceInfo = None): - if graph_type != 'scenario' and graph_type != 'state' and graph_type != 'scenario-state' \ - and graph_type != 'scenario-delta-value' and graph_type != 'reduced-sdv' \ - and graph_type != 'delta-value': - raise ValueError(f"Unknown graph type: {graph_type}!") + def __init__(self, graph_type: str, suite_name: str = "", export: str = '', + trace_info: TraceInfo = None): self.graph_type: str = graph_type if trace_info is None: @@ -70,27 +66,32 @@ def update_trace(self, trace: TraceState): model = snap.model self.trace_info.update_trace(ScenarioInfo(scenario), StateInfo(model), trace_len) - def _get_graph(self) -> AbstractGraph: - if self.graph_type == 'scenario': - graph: AbstractGraph = ScenarioGraph(self.trace_info) - elif self.graph_type == 'state': - graph: AbstractGraph = StateGraph(self.trace_info) - elif self.graph_type == 'scenario-delta-value': - graph: AbstractGraph = ScenarioDeltaValueGraph(self.trace_info) - elif self.graph_type == 'reduced-sdv': - graph: AbstractGraph = ReducedSDVGraph(self.trace_info) - elif self.graph_type == 'delta-value': - graph: AbstractGraph = DeltaValueGraph(self.trace_info) - else: - graph: AbstractGraph = ScenarioStateGraph(self.trace_info) - - return graph + def _get_graph(self) -> AbstractGraph | None: + match self.graph_type: + case 'scenario': + return ScenarioGraph(self.trace_info) + case 'state': + return StateGraph(self.trace_info) + case 'scenario-state': + return ScenarioStateGraph(self.trace_info) + case 'delta-value': + return DeltaValueGraph(self.trace_info) + case 'scenario-delta-value': + return ScenarioDeltaValueGraph(self.trace_info) + case 'recuded-sdv': + return ReducedSDVGraph(self.trace_info) + + return None def generate_visualisation(self) -> str: if self.export: - self.trace_info.export_graph(self.suite_name) + self.trace_info.export_graph(self.suite_name, self.export) graph: AbstractGraph = self._get_graph() + if graph is None: + if not self.export: + raise ValueError(f"Unknown graph type: {self.graph_type}") + return html_bokeh = networkvisualiser.NetworkVisualiser(graph, self.suite_name).generate_html() From b0804872017803240014b055ca2ebfbb46d53b10 Mon Sep 17 00:00:00 2001 From: Thomas Kas Date: Fri, 23 Jan 2026 15:57:11 +0100 Subject: [PATCH 107/131] Sync fork and minor changes (#67) * handle optional argument's default values * optional arguments stay hidden when not mentioned * fix argument text for POSITIONAL_OR_NAMED cases Now keeps the notation as used in the scenario (with or without the argument's name) in the generated keyword arguments. * fix failure on empty varargs * workaround for embedded argument name mismatch Accept that names of embedded arguments in the @keyword decorator must match their Python argument counterparts. * support step modifiers for non-embedded arguments * handling vararg modifiers * add tests for step modifier with free named arguments * add error message for vararg and free named arg modifiers * docu update for step modifiers on non-embedded arguments * promote random seed logging to info level Logging the seed at info level allows to run without debug logging for smaller log files, without losing the ability to rerun an interesting trace. * promote random seed logging to info level * make tests independent * Remove embedded-arguments-only restriction for modifiers * bump version to 0.10 * New tests for keeping direct setting's effects local * keep direct model options local * How to use configuration options * added cicd * update keyword documentation * added error propagation to run_tests.py * removed commented out comment in run-tests.yml CI/CD, fixed 'pip install' * apply pep8 Python formatting at max 120 char/line * added cicd * added error propagation to run_tests.py * removed commented out comment in run-tests.yml CI/CD, fixed 'pip install' * format with autopep8 (default line length) * reformat with new max line length setting * Use consistent casing in naming RobotMBT * Add contribution guidelines * add workflow to check demo * added exit code propagation to demo * install local requirements.txt for demo * refactor comments and yaml syntax * adjusted contributing (style, formatting, sentence structure * Text clarifications CONTRIBUTING.md * fix md linting issues * access TraceSnapShot model as copy * use exit code to pass/fail autopep8 * test: intentional pep8 violation * undo pep8 violation * reuse scenario indexes for TraceState * keep original scenario list unshuffled * refactor processing to reach full coverage Open issue: rewind under refinement * delegate refinement stack to TraceState * more tests for refinement and rewinds * new test for data consistency in split-up scenarios * move scenario processing to its own file * remove redundant return value (model) * restore logging tweak * Adding type hints (#44) Included type hints for: - arguments - return types, except return None - member variables Excluded: - local variables - warnings on optional variables (... | None) - stub/mock type warnings in test files - Self type (pending dropping Python 3.10 support) * Fix creation of graph if none is requested * Support RecursiveScope objects * Update contribution file * Implement feedback * Formatting --------- Co-authored-by: JFoederer <32476108+JFoederer@users.noreply.github.com> Co-authored-by: Douwe Osinga Co-authored-by: tychodub <93142605+tychodub@users.noreply.github.com> --- .github/workflows/autopep8.yml | 15 +- .github/workflows/run-tests.yml | 14 +- CONTRIBUTING.md | 62 ++++++- README.md | 60 ++----- robotmbt/modeller.py | 10 +- robotmbt/modelspace.py | 38 +++-- robotmbt/steparguments.py | 30 ++-- robotmbt/substitutionmap.py | 34 ++-- robotmbt/suitedata.py | 87 +++++----- robotmbt/suiteprocessors.py | 23 +-- robotmbt/suitereplacer.py | 56 ++----- robotmbt/tracestate.py | 37 ++--- robotmbt/visualise/networkvisualiser.py | 5 +- robotmbt/visualise/visualiser.py | 53 +++--- run_tests.py | 2 +- utest/test_steparguments.py | 10 +- utest/test_substitutionmap.py | 2 +- utest/test_suitedata.py | 61 ++++--- utest/test_tracestate.py | 210 ++++++++++++------------ 19 files changed, 388 insertions(+), 421 deletions(-) diff --git a/.github/workflows/autopep8.yml b/.github/workflows/autopep8.yml index 51b0c7ca..999b02cb 100644 --- a/.github/workflows/autopep8.yml +++ b/.github/workflows/autopep8.yml @@ -1,4 +1,4 @@ -name: autopep8 check +name: autopep8 Check on: [pull_request] @@ -20,15 +20,4 @@ jobs: id: check run: | # Check if autopep8 would make changes - formatting_issues=$(autopep8 --diff --recursive --max-line-length 120 .) - if [[ formatting_issues = *[$" \t\n"]* ]] then - echo "No formatting issues found." - else - echo "Formatting issues found:" - printf "%s\n" "$formatting_issues" - echo "------------------------------" - echo "-- Formatting issues found! --" - echo "------------------------------" - exit 1 - fi - + autopep8 --diff --recursive --max-line-length 120 --exit-code . diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 82a6d088..f63a0551 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -1,11 +1,9 @@ -# This workflow will install Python dependencies, run tests and lint with a single version of Python +# This workflow installs required Python dependencies and then runs the available tests. # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python name: Run Acceptance and Unit tests (with and without visualisation dependencies) on: - # push: - # branches: [ "main" ] pull_request: branches: [ "main" ] @@ -22,19 +20,19 @@ jobs: - name: Set up Python uses: actions/setup-python@v3 with: - python-version: "3.10" - + python-version: "3.10" # Only the oldest supported Python version is included here + - name: Install dependencies (without extra visualisation dependencies) run: | python -m pip install --upgrade pip # upgrade pip to latest version pip install . # no additional [visualization] dependencies - + - name: Run tests (without visualisation dependencies) run: python run_tests.py - + - name: Install extra visualisation dependencies run: pip install ".[visualization]" # extra [visualization] dependencies in pyproject.toml - + - name: Run Tests (with visualisation dependencies) run: python run_tests.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9969f32d..303948a0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -120,7 +120,7 @@ Researchers have suggested that longer lines are better suited for cases when th Extending the functionality of the visualizer with new graph types can result in better insights into created tests. The visualizer makes use of an abstract graph class that makes it easy to create new graph types. To create a new graph type, create an instance of AbstractGraph, instantiating the following methods: -- select_node_info: select the information you want to use to identify different nodes from all ScenarioInfo/StateInfo pairs that make up the different exploration steps. This info is also used to label nodes. Its return type has to match the first type used to instantiate AbstractGraph. +- select_node_info: select the information you want to use to identify different nodes from all ScenarioInfo/StateInfo pairs that make up the different exploration steps. This info is also used to label nodes. Its return type has to match the first type argument passed to AbstractGraph. - select_edge_info: ditto but for edges, which is also used for labeling. Its type has to match the second type used to instantiate AbstractGraph. - create_node_label: turn the selected information into a label for a node. - create_edge_label: ditto but for edges. @@ -129,11 +129,11 @@ To create a new graph type, create an instance of AbstractGraph, instantiating t - get_legend_info_final_trace_edge: ditto but for edges that appear in the final trace. - get_legend_info_other_edge: ditto but for edges that have backtracked. -It is recommended to create a new file for each graph type under `/robotmbt/visualise/graphs/`. +Please create a new file for each graph type under `/robotmbt/visualise/graphs/`. NOTE: when manually altering the networkx field, ensure its ids remain as a serializable and hashable type when the constructor finishes. -A simple example is given below. In this graph type, nodes represent scenarios encountered in exploration, and edges show the flow between these scenarios. +As an example, we show the implementation of the scenario graph below. In this graph type, nodes represent scenarios encountered in exploration, and edges show the flow between these scenarios. ```python class ScenarioGraph(AbstractGraph[ScenarioInfo, None]): @@ -171,17 +171,61 @@ class ScenarioGraph(AbstractGraph[ScenarioInfo, None]): ``` Once you have created a new Graph class, you can direct the visualizer to use it when your type is selected. -Edit `/robotmbt/visualise/visualiser.py` to construct your graph in `_get_graph` like the others. For our example: +Simply add your class to the `GRAPHS` dictionary in `robotmbt/visualise/visualiser.py` like the others. For our example: ```python -def _get_graph(self) -> AbstractGraph | None: +GRAPHS = { [...] - - case 'scenario': - return ScenarioGraph(self.trace_info) - + 'scenario': ScenarioGraph, [...] +} ``` Now, when selecting your graph type (in our example `Treat this test suite Model-based graph=scenario`), your graph will get constructed! + +## Development Tips +### Python virtual environment +Installing the proper virtual environment can be done with the default `python -m venv ./.venv` command built into python. However, if you have another version of python on your system, this might break dependencies. + +#### Pipenv+Pyenv (verified on Windows and Linux) +For the optimal experience (at least on Linux), we suggest installing the following packages: +- [`pyenv`](https://github.com/pyenv/pyenv) (Linux/Mac) or [`pyenv-win`](https://github.com/pyenv-win/pyenv-win) (Windows) +- [`pipenv`](https://github.com/pypa/pipenv) + +Then, you can install a python virtual environment with: + +```bash +pipenv --python +``` +..where the python version can be found in the `pyproject.toml`. For example, for 3.10: `pipenv --python 3.10`. + +You might need to manually make the folder `.venv` by doing `mkdir .venv`. + +You can verify if the installation went correctly with: +```bash +pipenv check +``` +This should return `Passed!` + +Errors related to minor versions (for example `3.10.0rc2` != `3.10.0`) can be ignored. + +Now activate the virtual environment by running +```bash +pipenv shell +``` + +..and you should have a virtual env! If you run +```bash +python --version +``` +..while in your virtual environment, it should show the `` from before. + + +### Installing dependencies +***NOTE: making sure that you are in the virtual environment***. + +It is recommended that you also include the optional dependencies for visualisation, e.g.: +```bash +pip install ".[visualization]" +``` diff --git a/README.md b/README.md index 6f832f68..2d4c132f 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,16 @@ Treat this test suite model-based seed=eag-etou-cxi-leamv-jsi Using `seed=new` will force generation of a new reusable seed and is identical to omitting the seed argument. To completely bypass seed generation and use the system's random source, use `seed=None`. This has even more variation but does not produce a reusable seed. +### Option management + +If you want to set configuration options for use in multiple test suites without having to repeat them, the keywords __Set model-based options__ and __Update model-based options__ can be used to configure RobotMBT library options. _Set_ takes the provided options and discards any previously set options. _Update_ allows you to modify existing options or add new ones. Reset all options by calling _Set_ without arguments. Direct options provided to __Treat this test suite model-based__ take precedence over library options and affect only the current test suite. + +Tip: [Robot dictionaries](https://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#dictionary-variable) (`&{ }`) can be used to group related options and pass them as one set. + +## Contributing + +If you have feedback, ideas, or want to get involved in coding, then check out the [Contribution guidelines](https://github.com/JFoederer/robotframeworkMBT/blob/main/CONTRIBUTING.md). + ### Graphs By default, no graphs are generated for test-runs. For development purposes, having a visual representation of the test-suite you are working on can be very useful. To have robotmbt generate a graph, ensure you have installed the optional dependencies (`pip install .[visualization]`) and pass the type as an argument: @@ -245,53 +255,3 @@ If you have feedback, ideas, or want to get involved in coding, then check out t ## Disclaimer Please note that this library is in a premature state and hasn't reached its first official (1.0) release yet. Developments are ongoing within the context of the [TiCToC](https://tictoc.cs.ru.nl/) research project. Interface changes are still frequent, and no deprecation warnings are being issued yet. - - -## Development -### Python virtual environment -Installing the proper virtual environment can be done with the default `python -m venv ./.venv` command built into python. However, if you have another version of python on your system, this might break dependencies. - -#### Pipenv+Pyenv (verified on Windows and Linux) -For the optimal experience (at least on Linux), we suggest installing the following packages: -- [`pyenv`](https://github.com/pyenv/pyenv) (Linux/Mac) or [`pyenv-win`](https://github.com/pyenv-win/pyenv-win) (Windows) -- [`pipenv`](https://github.com/pypa/pipenv) - -Then, you can install a python virtual environment with: - -```bash -pipenv --python -``` -..where the python version can be found in the `pyproject.toml`. For example, for 3.10: `pipenv --python 3.10`. - -You might need to manually make the folder `.venv` by doing `mkdir .venv`. - -You can verify if the install went correctly with: -```bash -pipenv check -``` -This should return `Passed!` - -Errors related to minor versions (for example `3.10.0rc2` != `3.10.0`) can be ignored. - -Now activate the virtual environment by running -```bash -pipenv shell -``` - -..and you should have a virtual env! If you run -```bash -python --version -``` -..while in your virtual environment, it should show the `` from before. - - -### Installing dependencies -***NOTE: making sure that you are in the virtual environment***. - -It is recommended that you also include the optional depedencies for visualisation, e.g.: -```bash -pip install ".[visualization]" -``` - - - diff --git a/robotmbt/modeller.py b/robotmbt/modeller.py index 3c9fd990..e92e8c66 100644 --- a/robotmbt/modeller.py +++ b/robotmbt/modeller.py @@ -36,7 +36,7 @@ from robot.utils import is_list_like from .modelspace import ModelSpace -from .steparguments import StepArgument, StepArguments +from .steparguments import StepArguments, ArgKind from .substitutionmap import SubstitutionMap from .suitedata import Scenario, Step from .tracestate import TraceState, TraceSnapShot @@ -172,7 +172,7 @@ def generate_scenario_variant(scenario: Scenario, model: ModelSpace) -> Scenario modded_arg, constraint = _parse_modifier_expression(expr, step.args) if step.args[modded_arg].is_default: continue - if step.args[modded_arg].kind in [StepArgument.EMBEDDED, StepArgument.POSITIONAL, StepArgument.NAMED]: + if step.args[modded_arg].kind in [ArgKind.EMBEDDED, ArgKind.POSITIONAL, ArgKind.NAMED]: org_example = step.args[modded_arg].org_value if step.gherkin_kw == 'then': constraint = None # No new constraints are processed for then-steps @@ -193,7 +193,7 @@ def generate_scenario_variant(scenario: Scenario, model: ModelSpace) -> Scenario else: options = None subs.substitute(org_example, options) - elif step.args[modded_arg].kind == StepArgument.VAR_POS: + elif step.args[modded_arg].kind == ArgKind.VAR_POS: if step.args[modded_arg].value: modded_varargs = model.process_expression(constraint, step.args) if not is_list_like(modded_varargs): @@ -202,7 +202,7 @@ def generate_scenario_variant(scenario: Scenario, model: ModelSpace) -> Scenario # change the number of arguments in the list, making it impossible to decide which values to # match and which to drop and/or duplicate. step.args[modded_arg].value = modded_varargs - elif step.args[modded_arg].kind == StepArgument.FREE_NAMED: + elif step.args[modded_arg].kind == ArgKind.FREE_NAMED: if step.args[modded_arg].value: modded_free_args = model.process_expression(constraint, step.args) if not isinstance(modded_free_args, dict): @@ -234,7 +234,7 @@ def generate_scenario_variant(scenario: Scenario, model: ModelSpace) -> Scenario if step.args[modded_arg].is_default: continue org_example = step.args[modded_arg].org_value - if step.args[modded_arg].kind in [StepArgument.EMBEDDED, StepArgument.POSITIONAL, StepArgument.NAMED]: + if step.args[modded_arg].kind in [ArgKind.EMBEDDED, ArgKind.POSITIONAL, ArgKind.NAMED]: step.args[modded_arg].value = subs.solution[org_example] return scenario diff --git a/robotmbt/modelspace.py b/robotmbt/modelspace.py index 1504a7fd..e5cac2a9 100644 --- a/robotmbt/modelspace.py +++ b/robotmbt/modelspace.py @@ -45,9 +45,7 @@ def __init__(self, reference_id=None): self.ref_id: str = str(reference_id) self.std_attrs: list[str] = [] self.props: dict[str, RecursiveScope | ModelSpace] = dict() - - # For using literals without having to use quotes (abc='abc') - self.values: dict[str, Any] = dict() + self.values: dict[str, Any] = dict() # For using literals without having to use quotes (abc='abc') self.scenario_vars: list[RecursiveScope] = [] self.std_attrs = dir(self) @@ -55,7 +53,6 @@ def __repr__(self): return self.ref_id if self.ref_id else super().__repr__() def copy(self): - # -> Self return copy.deepcopy(self) def __eq__(self, other): @@ -84,13 +81,11 @@ def __dir__(self, recurse=True): return self.__dict__.keys() def new_scenario_scope(self): - self.scenario_vars.append(RecursiveScope( - self.scenario_vars[-1] if len(self.scenario_vars) else None)) + self.scenario_vars.append(RecursiveScope(self.scenario_vars[-1] if len(self.scenario_vars) else None)) self.props['scenario'] = self.scenario_vars[-1] def end_scenario_scope(self): - assert len( - self.scenario_vars) > 0, ".end_scenario_scope() called, but there is no scenario scope open." + assert len(self.scenario_vars) > 0, ".end_scenario_scope() called, but there is no scenario scope open." self.scenario_vars.pop() if len(self.scenario_vars): self.props['scenario'] = self.scenario_vars[-1] @@ -111,8 +106,7 @@ def process_expression(self, expression: str, step_args: StepArguments = StepArg for p in self.props: exec(f"{p} = self.props['{p}']", local_locals) for v in self.values: - value = f"'{self.values[v]}'" if isinstance( - self.values[v], str) else self.values[v] + value = f"'{self.values[v]}'" if isinstance(self.values[v], str) else self.values[v] exec(f"{v} = {value}", local_locals) try: result = eval(expr, local_locals) @@ -138,7 +132,7 @@ def process_expression(self, expression: str, step_args: StepArguments = StepArg return result - def __handle_attribute_error(self, err): + def __handle_attribute_error(self, err: AttributeError): if isinstance(err.obj, str) and err.obj in self.values: # This situation occurs when using e.g. 'foo.bar' in the model before calling 'new foo'. # The NameError on foo is handled by adding its alias, which results in an AttributeError @@ -146,18 +140,16 @@ def __handle_attribute_error(self, err): raise ModellingError(f"{err.obj} used before definition") raise ModellingError(f"{err.name} used before assignment") - def __add_alias(self, missing_name: str, step_args): + def __add_alias(self, missing_name: str, step_args: StepArguments): if missing_name == 'scenario': raise ModellingError("Accessing scenario scope while there is no scenario active.\n" "If you intended this to be a literal, please use quotes ('scenario' or \"scenario\").") - matching_args = [ - arg.value for arg in step_args if arg.codestring == missing_name] + matching_args = [arg.value for arg in step_args if arg.codestring == missing_name] value = matching_args[0] if matching_args else missing_name if isinstance(value, str): for esc_char in "$@&=": # Prevent "Syntaxwarning: invalid escape sequence" on Robot escapes like '\$' and '\=' value = value.replace(f'\\{esc_char}', f'\\\\{esc_char}') - # Needed because we use single quotes in low level processing later on - value = value.replace("'", r"\'") + value = value.replace("'", r"\'") # Needed because we use single quotes in low level processing later on self.values[missing_name] = value @staticmethod @@ -223,3 +215,17 @@ def __iter__(self): def __bool__(self): return any(True for _ in self) + + def __eq__(self, other): + self_set = set([(attr, getattr(self, attr)) for attr in dir(self._outer_scope) + dir(self) + if not attr.startswith('__') and attr != '_outer_scope']) + other_set = set([(attr, getattr(other, attr)) for attr in dir(other._outer_scope) + dir(other) + if not attr.startswith('__') and attr != '_outer_scope']) + return self_set == other_set + + def __str__(self): + res = "{" + for k, v in self: + res += f"{k}={v}, " + res += "}" + return res diff --git a/robotmbt/steparguments.py b/robotmbt/steparguments.py index 06980151..c4f89866 100644 --- a/robotmbt/steparguments.py +++ b/robotmbt/steparguments.py @@ -30,9 +30,10 @@ # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +from enum import Enum, auto from keyword import iskeyword -import builtins from typing import Any +import builtins class StepArguments(list): @@ -57,24 +58,26 @@ def modified(self) -> bool: return any([arg.modified for arg in self]) +class ArgKind(Enum): + EMBEDDED = auto() + POSITIONAL = auto() + VAR_POS = auto() + NAMED = auto() + FREE_NAMED = auto() + UNKNOWN = auto() + + class StepArgument: - # kind list - EMBEDDED = 'EMBEDDED' - POSITIONAL = 'POSITIONAL' - VAR_POS = 'VAR_POS' - NAMED = 'NAMED' - FREE_NAMED = 'FREE_NAMED' - - def __init__(self, arg_name: str, value: Any, kind: str | None = None, is_default: bool = False): + def __init__(self, arg_name: str, value: Any, kind: ArgKind = ArgKind.UNKNOWN, is_default: bool = False): self.name: str = arg_name self.org_value: Any = value - self.kind: str | None = kind # one of the values from the kind list + self.kind: ArgKind = kind self._value: Any = None self._codestr: str | None = None - self.value: Any = value + self.value = value # is_default indicates that the argument was not filled in from the scenario. This # argment's value is taken from the keyword's default as provided by Robot. - self.is_default: bool = is_default + self.is_default: bool = is_default @property def arg(self) -> str: @@ -86,7 +89,7 @@ def value(self) -> Any: @value.setter def value(self, value: Any): - self._value = value + self._value: Any = value self._codestr = self.make_codestring(value) self.is_default = False @@ -99,7 +102,6 @@ def codestring(self) -> str | None: return self._codestr def copy(self): - # -> Self cp = StepArgument(self.arg.strip('${}'), self.value, self.kind, self.is_default) cp.org_value = self.org_value return cp diff --git a/robotmbt/substitutionmap.py b/robotmbt/substitutionmap.py index ea7648af..f2494ec4 100644 --- a/robotmbt/substitutionmap.py +++ b/robotmbt/substitutionmap.py @@ -31,6 +31,7 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import random +from typing import Any class SubstitutionMap: @@ -43,24 +44,20 @@ class SubstitutionMap: """ def __init__(self): - # {example_value:Constraint} - self.substitutions: dict[str, Constraint] = {} - - # {example_value:solution_value} - self.solution: dict[str, int | str] = {} + self.substitutions = {} # {example_value:Constraint} + self.solution = {} # {example_value:solution_value} def __str__(self): src = self.solution or self.substitutions return ", ".join([f"{k} ⤝ {v}" for k, v in src.items()]) def copy(self): - # -> Self new = SubstitutionMap() new.substitutions = {k: v.copy() for k, v in self.substitutions.items()} new.solution = self.solution.copy() return new - def substitute(self, example_value: str, constraint: list[int]): + def substitute(self, example_value: str, constraint: list[Any]): self.solution = {} if example_value in self.substitutions: self.substitutions[example_value].add_constraint(constraint) @@ -77,8 +74,7 @@ def solve(self) -> dict[str, str]: while unsolved_subs: unsolved_subs.sort(key=lambda i: len(substitutions[i].optionset)) example_value = unsolved_subs[0] - solution[example_value] = random.choice( - substitutions[example_value].optionset) + solution[example_value] = random.choice(substitutions[example_value].optionset) subs_stack.append(example_value) others_list = [] @@ -103,15 +99,13 @@ def solve(self) -> dict[str, str]: subs_stack.pop() except IndexError: # nothing left to roll back, no options remaining - raise ValueError( - "No solution found within the set of given constraints") + raise ValueError("No solution found within the set of given constraints") last_item = subs_stack[-1] unsolved_subs.insert(0, last_item) for other in [e for e in substitutions if e != last_item]: substitutions[other].undo_remove() try: - substitutions[last_item].remove_option( - solution.pop(last_item)) + substitutions[last_item].remove_option(solution.pop(last_item)) rollback_done = True except ValueError: # next level must also be rolled back @@ -122,17 +116,15 @@ def solve(self) -> dict[str, str]: class Constraint: - def __init__(self, constraint): + def __init__(self, constraint: list[Any]): try: # Keep the items in optionset unique. Refrain from using Python sets # due to non-deterministic behaviour when using random seeding. - self.optionset: list | None = list(dict.fromkeys(constraint)) + self.optionset: list[Any] | None = list(dict.fromkeys(constraint)) except: - self.optionset: list | None = None + self.optionset: list[Any] | None = None if not self.optionset or isinstance(constraint, str): - raise ValueError( - f"Invalid option set for initial constraint: {constraint}" - ) + raise ValueError(f"Invalid option set for initial constraint: {constraint}") self.removed_stack: list[str | Placeholder] = [] @@ -143,13 +135,11 @@ def __iter__(self): return iter(self.optionset) def copy(self): - # -> Self return Constraint(self.optionset) - def add_constraint(self, constraint): + def add_constraint(self, constraint: list[Any] | None): if constraint is None: return - self.optionset = [opt for opt in self.optionset if opt in constraint] if not len(self.optionset): raise ValueError('No options left after adding constraint') diff --git a/robotmbt/suitedata.py b/robotmbt/suitedata.py index 11a796d9..885f2022 100644 --- a/robotmbt/suitedata.py +++ b/robotmbt/suitedata.py @@ -31,24 +31,26 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import copy -from typing import Any +from typing import Literal, Any +from robot.running.arguments.argumentspec import ArgumentSpec from robot.running.arguments.argumentvalidator import ArgumentValidator +from robot.running.keywordimplementation import KeywordImplementation import robot.utils.notset -from .steparguments import StepArgument, StepArguments +from .steparguments import StepArgument, StepArguments, ArgKind from .substitutionmap import SubstitutionMap class Suite: - def __init__(self, name: str, parent: Any = None): + def __init__(self, name: str, parent=None): self.name: str = name self.filename: str = '' self.parent: Suite | None = parent self.suites: list[Suite] = [] self.scenarios: list[Scenario] = [] - self.setup: Step | str | None = None # Can be a single step or None - self.teardown: Step | str | None = None # Can be a single step or None + self.setup: Step | None = None # Can be a single step or None + self.teardown: Step | None = None # Can be a single step or None @property def longname(self) -> str: @@ -60,7 +62,6 @@ def has_error(self) -> bool: or any([s.has_error() for s in self.scenarios]) or (self.teardown.has_error() if self.teardown else False)) - # list[Step | str | None], Step needs to be moved up def steps_with_errors(self): return (([self.setup] if self.setup and self.setup.has_error() else []) + [e for s in map(Suite.steps_with_errors, self.suites) for e in s] @@ -74,11 +75,11 @@ def __init__(self, name: str, parent: Suite | None = None): # Parent scenario is kept for easy searching, processing and referencing # after steps and scenarios have been potentially moved around self.parent: Suite | None = parent - self.setup: Step | None = None - self.teardown: Step | None = None + self.setup: Step | None = None # Can be a single step or None + self.teardown: Step | None = None # Can be a single step or None self.steps: list[Step] = [] self.src_id: int | None = None - self.data_choices: dict | SubstitutionMap = {} # may be Dummy type in a test + self.data_choices: SubstitutionMap = SubstitutionMap() @property def longname(self) -> str: @@ -89,20 +90,18 @@ def has_error(self) -> bool: or any([s.has_error() for s in self.steps]) or (self.teardown.has_error() if self.teardown else False)) - def steps_with_errors(self): # list[Step | None] + def steps_with_errors(self): return (([self.setup] if self.setup and self.setup.has_error() else []) + [s for s in self.steps if s.has_error()] + ([self.teardown] if self.teardown and self.teardown.has_error() else [])) def copy(self): - # -> Self duplicate = copy.copy(self) duplicate.steps = [step.copy() for step in self.steps] duplicate.data_choices = self.data_choices.copy() return duplicate def split_at_step(self, stepindex: int): - # -> tuple[Self, Self] """Returns 2 partial scenarios. With stepindex 0 the first part has no steps and all steps are in the last part. With @@ -120,35 +119,26 @@ def split_at_step(self, stepindex: int): class Step: def __init__(self, steptext: str, *args, parent: Suite | Scenario, assign: tuple[str] = (), - prev_gherkin_kw: str | None = None): + prev_gherkin_kw: Literal['given', 'when', 'then'] | None = None): # org_step is the first keyword cell of the Robot line, including step_kw, # excluding positional args, excluding variable assignment. self.org_step: str = steptext - # org_pn_args are the positional and named arguments as parsed # from the Robot text ('posA' , 'posB', 'named1=namedA') - self.org_pn_args = args - # Parent scenario for easy searching and processing. - self.parent: Suite | Scenario = parent + self.org_pn_args: tuple[str, ...] = args + self.parent: Suite | Scenario = parent # Parent scenario for easy searching and processing. # For when a keyword's return value is assigned to a variable. # Taken directly from Robot. self.assign: tuple[str] = assign - # gherkin_kw is one of 'given', 'when', 'then', or None for non-bdd keywords. - self.gherkin_kw: str | None = self.step_kw \ - if str(self.step_kw).lower() in ['given', 'when', 'then', 'none'] \ - else prev_gherkin_kw - - # Robot keyword with its embedded arguments in ${...} notation. - self.signature: str | None = None - # embedded arguments list of StepArgument objects. - self.args: StepArguments = StepArguments() - # Decouples StepArguments from the step text (refinement use case) - self.detached: bool = False - + self.gherkin_kw = self.step_kw if \ + str(self.step_kw).lower() in ['given', 'when', 'then', 'none'] else prev_gherkin_kw + self.signature: str | None = None # Robot keyword with its embedded arguments in ${...} notation. + self.args: StepArguments = StepArguments() # embedded arguments list of StepArgument objects. + self.detached: bool = False # Decouples StepArguments from the step text (refinement use case) # model_info contains modelling information as a dictionary. The standard format is # dict(IN=[], OUT=[]) and can optionally contain an error field. - # IN and OUT are lists of Python evaluatable expressions. + # The values of IN and OUT are lists of Python evaluatable expressions. # The `new vocab` form can be used to create new domain objects. # The `vocab.attribute` form can then be used to express relations # between properties from the domain vocabulaire. @@ -162,7 +152,6 @@ def __repr__(self): return f"Step: '{self}' with model info: {self.model_info}" def copy(self): - # -> Self cp = Step(self.org_step, *self.org_pn_args, parent=self.parent, assign=self.assign) cp.gherkin_kw = self.gherkin_kw cp.signature = self.signature @@ -190,7 +179,7 @@ def keyword(self) -> str: return self.args.fill_in_args(s) @property - def posnom_args_str(self) -> tuple[Any]: + def posnom_args_str(self) -> tuple[str, ...]: """A tuple with all arguments in Robot accepted text format ('posA' , 'posB', 'named1=namedA')""" if self.detached or not self.args.modified: return self.org_pn_args @@ -198,26 +187,25 @@ def posnom_args_str(self) -> tuple[Any]: for arg in self.args: if arg.is_default: continue - if arg.kind == arg.POSITIONAL: + if arg.kind == ArgKind.POSITIONAL: result.append(arg.value) - elif arg.kind == arg.VAR_POS: + elif arg.kind == ArgKind.VAR_POS: for vararg in arg.value: result.append(vararg) - elif arg.kind == arg.NAMED: + elif arg.kind == ArgKind.NAMED: result.append(f"{arg.name}={arg.value}") - elif arg.kind == arg.FREE_NAMED: + elif arg.kind == ArgKind.FREE_NAMED: for name, value in arg.value.items(): result.append(f"{name}={value}") - else: # TODO: remove this - has no impact on the control flow. - continue return tuple(result) @property - def gherkin_kw(self) -> str | None: + def gherkin_kw(self) -> Literal['given', 'when', 'then'] | None: return self._gherkin_kw @gherkin_kw.setter def gherkin_kw(self, value: str | None): + """if value is type str, it must be a case insensitive variant of given, when, then""" self._gherkin_kw = value.lower() if value else None @property @@ -230,23 +218,22 @@ def kw_wo_gherkin(self) -> str: """The keyword without its Gherkin keyword. I.e., as it is known in Robot framework.""" return self.keyword.replace(self.step_kw, '', 1).strip() if self.step_kw else self.keyword - def add_robot_dependent_data(self, robot_kw): + def add_robot_dependent_data(self, robot_kw: KeywordImplementation): """robot_kw must be Robot Framework's keyword object from Robot's runner context""" try: if robot_kw.error: raise ValueError(robot_kw.error) if robot_kw.embedded: - self.args = StepArguments([StepArgument(*match, kind=StepArgument.EMBEDDED) for match in + self.args = StepArguments([StepArgument(*match, kind=ArgKind.EMBEDDED) for match in zip(robot_kw.embedded.args, robot_kw.embedded.parse_args(self.kw_wo_gherkin))]) - self.args += self.__handle_non_embedded_arguments(robot_kw.args) self.signature = robot_kw.name - self.model_info: dict[str, list[str] | str] = self.__parse_model_info(robot_kw._doc) + self.model_info = self.__parse_model_info(robot_kw._doc) except Exception as ex: self.model_info['error'] = str(ex) - def __handle_non_embedded_arguments(self, robot_argspec) -> list[StepArgument]: + def __handle_non_embedded_arguments(self, robot_argspec: ArgumentSpec) -> list[StepArgument]: result = [] p_args = [a for a in self.org_pn_args if '=' not in a or r'\=' in a] n_args = [a.split('=', 1) for a in self.org_pn_args if '=' in a and r'\=' not in a] @@ -257,19 +244,19 @@ def __handle_non_embedded_arguments(self, robot_argspec) -> list[StepArgument]: for arg in robot_argspec: if not p_args or (arg.kind != arg.POSITIONAL_ONLY and arg.kind != arg.POSITIONAL_OR_NAMED): break - result.append(StepArgument(argument_names.pop(0), p_args.pop(0), kind=StepArgument.POSITIONAL)) + result.append(StepArgument(argument_names.pop(0), p_args.pop(0), kind=ArgKind.POSITIONAL)) robot_args.pop(0) if p_args and robot_args[0].kind == robot_args[0].VAR_POSITIONAL: - result.append(StepArgument(argument_names.pop(0), p_args, kind=StepArgument.VAR_POS)) + result.append(StepArgument(argument_names.pop(0), p_args, kind=ArgKind.VAR_POS)) free = {} for name, value in n_args: if name in argument_names: - result.append(StepArgument(name, value, kind=StepArgument.NAMED)) + result.append(StepArgument(name, value, kind=ArgKind.NAMED)) argument_names.remove(name) else: free[name] = value if free: - result.append(StepArgument(argument_names.pop(-1), free, kind=StepArgument.FREE_NAMED)) + result.append(StepArgument(argument_names.pop(-1), free, kind=ArgKind.FREE_NAMED)) for unmentioned_arg in argument_names: arg = next(arg for arg in robot_args if arg.name == unmentioned_arg) default_value = arg.default @@ -283,7 +270,7 @@ def __handle_non_embedded_arguments(self, robot_argspec) -> list[StepArgument]: # but use different names in the method signature. Robot Framework implementation is incomplete for this # aspect and differs between library and user keywords. assert False, f"No default argument expected to be needed for '{unmentioned_arg}' here" - result.append(StepArgument(unmentioned_arg, default_value, kind=StepArgument.NAMED, is_default=True)) + result.append(StepArgument(unmentioned_arg, default_value, kind=ArgKind.NAMED, is_default=True)) return result @staticmethod @@ -293,8 +280,8 @@ def __validate_arguments(spec, positionals, nameds): # Robot's mapping favours positional when possible, even when the name is used # in the keyword call. The validator is sensitive to these differences. p, n = spec.map(positionals, nameds) - # for some reason .map() returns [None] instead of the empty list when there are no arguments if p == [None]: + # for some reason .map() returns [None] instead of the empty list when there are no arguments p = [] # Use the Robot mechanism for validation to yield familiar error messages ArgumentValidator(spec).validate(p, n) diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index 94b9c75b..31377b4b 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -41,6 +41,7 @@ from .suitedata import Suite, Scenario from .tracestate import TraceState + try: from .visualise.visualiser import Visualiser from .visualise.models import TraceInfo @@ -54,7 +55,7 @@ class SuiteProcessors: @staticmethod - def echo(in_suite): + def echo(in_suite: Suite) -> Suite: return in_suite def flatten(self, in_suite: Suite) -> Suite: @@ -87,8 +88,8 @@ def flatten(self, in_suite: Suite) -> Suite: out_suite.suites = [] return out_suite - def process_test_suite(self, in_suite: Suite, *, seed: Any = 'new', graph: str = '', - export_graph_data: str = '', import_graph_data: str = '') -> Suite: + def process_test_suite(self, in_suite: Suite, *, seed: str | int | bytes | bytearray = 'new', + graph: str = '', export_graph_data: str = '', import_graph_data: str = '') -> Suite: self.out_suite = Suite(in_suite.name) self.out_suite.filename = in_suite.filename self.out_suite.parent = in_suite.parent @@ -110,19 +111,19 @@ def _load_graph(self, graph: str, suite_name: str, from_json: str): traceinfo = traceinfo.import_graph(from_json) self.visualiser = Visualiser(graph, suite_name, trace_info=traceinfo) - def _run_test_suite(self, seed: Any, graph: str, suite_name: str, export_dir: str): + def _run_test_suite(self, seed: str | int | bytes | bytearray, graph: str, suite_name: str, export_dir: str): for id, scenario in enumerate(self.flat_suite.scenarios, start=1): scenario.src_id = id - self.scenarios = self.flat_suite.scenarios[:] + self.scenarios: list[Scenario] = self.flat_suite.scenarios[:] logger.debug("Use these numbers to reference scenarios from traces\n\t" + "\n\t".join([f"{s.src_id}: {s.name}" for s in self.scenarios])) self._init_randomiser(seed) - self.shuffled = [s.src_id for s in self.scenarios] + self.shuffled: list[int] = [s.src_id for s in self.scenarios] random.shuffle(self.shuffled) # Keep a single shuffle for all TraceStates (non-essential) self.visualiser = None - if visualisation_deps_present: + if visualisation_deps_present and (graph or export_dir): try: self.visualiser = Visualiser(graph, suite_name, export_dir) except Exception as e: @@ -143,6 +144,7 @@ def _run_test_suite(self, seed: Any, graph: str, suite_name: str, export_dir: st raise Exception("Unable to compose a consistent suite") self.out_suite.scenarios = tracestate.get_trace() + self._report_tracestate_wrapup(tracestate) def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool) -> TraceState: tracestate = TraceState(self.shuffled) @@ -192,7 +194,8 @@ def __update_visualisation(self, tracestate: TraceState): def __write_visualisation(self): if self.visualiser is not None: try: - logger.info(self.visualiser.generate_visualisation(), html=True) + text, html = self.visualiser.generate_visualisation() + logger.info(text, html=html) except Exception as e: logger.warn(f'Could not generate visualisation due to error!\n{e}') @@ -218,7 +221,7 @@ def _scenario_with_repeat_counter(self, index: int, tracestate: TraceState) -> S rep_count = tracestate.count(index) if rep_count: candidate = candidate.copy() - candidate.name = f"{candidate.name} (rep {rep_count + 1})" + candidate.name = f"{candidate.name} (rep {rep_count+1})" return candidate @staticmethod @@ -244,7 +247,7 @@ def _report_tracestate_wrapup(tracestate: TraceState): logger.debug(f"model\n{progression.model.get_status_text()}\n") @staticmethod - def _init_randomiser(seed: Any): + def _init_randomiser(seed: str | int | bytes | bytearray): if isinstance(seed, str): seed = seed.strip() diff --git a/robotmbt/suitereplacer.py b/robotmbt/suitereplacer.py index d44b83d2..2f0debb8 100644 --- a/robotmbt/suitereplacer.py +++ b/robotmbt/suitereplacer.py @@ -30,31 +30,25 @@ # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -from typing import Any - import robot.model - +import robot.running.model as rmodel from .suitedata import Suite, Scenario, Step from .suiteprocessors import SuiteProcessors -import robot.running.model as rmodel from robot.api import logger -from robot.api.deco import keyword +from robot.api.deco import library, keyword +from typing import Any from robot.libraries.BuiltIn import BuiltIn - Robot = BuiltIn() +@library(scope="GLOBAL", listener='SELF') class SuiteReplacer: - ROBOT_LIBRARY_SCOPE: str = 'GLOBAL' - ROBOT_LISTENER_API_VERSION: int = 3 - def __init__(self, processor: str = 'process_test_suite', processor_lib: str | None = None): - self.ROBOT_LIBRARY_LISTENER = self # : Self self.current_suite: robot.model.TestSuite | None = None self.robot_suite: robot.model.TestSuite | None = None self.processor_lib_name: str | None = processor_lib self.processor_name: str = processor - self._processor_lib: SuiteProcessors | None = None + self._processor_lib: SuiteProcessors | None | object = None self._processor_method: Any = None self.processor_options: dict[str, Any] = {} @@ -120,37 +114,27 @@ def __process_robot_suite(self, in_suite: robot.model.TestSuite, parent: Suite | out_suite.filename = in_suite.source if in_suite.setup and parent is not None: - step_info = Step(in_suite.setup.name, * - in_suite.setup.args, parent=out_suite) - step_info.add_robot_dependent_data( - Robot._namespace.get_runner(step_info.org_step).keyword) + step_info = Step(in_suite.setup.name, *in_suite.setup.args, parent=out_suite) + step_info.add_robot_dependent_data(Robot._namespace.get_runner(step_info.org_step).keyword) out_suite.setup = step_info if in_suite.teardown and parent is not None: - step_info = Step(in_suite.teardown.name, * - in_suite.teardown.args, parent=out_suite) - step_info.add_robot_dependent_data( - Robot._namespace.get_runner(step_info.org_step).keyword) + step_info = Step(in_suite.teardown.name, *in_suite.teardown.args, parent=out_suite) + step_info.add_robot_dependent_data(Robot._namespace.get_runner(step_info.org_step).keyword) out_suite.teardown = step_info for st in in_suite.suites: - out_suite.suites.append( - self.__process_robot_suite(st, parent=out_suite)) - + out_suite.suites.append(self.__process_robot_suite(st, parent=out_suite)) for tc in in_suite.tests: scenario = Scenario(tc.name, parent=out_suite) if tc.setup: - step_info = Step( - tc.setup.name, *tc.setup.args, parent=scenario) - step_info.add_robot_dependent_data( - Robot._namespace.get_runner(step_info.org_step).keyword) + step_info = Step(tc.setup.name, *tc.setup.args, parent=scenario) + step_info.add_robot_dependent_data(Robot._namespace.get_runner(step_info.org_step).keyword) scenario.setup = step_info if tc.teardown: - step_info = Step(tc.teardown.name, * - tc.teardown.args, parent=scenario) - step_info.add_robot_dependent_data( - Robot._namespace.get_runner(step_info.org_step).keyword) + step_info = Step(tc.teardown.name, *tc.teardown.args, parent=scenario) + step_info.add_robot_dependent_data(Robot._namespace.get_runner(step_info.org_step).keyword) scenario.teardown = step_info last_gwt = None @@ -158,16 +142,14 @@ def __process_robot_suite(self, in_suite: robot.model.TestSuite, parent: Suite | if isinstance(step_def, rmodel.Keyword): step_info = Step(step_def.name, *step_def.args, parent=scenario, assign=step_def.assign, prev_gherkin_kw=last_gwt) - step_info.add_robot_dependent_data( - Robot._namespace.get_runner(step_info.org_step).keyword) + step_info.add_robot_dependent_data(Robot._namespace.get_runner(step_info.org_step).keyword) scenario.steps.append(step_info) if step_info.gherkin_kw: last_gwt = step_info.gherkin_kw elif isinstance(step_def, rmodel.Var): - scenario.steps.append( - Step('VAR', step_def.name, *step_def.value, parent=scenario)) + scenario.steps.append(Step('VAR', step_def.name, *step_def.value, parent=scenario)) else: unsupported_step = Step(str(step_def), parent=scenario) unsupported_step.model_info['error'] = f"Robot construct not supported" @@ -206,11 +188,9 @@ def __generateRobotSuite(self, suite_model: Suite, target_suite: robot.model.Tes type='teardown') for step in tc.steps: if step.keyword == 'VAR': - new_tc.body.create_var( - step.posnom_args_str[0], step.posnom_args_str[1:]) + new_tc.body.create_var(step.posnom_args_str[0], step.posnom_args_str[1:]) else: - new_tc.body.create_keyword( - name=step.keyword, assign=step.assign, args=step.posnom_args_str) + new_tc.body.create_keyword(name=step.keyword, assign=step.assign, args=step.posnom_args_str) def _start_suite(self, suite: Suite | None, result): self.current_suite = suite diff --git a/robotmbt/tracestate.py b/robotmbt/tracestate.py index 770b0b43..4161662e 100644 --- a/robotmbt/tracestate.py +++ b/robotmbt/tracestate.py @@ -33,30 +33,28 @@ from robotmbt.modelspace import ModelSpace from robotmbt.suitedata import Scenario + class TraceSnapShot: - def __init__(self, id: str, inserted_scenario: Scenario | str, model_state: ModelSpace, remainder: Scenario | None = None, drought: int = 0): + def __init__(self, id: str, inserted_scenario: Scenario, model_state: ModelSpace, + remainder: Scenario | None = None, drought: int = 0): self.id: str = id - self.scenario: str | Scenario = inserted_scenario + self.scenario: Scenario = inserted_scenario self.remainder: Scenario | None = remainder self._model: ModelSpace = model_state.copy() self.coverage_drought: int = drought @property - def model(self): + def model(self) -> ModelSpace: return self._model.copy() class TraceState: def __init__(self, scenario_indexes: list[int]): - self.c_pool = {index: 0 for index in scenario_indexes} + self.c_pool: dict[int, int] = {index: 0 for index in scenario_indexes} if len(self.c_pool) != len(scenario_indexes): raise ValueError("Scenarios must be uniquely identifiable") - - # Keeps track of the scenarios already tried at each step in the trace - self._tried: list[list[int]] = [[]] - - # Keeps details for elements in trace - self._snapshots: list[TraceSnapShot] = [] + self._tried: list[list[int]] = [[]] # Keeps track of the scenarios already tried at each step in the trace + self._snapshots: list[TraceSnapShot] = [] # Keeps details for elements in trace self._open_refinements: list[int] = [] @property @@ -85,10 +83,10 @@ def active_refinements(self): def coverage_reached(self): return all(self.c_pool.values()) - def get_trace(self) -> list[str | Scenario]: + def get_trace(self) -> list[Scenario]: return [snap.scenario for snap in self._snapshots] - def next_candidate(self, retry: bool=False): + def next_candidate(self, retry: bool = False): for i in self.c_pool: if i not in self._tried[-1] and not self.is_refinement_active(i) and self.count(i) == 0: return i @@ -113,14 +111,14 @@ def highest_part(self, index: int) -> int: Given the current trace and an index, returns the highest part number of an ongoing refinement for the related scenario. Returns 0 when there is no refinement active. """ - for i in range(1, len(self.id_trace)+1): + for i in range(1, len(self.id_trace) + 1): if self.id_trace[-i] == f'{index}': return 0 if self.id_trace[-i].startswith(f'{index}.'): return int(self.id_trace[-i].split('.')[1]) return 0 - def is_refinement_active(self, index: int = None) -> bool: + def is_refinement_active(self, index: int | None = None) -> bool: """ When called with an index, returns True if that scenario is currently being refined When index is ommitted, return True if any refinement is active @@ -130,21 +128,21 @@ def is_refinement_active(self, index: int = None) -> bool: else: return self.highest_part(index) != 0 - def get_remainder(self, index: int): + def get_remainder(self, index: int) -> Scenario | None: """ When pushing a partial scenario, the remainder can be passed along for safe keeping. This method retrieves the remainder for the last part that was pushed. """ last_part = self.highest_part(index) - index = -self.id_trace[::-1].index(f'{index}.{last_part}')-1 + index = -self.id_trace[::-1].index(f'{index}.{last_part}') - 1 return self._snapshots[index].remainder def reject_scenario(self, i_scenario: int): """Trying a scenario excludes it from further cadidacy on this level""" self._tried[-1].append(i_scenario) - def confirm_full_scenario(self, index: int, scenario: Scenario | str, model: ModelSpace): - c_drought = 0 if self.c_pool[index] == 0 else self.coverage_drought+1 + def confirm_full_scenario(self, index: int, scenario: Scenario, model: ModelSpace): + c_drought = 0 if self.c_pool[index] == 0 else self.coverage_drought + 1 self.c_pool[index] += 1 if self.is_refinement_active(index): id = f"{index}.0" @@ -155,10 +153,9 @@ def confirm_full_scenario(self, index: int, scenario: Scenario | str, model: Mod self._tried.append([]) self._snapshots.append(TraceSnapShot(id, scenario, model, drought=c_drought)) - def push_partial_scenario(self, index: int, scenario: Scenario | str, model: ModelSpace, remainder=None): + def push_partial_scenario(self, index: int, scenario: Scenario, model: ModelSpace, remainder=None): if self.is_refinement_active(index): id = f"{index}.{self.highest_part(index) + 1}" - else: id = f"{index}.1" self._tried[-1].append(index) diff --git a/robotmbt/visualise/networkvisualiser.py b/robotmbt/visualise/networkvisualiser.py index f2dd4ff8..a97eeca4 100644 --- a/robotmbt/visualise/networkvisualiser.py +++ b/robotmbt/visualise/networkvisualiser.py @@ -204,13 +204,13 @@ def _create_layout(self) -> tuple[list[Node], list[Edge]]: # Feed the info to grandalf and get the layout. g = GGraph(vertices, edges) sugiyama = SugiyamaLayout(g.C[0]) - + # Set specific margins as these values worked best in user-testing sugiyama.xspace = 10 sugiyama.yspace = 15 sugiyama.dw = 2 sugiyama.dh = 2 - + sugiyama.init_all(roots=[start], inverted_edges=flips) sugiyama.draw() @@ -562,6 +562,7 @@ def _add_node_to_sources(node: Node, final_trace: list[str], node_source: Column node_label_source.data['y'].append(node.y) node_label_source.data['label'].append(node.label) + def _calculate_dimensions(label: str) -> tuple[float, float]: """ Calculate a node's dimensions based on its label and the given font size constant. diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py index 92d9e30c..efb1bf6e 100644 --- a/robotmbt/visualise/visualiser.py +++ b/robotmbt/visualise/visualiser.py @@ -11,6 +11,15 @@ from robotmbt.visualise.models import TraceInfo, StateInfo, ScenarioInfo import html +GRAPHS = { + 'scenario': ScenarioGraph, + 'state': StateGraph, + 'scenario-state': ScenarioStateGraph, + 'delta-value': DeltaValueGraph, + 'scenario-delta-value': ScenarioDeltaValueGraph, + 'reduced-sdv': ReducedSDVGraph, +} + class Visualiser: """ @@ -25,14 +34,17 @@ def construct(cls, graph_type: str): # just calls __init__, but without having underscores etc. return cls(graph_type) - def __init__(self, graph_type: str, suite_name: str = "", export: str = '', - trace_info: TraceInfo = None): + def __init__(self, graph_type: str, suite_name: str = "", export: str = '', trace_info: TraceInfo = None): + if not export and not graph_type in GRAPHS.keys(): + raise ValueError(f"Unknown graph type: {graph_type}") self.graph_type: str = graph_type + if trace_info is None: self.trace_info: TraceInfo = TraceInfo() else: self.trace_info = trace_info + self.suite_name = suite_name self.export = export @@ -67,32 +79,25 @@ def update_trace(self, trace: TraceState): self.trace_info.update_trace(ScenarioInfo(scenario), StateInfo(model), trace_len) def _get_graph(self) -> AbstractGraph | None: - match self.graph_type: - case 'scenario': - return ScenarioGraph(self.trace_info) - case 'state': - return StateGraph(self.trace_info) - case 'scenario-state': - return ScenarioStateGraph(self.trace_info) - case 'delta-value': - return DeltaValueGraph(self.trace_info) - case 'scenario-delta-value': - return ScenarioDeltaValueGraph(self.trace_info) - case 'recuded-sdv': - return ReducedSDVGraph(self.trace_info) - - return None - - def generate_visualisation(self) -> str: + if self.graph_type not in GRAPHS.keys(): + return None + + return GRAPHS[self.graph_type](self.trace_info) + + def generate_visualisation(self) -> tuple[str, bool]: + """ + Finalize the visualisation. Exports the graph to JSON if requested, and generates HTML if requested. + The boolean signals whether the output is in HTML format or not. + """ if self.export: self.trace_info.export_graph(self.suite_name, self.export) graph: AbstractGraph = self._get_graph() - if graph is None: - if not self.export: - raise ValueError(f"Unknown graph type: {self.graph_type}") - return + if graph is None and self.export: + return f"Successfully exported to {self.export}!", False + elif graph is None: + raise ValueError(f"Unknown graph type: {self.graph_type}") html_bokeh = networkvisualiser.NetworkVisualiser(graph, self.suite_name).generate_html() - return f'' + return f'', True diff --git a/run_tests.py b/run_tests.py index 3eb8bba0..5387e5c8 100644 --- a/run_tests.py +++ b/run_tests.py @@ -40,7 +40,7 @@ # Adding the robotframeworkMBT folder to the python path forces the development # version to be used instead of the one installed on your system. You will also # need to add this path to your IDE options when running from there. - exit_code: int = robot.run_cli(['--outputdir', OUTPUT_ROOT, # type: ignore + exit_code: int = robot.run_cli(['--outputdir', OUTPUT_ROOT, '--pythonpath', THIS_DIR] + sys.argv[1:], exit=False) if utest: diff --git a/utest/test_steparguments.py b/utest/test_steparguments.py index 6c906ca6..5c73903f 100644 --- a/utest/test_steparguments.py +++ b/utest/test_steparguments.py @@ -31,7 +31,7 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import unittest -from robotmbt.steparguments import StepArgument, StepArguments +from robotmbt.steparguments import StepArgument, StepArguments, ArgKind class TestStepArgument(unittest.TestCase): @@ -94,7 +94,7 @@ def test_is_default_property(self): self.assertFalse(arg2.is_default) def test_copies_are_the_same(self): - arg1 = StepArgument('foo', 7, kind=StepArgument.NAMED, is_default=True) + arg1 = StepArgument('foo', 7, kind=ArgKind.NAMED, is_default=True) arg2 = arg1.copy() self.assertEqual(arg1.arg, arg2.arg) self.assertEqual(arg1.value, arg2.value) @@ -107,7 +107,7 @@ def test_copies_are_the_same(self): self.assertEqual(arg2.arg, '${foo}') self.assertEqual(arg2.value, 8) self.assertEqual(arg2.org_value, 7) - self.assertEqual(arg2.kind, StepArgument.NAMED) + self.assertEqual(arg2.kind, ArgKind.NAMED) self.assertEqual(arg2.is_default, False) def test_original_value_is_kept_when_copying(self): @@ -118,11 +118,11 @@ def test_original_value_is_kept_when_copying(self): self.assertEqual(arg2.value, 8) def test_copies_are_independent(self): - arg1 = StepArgument('foo', 7, StepArgument.POSITIONAL) + arg1 = StepArgument('foo', 7, ArgKind.POSITIONAL) arg1.value = 8 arg2 = arg1.copy() arg2.value = 13 - arg2.kind = StepArgument.NAMED + arg2.kind = ArgKind.NAMED self.assertEqual(arg2.value, 13) self.assertEqual(arg1.value, 8) self.assertEqual(arg1.org_value, arg2.org_value) diff --git a/utest/test_substitutionmap.py b/utest/test_substitutionmap.py index eb6df944..b8e78a47 100644 --- a/utest/test_substitutionmap.py +++ b/utest/test_substitutionmap.py @@ -355,8 +355,8 @@ def test_adding_constraint_does_not_affect_undo_remove_stack(self): c.remove_option('four') c.add_constraint(['one', 'two']) self.assertCountEqual(c.optionset, ['two']) - # four was never in there, so isn't added, and three c.undo_remove() + # four was never in there, so isn't added, and three # was removed by adding a constraint and is ignored. self.assertCountEqual(c.optionset, ['two']) c.undo_remove() diff --git a/utest/test_suitedata.py b/utest/test_suitedata.py index ab9bb152..2623703a 100644 --- a/utest/test_suitedata.py +++ b/utest/test_suitedata.py @@ -33,6 +33,7 @@ import unittest from unittest.mock import patch +from enum import Enum, auto from types import SimpleNamespace from robotmbt.suitedata import Suite, Scenario, Step @@ -285,16 +286,25 @@ def test_copies_are_independent(self): def test_exteranally_determined_attributes_are_copied_along(self): self.scenario.src_id = 7 - class Dummy: + class SubstitutionMap: def copy(self): return 'dummy' - self.scenario.data_choices = Dummy() + self.scenario.data_choices = SubstitutionMap() dup = self.scenario.copy() self.assertEqual(dup.src_id, self.scenario.src_id) self.assertEqual(dup.data_choices, 'dummy') +class ArgKind(Enum): + EMBEDDED = auto() + POSITIONAL = auto() + VAR_POS = auto() + NAMED = auto() + FREE_NAMED = auto() + + @patch('robotmbt.suitedata.ArgumentValidator') +@patch('robotmbt.suitedata.ArgKind', new=ArgKind) class TestSteps(unittest.TestCase): def setUp(self): self.steps = self.create_steps() @@ -397,33 +407,37 @@ def test_return_value_multi_assignment_is_part_of_the_full_keyword_text(self, mo def test_argument_with_default_is_omitted_from_keyword_when_not_mentioned_named(self, mock): step = Step(RobotKwStub.STEPTEXT, 'posA', 'pos2=posB', parent=None) - step.args = StubStepArguments([StubArgument(name='pos1', value='posA', is_default=False, kind='POSITIONAL'), - StubArgument(name='pos2', value='posB', is_default=False, kind='NAMED'), - StubArgument(name='named1', value='namedA', is_default=True, kind='NAMED')]) + step.args = StubStepArguments( + [StubArgument(name='pos1', value='posA', is_default=False, kind=ArgKind.POSITIONAL), + StubArgument(name='pos2', value='posB', is_default=False, kind=ArgKind.NAMED), + StubArgument(name='named1', value='namedA', is_default=True, kind=ArgKind.NAMED)]) self.assertTupleEqual(step.posnom_args_str, ('posA', 'pos2=posB')) self.assertEqual(step.full_keyword, f"{RobotKwStub.STEPTEXT} posA pos2=posB") def test_argument_with_default_is_omitted_from_keyword_when_not_mentioned_positional(self, mock): step = Step(RobotKwStub.STEPTEXT, 'posA', 'posB', parent=None) - step.args = StubStepArguments([StubArgument(name='pos1', value='posA', is_default=False, kind='POSITIONAL'), - StubArgument(name='pos2', value='posB', is_default=False, kind='POSITIONAL'), - StubArgument(name='named1', value='namedA', is_default=True, kind='POSITIONAL')]) + step.args = StubStepArguments( + [StubArgument(name='pos1', value='posA', is_default=False, kind=ArgKind.POSITIONAL), + StubArgument(name='pos2', value='posB', is_default=False, kind=ArgKind.POSITIONAL), + StubArgument(name='named1', value='namedA', is_default=True, kind=ArgKind.POSITIONAL)]) self.assertTupleEqual(step.posnom_args_str, ('posA', 'posB')) self.assertEqual(step.full_keyword, f"{RobotKwStub.STEPTEXT} posA posB") def test_argument_with_default_is_included_in_keyword_when_mentioned_named(self, mock): step = Step(RobotKwStub.STEPTEXT, 'posA', 'pos2=posB', 'named1=namedA', parent=None) - step.args = StubStepArguments([StubArgument(name='pos1', value='posA', is_default=False, kind='POSITIONAL'), - StubArgument(name='pos2', value='posB', is_default=False, kind='NAMED'), - StubArgument(name='named1', value='namedA', is_default=False, kind='NAMED')]) + step.args = StubStepArguments( + [StubArgument(name='pos1', value='posA', is_default=False, kind=ArgKind.POSITIONAL), + StubArgument(name='pos2', value='posB', is_default=False, kind=ArgKind.NAMED), + StubArgument(name='named1', value='namedA', is_default=False, kind=ArgKind.NAMED)]) self.assertTupleEqual(step.posnom_args_str, ('posA', 'pos2=posB', 'named1=namedA')) self.assertEqual(step.full_keyword, f"{RobotKwStub.STEPTEXT} posA pos2=posB named1=namedA") def test_argument_with_default_is_included_in_keyword_when_mentioned_positional(self, mock): step = Step(RobotKwStub.STEPTEXT, 'posA', 'posB', 'namedA', parent=None) - step.args = StubStepArguments([StubArgument(name='pos1', value='posA', is_default=False, kind='POSITIONAL'), - StubArgument(name='pos2', value='posB', is_default=False, kind='POSITIONAL'), - StubArgument(name='named1', value='namedA', is_default=False, kind='POSITIONAL')]) + step.args = StubStepArguments( + [StubArgument(name='pos1', value='posA', is_default=False, kind=ArgKind.POSITIONAL), + StubArgument(name='pos2', value='posB', is_default=False, kind=ArgKind.POSITIONAL), + StubArgument(name='named1', value='namedA', is_default=False, kind=ArgKind.POSITIONAL)]) self.assertTupleEqual(step.posnom_args_str, ('posA', 'posB', 'namedA')) self.assertEqual(step.full_keyword, f"{RobotKwStub.STEPTEXT} posA posB namedA") @@ -482,24 +496,7 @@ class StubStepArguments(list): class StubArgument(SimpleNamespace): - EMBEDDED = 'EMBEDDED' - POSITIONAL = 'POSITIONAL' - VAR_POS = 'VAR_POS' - NAMED = 'NAMED' - FREE_NAMED = 'FREE_NAMED' - - - -class StubStepArguments(list): - modified = True # trigger modified status to get arguments processed, rather then just echoed - - -class StubArgument(SimpleNamespace): - EMBEDDED = 'EMBEDDED' - POSITIONAL = 'POSITIONAL' - VAR_POS = 'VAR_POS' - NAMED = 'NAMED' - FREE_NAMED = 'FREE_NAMED' + pass if __name__ == '__main__': diff --git a/utest/test_tracestate.py b/utest/test_tracestate.py index 77c1462a..29cb29ae 100644 --- a/utest/test_tracestate.py +++ b/utest/test_tracestate.py @@ -46,14 +46,14 @@ def test_completing_single_size_trace(self): ts = TraceState([1]) self.assertEqual(ts.next_candidate(), 1) self.assertIs(ts.coverage_reached(), False) - ts.confirm_full_scenario(1, 'one', {}) + ts.confirm_full_scenario(1, ScenarioStub('one'), ModelStub()) self.assertIs(ts.coverage_reached(), True) self.assertEqual(ts.get_trace(), ['one']) def test_confirming_excludes_scenario_from_candidacy(self): ts = TraceState([1]) self.assertEqual(ts.next_candidate(), 1) - ts.confirm_full_scenario(1, 'one', {}) + ts.confirm_full_scenario(1, ScenarioStub('one'), ModelStub()) self.assertIs(ts.next_candidate(), None) def test_trying_excludes_scenario_from_candidacy(self): @@ -65,7 +65,7 @@ def test_trying_excludes_scenario_from_candidacy(self): def test_scenario_still_excluded_from_candidacy_after_rewind(self): ts = TraceState([1]) self.assertEqual(ts.next_candidate(), 1) - ts.confirm_full_scenario(1, 'one', {}) + ts.confirm_full_scenario(1, ScenarioStub('one'), ModelStub()) ts.rewind() self.assertIs(ts.next_candidate(), None) @@ -74,7 +74,7 @@ def test_candidates_come_in_order_when_accepted(self): candidates = [] for _ in range(3): candidates.append(ts.next_candidate()) - ts.confirm_full_scenario(candidates[-1], 'scenario', {}) + ts.confirm_full_scenario(candidates[-1], ScenarioStub(), ModelStub()) candidates.append(ts.next_candidate()) self.assertEqual(candidates, [10, 20, 30, None]) @@ -96,30 +96,30 @@ def test_rejected_scenarios_are_candidates_for_new_positions(self): ts.reject_scenario(1) for _ in range(3): candidates.append(ts.next_candidate()) - ts.confirm_full_scenario(candidates[-1], 'scenario', {}) + ts.confirm_full_scenario(candidates[-1], ScenarioStub(), ModelStub()) candidates.append(ts.next_candidate()) self.assertEqual(candidates, [2, 1, 3, None]) def test_previously_confirmed_scenarios_can_be_retried_if_no_new_candidates_exist(self): ts = TraceState(range(3)) first_candidate = ts.next_candidate(retry=True) - ts.confirm_full_scenario(first_candidate, 'one', {}) + ts.confirm_full_scenario(first_candidate, ScenarioStub('one'), ModelStub()) ts.reject_scenario(ts.next_candidate(retry=True)) ts.reject_scenario(ts.next_candidate(retry=True)) retry_candidate = ts.next_candidate(retry=True) self.assertEqual(first_candidate, retry_candidate) - ts.confirm_full_scenario(retry_candidate, 'one', {}) - ts.confirm_full_scenario(ts.next_candidate(retry=True), 'two', {}) + ts.confirm_full_scenario(retry_candidate, ScenarioStub('one'), ModelStub()) + ts.confirm_full_scenario(ts.next_candidate(retry=True), ScenarioStub('two'), ModelStub()) self.assertFalse(ts.coverage_reached()) - ts.confirm_full_scenario(ts.next_candidate(retry=True), 'three', {}) + ts.confirm_full_scenario(ts.next_candidate(retry=True), ScenarioStub('three'), ModelStub()) self.assertTrue(ts.coverage_reached()) self.assertEqual(ts.get_trace(), ['one', 'one', 'two', 'three']) def test_retry_can_continue_once_coverage_is_reached(self): ts = TraceState([1, 2, 3]) - ts.confirm_full_scenario(ts.next_candidate(retry=True), 'one', {}) - ts.confirm_full_scenario(ts.next_candidate(retry=True), 'two', {}) - ts.confirm_full_scenario(ts.next_candidate(retry=True), 'three', {}) + ts.confirm_full_scenario(ts.next_candidate(retry=True), ScenarioStub('one'), ModelStub()) + ts.confirm_full_scenario(ts.next_candidate(retry=True), ScenarioStub('two'), ModelStub()) + ts.confirm_full_scenario(ts.next_candidate(retry=True), ScenarioStub('three'), ModelStub()) self.assertTrue(ts.coverage_reached()) self.assertEqual(ts.next_candidate(retry=True), 1) ts.reject_scenario(1) @@ -130,14 +130,14 @@ def test_count_scenario_repetitions(self): ts = TraceState([1, 2]) first = ts.next_candidate() self.assertEqual(ts.count(first), 0) - ts.confirm_full_scenario(first, 'one', {}) + ts.confirm_full_scenario(first, ScenarioStub('one'), ModelStub()) self.assertEqual(ts.count(first), 1) - ts.confirm_full_scenario(first, 'one', {}) + ts.confirm_full_scenario(first, ScenarioStub('one'), ModelStub()) self.assertEqual(ts.count(first), 2) def test_rewind_single_available_scenario(self): ts = TraceState([1]) - ts.confirm_full_scenario(1, 'one', {}) + ts.confirm_full_scenario(1, ScenarioStub('one'), ModelStub()) self.assertIs(ts.coverage_reached(), True) self.assertIs(ts.can_rewind(), True) ts.rewind() @@ -148,21 +148,21 @@ def test_rewind_single_available_scenario(self): def test_rewind_returns_none_after_rewinding_last_step(self): ts = TraceState([1]) - ts.confirm_full_scenario(1, 'one', {}) + ts.confirm_full_scenario(1, ScenarioStub('one'), ModelStub()) self.assertIs(ts.rewind(), None) def test_traces_can_have_multiple_scenarios(self): ts = TraceState([1, 2]) - ts.confirm_full_scenario(1, 'foo', dict(a=1)) + ts.confirm_full_scenario(1, ScenarioStub('foo'), ModelStub(a=1)) self.assertIs(ts.coverage_reached(), False) - ts.confirm_full_scenario(2, 'bar', dict(b=2)) + ts.confirm_full_scenario(2, ScenarioStub('bar'), ModelStub(b=2)) self.assertIs(ts.coverage_reached(), True) self.assertEqual(ts.get_trace(), ['foo', 'bar']) def test_rewind_returns_snapshot_of_the_step_before(self): ts = TraceState([1, 2]) - ts.confirm_full_scenario(1, 'foo', dict(a=1)) - ts.confirm_full_scenario(2, 'bar', dict(b=2)) + ts.confirm_full_scenario(1, ScenarioStub('foo'), ModelStub(a=1)) + ts.confirm_full_scenario(2, ScenarioStub('bar'), ModelStub(b=2)) tail = ts.rewind() self.assertEqual(tail.id, '1') self.assertEqual(tail.scenario, 'foo') @@ -170,32 +170,32 @@ def test_rewind_returns_snapshot_of_the_step_before(self): def test_completing_size_three_trace(self): ts = TraceState(range(3)) - ts.confirm_full_scenario(ts.next_candidate(), 'one', {}) - ts.confirm_full_scenario(ts.next_candidate(), 'two', {}) + ts.confirm_full_scenario(ts.next_candidate(), ScenarioStub('one'), ModelStub()) + ts.confirm_full_scenario(ts.next_candidate(), ScenarioStub('two'), ModelStub()) self.assertIs(ts.coverage_reached(), False) - ts.confirm_full_scenario(ts.next_candidate(), 'three', {}) + ts.confirm_full_scenario(ts.next_candidate(), ScenarioStub('three'), ModelStub()) self.assertIs(ts.coverage_reached(), True) self.assertEqual(ts.get_trace(), ['one', 'two', 'three']) def test_completing_size_three_trace_after_reject(self): ts = TraceState(range(3)) first = ts.next_candidate() - ts.confirm_full_scenario(first, first, {}) + ts.confirm_full_scenario(first, ScenarioStub('one'), ModelStub()) rejected = ts.next_candidate() ts.reject_scenario(rejected) third = ts.next_candidate() - ts.confirm_full_scenario(third, third, {}) + ts.confirm_full_scenario(third, ScenarioStub('three'), ModelStub()) self.assertIs(ts.coverage_reached(), False) second = ts.next_candidate() self.assertEqual(rejected, second) - ts.confirm_full_scenario(second, second, {}) + ts.confirm_full_scenario(second, ScenarioStub('two'), ModelStub()) self.assertIs(ts.coverage_reached(), True) - self.assertEqual(ts.get_trace(), [first, third, second]) + self.assertEqual(ts.get_trace(), ['one', 'three', 'two']) def test_completing_size_three_trace_after_rewind(self): ts = TraceState(range(3)) first = ts.next_candidate() - ts.confirm_full_scenario(first, first, {}) + ts.confirm_full_scenario(first, ScenarioStub('one'), ModelStub()) reject2 = ts.next_candidate() ts.reject_scenario(reject2) reject3 = ts.next_candidate() @@ -205,13 +205,13 @@ def test_completing_size_three_trace_after_rewind(self): self.assertEqual(len(ts.get_trace()), 0) retry_first = ts.next_candidate() self.assertNotEqual(first, retry_first) - ts.confirm_full_scenario(retry_first, retry_first, {}) + ts.confirm_full_scenario(retry_first, ScenarioStub('two'), ModelStub()) retry_second = ts.next_candidate() - ts.confirm_full_scenario(retry_second, retry_second, {}) + ts.confirm_full_scenario(retry_second, ScenarioStub('one'), ModelStub()) retry_third = ts.next_candidate() - ts.confirm_full_scenario(retry_third, retry_third, {}) + ts.confirm_full_scenario(retry_third, ScenarioStub('three'), ModelStub()) self.assertIs(ts.coverage_reached(), True) - self.assertEqual(ts.get_trace(), [retry_first, retry_second, retry_third]) + self.assertEqual(ts.get_trace(), ['two', 'one', 'three']) def test_highest_part_when_index_not_present(self): ts = TraceState([1]) @@ -219,13 +219,13 @@ def test_highest_part_when_index_not_present(self): def test_highest_part_for_non_partial_sceanrio(self): ts = TraceState([1]) - ts.confirm_full_scenario(1, 'one', {}) + ts.confirm_full_scenario(1, ScenarioStub(), ModelStub()) self.assertEqual(ts.highest_part(1), 0) def test_model_property_takes_model_from_tail(self): ts = TraceState(range(2)) - ts.confirm_full_scenario(ts.next_candidate(), 'one', dict(a=1)) - ts.confirm_full_scenario(ts.next_candidate(), 'two', dict(b=2)) + ts.confirm_full_scenario(ts.next_candidate(), ScenarioStub('one'), ModelStub(a=1)) + ts.confirm_full_scenario(ts.next_candidate(), ScenarioStub('two'), ModelStub(b=2)) self.assertEqual(ts.model, dict(b=2)) ts.rewind() self.assertEqual(ts.model, dict(a=1)) @@ -233,7 +233,7 @@ def test_model_property_takes_model_from_tail(self): def test_no_model_from_empty_trace(self): ts = TraceState([1]) self.assertIs(ts.model, None) - ts.confirm_full_scenario(1, 'one', {}) + ts.confirm_full_scenario(1, ScenarioStub('one'), ModelStub()) self.assertIsNotNone(ts.model) ts.rewind() self.assertIs(ts.model, None) @@ -249,16 +249,16 @@ def test_rejected_scenarios_are_tried(self): def test_confirmed_scenario_is_tried_and_triggers_next_step(self): ts = TraceState([1]) - ts.confirm_full_scenario(1, 'one', {}) + ts.confirm_full_scenario(1, ScenarioStub('one'), ModelStub()) self.assertEqual(ts.tried, ()) ts.rewind() self.assertEqual(ts.tried, (1,)) def test_can_iterate_over_tracestate_snapshots(self): ts = TraceState([1, 2, 3]) - ts.confirm_full_scenario(ts.next_candidate(), 'one', dict(a=1)) - ts.confirm_full_scenario(ts.next_candidate(), 'two', dict(b=2)) - ts.confirm_full_scenario(ts.next_candidate(), 'three', dict(c=3)) + ts.confirm_full_scenario(ts.next_candidate(), ScenarioStub('one'), ModelStub(a=1)) + ts.confirm_full_scenario(ts.next_candidate(), ScenarioStub('two'), ModelStub(b=2)) + ts.confirm_full_scenario(ts.next_candidate(), ScenarioStub('three'), ModelStub(c=3)) for act, exp in zip(ts, ['1', '2', '3']): self.assertEqual(act.id, exp) for act, exp in zip(ts, ['one', 'two', 'three']): @@ -268,9 +268,9 @@ def test_can_iterate_over_tracestate_snapshots(self): def test_can_index_tracestate_snapshots(self): ts = TraceState([1, 2, 3]) - ts.confirm_full_scenario(ts.next_candidate(), 'one', dict(a=1)) - ts.confirm_full_scenario(ts.next_candidate(), 'two', dict(b=2)) - ts.confirm_full_scenario(ts.next_candidate(), 'three', dict(c=3)) + ts.confirm_full_scenario(ts.next_candidate(), ScenarioStub('one'), ModelStub(a=1)) + ts.confirm_full_scenario(ts.next_candidate(), ScenarioStub('two'), ModelStub(b=2)) + ts.confirm_full_scenario(ts.next_candidate(), ScenarioStub('three'), ModelStub(c=3)) self.assertEqual(ts[0].id, '1') self.assertEqual(ts[1].scenario, 'two') self.assertEqual(ts[2].model, dict(c=3)) @@ -279,38 +279,38 @@ def test_can_index_tracestate_snapshots(self): def test_adding_coverage_prevents_drought(self): ts = TraceState(range(3)) - ts.confirm_full_scenario(ts.next_candidate(), 'one', {}) + ts.confirm_full_scenario(ts.next_candidate(), ScenarioStub('one'), ModelStub()) self.assertEqual(ts.coverage_drought, 0) - ts.confirm_full_scenario(ts.next_candidate(), 'two', {}) + ts.confirm_full_scenario(ts.next_candidate(), ScenarioStub('two'), ModelStub()) self.assertEqual(ts.coverage_drought, 0) - ts.confirm_full_scenario(ts.next_candidate(), 'three', {}) + ts.confirm_full_scenario(ts.next_candidate(), ScenarioStub('three'), ModelStub()) self.assertEqual(ts.coverage_drought, 0) def test_repeated_scenarios_increases_drought(self): ts = TraceState([1, 2]) - ts.confirm_full_scenario(1, 'one', {}) + ts.confirm_full_scenario(1, ScenarioStub('one'), ModelStub()) self.assertEqual(ts.coverage_drought, 0) - ts.confirm_full_scenario(1, 'one', {}) + ts.confirm_full_scenario(1, ScenarioStub('one'), ModelStub()) self.assertEqual(ts.coverage_drought, 1) - ts.confirm_full_scenario(1, 'one', {}) + ts.confirm_full_scenario(1, ScenarioStub('one'), ModelStub()) self.assertEqual(ts.coverage_drought, 2) def test_drought_is_reset_with_new_coverage(self): ts = TraceState([1, 2]) - ts.confirm_full_scenario(1, 'one', {}) + ts.confirm_full_scenario(1, ScenarioStub('one'), ModelStub()) self.assertEqual(ts.coverage_drought, 0) - ts.confirm_full_scenario(1, 'one', {}) + ts.confirm_full_scenario(1, ScenarioStub('one'), ModelStub()) self.assertEqual(ts.coverage_drought, 1) - ts.confirm_full_scenario(2, 'two', {}) + ts.confirm_full_scenario(2, ScenarioStub('two'), ModelStub()) self.assertEqual(ts.coverage_drought, 0) def test_rewind_includes_drought_update(self): ts = TraceState([1, 2]) - ts.confirm_full_scenario(1, 'one', {}) + ts.confirm_full_scenario(1, ScenarioStub('one'), ModelStub()) self.assertEqual(ts.coverage_drought, 0) - ts.confirm_full_scenario(1, 'one', {}) + ts.confirm_full_scenario(1, ScenarioStub('one'), ModelStub()) self.assertEqual(ts.coverage_drought, 1) - ts.confirm_full_scenario(2, 'two', {}) + ts.confirm_full_scenario(2, ScenarioStub('two'), ModelStub()) self.assertEqual(ts.coverage_drought, 0) ts.rewind() self.assertEqual(ts.coverage_drought, 1) @@ -321,29 +321,29 @@ def test_rewind_includes_drought_update(self): class TestPartialScenarios(unittest.TestCase): def test_push_partial_does_not_complete_coverage(self): ts = TraceState([1]) - ts.push_partial_scenario(1, 'part1', {}) + ts.push_partial_scenario(1, ScenarioStub('part1'), ModelStub()) self.assertEqual(ts.get_trace(), ['part1']) self.assertIs(ts.coverage_reached(), False) def test_confirm_full_after_push_partial_completes_coverage(self): ts = TraceState([1]) - ts.push_partial_scenario(1, 'part1', {}) + ts.push_partial_scenario(1, ScenarioStub('part1'), ModelStub()) self.assertIs(ts.coverage_reached(), False) - ts.push_partial_scenario(1, 'part2', {}) + ts.push_partial_scenario(1, ScenarioStub('part2'), ModelStub()) self.assertIs(ts.coverage_reached(), False) - ts.confirm_full_scenario(1, 'remainder', {}) + ts.confirm_full_scenario(1, ScenarioStub('remainder'), ModelStub()) self.assertEqual(ts.get_trace(), ['part1', 'part2', 'remainder']) self.assertIs(ts.coverage_reached(), True) def test_scenario_unavailble_once_pushed_partial(self): ts = TraceState([1]) candidate = ts.next_candidate() - ts.push_partial_scenario(candidate, 'part1', {}) + ts.push_partial_scenario(candidate, ScenarioStub('part1'), ModelStub()) self.assertIs(ts.next_candidate(), None) def test_rewind_of_single_part(self): ts = TraceState([1]) - ts.push_partial_scenario(1, 'part1', {}) + ts.push_partial_scenario(1, ScenarioStub('part1'), ModelStub()) self.assertEqual(ts.get_trace(), ['part1']) self.assertIs(ts.can_rewind(), True) ts.rewind() @@ -351,9 +351,9 @@ def test_rewind_of_single_part(self): def test_rewind_all_parts(self): ts = TraceState([1]) - ts.push_partial_scenario(1, 'part1', {}) + ts.push_partial_scenario(1, ScenarioStub('part1'), ModelStub()) self.assertIs(ts.coverage_reached(), False) - ts.push_partial_scenario(1, 'part2', {}) + ts.push_partial_scenario(1, ScenarioStub('part2'), ModelStub()) self.assertIs(ts.coverage_reached(), False) self.assertEqual(ts.get_trace(), ['part1', 'part2']) self.assertIs(ts.next_candidate(), None) @@ -368,14 +368,14 @@ def test_rewind_all_parts(self): def test_partial_scenario_still_excluded_from_candidacy_after_rewind(self): ts = TraceState([1]) self.assertEqual(ts.next_candidate(), 1) - ts.push_partial_scenario(1, 'part1', {}) + ts.push_partial_scenario(1, ScenarioStub('part1'), ModelStub()) ts.rewind() self.assertIs(ts.next_candidate(), None) def test_rewind_to_partial_scenario(self): ts = TraceState([1]) - ts.push_partial_scenario(1, 'part1', dict(a=1)) - ts.push_partial_scenario(1, 'part2', dict(b=2)) + ts.push_partial_scenario(1, ScenarioStub('part1'), ModelStub(a=1)) + ts.push_partial_scenario(1, ScenarioStub('part2'), ModelStub(b=2)) snapshot = ts.rewind() self.assertEqual(snapshot.id, '1.1') self.assertEqual(snapshot.scenario, 'part1') @@ -383,8 +383,8 @@ def test_rewind_to_partial_scenario(self): def test_rewind_last_part(self): ts = TraceState([1, 2]) - ts.confirm_full_scenario(1, 'one', dict(a=1)) - ts.push_partial_scenario(2, 'part1', dict(b=2)) + ts.confirm_full_scenario(1, ScenarioStub('one'), ModelStub(a=1)) + ts.push_partial_scenario(2, ScenarioStub('part1'), ModelStub(b=2)) snapshot = ts.rewind() self.assertEqual(ts.get_trace(), ['one']) self.assertEqual(snapshot.id, '1') @@ -393,9 +393,9 @@ def test_rewind_last_part(self): def test_rewind_all_parts_of_completed_scenario_at_once(self): ts = TraceState([1]) - ts.push_partial_scenario(1, 'part1', dict(a=1)) - ts.push_partial_scenario(1, 'part2', dict(b=2)) - ts.confirm_full_scenario(1, 'remainder', {}) + ts.push_partial_scenario(1, ScenarioStub('part1'), ModelStub(a=1)) + ts.push_partial_scenario(1, ScenarioStub('part2'), ModelStub(b=2)) + ts.confirm_full_scenario(1, ScenarioStub('remainder'), ModelStub()) tail = ts.rewind() self.assertEqual(ts.get_trace(), []) self.assertIs(ts.next_candidate(), None) @@ -403,11 +403,11 @@ def test_rewind_all_parts_of_completed_scenario_at_once(self): def test_tried_entries_after_rewind(self): ts = TraceState([1, 2, 10, 11, 12, 20, 21]) - ts.push_partial_scenario(1, 'part1', {}) + ts.push_partial_scenario(1, ScenarioStub('part1'), ModelStub()) ts.reject_scenario(10) ts.reject_scenario(11) - ts.confirm_full_scenario(2, 'two', {}) - ts.push_partial_scenario(1, 'part2', {}) + ts.confirm_full_scenario(2, ScenarioStub('two'), ModelStub()) + ts.push_partial_scenario(1, ScenarioStub('part2'), ModelStub()) ts.reject_scenario(20) ts.reject_scenario(21) self.assertEqual(ts.tried, (20, 21)) @@ -422,29 +422,29 @@ def test_tried_entries_after_rewind(self): def test_highest_part_after_first_part(self): ts = TraceState([1]) - ts.push_partial_scenario(1, 'part1', {}) + ts.push_partial_scenario(1, ScenarioStub('part1'), ModelStub()) self.assertEqual(ts[-1].id, '1.1') self.assertEqual(ts.highest_part(1), 1) def test_highest_part_after_multiple_parts(self): ts = TraceState([1]) - ts.push_partial_scenario(1, 'part1', {}) - ts.push_partial_scenario(1, 'part2', {}) + ts.push_partial_scenario(1, ScenarioStub('part1'), ModelStub()) + ts.push_partial_scenario(1, ScenarioStub('part2'), ModelStub()) self.assertEqual(ts[-1].id, '1.2') self.assertEqual(ts.highest_part(1), 2) def test_highest_part_after_completing_multiple_parts(self): ts = TraceState([1]) - ts.push_partial_scenario(1, 'part1', {}) - ts.push_partial_scenario(1, 'part2', {}) - ts.confirm_full_scenario(1, 'remainder', {}) + ts.push_partial_scenario(1, ScenarioStub('part1'), ModelStub()) + ts.push_partial_scenario(1, ScenarioStub('part2'), ModelStub()) + ts.confirm_full_scenario(1, ScenarioStub('remainder'), ModelStub()) self.assertEqual(ts[-1].id, '1.0') self.assertEqual(ts.highest_part(1), 0) def test_highest_part_after_partial_rewind(self): ts = TraceState([1]) - ts.push_partial_scenario(1, 'part1', {}) - ts.push_partial_scenario(1, 'part2', {}) + ts.push_partial_scenario(1, ScenarioStub('part1'), ModelStub()) + ts.push_partial_scenario(1, ScenarioStub('part2'), ModelStub()) self.assertEqual(ts.highest_part(1), 2) ts.rewind() self.assertEqual(ts.highest_part(1), 1) @@ -454,9 +454,9 @@ def test_highest_part_after_partial_rewind(self): def test_highest_part_is_0_when_no_refinement_is_ongoing(self): ts = TraceState([1]) self.assertEqual(ts.highest_part(1), 0) - ts.push_partial_scenario(1, 'part1', {}) - ts.push_partial_scenario(1, 'part2', {}) - ts.confirm_full_scenario(1, 'remainder', {}) + ts.push_partial_scenario(1, ScenarioStub('part1'), ModelStub()) + ts.push_partial_scenario(1, ScenarioStub('part2'), ModelStub()) + ts.confirm_full_scenario(1, ScenarioStub('remainder'), ModelStub()) self.assertEqual(ts.highest_part(1), 0) ts.rewind() self.assertEqual(ts.highest_part(1), 0) @@ -465,36 +465,36 @@ def test_count_scenario_repetitions_with_partials(self): ts = TraceState(range(2)) first = ts.next_candidate() self.assertEqual(ts.count(first), 0) - ts.confirm_full_scenario(first, 'full', {}) + ts.confirm_full_scenario(first, ScenarioStub('full'), ModelStub()) self.assertEqual(ts.count(first), 1) - ts.push_partial_scenario(first, 'part1', {}) - ts.push_partial_scenario(first, 'part2', {}) + ts.push_partial_scenario(first, ScenarioStub('part1'), ModelStub()) + ts.push_partial_scenario(first, ScenarioStub('part2'), ModelStub()) self.assertEqual(ts.count(first), 1) - ts.confirm_full_scenario(first, 'remainder', {}) + ts.confirm_full_scenario(first, ScenarioStub('remainder'), ModelStub()) self.assertEqual(ts.count(first), 2) second = ts.next_candidate() - ts.push_partial_scenario(second, 'part1', {}) + ts.push_partial_scenario(second, ScenarioStub('part1'), ModelStub()) self.assertEqual(ts.count(second), 0) - ts.push_partial_scenario(second, 'part2', {}) - ts.confirm_full_scenario(second, 'remainder', {}) + ts.push_partial_scenario(second, ScenarioStub('part2'), ModelStub()) + ts.confirm_full_scenario(second, ScenarioStub('remainder'), ModelStub()) self.assertEqual(ts.count(second), 1) def test_partial_scenario_is_tried_without_finishing(self): ts = TraceState([1]) - ts.push_partial_scenario(1, 'part1', {}) + ts.push_partial_scenario(1, ScenarioStub('part1'), ModelStub()) self.assertEqual(ts.tried, ()) ts.rewind() self.assertEqual(ts.tried, (1,)) def test_get_last_snapshot_by_index(self): ts = TraceState([1]) - ts.push_partial_scenario(1, 'part1', dict(a=1)) + ts.push_partial_scenario(1, ScenarioStub('part1'), ModelStub(a=1)) self.assertEqual(ts[-1].id, '1.1') self.assertEqual(ts[-1].scenario, 'part1') self.assertEqual(ts[-1].model, dict(a=1)) self.assertEqual(ts[-1].coverage_drought, 0) - ts.push_partial_scenario(1, 'part2', dict(b=2)) - ts.confirm_full_scenario(1, 'remainder', dict(c=3)) + ts.push_partial_scenario(1, ScenarioStub('part2'), ModelStub(b=2)) + ts.confirm_full_scenario(1, ScenarioStub('remainder'), ModelStub(c=3)) self.assertEqual(ts[-1].id, '1.0') self.assertEqual(ts[-1].scenario, 'remainder') self.assertEqual(ts[-1].model, dict(c=3)) @@ -502,16 +502,24 @@ def test_get_last_snapshot_by_index(self): def test_only_completed_scenarios_affect_drought(self): ts = TraceState([1, 2]) - ts.confirm_full_scenario(1, 'one full', {}) - ts.push_partial_scenario(1, 'one part1', {}) + ts.confirm_full_scenario(1, ScenarioStub('one full'), ModelStub()) + ts.push_partial_scenario(1, ScenarioStub('one part1'), ModelStub()) self.assertEqual(ts.coverage_drought, 0) - ts.confirm_full_scenario(1, 'one remainder', {}) + ts.confirm_full_scenario(1, ScenarioStub('one remainder'), ModelStub()) self.assertEqual(ts.coverage_drought, 1) - ts.push_partial_scenario(2, 'two part1', {}) + ts.push_partial_scenario(2, ScenarioStub('two part1'), ModelStub()) self.assertEqual(ts.coverage_drought, 1) - ts.confirm_full_scenario(2, 'two remainder', {}) + ts.confirm_full_scenario(2, ScenarioStub('two remainder'), ModelStub()) self.assertEqual(ts.coverage_drought, 0) +class ScenarioStub(str): + """Stub for suitedata.Scenario""" + + +class ModelStub(dict): + """Stub for modelspace.ModelSpace""" + + if __name__ == '__main__': unittest.main() From 1efa410a225fe7663049998e9625c2a004410739 Mon Sep 17 00:00:00 2001 From: Thomas Kas Date: Fri, 23 Jan 2026 17:02:13 +0100 Subject: [PATCH 108/131] Tooltips (#68) * Add tooltip with full state * Tooltip in contribution info * Formatting * Only show tooltip for nodes, not labels * Rework description selection * Update documentation --- CONTRIBUTING.md | 19 +++++++-- robotmbt/visualise/graphs/abstractgraph.py | 39 +++++++++++++------ robotmbt/visualise/graphs/deltavaluegraph.py | 14 +++++-- robotmbt/visualise/graphs/reducedSDVgraph.py | 15 +++++-- .../graphs/scenariodeltavaluegraph.py | 14 +++++-- robotmbt/visualise/graphs/scenariograph.py | 12 +++++- .../visualise/graphs/scenariostategraph.py | 12 +++++- robotmbt/visualise/graphs/stategraph.py | 8 ++++ robotmbt/visualise/networkvisualiser.py | 35 ++++++++++++++--- 9 files changed, 134 insertions(+), 34 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 303948a0..80bd72d8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -121,30 +121,37 @@ Extending the functionality of the visualizer with new graph types can result in To create a new graph type, create an instance of AbstractGraph, instantiating the following methods: - select_node_info: select the information you want to use to identify different nodes from all ScenarioInfo/StateInfo pairs that make up the different exploration steps. This info is also used to label nodes. Its return type has to match the first type argument passed to AbstractGraph. -- select_edge_info: ditto but for edges, which is also used for labeling. Its type has to match the second type used to instantiate AbstractGraph. +- select_edge_info: ditto but for edges, which is also used for labeling. Its return type has to match the second type argument passed to AbstractGraph. +- create_node_description: create a description for a node to be shown in a tooltip (if enabled). - create_node_label: turn the selected information into a label for a node. - create_edge_label: ditto but for edges. - get_legend_info_final_trace_node: return the text you want to appear in the legend for nodes that appear in the final trace. - get_legend_info_other_node: ditto but for nodes that have been backtracked. - get_legend_info_final_trace_edge: ditto but for edges that appear in the final trace. - get_legend_info_other_edge: ditto but for edges that have backtracked. +- get_tooltip_name: the title of a tooltip that appears when hovering over nodes. Setting to an empty string disables the tooltip. Please create a new file for each graph type under `/robotmbt/visualise/graphs/`. NOTE: when manually altering the networkx field, ensure its ids remain as a serializable and hashable type when the constructor finishes. As an example, we show the implementation of the scenario graph below. In this graph type, nodes represent scenarios encountered in exploration, and edges show the flow between these scenarios. +It does not enable tooltips. ```python class ScenarioGraph(AbstractGraph[ScenarioInfo, None]): @staticmethod - def select_node_info(pairs: list[tuple[ScenarioInfo, StateInfo]], index: int) -> ScenarioInfo: - return pairs[index][0] + def select_node_info(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) -> ScenarioInfo: + return trace[index][0] @staticmethod def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> None: return None - + + @staticmethod + def create_node_description(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) -> str: + return '' + @staticmethod def create_node_label(info: ScenarioInfo) -> str: return info.name @@ -168,6 +175,10 @@ class ScenarioGraph(AbstractGraph[ScenarioInfo, None]): @staticmethod def get_legend_info_other_edge() -> str: return "Execution Flow (backtracked)" + + @staticmethod + def get_tooltip_name() -> str: + return "" ``` Once you have created a new Graph class, you can direct the visualizer to use it when your type is selected. diff --git a/robotmbt/visualise/graphs/abstractgraph.py b/robotmbt/visualise/graphs/abstractgraph.py index 28341bf2..ebebe37d 100644 --- a/robotmbt/visualise/graphs/abstractgraph.py +++ b/robotmbt/visualise/graphs/abstractgraph.py @@ -18,10 +18,10 @@ def __init__(self, info: TraceInfo): self.networkx: nx.DiGraph = nx.DiGraph() # Keep track of node IDs - self.ids: dict[str, NodeInfo] = {} + self.ids: dict[str, tuple[NodeInfo, str]] = {} # Add the start node - self.networkx.add_node('start', label='start') + self.networkx.add_node('start', label='start', description='') self.start_node = 'start' # Add nodes and edges for all traces @@ -29,11 +29,13 @@ def __init__(self, info: TraceInfo): for i in range(len(trace)): if i > 0: from_node = self._get_or_create_id( - self.select_node_info(trace, i - 1)) + self.select_node_info(trace, i - 1), + self.create_node_description(trace, i - 1)) else: from_node = 'start' to_node = self._get_or_create_id( - self.select_node_info(trace, i)) + self.select_node_info(trace, i), + self.create_node_description(trace, i)) self._add_node(from_node) self._add_node(to_node) self._add_edge(from_node, to_node, @@ -44,11 +46,13 @@ def __init__(self, info: TraceInfo): for i in range(len(info.current_trace)): if i > 0: from_node = self._get_or_create_id( - self.select_node_info(info.current_trace, i - 1)) + self.select_node_info(info.current_trace, i - 1), + self.create_node_description(info.current_trace, i - 1)) else: from_node = 'start' to_node = self._get_or_create_id( - self.select_node_info(info.current_trace, i)) + self.select_node_info(info.current_trace, i), + self.create_node_description(info.current_trace, i)) self.final_trace.append(to_node) self._add_node(from_node) self._add_node(to_node) @@ -62,16 +66,16 @@ def get_final_trace(self) -> list[str]: """ return self.final_trace - def _get_or_create_id(self, info: NodeInfo) -> str: + def _get_or_create_id(self, info: NodeInfo, description: str) -> str: """ Get the ID for a state that has been added before, or create and store a new one. """ for i in self.ids.keys(): - if self.ids[i] == info: + if self.ids[i][0] == info: return i new_id = f"node{len(self.ids)}" - self.ids[new_id] = info + self.ids[new_id] = info, description return new_id def _add_node(self, node: str): @@ -80,7 +84,7 @@ def _add_node(self, node: str): """ if node not in self.networkx.nodes: self.networkx.add_node( - node, label=self.create_node_label(self.ids[node])) + node, label=self.create_node_label(self.ids[node][0]), description=self.ids[node][1]) def _add_edge(self, from_node: str, to_node: str, label: str): """ @@ -103,7 +107,7 @@ def _add_edge(self, from_node: str, to_node: str, label: str): @staticmethod @abstractmethod - def select_node_info(pair: list[tuple[ScenarioInfo, StateInfo]], index: int) -> NodeInfo: + def select_node_info(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) -> NodeInfo: """ Select the info to use to compare nodes and generate their labels for a specific graph type. """ @@ -117,6 +121,14 @@ def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> EdgeInfo: """ pass + @staticmethod + @abstractmethod + def create_node_description(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) -> str: + """ + Create the description to be shown in a tooltip for a node given the full trace and its index. + """ + pass + @staticmethod @abstractmethod def create_node_label(info: NodeInfo) -> str: @@ -164,3 +176,8 @@ def get_legend_info_other_edge() -> str: Get the information to include in the legend for edges that do not appear in the final trace. """ pass + + @staticmethod + @abstractmethod + def get_tooltip_name() -> str: + pass diff --git a/robotmbt/visualise/graphs/deltavaluegraph.py b/robotmbt/visualise/graphs/deltavaluegraph.py index 7a77aa7a..8dfebe1f 100644 --- a/robotmbt/visualise/graphs/deltavaluegraph.py +++ b/robotmbt/visualise/graphs/deltavaluegraph.py @@ -10,16 +10,20 @@ class DeltaValueGraph(AbstractGraph[set[tuple[str, str]], ScenarioInfo]): """ @staticmethod - def select_node_info(pairs: list[tuple[ScenarioInfo, StateInfo]], index: int) -> set[tuple[str, str]]: + def select_node_info(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) -> set[tuple[str, str]]: if index == 0: - return StateInfo(ModelSpace()).difference(pairs[0][1]) + return StateInfo(ModelSpace()).difference(trace[0][1]) else: - return pairs[index-1][1].difference(pairs[index][1]) + return trace[index-1][1].difference(trace[index][1]) @staticmethod def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> ScenarioInfo: return pair[0] + @staticmethod + def create_node_description(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) -> str: + return str(trace[index][1]).replace('\n', '
') + @staticmethod def create_node_label(info: set[tuple[str, str]]) -> str: res = "" @@ -46,3 +50,7 @@ def get_legend_info_final_trace_edge() -> str: @staticmethod def get_legend_info_other_edge() -> str: return "Executed Scenario (backtracked)" + + @staticmethod + def get_tooltip_name() -> str: + return "Full state" diff --git a/robotmbt/visualise/graphs/reducedSDVgraph.py b/robotmbt/visualise/graphs/reducedSDVgraph.py index 100702d6..15c639e0 100644 --- a/robotmbt/visualise/graphs/reducedSDVgraph.py +++ b/robotmbt/visualise/graphs/reducedSDVgraph.py @@ -6,7 +6,6 @@ from robotmbt.visualise.models import ScenarioInfo, StateInfo, TraceInfo -# TODO add tests for this graph representation class ReducedSDVGraph(AbstractGraph[tuple[ScenarioInfo, set[tuple[str, str]]], None]): """ The reduced Scenario-delta-Value graph keeps track of both the scenarios and state updates encountered. @@ -65,17 +64,21 @@ def __init__(self, info: TraceInfo): self.start_node: tuple[str, ...] = tuple(['start']) @staticmethod - def select_node_info(pairs: list[tuple[ScenarioInfo, StateInfo]], index: int) \ + def select_node_info(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) \ -> tuple[ScenarioInfo, set[tuple[str, str]]]: if index == 0: - return pairs[0][0], StateInfo(ModelSpace()).difference(pairs[0][1]) + return trace[0][0], StateInfo(ModelSpace()).difference(trace[0][1]) else: - return pairs[index][0], pairs[index - 1][1].difference(pairs[index][1]) + return trace[index][0], trace[index - 1][1].difference(trace[index][1]) @staticmethod def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> None: return None + @staticmethod + def create_node_description(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) -> str: + return str(trace[index][1]).replace('\n', '
') + @staticmethod def create_node_label(info: tuple[ScenarioInfo, set[tuple[str, str]]]) -> str: return ScenarioDeltaValueGraph.create_node_label(info) @@ -99,3 +102,7 @@ def get_legend_info_final_trace_edge() -> str: @staticmethod def get_legend_info_other_edge() -> str: return "Execution Flow (backtracked)" + + @staticmethod + def get_tooltip_name() -> str: + return "Full state" diff --git a/robotmbt/visualise/graphs/scenariodeltavaluegraph.py b/robotmbt/visualise/graphs/scenariodeltavaluegraph.py index 6534386c..a9d822ae 100644 --- a/robotmbt/visualise/graphs/scenariodeltavaluegraph.py +++ b/robotmbt/visualise/graphs/scenariodeltavaluegraph.py @@ -12,17 +12,21 @@ class ScenarioDeltaValueGraph(AbstractGraph[tuple[ScenarioInfo, set[tuple[str, s """ @staticmethod - def select_node_info(pairs: list[tuple[ScenarioInfo, StateInfo]], index: int) \ + def select_node_info(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) \ -> tuple[ScenarioInfo, set[tuple[str, str]]]: if index == 0: - return pairs[0][0], StateInfo(ModelSpace()).difference(pairs[0][1]) + return trace[0][0], StateInfo(ModelSpace()).difference(trace[0][1]) else: - return pairs[index][0], pairs[index-1][1].difference(pairs[index][1]) + return trace[index][0], trace[index-1][1].difference(trace[index][1]) @staticmethod def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> None: return None + @staticmethod + def create_node_description(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) -> str: + return str(trace[index][1]).replace('\n', '
') + @staticmethod def create_node_label(info: tuple[ScenarioInfo, set[tuple[str, str]]]) -> str: res = "" @@ -49,3 +53,7 @@ def get_legend_info_final_trace_edge() -> str: @staticmethod def get_legend_info_other_edge() -> str: return "Execution Flow (backtracked)" + + @staticmethod + def get_tooltip_name() -> str: + return "Full state" diff --git a/robotmbt/visualise/graphs/scenariograph.py b/robotmbt/visualise/graphs/scenariograph.py index f36a0911..4d1442c9 100644 --- a/robotmbt/visualise/graphs/scenariograph.py +++ b/robotmbt/visualise/graphs/scenariograph.py @@ -9,13 +9,17 @@ class ScenarioGraph(AbstractGraph[ScenarioInfo, None]): """ @staticmethod - def select_node_info(pairs: list[tuple[ScenarioInfo, StateInfo]], index: int) -> ScenarioInfo: - return pairs[index][0] + def select_node_info(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) -> ScenarioInfo: + return trace[index][0] @staticmethod def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> None: return None + @staticmethod + def create_node_description(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) -> str: + return '' + @staticmethod def create_node_label(info: ScenarioInfo) -> str: return info.name @@ -39,3 +43,7 @@ def get_legend_info_final_trace_edge() -> str: @staticmethod def get_legend_info_other_edge() -> str: return "Execution Flow (backtracked)" + + @staticmethod + def get_tooltip_name() -> str: + return "" diff --git a/robotmbt/visualise/graphs/scenariostategraph.py b/robotmbt/visualise/graphs/scenariostategraph.py index 460a1634..aebc7db2 100644 --- a/robotmbt/visualise/graphs/scenariostategraph.py +++ b/robotmbt/visualise/graphs/scenariostategraph.py @@ -10,13 +10,17 @@ class ScenarioStateGraph(AbstractGraph[tuple[ScenarioInfo, StateInfo], None]): """ @staticmethod - def select_node_info(pairs: list[tuple[ScenarioInfo, StateInfo]], index: int) -> tuple[ScenarioInfo, StateInfo]: - return pairs[index] + def select_node_info(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) -> tuple[ScenarioInfo, StateInfo]: + return trace[index] @staticmethod def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> None: return None + @staticmethod + def create_node_description(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) -> str: + return '' + @staticmethod def create_node_label(info: tuple[ScenarioInfo, StateInfo]) -> str: return f"{info[0].name}\n\n{str(info[1])}" @@ -40,3 +44,7 @@ def get_legend_info_final_trace_edge() -> str: @staticmethod def get_legend_info_other_edge() -> str: return "Execution Flow (backtracked)" + + @staticmethod + def get_tooltip_name() -> str: + return "" diff --git a/robotmbt/visualise/graphs/stategraph.py b/robotmbt/visualise/graphs/stategraph.py index 61d4455f..aab07ba5 100644 --- a/robotmbt/visualise/graphs/stategraph.py +++ b/robotmbt/visualise/graphs/stategraph.py @@ -16,6 +16,10 @@ def select_node_info(pairs: list[tuple[ScenarioInfo, StateInfo]], index: int) -> def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> ScenarioInfo: return pair[0] + @staticmethod + def create_node_description(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) -> str: + return '' + @staticmethod def create_node_label(info: StateInfo) -> str: return str(info) @@ -39,3 +43,7 @@ def get_legend_info_final_trace_edge() -> str: @staticmethod def get_legend_info_other_edge() -> str: return "Executed Scenario (backtracked)" + + @staticmethod + def get_tooltip_name() -> str: + return "" diff --git a/robotmbt/visualise/networkvisualiser.py b/robotmbt/visualise/networkvisualiser.py index a97eeca4..6c33f31f 100644 --- a/robotmbt/visualise/networkvisualiser.py +++ b/robotmbt/visualise/networkvisualiser.py @@ -2,7 +2,7 @@ from bokeh.core.property.vectorization import value from bokeh.embed import file_html from bokeh.models import ColumnDataSource, Rect, Text, ResetTool, SaveTool, WheelZoomTool, PanTool, Plot, Range1d, \ - Title, FullscreenTool, CustomJS, Segment, Arrow, NormalHead, Bezier, Legend, ZoomInTool, ZoomOutTool + Title, FullscreenTool, CustomJS, Segment, Arrow, NormalHead, Bezier, Legend, ZoomInTool, ZoomOutTool, HoverTool from grandalf.graphs import Vertex as GVertex, Edge as GEdge, Graph as GGraph from grandalf.layouts import SugiyamaLayout @@ -43,13 +43,14 @@ class Node: Contains the information we need to add a node to the graph. """ - def __init__(self, node_id: str, label: str, x: int, y: int, width: float, height: float): + def __init__(self, node_id: str, label: str, x: int, y: int, width: float, height: float, description: str): self.node_id = node_id self.label = label self.x = x self.y = y self.width = width self.height = height + self.description = description class Edge: @@ -88,6 +89,22 @@ def __init__(self, graph: AbstractGraph, suite_name: str): # Keep track of arrows in the graph for scaling self.arrows = [] + # Create the hover tool to show tooltips + tooltip_name = graph.get_tooltip_name() + if tooltip_name: + self.hover = HoverTool() + tooltips = f""" +

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%0AX!PO2Whe%2ng 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 StUsc Date: Wed, 26 Nov 2025 11:04:48 +0100 Subject: [PATCH 019/131] Arguments apply locally (#24) --- robotmbt/suitereplacer.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/robotmbt/suitereplacer.py b/robotmbt/suitereplacer.py index de4a0ee8..3eb9e08e 100644 --- a/robotmbt/suitereplacer.py +++ b/robotmbt/suitereplacer.py @@ -83,19 +83,21 @@ def treat_model_based(self, **kwargs): model info that is included in the test steps, the test cases are modifed, mixed and matched to create unique traces and achieve more test coverage quicker. - Any arguments are handled as if using keyword `Update model-based options` + Any arguments are handled only locally. To apply arguments to subsequent suites as well, + use `Set model-based options` or `Update model-based options`. """ self.robot_suite = self.current_suite logger.info( f"Analysing Robot test suite '{self.robot_suite.name}' for model-based execution.") - self.update_model_based_options(**kwargs) + temp = self.processor_options.copy() + temp.update(kwargs) master_suite = self.__process_robot_suite( self.robot_suite, parent=None) modelbased_suite = self.processor_method( - master_suite, **self.processor_options) + master_suite, **temp) self.__clearTestSuite(self.robot_suite) self.__generateRobotSuite(modelbased_suite, self.robot_suite) From 0b19c82edac989de788b635262bb4d1dd66e64c4 Mon Sep 17 00:00:00 2001 From: Jonathan <61787386+JWillegers@users.noreply.github.com> Date: Wed, 26 Nov 2025 11:05:12 +0100 Subject: [PATCH 020/131] fix line indent (#25) --- utest/test_visualise_scenariograph.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/utest/test_visualise_scenariograph.py b/utest/test_visualise_scenariograph.py index bcac393c..6af361d7 100644 --- a/utest/test_visualise_scenariograph.py +++ b/utest/test_visualise_scenariograph.py @@ -144,5 +144,5 @@ def test_scenario_graph_set_final_trace(self): # test end node self.assertEqual(sg.end_node, 'node2') - if __name__ == '__main__': - unittest.main() +if __name__ == '__main__': + unittest.main() From 2c7b6be3ac2e62cf0ae79bc1a0d324dc11fa2cbd Mon Sep 17 00:00:00 2001 From: tychodub <93142605+tychodub@users.noreply.github.com> Date: Thu, 27 Nov 2025 15:59:55 +0100 Subject: [PATCH 021/131] Scenario state (#26) * implemented scenario-state (state after scenario has run variant) graph representation * removed hardcoded starting node id by keeping track of starting node info * added unit tests for scenariostategraph.py and some supporting code for the unit tests * renamed unit tests because I forgot initially * implemented suggestions by Jonathan (moved function into scenariostategraph as static method, de-indented 'if main' in test_visualise_scenariostategraph.py and moved is not None check outside for-loop) * removed end_node from scenariostategraph.py * moved is not None check outside of loop in scenariograph.py --- robotmbt/visualise/graphs/scenariograph.py | 9 +- .../visualise/graphs/scenariostategraph.py | 104 ++++++++++++ robotmbt/visualise/visualiser.py | 3 + utest/test_visualise_scenariostategraph.py | 157 ++++++++++++++++++ 4 files changed, 269 insertions(+), 4 deletions(-) create mode 100644 robotmbt/visualise/graphs/scenariostategraph.py create mode 100644 utest/test_visualise_scenariostategraph.py diff --git a/robotmbt/visualise/graphs/scenariograph.py b/robotmbt/visualise/graphs/scenariograph.py index fee43987..c2e59315 100644 --- a/robotmbt/visualise/graphs/scenariograph.py +++ b/robotmbt/visualise/graphs/scenariograph.py @@ -41,10 +41,11 @@ def _get_or_create_id(self, scenario: ScenarioInfo) -> str: """ Get the ID for a scenario that has been added before, or create and store a new one. """ - for i in self.ids.keys(): - # TODO: decide how to deal with repeating scenarios, this merges repeated scenarios into a single scenario - if self.ids[i].src_id == scenario.src_id and scenario.src_id is not None: - return i + if scenario.src_id is not None: + for i in self.ids.keys(): + # TODO: decide how to deal with repeating scenarios, this merges repeated scenarios into a single scenario + if self.ids[i].src_id == scenario.src_id: + return i new_id = f"node{len(self.ids)}" self.ids[new_id] = scenario diff --git a/robotmbt/visualise/graphs/scenariostategraph.py b/robotmbt/visualise/graphs/scenariostategraph.py new file mode 100644 index 00000000..6a2d6020 --- /dev/null +++ b/robotmbt/visualise/graphs/scenariostategraph.py @@ -0,0 +1,104 @@ +import networkx as nx + +from robotmbt.modelspace import ModelSpace +from robotmbt.visualise.graphs.abstractgraph import AbstractGraph +from robotmbt.visualise.models import TraceInfo, ScenarioInfo, StateInfo + + +class ScenarioStateGraph(AbstractGraph): + """ + The scenario-State graph keeps track of both the scenarios and states encountered. + Its nodes are scenarios together with the state after the scenario has run. + Its edges represent steps in the trace. + """ + + def __init__(self): + # We use simplified IDs for nodes, and store the actual scenario info here + self.ids: dict[str, tuple[ScenarioInfo, StateInfo]] = {} + + # The networkx graph is a directional graph + self.networkx = nx.DiGraph() + + # add the start node + self.networkx.add_node('start', label='start') + + self.start_scenario: ScenarioInfo | None = None + self.start_state: StateInfo | None = None + + self.prev_state = StateInfo(ModelSpace()) + self.prev_state_len = 0 + + def update_visualisation(self, info: TraceInfo): + """ + This will add nodes for all new scenarios in the provided trace, as well as edges for all pairs in the provided trace. + """ + if len(info.trace) == 1: + self.start_scenario = info.trace[0] + self.start_state = info.state + + for i in range(0, len(info.trace) - 1): + from_node = self._get_or_create_id(info.trace[i], self.prev_state) + to_node = self._get_or_create_id(info.trace[i + 1], info.state) + + self._add_node(from_node) + self._add_node(to_node) + + if (from_node, to_node) not in self.networkx.edges: + self.networkx.add_edge( + from_node, to_node, label='') + + self.prev_state = info.state + self.prev_state_len = len(info.trace) + + def _get_or_create_id(self, scenario: ScenarioInfo, state: StateInfo) -> str: + """ + Get the ID for a scenario that has been added before, or create and store a new one. + """ + if scenario.src_id is not None: + for i in self.ids.keys(): + if self.ids[i][0].src_id == scenario.src_id and self.ids[i][1] == state: + return i + + new_id = f"node{len(self.ids)}" + self.ids[new_id] = (scenario, state) + return new_id + + @staticmethod + def _gen_label(scenario: ScenarioInfo, state: StateInfo) -> str: + """ + Creates the label for a node in a Scenario-State Graph from the scenario and state associated to it. + """ + return scenario.name + "\n\r" + str(state) + + def _add_node(self, node: str): + """ + Add node if it doesn't already exist. + """ + if node not in self.networkx.nodes: + self.networkx.add_node(node, label=self._gen_label(self.ids[node][0], self.ids[node][1])) + + def _set_starting_node(self, scenario: ScenarioInfo, state: StateInfo): + """ + Update the starting node. + """ + node = self._get_or_create_id(scenario, state) + self._add_node(node) + self.networkx.add_edge('start', node, label='') + + def set_final_trace(self, info: TraceInfo): + """ + Update the graph with information on the final trace. + """ + if self.start_scenario is None: + self.start_scenario = info.trace[0] + self.start_state = info.state # fallback if a trace with multiple nodes instantly materializes + first_node = self.ids[self._get_or_create_id(self.start_scenario, self.start_state)] + self._set_starting_node(first_node[0], first_node[1]) + + @property + def networkx(self) -> nx.DiGraph: + return self._networkx + + @networkx.setter + def networkx(self, value: nx.DiGraph): + self._networkx = value diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py index 2bd69cea..fd8eea53 100644 --- a/robotmbt/visualise/visualiser.py +++ b/robotmbt/visualise/visualiser.py @@ -2,6 +2,7 @@ from robotmbt.visualise.graphs.abstractgraph import AbstractGraph from robotmbt.visualise.graphs.scenariograph import ScenarioGraph from robotmbt.visualise.graphs.stategraph import StateGraph +from robotmbt.visualise.graphs.scenariostategraph import ScenarioStateGraph from robotmbt.visualise.models import TraceInfo import html @@ -24,6 +25,8 @@ def __init__(self, graph_type: str): self.graph: AbstractGraph = ScenarioGraph() elif graph_type == 'state': self.graph: AbstractGraph = StateGraph() + elif graph_type == 'scenario-state': + self.graph: AbstractGraph = ScenarioStateGraph() else: raise ValueError(f"Unknown graph type: {graph_type}!") diff --git a/utest/test_visualise_scenariostategraph.py b/utest/test_visualise_scenariostategraph.py new file mode 100644 index 00000000..5c8d5fe3 --- /dev/null +++ b/utest/test_visualise_scenariostategraph.py @@ -0,0 +1,157 @@ +import unittest +import networkx as nx +from robotmbt.tracestate import TraceState +try: + from robotmbt.visualise.graphs.scenariostategraph import ScenarioStateGraph + from robotmbt.visualise.models import TraceInfo, ScenarioInfo, ModelSpace, StateInfo + + VISUALISE = True +except ImportError: + VISUALISE = False + +if VISUALISE: + class TestVisualiseScenarioGraph(unittest.TestCase): + def test_scenario_state_graph_init(self): + stg = ScenarioStateGraph() + self.assertIn('start', stg.networkx.nodes) + self.assertEqual(stg.networkx.nodes['start']['label'], 'start') + + def test_scenario_state_graph_ids_empty(self): + stg = ScenarioStateGraph() + si = ScenarioInfo('test') + node_id = stg._get_or_create_id(si, StateInfo(ModelSpace())) + self.assertEqual(node_id, 'node0') + + def test_scenario_state_graph_ids_duplicate_scenario(self): + stg = ScenarioStateGraph() + si = ScenarioInfo('test') + sti = StateInfo(ModelSpace()) + id0 = stg._get_or_create_id(si, sti) + id1 = stg._get_or_create_id(si, sti) + self.assertEqual(id0, id1) + + def test_scenario_state_graph_ids_different_scenarios(self): + stg = ScenarioStateGraph() + si0 = ScenarioInfo('test0') + si1 = ScenarioInfo('test1') + sti = StateInfo(ModelSpace()) + id0 = stg._get_or_create_id(si0, sti) + id1 = stg._get_or_create_id(si1, sti) + self.assertEqual(id0, 'node0') + self.assertEqual(id1, 'node1') + + def test_scenario_state_graph_ids_different_states(self): + stg = ScenarioStateGraph() + si = ScenarioInfo('test0') + sti0 = StateInfo(ModelSpace("state0")) + sti1 = StateInfo(ModelSpace("state1")) + id0 = stg._get_or_create_id(si, sti0) + id1 = stg._get_or_create_id(si, sti1) + self.assertEqual(id0, 'node0') + self.assertEqual(id1, 'node1') + + def test_scenario_state_graph_add_new_node(self): + stg = ScenarioStateGraph() + stg.ids['test'] = (ScenarioInfo('test'), StateInfo(ModelSpace())) + stg._add_node('test') + self.assertIn('test', stg.networkx.nodes) + self.assertEqual(stg.networkx.nodes['test']['label'], + ScenarioStateGraph._gen_label(ScenarioInfo('test'), StateInfo(ModelSpace()))) + + def test_scenario_state_graph_add_existing_node(self): + stg = ScenarioStateGraph() + stg._add_node('start') + self.assertIn('start', stg.networkx.nodes) + self.assertEqual(len(stg.networkx.nodes), 1) + + def test_scenario_state_graph_update_visualisation_nodes(self): + ts = TraceState(3) + candidates = [] + for scenario in range(3): + candidates.append(ts.next_candidate()) + ts.confirm_full_scenario(candidates[-1], str(scenario), {}) + ti = TraceInfo.from_trace_state(ts, ModelSpace()) + stg = ScenarioStateGraph() + stg.update_visualisation(ti) + + self.assertIn('node0', stg.networkx.nodes) + self.assertEqual(stg.networkx.nodes['node0']['label'], + ScenarioStateGraph._gen_label(ScenarioInfo(str(0)), StateInfo(ModelSpace()))) + self.assertIn('node1', stg.networkx.nodes) + self.assertEqual(stg.networkx.nodes['node1']['label'], + ScenarioStateGraph._gen_label(ScenarioInfo(str(1)), StateInfo(ModelSpace()))) + self.assertIn('node2', stg.networkx.nodes) + self.assertEqual(stg.networkx.nodes['node2']['label'], + ScenarioStateGraph._gen_label(ScenarioInfo(str(2)), StateInfo(ModelSpace()))) + + def test_scenario_state_graph_update_visualisation_edges(self): + ts = TraceState(3) + candidates = [] + for scenario in range(3): + candidates.append(ts.next_candidate()) + ts.confirm_full_scenario(candidates[-1], str(scenario), {}) + ti = TraceInfo.from_trace_state(trace=ts, state=ModelSpace()) + stg = ScenarioStateGraph() + stg.update_visualisation(ti) + + self.assertIn(('node0', 'node1'), stg.networkx.edges) + self.assertIn(('node1', 'node2'), stg.networkx.edges) + + edge_labels = nx.get_edge_attributes(stg.networkx, "label") + self.assertEqual(edge_labels[('node0', 'node1')], '') + self.assertEqual(edge_labels[('node1', 'node2')], '') + + def test_scenario_state_graph_update_visualisation_single_node(self): + ts = TraceState(1) + ts.confirm_full_scenario(0, 'one', {}) + self.assertEqual(ts.get_trace(), ['one']) + ti = TraceInfo.from_trace_state(ts, ModelSpace()) + stg = ScenarioStateGraph() + stg.update_visualisation(ti) + + # expected behaviour: no nodes nor edges are added + self.assertEqual(len(stg.networkx.nodes), 1) + self.assertEqual(len(stg.networkx.edges), 0) + + def test_scenario_state_graph_set_starting_node_new_node(self): + stg = ScenarioStateGraph() + si = ScenarioInfo('test') + stg._set_starting_node(si, StateInfo(ModelSpace())) + node_id = stg._get_or_create_id(si, StateInfo(ModelSpace())) + # node + self.assertIn(node_id, stg.networkx.nodes) + self.assertEqual(stg.networkx.nodes[node_id]['label'], + ScenarioStateGraph._gen_label(si, StateInfo(ModelSpace()))) + + # edge + self.assertIn(('start', node_id), stg.networkx.edges) + edge_labels = nx.get_edge_attributes(stg.networkx, "label") + self.assertEqual(edge_labels[('start', node_id)], '') + + def test_scenario_state_graph_set_starting_node_existing_node(self): + stg = ScenarioStateGraph() + si = ScenarioInfo('test') + node_id = stg._get_or_create_id(si, StateInfo(ModelSpace())) + stg._add_node(node_id) + self.assertIn(node_id, stg.networkx.nodes) + + stg._set_starting_node(si, StateInfo(ModelSpace())) + self.assertIn(('start', node_id), stg.networkx.edges) + edge_labels = nx.get_edge_attributes(stg.networkx, "label") + self.assertEqual(edge_labels[('start', node_id)], '') + + def test_scenario_state_graph_set_final_trace(self): + ts = TraceState(3) + candidates = [] + for scenario in range(3): + candidates.append(ts.next_candidate()) + ts.confirm_full_scenario(candidates[-1], str(scenario), {}) + ti = TraceInfo.from_trace_state(trace=ts, state=ModelSpace()) + stg = ScenarioStateGraph() + stg.update_visualisation(ti) + stg.set_final_trace(ti) + # test start node + self.assertIn(('start', 'node0'), stg.networkx.edges) + +if __name__ == '__main__': + unittest.main() From f5e6f06a37e207df45b6f724a32f72f671cfaefd Mon Sep 17 00:00:00 2001 From: Diogo Silva Date: Fri, 28 Nov 2025 12:18:14 +0100 Subject: [PATCH 022/131] Path Highlighting (#27) * Implement Feature Path Highlighting in Scenario and State Graphs * Implement State Graph Path highlighting with Stack * Clean-up of unused features, move some logic, fix empty state entry bug * Formatting * Formatting * Remove unused return value * Merge with main, fix some bugs, alter ScenarioStateGraph to be in line with the rest and fix up some of its unit tests * Add short explanation --------- Co-authored-by: Thomas --- robotmbt/visualise/graphs/abstractgraph.py | 8 ++ robotmbt/visualise/graphs/scenariograph.py | 34 ++----- .../visualise/graphs/scenariostategraph.py | 66 +++++++------ robotmbt/visualise/graphs/stategraph.py | 54 +++++++---- robotmbt/visualise/models.py | 17 +++- robotmbt/visualise/networkvisualiser.py | 95 +++++++++++++++---- utest/test_visualise_scenariograph.py | 80 +++++++--------- utest/test_visualise_scenariostategraph.py | 94 ++++++++---------- 8 files changed, 249 insertions(+), 199 deletions(-) diff --git a/robotmbt/visualise/graphs/abstractgraph.py b/robotmbt/visualise/graphs/abstractgraph.py index a51a9443..e61cbdf5 100644 --- a/robotmbt/visualise/graphs/abstractgraph.py +++ b/robotmbt/visualise/graphs/abstractgraph.py @@ -18,6 +18,14 @@ def set_final_trace(self, info: TraceInfo): """ pass + @abstractmethod + def get_final_trace(self) -> list[str]: + """ + Get the final trace as ordered node ids. + Edges are subsequent entries in the list. + """ + pass + @property @abstractmethod def networkx(self) -> nx.DiGraph: diff --git a/robotmbt/visualise/graphs/scenariograph.py b/robotmbt/visualise/graphs/scenariograph.py index c2e59315..c4c5168f 100644 --- a/robotmbt/visualise/graphs/scenariograph.py +++ b/robotmbt/visualise/graphs/scenariograph.py @@ -18,9 +18,7 @@ def __init__(self): # add the start node self.networkx.add_node('start', label='start') - - # indicates last scenario of trace - self.end_node = 'start' + self.final_trace: list[str] = ['start'] def update_visualisation(self, info: TraceInfo): """ @@ -37,6 +35,15 @@ def update_visualisation(self, info: TraceInfo): self.networkx.add_edge( from_node, to_node, label='') + if i == 0 and ('start', from_node) not in self.networkx.edges: + self.networkx.add_edge('start', from_node, label='') + + def set_final_trace(self, info: TraceInfo): + self.final_trace.extend(map(lambda s: self._get_or_create_id(s), info.trace)) + + def get_final_trace(self) -> list[str]: + return self.final_trace + def _get_or_create_id(self, scenario: ScenarioInfo) -> str: """ Get the ID for a scenario that has been added before, or create and store a new one. @@ -58,27 +65,6 @@ def _add_node(self, node: str): if node not in self.networkx.nodes: self.networkx.add_node(node, label=self.ids[node].name) - def _set_starting_node(self, scenario: ScenarioInfo): - """ - Update the starting node. - """ - node = self._get_or_create_id(scenario) - self._add_node(node) - self.networkx.add_edge('start', node, label='') - - def _set_ending_node(self, scenario: ScenarioInfo): - """ - Update the end node. - """ - self.end_node = self._get_or_create_id(scenario) - - def set_final_trace(self, info: TraceInfo): - """ - Update the graph with information on the final trace. - """ - self._set_starting_node(info.trace[0]) - self._set_ending_node(info.trace[-1]) - @property def networkx(self) -> nx.DiGraph: return self._networkx diff --git a/robotmbt/visualise/graphs/scenariostategraph.py b/robotmbt/visualise/graphs/scenariostategraph.py index 6a2d6020..157ba049 100644 --- a/robotmbt/visualise/graphs/scenariostategraph.py +++ b/robotmbt/visualise/graphs/scenariostategraph.py @@ -22,33 +22,55 @@ def __init__(self): # add the start node self.networkx.add_node('start', label='start') - self.start_scenario: ScenarioInfo | None = None - self.start_state: StateInfo | None = None - self.prev_state = StateInfo(ModelSpace()) - self.prev_state_len = 0 + self.prev_trace_len = 0 + + # Stack to track the current execution path + self.node_stack: list[str] = ['start'] def update_visualisation(self, info: TraceInfo): """ - This will add nodes for all new scenarios in the provided trace, as well as edges for all pairs in the provided trace. + This will add nodes the newly reached scenario/state pair, as well as an edge from the previous to + the current scenario/state pair. """ + if len(info.trace) == 0: + self.prev_trace_len = len(info.trace) + self.prev_state = info.state + return + if len(info.trace) == 1: - self.start_scenario = info.trace[0] - self.start_state = info.state + from_node = 'start' + else: + from_node = self._get_or_create_id(info.trace[-2], self.prev_state) + to_node = self._get_or_create_id(info.trace[-1], info.state) - for i in range(0, len(info.trace) - 1): - from_node = self._get_or_create_id(info.trace[i], self.prev_state) - to_node = self._get_or_create_id(info.trace[i + 1], info.state) + if self.prev_trace_len < len(info.trace): + # New state added - add to stack + self.node_stack.append(to_node) self._add_node(from_node) self._add_node(to_node) if (from_node, to_node) not in self.networkx.edges: - self.networkx.add_edge( - from_node, to_node, label='') + self.networkx.add_edge(from_node, to_node, label='') + + elif self.prev_trace_len > len(info.trace): + # States removed - remove from stack + pop_count = self.prev_trace_len - len(info.trace) + for _ in range(pop_count): + if len(self.node_stack) > 1: # Always keep 'start' + self.node_stack.pop() self.prev_state = info.state - self.prev_state_len = len(info.trace) + self.prev_trace_len = len(info.trace) + + def set_final_trace(self, info: TraceInfo): + # We already have the final trace in state_stack, so we don't need to do anything + pass + + def get_final_trace(self) -> list[str]: + # The final trace is simply the state stack we've been keeping track of + return self.node_stack def _get_or_create_id(self, scenario: ScenarioInfo, state: StateInfo) -> str: """ @@ -77,24 +99,6 @@ def _add_node(self, node: str): if node not in self.networkx.nodes: self.networkx.add_node(node, label=self._gen_label(self.ids[node][0], self.ids[node][1])) - def _set_starting_node(self, scenario: ScenarioInfo, state: StateInfo): - """ - Update the starting node. - """ - node = self._get_or_create_id(scenario, state) - self._add_node(node) - self.networkx.add_edge('start', node, label='') - - def set_final_trace(self, info: TraceInfo): - """ - Update the graph with information on the final trace. - """ - if self.start_scenario is None: - self.start_scenario = info.trace[0] - self.start_state = info.state # fallback if a trace with multiple nodes instantly materializes - first_node = self.ids[self._get_or_create_id(self.start_scenario, self.start_state)] - self._set_starting_node(first_node[0], first_node[1]) - @property def networkx(self) -> nx.DiGraph: return self._networkx diff --git a/robotmbt/visualise/graphs/stategraph.py b/robotmbt/visualise/graphs/stategraph.py index d23ebcb1..7be5d8b9 100644 --- a/robotmbt/visualise/graphs/stategraph.py +++ b/robotmbt/visualise/graphs/stategraph.py @@ -23,30 +23,55 @@ def __init__(self): self.prev_state = StateInfo(ModelSpace()) self.prev_trace_len = 0 + # Stack to track the current execution path + self.node_stack: list[str] = ['start'] + def update_visualisation(self, info: TraceInfo): """ This will add nodes the newly reached state (if we did not roll back), as well as an edge from the previous to the current state labeled with the scenario that took it there. """ - if len(info.trace) > 0: - scenario = info.trace[-1] + if len(info.trace) == 0: + self.prev_trace_len = len(info.trace) + self.prev_state = info.state + return + + scenario = info.trace[-1] + + from_node = self._get_or_create_id(self.prev_state) + if len(info.trace) == 1: + from_node = 'start' + to_node = self._get_or_create_id(info.state) - from_node = self._get_or_create_id(self.prev_state) - if len(info.trace) == 1: - from_node = 'start' - to_node = self._get_or_create_id(info.state) + if self.prev_trace_len < len(info.trace): + # New state added - add to stack + self.node_stack.append(to_node) self._add_node(from_node) self._add_node(to_node) - if self.prev_trace_len < len(info.trace): - if (from_node, to_node) not in self.networkx.edges: - self.networkx.add_edge( - from_node, to_node, label=scenario.name) + if (from_node, to_node) not in self.networkx.edges: + self.networkx.add_edge( + from_node, to_node, label=scenario.name) + + elif self.prev_trace_len > len(info.trace): + # States removed - remove from stack + pop_count = self.prev_trace_len - len(info.trace) + for _ in range(pop_count): + if len(self.node_stack) > 1: # Always keep 'start' + self.node_stack.pop() self.prev_state = info.state self.prev_trace_len = len(info.trace) + def set_final_trace(self, info: TraceInfo): + # We already have the final trace in state_stack, so we don't need to do anything + pass + + def get_final_trace(self) -> list[str]: + # The final trace is simply the state stack we've been keeping track of + return self.node_stack + def _get_or_create_id(self, state: StateInfo) -> str: """ Get the ID for a state that has been added before, or create and store a new one. @@ -66,15 +91,6 @@ def _add_node(self, node: str): if node not in self.networkx.nodes: self.networkx.add_node(node, label=str(self.ids[node])) - def set_final_trace(self, info: TraceInfo): - self._set_ending_node(info.state) - - def _set_ending_node(self, state: StateInfo): - """ - Update the end node. - """ - self.end_node = self._get_or_create_id(state) - @property def networkx(self) -> nx.DiGraph: return self._networkx diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index 20327437..5ce13658 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -33,14 +33,23 @@ class StateInfo: def __init__(self, state: ModelSpace): self.domain = state.ref_id - self.properties = {} + + # Extract all attributes/properties stored in the model space and store them in the temp dict + # Similar in workings to ModelSpace's get_status_text + temp = {} for p in state.props: - self.properties[p] = {} + temp[p] = {} if p == 'scenario': - self.properties['scenario'] = dict(state.props['scenario']) + temp['scenario'] = dict(state.props['scenario']) else: for attr in dir(state.props[p]): - self.properties[p][attr] = getattr(state.props[p], attr) + temp[p][attr] = getattr(state.props[p], attr) + + # Filter empty entries + self.properties = {} + for p in temp.keys(): + if len(temp[p]) > 0: + self.properties[p] = temp[p].copy() def __eq__(self, other): return self.domain == other.domain and self.properties == other.properties diff --git a/robotmbt/visualise/networkvisualiser.py b/robotmbt/visualise/networkvisualiser.py index 2fc9acba..837daaa8 100644 --- a/robotmbt/visualise/networkvisualiser.py +++ b/robotmbt/visualise/networkvisualiser.py @@ -1,4 +1,5 @@ from robotmbt.visualise.graphs.abstractgraph import AbstractGraph +from robotmbt.visualise.graphs.scenariostategraph import ScenarioStateGraph from robotmbt.visualise.graphs.stategraph import StateGraph from bokeh.palettes import Spectral4 from bokeh.models import ( @@ -28,6 +29,20 @@ class NetworkVisualiser: GRAPH_SIZE_PX: int = 600 MAX_VERTEX_NAME_LEN: int = 20 # no. of characters + # Colors and styles for executed vs unexecuted elements + EXECUTED_NODE_COLOR = Spectral4[0] # Bright blue + UNEXECUTED_NODE_COLOR = '#D3D3D3' # Light gray + EXECUTED_TEXT_COLOR = 'white' + UNEXECUTED_TEXT_COLOR = '#A9A9A9' # Dark gray + EXECUTED_EDGE_COLOR = (12, 12, 12) # Black + UNEXECUTED_EDGE_COLOR = '#808080' # Gray + EXECUTED_EDGE_WIDTH = 2.5 + UNEXECUTED_EDGE_WIDTH = 1.2 + EXECUTED_EDGE_ALPHA = 0.7 + UNEXECUTED_EDGE_ALPHA = 0.3 + EXECUTED_LABEL_COLOR = 'black' + UNEXECUTED_LABEL_COLOR = '#A9A9A9' + def __init__(self, graph: AbstractGraph): self.plot = None self.graph = graph @@ -40,6 +55,15 @@ def __init__(self, graph: AbstractGraph): self.char_height = 0.1 self.padding = 0.1 + # Get executed elements for visual differentiation + final_trace = graph.get_final_trace() + self.executed_nodes = set(final_trace) + self.executed_edges = set() + for i in range(0, len(final_trace) - 1): + from_node = final_trace[i] + to_node = final_trace[i + 1] + self.executed_edges.add((from_node, to_node)) + def generate_html(self) -> str: """ Generate html file from networkx graph via Bokeh @@ -100,9 +124,9 @@ def _add_nodes_with_labels(self): node_labels = nx.get_node_attributes(self.graph.networkx, "label") # Create data sources for nodes and labels - circle_data = dict(x=[], y=[], radius=[], label=[]) - rect_data = dict(x=[], y=[], width=[], height=[], label=[]) - text_data = dict(x=[], y=[], text=[]) + circle_data = dict(x=[], y=[], radius=[], label=[], color=[], text_color=[]) + rect_data = dict(x=[], y=[], width=[], height=[], label=[], color=[], text_color=[]) + text_data = dict(x=[], y=[], text=[], text_color=[]) for node in self.graph.networkx.nodes: # Labels are always defined and cannot be lists @@ -110,6 +134,11 @@ def _add_nodes_with_labels(self): label = self._cap_name(label) x, y = self.graph_layout[node] + # Determine if node is executed + is_executed = node in self.executed_nodes + node_color = self.EXECUTED_NODE_COLOR if is_executed else self.UNEXECUTED_NODE_COLOR + text_color = self.EXECUTED_TEXT_COLOR if is_executed else self.UNEXECUTED_TEXT_COLOR + if node == 'start': # For start node (circle), calculate radius based on text width text_width, text_height = self._calculate_text_dimensions( @@ -121,6 +150,8 @@ def _add_nodes_with_labels(self): circle_data['y'].append(y) circle_data['radius'].append(radius) circle_data['label'].append(label) + circle_data['color'].append(node_color) + circle_data['text_color'].append(text_color) # Store node properties for arrow calculations self.node_props[node] = { @@ -136,6 +167,8 @@ def _add_nodes_with_labels(self): rect_data['width'].append(text_width) rect_data['height'].append(text_height) rect_data['label'].append(label) + rect_data['color'].append(node_color) + rect_data['text_color'].append(text_color) # Store node properties for arrow calculations self.node_props[node] = {'type': 'rect', 'x': x, 'y': y, 'width': text_width, 'height': text_height, @@ -145,26 +178,27 @@ def _add_nodes_with_labels(self): text_data['x'].append(x) text_data['y'].append(y) text_data['text'].append(label) + text_data['text_color'].append(text_color) # Add circles for start node if circle_data['x']: circle_source = ColumnDataSource(circle_data) circles = Circle(x='x', y='y', radius='radius', - fill_color=Spectral4[0]) + fill_color='color', line_color='color') self.plot.add_glyph(circle_source, circles) # Add rectangles for scenario nodes if rect_data['x']: rect_source = ColumnDataSource(rect_data) rectangles = Rect(x='x', y='y', width='width', height='height', - fill_color=Spectral4[0]) + fill_color='color', line_color='color') self.plot.add_glyph(rect_source, rectangles) # Add text labels for all nodes text_source = ColumnDataSource(text_data) text_labels = Text(x='x', y='y', text='text', text_align='center', text_baseline='middle', - text_color='white', text_font_size='9pt') + text_color='text_color', text_font_size='9pt') self.plot.add_glyph(text_source, text_labels) def _get_edge_points(self, start_node, end_node): @@ -271,15 +305,21 @@ def add_self_loop(self, node_id: str): control2_x = x + width / 8 control2_y = y + height / 2 + arc_height + # Determine if edge is executed + is_executed = (node_id, node_id) in self.executed_edges + edge_color = self.EXECUTED_EDGE_COLOR if is_executed else self.UNEXECUTED_EDGE_COLOR + edge_width = self.EXECUTED_EDGE_WIDTH if is_executed else self.UNEXECUTED_EDGE_WIDTH + edge_alpha = self.EXECUTED_EDGE_ALPHA if is_executed else self.UNEXECUTED_EDGE_ALPHA + # Create the Bezier curve (the main arc) with the same thickness as straight lines loop = Bezier( x0=start_x, y0=start_y, x1=end_x, y1=end_y, cx0=control1_x, cy0=control1_y, cx1=control2_x, cy1=control2_y, - line_color=NetworkVisualiser.EDGE_COLOUR, - line_width=NetworkVisualiser.EDGE_WIDTH, - line_alpha=NetworkVisualiser.EDGE_ALPHA, + line_color=edge_color, + line_width=edge_width, + line_alpha=edge_alpha, ) self.plot.add_glyph(loop) @@ -297,9 +337,9 @@ def add_self_loop(self, node_id: str): # Add just the arrowhead (NormalHead) at the end point, oriented along the tangent arrowhead = NormalHead( size=NetworkVisualiser.ARROWHEAD_SIZE, - line_color=NetworkVisualiser.EDGE_COLOUR, - fill_color=NetworkVisualiser.EDGE_COLOUR, - line_width=NetworkVisualiser.EDGE_WIDTH + line_color=edge_color, + fill_color=edge_color, + line_width=edge_width ) # Create a standalone arrowhead at the end point @@ -310,9 +350,9 @@ def add_self_loop(self, node_id: str): y_start=end_y - tangent_y * 0.001, x_end=end_x, y_end=end_y, - line_color=NetworkVisualiser.EDGE_COLOUR, - line_width=NetworkVisualiser.EDGE_WIDTH, - line_alpha=NetworkVisualiser.EDGE_ALPHA + line_color=edge_color, + line_width=edge_width, + line_alpha=edge_alpha ) self.plot.add_layout(arrow) @@ -326,13 +366,22 @@ def _add_edges(self): edge_labels = nx.get_edge_attributes(self.graph.networkx, "label") # Create data sources for edges and edge labels - edge_text_data = dict(x=[], y=[], text=[]) + edge_text_data = dict(x=[], y=[], text=[], text_color=[]) for edge in self.graph.networkx.edges(): # Edge labels are always defined and cannot be lists edge_label = edge_labels[edge] edge_label = self._cap_name(edge_label) + + # Determine if edge is executed + is_executed = edge in self.executed_edges + edge_color = self.EXECUTED_EDGE_COLOR if is_executed else self.UNEXECUTED_EDGE_COLOR + edge_width = self.EXECUTED_EDGE_WIDTH if is_executed else self.UNEXECUTED_EDGE_WIDTH + edge_alpha = self.EXECUTED_EDGE_ALPHA if is_executed else self.UNEXECUTED_EDGE_ALPHA + label_color = self.EXECUTED_LABEL_COLOR if is_executed else self.UNEXECUTED_LABEL_COLOR + edge_text_data['text'].append(edge_label) + edge_text_data['text_color'].append(label_color) if edge[0] == edge[1]: # Self-loop handled separately @@ -349,10 +398,14 @@ def _add_edges(self): arrow = Arrow( end=NormalHead( size=NetworkVisualiser.ARROWHEAD_SIZE, - line_color=NetworkVisualiser.EDGE_COLOUR, - fill_color=NetworkVisualiser.EDGE_COLOUR), + line_color=edge_color, + fill_color=edge_color, + line_width=edge_width), x_start=start_x, y_start=start_y, - x_end=end_x, y_end=end_y + x_end=end_x, y_end=end_y, + line_color=edge_color, + line_width=edge_width, + line_alpha=edge_alpha ) self.plot.add_layout(arrow) @@ -365,11 +418,11 @@ def _add_edges(self): edge_text_source = ColumnDataSource(edge_text_data) edge_labels_glyph = Text(x='x', y='y', text='text', text_align='center', text_baseline='middle', - text_font_size='7pt') + text_color='text_color', text_font_size='7pt') self.plot.add_glyph(edge_text_source, edge_labels_glyph) def _cap_name(self, name: str) -> str: - if len(name) < self.MAX_VERTEX_NAME_LEN or isinstance(self.graph, StateGraph): + if len(name) < self.MAX_VERTEX_NAME_LEN or isinstance(self.graph, StateGraph) or isinstance(self.graph, ScenarioStateGraph): return name return f"{name[:(self.MAX_VERTEX_NAME_LEN - 3)]}..." diff --git a/utest/test_visualise_scenariograph.py b/utest/test_visualise_scenariograph.py index 6af361d7..57f20226 100644 --- a/utest/test_visualise_scenariograph.py +++ b/utest/test_visualise_scenariograph.py @@ -1,9 +1,11 @@ import unittest import networkx as nx from robotmbt.tracestate import TraceState + try: from robotmbt.visualise.graphs.scenariograph import ScenarioGraph from robotmbt.visualise.models import TraceInfo, ScenarioInfo, ModelSpace + VISUALISE = True except ImportError: VISUALISE = False @@ -30,12 +32,24 @@ def test_scenario_graph_ids_duplicate_scenario(self): def test_scenario_graph_ids_different_scenarios(self): sg = ScenarioGraph() - si0 = ScenarioInfo('test0') - si1 = ScenarioInfo('test1') - id0 = sg._get_or_create_id(si0) - id1 = sg._get_or_create_id(si1) - self.assertEqual(id0, 'node0') - self.assertEqual(id1, 'node1') + si00 = ScenarioInfo('test0') + si01 = ScenarioInfo('test0') + si10 = ScenarioInfo('test1') + si11 = ScenarioInfo('test1') + id00 = sg._get_or_create_id(si00) + id01 = sg._get_or_create_id(si01) + id10 = sg._get_or_create_id(si10) + id11 = sg._get_or_create_id(si11) + self.assertEqual(id00, 'node0') + self.assertEqual(id01, 'node0') + self.assertEqual(id00, id01) + self.assertEqual(id10, 'node1') + self.assertEqual(id11, 'node1') + self.assertEqual(id10, id11) + self.assertNotEqual(id00, id10) + self.assertNotEqual(id00, id11) + self.assertNotEqual(id01, id10) + self.assertNotEqual(id01, id11) def test_scenario_graph_add_new_node(self): sg = ScenarioGraph() @@ -60,6 +74,8 @@ def test_scenario_graph_update_visualisation_nodes(self): sg = ScenarioGraph() sg.update_visualisation(ti) + self.assertIn('start', sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') self.assertIn('node0', sg.networkx.nodes) self.assertEqual(sg.networkx.nodes['node0']['label'], str(0)) self.assertIn('node1', sg.networkx.nodes) @@ -73,14 +89,16 @@ def test_scenario_graph_update_visualisation_edges(self): for scenario in range(3): candidates.append(ts.next_candidate()) ts.confirm_full_scenario(candidates[-1], str(scenario), {}) - ti = TraceInfo.from_trace_state(trace=ts, state=ModelSpace()) + ti = TraceInfo.from_trace_state(ts, ModelSpace()) sg = ScenarioGraph() sg.update_visualisation(ti) + self.assertIn(('start', 'node0'), sg.networkx.edges) self.assertIn(('node0', 'node1'), sg.networkx.edges) self.assertIn(('node1', 'node2'), sg.networkx.edges) edge_labels = nx.get_edge_attributes(sg.networkx, "label") + self.assertEqual(edge_labels[('start', 'node0')], '') self.assertEqual(edge_labels[('node0', 'node1')], '') self.assertEqual(edge_labels[('node1', 'node2')], '') @@ -96,53 +114,23 @@ def test_scenario_graph_update_visualisation_single_node(self): self.assertEqual(len(sg.networkx.nodes), 1) self.assertEqual(len(sg.networkx.edges), 0) - def test_scenario_graph_set_starting_node_new_node(self): - sg = ScenarioGraph() - si = ScenarioInfo('test') - sg._set_starting_node(si) - node_id = sg._get_or_create_id(si) - # node - self.assertIn(node_id, sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes[node_id]['label'], 'test') - - # edge - self.assertIn(('start', node_id), sg.networkx.edges) - edge_labels = nx.get_edge_attributes(sg.networkx, "label") - self.assertEqual(edge_labels[('start', node_id)], '') - - def test_scenario_graph_set_starting_node_existing_node(self): - sg = ScenarioGraph() - si = ScenarioInfo('test') - node_id = sg._get_or_create_id(si) - sg._add_node(node_id) - self.assertIn(node_id, sg.networkx.nodes) - - sg._set_starting_node(si) - self.assertIn(('start', node_id), sg.networkx.edges) - edge_labels = nx.get_edge_attributes(sg.networkx, "label") - self.assertEqual(edge_labels[('start', node_id)], '') - - def test_scenario_graph_set_end_node(self): - sg = ScenarioGraph() - si = ScenarioInfo('test') - node_id = sg._get_or_create_id(si) - sg._set_ending_node(si) - self.assertEqual(sg.end_node, node_id) - - def test_scenario_graph_set_final_trace(self): + def test_scenario_graph_get_final_trace(self): ts = TraceState(3) candidates = [] for scenario in range(3): candidates.append(ts.next_candidate()) ts.confirm_full_scenario(candidates[-1], str(scenario), {}) - ti = TraceInfo.from_trace_state(trace=ts, state=ModelSpace()) + ti = TraceInfo.from_trace_state(ts, ModelSpace()) sg = ScenarioGraph() sg.update_visualisation(ti) sg.set_final_trace(ti) - # test start node - self.assertIn(('start', 'node0'), sg.networkx.edges) - # test end node - self.assertEqual(sg.end_node, 'node2') + trace = sg.get_final_trace() + # confirm they are proper ids + for node in trace: + self.assertIn(node, sg.networkx.nodes) + # confirm the edges exist + for i in range(0, len(trace) - 1): + self.assertIn((trace[i], trace[i + 1]), sg.networkx.edges) if __name__ == '__main__': unittest.main() diff --git a/utest/test_visualise_scenariostategraph.py b/utest/test_visualise_scenariostategraph.py index 5c8d5fe3..45ea8154 100644 --- a/utest/test_visualise_scenariostategraph.py +++ b/utest/test_visualise_scenariostategraph.py @@ -1,6 +1,7 @@ import unittest import networkx as nx from robotmbt.tracestate import TraceState + try: from robotmbt.visualise.graphs.scenariostategraph import ScenarioStateGraph from robotmbt.visualise.models import TraceInfo, ScenarioInfo, ModelSpace, StateInfo @@ -13,48 +14,70 @@ class TestVisualiseScenarioGraph(unittest.TestCase): def test_scenario_state_graph_init(self): stg = ScenarioStateGraph() + self.assertIn('start', stg.networkx.nodes) self.assertEqual(stg.networkx.nodes['start']['label'], 'start') + self.assertEqual(len(stg.networkx.nodes), 1) + self.assertEqual(len(stg.networkx.edges), 0) def test_scenario_state_graph_ids_empty(self): stg = ScenarioStateGraph() + si = ScenarioInfo('test') node_id = stg._get_or_create_id(si, StateInfo(ModelSpace())) + self.assertEqual(node_id, 'node0') def test_scenario_state_graph_ids_duplicate_scenario(self): stg = ScenarioStateGraph() + si = ScenarioInfo('test') sti = StateInfo(ModelSpace()) + id0 = stg._get_or_create_id(si, sti) id1 = stg._get_or_create_id(si, sti) + self.assertEqual(id0, id1) def test_scenario_state_graph_ids_different_scenarios(self): stg = ScenarioStateGraph() + si0 = ScenarioInfo('test0') si1 = ScenarioInfo('test1') sti = StateInfo(ModelSpace()) + id0 = stg._get_or_create_id(si0, sti) id1 = stg._get_or_create_id(si1, sti) + self.assertEqual(id0, 'node0') self.assertEqual(id1, 'node1') def test_scenario_state_graph_ids_different_states(self): stg = ScenarioStateGraph() + si = ScenarioInfo('test0') sti0 = StateInfo(ModelSpace("state0")) sti1 = StateInfo(ModelSpace("state1")) + id0 = stg._get_or_create_id(si, sti0) id1 = stg._get_or_create_id(si, sti1) + self.assertEqual(id0, 'node0') self.assertEqual(id1, 'node1') def test_scenario_state_graph_add_new_node(self): stg = ScenarioStateGraph() + + self.assertIn('start', stg.networkx.nodes) + self.assertNotIn('test', stg.networkx.nodes) + self.assertEqual(len(stg.networkx.nodes), 1) + stg.ids['test'] = (ScenarioInfo('test'), StateInfo(ModelSpace())) stg._add_node('test') + + self.assertIn('start', stg.networkx.nodes) self.assertIn('test', stg.networkx.nodes) + self.assertEqual(len(stg.networkx.nodes), 2) self.assertEqual(stg.networkx.nodes['test']['label'], ScenarioStateGraph._gen_label(ScenarioInfo('test'), StateInfo(ModelSpace()))) @@ -65,34 +88,37 @@ def test_scenario_state_graph_add_existing_node(self): self.assertEqual(len(stg.networkx.nodes), 1) def test_scenario_state_graph_update_visualisation_nodes(self): + stg = ScenarioStateGraph() + ts = TraceState(3) candidates = [] for scenario in range(3): candidates.append(ts.next_candidate()) ts.confirm_full_scenario(candidates[-1], str(scenario), {}) - ti = TraceInfo.from_trace_state(ts, ModelSpace()) - stg = ScenarioStateGraph() - stg.update_visualisation(ti) + ti = TraceInfo.from_trace_state(ts, ModelSpace()) + stg.update_visualisation(ti) self.assertIn('node0', stg.networkx.nodes) + self.assertIn('node1', stg.networkx.nodes) + self.assertIn('node2', stg.networkx.nodes) + self.assertEqual(stg.networkx.nodes['node0']['label'], ScenarioStateGraph._gen_label(ScenarioInfo(str(0)), StateInfo(ModelSpace()))) - self.assertIn('node1', stg.networkx.nodes) self.assertEqual(stg.networkx.nodes['node1']['label'], ScenarioStateGraph._gen_label(ScenarioInfo(str(1)), StateInfo(ModelSpace()))) - self.assertIn('node2', stg.networkx.nodes) self.assertEqual(stg.networkx.nodes['node2']['label'], ScenarioStateGraph._gen_label(ScenarioInfo(str(2)), StateInfo(ModelSpace()))) def test_scenario_state_graph_update_visualisation_edges(self): + stg = ScenarioStateGraph() + ts = TraceState(3) candidates = [] for scenario in range(3): candidates.append(ts.next_candidate()) ts.confirm_full_scenario(candidates[-1], str(scenario), {}) - ti = TraceInfo.from_trace_state(trace=ts, state=ModelSpace()) - stg = ScenarioStateGraph() - stg.update_visualisation(ti) + ti = TraceInfo.from_trace_state(trace=ts, state=ModelSpace()) + stg.update_visualisation(ti) self.assertIn(('node0', 'node1'), stg.networkx.edges) self.assertIn(('node1', 'node2'), stg.networkx.edges) @@ -102,56 +128,16 @@ def test_scenario_state_graph_update_visualisation_edges(self): self.assertEqual(edge_labels[('node1', 'node2')], '') def test_scenario_state_graph_update_visualisation_single_node(self): - ts = TraceState(1) - ts.confirm_full_scenario(0, 'one', {}) - self.assertEqual(ts.get_trace(), ['one']) - ti = TraceInfo.from_trace_state(ts, ModelSpace()) - stg = ScenarioStateGraph() - stg.update_visualisation(ti) - - # expected behaviour: no nodes nor edges are added - self.assertEqual(len(stg.networkx.nodes), 1) - self.assertEqual(len(stg.networkx.edges), 0) - - def test_scenario_state_graph_set_starting_node_new_node(self): stg = ScenarioStateGraph() - si = ScenarioInfo('test') - stg._set_starting_node(si, StateInfo(ModelSpace())) - node_id = stg._get_or_create_id(si, StateInfo(ModelSpace())) - # node - self.assertIn(node_id, stg.networkx.nodes) - self.assertEqual(stg.networkx.nodes[node_id]['label'], - ScenarioStateGraph._gen_label(si, StateInfo(ModelSpace()))) - # edge - self.assertIn(('start', node_id), stg.networkx.edges) - edge_labels = nx.get_edge_attributes(stg.networkx, "label") - self.assertEqual(edge_labels[('start', node_id)], '') - - def test_scenario_state_graph_set_starting_node_existing_node(self): - stg = ScenarioStateGraph() - si = ScenarioInfo('test') - node_id = stg._get_or_create_id(si, StateInfo(ModelSpace())) - stg._add_node(node_id) - self.assertIn(node_id, stg.networkx.nodes) + ti = TraceInfo([ScenarioInfo('one')], ModelSpace()) + stg.update_visualisation(ti) - stg._set_starting_node(si, StateInfo(ModelSpace())) - self.assertIn(('start', node_id), stg.networkx.edges) - edge_labels = nx.get_edge_attributes(stg.networkx, "label") - self.assertEqual(edge_labels[('start', node_id)], '') + # expected behaviour: only start and added node and their edge + self.assertEqual(len(stg.networkx.nodes), 2) + self.assertEqual(len(stg.networkx.edges), 1) - def test_scenario_state_graph_set_final_trace(self): - ts = TraceState(3) - candidates = [] - for scenario in range(3): - candidates.append(ts.next_candidate()) - ts.confirm_full_scenario(candidates[-1], str(scenario), {}) - ti = TraceInfo.from_trace_state(trace=ts, state=ModelSpace()) - stg = ScenarioStateGraph() - stg.update_visualisation(ti) - stg.set_final_trace(ti) - # test start node - self.assertIn(('start', 'node0'), stg.networkx.edges) + # TODO: improve existing tests and add tests for set_final_trace/get_final_trace, _gen_label if __name__ == '__main__': unittest.main() From 825c5276fb7d1a82573e6fa1329107ee42b93edd Mon Sep 17 00:00:00 2001 From: Thomas Kas Date: Tue, 2 Dec 2025 16:43:50 +0100 Subject: [PATCH 023/131] Unit tests and bug fixes (#28) * Implement Feature Path Highlighting in Scenario and State Graphs * Implement State Graph Path highlighting with Stack * Clean-up of unused features, move some logic, fix empty state entry bug * Formatting * Formatting * Remove unused return value * Fix bug * StateInfo unit tests * StateGraph unit tests * Merge with main, fix some bugs, alter ScenarioStateGraph to be in line with the rest and fix up some of its unit tests * Add short explanation * Test self-loops * Minor tweaks * Remove trailing newline * Fix nuking of stack * Better formatting of state info * Spam __update_visualisation all over the place for refinement * Fix nuking in scenario-state graph * Warn instead of crashing * Sanity check * Do add edge with start node * Expand tests * Merge mistake * Fix oopsie * Move helper to StateInfo * Protected helper * Forgot this one * added graph getter for resources --------- Co-authored-by: Diogo Silva Co-authored-by: Douwe Osinga --- atest/resources/helpers/modelgenerator.py | 17 +- robotmbt/suiteprocessors.py | 22 +- robotmbt/visualise/graphs/scenariograph.py | 12 +- .../visualise/graphs/scenariostategraph.py | 37 +- robotmbt/visualise/graphs/stategraph.py | 39 +- robotmbt/visualise/models.py | 23 +- robotmbt/visualise/networkvisualiser.py | 4 +- utest/test_visualise_models.py | 51 ++- utest/test_visualise_scenariograph.py | 282 +++++++++--- utest/test_visualise_scenariostategraph.py | 414 +++++++++++++++--- utest/test_visualise_stategraph.py | 318 +++++++++++++- 11 files changed, 1033 insertions(+), 186 deletions(-) diff --git a/atest/resources/helpers/modelgenerator.py b/atest/resources/helpers/modelgenerator.py index 58339df8..b5837aeb 100644 --- a/atest/resources/helpers/modelgenerator.py +++ b/atest/resources/helpers/modelgenerator.py @@ -3,18 +3,31 @@ from robot.api.deco import keyword # type:ignore from robotmbt.modelspace import ModelSpace -from robotmbt.visualise.models import TraceInfo, ScenarioInfo +from robotmbt.visualise.models import TraceInfo, ScenarioInfo, StateInfo from robotmbt.visualise.graphs.scenariograph import ScenarioGraph +from robotmbt.visualise.graphs.abstractgraph import AbstractGraph +from robotmbt.visualise.graphs.stategraph import StateGraph class ModelGenerator: + @keyword(name="Create Graph") # type: ignore + def create_graph(self, graph_type :str) -> AbstractGraph: + match graph_type: + case "scenario": + return ScenarioGraph() + case "state": + return StateGraph() + case _: + raise Exception(f"Trying to create unknown graph type {graph_type}") + + @keyword(name="Generate Trace Information") # type: ignore def generate_trace_info(self, scenario_count: int) -> TraceInfo: """Generates a list of unique random scenarios.""" scenarios: list[ScenarioInfo] = ModelGenerator.generate_scenario_names( scenario_count) - return TraceInfo(scenarios, ModelSpace()) + return TraceInfo(scenarios, StateInfo(ModelSpace())) @keyword(name="Ensure Scenario Present") # type: ignore def ensure_scenario_present(self, trace_info: TraceInfo, scenario_name: str) -> TraceInfo: diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index c41a4a5a..208fb8fe 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -153,6 +153,9 @@ def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool): self.active_model.new_scenario_scope() inserted = self._try_to_fit_in_scenario(i_candidate, self._scenario_with_repeat_counter(i_candidate), retry_flag=allow_duplicate_scenarios) + + self.__update_visualisation() + if inserted: self.DROUGHT_LIMIT = 50 if self.__last_candidate_changed_nothing(): @@ -167,9 +170,12 @@ def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool): self._report_tracestate_to_user() logger.debug( f"last state:\n{self.active_model.get_status_text()}") - if self.visualiser is not None: - self.visualiser.update_visualisation( - TraceInfo.from_trace_state(self.tracestate, self.active_model)) + self.__update_visualisation() + + def __update_visualisation(self): + if self.visualiser is not None: + self.visualiser.update_visualisation( + TraceInfo.from_trace_state(self.tracestate, self.active_model)) def __last_candidate_changed_nothing(self) -> bool: if len(self.tracestate) < 2: @@ -223,6 +229,7 @@ def _try_to_fit_in_scenario(self, index: int, candidate: Scenario, retry_flag: b f"Inserted scenario {confirmed_candidate.src_id}, {confirmed_candidate.name}") self._report_tracestate_to_user() logger.debug(f"last state:\n{self.active_model.get_status_text()}") + self.__update_visualisation() return True part1, part2 = self._split_candidate_if_refinement_needed( @@ -243,12 +250,15 @@ def _try_to_fit_in_scenario(self, index: int, candidate: Scenario, retry_flag: b "Refinement needed, but there are no scenarios left") self._rewind() self._report_tracestate_to_user() + self.__update_visualisation() return False while i_refine is not None: + self.__update_visualisation() self.active_model.new_scenario_scope() m_inserted = self._try_to_fit_in_scenario(i_refine, self._scenario_with_repeat_counter(i_refine), retry_flag) + self.__update_visualisation() if m_inserted: insert_valid_here = True try: @@ -265,6 +275,7 @@ def _try_to_fit_in_scenario(self, index: int, candidate: Scenario, retry_flag: b m_finished = self._try_to_fit_in_scenario( index, part2, retry_flag) if m_finished: + self.__update_visualisation() return True else: logger.debug( @@ -276,15 +287,20 @@ def _try_to_fit_in_scenario(self, index: int, candidate: Scenario, retry_flag: b self._rewind() self._report_tracestate_to_user() + self.__update_visualisation() i_refine = self.tracestate.next_candidate(retry=retry_flag) + self.__update_visualisation() + self._rewind() self._report_tracestate_to_user() + self.__update_visualisation() return False self.active_model.end_scenario_scope() self.tracestate.reject_scenario(index) self._report_tracestate_to_user() + self.__update_visualisation() return False def _rewind(self, drought_recovery: bool = False) -> TraceSnapShot | None: diff --git a/robotmbt/visualise/graphs/scenariograph.py b/robotmbt/visualise/graphs/scenariograph.py index c4c5168f..cddc6a48 100644 --- a/robotmbt/visualise/graphs/scenariograph.py +++ b/robotmbt/visualise/graphs/scenariograph.py @@ -32,11 +32,15 @@ def update_visualisation(self, info: TraceInfo): self._add_node(to_node) if (from_node, to_node) not in self.networkx.edges: - self.networkx.add_edge( - from_node, to_node, label='') + self.networkx.add_edge(from_node, to_node, label='') - if i == 0 and ('start', from_node) not in self.networkx.edges: - self.networkx.add_edge('start', from_node, label='') + if len(info.trace) > 0: + first_id = self._get_or_create_id(info.trace[0]) + + self._add_node(first_id) + + if ('start', first_id) not in self.networkx.edges: + self.networkx.add_edge('start', first_id, label='') def set_final_trace(self, info: TraceInfo): self.final_trace.extend(map(lambda s: self._get_or_create_id(s), info.trace)) diff --git a/robotmbt/visualise/graphs/scenariostategraph.py b/robotmbt/visualise/graphs/scenariostategraph.py index 157ba049..bf500178 100644 --- a/robotmbt/visualise/graphs/scenariostategraph.py +++ b/robotmbt/visualise/graphs/scenariostategraph.py @@ -1,6 +1,6 @@ import networkx as nx +from robot.api import logger -from robotmbt.modelspace import ModelSpace from robotmbt.visualise.graphs.abstractgraph import AbstractGraph from robotmbt.visualise.models import TraceInfo, ScenarioInfo, StateInfo @@ -22,7 +22,6 @@ def __init__(self): # add the start node self.networkx.add_node('start', label='start') - self.prev_state = StateInfo(ModelSpace()) self.prev_trace_len = 0 # Stack to track the current execution path @@ -33,26 +32,17 @@ def update_visualisation(self, info: TraceInfo): This will add nodes the newly reached scenario/state pair, as well as an edge from the previous to the current scenario/state pair. """ - if len(info.trace) == 0: - self.prev_trace_len = len(info.trace) - self.prev_state = info.state - return - - if len(info.trace) == 1: - from_node = 'start' - else: - from_node = self._get_or_create_id(info.trace[-2], self.prev_state) - to_node = self._get_or_create_id(info.trace[-1], info.state) - if self.prev_trace_len < len(info.trace): # New state added - add to stack - self.node_stack.append(to_node) - - self._add_node(from_node) - self._add_node(to_node) + push_count = len(info.trace) - self.prev_trace_len + for i in range(push_count): + node = self._get_or_create_id(info.trace[-push_count + i], info.state) + self.node_stack.append(node) + self._add_node(self.node_stack[-2]) + self._add_node(self.node_stack[-1]) - if (from_node, to_node) not in self.networkx.edges: - self.networkx.add_edge(from_node, to_node, label='') + if (self.node_stack[-2], self.node_stack[-1]) not in self.networkx.edges: + self.networkx.add_edge(self.node_stack[-2], self.node_stack[-1], label='') elif self.prev_trace_len > len(info.trace): # States removed - remove from stack @@ -60,13 +50,16 @@ def update_visualisation(self, info: TraceInfo): for _ in range(pop_count): if len(self.node_stack) > 1: # Always keep 'start' self.node_stack.pop() + else: + logger.warn("Tried to rollback more than was previously added to the stack!") - self.prev_state = info.state self.prev_trace_len = len(info.trace) def set_final_trace(self, info: TraceInfo): # We already have the final trace in state_stack, so we don't need to do anything - pass + # But do a sanity check + if self.prev_trace_len != len(info.trace): + logger.warn("Final trace was of a different length than our stack was based on!") def get_final_trace(self) -> list[str]: # The final trace is simply the state stack we've been keeping track of @@ -90,7 +83,7 @@ def _gen_label(scenario: ScenarioInfo, state: StateInfo) -> str: """ Creates the label for a node in a Scenario-State Graph from the scenario and state associated to it. """ - return scenario.name + "\n\r" + str(state) + return scenario.name + "\n\n" + str(state) def _add_node(self, node: str): """ diff --git a/robotmbt/visualise/graphs/stategraph.py b/robotmbt/visualise/graphs/stategraph.py index 7be5d8b9..e61bb32e 100644 --- a/robotmbt/visualise/graphs/stategraph.py +++ b/robotmbt/visualise/graphs/stategraph.py @@ -1,6 +1,7 @@ +from robot.api import logger + from robotmbt.visualise.graphs.abstractgraph import AbstractGraph from robotmbt.visualise.models import TraceInfo, StateInfo -from robotmbt.modelspace import ModelSpace import networkx as nx @@ -20,7 +21,7 @@ def __init__(self): # add the start node self.networkx.add_node('start', label='start') - self.prev_state = StateInfo(ModelSpace()) + # To check if we've backtracked self.prev_trace_len = 0 # Stack to track the current execution path @@ -31,28 +32,19 @@ def update_visualisation(self, info: TraceInfo): This will add nodes the newly reached state (if we did not roll back), as well as an edge from the previous to the current state labeled with the scenario that took it there. """ - if len(info.trace) == 0: - self.prev_trace_len = len(info.trace) - self.prev_state = info.state - return - - scenario = info.trace[-1] - - from_node = self._get_or_create_id(self.prev_state) - if len(info.trace) == 1: - from_node = 'start' - to_node = self._get_or_create_id(info.state) + node = self._get_or_create_id(info.state) if self.prev_trace_len < len(info.trace): # New state added - add to stack - self.node_stack.append(to_node) - - self._add_node(from_node) - self._add_node(to_node) + push_count = len(info.trace) - self.prev_trace_len + for i in range(push_count): + self.node_stack.append(node) + self._add_node(self.node_stack[-2]) + self._add_node(self.node_stack[-1]) - if (from_node, to_node) not in self.networkx.edges: - self.networkx.add_edge( - from_node, to_node, label=scenario.name) + if (self.node_stack[-2], self.node_stack[-1]) not in self.networkx.edges: + self.networkx.add_edge( + self.node_stack[-2], self.node_stack[-1], label=info.trace[-push_count + i].name) elif self.prev_trace_len > len(info.trace): # States removed - remove from stack @@ -60,13 +52,16 @@ def update_visualisation(self, info: TraceInfo): for _ in range(pop_count): if len(self.node_stack) > 1: # Always keep 'start' self.node_stack.pop() + else: + logger.warn("Tried to rollback more than was previously added to the stack!") - self.prev_state = info.state self.prev_trace_len = len(info.trace) def set_final_trace(self, info: TraceInfo): # We already have the final trace in state_stack, so we don't need to do anything - pass + # But do a sanity check + if self.prev_trace_len != len(info.trace): + logger.warn("Final trace was of a different length than our stack was based on!") def get_final_trace(self) -> list[str]: # The final trace is simply the state stack we've been keeping track of diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index 5ce13658..f6afd14b 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -1,3 +1,5 @@ +from typing import Any + from robotmbt.modelspace import ModelSpace from robotmbt.suitedata import Scenario from robotmbt.tracestate import TraceState @@ -31,6 +33,15 @@ class StateInfo: - properties """ + @classmethod + def _create_state_with_prop(cls, name: str, attrs: list[tuple[str, Any]]): + space = ModelSpace() + prop = ModelSpace() + for (key, val) in attrs: + prop.__setattr__(key, val) + space.props[name] = prop + return cls(space) + def __init__(self, state: ModelSpace): self.domain = state.ref_id @@ -57,9 +68,11 @@ def __eq__(self, other): def __str__(self): res = "" for p in self.properties: - res += f"{p}:\n" + if res != "": + res += "\n\n" + res += f"{p}:" for k, v in self.properties[p].items(): - res += f"\t{k}={v}\n" + res += f"\n\t{k}={v}" return res @@ -72,11 +85,11 @@ class TraceInfo: @classmethod def from_trace_state(cls, trace: TraceState, state: ModelSpace): - return cls([ScenarioInfo(t) for t in trace.get_trace()], state) + return cls([ScenarioInfo(t) for t in trace.get_trace()], StateInfo(state)) - def __init__(self, trace: list[ScenarioInfo], state: ModelSpace): + def __init__(self, trace: list[ScenarioInfo], state: StateInfo): self.trace: list[ScenarioInfo] = trace - self.state = StateInfo(state) + self.state = state def __repr__(self) -> str: return f"TraceInfo(trace=[{[str(t) for t in self.trace]}], state={self.state})" diff --git a/robotmbt/visualise/networkvisualiser.py b/robotmbt/visualise/networkvisualiser.py index 837daaa8..5e4b6f71 100644 --- a/robotmbt/visualise/networkvisualiser.py +++ b/robotmbt/visualise/networkvisualiser.py @@ -1,6 +1,6 @@ from robotmbt.visualise.graphs.abstractgraph import AbstractGraph -from robotmbt.visualise.graphs.scenariostategraph import ScenarioStateGraph from robotmbt.visualise.graphs.stategraph import StateGraph +from robotmbt.visualise.graphs.scenariostategraph import ScenarioStateGraph from bokeh.palettes import Spectral4 from bokeh.models import ( Plot, Range1d, Circle, Rect, @@ -32,7 +32,7 @@ class NetworkVisualiser: # Colors and styles for executed vs unexecuted elements EXECUTED_NODE_COLOR = Spectral4[0] # Bright blue UNEXECUTED_NODE_COLOR = '#D3D3D3' # Light gray - EXECUTED_TEXT_COLOR = 'white' + EXECUTED_TEXT_COLOR = '#C8C8C8' UNEXECUTED_TEXT_COLOR = '#A9A9A9' # Dark gray EXECUTED_EDGE_COLOR = (12, 12, 12) # Black UNEXECUTED_EDGE_COLOR = '#808080' # Gray diff --git a/utest/test_visualise_models.py b/utest/test_visualise_models.py index 7fbc59ab..8bca3ffb 100644 --- a/utest/test_visualise_models.py +++ b/utest/test_visualise_models.py @@ -1,6 +1,8 @@ import unittest + try: from robotmbt.visualise.models import * + VISUALISE = True except ImportError: VISUALISE = False @@ -12,8 +14,8 @@ class TestVisualiseModels(unittest.TestCase): """ """ - Class: ScenarioInfo - """ + Class: ScenarioInfo + """ def test_scenarioInfo_str(self): si = ScenarioInfo('test') @@ -28,10 +30,45 @@ def test_scenarioInfo_Scenario(self): self.assertEqual(si.src_id, 0) """ - Class: TraceInfo - """ + Class: StateInfo + """ + + def test_stateInfo_empty(self): + s = StateInfo(ModelSpace()) + self.assertEqual(str(s), '') + + def test_stateInfo_prop_empty(self): + space = ModelSpace() + space.props['prop1'] = ModelSpace() + s = StateInfo(space) + self.assertEqual(str(s), '') + + def test_stateInfo_prop_val(self): + space = ModelSpace() + prop1 = ModelSpace() + prop1.value = 1 + space.props['prop1'] = prop1 + s = StateInfo(space) + self.assertTrue('prop1:' in str(s)) + self.assertTrue('value=1' in str(s)) + + def test_stateInfo_prop_val_empty(self): + space = ModelSpace() + prop1 = ModelSpace() + prop1.value = 1 + prop2 = ModelSpace() + space.props['prop1'] = prop1 + space.props['prop2'] = prop2 + s = StateInfo(space) + self.assertTrue('prop1:' in str(s)) + self.assertTrue('value=1' in str(s)) + self.assertFalse('prop2:' in str(s)) + + """ + Class: TraceInfo + """ - def test_create_TraceInfo(self): + def test_traceInfo(self): ts = TraceState(3) candidates = [] for scenario in range(3): @@ -43,9 +80,7 @@ def test_create_TraceInfo(self): self.assertEqual(ti.trace[1].name, str(1)) self.assertEqual(ti.trace[2].name, str(2)) - self.assertIsNotNone(ti.state) - # TODO check state - + self.assertEqual(str(ti.state), '') if __name__ == '__main__': unittest.main() diff --git a/utest/test_visualise_scenariograph.py b/utest/test_visualise_scenariograph.py index 57f20226..5cbafe05 100644 --- a/utest/test_visualise_scenariograph.py +++ b/utest/test_visualise_scenariograph.py @@ -1,10 +1,8 @@ import unittest -import networkx as nx -from robotmbt.tracestate import TraceState try: from robotmbt.visualise.graphs.scenariograph import ScenarioGraph - from robotmbt.visualise.models import TraceInfo, ScenarioInfo, ModelSpace + from robotmbt.visualise.models import * VISUALISE = True except ImportError: @@ -14,38 +12,54 @@ class TestVisualiseScenarioGraph(unittest.TestCase): def test_scenario_graph_init(self): sg = ScenarioGraph() + + self.assertEqual(len(sg.networkx.nodes), 1) + self.assertEqual(len(sg.networkx.edges), 0) + self.assertIn('start', sg.networkx.nodes) self.assertEqual(sg.networkx.nodes['start']['label'], 'start') def test_scenario_graph_ids_empty(self): sg = ScenarioGraph() - si = ScenarioInfo('test') - node_id = sg._get_or_create_id(si) + + s = ScenarioInfo('test') + + node_id = sg._get_or_create_id(s) + self.assertEqual(node_id, 'node0') def test_scenario_graph_ids_duplicate_scenario(self): sg = ScenarioGraph() - si = ScenarioInfo('test') - id0 = sg._get_or_create_id(si) - id1 = sg._get_or_create_id(si) + + s0 = ScenarioInfo('test') + s1 = ScenarioInfo('test') + + id0 = sg._get_or_create_id(s0) + id1 = sg._get_or_create_id(s1) + self.assertEqual(id0, id1) def test_scenario_graph_ids_different_scenarios(self): sg = ScenarioGraph() + si00 = ScenarioInfo('test0') si01 = ScenarioInfo('test0') si10 = ScenarioInfo('test1') si11 = ScenarioInfo('test1') + id00 = sg._get_or_create_id(si00) id01 = sg._get_or_create_id(si01) id10 = sg._get_or_create_id(si10) id11 = sg._get_or_create_id(si11) + self.assertEqual(id00, 'node0') self.assertEqual(id01, 'node0') self.assertEqual(id00, id01) + self.assertEqual(id10, 'node1') self.assertEqual(id11, 'node1') self.assertEqual(id10, id11) + self.assertNotEqual(id00, id10) self.assertNotEqual(id00, id11) self.assertNotEqual(id01, id10) @@ -53,84 +67,254 @@ def test_scenario_graph_ids_different_scenarios(self): def test_scenario_graph_add_new_node(self): sg = ScenarioGraph() + sg.ids['test'] = ScenarioInfo('test') sg._add_node('test') + + self.assertEqual(len(sg.networkx.nodes), 2) + + self.assertIn('start', sg.networkx.nodes) self.assertIn('test', sg.networkx.nodes) + + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') self.assertEqual(sg.networkx.nodes['test']['label'], 'test') def test_scenario_graph_add_existing_node(self): sg = ScenarioGraph() - sg._add_node('start') + + self.assertEqual(len(sg.networkx.nodes), 1) self.assertIn('start', sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') + + sg._add_node('start') + self.assertEqual(len(sg.networkx.nodes), 1) + self.assertIn('start', sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') - def test_scenario_graph_update_visualisation_nodes(self): - ts = TraceState(3) - candidates = [] - for scenario in range(3): - candidates.append(ts.next_candidate()) - ts.confirm_full_scenario(candidates[-1], str(scenario), {}) - ti = TraceInfo.from_trace_state(ts, ModelSpace()) + def test_scenario_graph_update_nodes(self): sg = ScenarioGraph() - sg.update_visualisation(ti) + + scenario0 = ScenarioInfo('0') + scenario1 = ScenarioInfo('1') + scenario2 = ScenarioInfo('2') + + self.assertEqual(len(sg.networkx.nodes), 1) + self.assertEqual(len(sg.networkx.edges), 0) + + self.assertIn('start', sg.networkx.nodes) + + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') + + sg.update_visualisation(TraceInfo([scenario0], StateInfo(ModelSpace()))) + + self.assertEqual(len(sg.networkx.nodes), 2) + self.assertEqual(len(sg.networkx.edges), 1) + + self.assertIn('start', sg.networkx.nodes) + self.assertIn('node0', sg.networkx.nodes) + + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') + self.assertEqual(sg.networkx.nodes['node0']['label'], '0') + + sg.update_visualisation(TraceInfo([scenario0, scenario1], StateInfo(ModelSpace()))) + + self.assertEqual(len(sg.networkx.nodes), 3) + self.assertEqual(len(sg.networkx.edges), 2) self.assertIn('start', sg.networkx.nodes) + self.assertIn('node0', sg.networkx.nodes) + self.assertIn('node1', sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') + self.assertEqual(sg.networkx.nodes['node0']['label'], '0') + self.assertEqual(sg.networkx.nodes['node1']['label'], '1') + + sg.update_visualisation(TraceInfo([scenario0, scenario1, scenario2], StateInfo(ModelSpace()))) + + self.assertEqual(len(sg.networkx.nodes), 4) + self.assertEqual(len(sg.networkx.edges), 3) + + self.assertIn('start', sg.networkx.nodes) self.assertIn('node0', sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes['node0']['label'], str(0)) self.assertIn('node1', sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes['node1']['label'], str(1)) self.assertIn('node2', sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes['node2']['label'], str(2)) - - def test_scenario_graph_update_visualisation_edges(self): - ts = TraceState(3) - candidates = [] - for scenario in range(3): - candidates.append(ts.next_candidate()) - ts.confirm_full_scenario(candidates[-1], str(scenario), {}) - ti = TraceInfo.from_trace_state(ts, ModelSpace()) + + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') + self.assertEqual(sg.networkx.nodes['node0']['label'], '0') + self.assertEqual(sg.networkx.nodes['node1']['label'], '1') + self.assertEqual(sg.networkx.nodes['node2']['label'], '2') + + def test_scenario_graph_update_edges(self): sg = ScenarioGraph() - sg.update_visualisation(ti) + + scenario0 = ScenarioInfo('0') + scenario1 = ScenarioInfo('1') + scenario2 = ScenarioInfo('2') + + self.assertEqual(len(sg.networkx.nodes), 1) + self.assertEqual(len(sg.networkx.edges), 0) + + sg.update_visualisation(TraceInfo([scenario0], StateInfo(ModelSpace()))) + + self.assertEqual(len(sg.networkx.nodes), 2) + self.assertEqual(len(sg.networkx.edges), 1) + + self.assertIn(('start', 'node0'), sg.networkx.edges) + + self.assertEqual(sg.networkx.edges[('start', 'node0')]['label'], '') + + sg.update_visualisation(TraceInfo([scenario0, scenario1], StateInfo(ModelSpace()))) + + self.assertEqual(len(sg.networkx.nodes), 3) + self.assertEqual(len(sg.networkx.edges), 2) + + self.assertIn(('start', 'node0'), sg.networkx.edges) + self.assertIn(('node0', 'node1'), sg.networkx.edges) + + self.assertEqual(sg.networkx.edges[('start', 'node0')]['label'], '') + self.assertEqual(sg.networkx.edges[('node0', 'node1')]['label'], '') + + sg.update_visualisation(TraceInfo([scenario0, scenario1, scenario2], StateInfo(ModelSpace()))) + + self.assertEqual(len(sg.networkx.nodes), 4) + self.assertEqual(len(sg.networkx.edges), 3) self.assertIn(('start', 'node0'), sg.networkx.edges) self.assertIn(('node0', 'node1'), sg.networkx.edges) self.assertIn(('node1', 'node2'), sg.networkx.edges) - edge_labels = nx.get_edge_attributes(sg.networkx, "label") - self.assertEqual(edge_labels[('start', 'node0')], '') - self.assertEqual(edge_labels[('node0', 'node1')], '') - self.assertEqual(edge_labels[('node1', 'node2')], '') + self.assertEqual(sg.networkx.edges[('start', 'node0')]['label'], '') + self.assertEqual(sg.networkx.edges[('node0', 'node1')]['label'], '') + self.assertEqual(sg.networkx.edges[('node1', 'node2')]['label'], '') - def test_scenario_graph_update_visualisation_single_node(self): - ts = TraceState(1) - ts.confirm_full_scenario(0, 'one', {}) - self.assertEqual(ts.get_trace(), ['one']) - ti = TraceInfo.from_trace_state(ts, ModelSpace()) + def test_scenario_graph_update_single_node(self): sg = ScenarioGraph() - sg.update_visualisation(ti) - # expected behaviour: no nodes nor edges are added + scenario = ScenarioInfo('test') + self.assertEqual(len(sg.networkx.nodes), 1) self.assertEqual(len(sg.networkx.edges), 0) - def test_scenario_graph_get_final_trace(self): - ts = TraceState(3) - candidates = [] - for scenario in range(3): - candidates.append(ts.next_candidate()) - ts.confirm_full_scenario(candidates[-1], str(scenario), {}) - ti = TraceInfo.from_trace_state(ts, ModelSpace()) + sg.update_visualisation(TraceInfo([scenario], StateInfo(ModelSpace()))) + + self.assertEqual(len(sg.networkx.nodes), 2) + self.assertEqual(len(sg.networkx.edges), 1) + + self.assertIn('start', sg.networkx.nodes) + self.assertIn('node0', sg.networkx.nodes) + + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') + self.assertEqual(sg.networkx.nodes['node0']['label'], 'test') + + self.assertIn(('start', 'node0'), sg.networkx.edges) + + self.assertEqual(sg.networkx.edges[('start', 'node0')]['label'], '') + + def test_scenario_graph_update_backtrack(self): sg = ScenarioGraph() - sg.update_visualisation(ti) - sg.set_final_trace(ti) + + scenario0 = ScenarioInfo('0') + scenario1 = ScenarioInfo('1') + scenario2 = ScenarioInfo('2') + scenario3 = ScenarioInfo('3') + + self.assertEqual(len(sg.networkx.nodes), 1) + self.assertEqual(len(sg.networkx.edges), 0) + + sg.update_visualisation(TraceInfo([scenario0], StateInfo(ModelSpace()))) + + self.assertEqual(len(sg.networkx.nodes), 2) + self.assertEqual(len(sg.networkx.edges), 1) + + sg.update_visualisation(TraceInfo([scenario0, scenario2], StateInfo(ModelSpace()))) + + self.assertEqual(len(sg.networkx.nodes), 3) + self.assertEqual(len(sg.networkx.edges), 2) + + sg.update_visualisation(TraceInfo([scenario0, scenario2, scenario1], StateInfo(ModelSpace()))) + + self.assertEqual(len(sg.networkx.nodes), 4) + self.assertEqual(len(sg.networkx.edges), 3) + + sg.update_visualisation(TraceInfo([scenario0, scenario2], StateInfo(ModelSpace()))) + + self.assertEqual(len(sg.networkx.nodes), 4) + self.assertEqual(len(sg.networkx.edges), 3) + + sg.update_visualisation(TraceInfo([scenario0], StateInfo(ModelSpace()))) + + self.assertEqual(len(sg.networkx.nodes), 4) + self.assertEqual(len(sg.networkx.edges), 3) + + sg.update_visualisation(TraceInfo([scenario0, scenario3], StateInfo(ModelSpace()))) + + self.assertEqual(len(sg.networkx.nodes), 5) + self.assertEqual(len(sg.networkx.edges), 4) + + sg.update_visualisation(TraceInfo([scenario0, scenario3, scenario1], StateInfo(ModelSpace()))) + + self.assertEqual(len(sg.networkx.nodes), 5) + self.assertEqual(len(sg.networkx.edges), 5) + + sg.update_visualisation(TraceInfo([scenario0, scenario3, scenario1, scenario2], StateInfo(ModelSpace()))) + + self.assertEqual(len(sg.networkx.nodes), 5) + self.assertEqual(len(sg.networkx.edges), 6) + + def test_scenario_graph_final_trace_normal(self): + sg = ScenarioGraph() + + scenario0 = ScenarioInfo('0') + scenario1 = ScenarioInfo('1') + scenario2 = ScenarioInfo('2') + + sg.update_visualisation(TraceInfo([scenario0], StateInfo(ModelSpace()))) + sg.update_visualisation(TraceInfo([scenario0, scenario1], StateInfo(ModelSpace()))) + sg.update_visualisation(TraceInfo([scenario0, scenario1, scenario2], StateInfo(ModelSpace()))) + + sg.set_final_trace(TraceInfo([scenario0, scenario1, scenario2], StateInfo(ModelSpace()))) + + trace = sg.get_final_trace() + + # confirm they are proper ids + for node in trace: + self.assertIn(node, sg.networkx.nodes) + + # confirm the edges exist + for i in range(0, len(trace) - 1): + self.assertIn((trace[i], trace[i + 1]), sg.networkx.edges) + + self.assertEqual(trace, ['start', 'node0', 'node1', 'node2']) + + def test_scenario_graph_final_trace_backtrack(self): + sg = ScenarioGraph() + + scenario0 = ScenarioInfo('0') + scenario1 = ScenarioInfo('1') + scenario2 = ScenarioInfo('2') + + sg.update_visualisation(TraceInfo([scenario0], StateInfo(ModelSpace()))) + sg.update_visualisation(TraceInfo([scenario0, scenario2], StateInfo(ModelSpace()))) + sg.update_visualisation(TraceInfo([scenario0, scenario2, scenario1], StateInfo(ModelSpace()))) + sg.update_visualisation(TraceInfo([scenario0, scenario2], StateInfo(ModelSpace()))) + sg.update_visualisation(TraceInfo([scenario0], StateInfo(ModelSpace()))) + sg.update_visualisation(TraceInfo([scenario0, scenario1], StateInfo(ModelSpace()))) + sg.update_visualisation(TraceInfo([scenario0, scenario1, scenario2], StateInfo(ModelSpace()))) + + sg.set_final_trace(TraceInfo([scenario0, scenario1, scenario2], StateInfo(ModelSpace()))) + trace = sg.get_final_trace() + # confirm they are proper ids for node in trace: self.assertIn(node, sg.networkx.nodes) + # confirm the edges exist for i in range(0, len(trace) - 1): self.assertIn((trace[i], trace[i + 1]), sg.networkx.edges) + self.assertEqual(trace, ['start', 'node0', 'node2', 'node1']) + if __name__ == '__main__': unittest.main() diff --git a/utest/test_visualise_scenariostategraph.py b/utest/test_visualise_scenariostategraph.py index 45ea8154..b56d8620 100644 --- a/utest/test_visualise_scenariostategraph.py +++ b/utest/test_visualise_scenariostategraph.py @@ -1,10 +1,9 @@ import unittest -import networkx as nx -from robotmbt.tracestate import TraceState +from typing import Any try: from robotmbt.visualise.graphs.scenariostategraph import ScenarioStateGraph - from robotmbt.visualise.models import TraceInfo, ScenarioInfo, ModelSpace, StateInfo + from robotmbt.visualise.models import * VISUALISE = True except ImportError: @@ -15,129 +14,408 @@ class TestVisualiseScenarioGraph(unittest.TestCase): def test_scenario_state_graph_init(self): stg = ScenarioStateGraph() - self.assertIn('start', stg.networkx.nodes) - self.assertEqual(stg.networkx.nodes['start']['label'], 'start') self.assertEqual(len(stg.networkx.nodes), 1) self.assertEqual(len(stg.networkx.edges), 0) + self.assertIn('start', stg.networkx.nodes) + self.assertEqual(stg.networkx.nodes['start']['label'], 'start') + def test_scenario_state_graph_ids_empty(self): stg = ScenarioStateGraph() - si = ScenarioInfo('test') - node_id = stg._get_or_create_id(si, StateInfo(ModelSpace())) + scenario = ScenarioInfo('test') + state = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + + node_id = stg._get_or_create_id(scenario, state) self.assertEqual(node_id, 'node0') def test_scenario_state_graph_ids_duplicate_scenario(self): stg = ScenarioStateGraph() - si = ScenarioInfo('test') - sti = StateInfo(ModelSpace()) + s0 = ScenarioInfo('test') + s1 = ScenarioInfo('test') + st0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + st1 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - id0 = stg._get_or_create_id(si, sti) - id1 = stg._get_or_create_id(si, sti) + id0 = stg._get_or_create_id(s0, st0) + id1 = stg._get_or_create_id(s1, st1) self.assertEqual(id0, id1) def test_scenario_state_graph_ids_different_scenarios(self): stg = ScenarioStateGraph() - si0 = ScenarioInfo('test0') - si1 = ScenarioInfo('test1') - sti = StateInfo(ModelSpace()) + s00 = ScenarioInfo('test0') + s01 = ScenarioInfo('test0') + s10 = ScenarioInfo('test1') + s11 = ScenarioInfo('test1') + + state = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + + id00 = stg._get_or_create_id(s00, state) + id01 = stg._get_or_create_id(s01, state) + id10 = stg._get_or_create_id(s10, state) + id11 = stg._get_or_create_id(s11, state) - id0 = stg._get_or_create_id(si0, sti) - id1 = stg._get_or_create_id(si1, sti) + self.assertEqual(id00, 'node0') + self.assertEqual(id01, 'node0') + self.assertEqual(id00, id01) - self.assertEqual(id0, 'node0') - self.assertEqual(id1, 'node1') + self.assertEqual(id10, 'node1') + self.assertEqual(id11, 'node1') + self.assertEqual(id10, id11) + + self.assertNotEqual(id00, id10) + self.assertNotEqual(id00, id11) + self.assertNotEqual(id01, id10) + self.assertNotEqual(id01, id11) def test_scenario_state_graph_ids_different_states(self): stg = ScenarioStateGraph() - si = ScenarioInfo('test0') - sti0 = StateInfo(ModelSpace("state0")) - sti1 = StateInfo(ModelSpace("state1")) + scenario = ScenarioInfo('test') + + s00 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + s01 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + s10 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) + s11 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) + + id00 = stg._get_or_create_id(scenario, s00) + id01 = stg._get_or_create_id(scenario, s01) + id10 = stg._get_or_create_id(scenario, s10) + id11 = stg._get_or_create_id(scenario, s11) + + self.assertEqual(id00, 'node0') + self.assertEqual(id01, 'node0') + self.assertEqual(id00, id01) - id0 = stg._get_or_create_id(si, sti0) - id1 = stg._get_or_create_id(si, sti1) + self.assertEqual(id10, 'node1') + self.assertEqual(id11, 'node1') + self.assertEqual(id10, id11) + + self.assertNotEqual(id00, id10) + self.assertNotEqual(id00, id11) + self.assertNotEqual(id01, id10) + self.assertNotEqual(id01, id11) + + def test_scenario_state_graph_ids_different_scenario_state(self): + stg = ScenarioStateGraph() - self.assertEqual(id0, 'node0') - self.assertEqual(id1, 'node1') + s00 = ScenarioInfo('test0') + s01 = ScenarioInfo('test1') + s10 = ScenarioInfo('test0') + s11 = ScenarioInfo('test1') + + st00 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + st01 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + st10 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) + st11 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) + + id00 = stg._get_or_create_id(s00, st00) + id01 = stg._get_or_create_id(s01, st01) + id10 = stg._get_or_create_id(s10, st10) + id11 = stg._get_or_create_id(s11, st11) + + self.assertEqual(id00, 'node0') + self.assertEqual(id01, 'node1') + self.assertEqual(id10, 'node2') + self.assertEqual(id11, 'node3') + + self.assertNotEqual(id00, id01) + self.assertNotEqual(id00, id10) + self.assertNotEqual(id00, id11) + self.assertNotEqual(id01, id10) + self.assertNotEqual(id01, id11) + self.assertNotEqual(id10, id11) def test_scenario_state_graph_add_new_node(self): stg = ScenarioStateGraph() + self.assertEqual(len(stg.networkx.nodes), 1) + self.assertIn('start', stg.networkx.nodes) self.assertNotIn('test', stg.networkx.nodes) - self.assertEqual(len(stg.networkx.nodes), 1) - stg.ids['test'] = (ScenarioInfo('test'), StateInfo(ModelSpace())) + scenario = ScenarioInfo('test') + state = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + + stg.ids['test'] = (scenario, state) stg._add_node('test') + self.assertEqual(len(stg.networkx.nodes), 2) + self.assertIn('start', stg.networkx.nodes) self.assertIn('test', stg.networkx.nodes) - self.assertEqual(len(stg.networkx.nodes), 2) - self.assertEqual(stg.networkx.nodes['test']['label'], - ScenarioStateGraph._gen_label(ScenarioInfo('test'), StateInfo(ModelSpace()))) + + self.assertEqual(stg.networkx.nodes['start']['label'], 'start') + self.assertIn('test', stg.networkx.nodes['test']['label']) + self.assertIn('prop:', stg.networkx.nodes['test']['label']) + self.assertIn('value=some_value', stg.networkx.nodes['test']['label']) def test_scenario_state_graph_add_existing_node(self): stg = ScenarioStateGraph() - stg._add_node('start') + + self.assertEqual(len(stg.networkx.nodes), 1) self.assertIn('start', stg.networkx.nodes) + self.assertEqual(stg.networkx.nodes['start']['label'], 'start') + + stg._add_node('start') + self.assertEqual(len(stg.networkx.nodes), 1) + self.assertIn('start', stg.networkx.nodes) + self.assertEqual(stg.networkx.nodes['start']['label'], 'start') - def test_scenario_state_graph_update_visualisation_nodes(self): + def test_scenario_state_graph_update_single(self): stg = ScenarioStateGraph() - ts = TraceState(3) - candidates = [] - for scenario in range(3): - candidates.append(ts.next_candidate()) - ts.confirm_full_scenario(candidates[-1], str(scenario), {}) - ti = TraceInfo.from_trace_state(ts, ModelSpace()) - stg.update_visualisation(ti) - - self.assertIn('node0', stg.networkx.nodes) - self.assertIn('node1', stg.networkx.nodes) - self.assertIn('node2', stg.networkx.nodes) - - self.assertEqual(stg.networkx.nodes['node0']['label'], - ScenarioStateGraph._gen_label(ScenarioInfo(str(0)), StateInfo(ModelSpace()))) - self.assertEqual(stg.networkx.nodes['node1']['label'], - ScenarioStateGraph._gen_label(ScenarioInfo(str(1)), StateInfo(ModelSpace()))) - self.assertEqual(stg.networkx.nodes['node2']['label'], - ScenarioStateGraph._gen_label(ScenarioInfo(str(2)), StateInfo(ModelSpace()))) - - def test_scenario_state_graph_update_visualisation_edges(self): + scenario = ScenarioInfo('1') + + space = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + + stg.update_visualisation(TraceInfo([scenario], space)) + + self.assertEqual(len(stg.networkx.nodes), 2) + self.assertEqual(len(stg.networkx.edges), 1) + + self.assertEqual(stg.networkx.nodes['start']['label'], 'start') + + self.assertIn('1', stg.networkx.nodes['node0']['label']) + self.assertIn('prop:', stg.networkx.nodes['node0']['label']) + self.assertIn('value=some_value', stg.networkx.nodes['node0']['label']) + + self.assertIn(('start', 'node0'), stg.networkx.edges) + self.assertEqual(stg.networkx.edges[('start', 'node0')]['label'], '') + + def test_scenario_state_graph_update_multi_loop(self): stg = ScenarioStateGraph() - ts = TraceState(3) - candidates = [] - for scenario in range(3): - candidates.append(ts.next_candidate()) - ts.confirm_full_scenario(candidates[-1], str(scenario), {}) - ti = TraceInfo.from_trace_state(trace=ts, state=ModelSpace()) - stg.update_visualisation(ti) + scenario1 = ScenarioInfo('1') + scenario2 = ScenarioInfo('2') + + space1 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + space2 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) + + ti1 = TraceInfo([scenario1], space1) + ti2 = TraceInfo([scenario1, scenario2], space2) + ti3 = TraceInfo([scenario1, scenario2, scenario1], space1) + + self.assertEqual(len(stg.networkx.nodes), 1) + self.assertEqual(len(stg.networkx.edges), 0) + + stg.update_visualisation(ti1) + + self.assertEqual(len(stg.networkx.nodes), 2) + self.assertEqual(len(stg.networkx.edges), 1) + + stg.update_visualisation(ti2) + + self.assertEqual(len(stg.networkx.nodes), 3) + self.assertEqual(len(stg.networkx.edges), 2) + + stg.update_visualisation(ti3) + + self.assertEqual(len(stg.networkx.nodes), 3) + self.assertEqual(len(stg.networkx.edges), 3) + + self.assertEqual(stg.networkx.nodes['start']['label'], 'start') + + self.assertIn('1', stg.networkx.nodes['node0']['label']) + self.assertIn('2', stg.networkx.nodes['node1']['label']) + self.assertIn('prop:', stg.networkx.nodes['node0']['label']) + self.assertIn('prop:', stg.networkx.nodes['node1']['label']) + self.assertIn('value=some_value', stg.networkx.nodes['node0']['label']) + self.assertIn('value=another_value', stg.networkx.nodes['node1']['label']) + self.assertIn(('start', 'node0'), stg.networkx.edges) self.assertIn(('node0', 'node1'), stg.networkx.edges) - self.assertIn(('node1', 'node2'), stg.networkx.edges) + self.assertIn(('node1', 'node0'), stg.networkx.edges) - edge_labels = nx.get_edge_attributes(stg.networkx, "label") - self.assertEqual(edge_labels[('node0', 'node1')], '') - self.assertEqual(edge_labels[('node1', 'node2')], '') + self.assertEqual(stg.networkx.edges[('start', 'node0')]['label'], '') + self.assertEqual(stg.networkx.edges[('node0', 'node1')]['label'], '') + self.assertEqual(stg.networkx.edges[('node1', 'node0')]['label'], '') - def test_scenario_state_graph_update_visualisation_single_node(self): + def test_scenario_state_graph_update_self_loop(self): stg = ScenarioStateGraph() - ti = TraceInfo([ScenarioInfo('one')], ModelSpace()) - stg.update_visualisation(ti) + scenario = ScenarioInfo('1') + + space = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + + ti1 = TraceInfo([scenario], space) + ti2 = TraceInfo([scenario, scenario], space) + + self.assertEqual(len(stg.networkx.nodes), 1) + self.assertEqual(len(stg.networkx.edges), 0) + + stg.update_visualisation(ti1) - # expected behaviour: only start and added node and their edge self.assertEqual(len(stg.networkx.nodes), 2) self.assertEqual(len(stg.networkx.edges), 1) - # TODO: improve existing tests and add tests for set_final_trace/get_final_trace, _gen_label + stg.update_visualisation(ti2) + + self.assertEqual(len(stg.networkx.nodes), 2) + self.assertEqual(len(stg.networkx.edges), 2) + + self.assertEqual(stg.networkx.nodes['start']['label'], 'start') + + self.assertIn('1', stg.networkx.nodes['node0']['label']) + self.assertIn('prop:', stg.networkx.nodes['node0']['label']) + self.assertIn('value=some_value', stg.networkx.nodes['node0']['label']) + + self.assertIn(('start', 'node0'), stg.networkx.edges) + self.assertIn(('node0', 'node0'), stg.networkx.edges) + + self.assertEqual(stg.networkx.edges[('start', 'node0')]['label'], '') + self.assertEqual(stg.networkx.edges[('node0', 'node0')]['label'], '') + + def test_scenario_state_graph_update_backtrack(self): + sg = ScenarioStateGraph() + + scenario0 = ScenarioInfo('0') + scenario1 = ScenarioInfo('1') + scenario2 = ScenarioInfo('2') + + space0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + space1 = StateInfo._create_state_with_prop("prop", [("value", "other_value")]) + space2 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) + space3 = StateInfo._create_state_with_prop("prop", [("value", "more_value")]) + space4 = StateInfo._create_state_with_prop("prop", [("value", "yet_another_value")]) + + self.assertEqual(len(sg.networkx.nodes), 1) + self.assertEqual(len(sg.networkx.edges), 0) + + sg.update_visualisation(TraceInfo([scenario0], space0)) + + self.assertEqual(len(sg.networkx.nodes), 2) + self.assertEqual(len(sg.networkx.edges), 1) + + sg.update_visualisation(TraceInfo([scenario0, scenario1], space1)) + + self.assertEqual(len(sg.networkx.nodes), 3) + self.assertEqual(len(sg.networkx.edges), 2) + + sg.update_visualisation(TraceInfo([scenario0, scenario1, scenario2], space2)) + + self.assertEqual(len(sg.networkx.nodes), 4) + self.assertEqual(len(sg.networkx.edges), 3) + + sg.update_visualisation(TraceInfo([scenario0, scenario1], space1)) + + self.assertEqual(len(sg.networkx.nodes), 4) + self.assertEqual(len(sg.networkx.edges), 3) + + sg.update_visualisation(TraceInfo([scenario0], space0)) + + self.assertEqual(len(sg.networkx.nodes), 4) + self.assertEqual(len(sg.networkx.edges), 3) + + sg.update_visualisation(TraceInfo([scenario0, scenario2], space3)) + + self.assertEqual(len(sg.networkx.nodes), 5) + self.assertEqual(len(sg.networkx.edges), 4) + + sg.update_visualisation(TraceInfo([scenario0, scenario2, scenario1], space4)) + + self.assertEqual(len(sg.networkx.nodes), 6) + self.assertEqual(len(sg.networkx.edges), 5) + + def test_scenario_state_graph_final_trace_normal(self): + sg = ScenarioStateGraph() + + scenario0 = ScenarioInfo('0') + scenario1 = ScenarioInfo('1') + scenario2 = ScenarioInfo('2') + + space0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + space1 = StateInfo._create_state_with_prop("prop", [("value", "other_value")]) + space2 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) + + sg.update_visualisation(TraceInfo([scenario0], space0)) + sg.update_visualisation(TraceInfo([scenario0, scenario1], space1)) + sg.update_visualisation(TraceInfo([scenario0, scenario1, scenario2], space2)) + + sg.set_final_trace(TraceInfo([scenario0, scenario1, scenario2], space2)) + + trace = sg.get_final_trace() + + for node in trace: + self.assertIn(node, sg.networkx.nodes) + + for i in range(0, len(trace) - 1): + self.assertIn((trace[i], trace[i + 1]), sg.networkx.edges) + + self.assertEqual(trace, ['start', 'node0', 'node1', 'node2']) + + def test_scenario_state_graph_final_trace_backtrack(self): + sg = ScenarioStateGraph() + + scenario0 = ScenarioInfo('0') + scenario1 = ScenarioInfo('1') + scenario2 = ScenarioInfo('2') + + space0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + space1 = StateInfo._create_state_with_prop("prop", [("value", "other_value")]) + space2 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) + space3 = StateInfo._create_state_with_prop("prop", [("value", "more_value")]) + space4 = StateInfo._create_state_with_prop("prop", [("value", "yet_another_value")]) + + sg.update_visualisation(TraceInfo([scenario0], space0)) + sg.update_visualisation(TraceInfo([scenario0, scenario1], space1)) + sg.update_visualisation(TraceInfo([scenario0, scenario1, scenario2], space2)) + sg.update_visualisation(TraceInfo([scenario0, scenario1], space1)) + sg.update_visualisation(TraceInfo([scenario0], space0)) + sg.update_visualisation(TraceInfo([scenario0, scenario2], space3)) + sg.update_visualisation(TraceInfo([scenario0, scenario2, scenario1], space4)) + + sg.set_final_trace(TraceInfo([scenario0, scenario2, scenario1], space4)) + + trace = sg.get_final_trace() + + for node in trace: + self.assertIn(node, sg.networkx.nodes) + + for i in range(0, len(trace) - 1): + self.assertIn((trace[i], trace[i + 1]), sg.networkx.edges) + + self.assertEqual(trace, ['start', 'node0', 'node3', 'node4']) + + def test_scenario_state_graph_gen_label(self): + sg = ScenarioStateGraph() + + scenario0 = ScenarioInfo('0') + scenario1 = ScenarioInfo('1') + + space0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + space1 = StateInfo._create_state_with_prop("prop", [("value", "other_value")]) + + label00 = sg._gen_label(scenario0, space0) + label01 = sg._gen_label(scenario0, space1) + label10 = sg._gen_label(scenario1, space0) + label11 = sg._gen_label(scenario1, space1) + + self.assertNotEqual(label00, label01) + self.assertNotEqual(label00, label10) + self.assertNotEqual(label00, label11) + self.assertNotEqual(label01, label10) + self.assertNotEqual(label01, label11) + self.assertNotEqual(label10, label11) + + self.assertIn('0', label00) + self.assertIn('0', label01) + self.assertIn('1', label10) + self.assertIn('1', label11) + + self.assertIn('prop:', label00) + self.assertIn('prop:', label01) + self.assertIn('prop:', label10) + self.assertIn('prop:', label11) + + self.assertIn('value=some_value', label00) + self.assertIn('value=other_value', label01) + self.assertIn('value=some_value', label10) + self.assertIn('value=other_value', label11) if __name__ == '__main__': unittest.main() diff --git a/utest/test_visualise_stategraph.py b/utest/test_visualise_stategraph.py index c9d30a4e..08bb6e98 100644 --- a/utest/test_visualise_stategraph.py +++ b/utest/test_visualise_stategraph.py @@ -1,8 +1,9 @@ import unittest -import networkx as nx + try: from robotmbt.visualise.graphs.stategraph import StateGraph from robotmbt.visualise.models import * + VISUALISE = True except ImportError: VISUALISE = False @@ -11,9 +12,324 @@ class TestVisualiseStateGraph(unittest.TestCase): def test_state_graph_init(self): sg = StateGraph() + + self.assertEqual(len(sg.networkx.nodes), 1) + self.assertEqual(len(sg.networkx.edges), 0) + + self.assertIn('start', sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') + + def test_state_graph_ids_empty(self): + sg = StateGraph() + + si = StateInfo(ModelSpace()) + + node_id = sg._get_or_create_id(si) + + self.assertEqual(node_id, 'node0') + + def test_state_graph_ids_duplicate_state(self): + sg = StateGraph() + + s0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + s1 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + + id0 = sg._get_or_create_id(s0) + id1 = sg._get_or_create_id(s1) + + self.assertEqual(id0, id1) + + def test_state_graph_ids_different_states(self): + sg = StateGraph() + + s00 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + s01 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + s10 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) + s11 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) + + id00 = sg._get_or_create_id(s00) + id01 = sg._get_or_create_id(s01) + id10 = sg._get_or_create_id(s10) + id11 = sg._get_or_create_id(s11) + + self.assertEqual(id00, 'node0') + self.assertEqual(id01, 'node0') + self.assertEqual(id00, id01) + + self.assertEqual(id10, 'node1') + self.assertEqual(id11, 'node1') + self.assertEqual(id10, id11) + + self.assertNotEqual(id00, id10) + self.assertNotEqual(id00, id11) + self.assertNotEqual(id01, id10) + self.assertNotEqual(id01, id11) + + def test_state_graph_add_new_node(self): + sg = StateGraph() + + self.assertEqual(len(sg.networkx.nodes), 1) + + self.assertIn('start', sg.networkx.nodes) + self.assertNotIn('test', sg.networkx.nodes) + + sg.ids['test'] = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + sg._add_node('test') + + self.assertEqual(len(sg.networkx.nodes), 2) + self.assertIn('start', sg.networkx.nodes) + self.assertIn('test', sg.networkx.nodes) + + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') + self.assertIn('prop:', sg.networkx.nodes['test']['label']) + self.assertIn('value=some_value', sg.networkx.nodes['test']['label']) + + def test_state_graph_add_existing_node(self): + sg = StateGraph() + + self.assertEqual(len(sg.networkx.nodes), 1) + self.assertIn('start', sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') + + sg._add_node('start') + + self.assertEqual(len(sg.networkx.nodes), 1) + self.assertIn('start', sg.networkx.nodes) + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') + + def test_state_graph_update_single(self): + sg = StateGraph() + + scenario = ScenarioInfo('1') + space = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + sg.update_visualisation(TraceInfo([scenario], space)) + + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') + + self.assertIn('prop:', sg.networkx.nodes['node0']['label']) + self.assertIn('value=some_value', sg.networkx.nodes['node0']['label']) + + self.assertIn(('start', 'node0'), sg.networkx.edges) + self.assertEqual(sg.networkx.edges[('start', 'node0')]['label'], '1') + + def test_state_graph_update_multi(self): + sg = StateGraph() + + scenario1 = ScenarioInfo('1') + scenario2 = ScenarioInfo('2') + scenario3 = ScenarioInfo('3') + + space1 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + space2 = StateInfo._create_state_with_prop("prop", [("value", "other_value")]) + space3 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) + + ti1 = TraceInfo([scenario1], space1) + ti2 = TraceInfo([scenario1, scenario2], space2) + ti3 = TraceInfo([scenario1, scenario2, scenario3], space3) + + sg.update_visualisation(ti1) + sg.update_visualisation(ti2) + sg.update_visualisation(ti3) + + self.assertEqual(len(sg.networkx.nodes), 4) + self.assertEqual(len(sg.networkx.edges), 3) + + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') + self.assertIn('prop:', sg.networkx.nodes['node0']['label']) + self.assertIn('prop:', sg.networkx.nodes['node1']['label']) + self.assertIn('prop:', sg.networkx.nodes['node2']['label']) + self.assertIn('value=some_value', sg.networkx.nodes['node0']['label']) + self.assertIn('value=other_value', sg.networkx.nodes['node1']['label']) + self.assertIn('value=another_value', sg.networkx.nodes['node2']['label']) + + self.assertIn(('start', 'node0'), sg.networkx.edges) + self.assertIn(('node0', 'node1'), sg.networkx.edges) + self.assertIn(('node1', 'node2'), sg.networkx.edges) + + self.assertEqual(sg.networkx.edges[('start', 'node0')]['label'], '1') + self.assertEqual(sg.networkx.edges[('node0', 'node1')]['label'], '2') + self.assertEqual(sg.networkx.edges[('node1', 'node2')]['label'], '3') + + def test_state_graph_update_multi_loop(self): + sg = StateGraph() + + scenario0 = ScenarioInfo('0') + scenario1 = ScenarioInfo('1') + scenario2 = ScenarioInfo('2') + + space0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + space1 = StateInfo._create_state_with_prop("prop", [("value", "other_value")]) + + ti1 = TraceInfo([scenario0], space0) + ti2 = TraceInfo([scenario0, scenario1], space1) + ti3 = TraceInfo([scenario0, scenario1, scenario2], space0) + + sg.update_visualisation(ti1) + sg.update_visualisation(ti2) + sg.update_visualisation(ti3) + + self.assertEqual(len(sg.networkx.nodes), 3) + self.assertEqual(len(sg.networkx.edges), 3) + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') + self.assertIn('prop:', sg.networkx.nodes['node0']['label']) + self.assertIn('prop:', sg.networkx.nodes['node1']['label']) + self.assertIn('value=some_value', sg.networkx.nodes['node0']['label']) + self.assertIn('value=other_value', sg.networkx.nodes['node1']['label']) + + self.assertIn(('start', 'node0'), sg.networkx.edges) + self.assertIn(('node0', 'node1'), sg.networkx.edges) + self.assertIn(('node1', 'node0'), sg.networkx.edges) + + self.assertEqual(sg.networkx.edges[('start', 'node0')]['label'], '0') + self.assertEqual(sg.networkx.edges[('node0', 'node1')]['label'], '1') + self.assertEqual(sg.networkx.edges[('node1', 'node0')]['label'], '2') + + def test_state_graph_update_self_loop(self): + sg = StateGraph() + + scenario1 = ScenarioInfo('1') + scenario2 = ScenarioInfo('2') + + space = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + + ti1 = TraceInfo([scenario1], space) + ti2 = TraceInfo([scenario1, scenario2], space) + + self.assertEqual(len(sg.networkx.nodes), 1) + self.assertEqual(len(sg.networkx.edges), 0) + + sg.update_visualisation(ti1) + + self.assertEqual(len(sg.networkx.nodes), 2) + self.assertEqual(len(sg.networkx.edges), 1) + + sg.update_visualisation(ti2) + + self.assertEqual(len(sg.networkx.nodes), 2) + self.assertEqual(len(sg.networkx.edges), 2) + + self.assertEqual(sg.networkx.nodes['start']['label'], 'start') + self.assertIn('prop:', sg.networkx.nodes['node0']['label']) + self.assertIn('value=some_value', sg.networkx.nodes['node0']['label']) + + self.assertIn(('start', 'node0'), sg.networkx.edges) + self.assertIn(('node0', 'node0'), sg.networkx.edges) + + self.assertEqual(sg.networkx.edges[('start', 'node0')]['label'], '1') + self.assertEqual(sg.networkx.edges[('node0', 'node0')]['label'], '2') + + def test_state_graph_update_backtrack(self): + sg = StateGraph() + + scenario0 = ScenarioInfo('0') + scenario1 = ScenarioInfo('1') + scenario2 = ScenarioInfo('2') + + space0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + space1 = StateInfo._create_state_with_prop("prop", [("value", "other_value")]) + space2 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) + space3 = StateInfo._create_state_with_prop("prop", [("value", "more_value")]) + space4 = StateInfo._create_state_with_prop("prop", [("value", "yet_another_value")]) + + self.assertEqual(len(sg.networkx.nodes), 1) + self.assertEqual(len(sg.networkx.edges), 0) + + sg.update_visualisation(TraceInfo([scenario0], space0)) + + self.assertEqual(len(sg.networkx.nodes), 2) + self.assertEqual(len(sg.networkx.edges), 1) + + sg.update_visualisation(TraceInfo([scenario0, scenario1], space1)) + + self.assertEqual(len(sg.networkx.nodes), 3) + self.assertEqual(len(sg.networkx.edges), 2) + + sg.update_visualisation(TraceInfo([scenario0, scenario1, scenario2], space2)) + + self.assertEqual(len(sg.networkx.nodes), 4) + self.assertEqual(len(sg.networkx.edges), 3) + + sg.update_visualisation(TraceInfo([scenario0, scenario1], space1)) + + self.assertEqual(len(sg.networkx.nodes), 4) + self.assertEqual(len(sg.networkx.edges), 3) + + sg.update_visualisation(TraceInfo([scenario0], space0)) + + self.assertEqual(len(sg.networkx.nodes), 4) + self.assertEqual(len(sg.networkx.edges), 3) + + sg.update_visualisation(TraceInfo([scenario0, scenario2], space3)) + + self.assertEqual(len(sg.networkx.nodes), 5) + self.assertEqual(len(sg.networkx.edges), 4) + + sg.update_visualisation(TraceInfo([scenario0, scenario2, scenario1], space4)) + + self.assertEqual(len(sg.networkx.nodes), 6) + self.assertEqual(len(sg.networkx.edges), 5) + + def test_state_graph_final_trace_normal(self): + sg = StateGraph() + + scenario0 = ScenarioInfo('0') + scenario1 = ScenarioInfo('1') + scenario2 = ScenarioInfo('2') + + space0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + space1 = StateInfo._create_state_with_prop("prop", [("value", "other_value")]) + space2 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) + + sg.update_visualisation(TraceInfo([scenario0], space0)) + sg.update_visualisation(TraceInfo([scenario0, scenario1], space1)) + sg.update_visualisation(TraceInfo([scenario0, scenario1, scenario2], space2)) + + sg.set_final_trace(TraceInfo([scenario0, scenario1, scenario2], space2)) + + trace = sg.get_final_trace() + + for node in trace: + self.assertIn(node, sg.networkx.nodes) + + for i in range(0, len(trace) - 1): + self.assertIn((trace[i], trace[i + 1]), sg.networkx.edges) + + self.assertEqual(trace, ['start', 'node0', 'node1', 'node2']) + + def test_state_graph_final_trace_backtrack(self): + sg = StateGraph() + + scenario0 = ScenarioInfo('0') + scenario1 = ScenarioInfo('1') + scenario2 = ScenarioInfo('2') + + space0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) + space1 = StateInfo._create_state_with_prop("prop", [("value", "other_value")]) + space2 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) + space3 = StateInfo._create_state_with_prop("prop", [("value", "more_value")]) + space4 = StateInfo._create_state_with_prop("prop", [("value", "yet_another_value")]) + + sg.update_visualisation(TraceInfo([scenario0], space0)) + sg.update_visualisation(TraceInfo([scenario0, scenario1], space1)) + sg.update_visualisation(TraceInfo([scenario0, scenario1, scenario2], space2)) + sg.update_visualisation(TraceInfo([scenario0, scenario1], space1)) + sg.update_visualisation(TraceInfo([scenario0], space0)) + sg.update_visualisation(TraceInfo([scenario0, scenario2], space3)) + sg.update_visualisation(TraceInfo([scenario0, scenario2, scenario1], space4)) + + sg.set_final_trace(TraceInfo([scenario0, scenario2, scenario1], space4)) + + trace = sg.get_final_trace() + + for node in trace: + self.assertIn(node, sg.networkx.nodes) + + for i in range(0, len(trace) - 1): + self.assertIn((trace[i], trace[i + 1]), sg.networkx.edges) + self.assertEqual(trace, ['start', 'node0', 'node3', 'node4']) if __name__ == '__main__': unittest.main() From 16ed4e95f81cc80905ef9c340e4e15b6e191a4c7 Mon Sep 17 00:00:00 2001 From: Thomas Kas Date: Tue, 2 Dec 2025 20:18:18 +0100 Subject: [PATCH 024/131] Sync fork (#30) * handle optional argument's default values * optional arguments stay hidden when not mentioned * fix argument text for POSITIONAL_OR_NAMED cases Now keeps the notation as used in the scenario (with or without the argument's name) in the generated keyword arguments. * fix failure on empty varargs * workaround for embedded argument name mismatch Accept that names of embedded arguments in the @keyword decorator must match their Python argument counterparts. * support step modifiers for non-embedded arguments * handling vararg modifiers * add tests for step modifier with free named arguments * add error message for vararg and free named arg modifiers * docu update for step modifiers on non-embedded arguments * promote random seed logging to info level Logging the seed at info level allows to run without debug logging for smaller log files, without losing the ability to rerun an interesting trace. * promote random seed logging to info level * make tests independent * Remove embedded-arguments-only restriction for modifiers * bump version to 0.10 * Implement Feature Path Highlighting in Scenario and State Graphs * Implement State Graph Path highlighting with Stack * Clean-up of unused features, move some logic, fix empty state entry bug * Formatting * Formatting * Remove unused return value * Fix bug * StateInfo unit tests * StateGraph unit tests * Merge with main, fix some bugs, alter ScenarioStateGraph to be in line with the rest and fix up some of its unit tests * Add short explanation * Test self-loops * Minor tweaks * Remove trailing newline * Fix nuking of stack * Better formatting of state info * Spam __update_visualisation all over the place for refinement * Fix nuking in scenario-state graph * Warn instead of crashing * Sanity check * Do add edge with start node * Expand tests * Merge mistake * Fix oopsie * Format and fix type * Move helper to StateInfo * Protected helper * Forgot this one * Fix nuked graphs * Implement feedback --------- Co-authored-by: JFoederer <32476108+JFoederer@users.noreply.github.com> Co-authored-by: Diogo Silva --- README.md | 8 +- .../01__generating_random_traces/traces.py | 2 +- .../01__keyword_arguments.robot | 162 ++++++++++----- .../04__argument_modifiers.robot | 193 ++++++++++++++++++ pyproject.toml | 2 +- robotmbt/steparguments.py | 14 +- robotmbt/suitedata.py | 96 +++++---- robotmbt/suiteprocessors.py | 183 ++++++++--------- robotmbt/tracestate.py | 2 +- robotmbt/version.py | 2 +- utest/test_steparguments.py | 19 +- utest/test_suitedata.py | 45 ++++ 12 files changed, 526 insertions(+), 202 deletions(-) create mode 100644 atest/robotMBT tests/09__argument_handling/04__argument_modifiers.robot diff --git a/README.md b/README.md index a33cad24..9051a942 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,8 @@ If an example value is used multiple times in a scenario, like `Johan` in the ab The opposite is also true. Any example values that differ in the original scenario text, are guaranteed to get distinct values in the generated scenario. That means that in the above example, where `Johan` sends a card to `Tannaz`, you can be sure that the generated scenario will not include a variant where `Tannaz` sends a birthday card to herself, even if `Tannaz` were a valid option for both arguments. If, however, this is a relevant scenario for you, you can include it as a new key example in your test suite. +Modifiers can be used on any type of argument: embedded, positional or named. If an argument is optional and it is ommitted in the scenario, then the argument's default value is used and the modifier is not triggered. Passing a variable number of arguments using modifiers is supported for both varargs and free named arguments. The modifier must yield a list vor varargs or a dict for free named arguments. They are used directly as-is without matching against other arguments. Just like optional arguments, when no arguments are provided, the modifier is not triggered. + #### Technicalities Please note that all modifiers in the scenario are processed before processing the regular `:IN:` and `:OUT:` expressions. This implies that when model data is used in a modifier, that it will use the model data as it is at the start of the scenario. Any updates to the model data during the scenario steps do not affect the possible choices for the example values. @@ -183,15 +185,13 @@ In a then-step, modifiers behave slightly different. In then-steps no new option #### Limitations -This first implementation for variable data considers strict equivalence classes only. This means that all variants are considered equal for all purposes. If, for a certain scenario, a single valid example variant has been generated and executed, then this scenario is considered covered. There are no options yet to indicate deeper coverage targets based on data variations. It also implies that whenever any variant is valid, all scenario variants must be valid. And that regardless of which variant is chosen, the exact same scenarios can be chosen as the next one. This does however not mean that once a variant is chosen, that this variant will be used throughout the whole trace. If a scenario is selected multiple times in the same trace, then each occurrence will get new randomly selected data. - -Modifiers are currently only supported for embedded arguments. +For now, variable data considers strict equivalence classes only. This means that all variants are considered equal for all purposes. If, for a certain scenario, a single valid example variant has been generated and executed, then this scenario is considered covered. There are no options yet to indicate deeper coverage targets based on data variations. It also implies that whenever any variant is valid, all scenario variants must be valid. And that regardless of which variant is chosen, the exact same scenarios can be chosen as the next one. This does however not mean that once a variant is chosen, that this variant will be used throughout the whole trace. If a scenario is selected multiple times in the same trace, then each occurrence will get new randomly selected data. ## Configuration options ### Random seed -By default, trace generation is random. The random seed used for the trace is logged (debug level) by _Treat this test suite model-based_. This seed can be used to rerun the same trace, if no external random factors influence the test run. To activate the seed, pass it as argument: +By default, trace generation is random. The random seed used for the trace is logged by _Treat this test suite model-based_. This seed can be used to rerun the same trace, if no external random factors influence the test run. To activate the seed, pass it as argument: ``` Treat this test suite model-based seed=eag-etou-cxi-leamv-jsi diff --git a/atest/robotMBT tests/07__processor_options/random_seeds/01__generating_random_traces/traces.py b/atest/robotMBT tests/07__processor_options/random_seeds/01__generating_random_traces/traces.py index bd61663a..e9322442 100644 --- a/atest/robotMBT tests/07__processor_options/random_seeds/01__generating_random_traces/traces.py +++ b/atest/robotMBT tests/07__processor_options/random_seeds/01__generating_random_traces/traces.py @@ -5,7 +5,7 @@ class traces: def reset_traces(self): self.traces = {} - @keyword("Trace '${n}', scenario number ${m} is executed") + @keyword("Trace '${trace}', scenario number ${test_id} is executed") def add_test(self, trace, test_id:str): """*model info* :IN: None diff --git a/atest/robotMBT tests/09__argument_handling/01__keyword_arguments.robot b/atest/robotMBT tests/09__argument_handling/01__keyword_arguments.robot index 894cffa7..01f3cdae 100644 --- a/atest/robotMBT tests/09__argument_handling/01__keyword_arguments.robot +++ b/atest/robotMBT tests/09__argument_handling/01__keyword_arguments.robot @@ -12,100 +12,170 @@ No arguments Keyword without arguments Embedded argument - Keyword with Green as embedded argument + ${emb_arg}= Keyword with Green as embedded argument + Should be equal ${emb_arg} Green Positional arguments - Keyword with positional argument Green - Keyword with multiple positional arguments Green Red + ${pos1}= Keyword with positional argument Green + Should be equal ${pos1} Green + ${pos1} ${pos2}= Keyword with multiple positional arguments Green Red + Should be equal ${pos1} Green + Should be equal ${pos2} Red + +Optional argument + ${pos1_a} ${pos2_a} ${pos3_a}= Keyword with positional arguments and optional argument Green Red + Should be equal ${pos1_a} Green + Should be equal ${pos2_a} Red + Should be equal ${pos3_a} Orange + ${pos1_b} ${pos2_b} ${pos3_b}= Keyword with positional arguments and optional argument Green Red Blue + Should be equal ${pos1_b} Green + Should be equal ${pos2_b} Red + Should be equal ${pos3_b} Blue Variable number of arguments - Keyword with variable number of arguments Green Red Blue + @{varargs}= Keyword with variable number of arguments Green Red Blue + Should be equal ${varargs}[0] Green + Should be equal ${varargs}[1] Red + Should be equal ${varargs}[2] Blue Named arguments - Keyword with named argument named1=Green - Keyword with multiple named arguments named1=Green named2=Red + ${named1_a}= Keyword with named argument named1=Green + Should be equal ${named1_a} Green + ${named1_b} ${named2}= Keyword with multiple named arguments named1=Green named2=Red + Should be equal ${named1_b} Green + Should be equal ${named2} Red Free named arguments - Keyword with free named arguments free1=Green free2=Red free3=Blue + &{free}= Keyword with free named arguments free1=Green free2=Red free3=Blue + Should be equal ${free}[free1] Green + Should be equal ${free}[free2] Red + Should be equal ${free}[free3] Blue Mixed argument styles - Keyword with all 5 argument styles mixed A B C D named1=E named2=F free1=G free2=H + &{all_args}= Keyword with all 5 argument styles mixed A B C D named1=E named2=F free1=G free2=H + Should be equal ${all_args}[emb_arg] 5 + Should be equal ${all_args}[pos1] A + Should be equal ${all_args}[pos2] B + Should be equal ${all_args}[var_args][0] C + Should be equal ${all_args}[var_args][1] D + Should be equal ${all_args}[named1] E + Should be equal ${all_args}[named2] F + Should be equal ${all_args}[free1] G + Should be equal ${all_args}[free2] H BDD style with arguments Given Keyword with all 5 argument styles mixed A B C D named1=E named2=F free1=G free2=H Then Keyword with all 5 argument styles mixed A B C D named1=E named2=F free1=G free2=H +Empty varargs and free named args + &{all_args}= Keyword with some argument styles mixed (no OUT-check) A B named1=C + ${n_varargs}= Get length ${all_args}[var_args] + Should be equal ${n_varargs} ${0} + Should be equal ${all_args}[named2] the default + ${total_args}= Get length ${all_args} + Should be equal ${total_args} ${6} + +Shuffled argument ordering + &{all_args}= Keyword with some argument styles mixed (no OUT-check) A B named2=C free1=D named1=E + ${n_varargs}= Get length ${all_args}[var_args] + Should be equal ${n_varargs} ${0} + Should be equal ${all_args}[named2] C + Should be equal ${all_args}[free1] D + ${total_args}= Get length ${all_args} + Should be equal ${total_args} ${7} + + *** Keywords *** Keyword without arguments [Documentation] *model info* ... :IN: None - ... :OUT: new args + ... :OUT: None No Operation Keyword with ${emb_arg} as embedded argument [Documentation] *model info* - ... :IN: args.emb_arg = ${emb_arg} - ... :OUT: args.emb_arg == Green - No Operation + ... :IN: scenario.emb_arg = ${emb_arg} + ... :OUT: scenario.emb_arg == Green + RETURN ${emb_arg} Keyword with positional argument [Documentation] *model info* - ... :IN: args.pos1 = ${pos1} - ... :OUT: args.pos1 == Green + ... :IN: scenario.pos1 = ${pos1} + ... :OUT: scenario.pos1 == Green [Arguments] ${pos1} - No Operation + RETURN ${pos1} Keyword with multiple positional arguments [Documentation] *model info* - ... :IN: args.pos1 = ${pos1} | args.pos2 = ${pos2} - ... :OUT: args.pos1 == Green | args.pos2 == Red + ... :IN: scenario.pos1 = ${pos1} | scenario.pos2 = ${pos2} + ... :OUT: scenario.pos1 == Green | scenario.pos2 == Red [Arguments] ${pos1} ${pos2} - No Operation + RETURN ${pos1} ${pos2} + +Keyword with positional arguments and optional argument + [Documentation] *model info* + ... :IN: scenario.pos1 = ${pos1} | scenario.pos2 = ${pos2} | scenario.pos3 = ${pos3} + ... :OUT: scenario.pos1 == Green | scenario.pos2 == Red | scenario.pos3 == ${pos3} + [Arguments] ${pos1} ${pos2} ${pos3}=Orange + RETURN ${pos1} ${pos2} ${pos3} Keyword with variable number of arguments [Documentation] *model info* - ... :IN: args.varargs = ${varargs} | args.vararg1 = ${varargs}[0] - ... :OUT: len(args.varargs) == 3 | args.vararg1 == Green - ... args.varargs[0] == Green | args.varargs[1] == Red | args.varargs[2] == Blue + ... :IN: scenario.varargs = ${varargs} | scenario.vararg1 = ${varargs}[0] + ... :OUT: len(scenario.varargs) == 3 | scenario.vararg1 == Green + ... scenario.varargs[0] == Green | scenario.varargs[1] == Red | scenario.varargs[2] == Blue [Arguments] @{varargs} - No Operation + RETURN ${varargs} Keyword with named argument [Documentation] *model info* - ... :IN: args.named1 = ${named1} - ... :OUT: args.named1 == Green + ... :IN: scenario.named1 = ${named1} + ... :OUT: scenario.named1 == Green [Arguments] ${named1}= - No Operation + RETURN ${named1} Keyword with multiple named arguments [Documentation] *model info* - ... :IN: args.named1 = ${named1} | args.named2 = ${named2} - ... :OUT: args.named1 == Green | args.named2 == Red + ... :IN: scenario.named1 = ${named1} | scenario.named2 = ${named2} + ... :OUT: scenario.named1 == Green | scenario.named2 == Red [Arguments] ${named1}= ${named2}= - No Operation + RETURN ${named1} ${named2} Keyword with free named arguments [Documentation] *model info* - ... :IN: args.free = ${free} | args.free1 = ${free}[free1] - ... :OUT: len(args.free) == 3 | args.free1 == Green - ... args.free[free1] == Green | args.free[free2] == Red | args.free[free3] == Blue + ... :IN: scenario.free = ${free} | scenario.free1 = ${free}[free1] + ... :OUT: len(scenario.free) == 3 | scenario.free1 == Green + ... scenario.free[free1] == Green | scenario.free[free2] == Red | scenario.free[free3] == Blue [Arguments] &{free} - No Operation + RETURN ${free} Keyword with all ${emb_arg} argument styles mixed [Documentation] *model info* - ... :IN: new combiargs | combiargs.emb_var = ${emb_arg} - ... combiargs.pos1 = ${pos1} | combiargs.pos2 = ${pos2} - ... combiargs.varargs = ${varargs} | combiargs.vararg1 = ${varargs}[0] - ... combiargs.named1 = ${named1} | combiargs.named2 = ${named2} - ... combiargs.free = ${free} | combiargs.free1 = ${free}[free1] - ... :OUT: combiargs.emb_var == 5 - ... combiargs.pos1 == A | combiargs.pos2 == B - ... len(combiargs.varargs) == 2 | combiargs.vararg1 == C - ... combiargs.varargs[0] == C | combiargs.varargs[1] == D - ... combiargs.named1 == E | combiargs.named2 == F - ... len(combiargs.free) == 2 | combiargs.free1 == G - ... combiargs.free[free1] == G | combiargs.free[free2] == H - ... del combiargs + ... :IN: scenario.emb_var = ${emb_arg} + ... scenario.pos1 = ${pos1} | scenario.pos2 = ${pos2} + ... scenario.varargs = ${varargs} | scenario.vararg1 = ${varargs}[0] + ... scenario.named1 = ${named1} | scenario.named2 = ${named2} + ... scenario.free = ${free} | scenario.free1 = ${free}[free1] + ... :OUT: scenario.emb_var == 5 + ... scenario.pos1 == A | scenario.pos2 == B + ... len(scenario.varargs) == 2 | scenario.vararg1 == C + ... scenario.varargs[0] == C | scenario.varargs[1] == D + ... scenario.named1 == E | scenario.named2 == F + ... len(scenario.free) == 2 | scenario.free1 == G + ... scenario.free[free1] == G | scenario.free[free2] == H [Arguments] ${pos1} ${pos2} @{varargs} ${named1}= ${named2}= &{free} - No Operation + VAR &{all_args}= emb_arg=${emb_arg} pos1=${pos1} pos2=${pos2} + ... var_args=${varargs} named1=${named1} named2=${named2} &{free} + RETURN ${all_args} + +Keyword with ${emb_arg} argument styles mixed (no OUT-check) + [Documentation] *model info* + ... :IN: scenario.emb_var = ${emb_arg} + ... scenario.pos1 = ${pos1} | scenario.pos2 = ${pos2} + ... scenario.named1 = ${named1} | scenario.named2 = ${named2} + ... scenario.varargs = ${varargs} | scenario.free = ${free} + ... :OUT: None + [Arguments] ${pos1} ${pos2} @{varargs} ${named1}= ${named2}=the default &{free} + VAR &{all_args}= emb_arg=${emb_arg} pos1=${pos1} pos2=${pos2} + ... var_args=${varargs} named1=${named1} named2=${named2} &{free} + RETURN ${all_args} diff --git a/atest/robotMBT tests/09__argument_handling/04__argument_modifiers.robot b/atest/robotMBT tests/09__argument_handling/04__argument_modifiers.robot new file mode 100644 index 00000000..0ca58ae5 --- /dev/null +++ b/atest/robotMBT tests/09__argument_handling/04__argument_modifiers.robot @@ -0,0 +1,193 @@ +*** Settings *** +Documentation This test suites focuses on using modifiers on non-embedded arguments. +... A variation of colours is used as arguments. Modifers are set up in such a way +... that, when modified: +... * Red turns to Green, +... * Yellow turns to Blue, +... * Orange is a default (unmodified) argument +... * Pink is never used in a test and alway comes from a modifier +... Note that in this suite the value checks are also included (hard coded) in the +... :OUT: expressions of the keyword. This is to check that all data lands correctly +... in the model. +Suite Setup Treat this test suite Model-based +Library robotmbt + +*** Test Cases *** +Positional arguments can be modified + [Documentation] Red is force modified to Green + ${pos}= Keyword with positional argument Red + Should be equal ${pos} Green + +Multiple different positional arguments should stay different + [Documentation] The second argument is force modified to Blue, the first argument must be different + ${one} ${two}= Keyword with multiple positional arguments Red Yellow + Should be equal ${one} Green + Should be equal ${two} Blue + +Multiple same positional arguments should stay identical + [Documentation] The second argument is force modified to Blue, the first argument must follow + ${one} ${two}= Keyword with multiple positional arguments Yellow Yellow + Should be equal ${one} Blue + Should be equal ${two} Blue + +Unused optional arguments are not modified + [Documentation] ${opt} has the default value 'Orange' and ${opt} also has a modifier. The modifer does + ... does not have 'Orange' as a valid value. Because the argument is not used, its default + ... is used, regardless of the modifer. + ${one} ${two} ${opt}= Keyword with positional arguments and optional argument Red Yellow + Should be equal ${one} Green + Should be equal ${two} Blue + Should be equal ${opt} Orange + +Used optional arguments are modified + [Documentation] ${opt} has a default value and ${opt} also has a modifier. The modifier is triggered, because + ... the argument is used and modifies 'Pink' into the only remaining option 'Purple'. + ${one} ${two} ${opt}= Keyword with positional arguments and optional argument Red Yellow Purple + Should be equal ${one} Green + Should be equal ${two} Blue + Should be equal ${opt} Pink + +Used optional arguments are modified and matched with other example values + [Documentation] ${opt} has a default value and ${opt} also has a modifier. The modifier is triggered, because + ... the argument is used and modifies 'Yellow' into Blue to match the second argument. + ${one} ${two} ${opt}= Keyword with positional arguments and optional argument Red Yellow Yellow + Should be equal ${one} Green + Should be equal ${two} Blue + Should be equal ${opt} Blue + +Unused optional arguments are not modified regardless other matching example values + [Documentation] ${opt} has default value 'Orange', but this argument is not used. One of the other arguments + ... uses Orange as its example value, but after modification this is no longer a valid value. The + ... positional argument gets modified into one of its valid values, while the unused argument + ... keeps its default value. + ${one} ${two} ${opt}= Keyword with positional arguments and optional argument Red Orange + Should be equal ${one} Green + Should be equal ${two} Blue + Should be equal ${opt} Orange + +Used optional arguments are modified when set to the value that is the default + [Documentation] ${opt} has default value 'Orange', and this exact value happens to be used in the scenario. + ... 'Orange' is however not a valid value after modification. Since the argument is used, the + ... modifier is triggered and set to a modified value. + ${one} ${two} ${opt}= Keyword with positional arguments and optional argument Red Yellow Orange + Should be equal ${one} Green + Should be equal ${two} Blue + Should be equal ${opt} Pink + +Mixed argument types are matched on their example values + &{all_args}= Keyword with all Red argument styles mixed Red Yellow named2=Red + Should be equal ${all_args}[emb] Green + Should be equal ${all_args}[pos1] Green + Should be equal ${all_args}[pos2] Blue + Should be equal ${all_args}[named1] Orange + Should be equal ${all_args}[named2] Green + +Varargs can be modified + @{all_args}= Keyword that expands its varargs Red Yellow + VAR @{expected} Red Yellow Pink Red + Should Be Equal ${all_args} ${expected} + +Varargs are not matched with other arguments + &{all_args}= Keyword with all Red argument styles mixed Red Yellow Red Blue named2=Red + Should be equal ${all_args}[emb] Green + Should be equal ${all_args}[pos1] Green + Should be equal ${all_args}[pos2] Blue + Should be equal ${all_args}[varargs][0] Red # Red from original argument, unaffected by other Red to Green modifiers + Should be equal ${all_args}[varargs][1] Blue # Blue from original argument, unaffected by other Yellow to Blue modifiers + Should be equal ${all_args}[varargs][2] Pink # Newly added argument by vararg modifier + Should be equal ${all_args}[varargs][3] Red # First vararg copied to the end unmodified + Should be equal ${all_args}[named1] Orange + Should be equal ${all_args}[named2] Green + +Unused varargs skip their modifiers + @{all_args}= Keyword that expands its varargs + Should Be Empty ${all_args} + +Free named arguments can be modified + &{all_args}= Keyword that expands free named arguments colour1=Red colour2=Yellow + Should be equal ${all_args}[colour1] Red + Should be equal ${all_args}[colour2] Blue + Should be equal ${all_args}[newcolour] Pink + Should be equal ${all_args}[dupcolour] Red + +Unused free named arguments skip their modifiers + &{all_args}= Keyword that expands free named arguments + Should Be Empty ${all_args} + +Free named arguments are not matched with other arguments + &{all_args}= Keyword with all Red argument styles mixed Red Yellow Red Blue + ... named2=Red colour1=Red colour2=Yellow + Should be equal ${all_args}[emb] Green + Should be equal ${all_args}[pos1] Green + Should be equal ${all_args}[pos2] Blue + Should be equal ${all_args}[varargs][0] Red + Should be equal ${all_args}[varargs][1] Blue + Should be equal ${all_args}[varargs][2] Pink + Should be equal ${all_args}[varargs][3] Red + Should be equal ${all_args}[named1] Orange + Should be equal ${all_args}[named2] Green + Should be equal ${all_args}[colour1] Red # Red from original argument, unaffected by other Red to Green modifiers + Should be equal ${all_args}[colour2] Blue # Modified from Yellow to Blue by modifer + Should be equal ${all_args}[newcolour] Pink # Newly added named argument by modifier + Should be equal ${all_args}[dupcolour] Red # colour1 copied to dupcolour by modifier + +*** Keywords *** +Keyword with positional argument + [Documentation] *model info* + ... :MOD: ${pos1}= [Green] + ... :IN: scenario.pos1 = ${pos1} + ... :OUT: scenario.pos1 == Green + [Arguments] ${pos1} + RETURN ${pos1} + +Keyword with multiple positional arguments + [Documentation] *model info* + ... :MOD: ${pos1}= [Green, Blue] | ${pos2}= [Blue] + ... :IN: scenario.pos1 = ${pos1} | scenario.pos2 = ${pos2} + ... :OUT: scenario.pos1 == Green | scenario.pos2 == Blue + [Arguments] ${pos1} ${pos2} + RETURN ${pos1} ${pos2} + +Keyword with positional arguments and optional argument + [Documentation] *model info* + ... :MOD: ${pos1}= [Green, Blue] | ${pos2}= [Blue] | ${pos3}=[Blue, Pink] + ... :IN: scenario.pos1 = ${pos1} | scenario.pos2 = ${pos2} | scenario.pos3 = ${pos3} + ... :OUT: scenario.pos1 == Green | scenario.pos2 == Blue | scenario.pos3 == ${pos3} + [Arguments] ${pos1} ${pos2} ${pos3}=Orange + RETURN ${pos1} ${pos2} ${pos3} + +Keyword that expands its varargs + [Documentation] *model info* + ... :MOD: ${varargs}= ${varargs} + [Pink, ${varargs}[0]] + ... :IN: scenario.varargs = ${varargs} + ... :OUT: if scenario.varargs: scenario.varargs[-1] == scenario.varargs[0] and scenario.varargs[-2] == Pink + [Arguments] @{varargs} + RETURN @{varargs} + +Keyword that expands free named arguments + [Documentation] *model info* + ... :MOD: ${free}= {**${free}, **dict(colour2=Blue, newcolour=Pink, dupcolour=${free}[colour1])} + ... :IN: scenario.free = ${free} + ... :OUT: if scenario.free: scenario.free[colour1] == scenario.free[dupcolour] and scenario.free[newcolour] == Pink + [Arguments] &{free} + RETURN &{free} + +Keyword with all ${emb_arg} argument styles mixed + [Documentation] *model info* + ... :MOD: ${emb_arg}= [Green] | ${pos1}= [Green, Blue] | ${pos2}= [Green, Blue] + ... ${named1}= [Green, Blue] | ${named2}= [Green, Blue] + ... ${varargs}= ${varargs} + [Pink, ${varargs}[0]] + ... ${free}= {**${free}, **dict(colour2=Blue, newcolour=Pink, dupcolour=${free}[colour1])} + ... :IN: scenario.emb = ${emb_arg} + ... scenario.pos1 = ${pos1} | scenario.pos2 = ${pos2} + ... scenario.named1 = ${named1} | scenario.named2 = ${named2} + ... scenario.varargs = ${varargs} | scenario.free = ${free} + ... :OUT: scenario.emb == Green + ... scenario.pos1 == Green | scenario.pos2 == Blue + ... scenario.named1 == Orange | scenario.named2 == Green + ... if scenario.varargs: scenario.varargs[-1] == scenario.varargs[0] and scenario.varargs[-2] == Pink + ... if scenario.free: scenario.free[colour1] == scenario.free[dupcolour] and scenario.free[newcolour] == Pink + [Arguments] ${pos1} ${pos2} @{varargs} ${named1}=Orange ${named2} &{free} + VAR &{all_args}= emb=${emb_arg} pos1=${pos1} pos2=${pos2} named1=${named1} named2=${named2} + ... varargs=${varargs} &{free} + RETURN ${all_args} diff --git a/pyproject.toml b/pyproject.toml index 1dfea0fc..505bbf4f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta" [project] name = "robotframework-mbt" -version = "0.9.0" +version = "0.10.0" description = "Model-Based Testing in Robot framework with test case generation" readme = "README.md" authors = [{ name = "Johan Foederer", email = "github@famfoe.nl" }] diff --git a/robotmbt/steparguments.py b/robotmbt/steparguments.py index 503481fc..42489581 100644 --- a/robotmbt/steparguments.py +++ b/robotmbt/steparguments.py @@ -57,19 +57,23 @@ def modified(self) -> bool: class StepArgument: + # kind list EMBEDDED = 'EMBEDDED' POSITIONAL = 'POSITIONAL' VAR_POS = 'VAR_POS' NAMED = 'NAMED' FREE_NAMED = 'FREE_NAMED' - def __init__(self, arg_name: str, value: any, kind: str | None = None): + def __init__(self, arg_name: str, value: any, kind: str | None = None, is_default: bool = False): self.name: str = arg_name self.org_value: any = value - self.kind: str | None = kind + self.kind: str | None = kind # one of the values from the kind list self._value: any = None self._codestr: str | None = None self.value: any = value + self.is_default: bool = is_default # indicates that the argument was not + # filled in from the scenario. This argment's value is taken + # from the keyword's default as provided by Robot. @property def arg(self) -> str: @@ -83,6 +87,7 @@ def value(self) -> any: def value(self, value: any): self._value = value self._codestr = self.make_codestring(value) + self.is_default = False @property def modified(self) -> bool: @@ -94,10 +99,13 @@ def codestring(self) -> str | None: def copy(self): # -> Self - cp = StepArgument(self.arg.strip('${}'), self.value, self.kind) + cp = StepArgument(self.arg.strip('${}'), self.value, self.kind, self.is_default) cp.org_value = self.org_value return cp + def __str__(self): + return f"{self.name}={self.value}" + @staticmethod def make_codestring(text: any) -> str: codestr = str(text) diff --git a/robotmbt/suitedata.py b/robotmbt/suitedata.py index 2684ad41..bfa41c74 100644 --- a/robotmbt/suitedata.py +++ b/robotmbt/suitedata.py @@ -33,6 +33,7 @@ import copy from robot.running.arguments.argumentvalidator import ArgumentValidator +import robot.utils.notset from .steparguments import StepArgument, StepArguments from .substitutionmap import SubstitutionMap @@ -71,13 +72,17 @@ def steps_with_errors(self): class Scenario: def __init__(self, name: str, parent=None): self.name: str = name + # Parent scenario for easy searching, processing and referencing - self.parent: Suite | None = parent # after steps and scenarios have been potentially moved around + self.parent: Suite | None = parent + # Can be a single step or None, may also be a str in tests self.setup: Step | None = None + # Can be a single step or None, may also be a str in tests self.teardown: Step | None = None + self.steps: list[Step] = [] self.src_id: int | None = None self.data_choices: dict | SubstitutionMap = {} # may be Dummy type in a test @@ -110,8 +115,7 @@ def split_at_step(self, stepindex: int): With stepindex 0 the first part has no steps and all steps are in the last part. With stepindex 1 the first step is in the first part, the other in the last part, and so on. """ - assert stepindex <= len( - self.steps), "Split index out of range. Not enough steps in scenario." + assert stepindex <= len(self.steps), "Split index out of range. Not enough steps in scenario." front = self.copy() front.teardown = None front.steps = self.steps[:stepindex] @@ -125,43 +129,35 @@ class Step: def __init__(self, steptext: str, *args, parent: Suite | Scenario, assign: tuple[str] = (), prev_gherkin_kw: str | None = None): # first keyword cell of the Robot line, including step_kw, - self.org_step: str = steptext - # excluding positional args, excluding variable assignment. + self.org_step: str = steptext # positional and named arguments as parsed from Robot text ('posA' , 'posB', 'named1=namedA') self.org_pn_args = args - # Parent scenario for easy searching and processing. self.parent: Suite | Scenario = parent - # For when a keyword's return value is assigned to a variable. - self.assign: tuple[str] = assign - # Taken directly from Robot. + self.assign: tuple[str] = assign + # 'given', 'when', 'then' or None for non-bdd keywords. self.gherkin_kw: str | None = self.step_kw \ if str(self.step_kw).lower() in ['given', 'when', 'then', 'none'] \ else prev_gherkin_kw - - # 'given', 'when', 'then' or None for non-bdd keywords. # Robot keyword with its embedded arguments in ${...} notation. self.signature: str | None = None - # embedded arguments list of StepArgument objects. self.args: StepArguments = StepArguments() - # Decouples StepArguments from the step text (refinement use case) self.detached: bool = False - # Modelling information is available as a dictionary. # TODO: Maybe use a data structure for this instead of a dict with specific keys. - self.model_info: dict[str, str | list[str]] = dict() - # The standard format of `model_info` is dict(IN=[], OUT=[]) and can + # The standard format is dict(IN=[], OUT=[]) and can # optionally contain an error field. # IN and OUT are lists of Python evaluatable expressions. # The `new vocab` form can be used to create new domain objects. # The `vocab.attribute` form can then be used to express relations # between properties from the domain vocabulaire. # Custom processors can define their own attributes. + self.model_info: dict[str, str | list[str]] = dict() def __str__(self): return self.keyword @@ -171,8 +167,7 @@ def __repr__(self): def copy(self): # -> Self - cp = Step(self.org_step, *self.org_pn_args, - parent=self.parent, assign=self.assign) + cp = Step(self.org_step, *self.org_pn_args, parent=self.parent, assign=self.assign) cp.gherkin_kw = self.gherkin_kw cp.signature = self.signature cp.args = StepArguments(self.args) @@ -205,6 +200,8 @@ def posnom_args_str(self) -> tuple[any]: return self.org_pn_args result: list[any] = [] for arg in self.args: + if arg.is_default: + continue if arg.kind == arg.POSITIONAL: result.append(arg.value) elif arg.kind == arg.VAR_POS: @@ -238,9 +235,7 @@ def kw_wo_gherkin(self) -> str: return self.keyword.replace(self.step_kw, '', 1).strip() if self.step_kw else self.keyword def add_robot_dependent_data(self, robot_kw): - """ - robot_kw must be Robot Framework's keyword object from Robot's runner context - """ + """robot_kw must be Robot Framework's keyword object from Robot's runner context""" try: if robot_kw.error: raise ValueError(robot_kw.error) @@ -257,39 +252,57 @@ def add_robot_dependent_data(self, robot_kw): def __handle_non_embedded_arguments(self, robot_argspec) -> list[StepArgument]: result = [] + p_args = [a for a in self.org_pn_args if '=' not in a or r'\=' in a] + n_args = [a.split('=', 1) for a in self.org_pn_args if '=' in a and r'\=' not in a] + self.__validate_arguments(robot_argspec, p_args, n_args) - p_args, n_args = robot_argspec.map([a for a in self.org_pn_args if '=' not in a or r'\=' in a], - [a.split('=', 1) for a in self.org_pn_args if '=' in a and r'\=' not in a]) - - # for some reason .map() returns [None] instead of the empty list when there are no arguments - if p_args == [None]: - p_args = [] - - ArgumentValidator(robot_argspec).validate(p_args, n_args) robot_args = [a for a in robot_argspec] - argument_names = list(robot_argspec.argument_names) + argument_names = [a for a in robot_argspec.argument_names if a not in robot_argspec.embedded] for arg in robot_argspec: - if arg.kind != arg.POSITIONAL_ONLY and arg.kind != arg.POSITIONAL_OR_NAMED: + if not p_args or (arg.kind != arg.POSITIONAL_ONLY and arg.kind != arg.POSITIONAL_OR_NAMED): break - result += [StepArgument(argument_names.pop(0), - p_args.pop(0), kind=StepArgument.POSITIONAL)] + result.append(StepArgument(argument_names.pop(0), p_args.pop(0), kind=StepArgument.POSITIONAL)) robot_args.pop(0) - if not p_args: - break if p_args and robot_args[0].kind == robot_args[0].VAR_POSITIONAL: - result += [StepArgument(argument_names.pop(0), - p_args, kind=StepArgument.VAR_POS)] + result.append(StepArgument(argument_names.pop(0), p_args, kind=StepArgument.VAR_POS)) free = {} for name, value in n_args: if name in argument_names: - result += [StepArgument(name, value, kind=StepArgument.NAMED)] + result.append(StepArgument(name, value, kind=StepArgument.NAMED)) + argument_names.remove(name) else: free[name] = value if free: - result += [StepArgument(argument_names[-1], - free, kind=StepArgument.FREE_NAMED)] + result.append(StepArgument(argument_names.pop(-1), free, kind=StepArgument.FREE_NAMED)) + for unmentioned_arg in argument_names: + arg = next(arg for arg in robot_args if arg.name == unmentioned_arg) + default_value = arg.default + if default_value is robot.utils.notset.NOT_SET: + if arg.kind == arg.VAR_POSITIONAL: + default_value = [] + elif arg.kind == arg.VAR_NAMED: + default_value = {} + else: + # This can happen when using library keywords that specify embedded arguments in the @keyword decorator + # but use different names in the method signature. Robot Framework implementation is incomplete for this + # aspect and differs between library and user keywords. + assert False, f"No default argument expected to be needed for '{unmentioned_arg}' here" + result.append(StepArgument(unmentioned_arg, default_value, kind=StepArgument.NAMED, is_default=True)) return result + @staticmethod + def __validate_arguments(spec, positionals, nameds): + # Robot uses a slightly different mapping for positional and named arguments. + # We keep the notation from the scenario (with or without the argument's name). + # Robot's mapping favours positional when possible, even when the name is used + # in the keyword call. The validator is sensitive to these differences. + p, n = spec.map(positionals, nameds) + # for some reason .map() returns [None] instead of the empty list when there are no arguments + if p == [None]: + p = [] + # Use the Robot mechanism for validation to yield familiar error messages + ArgumentValidator(spec).validate(p, n) + def __parse_model_info(self, docu: str) -> dict[str, list[str]]: model_info = dict() mi_index = docu.find("*model info*") @@ -311,8 +324,7 @@ def __parse_model_info(self, docu: str) -> dict[str, list[str]]: key = elms[1].strip() expressions = [e.strip() for e in elms[-1].split("|") if e] while lines and not lines[0].startswith(":"): - expressions.extend([e.strip() - for e in lines.pop(0).split("|") if e]) + expressions.extend([e.strip() for e in lines.pop(0).split("|") if e]) model_info[key] = expressions if not model_info: raise ValueError("When present, *model info* cannot be empty") diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index 208fb8fe..2b4aa3c3 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -115,22 +115,16 @@ def process_test_suite(self, in_suite: Suite, *, seed: any = 'new', graph: str = self._try_to_reach_full_coverage(allow_duplicate_scenarios=False) if not self.tracestate.coverage_reached(): - logger.debug( - "Direct trace not available. Allowing repetition of scenarios") + logger.debug("Direct trace not available. Allowing repetition of scenarios") self._try_to_reach_full_coverage(allow_duplicate_scenarios=True) if not self.tracestate.coverage_reached(): - if self.visualiser is not None: - logger.write( - self.visualiser.generate_visualisation(), html=True) + self.__write_visualisation() raise Exception("Unable to compose a consistent suite") self.out_suite.scenarios = self.tracestate.get_trace() self._report_tracestate_wrapup() - if self.visualiser is not None: - self.visualiser.set_final_trace( - TraceInfo.from_trace_state(self.tracestate, self.active_model)) - logger.write(self.visualiser.generate_visualisation(), html=True) + self.__write_visualisation() return self.out_suite @@ -138,9 +132,7 @@ def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool): self.tracestate = TraceState(len(self.scenarios)) self.active_model = ModelSpace() while not self.tracestate.coverage_reached(): - i_candidate = self.tracestate.next_candidate( - retry=allow_duplicate_scenarios) - + i_candidate = self.tracestate.next_candidate(retry=allow_duplicate_scenarios) if i_candidate is None: if not self.tracestate.can_rewind(): break @@ -159,8 +151,7 @@ def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool): if inserted: self.DROUGHT_LIMIT = 50 if self.__last_candidate_changed_nothing(): - logger.debug( - "Repeated scenario did not change the model's state. Stop trying.") + logger.debug("Repeated scenario did not change the model's state. Stop trying.") self._rewind() elif self.tracestate.coverage_drought > self.DROUGHT_LIMIT: @@ -177,6 +168,10 @@ def __update_visualisation(self): self.visualiser.update_visualisation( TraceInfo.from_trace_state(self.tracestate, self.active_model)) + def __write_visualisation(self): + if self.visualiser is not None: + logger.info(self.visualiser.generate_visualisation(), html=True) + def __last_candidate_changed_nothing(self) -> bool: if len(self.tracestate) < 2: return False @@ -208,8 +203,7 @@ def _fail_on_step_errors(suite: Suite): raise Exception(err_msg) def _try_to_fit_in_scenario(self, index: int, candidate: Scenario, retry_flag: bool) -> bool: - candidate = self._generate_scenario_variant( - candidate, self.active_model) + candidate = self._generate_scenario_variant(candidate, self.active_model) if not candidate: self.active_model.end_scenario_scope() @@ -217,24 +211,19 @@ def _try_to_fit_in_scenario(self, index: int, candidate: Scenario, retry_flag: b self._report_tracestate_to_user() return False - confirmed_candidate, new_model = self._process_scenario( - candidate, self.active_model) + confirmed_candidate, new_model = self._process_scenario(candidate, self.active_model) if confirmed_candidate: self.active_model = new_model self.active_model.end_scenario_scope() - self.tracestate.confirm_full_scenario( - index, confirmed_candidate, self.active_model) - logger.debug( - f"Inserted scenario {confirmed_candidate.src_id}, {confirmed_candidate.name}") + self.tracestate.confirm_full_scenario(index, confirmed_candidate, self.active_model) + logger.debug(f"Inserted scenario {confirmed_candidate.src_id}, {confirmed_candidate.name}") self._report_tracestate_to_user() logger.debug(f"last state:\n{self.active_model.get_status_text()}") self.__update_visualisation() return True - part1, part2 = self._split_candidate_if_refinement_needed( - candidate, self.active_model) - + part1, part2 = self._split_candidate_if_refinement_needed(candidate, self.active_model) if part2: exit_conditions = part2.steps[1].model_info['OUT'] part1.name = f"{part1.name} (part {self.tracestate.highest_part(index) + 1})" @@ -246,8 +235,7 @@ def _try_to_fit_in_scenario(self, index: int, candidate: Scenario, retry_flag: b i_refine = self.tracestate.next_candidate(retry=retry_flag) if i_refine is None: - logger.debug( - "Refinement needed, but there are no scenarios left") + logger.debug("Refinement needed, but there are no scenarios left") self._rewind() self._report_tracestate_to_user() self.__update_visualisation() @@ -272,18 +260,14 @@ def _try_to_fit_in_scenario(self, index: int, candidate: Scenario, retry_flag: b insert_valid_here = False if insert_valid_here: - m_finished = self._try_to_fit_in_scenario( - index, part2, retry_flag) + m_finished = self._try_to_fit_in_scenario(index, part2, retry_flag) if m_finished: self.__update_visualisation() return True else: - logger.debug( - f"Scenario did not meet refinement conditions {exit_conditions}") - logger.debug( - f"last state:\n{self.active_model.get_status_text()}") - logger.debug( - f"Reconsidering {self.scenarios[i_refine].name}, scenario excluded") + logger.debug(f"Scenario did not meet refinement conditions {exit_conditions}") + logger.debug(f"last state:\n{self.active_model.get_status_text()}") + logger.debug(f"Reconsidering {self.scenarios[i_refine].name}, scenario excluded") self._rewind() self._report_tracestate_to_user() @@ -336,8 +320,7 @@ def _split_candidate_if_refinement_needed(scenario: Scenario, model: ModelSpace) try: if m.process_expression(expr, step.args) is False: if step.gherkin_kw in ['when', None]: - logger.debug( - f"Refinement needed for scenario: {scenario.name}\nat step: {step}") + logger.debug(f"Refinement needed for scenario: {scenario.name}\nat step: {step}") refine_here = True else: return no_split @@ -346,22 +329,17 @@ def _split_candidate_if_refinement_needed(scenario: Scenario, model: ModelSpace) return no_split if refine_here: - front, back = scenario.split_at_step( - scenario.steps.index(step)) + front, back = scenario.split_at_step(scenario.steps.index(step)) remaining_steps = '\n\t'.join( [step.full_keyword, '- ' * 35] + [s.full_keyword for s in back.steps[1:]]) - remaining_steps = SuiteProcessors.escape_robot_vars( - remaining_steps) - edge_step = Step( - 'Log', f"Refinement follows for step:\n\t{remaining_steps}", parent=scenario) + remaining_steps = SuiteProcessors.escape_robot_vars(remaining_steps) + edge_step = Step('Log', f"Refinement follows for step:\n\t{remaining_steps}", parent=scenario) edge_step.gherkin_kw = step.gherkin_kw - edge_step.model_info = dict( - IN=step.model_info['IN'], OUT=[]) + edge_step.model_info = dict(IN=step.model_info['IN'], OUT=[]) edge_step.detached = True edge_step.args = StepArguments(step.args) front.steps.append(edge_step) - back.steps.insert( - 0, Step('Log', f"Refinement ready, completing step", parent=scenario)) + back.steps.insert(0, Step('Log', f"Refinement ready, completing step", parent=scenario)) back.steps[1] = back.steps[1].copy() back.steps[1].model_info['IN'] = [] return (front, back) @@ -381,8 +359,7 @@ def _process_scenario(scenario: Scenario, model: ModelSpace) -> tuple[Scenario, scenario = scenario.copy() for step in scenario.steps: if 'error' in step.model_info: - logger.debug( - f"Error in scenario {scenario.name} at step {step}: {step.model_info['error']}") + logger.debug(f"Error in scenario {scenario.name} at step {step}: {step.model_info['error']}") return None, None for expr in SuiteProcessors._relevant_expressions(step): @@ -425,49 +402,53 @@ def _generate_scenario_variant(self, scenario: Scenario, model: ModelSpace) -> S # collect set of constraints subs = SubstitutionMap() - try: # TODO: look into refactoring this... interestingly structured code. + try: for step in scenario.steps: if 'MOD' in step.model_info: for expr in step.model_info['MOD']: - modded_arg, constraint = self._parse_modifier_expression( - expr, step.args) - - if step.args[modded_arg].kind != StepArgument.EMBEDDED: - raise ValueError( - "Modifers are currently only supported for embedded arguments.") - - org_example = step.args[modded_arg].org_value - if step.gherkin_kw == 'then': - constraint = None # No new constraints are processed for then-steps - if org_example not in subs.substitutions: - # if a then-step signals the first use of an example value, it is considered a new definition - subs.substitute(org_example, [org_example]) - continue - - if not constraint and org_example not in subs.substitutions: - raise ValueError( - f"No options to choose from at first assignment to {org_example}") - - if constraint and constraint != '.*': - options = m.process_expression( - constraint, step.args) - if options == 'exec': - raise ValueError( - f"Invalid constraint for argument substitution: {expr}") - - if not options: - raise ValueError( - f"Constraint on modifer did not yield any options: {expr}") - - if not is_list_like(options): - raise ValueError( - f"Constraint on modifer did not yield a set of options: {expr}") - + modded_arg, constraint = self._parse_modifier_expression(expr, step.args) + if step.args[modded_arg].is_default: + continue + if step.args[modded_arg].kind in [StepArgument.EMBEDDED, StepArgument.POSITIONAL, + StepArgument.NAMED]: + org_example = step.args[modded_arg].org_value + if step.gherkin_kw == 'then': + constraint = None # No new constraints are processed for then-steps + if org_example not in subs.substitutions: + # if a then-step signals the first use of an example value, it is considered a new definition + subs.substitute(org_example, [org_example]) + continue + if not constraint and org_example not in subs.substitutions: + raise ValueError(f"No options to choose from at first assignment to {org_example}") + if constraint and constraint != '.*': + options = m.process_expression(constraint, step.args) + if options == 'exec': + raise ValueError(f"Invalid constraint for argument substitution: {expr}") + if not options: + raise ValueError(f"Constraint on modifer did not yield any options: {expr}") + if not is_list_like(options): + raise ValueError(f"Constraint on modifer did not yield a set of options: {expr}") + else: + options = None + subs.substitute(org_example, options) + elif step.args[modded_arg].kind == StepArgument.VAR_POS: + if step.args[modded_arg].value: + modded_varargs = m.process_expression(constraint, step.args) + if not is_list_like(modded_varargs): + raise ValueError(f"Modifying varargs must yield a list of arguments") + # Varargs are not added to the substitution map, but are used directly as-is. A modifier can + # change the number of arguments in the list, making it impossible to decide which values to + # match and which to drop and/or duplicate. + step.args[modded_arg].value = modded_varargs + elif step.args[modded_arg].kind == StepArgument.FREE_NAMED: + if step.args[modded_arg].value: + modded_free_args = m.process_expression(constraint, step.args) + if not isinstance(modded_free_args, dict): + raise ValueError("Modifying free named arguments must yield a dict") + # Similar to varargs, modified free named arguments are used directly as-is. + step.args[modded_arg].value = modded_free_args else: - options = None - - subs.substitute(org_example, options) - + raise AssertionError(f"Unknown argument kind for {modded_arg}") except Exception as err: logger.debug(f"Unable to insert scenario {scenario.src_id}, {scenario.name}, due to modifier\n" f" In step {step}: {err}") @@ -482,19 +463,19 @@ def _generate_scenario_variant(self, scenario: Scenario, model: ModelSpace) -> S # Update scenario with generated values if subs.solution: - logger.debug( - f"Example variant generated with argument substitution: {subs}") - + logger.debug(f"Example variant generated with argument substitution: {subs}") scenario.data_choices = subs for step in scenario.steps: if 'MOD' in step.model_info: for expr in step.model_info['MOD']: - modded_arg, _ = self._parse_modifier_expression( - expr, step.args) + modded_arg, _ = self._parse_modifier_expression(expr, step.args) + if step.args[modded_arg].is_default: + continue org_example = step.args[modded_arg].org_value - step.args[modded_arg].value = subs.solution[org_example] - + if step.args[modded_arg].kind in [StepArgument.EMBEDDED, StepArgument.POSITIONAL, + StepArgument.NAMED]: + step.args[modded_arg].value = subs.solution[org_example] return scenario @staticmethod @@ -502,8 +483,7 @@ def _parse_modifier_expression(expression: str, args: StepArguments) -> tuple[st if expression.startswith('${'): for var in args: if expression.casefold().startswith(var.arg.casefold()): - assignment_expr = expression.replace( - var.arg, '', 1).strip() + assignment_expr = expression.replace(var.arg, '', 1).strip() if not assignment_expr.startswith('=') or assignment_expr.startswith('=='): break # not an assignment @@ -520,8 +500,7 @@ def _report_tracestate_to_user(self): user_trace += f"{snapshot.scenario.src_id}{part}, " user_trace = user_trace[:-2] + "]" if ',' in user_trace else "[]" - reject_trace = [ - self.scenarios[i].src_id for i in self.tracestate.tried] + reject_trace = [self.scenarios[i].src_id for i in self.tracestate.tried] logger.debug(f"Trace: {user_trace} Reject: {reject_trace}") def _report_tracestate_wrapup(self): @@ -536,14 +515,14 @@ def _init_randomiser(seed: any): seed = seed.strip() if str(seed).lower() == 'none': - logger.debug( + logger.info( f"Using system's random seed for trace generation. This trace cannot be rerun. Use `seed=new` to generate a reusable seed.") elif str(seed).lower() == 'new': new_seed = SuiteProcessors._generate_seed() - logger.debug(f"seed={new_seed} (use seed to rerun this trace)") + logger.info(f"seed={new_seed} (use seed to rerun this trace)") random.seed(new_seed) else: - logger.debug(f"seed={seed} (as provided)") + logger.info(f"seed={seed} (as provided)") random.seed(seed) @staticmethod diff --git a/robotmbt/tracestate.py b/robotmbt/tracestate.py index 362375d7..689c4971 100644 --- a/robotmbt/tracestate.py +++ b/robotmbt/tracestate.py @@ -61,7 +61,7 @@ def model(self) -> dict[str, int] | None: return self._snapshots[-1].model if self._trace else None @property - def tried(self) -> tuple[int]: + def tried(self) -> tuple[int, ...]: """returns the indices that were rejected or previously inserted at the current position""" return tuple(self._tried[-1]) diff --git a/robotmbt/version.py b/robotmbt/version.py index 58e1a598..a5bdcd62 100644 --- a/robotmbt/version.py +++ b/robotmbt/version.py @@ -1 +1 @@ -VERSION: str = '0.9.0' +VERSION: str = '0.10.0' diff --git a/utest/test_steparguments.py b/utest/test_steparguments.py index 5e625085..da1bdce5 100644 --- a/utest/test_steparguments.py +++ b/utest/test_steparguments.py @@ -85,13 +85,30 @@ def test_modified_property(self): arg1.value = 7 self.assertFalse(arg1.modified) - def test_copies_are_the_same(self): + def test_is_default_property(self): arg1 = StepArgument('foo', 7) + self.assertFalse(arg1.is_default) + arg2 = StepArgument('foo', 7, is_default=True) + self.assertTrue(arg2.is_default) + arg2.value = 7 + self.assertFalse(arg2.is_default) + + def test_copies_are_the_same(self): + arg1 = StepArgument('foo', 7, kind=StepArgument.NAMED, is_default=True) arg2 = arg1.copy() self.assertEqual(arg1.arg, arg2.arg) self.assertEqual(arg1.value, arg2.value) self.assertEqual(arg1.org_value, arg2.org_value) self.assertEqual(arg1.codestring, arg2.codestring) + self.assertEqual(arg1.kind, arg2.kind) + self.assertEqual(arg1.is_default, arg2.is_default) + arg1.value = 8 + arg2 = arg1.copy() + self.assertEqual(arg2.arg, '${foo}') + self.assertEqual(arg2.value, 8) + self.assertEqual(arg2.org_value, 7) + self.assertEqual(arg2.kind, StepArgument.NAMED) + self.assertEqual(arg2.is_default, False) def test_original_value_is_kept_when_copying(self): arg1 = StepArgument('foo', 7) diff --git a/utest/test_suitedata.py b/utest/test_suitedata.py index 88e6347c..a8bc1025 100644 --- a/utest/test_suitedata.py +++ b/utest/test_suitedata.py @@ -394,6 +394,38 @@ def test_return_value_multi_assignment_is_part_of_the_full_keyword_text(self, mo step = Step(RobotKwStub.STEPTEXT, assign=('${output1}', '${output2}='), parent=None) self.assertEqual(step.full_keyword, "${output1} ${output2}= " + RobotKwStub.STEPTEXT) + def test_argument_with_default_is_omitted_from_keyword_when_not_mentioned_named(self, mock): + step = Step(RobotKwStub.STEPTEXT, 'posA', 'pos2=posB', parent=None) + step.args = StubStepArguments([StubArgument(name='pos1', value='posA', is_default=False, kind='POSITIONAL'), + StubArgument(name='pos2', value='posB', is_default=False, kind='NAMED'), + StubArgument(name='named1', value='namedA', is_default=True, kind='NAMED')]) + self.assertTupleEqual(step.posnom_args_str, ('posA', 'pos2=posB')) + self.assertEqual(step.full_keyword, f"{RobotKwStub.STEPTEXT} posA pos2=posB") + + def test_argument_with_default_is_omitted_from_keyword_when_not_mentioned_positional(self, mock): + step = Step(RobotKwStub.STEPTEXT, 'posA', 'posB', parent=None) + step.args = StubStepArguments([StubArgument(name='pos1', value='posA', is_default=False, kind='POSITIONAL'), + StubArgument(name='pos2', value='posB', is_default=False, kind='POSITIONAL'), + StubArgument(name='named1', value='namedA', is_default=True, kind='POSITIONAL')]) + self.assertTupleEqual(step.posnom_args_str, ('posA', 'posB')) + self.assertEqual(step.full_keyword, f"{RobotKwStub.STEPTEXT} posA posB") + + def test_argument_with_default_is_included_in_keyword_when_mentioned_named(self, mock): + step = Step(RobotKwStub.STEPTEXT, 'posA', 'pos2=posB', 'named1=namedA', parent=None) + step.args = StubStepArguments([StubArgument(name='pos1', value='posA', is_default=False, kind='POSITIONAL'), + StubArgument(name='pos2', value='posB', is_default=False, kind='NAMED'), + StubArgument(name='named1', value='namedA', is_default=False, kind='NAMED')]) + self.assertTupleEqual(step.posnom_args_str, ('posA', 'pos2=posB', 'named1=namedA')) + self.assertEqual(step.full_keyword, f"{RobotKwStub.STEPTEXT} posA pos2=posB named1=namedA") + + def test_argument_with_default_is_included_in_keyword_when_mentioned_positional(self, mock): + step = Step(RobotKwStub.STEPTEXT, 'posA', 'posB', 'namedA', parent=None) + step.args = StubStepArguments([StubArgument(name='pos1', value='posA', is_default=False, kind='POSITIONAL'), + StubArgument(name='pos2', value='posB', is_default=False, kind='POSITIONAL'), + StubArgument(name='named1', value='namedA', is_default=False, kind='POSITIONAL')]) + self.assertTupleEqual(step.posnom_args_str, ('posA', 'posB', 'namedA')) + self.assertEqual(step.full_keyword, f"{RobotKwStub.STEPTEXT} posA posB namedA") + def test_positional_and_named_arguments_are_available_in_robot_tuple_format(self, mock): argtuple = 'posA', 'pos2=posB', 'named1=namedA' step = Step(RobotKwStub.STEPTEXT, *argtuple, parent=None) @@ -442,5 +474,18 @@ class argstub: map = lambda x,y,z: ([], []) __iter__ = lambda _: iter([]) + +class StubStepArguments(list): + modified = True # trigger modified status to get arguments processed, rather then just echoed + + +class StubArgument(SimpleNamespace): + EMBEDDED = 'EMBEDDED' + POSITIONAL = 'POSITIONAL' + VAR_POS = 'VAR_POS' + NAMED = 'NAMED' + FREE_NAMED = 'FREE_NAMED' + + if __name__ == '__main__': unittest.main() From 57aad1364c5ccde2b337839c54f6022b08966bff Mon Sep 17 00:00:00 2001 From: Thomas Kas Date: Mon, 8 Dec 2025 10:17:09 +0100 Subject: [PATCH 025/131] Rework trace info and fix bugs (#32) * Clean-up of unused features, move some logic, fix empty state entry bug * Merge with main, fix some bugs, alter ScenarioStateGraph to be in line with the rest and fix up some of its unit tests * Merge mistake * Start of rework * Rework TraceInfo, extract common graph logic * Fix unit tests * Bug fixes and minor changes * Fix nuked graphs * Remove outdate acceptance tests * Generics * Proper type * Fix bug in Johan's code * More sanity, implement feedback --- atest/resources/helpers/__init__.py | 0 atest/resources/helpers/modelgenerator.py | 90 ------- atest/resources/visualisation.resource | 70 ------ .../M1a1_VertexCount.robot | 11 - .../M1a2_EdgeRepresentation.robot | 12 - robotmbt/suiteprocessors.py | 7 +- robotmbt/tracestate.py | 13 +- robotmbt/visualise/graphs/abstractgraph.py | 98 ++++++-- robotmbt/visualise/graphs/scenariograph.py | 81 ++----- .../visualise/graphs/scenariostategraph.py | 101 +------- robotmbt/visualise/graphs/stategraph.py | 98 ++------ robotmbt/visualise/models.py | 121 +++++++--- robotmbt/visualise/networkvisualiser.py | 1 + robotmbt/visualise/visualiser.py | 32 +-- utest/test_visualise_models.py | 84 ++++++- utest/test_visualise_scenariograph.py | 172 ++++---------- utest/test_visualise_scenariostategraph.py | 222 ++++++------------ utest/test_visualise_stategraph.py | 155 ++++++------ 18 files changed, 504 insertions(+), 864 deletions(-) delete mode 100644 atest/resources/helpers/__init__.py delete mode 100644 atest/resources/helpers/modelgenerator.py delete mode 100644 atest/resources/visualisation.resource delete mode 100644 atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a1_VertexCount.robot delete mode 100644 atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a2_EdgeRepresentation.robot diff --git a/atest/resources/helpers/__init__.py b/atest/resources/helpers/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/atest/resources/helpers/modelgenerator.py b/atest/resources/helpers/modelgenerator.py deleted file mode 100644 index b5837aeb..00000000 --- a/atest/resources/helpers/modelgenerator.py +++ /dev/null @@ -1,90 +0,0 @@ -import random -import string - -from robot.api.deco import keyword # type:ignore -from robotmbt.modelspace import ModelSpace -from robotmbt.visualise.models import TraceInfo, ScenarioInfo, StateInfo -from robotmbt.visualise.graphs.scenariograph import ScenarioGraph -from robotmbt.visualise.graphs.abstractgraph import AbstractGraph -from robotmbt.visualise.graphs.stategraph import StateGraph - - -class ModelGenerator: - @keyword(name="Create Graph") # type: ignore - def create_graph(self, graph_type :str) -> AbstractGraph: - match graph_type: - case "scenario": - return ScenarioGraph() - case "state": - return StateGraph() - case _: - raise Exception(f"Trying to create unknown graph type {graph_type}") - - - @keyword(name="Generate Trace Information") # type: ignore - def generate_trace_info(self, scenario_count: int) -> TraceInfo: - """Generates a list of unique random scenarios.""" - scenarios: list[ScenarioInfo] = ModelGenerator.generate_scenario_names( - scenario_count) - - return TraceInfo(scenarios, StateInfo(ModelSpace())) - - @keyword(name="Ensure Scenario Present") # type: ignore - def ensure_scenario_present(self, trace_info: TraceInfo, scenario_name: str) -> TraceInfo: - if trace_info.contains_scenario(scenario_name): - return trace_info - - trace_info.add_scenario(ScenarioInfo(scenario_name)) - return trace_info - - @keyword(name="Ensure Scenario Follows") # type: ignore - def ensure_scenario_follows(self, trace_info: TraceInfo, scen1: str, scen2: str) -> TraceInfo: - scen1_info: ScenarioInfo | None = trace_info.get_scenario(scen1) - scen2_info: ScenarioInfo | None = trace_info.get_scenario(scen2) - - if scen1_info is None or scen2_info is None: - raise Exception( - f"Ensure Scenario Follows for scenarios that did not exist! scen1={scen1}, scen2={scen2}") - - # both scenarios apparently exist, now make sure that scenario2 follows after some appearance of scenario 1: - scen1_index: int = trace_info.trace.index(scen1_info) - scen2_index: int = trace_info.trace.index(scen2_info) - if scen2_index == scen1_index + 1: - return trace_info - - # if it doesn't follow, make it follow - trace_info.insert_trace_at(scen1_index, scen2_info) - return trace_info - - @keyword(name="Ensure Edge Exists") # type: ignore - def ensure_edge_exists(self, graph: ScenarioGraph, scen_name1: str, scen_name2: str): - # get node name based on scenario - nodename1: str = "" - nodename2: str = "" - for (nodename, label) in graph.networkx.nodes(data='label', default=None): - if label == scen_name1: - nodename1 = nodename - - if label == scen_name2: - nodename2 = nodename - - # now check the relation: - if (nodename1, nodename2) in graph.networkx.edges: # type: ignore - return # exists :) - - # make sure that it exists - graph.networkx.add_edge(nodename1, nodename2) - - @staticmethod - def generate_random_scenario_name(length: int = 10) -> str: - """Generates a random scenario name.""" - return ''.join(random.choices(string.ascii_letters + string.digits, k=length)) - - @staticmethod - def generate_scenario_names(count: int) -> list[ScenarioInfo]: - """Generates a list of unique random scenarios.""" - scenarios: set[str] = set() - while len(scenarios) < count: - scenario = ModelGenerator.generate_random_scenario_name() - scenarios.add(scenario) - return [ScenarioInfo(s) for s in scenarios] diff --git a/atest/resources/visualisation.resource b/atest/resources/visualisation.resource deleted file mode 100644 index 2e883d26..00000000 --- a/atest/resources/visualisation.resource +++ /dev/null @@ -1,70 +0,0 @@ -*** Settings *** -Documentation Resource file for testing the visualisation of RobotMBT -Library atest.resources.helpers.modelgenerator.ModelGenerator -Library robotmbt.visualise.visualiser.Visualiser scenario -Library Collections - - -*** Keywords *** -Test Suite ${suite} exists - [Documentation] Makes a test suite - ... :IN: suite = ${suite} - ... :OUT: None - Set Suite Variable ${suite} - ${trace_info} = Generate Trace Information ${0} - Set Suite Variable ${trace_info} # make empty trace info - -Test Suite ${suite} has ${count} scenarios - [Documentation] Makes a test suite - ... :IN: scenario_count = ${count}, suite = ${suite} - ... :OUT: None - ${trace_info} = Generate Trace Information ${count} - Set Suite Variable ${trace_info} - -# WARNING: this only works for Scenario graphs, this needs to be adjusted to continually update the model when adding new state. -Test Suite ${suite} contains scenario ${scenario_name} - [Documentation] Ensures test suite Suite has scenario with name=Scenario name - ... :IN: suite = ${suite}, scenario_name = ${scenario_name} - ... :OUT: None - Variable Should Exist ${trace_info} - - ${trace_info} = Ensure Scenario Present ${trace_info} ${scenario_name} - Set Suite Variable ${trace_info} - -In Test Suite ${suite}, scenario ${s2} can be reached from ${s1} - [Documentation] Ensures a scenario s2 immediately follows s1 in a trace - ... :IN: suite = ${suite}, scenario2 = ${s2}, scenario1 = ${s1} - ... :OUT: None - - ${trace_info} = Ensure Scenario Follows ${trace_info} ${s1} ${s2} - Set Suite Variable ${trace_info} - -Graph ${graph} of type ${graph_type} is generated based on Test Suite ${suite} - [Documentation] Generates the graph - ... :IN: graph = ${graph}, graph_type = ${graph_type}, suite = ${suite} - ... :OUT: None - Variable Should Exist ${trace_info} - ${visualiser} = robotmbt.visualise.visualiser.Visualiser.Construct graph_type=${graph_type} - Call Method ${visualiser} update_visualisation ${trace_info} - ${html} = Call Method ${visualiser} generate_visualisation - - Set Suite Variable ${visualiser} - Set Suite Variable ${html} - -Graph ${graph} contains ${number} vertices - [Documentation] Verifies that the graph contains the specified number of vertices. - ... :IN: graph = ${graph}, number = ${number} - ... :OUT: None - Variable Should Exist ${visualiser} - Variable Should Exist ${trace_info} - - ${vertex_count} = Get Length ${visualiser.graph.networkx.nodes} - Should Be Equal As Integers ${vertex_count} ${number} - -Graph ${graph} shows an edge from ${scenname1} towards ${scenname2} - [Documentation] Verifies that a generated graph contains a certain edge - ... :IN: graph = ${graph}, scenario name 1 = ${scenname1}, scenario name 2 = ${scenname2} - ... :OUT: None - Variable Should Exist ${visualiser} - - ${res} = Ensure Edge Exists ${visualiser.graph} ${scenname1} ${scenname2} \ No newline at end of file diff --git a/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a1_VertexCount.robot b/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a1_VertexCount.robot deleted file mode 100644 index 9224c2af..00000000 --- a/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a1_VertexCount.robot +++ /dev/null @@ -1,11 +0,0 @@ -*** Settings *** -Resource ../../../../resources/visualisation.resource -Library robotmbt processor=echo -Suite Setup Set Global Variable ${scen_count} ${2} - -*** Test Cases *** -Graph should contain vertex count equal to scenario count + 1 for scenario-graph - Given Test Suite s exists - Given Test Suite s has ${scen_count} scenarios - When Graph g of type scenario is generated based on Test Suite s - Then Graph g contains ${scen_count + 1} vertices \ No newline at end of file diff --git a/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a2_EdgeRepresentation.robot b/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a2_EdgeRepresentation.robot deleted file mode 100644 index a8162950..00000000 --- a/atest/robotMBT tests/10_visualisation_tests/01_ScenarioGraph/01_ScenarioIsVertex/M1a2_EdgeRepresentation.robot +++ /dev/null @@ -1,12 +0,0 @@ -*** Settings *** -Resource ../../../../resources/visualisation.resource -Library robotmbt processor=echo - -*** Test Cases *** -Graph should contain edge from vertex A to vertex B if B can be reached from A - Given Test Suite s exists - Given Test Suite s contains scenario Drive To Destination - Given Test Suite s contains scenario Arrive At Destination - Given In Test Suite s, scenario Arrive At Destination can be reached from Drive To Destination - When Graph g of type scenario is generated based on Test Suite s - Then Graph g shows an edge from Drive To Destination towards Arrive At Destination diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index 2b4aa3c3..9887d9c0 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -165,8 +165,11 @@ def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool): def __update_visualisation(self): if self.visualiser is not None: - self.visualiser.update_visualisation( - TraceInfo.from_trace_state(self.tracestate, self.active_model)) + self.visualiser.update_trace(self.tracestate, self.active_model) + + def __write_visualisation(self): + if self.visualiser is not None: + logger.info(self.visualiser.generate_visualisation(), html=True) def __write_visualisation(self): if self.visualiser is not None: diff --git a/robotmbt/tracestate.py b/robotmbt/tracestate.py index 689c4971..03941059 100644 --- a/robotmbt/tracestate.py +++ b/robotmbt/tracestate.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +from robotmbt.modelspace import ModelSpace from robotmbt.suitedata import Scenario @@ -33,10 +34,10 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. class TraceSnapShot: - def __init__(self, id: str, inserted_scenario: str | Scenario, model_state: dict[str, int], drought: int = 0): + def __init__(self, id: str, inserted_scenario: str | Scenario, model_state: ModelSpace, drought: int = 0): self.id: str = id self.scenario: str | Scenario = inserted_scenario - self.model: dict[str, int] = model_state.copy() + self.model: ModelSpace = model_state.copy() self.coverage_drought: int = drought @@ -56,9 +57,9 @@ def __init__(self, n_scenarios: int): self._open_refinements: list[int] = [] @property - def model(self) -> dict[str, int] | None: + def model(self) -> ModelSpace | None: """returns the model as it is at the end of the current trace""" - return self._snapshots[-1].model if self._trace else None + return self._snapshots[-1].model.copy() if self._trace else None @property def tried(self) -> tuple[int, ...]: @@ -122,7 +123,7 @@ def reject_scenario(self, i_scenario: int): """Trying a scenario excludes it from further cadidacy on this level""" self._tried[-1].append(i_scenario) - def confirm_full_scenario(self, index: int, scenario: str, model: dict[str, int]): + def confirm_full_scenario(self, index: int, scenario: str, model: ModelSpace): if not self._c_pool[index]: self._c_pool[index] = True c_drought = 0 @@ -140,7 +141,7 @@ def confirm_full_scenario(self, index: int, scenario: str, model: dict[str, int] self._trace.append(id) self._snapshots.append(TraceSnapShot(id, scenario, model, c_drought)) - def push_partial_scenario(self, index: int, scenario: str, model: dict[str, int]): + def push_partial_scenario(self, index: int, scenario: str, model: ModelSpace): if self._is_refinement_active(index): id = f"{index}.{self.highest_part(index) + 1}" diff --git a/robotmbt/visualise/graphs/abstractgraph.py b/robotmbt/visualise/graphs/abstractgraph.py index e61cbdf5..b9c6dcc3 100644 --- a/robotmbt/visualise/graphs/abstractgraph.py +++ b/robotmbt/visualise/graphs/abstractgraph.py @@ -1,40 +1,106 @@ from abc import ABC, abstractmethod -from robotmbt.visualise.models import TraceInfo +from typing import Generic, TypeVar + +from robotmbt.visualise.models import TraceInfo, ScenarioInfo, StateInfo import networkx as nx -class AbstractGraph(ABC): - @abstractmethod - def update_visualisation(self, info: TraceInfo): +NodeInfo = TypeVar('NodeInfo') +EdgeInfo = TypeVar('EdgeInfo') + + +class AbstractGraph(ABC, Generic[NodeInfo, EdgeInfo]): + def __init__(self, info: TraceInfo): + # The underlying storage - a NetworkX DiGraph + self.networkx: nx.DiGraph = nx.DiGraph() + + # Keep track of node IDs + self.ids: dict[str, NodeInfo] = {} + + # Add the start node + self.networkx.add_node('start', label='start') + + # Add nodes and edges for all traces + for trace in info.all_traces: + for i in range(len(trace)): + if i > 0: + from_node = self._get_or_create_id(self.select_node_info(trace[i - 1])) + else: + from_node = 'start' + to_node = self._get_or_create_id(self.select_node_info(trace[i])) + self._add_node(from_node) + self._add_node(to_node) + self.networkx.add_edge(from_node, to_node, + label=self.create_edge_label(self.select_edge_info(trace[i]))) + + # Set the final trace and add any missing nodes/edges + self.final_trace = ['start'] + for i in range(len(info.current_trace)): + if i > 0: + from_node = self._get_or_create_id(self.select_node_info(info.current_trace[i - 1])) + else: + from_node = 'start' + to_node = self._get_or_create_id(self.select_node_info(info.current_trace[i])) + self.final_trace.append(to_node) + self._add_node(from_node) + self._add_node(to_node) + self.networkx.add_edge(from_node, to_node, + label=self.create_edge_label(self.select_edge_info(info.current_trace[i]))) + + def get_final_trace(self) -> list[str]: """ - Update the visualisation with new trace information from another exploration step. + Get the final trace as ordered node ids. + Edges are subsequent entries in the list. """ - pass + return self.final_trace + def _get_or_create_id(self, info: NodeInfo) -> str: + """ + Get the ID for a state that has been added before, or create and store a new one. + """ + for i in self.ids.keys(): + if self.ids[i] == info: + return i + + new_id = f"node{len(self.ids)}" + self.ids[new_id] = info + return new_id + + def _add_node(self, node: str): + """ + Add node if it doesn't already exist. + """ + if node not in self.networkx.nodes: + self.networkx.add_node(node, label=self.create_node_label(self.ids[node])) + + @staticmethod @abstractmethod - def set_final_trace(self, info: TraceInfo): + def select_node_info(pair: tuple[ScenarioInfo, StateInfo]) -> NodeInfo: """ - Update the graph with information on the final trace. + Select the info to use to compare nodes and generate their labels for a specific graph type. """ pass + @staticmethod @abstractmethod - def get_final_trace(self) -> list[str]: + def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> EdgeInfo: """ - Get the final trace as ordered node ids. - Edges are subsequent entries in the list. + Select the info to use to generate the label for each edge for a specific graph type. """ pass - @property + @staticmethod @abstractmethod - def networkx(self) -> nx.DiGraph: + def create_node_label(info: NodeInfo) -> str: """ - We use networkx to store nodes and edges. + Create the label for a node given its chosen information. """ pass - @networkx.setter + @staticmethod @abstractmethod - def networkx(self, value: nx.DiGraph): + def create_edge_label(info: EdgeInfo) -> str: + """ + Create the label for an edge given its chosen information. + """ pass diff --git a/robotmbt/visualise/graphs/scenariograph.py b/robotmbt/visualise/graphs/scenariograph.py index cddc6a48..0c17ac9a 100644 --- a/robotmbt/visualise/graphs/scenariograph.py +++ b/robotmbt/visualise/graphs/scenariograph.py @@ -1,78 +1,25 @@ from robotmbt.visualise.graphs.abstractgraph import AbstractGraph -from robotmbt.visualise.models import TraceInfo, ScenarioInfo -import networkx as nx +from robotmbt.visualise.models import ScenarioInfo, StateInfo -class ScenarioGraph(AbstractGraph): +class ScenarioGraph(AbstractGraph[ScenarioInfo, None]): """ The scenario graph is the most basic representation of trace exploration. It represents scenarios as nodes, and the trace as edges. """ - def __init__(self): - # We use simplified IDs for nodes, and store the actual scenario info here - self.ids: dict[str, ScenarioInfo] = {} + @staticmethod + def select_node_info(pair: tuple[ScenarioInfo, StateInfo]) -> ScenarioInfo: + return pair[0] - # The networkx graph is a directional graph - self.networkx = nx.DiGraph() + @staticmethod + def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> None: + return None - # add the start node - self.networkx.add_node('start', label='start') - self.final_trace: list[str] = ['start'] + @staticmethod + def create_node_label(info: ScenarioInfo) -> str: + return info.name - def update_visualisation(self, info: TraceInfo): - """ - This will add nodes for all new scenarios in the provided trace, as well as edges for all pairs in the provided trace. - """ - for i in range(0, len(info.trace) - 1): - from_node = self._get_or_create_id(info.trace[i]) - to_node = self._get_or_create_id(info.trace[i + 1]) - - self._add_node(from_node) - self._add_node(to_node) - - if (from_node, to_node) not in self.networkx.edges: - self.networkx.add_edge(from_node, to_node, label='') - - if len(info.trace) > 0: - first_id = self._get_or_create_id(info.trace[0]) - - self._add_node(first_id) - - if ('start', first_id) not in self.networkx.edges: - self.networkx.add_edge('start', first_id, label='') - - def set_final_trace(self, info: TraceInfo): - self.final_trace.extend(map(lambda s: self._get_or_create_id(s), info.trace)) - - def get_final_trace(self) -> list[str]: - return self.final_trace - - def _get_or_create_id(self, scenario: ScenarioInfo) -> str: - """ - Get the ID for a scenario that has been added before, or create and store a new one. - """ - if scenario.src_id is not None: - for i in self.ids.keys(): - # TODO: decide how to deal with repeating scenarios, this merges repeated scenarios into a single scenario - if self.ids[i].src_id == scenario.src_id: - return i - - new_id = f"node{len(self.ids)}" - self.ids[new_id] = scenario - return new_id - - def _add_node(self, node: str): - """ - Add node if it doesn't already exist. - """ - if node not in self.networkx.nodes: - self.networkx.add_node(node, label=self.ids[node].name) - - @property - def networkx(self) -> nx.DiGraph: - return self._networkx - - @networkx.setter - def networkx(self, value: nx.DiGraph): - self._networkx = value + @staticmethod + def create_edge_label(info: None) -> str: + return '' diff --git a/robotmbt/visualise/graphs/scenariostategraph.py b/robotmbt/visualise/graphs/scenariostategraph.py index bf500178..442c6600 100644 --- a/robotmbt/visualise/graphs/scenariostategraph.py +++ b/robotmbt/visualise/graphs/scenariostategraph.py @@ -1,101 +1,26 @@ -import networkx as nx -from robot.api import logger - from robotmbt.visualise.graphs.abstractgraph import AbstractGraph -from robotmbt.visualise.models import TraceInfo, ScenarioInfo, StateInfo +from robotmbt.visualise.models import ScenarioInfo, StateInfo -class ScenarioStateGraph(AbstractGraph): +class ScenarioStateGraph(AbstractGraph[tuple[ScenarioInfo, StateInfo], None]): """ The scenario-State graph keeps track of both the scenarios and states encountered. Its nodes are scenarios together with the state after the scenario has run. Its edges represent steps in the trace. """ - def __init__(self): - # We use simplified IDs for nodes, and store the actual scenario info here - self.ids: dict[str, tuple[ScenarioInfo, StateInfo]] = {} - - # The networkx graph is a directional graph - self.networkx = nx.DiGraph() - - # add the start node - self.networkx.add_node('start', label='start') - - self.prev_trace_len = 0 - - # Stack to track the current execution path - self.node_stack: list[str] = ['start'] - - def update_visualisation(self, info: TraceInfo): - """ - This will add nodes the newly reached scenario/state pair, as well as an edge from the previous to - the current scenario/state pair. - """ - if self.prev_trace_len < len(info.trace): - # New state added - add to stack - push_count = len(info.trace) - self.prev_trace_len - for i in range(push_count): - node = self._get_or_create_id(info.trace[-push_count + i], info.state) - self.node_stack.append(node) - self._add_node(self.node_stack[-2]) - self._add_node(self.node_stack[-1]) - - if (self.node_stack[-2], self.node_stack[-1]) not in self.networkx.edges: - self.networkx.add_edge(self.node_stack[-2], self.node_stack[-1], label='') - - elif self.prev_trace_len > len(info.trace): - # States removed - remove from stack - pop_count = self.prev_trace_len - len(info.trace) - for _ in range(pop_count): - if len(self.node_stack) > 1: # Always keep 'start' - self.node_stack.pop() - else: - logger.warn("Tried to rollback more than was previously added to the stack!") - - self.prev_trace_len = len(info.trace) - - def set_final_trace(self, info: TraceInfo): - # We already have the final trace in state_stack, so we don't need to do anything - # But do a sanity check - if self.prev_trace_len != len(info.trace): - logger.warn("Final trace was of a different length than our stack was based on!") - - def get_final_trace(self) -> list[str]: - # The final trace is simply the state stack we've been keeping track of - return self.node_stack - - def _get_or_create_id(self, scenario: ScenarioInfo, state: StateInfo) -> str: - """ - Get the ID for a scenario that has been added before, or create and store a new one. - """ - if scenario.src_id is not None: - for i in self.ids.keys(): - if self.ids[i][0].src_id == scenario.src_id and self.ids[i][1] == state: - return i - - new_id = f"node{len(self.ids)}" - self.ids[new_id] = (scenario, state) - return new_id - @staticmethod - def _gen_label(scenario: ScenarioInfo, state: StateInfo) -> str: - """ - Creates the label for a node in a Scenario-State Graph from the scenario and state associated to it. - """ - return scenario.name + "\n\n" + str(state) + def select_node_info(pair: tuple[ScenarioInfo, StateInfo]) -> tuple[ScenarioInfo, StateInfo]: + return pair - def _add_node(self, node: str): - """ - Add node if it doesn't already exist. - """ - if node not in self.networkx.nodes: - self.networkx.add_node(node, label=self._gen_label(self.ids[node][0], self.ids[node][1])) + @staticmethod + def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> None: + return None - @property - def networkx(self) -> nx.DiGraph: - return self._networkx + @staticmethod + def create_node_label(info: tuple[ScenarioInfo, StateInfo]) -> str: + return f"{info[0].name}\n\n{str(info[1])}" - @networkx.setter - def networkx(self, value: nx.DiGraph): - self._networkx = value + @staticmethod + def create_edge_label(info: None) -> str: + return '' diff --git a/robotmbt/visualise/graphs/stategraph.py b/robotmbt/visualise/graphs/stategraph.py index e61bb32e..bc9b57b7 100644 --- a/robotmbt/visualise/graphs/stategraph.py +++ b/robotmbt/visualise/graphs/stategraph.py @@ -1,95 +1,25 @@ -from robot.api import logger - from robotmbt.visualise.graphs.abstractgraph import AbstractGraph -from robotmbt.visualise.models import TraceInfo, StateInfo -import networkx as nx +from robotmbt.visualise.models import StateInfo, ScenarioInfo -class StateGraph(AbstractGraph): +class StateGraph(AbstractGraph[StateInfo, ScenarioInfo]): """ The state graph is a more advanced representation of trace exploration, allowing you to see the internal state. It represents states as nodes, and scenarios as edges. """ - def __init__(self): - # We use simplified IDs for nodes, and store the actual state info here - self.ids: dict[str, StateInfo] = {} - - # The networkx graph is a directional graph - self.networkx = nx.DiGraph() - - # add the start node - self.networkx.add_node('start', label='start') - - # To check if we've backtracked - self.prev_trace_len = 0 - - # Stack to track the current execution path - self.node_stack: list[str] = ['start'] - - def update_visualisation(self, info: TraceInfo): - """ - This will add nodes the newly reached state (if we did not roll back), as well as an edge from the previous to - the current state labeled with the scenario that took it there. - """ - node = self._get_or_create_id(info.state) - - if self.prev_trace_len < len(info.trace): - # New state added - add to stack - push_count = len(info.trace) - self.prev_trace_len - for i in range(push_count): - self.node_stack.append(node) - self._add_node(self.node_stack[-2]) - self._add_node(self.node_stack[-1]) - - if (self.node_stack[-2], self.node_stack[-1]) not in self.networkx.edges: - self.networkx.add_edge( - self.node_stack[-2], self.node_stack[-1], label=info.trace[-push_count + i].name) - - elif self.prev_trace_len > len(info.trace): - # States removed - remove from stack - pop_count = self.prev_trace_len - len(info.trace) - for _ in range(pop_count): - if len(self.node_stack) > 1: # Always keep 'start' - self.node_stack.pop() - else: - logger.warn("Tried to rollback more than was previously added to the stack!") - - self.prev_trace_len = len(info.trace) - - def set_final_trace(self, info: TraceInfo): - # We already have the final trace in state_stack, so we don't need to do anything - # But do a sanity check - if self.prev_trace_len != len(info.trace): - logger.warn("Final trace was of a different length than our stack was based on!") - - def get_final_trace(self) -> list[str]: - # The final trace is simply the state stack we've been keeping track of - return self.node_stack - - def _get_or_create_id(self, state: StateInfo) -> str: - """ - Get the ID for a state that has been added before, or create and store a new one. - """ - for i in self.ids.keys(): - if self.ids[i] == state: - return i - - new_id = f"node{len(self.ids)}" - self.ids[new_id] = state - return new_id + @staticmethod + def select_node_info(pair: tuple[ScenarioInfo, StateInfo]) -> StateInfo: + return pair[1] - def _add_node(self, node: str): - """ - Add node if it doesn't already exist. - """ - if node not in self.networkx.nodes: - self.networkx.add_node(node, label=str(self.ids[node])) + @staticmethod + def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> ScenarioInfo: + return pair[0] - @property - def networkx(self) -> nx.DiGraph: - return self._networkx + @staticmethod + def create_node_label(info: StateInfo) -> str: + return str(info) - @networkx.setter - def networkx(self, value: nx.DiGraph): - self._networkx = value + @staticmethod + def create_edge_label(info: ScenarioInfo) -> str: + return info.name diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index f6afd14b..98c3dd4b 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -1,8 +1,9 @@ from typing import Any +from robot.api import logger + from robotmbt.modelspace import ModelSpace from robotmbt.suitedata import Scenario -from robotmbt.tracestate import TraceState class ScenarioInfo: @@ -25,6 +26,9 @@ def __init__(self, scenario: Scenario | str): def __str__(self): return f"Scenario {self.src_id}: {self.name}" + def __eq__(self, other): + return self.src_id == other.src_id + class StateInfo: """ @@ -78,43 +82,88 @@ def __str__(self): class TraceInfo: """ - This contains all information we need at any given step in trace exploration: - - trace: the strung together scenarios up until this point - - state: the model space + This keeps track of all information we need from all steps in trace exploration: + - current_trace: the trace currently being built up, a list of scenario/state pairs in order of execution + - all_traces: all valid traces encountered in trace exploration, up until the point they could not go any further + - previous_length: used to identify backtracking """ - @classmethod - def from_trace_state(cls, trace: TraceState, state: ModelSpace): - return cls([ScenarioInfo(t) for t in trace.get_trace()], StateInfo(state)) + def __init__(self): + self.current_trace: list[tuple[ScenarioInfo, StateInfo]] = [] + self.all_traces: list[list[tuple[ScenarioInfo, StateInfo]]] = [] + self.previous_length: int = 0 + self.pushed: bool = False + + def update_trace(self, scenario: ScenarioInfo | None, state: StateInfo, length: int): + if length > self.previous_length: + # New state - push + self._push(scenario, state, length - self.previous_length) + self.previous_length = length + elif length < self.previous_length: + # Backtrack - pop + self._pop(self.previous_length - length) + self.previous_length = length + + # Sanity check + if len(self.current_trace) > 0: + self._sanity_check(scenario, state, 'popping') + else: + # No change - sanity check + if len(self.current_trace) > 0: + self._sanity_check(scenario, state, 'nothing') + + def _push(self, scenario: ScenarioInfo, state: StateInfo, n: int): + if n > 1: + logger.warn(f"Pushing multiple scenario/state pairs at once to trace info ({n})! Some info might be lost!") + for _ in range(n): + self.current_trace.append((scenario, state)) + self.pushed = True + + def _pop(self, n: int): + if self.pushed: + self.all_traces.append(self.current_trace.copy()) + for _ in range(n): + self.current_trace.pop() + self.pushed = False + + def encountered_scenarios(self) -> set[ScenarioInfo]: + res = set() + + for trace in self.all_traces: + for (scenario, state) in trace: + res.add(scenario) + + return res - def __init__(self, trace: list[ScenarioInfo], state: StateInfo): - self.trace: list[ScenarioInfo] = trace - self.state = state + def encountered_states(self) -> set[StateInfo]: + res = set() + + for trace in self.all_traces: + for (scenario, state) in trace: + res.add(state) + + return res + + def encountered_scenario_state_pairs(self) -> set[tuple[ScenarioInfo, StateInfo]]: + res = set() + + for trace in self.all_traces: + for (scenario, state) in trace: + res.add((scenario, state)) + + return res def __repr__(self) -> str: - return f"TraceInfo(trace=[{[str(t) for t in self.trace]}], state={self.state})" - - def contains_scenario(self, scen_name: str) -> bool: - for scenario in self.trace: - if scenario.name == scen_name: - return True - return False - - def add_scenario(self, scen: ScenarioInfo): - """ - Used in acceptance testing - """ - self.trace.append(scen) - - def get_scenario(self, scen_name: str) -> ScenarioInfo | None: - for scenario in self.trace: - if scenario.name == scen_name: - return scenario - return None - - def insert_trace_at(self, index: int, scen_info: ScenarioInfo): - if index < 0 or index >= len(self.trace): - raise IndexError( - f"InsertTraceAt received invalid index ({index}) for length of list ({len(self.trace)})") - - self.trace.insert(index, scen_info) + return f"TraceInfo(traces=[{[f'[{[self.stringify_pair(pair) for pair in trace]}]' for trace in self.all_traces]}], current=[{[self.stringify_pair(pair) for pair in self.current_trace]}])" + + def _sanity_check(self, scen: ScenarioInfo, state: StateInfo, after: str): + (prev_scen, prev_state) = self.current_trace[-1] + if prev_scen != scen: + logger.warn( + f'TraceInfo got out of sync after {after}\nExpected scenario: {prev_scen}\nActual scenario: {scen}') + if prev_state != state: + logger.warn(f'TraceInfo got out of sync after {after}\nExpected state: {prev_state}\nActual state: {state}') + + @staticmethod + def stringify_pair(pair: tuple[ScenarioInfo, StateInfo]) -> str: + return f"Scenario={pair[0].name}, State={pair[1]}" diff --git a/robotmbt/visualise/networkvisualiser.py b/robotmbt/visualise/networkvisualiser.py index 5e4b6f71..6f76e669 100644 --- a/robotmbt/visualise/networkvisualiser.py +++ b/robotmbt/visualise/networkvisualiser.py @@ -1,4 +1,5 @@ from robotmbt.visualise.graphs.abstractgraph import AbstractGraph +from robotmbt.visualise.graphs.scenariostategraph import ScenarioStateGraph from robotmbt.visualise.graphs.stategraph import StateGraph from robotmbt.visualise.graphs.scenariostategraph import ScenarioStateGraph from bokeh.palettes import Spectral4 diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py index fd8eea53..838a2229 100644 --- a/robotmbt/visualise/visualiser.py +++ b/robotmbt/visualise/visualiser.py @@ -1,9 +1,11 @@ +from robotmbt.modelspace import ModelSpace +from robotmbt.tracestate import TraceState from robotmbt.visualise.networkvisualiser import NetworkVisualiser from robotmbt.visualise.graphs.abstractgraph import AbstractGraph from robotmbt.visualise.graphs.scenariograph import ScenarioGraph from robotmbt.visualise.graphs.stategraph import StateGraph from robotmbt.visualise.graphs.scenariostategraph import ScenarioStateGraph -from robotmbt.visualise.models import TraceInfo +from robotmbt.visualise.models import TraceInfo, StateInfo, ScenarioInfo import html @@ -21,23 +23,25 @@ def construct(cls, graph_type: str): return cls(graph_type) def __init__(self, graph_type: str): - if graph_type == 'scenario': - self.graph: AbstractGraph = ScenarioGraph() - elif graph_type == 'state': - self.graph: AbstractGraph = StateGraph() - elif graph_type == 'scenario-state': - self.graph: AbstractGraph = ScenarioStateGraph() - else: + if graph_type != 'scenario' and graph_type != 'state' and graph_type != 'scenario-state': raise ValueError(f"Unknown graph type: {graph_type}!") + self.graph_type: str = graph_type + self.trace_info: TraceInfo = TraceInfo() - def update_visualisation(self, info: TraceInfo): - self.graph.update_visualisation(info) - - def set_final_trace(self, info: TraceInfo): - self.graph.set_final_trace(info) + def update_trace(self, trace: TraceState, state: ModelSpace): + if len(trace.get_trace()) > 0: + self.trace_info.update_trace(ScenarioInfo(trace.get_trace()[-1]), StateInfo(state), len(trace.get_trace())) + else: + self.trace_info.update_trace(None, StateInfo(state), 0) def generate_visualisation(self) -> str: - html_bokeh = NetworkVisualiser(self.graph).generate_html() + if self.graph_type == 'scenario': + graph: AbstractGraph = ScenarioGraph(self.trace_info) + elif self.graph_type == 'state': + graph: AbstractGraph = StateGraph(self.trace_info) + else: + graph: AbstractGraph = ScenarioStateGraph(self.trace_info) + html_bokeh = NetworkVisualiser(graph).generate_html() return f"" + + vis = networkvisualiser.NetworkVisualiser(graph, self.suite_name) + html_bokeh = vis.generate_html() + + graph_size = networkvisualiser.NetworkVisualiser.GRAPH_SIZE_PX + + return f'' \ No newline at end of file From 51d0ec3d50786f4be3f905cfc57af089d158ad3d Mon Sep 17 00:00:00 2001 From: Jonathan <61787386+JWillegers@users.noreply.github.com> Date: Wed, 17 Dec 2025 12:02:56 +0100 Subject: [PATCH 030/131] stategraph labeling: 1 edge with multiple scenarios (#39) --- robotmbt/visualise/graphs/abstractgraph.py | 42 +++++++++++++++++----- utest/test_visualise_abstractgraph.py | 39 ++++++++++++++++++++ 2 files changed, 72 insertions(+), 9 deletions(-) create mode 100644 utest/test_visualise_abstractgraph.py diff --git a/robotmbt/visualise/graphs/abstractgraph.py b/robotmbt/visualise/graphs/abstractgraph.py index 9fef9e7e..05048206 100644 --- a/robotmbt/visualise/graphs/abstractgraph.py +++ b/robotmbt/visualise/graphs/abstractgraph.py @@ -24,28 +24,32 @@ def __init__(self, info: TraceInfo): for trace in info.all_traces: for i in range(len(trace)): if i > 0: - from_node = self._get_or_create_id(self.select_node_info(trace, i - 1)) + from_node = self._get_or_create_id( + self.select_node_info(trace, i - 1)) else: from_node = 'start' - to_node = self._get_or_create_id(self.select_node_info(trace, i)) + to_node = self._get_or_create_id( + self.select_node_info(trace, i)) self._add_node(from_node) self._add_node(to_node) - self.networkx.add_edge(from_node, to_node, - label=self.create_edge_label(self.select_edge_info(trace[i]))) + self._add_edge(from_node, to_node, + self.create_edge_label(self.select_edge_info(trace[i]))) # Set the final trace and add any missing nodes/edges self.final_trace = ['start'] for i in range(len(info.current_trace)): if i > 0: - from_node = self._get_or_create_id(self.select_node_info(info.current_trace, i - 1)) + from_node = self._get_or_create_id( + self.select_node_info(info.current_trace, i - 1)) else: from_node = 'start' - to_node = self._get_or_create_id(self.select_node_info(info.current_trace, i)) + to_node = self._get_or_create_id( + self.select_node_info(info.current_trace, i)) self.final_trace.append(to_node) self._add_node(from_node) self._add_node(to_node) - self.networkx.add_edge(from_node, to_node, - label=self.create_edge_label(self.select_edge_info(info.current_trace[i]))) + self._add_edge(from_node, to_node, + self.create_edge_label(self.select_edge_info(info.current_trace[i]))) def get_final_trace(self) -> list[str]: """ @@ -71,7 +75,27 @@ def _add_node(self, node: str): Add node if it doesn't already exist. """ if node not in self.networkx.nodes: - self.networkx.add_node(node, label=self.create_node_label(self.ids[node])) + self.networkx.add_node( + node, label=self.create_node_label(self.ids[node])) + + def _add_edge(self, from_node: str, to_node: str, label: str): + """ + Add edge if it doesn't already exist. + If edge exists, update the label information + """ + if (from_node, to_node) in self.networkx.edges: + if label == '': + return + old_label = nx.get_edge_attributes(self.networkx, 'label')[ + (from_node, to_node)] + if label in old_label.split('\n'): + return + new_label = old_label + '\n' + label + attr = {(from_node, to_node): {'label': new_label}} + nx.set_edge_attributes(self.networkx, attr) + else: + self.networkx.add_edge( + from_node, to_node, label=label) @staticmethod @abstractmethod diff --git a/utest/test_visualise_abstractgraph.py b/utest/test_visualise_abstractgraph.py new file mode 100644 index 00000000..7d966511 --- /dev/null +++ b/utest/test_visualise_abstractgraph.py @@ -0,0 +1,39 @@ +import unittest + +try: + import networkx as nx + from robotmbt.visualise.graphs.stategraph import StateGraph + from robotmbt.visualise.models import * + + VISUALISE = True +except ImportError: + VISUALISE = False + +if VISUALISE: + class TestVisualiseAbstractGraph(unittest.TestCase): + def test_abstract_graph_add_edge_labels_for_state_graph_self_loop(self): + """ + Testing the case where on an edge "scenario2" occurs twice + Without the "(rep x)" present + """ + info = TraceInfo() + + scenario1 = ScenarioInfo('scenario1') + scenario2 = ScenarioInfo('scenario2') + scenario3 = ScenarioInfo('scenario3') + + space1 = StateInfo._create_state_with_prop( + "prop", [("value", "some_value")]) + + info.update_trace(scenario1, space1, 1) + info.update_trace(scenario2, space1, 2) + info.update_trace(scenario3, space1, 3) + info.update_trace(scenario2, space1, 4) + + sg = StateGraph(info) + labels = nx.get_edge_attributes(sg.networkx, 'label') + self.assertEqual(labels[('node0', 'node0')], + 'scenario2\nscenario3') + +if __name__ == '__main__': + unittest.main() From 4e89cab71bc5eac33ffd2fb0e8cb5656e30e2e45 Mon Sep 17 00:00:00 2001 From: tychodub <93142605+tychodub@users.noreply.github.com> Date: Wed, 17 Dec 2025 12:13:56 +0100 Subject: [PATCH 031/131] Major fix reducedSDV (#40) * changed part of abstractgraph interface for delta graph development * implemented scenariodeltavaluegraph * implemented reduced-scenariodeltavaluegraph, but should still discuss how to label equivalence classes (currently just picks a representative). Fixed part of vertex info being indicated as edge info for scenariodeltavaluegraph. Trace highlighting is still broken for reducedSDV * removed redundant code in __init__ of reducedSDVgraph and added code to update final_trace to make trace highlighting work * initial test commit for SDVGraphs * documentation for SDV graphs * added TODO comment * implemented comments from Douwe (added comments and reset graph setting in suiteprocessors.py) * fixed reducedSDV graph to have a properly functioning equivalence relation --- robotmbt/visualise/graphs/reducedSDVgraph.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/robotmbt/visualise/graphs/reducedSDVgraph.py b/robotmbt/visualise/graphs/reducedSDVgraph.py index 1667392f..4568db64 100644 --- a/robotmbt/visualise/graphs/reducedSDVgraph.py +++ b/robotmbt/visualise/graphs/reducedSDVgraph.py @@ -18,8 +18,9 @@ class ReducedSDVGraph(AbstractGraph[tuple[ScenarioInfo, set[tuple[str, str]]], N def chain_equiv(self, node1, node2) -> bool: context = self.networkx if not node1 == 'start' and not node2 == 'start' and self.ids[node1][0] == self.ids[node2][0] and \ - (context.has_edge(node1, node2) or context.has_edge(node2, node1)): - return len(set(context.edges(node1)) ^ set(context.edges(node2))) <= 2 + (networkx.has_path(context, node1, node2) or networkx.has_path(context, node2, node1)): + return len(set(context.in_edges(node1)) ^ set(context.in_edges(node2))) <= 2 and \ + len(set(context.out_edges(node1)) ^ set(context.out_edges(node2))) <= 2 else: return False From f1b0c83cca3140cfa611df25fc901d4a33d52ea5 Mon Sep 17 00:00:00 2001 From: tychodub <93142605+tychodub@users.noreply.github.com> Date: Wed, 17 Dec 2025 14:10:16 +0100 Subject: [PATCH 032/131] Start node property (#41) * implemented scenariodeltavaluegraph * implemented reduced-scenariodeltavaluegraph, but should still discuss how to label equivalence classes (currently just picks a representative). Fixed part of vertex info being indicated as edge info for scenariodeltavaluegraph. Trace highlighting is still broken for reducedSDV * implemented comments from Douwe (added comments and reset graph setting in suiteprocessors.py) * fixed reducedSDV graph to have a properly functioning equivalence relation * fixed layout for reducedSDV graphs --- robotmbt/visualise/graphs/abstractgraph.py | 1 + robotmbt/visualise/graphs/reducedSDVgraph.py | 4 +++- robotmbt/visualise/networkvisualiser.py | 4 ++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/robotmbt/visualise/graphs/abstractgraph.py b/robotmbt/visualise/graphs/abstractgraph.py index 05048206..870f020d 100644 --- a/robotmbt/visualise/graphs/abstractgraph.py +++ b/robotmbt/visualise/graphs/abstractgraph.py @@ -19,6 +19,7 @@ def __init__(self, info: TraceInfo): # Add the start node self.networkx.add_node('start', label='start') + self.start_node = 'start' # Add nodes and edges for all traces for trace in info.all_traces: diff --git a/robotmbt/visualise/graphs/reducedSDVgraph.py b/robotmbt/visualise/graphs/reducedSDVgraph.py index 4568db64..eb9c6c8a 100644 --- a/robotmbt/visualise/graphs/reducedSDVgraph.py +++ b/robotmbt/visualise/graphs/reducedSDVgraph.py @@ -1,10 +1,11 @@ import networkx -from robotmbt.modelspace import ModelSpace +from robotmbt.modelspace import ModelSpace from robotmbt.visualise.graphs.abstractgraph import AbstractGraph from robotmbt.visualise.graphs.scenariodeltavaluegraph import ScenarioDeltaValueGraph from robotmbt.visualise.models import ScenarioInfo, StateInfo, TraceInfo + # TODO add tests for this graph representation class ReducedSDVGraph(AbstractGraph[tuple[ScenarioInfo, set[tuple[str, str]]], None]): """ @@ -38,6 +39,7 @@ def __init__(self, info: TraceInfo): for new_node in nodes: if current_node in new_node: self.final_trace[i] = new_node + self.start_node = frozenset(['start']) @staticmethod def select_node_info(pairs: list[tuple[ScenarioInfo, StateInfo]], index: int) \ diff --git a/robotmbt/visualise/networkvisualiser.py b/robotmbt/visualise/networkvisualiser.py index 49ed2373..2e6cabc9 100644 --- a/robotmbt/visualise/networkvisualiser.py +++ b/robotmbt/visualise/networkvisualiser.py @@ -147,7 +147,7 @@ def _add_nodes_with_labels(self): node_color = self.EXECUTED_NODE_COLOR if is_executed else self.UNEXECUTED_NODE_COLOR text_color = self.EXECUTED_TEXT_COLOR if is_executed else self.UNEXECUTED_TEXT_COLOR - if node == 'start': + if node == self.graph.start_node: # For start node (circle), calculate radius based on text width text_width, text_height = self._calculate_text_dimensions( label) @@ -440,7 +440,7 @@ def _cap_name(self, name: str) -> str: def _calculate_graph_layout(self): try: self.graph_layout = nx.bfs_layout( - self.graph.networkx, 'start', align='horizontal') + self.graph.networkx, self.graph.start_node, align='horizontal') # horizontal mirror for node in self.graph_layout: self.graph_layout[node] = (self.graph_layout[node][0], From 0288b9b8ae6553e9c21a8d822433ebf328bddcf0 Mon Sep 17 00:00:00 2001 From: Jonathan <61787386+JWillegers@users.noreply.github.com> Date: Sat, 27 Dec 2025 19:21:03 +0100 Subject: [PATCH 033/131] Export json (#44) * export and import to/from json * export option in suiteprocessors * moved import from json from visualiser.py to suiteprocessors.py * [WIP] atest for importing * remove json file * revert gitignore to make json folder fully ignored * [WIP] generate test suite * complete atest for importing/exporting * renaming file and folder of .robot file * made test run with robotmbt and generate graph * updated test suite * fix line length * fixing comments not related to atest * deleting "JSON" as it is not specific to JSON * Added docstring and made a keyword more generic * Added documentation about why the usage of mkstemp instead of temporary file --- .gitignore | 3 + atest/resources/helpers/__init__.py | 0 atest/resources/helpers/modelgenerator.py | 84 +++++++++++ atest/resources/visualisation.resource | 134 ++++++++++++++++++ .../C.2.3__export to JSON.robot | 35 +++++ pyproject.toml | 2 +- robotmbt/suiteprocessors.py | 127 +++++++++++------ robotmbt/visualise/models.py | 40 +++++- robotmbt/visualise/visualiser.py | 22 ++- 9 files changed, 395 insertions(+), 52 deletions(-) create mode 100644 atest/resources/helpers/__init__.py create mode 100644 atest/resources/helpers/modelgenerator.py create mode 100644 atest/resources/visualisation.resource create mode 100644 atest/robotMBT tests/10__visualisation_tests/C.2.3__export to JSON.robot diff --git a/.gitignore b/.gitignore index 3ab9aefe..ce6f945a 100644 --- a/.gitignore +++ b/.gitignore @@ -160,3 +160,6 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. .idea/ + +# json documents for graphs +json/ \ No newline at end of file diff --git a/atest/resources/helpers/__init__.py b/atest/resources/helpers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/atest/resources/helpers/modelgenerator.py b/atest/resources/helpers/modelgenerator.py new file mode 100644 index 00000000..83270285 --- /dev/null +++ b/atest/resources/helpers/modelgenerator.py @@ -0,0 +1,84 @@ +import jsonpickle +from robot.api.deco import keyword # type:ignore +from robotmbt.visualise.models import TraceInfo, ScenarioInfo, StateInfo +from robotmbt.visualise.visualiser import Visualiser +import os + + +class ModelGenerator: + @keyword(name='Generate Trace Information') # type:ignore + def generate_trace_information(self) -> TraceInfo: + return TraceInfo() + + @keyword(name='Current Trace Contains') # type:ignore + def current_trace_contains(self, trace_info: TraceInfo, scenario_name: str, state_str: str) -> TraceInfo: + ''' + State should be of format + "name: key=value" + ''' + scenario = ScenarioInfo(scenario_name) + s = state_str.split(': ') + key, item = s[1].split('=') + state = StateInfo._create_state_with_prop(s[0], [(key, item)]) + trace_info.update_trace(scenario, state, trace_info.previous_length+1) + + return trace_info + + @keyword(name='All Traces Contains List') # type:ignore + def all_traces_contains_list(self, trace_info: TraceInfo) -> TraceInfo: + trace_info.all_traces.append([]) + return trace_info + + @keyword(name='All Traces Contains') # type:ignore + def all_traces_contains(self, trace_info: TraceInfo, scenario_name: str, state_str: str) -> TraceInfo: + ''' + State should be of format + "name: key=value" + ''' + scenario = ScenarioInfo(scenario_name) + s = state_str.split(': ') + key, item = s[1].split('=') + state = StateInfo._create_state_with_prop(s[0], [(key, item)]) + + trace_info.all_traces[0].append((scenario, state)) + + return trace_info + + @keyword(name='Export Graph') # type:ignore + def export_to_json(self, suite:str, trace_info: TraceInfo) -> str: + return trace_info.export_graph(suite, True) + + @keyword(name='Import JSON File') # type:ignore + def import_json_file(self, filepath: str) -> TraceInfo: + with open(filepath, 'r') as f: + string = f.read() + decoded_instance = jsonpickle.decode(string) + visualiser = Visualiser('state', trace_info=decoded_instance) + return visualiser.trace_info + + @keyword(name='Check File Exists') # type:ignore + def check_file_exists(self, filepath: str) -> str: + ''' + Checks if file exists + + Returns string for .resource error message in case values are not equal + Expected != result + ''' + return 'file exists' if os.path.exists(filepath) else 'file does not exist' + + @keyword(name='Compare Trace Info') # type:ignore + def compare_trace_info(self, t1: TraceInfo, t2: TraceInfo) -> str: + ''' + Checks if current trace and all traces of t1 and t2 are equal + + Returns string for .resource error message in case values are not equal + Expected != result + ''' + succes = 'imported model equals exported model' + fail = 'imported models differs from exported model' + return succes if repr(t1) == repr(t2) else fail + + @keyword(name='Delete File') # type:ignore + def delete_json_file(self, filepath: str): + os.remove(filepath) + diff --git a/atest/resources/visualisation.resource b/atest/resources/visualisation.resource new file mode 100644 index 00000000..1e99eac1 --- /dev/null +++ b/atest/resources/visualisation.resource @@ -0,0 +1,134 @@ +*** Settings *** +Documentation Resource file for testing the visualisation of RobotMBT +Library atest.resources.helpers.modelgenerator.ModelGenerator +Library Collections + + +*** Keywords *** +test suite ${suite} + [Documentation] *model info* + ... :IN: None + ... :OUT: new suite + Set Suite Variable ${suite} + +test suite ${suite} has trace info ${trace} + [Documentation] *model info* + ... :IN: suite + ... :OUT: new trace_info + Variable Should Exist ${suite} + + ${trace_info} = Generate Trace Information + Set Suite Variable ${trace_info} + +trace info ${trace} has current trace ${current} + [Documentation] *model info* + ... :IN: trace_info + ... :OUT: trace_info.current_trace=[] + Variable Should Exist ${trace_info} + +current trace ${current_trace} has a tuple 'scenario ${scenario}, state ${state}' + [Documentation] *model info* + ... :IN: trace_info.current_trace + ... :OUT: trace_info.current_trace.append((${scenario}, ${state})) + Variable Should Exist ${trace_info} + + ${trace_info} = Current Trace Contains ${trace_info} ${scenario} ${state} + Set Suite Variable ${trace_info} + +trace info ${trace} has all traces ${all} + [Documentation] *model info* + ... :IN: trace_info + ... :OUT: trace_info.all_traces=[] + Variable Should Exist ${trace_info} + +all traces ${all} has list ${list} + [Documentation] *model info* + ... :IN: trace_info + ... :OUT: trace_info.all_traces.append([]) + Variable Should Exist ${trace_info} + + ${trace_info} = All Traces Contains List ${trace_info} + Set Suite Variable ${trace_info} + +list ${list} has a tuple 'scenario ${scenario}, state ${state}' + [Documentation] *model info* + ... :IN: trace_info.all_traces + ... :OUT: trace_info.all_traces.append((${scenario}, ${state})) + Variable Should Exist ${trace_info} + + ${trace_info} = All Traces Contains ${trace_info} ${scenario} ${state} + Set Suite Variable ${trace_info} + +test suite ${suite} contains trace info ${ti} + [Documentation] *model info* + ... :IN: suite, trace_info + ... :OUT: suite, trace_info + Variable Should Exist ${suite} + Variable Should Exist ${trace_info} + +test suite ${suite} is exported to json + [Documentation] *model info* + ... :IN: suite, trace_info + ... :OUT: new filepath + Variable Should Exist ${suite} + Variable Should Exist ${trace_info} + + ${filepath} = Export Graph ${suite} ${trace_info} + Set Suite Variable ${filepath} + +the file ${filename} exists + [Documentation] *model info* + ... :IN: suite, filepath + ... :OUT: suite, filepath + Variable Should Exist ${filepath} + + ${result} = Check File Exists ${filepath} + Should Be Equal ${result} file exists + +${filename} is imported + [Documentation] *model info* + ... :IN: suite, filepath + ... :OUT: new new_trace_info + Variable Should Exist ${filepath} + + ${new_trace_info} = Import JSON File ${filepath} + Set Suite Variable ${new_trace_info} + +trace info from ${filename} is the same as trace info ${trace} + [Documentation] *model info* + ... :IN: new_trace_info, trace_info + ... :OUT: new flag_cleanup + Variable Should Exist ${trace_info} + Variable Should Exist ${new_trace_info} + + ${result} = Compare Trace Info ${trace_info} ${new_trace_info} + Should Be Equal ${result} imported model equals exported model + +${filename} is deleted + [Documentation] *model info* + ... :IN: filepath + ... :OUT: filepath + Variable Should Exist ${filepath} + + Delete File ${filepath} + +${filename} does not exist + [Documentation] *model info* + ... :IN: filepath + ... :OUT: filepath + Variable Should Exist ${filepath} + + ${result} = Check File Exists ${filepath} + Should Be Equal ${result} file does not exist + +flag cleanup is set + [Documentation] *model info* + ... :IN: suite + ... :OUT: suite + Set Suite Variable ${flag_cleanup} True + +flag cleanup has been set + [Documentation] *model info* + ... :IN: flag_cleanup + ... :OUT: flag_cleanup + Variable Should Exist ${flag_cleanup} diff --git a/atest/robotMBT tests/10__visualisation_tests/C.2.3__export to JSON.robot b/atest/robotMBT tests/10__visualisation_tests/C.2.3__export to JSON.robot new file mode 100644 index 00000000..bf3e309a --- /dev/null +++ b/atest/robotMBT tests/10__visualisation_tests/C.2.3__export to JSON.robot @@ -0,0 +1,35 @@ +*** Settings *** +Documentation Export and import a test suite from and to JSON +... and check that the imported suite equals the +... exported suite. +Suite Setup Treat this test suite Model-based graph=scenario +Resource ../../resources/visualisation.resource +Library robotmbt + +*** Test Cases *** +Create test suite + Given test suite s + Then test suite s has trace info t + and trace info t has current trace c + and current trace c has a tuple 'scenario i, state p: v=1' + and current trace c has a tuple 'scenario j, state p: v=2' + and trace info t has all traces a + and all traces a has list l + and list l has a tuple 'scenario i, state p: v=2' + +Export test suite to json file + Given test suite s contains trace info t + When test suite s is exported to json + Then the file s.json exists + +Load json file into robotmbt + Given the file s.json exists + When s.json is imported + Then trace info from s.json is the same as trace info t + and flag cleanup is set + +Cleanup + Given the file s.json exists + and flag cleanup has been set + When s.json is deleted + Then s.json does not exist \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 505bbf4f..9af8d807 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,4 +30,4 @@ include = ["robotmbt*"] Homepage = "https://github.com/JFoederer/robotframeworkMBT" [project.optional-dependencies] -visualization = ["numpy","networkx","bokeh"] +visualization = ["numpy","networkx","bokeh","jsonpickle"] diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index 89be9dd7..73192b5d 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -88,25 +88,43 @@ def flatten(self, in_suite: Suite) -> Suite: out_suite.suites = [] return out_suite - def process_test_suite(self, in_suite: Suite, *, seed: any = 'new', graph: str = '') -> Suite: + def process_test_suite(self, in_suite: Suite, *, seed: any = 'new', graph: str = '', + to_json: bool = False, from_json: str = 'false') -> Suite: self.out_suite = Suite(in_suite.name) self.out_suite.filename = in_suite.filename self.out_suite.parent = in_suite.parent self._fail_on_step_errors(in_suite) self.flat_suite = self.flatten(in_suite) + if from_json != 'false': + self._load_graph(graph, in_suite.name, from_json) + + else: + self._run_test_suite(seed, graph, in_suite.name, to_json) + + self.__write_visualisation() + + return self.out_suite + + def _load_graph(self, graph:str, suite_name: str, from_json: str): + traceinfo = TraceInfo() + traceinfo = traceinfo.import_graph(from_json) + self.visualiser = Visualiser( + graph, suite_name, trace_info=traceinfo) + + def _run_test_suite(self, seed: any, graph: str, suite_name: str, to_json: bool): for id, scenario in enumerate(self.flat_suite.scenarios, start=1): scenario.src_id = id self.scenarios = self.flat_suite.scenarios[:] logger.debug("Use these numbers to reference scenarios from traces\n\t" + - "\n\t".join([f"{s.src_id}: {s.name}" for s in self.scenarios])) + "\n\t".join([f"{s.src_id}: {s.name}" for s in self.scenarios])) self._init_randomiser(seed) random.shuffle(self.scenarios) self.visualiser = None if graph != '' and VISUALISE: - self.visualiser = Visualiser(graph, in_suite.name) # Pass suite name + self.visualiser = Visualiser(graph, suite_name, to_json) # Pass suite name elif graph != '' and not VISUALISE: logger.warn(f'Visualisation {graph} requested, but required dependencies are not installed.' 'Install them with `pip install .[visualization]`.') @@ -124,15 +142,12 @@ def process_test_suite(self, in_suite: Suite, *, seed: any = 'new', graph: str = self.out_suite.scenarios = self.tracestate.get_trace() self._report_tracestate_wrapup() - self.__write_visualisation() - - return self.out_suite - def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool): self.tracestate = TraceState(len(self.scenarios)) self.active_model = ModelSpace() while not self.tracestate.coverage_reached(): - i_candidate = self.tracestate.next_candidate(retry=allow_duplicate_scenarios) + i_candidate = self.tracestate.next_candidate( + retry=allow_duplicate_scenarios) if i_candidate is None: if not self.tracestate.can_rewind(): break @@ -151,7 +166,8 @@ def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool): if inserted: self.DROUGHT_LIMIT = 50 if self.__last_candidate_changed_nothing(): - logger.debug("Repeated scenario did not change the model's state. Stop trying.") + logger.debug( + "Repeated scenario did not change the model's state. Stop trying.") self._rewind() elif self.tracestate.coverage_drought > self.DROUGHT_LIMIT: @@ -202,7 +218,8 @@ def _fail_on_step_errors(suite: Suite): raise Exception(err_msg) def _try_to_fit_in_scenario(self, index: int, candidate: Scenario, retry_flag: bool) -> bool: - candidate = self._generate_scenario_variant(candidate, self.active_model) + candidate = self._generate_scenario_variant( + candidate, self.active_model) if not candidate: self.active_model.end_scenario_scope() @@ -210,19 +227,23 @@ def _try_to_fit_in_scenario(self, index: int, candidate: Scenario, retry_flag: b self._report_tracestate_to_user() return False - confirmed_candidate, new_model = self._process_scenario(candidate, self.active_model) + confirmed_candidate, new_model = self._process_scenario( + candidate, self.active_model) if confirmed_candidate: self.active_model = new_model self.active_model.end_scenario_scope() - self.tracestate.confirm_full_scenario(index, confirmed_candidate, self.active_model) - logger.debug(f"Inserted scenario {confirmed_candidate.src_id}, {confirmed_candidate.name}") + self.tracestate.confirm_full_scenario( + index, confirmed_candidate, self.active_model) + logger.debug( + f"Inserted scenario {confirmed_candidate.src_id}, {confirmed_candidate.name}") self._report_tracestate_to_user() logger.debug(f"last state:\n{self.active_model.get_status_text()}") self.__update_visualisation() return True - part1, part2 = self._split_candidate_if_refinement_needed(candidate, self.active_model) + part1, part2 = self._split_candidate_if_refinement_needed( + candidate, self.active_model) if part2: exit_conditions = part2.steps[1].model_info['OUT'] part1.name = f"{part1.name} (part {self.tracestate.highest_part(index) + 1})" @@ -234,7 +255,8 @@ def _try_to_fit_in_scenario(self, index: int, candidate: Scenario, retry_flag: b i_refine = self.tracestate.next_candidate(retry=retry_flag) if i_refine is None: - logger.debug("Refinement needed, but there are no scenarios left") + logger.debug( + "Refinement needed, but there are no scenarios left") self._rewind() self._report_tracestate_to_user() self.__update_visualisation() @@ -259,14 +281,18 @@ def _try_to_fit_in_scenario(self, index: int, candidate: Scenario, retry_flag: b insert_valid_here = False if insert_valid_here: - m_finished = self._try_to_fit_in_scenario(index, part2, retry_flag) + m_finished = self._try_to_fit_in_scenario( + index, part2, retry_flag) if m_finished: self.__update_visualisation() return True else: - logger.debug(f"Scenario did not meet refinement conditions {exit_conditions}") - logger.debug(f"last state:\n{self.active_model.get_status_text()}") - logger.debug(f"Reconsidering {self.scenarios[i_refine].name}, scenario excluded") + logger.debug( + f"Scenario did not meet refinement conditions {exit_conditions}") + logger.debug( + f"last state:\n{self.active_model.get_status_text()}") + logger.debug( + f"Reconsidering {self.scenarios[i_refine].name}, scenario excluded") self._rewind() self._report_tracestate_to_user() @@ -319,7 +345,8 @@ def _split_candidate_if_refinement_needed(scenario: Scenario, model: ModelSpace) try: if m.process_expression(expr, step.args) is False: if step.gherkin_kw in ['when', None]: - logger.debug(f"Refinement needed for scenario: {scenario.name}\nat step: {step}") + logger.debug( + f"Refinement needed for scenario: {scenario.name}\nat step: {step}") refine_here = True else: return no_split @@ -328,17 +355,22 @@ def _split_candidate_if_refinement_needed(scenario: Scenario, model: ModelSpace) return no_split if refine_here: - front, back = scenario.split_at_step(scenario.steps.index(step)) + front, back = scenario.split_at_step( + scenario.steps.index(step)) remaining_steps = '\n\t'.join( [step.full_keyword, '- ' * 35] + [s.full_keyword for s in back.steps[1:]]) - remaining_steps = SuiteProcessors.escape_robot_vars(remaining_steps) - edge_step = Step('Log', f"Refinement follows for step:\n\t{remaining_steps}", parent=scenario) + remaining_steps = SuiteProcessors.escape_robot_vars( + remaining_steps) + edge_step = Step( + 'Log', f"Refinement follows for step:\n\t{remaining_steps}", parent=scenario) edge_step.gherkin_kw = step.gherkin_kw - edge_step.model_info = dict(IN=step.model_info['IN'], OUT=[]) + edge_step.model_info = dict( + IN=step.model_info['IN'], OUT=[]) edge_step.detached = True edge_step.args = StepArguments(step.args) front.steps.append(edge_step) - back.steps.insert(0, Step('Log', f"Refinement ready, completing step", parent=scenario)) + back.steps.insert( + 0, Step('Log', f"Refinement ready, completing step", parent=scenario)) back.steps[1] = back.steps[1].copy() back.steps[1].model_info['IN'] = [] return (front, back) @@ -358,7 +390,8 @@ def _process_scenario(scenario: Scenario, model: ModelSpace) -> tuple[Scenario, scenario = scenario.copy() for step in scenario.steps: if 'error' in step.model_info: - logger.debug(f"Error in scenario {scenario.name} at step {step}: {step.model_info['error']}") + logger.debug( + f"Error in scenario {scenario.name} at step {step}: {step.model_info['error']}") return None, None for expr in SuiteProcessors._relevant_expressions(step): @@ -405,7 +438,8 @@ def _generate_scenario_variant(self, scenario: Scenario, model: ModelSpace) -> S for step in scenario.steps: if 'MOD' in step.model_info: for expr in step.model_info['MOD']: - modded_arg, constraint = self._parse_modifier_expression(expr, step.args) + modded_arg, constraint = self._parse_modifier_expression( + expr, step.args) if step.args[modded_arg].is_default: continue if step.args[modded_arg].kind in [StepArgument.EMBEDDED, StepArgument.POSITIONAL, @@ -418,36 +452,46 @@ def _generate_scenario_variant(self, scenario: Scenario, model: ModelSpace) -> S subs.substitute(org_example, [org_example]) continue if not constraint and org_example not in subs.substitutions: - raise ValueError(f"No options to choose from at first assignment to {org_example}") + raise ValueError( + f"No options to choose from at first assignment to {org_example}") if constraint and constraint != '.*': - options = m.process_expression(constraint, step.args) + options = m.process_expression( + constraint, step.args) if options == 'exec': - raise ValueError(f"Invalid constraint for argument substitution: {expr}") + raise ValueError( + f"Invalid constraint for argument substitution: {expr}") if not options: - raise ValueError(f"Constraint on modifer did not yield any options: {expr}") + raise ValueError( + f"Constraint on modifer did not yield any options: {expr}") if not is_list_like(options): - raise ValueError(f"Constraint on modifer did not yield a set of options: {expr}") + raise ValueError( + f"Constraint on modifer did not yield a set of options: {expr}") else: options = None subs.substitute(org_example, options) elif step.args[modded_arg].kind == StepArgument.VAR_POS: if step.args[modded_arg].value: - modded_varargs = m.process_expression(constraint, step.args) + modded_varargs = m.process_expression( + constraint, step.args) if not is_list_like(modded_varargs): - raise ValueError(f"Modifying varargs must yield a list of arguments") + raise ValueError( + f"Modifying varargs must yield a list of arguments") # Varargs are not added to the substitution map, but are used directly as-is. A modifier can # change the number of arguments in the list, making it impossible to decide which values to # match and which to drop and/or duplicate. step.args[modded_arg].value = modded_varargs elif step.args[modded_arg].kind == StepArgument.FREE_NAMED: if step.args[modded_arg].value: - modded_free_args = m.process_expression(constraint, step.args) + modded_free_args = m.process_expression( + constraint, step.args) if not isinstance(modded_free_args, dict): - raise ValueError("Modifying free named arguments must yield a dict") + raise ValueError( + "Modifying free named arguments must yield a dict") # Similar to varargs, modified free named arguments are used directly as-is. step.args[modded_arg].value = modded_free_args else: - raise AssertionError(f"Unknown argument kind for {modded_arg}") + raise AssertionError( + f"Unknown argument kind for {modded_arg}") except Exception as err: logger.debug(f"Unable to insert scenario {scenario.src_id}, {scenario.name}, due to modifier\n" f" In step {step}: {err}") @@ -462,13 +506,15 @@ def _generate_scenario_variant(self, scenario: Scenario, model: ModelSpace) -> S # Update scenario with generated values if subs.solution: - logger.debug(f"Example variant generated with argument substitution: {subs}") + logger.debug( + f"Example variant generated with argument substitution: {subs}") scenario.data_choices = subs for step in scenario.steps: if 'MOD' in step.model_info: for expr in step.model_info['MOD']: - modded_arg, _ = self._parse_modifier_expression(expr, step.args) + modded_arg, _ = self._parse_modifier_expression( + expr, step.args) if step.args[modded_arg].is_default: continue org_example = step.args[modded_arg].org_value @@ -482,7 +528,8 @@ def _parse_modifier_expression(expression: str, args: StepArguments) -> tuple[st if expression.startswith('${'): for var in args: if expression.casefold().startswith(var.arg.casefold()): - assignment_expr = expression.replace(var.arg, '', 1).strip() + assignment_expr = expression.replace( + var.arg, '', 1).strip() if not assignment_expr.startswith('=') or assignment_expr.startswith('=='): break # not an assignment diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index 1fb075b8..457f07fa 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -5,6 +5,10 @@ from robotmbt.modelspace import ModelSpace from robotmbt.suitedata import Scenario +import jsonpickle +import tempfile +import os + class ScenarioInfo: """ @@ -53,10 +57,10 @@ def difference(self, new_state) -> set[tuple[str, str]]: right: dict[str, dict | str] = new_state.properties.copy() for key in right.keys(): right[key] = str(right[key]) - temp: set[tuple[str, str]] = set(right.items()) - set(left.items()) # type inference goes doodoo here + # type inference goes doodoo here + temp: set[tuple[str, str]] = set(right.items()) - set(left.items()) return temp - def __init__(self, state: ModelSpace): self.domain = state.ref_id @@ -104,6 +108,7 @@ def __init__(self): self.all_traces: list[list[tuple[ScenarioInfo, StateInfo]]] = [] self.previous_length: int = 0 self.pushed: bool = False + self.path = "json/" def update_trace(self, scenario: ScenarioInfo | None, state: StateInfo, length: int): if length > self.previous_length: @@ -125,7 +130,8 @@ def update_trace(self, scenario: ScenarioInfo | None, state: StateInfo, length: def _push(self, scenario: ScenarioInfo, state: StateInfo, n: int): if n > 1: - logger.warn(f"Pushing multiple scenario/state pairs at once to trace info ({n})! Some info might be lost!") + logger.warn( + f"Pushing multiple scenario/state pairs at once to trace info ({n})! Some info might be lost!") for _ in range(n): self.current_trace.append((scenario, state)) self.pushed = True @@ -173,7 +179,33 @@ def _sanity_check(self, scen: ScenarioInfo, state: StateInfo, after: str): logger.warn( f'TraceInfo got out of sync after {after}\nExpected scenario: {prev_scen}\nActual scenario: {scen}') if prev_state != state: - logger.warn(f'TraceInfo got out of sync after {after}\nExpected state: {prev_state}\nActual state: {state}') + logger.warn( + f'TraceInfo got out of sync after {after}\nExpected state: {prev_state}\nActual state: {state}') + + def export_graph(self, suite_name: str, atest: bool = False) -> str | None: + encoded_instance = jsonpickle.encode(self) + name = suite_name.lower().replace(' ', '_') + if atest: + ''' + temporary file to not accidentaly overwrite an existing file + mkstemp() is not ideal but given Python's limitations this is the easiest solution + as temporary file, a different method, is problamatic on Windows + https://stackoverflow.com/a/57015383 + ''' + fd, path = tempfile.mkstemp() + with os.fdopen(fd, "w") as f: + f.write(encoded_instance) + return path + + with open(f"{self.path}{name}.json", "w") as f: + f.write(encoded_instance) + return None + + def import_graph(self, file_name: str): + with open(f"{self.path}{file_name}.json", "r") as f: + string = f.read() + self = jsonpickle.decode(string) + @staticmethod def stringify_pair(pair: tuple[ScenarioInfo, StateInfo]) -> str: diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py index b50f086b..f5c69c0f 100644 --- a/robotmbt/visualise/visualiser.py +++ b/robotmbt/visualise/visualiser.py @@ -24,22 +24,30 @@ def construct(cls, graph_type: str): # just calls __init__, but without having underscores etc. return cls(graph_type) - def __init__(self, graph_type: str, suite_name: str = ""): + def __init__(self, graph_type: str, suite_name: str = "", export: bool = False, trace_info: TraceInfo = None): if graph_type != 'scenario' and graph_type != 'state' and graph_type != 'scenario-state' \ and graph_type != 'scenario-delta-value' and graph_type != 'reduced-sdv': raise ValueError(f"Unknown graph type: {graph_type}!") self.graph_type: str = graph_type - self.trace_info: TraceInfo = TraceInfo() + if trace_info == None: + self.trace_info: TraceInfo = TraceInfo() + else: + self.trace_info = trace_info self.suite_name = suite_name + self.export = export def update_trace(self, trace: TraceState, state: ModelSpace): if len(trace.get_trace()) > 0: - self.trace_info.update_trace(ScenarioInfo(trace.get_trace()[-1]), StateInfo(state), len(trace.get_trace())) + self.trace_info.update_trace(ScenarioInfo( + trace.get_trace()[-1]), StateInfo(state), len(trace.get_trace())) else: self.trace_info.update_trace(None, StateInfo(state), 0) def generate_visualisation(self) -> str: + if self.export: + self.trace_info.export_graph(self.suite_name) + if self.graph_type == 'scenario': graph: AbstractGraph = ScenarioGraph(self.trace_info) elif self.graph_type == 'state': @@ -50,10 +58,10 @@ def generate_visualisation(self) -> str: graph: AbstractGraph = ReducedSDVGraph(self.trace_info) else: graph: AbstractGraph = ScenarioStateGraph(self.trace_info) - + vis = networkvisualiser.NetworkVisualiser(graph, self.suite_name) html_bokeh = vis.generate_html() - + graph_size = networkvisualiser.NetworkVisualiser.GRAPH_SIZE_PX - - return f'' \ No newline at end of file + + return f'' From d1a7fb9c0dbf5be8517ade7004312f27188ca317 Mon Sep 17 00:00:00 2001 From: Thomas Kas Date: Sat, 3 Jan 2026 15:46:25 +0100 Subject: [PATCH 034/131] Sugiyama layout (#45) * Make HTML generation static * Begin visualisation rework - nodes * Move some code * Import full screen and title from Diogo * Sugiyama layout * Rescale when zooming * Draw edges * Align edge labels center * Make HTML generation static * Begin visualisation rework - nodes * Move some code * Import full screen and title from Diogo * Sugiyama layout * Rescale when zooming * Draw edges * Align edge labels center * Fix minor merge issues * Format delta value the same as state * Inline mini static method * Make HTML generation static * Begin visualisation rework - nodes * Move some code * Import full screen and title from Diogo * Sugiyama layout * Rescale when zooming * Draw edges * Align edge labels center * Make HTML generation static * Begin visualisation rework - nodes * Move some code * Import full screen and title from Diogo * Sugiyama layout * Rescale when zooming * Draw edges * Align edge labels center * Fix minor merge issues * Format delta value the same as state * Inline mini static method * Fix fails * Remove unused dependency * Remove static method * Remove static method * Connect edges at the edge of nodes * Format * Remove unused constants * They weren't unused * Now they are * Extract functions * Self-loops * Extract constants * Scale properly on full-screen * Scale arrows as well * Color edges * Documentation * Add legend * Single comment * Change xrange based on aspect ratio * Floor font size to make it stay within nodes * Simplify resize callback * Include seed under graph title * Sort items to ensure equal ordering * Make HTML generation static * Begin visualisation rework - nodes * Move some code * Import full screen and title from Diogo * Sugiyama layout * Rescale when zooming * Draw edges * Align edge labels center * Make HTML generation static * Begin visualisation rework - nodes * Move some code * Import full screen and title from Diogo * Sugiyama layout * Rescale when zooming * Draw edges * Align edge labels center * Fix minor merge issues * Format delta value the same as state * Inline mini static method * Begin visualisation rework - nodes * Fix minor merge issues * Fix fails * Remove unused dependency * Remove static method * Remove static method * Connect edges at the edge of nodes * Format * Remove unused constants * They weren't unused * Now they are * Extract functions * Self-loops * Extract constants * Scale properly on full-screen * Scale arrows as well * Color edges * Documentation * Add legend * Single comment * Change xrange based on aspect ratio * Floor font size to make it stay within nodes * Simplify resize callback * Include seed under graph title * Sort items to ensure equal ordering --- pyproject.toml | 2 +- robotmbt/suiteprocessors.py | 13 +- robotmbt/visualise/graphs/abstractgraph.py | 32 + robotmbt/visualise/graphs/reducedSDVgraph.py | 18 +- .../graphs/scenariodeltavaluegraph.py | 27 +- robotmbt/visualise/graphs/scenariograph.py | 16 + .../visualise/graphs/scenariostategraph.py | 16 + robotmbt/visualise/graphs/stategraph.py | 16 + robotmbt/visualise/models.py | 16 +- robotmbt/visualise/networkvisualiser.py | 1017 ++++++++++------- robotmbt/visualise/visualiser.py | 12 +- 11 files changed, 740 insertions(+), 445 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9af8d807..bc5c7e73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,4 +30,4 @@ include = ["robotmbt*"] Homepage = "https://github.com/JFoederer/robotframeworkMBT" [project.optional-dependencies] -visualization = ["numpy","networkx","bokeh","jsonpickle"] +visualization = ["networkx", "bokeh", "grandalf", "jsonpickle"] diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index 73192b5d..be08ee94 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -105,13 +105,13 @@ def process_test_suite(self, in_suite: Suite, *, seed: any = 'new', graph: str = self.__write_visualisation() return self.out_suite - + def _load_graph(self, graph:str, suite_name: str, from_json: str): traceinfo = TraceInfo() traceinfo = traceinfo.import_graph(from_json) self.visualiser = Visualiser( graph, suite_name, trace_info=traceinfo) - + def _run_test_suite(self, seed: any, graph: str, suite_name: str, to_json: bool): for id, scenario in enumerate(self.flat_suite.scenarios, start=1): scenario.src_id = id @@ -119,12 +119,12 @@ def _run_test_suite(self, seed: any, graph: str, suite_name: str, to_json: bool) logger.debug("Use these numbers to reference scenarios from traces\n\t" + "\n\t".join([f"{s.src_id}: {s.name}" for s in self.scenarios])) - self._init_randomiser(seed) + init_seed = self._init_randomiser(seed) random.shuffle(self.scenarios) self.visualiser = None if graph != '' and VISUALISE: - self.visualiser = Visualiser(graph, suite_name, to_json) # Pass suite name + self.visualiser = Visualiser(graph, suite_name, init_seed, to_json) # Pass suite name elif graph != '' and not VISUALISE: logger.warn(f'Visualisation {graph} requested, but required dependencies are not installed.' 'Install them with `pip install .[visualization]`.') @@ -556,20 +556,23 @@ def _report_tracestate_wrapup(self): logger.debug(f"model\n{step.model.get_status_text()}\n") @staticmethod - def _init_randomiser(seed: any): + def _init_randomiser(seed: any) -> str: if isinstance(seed, str): seed = seed.strip() if str(seed).lower() == 'none': logger.info( f"Using system's random seed for trace generation. This trace cannot be rerun. Use `seed=new` to generate a reusable seed.") + return "" elif str(seed).lower() == 'new': new_seed = SuiteProcessors._generate_seed() logger.info(f"seed={new_seed} (use seed to rerun this trace)") random.seed(new_seed) + return new_seed else: logger.info(f"seed={seed} (as provided)") random.seed(seed) + return seed @staticmethod def _generate_seed() -> str: diff --git a/robotmbt/visualise/graphs/abstractgraph.py b/robotmbt/visualise/graphs/abstractgraph.py index 870f020d..ba69a157 100644 --- a/robotmbt/visualise/graphs/abstractgraph.py +++ b/robotmbt/visualise/graphs/abstractgraph.py @@ -129,3 +129,35 @@ def create_edge_label(info: EdgeInfo) -> str: Create the label for an edge given its chosen information. """ pass + + @staticmethod + @abstractmethod + def get_legend_info_final_trace_node() -> str: + """ + Get the information to include in the legend for nodes that appear in the final trace. + """ + pass + + @staticmethod + @abstractmethod + def get_legend_info_other_node() -> str: + """ + Get the information to include in the legend for nodes that do not appear in the final trace. + """ + pass + + @staticmethod + @abstractmethod + def get_legend_info_final_trace_edge() -> str: + """ + Get the information to include in the legend for edges that appear in the final trace. + """ + pass + + @staticmethod + @abstractmethod + def get_legend_info_other_edge() -> str: + """ + Get the information to include in the legend for edges that do not appear in the final trace. + """ + pass diff --git a/robotmbt/visualise/graphs/reducedSDVgraph.py b/robotmbt/visualise/graphs/reducedSDVgraph.py index eb9c6c8a..c9ea89b3 100644 --- a/robotmbt/visualise/graphs/reducedSDVgraph.py +++ b/robotmbt/visualise/graphs/reducedSDVgraph.py @@ -55,8 +55,24 @@ def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> None: @staticmethod def create_node_label(info: tuple[ScenarioInfo, set[tuple[str, str]]]) -> str: - return f"{info[0].name}\n{ScenarioDeltaValueGraph.assignment_rep(info[1])}" + return ScenarioDeltaValueGraph.create_node_label(info) @staticmethod def create_edge_label(info: None) -> str: return '' + + @staticmethod + def get_legend_info_final_trace_node() -> str: + return "Executed Scenario w/ Changes in Execution State (in final trace)" + + @staticmethod + def get_legend_info_other_node() -> str: + return "Executed Scenario w/ Changes in Execution State (backtracked)" + + @staticmethod + def get_legend_info_final_trace_edge() -> str: + return "Execution Flow (final trace)" + + @staticmethod + def get_legend_info_other_edge() -> str: + return "Execution Flow (backtracked)" diff --git a/robotmbt/visualise/graphs/scenariodeltavaluegraph.py b/robotmbt/visualise/graphs/scenariodeltavaluegraph.py index 6681c874..6534386c 100644 --- a/robotmbt/visualise/graphs/scenariodeltavaluegraph.py +++ b/robotmbt/visualise/graphs/scenariodeltavaluegraph.py @@ -11,13 +11,6 @@ class ScenarioDeltaValueGraph(AbstractGraph[tuple[ScenarioInfo, set[tuple[str, s Its edges represent steps in the trace. """ - @staticmethod - def assignment_rep(delta: set[tuple[str, str]]) -> str: - res = "" - for assignment in delta: - res += "\n"+assignment[0]+" = "+assignment[1]+"," - return res - @staticmethod def select_node_info(pairs: list[tuple[ScenarioInfo, StateInfo]], index: int) \ -> tuple[ScenarioInfo, set[tuple[str, str]]]: @@ -32,9 +25,27 @@ def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> None: @staticmethod def create_node_label(info: tuple[ScenarioInfo, set[tuple[str, str]]]) -> str: - return f"{info[0].name}\n{ScenarioDeltaValueGraph.assignment_rep(info[1])}" + res = "" + for assignment in info[1]: + res += "\n\n"+assignment[0]+":"+assignment[1] + return f"{info[0].name}{res}" @staticmethod def create_edge_label(info: None) -> str: return '' + @staticmethod + def get_legend_info_final_trace_node() -> str: + return "Executed Scenario w/ Changes in Execution State (in final trace)" + + @staticmethod + def get_legend_info_other_node() -> str: + return "Executed Scenario w/ Changes in Execution State (backtracked)" + + @staticmethod + def get_legend_info_final_trace_edge() -> str: + return "Execution Flow (final trace)" + + @staticmethod + def get_legend_info_other_edge() -> str: + return "Execution Flow (backtracked)" diff --git a/robotmbt/visualise/graphs/scenariograph.py b/robotmbt/visualise/graphs/scenariograph.py index bafc6ee9..f36a0911 100644 --- a/robotmbt/visualise/graphs/scenariograph.py +++ b/robotmbt/visualise/graphs/scenariograph.py @@ -23,3 +23,19 @@ def create_node_label(info: ScenarioInfo) -> str: @staticmethod def create_edge_label(info: None) -> str: return '' + + @staticmethod + def get_legend_info_final_trace_node() -> str: + return "Executed Scenario (in final trace)" + + @staticmethod + def get_legend_info_other_node() -> str: + return "Executed Scenario (backtracked)" + + @staticmethod + def get_legend_info_final_trace_edge() -> str: + return "Execution Flow (final trace)" + + @staticmethod + def get_legend_info_other_edge() -> str: + return "Execution Flow (backtracked)" diff --git a/robotmbt/visualise/graphs/scenariostategraph.py b/robotmbt/visualise/graphs/scenariostategraph.py index 4a4aaedd..460a1634 100644 --- a/robotmbt/visualise/graphs/scenariostategraph.py +++ b/robotmbt/visualise/graphs/scenariostategraph.py @@ -24,3 +24,19 @@ def create_node_label(info: tuple[ScenarioInfo, StateInfo]) -> str: @staticmethod def create_edge_label(info: None) -> str: return '' + + @staticmethod + def get_legend_info_final_trace_node() -> str: + return "Executed Scenario w/ Execution State (in final trace)" + + @staticmethod + def get_legend_info_other_node() -> str: + return "Executed Scenario w/ Execution State (backtracked)" + + @staticmethod + def get_legend_info_final_trace_edge() -> str: + return "Execution Flow (final trace)" + + @staticmethod + def get_legend_info_other_edge() -> str: + return "Execution Flow (backtracked)" diff --git a/robotmbt/visualise/graphs/stategraph.py b/robotmbt/visualise/graphs/stategraph.py index a39274fc..61d4455f 100644 --- a/robotmbt/visualise/graphs/stategraph.py +++ b/robotmbt/visualise/graphs/stategraph.py @@ -23,3 +23,19 @@ def create_node_label(info: StateInfo) -> str: @staticmethod def create_edge_label(info: ScenarioInfo) -> str: return info.name + + @staticmethod + def get_legend_info_final_trace_node() -> str: + return "Execution State (in final trace)" + + @staticmethod + def get_legend_info_other_node() -> str: + return "Execution State (backtracked)" + + @staticmethod + def get_legend_info_final_trace_edge() -> str: + return "Executed Scenario (in final trace)" + + @staticmethod + def get_legend_info_other_edge() -> str: + return "Executed Scenario (backtracked)" diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index 457f07fa..a95a2272 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -53,10 +53,16 @@ def _create_state_with_prop(cls, name: str, attrs: list[tuple[str, Any]]): def difference(self, new_state) -> set[tuple[str, str]]: left: dict[str, dict | str] = self.properties.copy() for key in left.keys(): - left[key] = str(left[key]) + res = "" + for k, v in sorted(left[key].items()): + res += f"\n\t{k}={v}" + left[key] = res right: dict[str, dict | str] = new_state.properties.copy() for key in right.keys(): - right[key] = str(right[key]) + res = "" + for k, v in sorted(right[key].items()): + res += f"\n\t{k}={v}" + right[key] = res # type inference goes doodoo here temp: set[tuple[str, str]] = set(right.items()) - set(left.items()) return temp @@ -196,16 +202,16 @@ def export_graph(self, suite_name: str, atest: bool = False) -> str | None: with os.fdopen(fd, "w") as f: f.write(encoded_instance) return path - + with open(f"{self.path}{name}.json", "w") as f: f.write(encoded_instance) return None - + def import_graph(self, file_name: str): with open(f"{self.path}{file_name}.json", "r") as f: string = f.read() self = jsonpickle.decode(string) - + @staticmethod def stringify_pair(pair: tuple[ScenarioInfo, StateInfo]) -> str: diff --git a/robotmbt/visualise/networkvisualiser.py b/robotmbt/visualise/networkvisualiser.py index 2e6cabc9..4beedea5 100644 --- a/robotmbt/visualise/networkvisualiser.py +++ b/robotmbt/visualise/networkvisualiser.py @@ -1,450 +1,631 @@ -from robotmbt.visualise.graphs.abstractgraph import AbstractGraph -from robotmbt.visualise.graphs.reducedSDVgraph import ReducedSDVGraph -from robotmbt.visualise.graphs.scenariodeltavaluegraph import ScenarioDeltaValueGraph -from robotmbt.visualise.graphs.scenariostategraph import ScenarioStateGraph -from robotmbt.visualise.graphs.stategraph import StateGraph -from robotmbt.visualise.graphs.scenariostategraph import ScenarioStateGraph -from bokeh.palettes import Spectral4 -from bokeh.models import ( - Plot, Range1d, Circle, Rect, - Arrow, NormalHead, - Bezier, ColumnDataSource, ResetTool, - SaveTool, WheelZoomTool, PanTool, Text, - FullscreenTool, Title -) +from bokeh.core.enums import PlaceType, LegendLocationType +from bokeh.core.property.vectorization import value from bokeh.embed import file_html -from bokeh.resources import CDN -from math import sqrt -import networkx as nx +from bokeh.models import ColumnDataSource, Rect, Text, ResetTool, SaveTool, WheelZoomTool, PanTool, Plot, Range1d, \ + Title, FullscreenTool, CustomJS, Segment, Arrow, NormalHead, Bezier, Legend + +from grandalf.graphs import Vertex as GVertex, Edge as GEdge, Graph as GGraph +from grandalf.layouts import SugiyamaLayout + +from networkx import DiGraph + +from robotmbt.visualise.graphs.abstractgraph import AbstractGraph + +# Padding within the nodes between the borders and inner text +HORIZONTAL_PADDING_WITHIN_NODES: int = 5 +VERTICAL_PADDING_WITHIN_NODES: int = 5 + +# Colors for different parts of the graph +FINAL_TRACE_NODE_COLOR: str = '#CCCC00' +OTHER_NODE_COLOR: str = '#999989' +FINAL_TRACE_EDGE_COLOR: str = '#444422' +OTHER_EDGE_COLOR: str = '#BBBBAA' + +# Legend placement +# Alignment within graph ('center' is in the middle, 'top_right' is the top right, etc.) +LEGEND_LOCATION: LegendLocationType | tuple[float, float] = 'top_right' +# Where it appears relative to graph ('center' is within, 'below' is below, etc.) +LEGEND_PLACE: PlaceType = 'center' + +# Dimensions of the plot in the window +INNER_WINDOW_WIDTH: int = 720 +INNER_WINDOW_HEIGHT: int = 480 +OUTER_WINDOW_WIDTH: int = INNER_WINDOW_WIDTH + (280 if LEGEND_PLACE == 'left' or LEGEND_PLACE == 'right' else 30) +OUTER_WINDOW_HEIGHT: int = INNER_WINDOW_HEIGHT + (150 if LEGEND_PLACE == 'below' or LEGEND_PLACE == 'center' else 30) + +# Font sizes +MAJOR_FONT_SIZE: int = 16 +MINOR_FONT_SIZE: int = 8 + + +class Node: + """ + Contains the information we need to add a node to the graph. + """ + + def __init__(self, node_id: str, label: str, x: int, y: int, width: float, height: float): + self.node_id = node_id + self.label = label + self.x = x + self.y = y + self.width = width + self.height = height + + +class Edge: + """ + Contains the information we need to add an edge to the graph. + """ + + def __init__(self, from_node: str, to_node: str, label: str, points: list[tuple[float, float]]): + self.from_node = from_node + self.to_node = to_node + self.label = label + self.points = points + class NetworkVisualiser: """ - Generate plot with Bokeh + A container for a Bokeh graph, which can be created from any abstract graph. """ - ARROWHEAD_SIZE: int = 6 # Consistent arrowhead size - EDGE_WIDTH: float = 2.0 - EDGE_ALPHA: float = 0.7 - EDGE_COLOUR: str | tuple[int, int, int] = ( - 12, 12, 12) # 'visual studio black' - GRAPH_PADDING_PERC: int = 15 # % - # in px, needs to be equal for height and width otherwise calculations are wrong - GRAPH_SIZE_PX: int = 600 - MAX_VERTEX_NAME_LEN: int = 20 # no. of characters - - # Colors and styles for executed vs unexecuted elements - EXECUTED_NODE_COLOR = Spectral4[0] # Bright blue - UNEXECUTED_NODE_COLOR = '#D3D3D3' # Light gray - EXECUTED_TEXT_COLOR = '#C8C8C8' - UNEXECUTED_TEXT_COLOR = '#A9A9A9' # Dark gray - EXECUTED_EDGE_COLOR = (12, 12, 12) # Black - UNEXECUTED_EDGE_COLOR = '#808080' # Gray - EXECUTED_EDGE_WIDTH = 2.5 - UNEXECUTED_EDGE_WIDTH = 1.2 - EXECUTED_EDGE_ALPHA = 0.7 - UNEXECUTED_EDGE_ALPHA = 0.3 - EXECUTED_LABEL_COLOR = 'black' - UNEXECUTED_LABEL_COLOR = '#A9A9A9' - - def __init__(self, graph: AbstractGraph, suite_name: str = ""): - self.plot = None - self.graph = graph - self.suite_name = suite_name - self.node_props = {} # Store node properties for arrow calculations - self.graph_layout = {} - - # graph customisation options - self.node_radius = 1.0 - self.char_width = 0.1 - self.char_height = 0.1 - self.padding = 0.1 - - # Get executed elements for visual differentiation - final_trace = graph.get_final_trace() - self.executed_nodes = set(final_trace) - self.executed_edges = set() - for i in range(0, len(final_trace) - 1): - from_node = final_trace[i] - to_node = final_trace[i + 1] - self.executed_edges.add((from_node, to_node)) - - def generate_html(self) -> str: + def __init__(self, graph: AbstractGraph, suite_name: str, seed: str): + # Extract what we need from the graph + self.networkx: DiGraph = graph.networkx + self.final_trace = graph.get_final_trace() + self.start = graph.start_node + + # Set up a Bokeh figure + self.plot = Plot(width=OUTER_WINDOW_WIDTH, height=OUTER_WINDOW_HEIGHT) + + # Create Sugiyama layout + nodes, edges = self._create_layout() + + # Keep track of arrows in the graph for scaling + self.arrows = [] + + # Add the nodes to the graph + self._add_nodes(nodes) + + # Add the edges to the graph + self._add_edges(nodes, edges) + + # Add a legend to the graph + self._add_legend(graph) + + # Add our features to the graph (e.g. tools) + self._add_features(suite_name, seed) + + def generate_html(self): """ - Generate html file from networkx graph via Bokeh + Generate HTML for the Bokeh graph. """ - self._calculate_graph_layout() - self._initialise_plot() - self._add_nodes_with_labels() - self._add_edges() - return file_html(self.plot, CDN, "graph") + return file_html(self.plot, 'inline', "graph") - def _initialise_plot(self): + def _add_nodes(self, nodes: list[Node]): """ - Define plot with width, height, x_range, y_range and enable tools. - x_range and y_range are padded. Plot needs to be a square + Add the nodes to the graph in the form of Rect and Text glyphs. """ - padding: float = self.GRAPH_PADDING_PERC / 100 - - x_range, y_range = zip(*self.graph_layout.values()) - x_min = min(x_range) - padding * (max(x_range) - min(x_range)) - x_max = max(x_range) + padding * (max(x_range) - min(x_range)) - y_min = min(y_range) - padding * (max(y_range) - min(y_range)) - y_max = max(y_range) + padding * (max(y_range) - min(y_range)) - - # scale node radius based on range - nodes_range = max(x_max - x_min, y_max - y_min) - self.node_radius = nodes_range / 150 - self.char_width = nodes_range / 150 - self.char_height = nodes_range / 150 + # The ColumnDataSources to store our nodes and edges in Bokeh's format + node_source: ColumnDataSource = ColumnDataSource( + {'id': [], 'x': [], 'y': [], 'w': [], 'h': [], 'color': []}) + node_label_source: ColumnDataSource = ColumnDataSource( + {'id': [], 'x': [], 'y': [], 'label': []}) + + # Add all nodes to the column data sources + for node in nodes: + _add_node_to_sources(node, self.final_trace, node_source, node_label_source) + + # Add the glyphs for nodes and their labels + node_glyph = Rect(x='x', y='y', width='w', height='h', fill_color='color') + self.plot.add_glyph(node_source, node_glyph) + + node_label_glyph = Text(x='x', y='y', text='label', text_align='left', text_baseline='middle', + text_font_size=f'{MAJOR_FONT_SIZE}pt', text_font=value("Courier New")) + node_label_glyph.tags = [f"scalable_text{MAJOR_FONT_SIZE}"] + self.plot.add_glyph(node_label_source, node_label_glyph) + + def _add_edges(self, nodes: list[Node], edges: list[Edge]): + """ + Add the edges to the graph in the form of Arrow layouts and Segment, Bezier, and Text glyphs. + """ + # The ColumnDataSources to store our edges in Bokeh's format + edge_part_source: ColumnDataSource = ColumnDataSource( + {'from': [], 'to': [], 'start_x': [], 'start_y': [], 'end_x': [], 'end_y': [], 'color': []}) + edge_arrow_source: ColumnDataSource = ColumnDataSource( + {'from': [], 'to': [], 'start_x': [], 'start_y': [], 'end_x': [], 'end_y': [], 'color': []}) + edge_bezier_source: ColumnDataSource = ColumnDataSource( + {'from': [], 'to': [], 'start_x': [], 'start_y': [], 'end_x': [], 'end_y': [], 'control1_x': [], + 'control1_y': [], 'control2_x': [], 'control2_y': [], 'color': []}) + edge_label_source: ColumnDataSource = ColumnDataSource({'from': [], 'to': [], 'x': [], 'y': [], 'label': []}) + + for edge in edges: + _add_edge_to_sources(nodes, edge, self.final_trace, edge_part_source, edge_arrow_source, edge_bezier_source, + edge_label_source) + + # Add the glyphs for edges and their labels + edge_part_glyph = Segment(x0='start_x', y0='start_y', x1='end_x', y1='end_y', line_color='color') + self.plot.add_glyph(edge_part_source, edge_part_glyph) + + arrow_layout = Arrow( + end=NormalHead(size=10, fill_color='color', line_color='color'), + x_start='start_x', y_start='start_y', + x_end='end_x', y_end='end_y', line_color='color', + source=edge_arrow_source + ) + self.plot.add_layout(arrow_layout) + self.arrows.append(arrow_layout) - # create plot - x_range = Range1d(min(x_min, y_min), max(x_max, y_max)) - y_range = Range1d(min(x_min, y_min), max(x_max, y_max)) + edge_bezier_glyph = Bezier(x0='start_x', y0='start_y', x1='end_x', y1='end_y', cx0='control1_x', + cy0='control1_y', cx1='control2_x', cy1='control2_y', line_color='color') + self.plot.add_glyph(edge_bezier_source, edge_bezier_glyph) - self.plot = Plot(width=self.GRAPH_SIZE_PX, - height=self.GRAPH_SIZE_PX, - x_range=x_range, - y_range=y_range) + edge_label_glyph = Text(x='x', y='y', text='label', text_align='center', text_baseline='middle', + text_font_size=f'{MINOR_FONT_SIZE}pt', text_font=value("Courier New")) + edge_label_glyph.tags = [f"scalable_text{MINOR_FONT_SIZE}"] + self.plot.add_glyph(edge_label_source, edge_label_glyph) - # add title - self.plot.add_layout(Title(text=self.suite_name, align="center"), "above") + def _create_layout(self) -> tuple[list[Node], list[Edge]]: + """ + Create the Sugiyama layout using grandalf. + """ + # Containers to convert networkx nodes/edges to the proper format. + vertices = [] + edges = [] + flips = [] + + # Extract nodes from networkx and put them in the proper format to be used by grandalf. + start = None + for node_id in self.networkx.nodes: + v = GVertex(node_id) + w, h = _calculate_dimensions(self.networkx.nodes[node_id]['label']) + v.view = NodeView(w, h) + vertices.append(v) + if node_id == self.start: + start = v + + # Calculate which edges need to be flipped to make the graph acyclic. + flip = _flip_edges([e for e in self.networkx.edges]) + + # Extract edges from networkx and put them in the proper format to be used by grandalf. + for (from_id, to_id) in self.networkx.edges: + from_node = _find_node(vertices, from_id) + to_node = _find_node(vertices, to_id) + e = GEdge(from_node, to_node) + e.view = EdgeView() + edges.append(e) + if (from_id, to_id) in flip: + flips.append(e) + + # Feed the info to grandalf and get the layout. + g = GGraph(vertices, edges) + + sugiyama = SugiyamaLayout(g.C[0]) + sugiyama.init_all(roots=[start], inverted_edges=flips) + sugiyama.draw() + + # Extract the information we need from the nodes and edges and return them in our format. + ns = [] + for v in g.C[0].sV: + node_id = v.data + label = self.networkx.nodes[node_id]['label'] + (x, y) = v.view.xy + (w, h) = _calculate_dimensions(label) + ns.append(Node(node_id, label, x, y, w, h)) + + es = [] + for e in g.C[0].sE: + from_id = e.v[0].data + to_id = e.v[1].data + label = self.networkx.edges[(from_id, to_id)]['label'] + points = e.view.points + es.append(Edge(from_id, to_id, label, points)) + + return ns, es + + def _add_features(self, suite_name: str, seed: str): + """ + Add our features to the graph such as tools, titles, and JavaScript callbacks. + """ + if seed != "": + self.plot.add_layout(Title(text="seed=" + seed, align="center", text_color="#999999"), "above") + self.plot.add_layout(Title(text=suite_name, align="center"), "above") - # add tools + # Add the different tools self.plot.add_tools(ResetTool(), SaveTool(), WheelZoomTool(), PanTool(), FullscreenTool()) - def _calculate_text_dimensions(self, text: str) -> tuple[float, float]: - """Calculate width and height needed for text based on actual text length""" - # Calculate width based on character count - text_length = len(text) - width = (text_length * self.char_width) + (2 * self.padding) + # Specify the default range - these values represent the aspect ratio of the actual view in the window + self.plot.x_range = Range1d(-INNER_WINDOW_WIDTH / 2, INNER_WINDOW_WIDTH / 2) + self.plot.y_range = Range1d(-INNER_WINDOW_HEIGHT + (0 if seed == '' else 20), 0) + self.plot.x_range.tags = [{"initial_span": INNER_WINDOW_WIDTH}] + self.plot.y_range.tags = [{"initial_span": INNER_WINDOW_HEIGHT - (0 if seed == '' else 20)}] + + # A JS callback to scale text and arrows, and change aspect ratio. + resize_cb = CustomJS(args=dict(xr=self.plot.x_range, yr=self.plot.y_range, plot=self.plot, arrows=self.arrows), + code=f""" + // Initialize initial scale tag + if (!plot.tags || plot.tags.length === 0) {{ + plot.tags = [{{ + initial_scale: plot.inner_height / (yr.end - yr.start) + }}] + }} + + // Calculate current x and y span + const xspan = xr.end - xr.start; + const yspan = yr.end - yr.start; + + // Calculate inner aspect ratio and span aspect ratio + const inner_aspect = plot.inner_width / plot.inner_height; + const span_aspect = xspan / yspan; + + // Let span aspect ratio match inner aspect ratio if needed + if (Math.abs(inner_aspect - span_aspect) > 0.05) {{ + const xmid = xr.start + xspan / 2; + const new_xspan = yspan * inner_aspect; + xr.start = xmid - new_xspan / 2; + xr.end = xmid + new_xspan / 2; + }} + + // Calculate scale factor + const scale = (plot.inner_height / yspan) / plot.tags[0].initial_scale + + // Scale text + for (const r of plot.renderers) {{ + if (!r.glyph || !r.glyph.tags) continue + + if (r.glyph.tags.includes("scalable_text{MAJOR_FONT_SIZE}")) {{ + r.glyph.text_font_size = Math.floor({MAJOR_FONT_SIZE} * scale) + "pt" + }} + + if (r.glyph.tags.includes("scalable_text{MINOR_FONT_SIZE}")) {{ + r.glyph.text_font_size = Math.floor({MINOR_FONT_SIZE} * scale) + "pt" + }} + }} + + // Scale arrows + for (const a of arrows) {{ + if (!a.properties) continue; + if (!a.properties.end) continue; + if (!a.properties.end._value) continue; + if (!a.properties.end._value.properties) continue; + if (!a.properties.end._value.properties.size) continue; + if (!a.properties.end._value.properties.size._value) continue; + if (!a.properties.end._value.properties.size._value.value) continue; + if (a._base_end_size == null) + a._base_end_size = a.properties.end._value.properties.size._value.value; + a.properties.end._value.properties.size._value.value = a._base_end_size * scale; + a.change.emit(); + }}""") + + # Add the callback to the values that change when zooming/resizing. + self.plot.x_range.js_on_change("start", resize_cb) + self.plot.x_range.js_on_change("end", resize_cb) + self.plot.y_range.js_on_change("start", resize_cb) + self.plot.y_range.js_on_change("end", resize_cb) + self.plot.js_on_change("inner_width", resize_cb) + self.plot.js_on_change("inner_height", resize_cb) + + def _add_legend(self, graph: AbstractGraph): + """ + Adds a legend to the graph with the node/edge information from the given graph. + """ + empty_source = ColumnDataSource({'_': [0]}) - # Reduced height for more compact rectangles - height = self.char_height + self.padding + final_trace_node_glyph = Rect(x='_', y='_', width='_', height='_', fill_color=FINAL_TRACE_NODE_COLOR) + final_trace_node = self.plot.add_glyph(empty_source, final_trace_node_glyph) - return width, height + other_node_glyph = Rect(x='_', y='_', width='_', height='_', fill_color=OTHER_NODE_COLOR) + other_node = self.plot.add_glyph(empty_source, other_node_glyph) - def _add_nodes_with_labels(self): - """ - Add nodes with text labels inside them - """ - node_labels = nx.get_node_attributes(self.graph.networkx, "label") - - # Create data sources for nodes and labels - circle_data = dict(x=[], y=[], radius=[], label=[], color=[], text_color=[]) - rect_data = dict(x=[], y=[], width=[], height=[], label=[], color=[], text_color=[]) - text_data = dict(x=[], y=[], text=[], text_color=[]) - - for node in self.graph.networkx.nodes: - # Labels are always defined and cannot be lists - label = node_labels[node] - label = self._cap_name(label) - x, y = self.graph_layout[node] - - # Determine if node is executed - is_executed = node in self.executed_nodes - node_color = self.EXECUTED_NODE_COLOR if is_executed else self.UNEXECUTED_NODE_COLOR - text_color = self.EXECUTED_TEXT_COLOR if is_executed else self.UNEXECUTED_TEXT_COLOR - - if node == self.graph.start_node: - # For start node (circle), calculate radius based on text width - text_width, text_height = self._calculate_text_dimensions( - label) - # Calculate radius from text dimensions - radius = (text_width / 2.5) - - circle_data['x'].append(x) - circle_data['y'].append(y) - circle_data['radius'].append(radius) - circle_data['label'].append(label) - circle_data['color'].append(node_color) - circle_data['text_color'].append(text_color) - - # Store node properties for arrow calculations - self.node_props[node] = { - 'type': 'circle', 'x': x, 'y': y, 'radius': radius, 'label': label} - - else: - # For scenario nodes (rectangles), calculate dimensions based on text - text_width, text_height = self._calculate_text_dimensions( - label) - - rect_data['x'].append(x) - rect_data['y'].append(y) - rect_data['width'].append(text_width) - rect_data['height'].append(text_height) - rect_data['label'].append(label) - rect_data['color'].append(node_color) - rect_data['text_color'].append(text_color) - - # Store node properties for arrow calculations - self.node_props[node] = {'type': 'rect', 'x': x, 'y': y, 'width': text_width, 'height': text_height, - 'label': label} - - # Add text for all nodes - text_data['x'].append(x) - text_data['y'].append(y) - text_data['text'].append(label) - text_data['text_color'].append(text_color) - - # Add circles for start node - if circle_data['x']: - circle_source = ColumnDataSource(circle_data) - circles = Circle(x='x', y='y', radius='radius', - fill_color='color', line_color='color') - self.plot.add_glyph(circle_source, circles) - - # Add rectangles for scenario nodes - if rect_data['x']: - rect_source = ColumnDataSource(rect_data) - rectangles = Rect(x='x', y='y', width='width', height='height', - fill_color='color', line_color='color') - self.plot.add_glyph(rect_source, rectangles) - - # Add text labels for all nodes - text_source = ColumnDataSource(text_data) - text_labels = Text(x='x', y='y', text='text', - text_align='center', text_baseline='middle', - text_color='text_color', text_font_size='9pt') - self.plot.add_glyph(text_source, text_labels) - - def _get_edge_points(self, start_node, end_node): - """Calculate edge start and end points at node borders""" - start_props = self.node_props.get(start_node) - end_props = self.node_props.get(end_node) - - # Node properties should always exist - if not start_props or not end_props: - raise ValueError( - f"Node properties not found for nodes: {start_node}, {end_node}") - - # Calculate direction vector - dx = end_props['x'] - start_props['x'] - dy = end_props['y'] - start_props['y'] - distance = sqrt(dx * dx + dy * dy) - - # Self-loops are handled separately, distance should never be 0 - if distance == 0: - raise ValueError( - "Distance between different nodes should not be zero") - - # Normalize direction vector - dx /= distance - dy /= distance - - # Calculate start point at border - if start_props['type'] == 'circle': - start_x = start_props['x'] + dx * start_props['radius'] - start_y = start_props['y'] + dy * start_props['radius'] - else: - # Find where the line intersects the rectangle border - rect_width = start_props['width'] - rect_height = start_props['height'] + final_trace_edge_glyph = Segment( + x0='_', x1='_', + y0='_', y1='_', line_color=FINAL_TRACE_EDGE_COLOR + ) + final_trace_edge = self.plot.add_glyph(empty_source, final_trace_edge_glyph) - # Calculate scaling factors for x and y directions - scale_x = rect_width / (2 * abs(dx)) if dx != 0 else float('inf') - scale_y = rect_height / (2 * abs(dy)) if dy != 0 else float('inf') + other_edge_glyph = Segment( + x0='_', x1='_', + y0='_', y1='_', line_color=OTHER_EDGE_COLOR + ) + other_edge = self.plot.add_glyph(empty_source, other_edge_glyph) - # Use the smaller scale to ensure we hit the border - scale = min(scale_x, scale_y) + legend = Legend(items=[(graph.get_legend_info_final_trace_node(), [final_trace_node]), + (graph.get_legend_info_other_node(), [other_node]), + (graph.get_legend_info_final_trace_edge(), [final_trace_edge]), + (graph.get_legend_info_other_edge(), [other_edge])], + location=LEGEND_LOCATION, orientation="vertical") + self.plot.add_layout(legend, LEGEND_PLACE) - start_x = start_props['x'] + dx * scale - start_y = start_props['y'] + dy * scale - # Calculate end point at border (reverse direction) - # End nodes should never be circles for regular edges - if end_props['type'] == 'circle': - raise ValueError( - f"End node should not be a circle for regular edges: {end_node}") - else: - rect_width = end_props['width'] - rect_height = end_props['height'] +class NodeView: + """ + A view of a node in the format that grandalf expects. + """ - # Calculate scaling factors for x and y directions (reverse) - scale_x = rect_width / (2 * abs(dx)) if dx != 0 else float('inf') - scale_y = rect_height / (2 * abs(dy)) if dy != 0 else float('inf') + def __init__(self, width: float, height: float): + self.w, self.h = width, height + self.xy = (0, 0) - # Use the smaller scale to ensure we hit the border - scale = min(scale_x, scale_y) - end_x = end_props['x'] - dx * scale - end_y = end_props['y'] - dy * scale +class EdgeView: + """ + A view of an edge in the format that grandalf expects. + """ - return start_x, start_y, end_x, end_y + def __init__(self): + self.points = [] - def add_self_loop(self, node_id: str): - """ - Circular arc that starts and ends at the top side of the rectangle - Start at 1/4 width, end at 3/4 width, with a circular arc above - The arc itself ends with the arrowhead pointing into the rectangle - """ - # Get node properties directly by node ID - node_props = self.node_props.get(node_id) - - # Node properties should always exist - if node_props is None: - raise ValueError(f"Node properties not found for node: {node_id}") - - # Self-loops should only be for rectangle nodes (scenarios) - if node_props['type'] != 'rect': - raise ValueError( - f"Self-loops should only be for rectangle nodes, got: {node_props['type']}") - - x, y = node_props['x'], node_props['y'] - width = node_props['width'] - height = node_props['height'] - - # Start: 1/4 width from left, top side - start_x = x - width / 4 - start_y = y + height / 2 - - # End: 3/4 width from left, top side - end_x = x + width / 4 - end_y = y + height / 2 - - # Arc height above the rectangle - arc_height = width * 0.4 - - # Control points for a circular arc above - control1_x = x - width / 8 - control1_y = y + height / 2 + arc_height - - control2_x = x + width / 8 - control2_y = y + height / 2 + arc_height - - # Determine if edge is executed - is_executed = (node_id, node_id) in self.executed_edges - edge_color = self.EXECUTED_EDGE_COLOR if is_executed else self.UNEXECUTED_EDGE_COLOR - edge_width = self.EXECUTED_EDGE_WIDTH if is_executed else self.UNEXECUTED_EDGE_WIDTH - edge_alpha = self.EXECUTED_EDGE_ALPHA if is_executed else self.UNEXECUTED_EDGE_ALPHA - - # Create the Bezier curve (the main arc) with the same thickness as straight lines - loop = Bezier( - x0=start_x, y0=start_y, - x1=end_x, y1=end_y, - cx0=control1_x, cy0=control1_y, - cx1=control2_x, cy1=control2_y, - line_color=edge_color, - line_width=edge_width, - line_alpha=edge_alpha, - ) - self.plot.add_glyph(loop) - - # Calculate the tangent direction at the end of the Bezier curve - # For a cubic Bezier, the tangent at the end point is from the last control point to the end point - tangent_x = end_x - control2_x - tangent_y = end_y - control2_y - - # Normalize the tangent vector - tangent_length = sqrt(tangent_x ** 2 + tangent_y ** 2) - if tangent_length > 0: - tangent_x /= tangent_length - tangent_y /= tangent_length - - # Add just the arrowhead (NormalHead) at the end point, oriented along the tangent - arrowhead = NormalHead( - size=NetworkVisualiser.ARROWHEAD_SIZE, - line_color=edge_color, - fill_color=edge_color, - line_width=edge_width - ) + def setpath(self, points: list[tuple[float, float]]): + self.points = points - # Create a standalone arrowhead at the end point - # Strategy: use a very short Arrow that's essentially just the head - arrow = Arrow( - end=arrowhead, - x_start=end_x - tangent_x * 0.001, # Almost zero length line - y_start=end_y - tangent_y * 0.001, - x_end=end_x, - y_end=end_y, - line_color=edge_color, - line_width=edge_width, - line_alpha=edge_alpha - ) - self.plot.add_layout(arrow) - - # Add edge label - positioned above the arc - label_x = x - label_y = y + height / 2 + arc_height * 0.6 - - return label_x, label_y - - def _add_edges(self): - edge_labels = nx.get_edge_attributes(self.graph.networkx, "label") - - # Create data sources for edges and edge labels - edge_text_data = dict(x=[], y=[], text=[], text_color=[]) - - for edge in self.graph.networkx.edges(): - # Edge labels are always defined and cannot be lists - edge_label = edge_labels[edge] - edge_label = self._cap_name(edge_label) - - # Determine if edge is executed - is_executed = edge in self.executed_edges - edge_color = self.EXECUTED_EDGE_COLOR if is_executed else self.UNEXECUTED_EDGE_COLOR - edge_width = self.EXECUTED_EDGE_WIDTH if is_executed else self.UNEXECUTED_EDGE_WIDTH - edge_alpha = self.EXECUTED_EDGE_ALPHA if is_executed else self.UNEXECUTED_EDGE_ALPHA - label_color = self.EXECUTED_LABEL_COLOR if is_executed else self.UNEXECUTED_LABEL_COLOR - - edge_text_data['text'].append(edge_label) - edge_text_data['text_color'].append(label_color) - - if edge[0] == edge[1]: - # Self-loop handled separately - label_x, label_y = self.add_self_loop(edge[0]) - edge_text_data['x'].append(label_x) - edge_text_data['y'].append(label_y) - - else: - # Calculate edge points at node borders - start_x, start_y, end_x, end_y = self._get_edge_points( - edge[0], edge[1]) - - # Add arrow between the calculated points - arrow = Arrow( - end=NormalHead( - size=NetworkVisualiser.ARROWHEAD_SIZE, - line_color=edge_color, - fill_color=edge_color, - line_width=edge_width), - x_start=start_x, y_start=start_y, - x_end=end_x, y_end=end_y, - line_color=edge_color, - line_width=edge_width, - line_alpha=edge_alpha - ) - self.plot.add_layout(arrow) - - # Collect edge label data (position at midpoint) - edge_text_data['x'].append((start_x + end_x) / 2) - edge_text_data['y'].append((start_y + end_y) / 2) - - # Add all edge labels at once - if edge_text_data['x']: - edge_text_source = ColumnDataSource(edge_text_data) - edge_labels_glyph = Text(x='x', y='y', text='text', - text_align='center', text_baseline='middle', - text_color='text_color', text_font_size='7pt') - self.plot.add_glyph(edge_text_source, edge_labels_glyph) - - def _cap_name(self, name: str) -> str: - if len(name) < self.MAX_VERTEX_NAME_LEN or isinstance(self.graph, StateGraph) \ - or isinstance(self.graph, ScenarioStateGraph) or isinstance(self.graph, ScenarioDeltaValueGraph)\ - or isinstance(self.graph, ReducedSDVGraph): - return name - - return f"{name[:(self.MAX_VERTEX_NAME_LEN - 3)]}..." - - def _calculate_graph_layout(self): - try: - self.graph_layout = nx.bfs_layout( - self.graph.networkx, self.graph.start_node, align='horizontal') - # horizontal mirror - for node in self.graph_layout: - self.graph_layout[node] = (self.graph_layout[node][0], - -1 * self.graph_layout[node][1]) - except nx.NetworkXException: - # if planar layout cannot find a graph without crossing edges - self.graph_layout = nx.arf_layout(self.graph.networkx, seed=42) + +def _find_node(nodes: list[GVertex], node_id: str): + """ + Find a node given its id in a list of grandalf nodes. + """ + for node in nodes: + if node.data == node_id: + return node + return None + + +def _get_connection_coordinates(nodes: list[Node], node_id: str) -> list[tuple[float, float]]: + """ + Get the coordinates where edges can connect to a node given its id. + These places are the middle of the left, right, top, and bottom edge, as well as the corners of the node. + """ + start_possibilities = [] + + # Get node from list + node = None + for n in nodes: + if n.node_id == node_id: + node = n + break + + # Left + start_possibilities.append((node.x - node.width / 2, node.y)) + # Right + start_possibilities.append((node.x + node.width / 2, node.y)) + # Bottom + start_possibilities.append((node.x, node.y - node.height / 2)) + # Top + start_possibilities.append((node.x, node.y + node.height / 2)) + # Left bottom + start_possibilities.append((node.x - node.width / 2, node.y - node.height / 2)) + # Left top + start_possibilities.append((node.x - node.width / 2, node.y + node.height / 2)) + # Right bottom + start_possibilities.append((node.x + node.width / 2, node.y - node.height / 2)) + # Right top + start_possibilities.append((node.x + node.width / 2, node.y + node.height / 2)) + + return start_possibilities + + +def _minimize_distance(from_pos: list[tuple[float, float]], to_pos: list[tuple[float, float]]) -> tuple[ + float, float, float, float]: + """ + Find a pair of positions that minimizes their distance. + """ + min_dist = -1 + fx, fy, tx, ty = 0, 0, 0, 0 + + # Calculate the distance between all permutations + for fp in from_pos: + for tp in to_pos: + distance = (fp[0] - tp[0]) ** 2 + (fp[1] - tp[1]) ** 2 + if min_dist == -1 or distance < min_dist: + min_dist = distance + fx, fy, tx, ty = fp[0], fp[1], tp[0], tp[1] + + # Return the permutation with the shortest distance + return fx, fy, tx, ty + + +def _add_edge_to_sources(nodes: list[Node], edge: Edge, final_trace: list[str], edge_part_source: ColumnDataSource, + edge_arrow_source: ColumnDataSource, edge_bezier_source: ColumnDataSource, + edge_label_source: ColumnDataSource): + """ + Add an edge between two nodes to the ColumnDataSources. + Contains all logic to set their color, find their attachment points, and do self-loops properly. + """ + in_final_trace = False + for i in range(len(final_trace) - 1): + if edge.from_node == final_trace[i] and edge.to_node == final_trace[i + 1]: + in_final_trace = True + break + + if edge.from_node == edge.to_node: + _add_self_loop_to_sources(nodes, edge, in_final_trace, edge_arrow_source, edge_bezier_source, edge_label_source) + return + + start_x, start_y = 0, 0 + end_x, end_y = 0, 0 + + if isinstance(edge.from_node, frozenset): + from_id = tuple(sorted(edge.from_node)) + else: + from_id = edge.from_node + + if isinstance(edge.to_node, frozenset): + to_id = tuple(sorted(edge.to_node)) + else: + to_id = edge.to_node + + # Add edges going through the calculated points + for i in range(len(edge.points) - 1): + start_x, start_y = edge.points[i] + end_x, end_y = edge.points[i + 1] + + # Collect possibilities where the edge can start and end + if i == 0: + from_possibilities = _get_connection_coordinates(nodes, edge.from_node) + else: + from_possibilities = [(start_x, start_y)] + + if i == len(edge.points) - 2: + to_possibilities = _get_connection_coordinates(nodes, edge.to_node) + else: + to_possibilities = [(end_x, end_y)] + + # Choose connection points that minimize edge length + start_x, start_y, end_x, end_y = _minimize_distance(from_possibilities, to_possibilities) + + if i < len(edge.points) - 2: + # Middle part of edge without arrow + edge_part_source.data['from'].append(from_id) + edge_part_source.data['to'].append(to_id) + edge_part_source.data['start_x'].append(start_x) + edge_part_source.data['start_y'].append(-start_y) + edge_part_source.data['end_x'].append(end_x) + edge_part_source.data['end_y'].append(-end_y) + edge_part_source.data['color'].append(FINAL_TRACE_EDGE_COLOR if in_final_trace else OTHER_EDGE_COLOR) + else: + # End of edge with arrow + edge_arrow_source.data['from'].append(from_id) + edge_arrow_source.data['to'].append(to_id) + edge_arrow_source.data['start_x'].append(start_x) + edge_arrow_source.data['start_y'].append(-start_y) + edge_arrow_source.data['end_x'].append(end_x) + edge_arrow_source.data['end_y'].append(-end_y) + edge_arrow_source.data['color'].append(FINAL_TRACE_EDGE_COLOR if in_final_trace else OTHER_EDGE_COLOR) + + # Add the label + edge_label_source.data['from'].append(from_id) + edge_label_source.data['to'].append(to_id) + edge_label_source.data['x'].append((start_x + end_x) / 2) + edge_label_source.data['y'].append(- (start_y + end_y) / 2) + edge_label_source.data['label'].append(edge.label) + + +def _add_self_loop_to_sources(nodes: list[Node], edge: Edge, in_final_trace: bool, edge_arrow_source: ColumnDataSource, + edge_bezier_source: ColumnDataSource, edge_label_source: ColumnDataSource): + """ + Add a self-loop edge for a node to the ColumnDataSources, consisting of a Beziér curve and an arrow. + """ + connection = _get_connection_coordinates(nodes, edge.from_node) + + if isinstance(edge.from_node, frozenset): + from_id = tuple(sorted(edge.from_node)) + else: + from_id = edge.from_node + + if isinstance(edge.to_node, frozenset): + to_id = tuple(sorted(edge.to_node)) + else: + to_id = edge.to_node + + right_x, right_y = connection[1] + + # Add the Bézier curve + edge_bezier_source.data['from'].append(from_id) + edge_bezier_source.data['to'].append(to_id) + edge_bezier_source.data['start_x'].append(right_x) + edge_bezier_source.data['start_y'].append(-right_y + 5) + edge_bezier_source.data['end_x'].append(right_x) + edge_bezier_source.data['end_y'].append(-right_y - 5) + edge_bezier_source.data['control1_x'].append(right_x + 25) + edge_bezier_source.data['control1_y'].append(-right_y + 25) + edge_bezier_source.data['control2_x'].append(right_x + 25) + edge_bezier_source.data['control2_y'].append(-right_y - 25) + edge_bezier_source.data['color'].append(FINAL_TRACE_EDGE_COLOR if in_final_trace else OTHER_EDGE_COLOR) + + # Add the arrow + edge_arrow_source.data['from'].append(from_id) + edge_arrow_source.data['to'].append(to_id) + edge_arrow_source.data['start_x'].append(right_x + 0.001) + edge_arrow_source.data['start_y'].append(-right_y - 5.001) + edge_arrow_source.data['end_x'].append(right_x) + edge_arrow_source.data['end_y'].append(-right_y - 5) + edge_arrow_source.data['color'].append(FINAL_TRACE_EDGE_COLOR if in_final_trace else OTHER_EDGE_COLOR) + + # Add the label + edge_label_source.data['from'].append(from_id) + edge_label_source.data['to'].append(to_id) + edge_label_source.data['x'].append(right_x + 25) + edge_label_source.data['y'].append(-right_y) + edge_label_source.data['label'].append(edge.label) + + +def _add_node_to_sources(node: Node, final_trace: list[str], node_source: ColumnDataSource, + node_label_source: ColumnDataSource): + """ + Add a node to the ColumnDataSources. + """ + if isinstance(node.node_id, frozenset): + node_id = tuple(sorted(node.node_id)) + else: + node_id = node.node_id + + node_source.data['id'].append(node_id) + node_source.data['x'].append(node.x) + node_source.data['y'].append(-node.y) + node_source.data['w'].append(node.width) + node_source.data['h'].append(node.height) + node_source.data['color'].append( + FINAL_TRACE_NODE_COLOR if node.node_id in final_trace else OTHER_NODE_COLOR) + + node_label_source.data['id'].append(node_id) + node_label_source.data['x'].append(node.x - node.width / 2 + HORIZONTAL_PADDING_WITHIN_NODES) + node_label_source.data['y'].append(-node.y) + node_label_source.data['label'].append(node.label) + + +def _calculate_dimensions(label: str) -> tuple[float, float]: + """ + Calculate a node's dimensions based on its label and the given font size constant. + Assumes the font is Courier New. + """ + lines = label.splitlines() + width = 0 + for line in lines: + width = max(width, len(line) * (MAJOR_FONT_SIZE / 3 + 5)) + height = len(lines) * (MAJOR_FONT_SIZE / 2 + 9) * 1.37 - 9 + return width + 2 * HORIZONTAL_PADDING_WITHIN_NODES, height + 2 * VERTICAL_PADDING_WITHIN_NODES + + +def _flip_edges(edges: list[tuple[str, str]]) -> list[tuple[str, str]]: + """ + Calculate which edges need to be flipped to make a graph acyclic. + """ + # Step 1: Build adjacency list from edges + adj = {} + for u, v in edges: + if u not in adj: + adj[u] = [] + adj[u].append(v) + + # Step 2: Helper function to detect cycles + def dfs(node, visited, rec_stack, cycle_edges): + visited[node] = True + rec_stack[node] = True + + if node in adj: + for neighbor in adj[node]: + edge = (node, neighbor) + + if not visited.get(neighbor, False): + if dfs(neighbor, visited, rec_stack, cycle_edges): + cycle_edges.append(edge) + elif rec_stack.get(neighbor, False): + # Found a cycle, add the edge to the cycle_edges list + cycle_edges.append(edge) + + rec_stack[node] = False + return False + + # Step 3: Detect cycles + visited = {} + rec_stack = {} + cycle_edges = [] + + for node in adj: + if not visited.get(node, False): + dfs(node, visited, rec_stack, cycle_edges) + + # Step 4: Return the list of edges that need to be flipped + # In this case, the cycle_edges are the ones that we need to "break" by flipping + return cycle_edges diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py index f5c69c0f..e9d66913 100644 --- a/robotmbt/visualise/visualiser.py +++ b/robotmbt/visualise/visualiser.py @@ -1,8 +1,8 @@ from robotmbt.modelspace import ModelSpace from robotmbt.tracestate import TraceState +from robotmbt.visualise import networkvisualiser from robotmbt.visualise.graphs.reducedSDVgraph import ReducedSDVGraph from robotmbt.visualise.graphs.scenariodeltavaluegraph import ScenarioDeltaValueGraph -from robotmbt.visualise import networkvisualiser from robotmbt.visualise.graphs.abstractgraph import AbstractGraph from robotmbt.visualise.graphs.scenariograph import ScenarioGraph from robotmbt.visualise.graphs.stategraph import StateGraph @@ -24,7 +24,7 @@ def construct(cls, graph_type: str): # just calls __init__, but without having underscores etc. return cls(graph_type) - def __init__(self, graph_type: str, suite_name: str = "", export: bool = False, trace_info: TraceInfo = None): + def __init__(self, graph_type: str, suite_name: str = "", seed: str = "", export: bool = False, trace_info: TraceInfo = None): if graph_type != 'scenario' and graph_type != 'state' and graph_type != 'scenario-state' \ and graph_type != 'scenario-delta-value' and graph_type != 'reduced-sdv': raise ValueError(f"Unknown graph type: {graph_type}!") @@ -36,6 +36,7 @@ def __init__(self, graph_type: str, suite_name: str = "", export: bool = False, self.trace_info = trace_info self.suite_name = suite_name self.export = export + self.seed = seed def update_trace(self, trace: TraceState, state: ModelSpace): if len(trace.get_trace()) > 0: @@ -59,9 +60,6 @@ def generate_visualisation(self) -> str: else: graph: AbstractGraph = ScenarioStateGraph(self.trace_info) - vis = networkvisualiser.NetworkVisualiser(graph, self.suite_name) - html_bokeh = vis.generate_html() - - graph_size = networkvisualiser.NetworkVisualiser.GRAPH_SIZE_PX + html_bokeh = networkvisualiser.NetworkVisualiser(graph, self.suite_name, self.seed).generate_html() - return f'' + return f'' \ No newline at end of file From bc568c663e87716d3630c647cab88e67a25f9eaa Mon Sep 17 00:00:00 2001 From: tychodub <93142605+tychodub@users.noreply.github.com> Date: Mon, 5 Jan 2026 13:40:48 +0100 Subject: [PATCH 035/131] Delta value+improved delta (#46) * implemented scenariodeltavaluegraph * implemented reduced-scenariodeltavaluegraph, but should still discuss how to label equivalence classes (currently just picks a representative). Fixed part of vertex info being indicated as edge info for scenariodeltavaluegraph. Trace highlighting is still broken for reducedSDV * implemented comments from Douwe (added comments and reset graph setting in suiteprocessors.py) * fixed reducedSDV graph to have a properly functioning equivalence relation * added DeltaValueGraph and improved difference method so that nested unchanged info gets removed as well (as was always intended but not yet implemented) * forgot to reset the graph parameter in process_test_suite from testing * fixed difference sometimes giving an empty namespace because of incorrect handling of nested namespaces when they are the same as in the previous state --- robotmbt/visualise/graphs/deltavaluegraph.py | 48 ++++++++++++++++++++ robotmbt/visualise/models.py | 36 +++++++++------ robotmbt/visualise/visualiser.py | 6 ++- 3 files changed, 76 insertions(+), 14 deletions(-) create mode 100644 robotmbt/visualise/graphs/deltavaluegraph.py diff --git a/robotmbt/visualise/graphs/deltavaluegraph.py b/robotmbt/visualise/graphs/deltavaluegraph.py new file mode 100644 index 00000000..7a77aa7a --- /dev/null +++ b/robotmbt/visualise/graphs/deltavaluegraph.py @@ -0,0 +1,48 @@ +from robotmbt.visualise.graphs.abstractgraph import AbstractGraph +from robotmbt.visualise.models import StateInfo, ScenarioInfo +from robotmbt.modelspace import ModelSpace + + +class DeltaValueGraph(AbstractGraph[set[tuple[str, str]], ScenarioInfo]): + """ + The state graph is a more advanced representation of trace exploration, allowing you to see the internal state. + It represents states as nodes, and scenarios as edges. + """ + + @staticmethod + def select_node_info(pairs: list[tuple[ScenarioInfo, StateInfo]], index: int) -> set[tuple[str, str]]: + if index == 0: + return StateInfo(ModelSpace()).difference(pairs[0][1]) + else: + return pairs[index-1][1].difference(pairs[index][1]) + + @staticmethod + def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> ScenarioInfo: + return pair[0] + + @staticmethod + def create_node_label(info: set[tuple[str, str]]) -> str: + res = "" + for assignment in info: + res += "\n"+assignment[0]+":"+assignment[1] + return f"{res}" + + @staticmethod + def create_edge_label(info: ScenarioInfo) -> str: + return info.name + + @staticmethod + def get_legend_info_final_trace_node() -> str: + return "Execution State Update (in final trace)" + + @staticmethod + def get_legend_info_other_node() -> str: + return "Execution State Update (backtracked)" + + @staticmethod + def get_legend_info_final_trace_edge() -> str: + return "Executed Scenario (in final trace)" + + @staticmethod + def get_legend_info_other_edge() -> str: + return "Executed Scenario (backtracked)" diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index a95a2272..c533dc76 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -1,3 +1,4 @@ +import logging from typing import Any from robot.api import logger @@ -51,21 +52,30 @@ def _create_state_with_prop(cls, name: str, attrs: list[tuple[str, Any]]): return cls(space) def difference(self, new_state) -> set[tuple[str, str]]: - left: dict[str, dict | str] = self.properties.copy() - for key in left.keys(): + old: dict[str, dict | str] = self.properties.copy() + new: dict[str, dict | str] = new_state.properties.copy() + temp = StateInfo._dict_deep_diff(old, new) + for key in temp.keys(): res = "" - for k, v in sorted(left[key].items()): + for k, v in sorted(temp[key].items()): res += f"\n\t{k}={v}" - left[key] = res - right: dict[str, dict | str] = new_state.properties.copy() - for key in right.keys(): - res = "" - for k, v in sorted(right[key].items()): - res += f"\n\t{k}={v}" - right[key] = res - # type inference goes doodoo here - temp: set[tuple[str, str]] = set(right.items()) - set(left.items()) - return temp + temp[key] = res + return set(temp.items()) # type inference goes wacky here + + @staticmethod + def _dict_deep_diff(old_state: dict[str, any], new_state: dict[str, any]) -> dict[str, any]: + res = {} + for key in new_state.keys(): + if key not in old_state: + res[key] = new_state[key] + elif isinstance(old_state[key], dict): + diff = StateInfo._dict_deep_diff(old_state[key], new_state[key]) + if len(diff) != 0: + res[key] = diff + elif old_state[key] != new_state[key]: + res[key] = new_state[key] + return res + def __init__(self, state: ModelSpace): self.domain = state.ref_id diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py index e9d66913..faa9a2b9 100644 --- a/robotmbt/visualise/visualiser.py +++ b/robotmbt/visualise/visualiser.py @@ -1,6 +1,7 @@ from robotmbt.modelspace import ModelSpace from robotmbt.tracestate import TraceState from robotmbt.visualise import networkvisualiser +from robotmbt.visualise.graphs.deltavaluegraph import DeltaValueGraph from robotmbt.visualise.graphs.reducedSDVgraph import ReducedSDVGraph from robotmbt.visualise.graphs.scenariodeltavaluegraph import ScenarioDeltaValueGraph from robotmbt.visualise.graphs.abstractgraph import AbstractGraph @@ -26,7 +27,8 @@ def construct(cls, graph_type: str): def __init__(self, graph_type: str, suite_name: str = "", seed: str = "", export: bool = False, trace_info: TraceInfo = None): if graph_type != 'scenario' and graph_type != 'state' and graph_type != 'scenario-state' \ - and graph_type != 'scenario-delta-value' and graph_type != 'reduced-sdv': + and graph_type != 'scenario-delta-value' and graph_type != 'reduced-sdv' \ + and graph_type != 'delta-value': raise ValueError(f"Unknown graph type: {graph_type}!") self.graph_type: str = graph_type @@ -57,6 +59,8 @@ def generate_visualisation(self) -> str: graph: AbstractGraph = ScenarioDeltaValueGraph(self.trace_info) elif self.graph_type == 'reduced-sdv': graph: AbstractGraph = ReducedSDVGraph(self.trace_info) + elif self.graph_type == 'delta-value': + graph: AbstractGraph = DeltaValueGraph(self.trace_info) else: graph: AbstractGraph = ScenarioStateGraph(self.trace_info) From cb1fdc9f8dfe89cf9284e988ce9f2d8437526b93 Mon Sep 17 00:00:00 2001 From: tychodub <93142605+tychodub@users.noreply.github.com> Date: Wed, 7 Jan 2026 11:20:25 +0100 Subject: [PATCH 036/131] Reduced sdv label improvement (#47) * implemented scenariodeltavaluegraph * implemented reduced-scenariodeltavaluegraph, but should still discuss how to label equivalence classes (currently just picks a representative). Fixed part of vertex info being indicated as edge info for scenariodeltavaluegraph. Trace highlighting is still broken for reducedSDV * implemented comments from Douwe (added comments and reset graph setting in suiteprocessors.py) * fixed reducedSDV graph to have a properly functioning equivalence relation * added merged annotation to labels generated by reducedSDV for merged nodes --- robotmbt/visualise/graphs/reducedSDVgraph.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/robotmbt/visualise/graphs/reducedSDVgraph.py b/robotmbt/visualise/graphs/reducedSDVgraph.py index c9ea89b3..b7147bef 100644 --- a/robotmbt/visualise/graphs/reducedSDVgraph.py +++ b/robotmbt/visualise/graphs/reducedSDVgraph.py @@ -25,12 +25,19 @@ def chain_equiv(self, node1, node2) -> bool: else: return False + @staticmethod + def _generate_equiv_class_label(equiv_class, old_labels): + if len(equiv_class) == 1: + return old_labels[set(equiv_class).pop()] + else: + return "(merged: "+str(len(equiv_class))+")\n"+old_labels[set(equiv_class).pop()] + def __init__(self, info: TraceInfo): super().__init__(info) old_labels = networkx.get_node_attributes(self.networkx, "label") self.networkx = networkx.quotient_graph(self.networkx, lambda x, y: self.chain_equiv(x, y), node_data=lambda equiv_class: { - 'label': old_labels[set(equiv_class).pop()]}, + 'label': self._generate_equiv_class_label(equiv_class, old_labels)}, edge_data=lambda x, y: {'label': ''}) # TODO make generated label more obvious to be equivalence class nodes = self.networkx.nodes From 9161926a6e2d8828484400d934b573a4ede27791 Mon Sep 17 00:00:00 2001 From: Thomas Kas Date: Thu, 8 Jan 2026 14:14:19 +0100 Subject: [PATCH 037/131] Sync fork (#48) * handle optional argument's default values * optional arguments stay hidden when not mentioned * fix argument text for POSITIONAL_OR_NAMED cases Now keeps the notation as used in the scenario (with or without the argument's name) in the generated keyword arguments. * fix failure on empty varargs * workaround for embedded argument name mismatch Accept that names of embedded arguments in the @keyword decorator must match their Python argument counterparts. * support step modifiers for non-embedded arguments * handling vararg modifiers * add tests for step modifier with free named arguments * add error message for vararg and free named arg modifiers * docu update for step modifiers on non-embedded arguments * promote random seed logging to info level Logging the seed at info level allows to run without debug logging for smaller log files, without losing the ability to rerun an interesting trace. * promote random seed logging to info level * make tests independent * Remove embedded-arguments-only restriction for modifiers * bump version to 0.10 * New tests for keeping direct setting's effects local * keep direct model options local * How to use configuration options * added cicd * update keyword documentation * added error propagation to run_tests.py * removed commented out comment in run-tests.yml CI/CD, fixed 'pip install' * apply pep8 Python formatting at max 120 char/line * added cicd * added error propagation to run_tests.py * removed commented out comment in run-tests.yml CI/CD, fixed 'pip install' * format with autopep8 (default line length) * reformat with new max line length setting * Use consistent casing in naming RobotMBT * Add contribution guidelines * add workflow to check demo * added exit code propagation to demo * install local requirements.txt for demo * refactor comments and yaml syntax * adjusted contributing (style, formatting, sentence structure * Text clarifications CONTRIBUTING.md * fix md linting issues * access TraceSnapShot model as copy * use exit code to pass/fail autopep8 * test: intentional pep8 violation * undo pep8 violation * reuse scenario indexes for TraceState * keep original scenario list unshuffled * refactor processing to reach full coverage Open issue: rewind under refinement * delegate refinement stack to TraceState * more tests for refinement and rewinds * new test for data consistency in split-up scenarios * move scenario processing to its own file * remove redundant return value (model) * restore logging tweak * Typehints + formatting (#4) * added type hints to modelspace.py, steparguments.py, substitutionmap.py, version.py and __init__.py, a little bit of suitedata.py and to tracestate.py. Reordered classes in tracestate.py for this purpose. * added nearly all remaining type hints, including missing "Self" types from files typed in earlier commits. Not everything in suitereplacer.py is typed because of severe ambiguity issues * reformatted according to PEP8 (using in VSCode) --------- Co-authored-by: tychodub * fixed typing of get_trace in tracestate.py (#5) * Jetbrains gitignore (#7) * Create python-app.yml, for Github actions * Create python-app.yml, for Github actions Create python-app.yml, runs utests when pushing/merging pull-request into main * test run actual python file * use python latest version * use python version 3.13 * fix comment * actually return exit code that robot returns --------- Co-authored-by: Douwe Osinga * Visual Paradigm Architectural Designs (#8) * added ignore for VPP * added initial VPP * vpp * modified gitignore * added new diagram - extension plan * added initial renders * .vpp commit * added improved event-based design * changed design to accomodate different graph repr. better * vpp * Visualisation architecture (#9) * Implement basic scenario graph architecture (without actual visualisation) * Also output graph if coverage could not be reached * from visualiser.graph to networkx graph * added optional dependencies for visualization feature * rework scenariograph to use networkx * Update python-app.yml to install optional dependencies * moved documentation according to python * remove static variables --------- Co-authored-by: Thomas Co-authored-by: jonathan <148167.jw@gmail.com> Co-authored-by: tychodub Co-authored-by: Jonathan <61787386+JWillegers@users.noreply.github.com> * Restructure + disable check on main (#10) * Seperated ScenarioInfo, TraceInfo, and ScnearioGraph into seperate file according to architecture * Don't run automated testing on main * fixing imports * Rollback self typing branch (#11) * commented Self typing annotations * changed Github actions python-version to 3.10 * change from spring layout to planar layout --------- Co-authored-by: jonathan <148167.jw@gmail.com> * Documentation (+gitignore) for setting up virtual environment. (#13) * add initial readme adjustments for pipenv * ignore pyenv pipfile * updated README - windows specific stuff and overall improvement * Generate html with bokeh (#15) * add bokeh as optional dependency * remove dependency on scipy * bokeh graph with arrows and a start on selfloops * add labels to vertices * restructure according to architecture * fixed self-loops; fixed arrowheads not being at boundary of vertex * embedded plot in html * added extra documentation * fixed edge_case where there is only 1 scenario * consistently use "node" in the code * remove draw_from_networkx and draw nodes in a for-loop * moved width/height/edge styling/... to constants * fixed zooming with tools * adding future support for having labels at edges * reorder imports, prepend private methods with underscore * fix requested changes (code comments) * test class traceInfo --------- Co-authored-by: Douwe Osinga * Unit test for models.py (#17) * removed self.fixed from ScenarioGraph added self.end_node in ScenarioGraph * test cases for most methods in models.py (missing: set_ending_node and calculate_pos) * added unit test for scenariograph.set_end_node() * Acceptance testing (#18) * initial acceptance test PoC * added traceInfo generator, adjusted some constructors to work with Robot testing, first working PoC of Atest * found first bug with Acceptance Test * fix unit tests * refactored helper, removed unnecessary logging * added return type for * deleted useless testing file * inlined suite setup * reduced type of state to ModelSpace, fixed incorrect type hint * added warning for future state implementations * changed utest to reflect comments from PR pull #8 (acceptance testing) - TODO add model state Thomas/Tycho * fixed constructor TraceInfo to require ModelSpace * Revert accidental change --------- Co-authored-by: jonathan <148167.jw@gmail.com> Co-authored-by: Thomas * Acceptance test - Vertex and Edge rules (#19) * initial acceptance test PoC * added traceInfo generator, adjusted some constructors to work with Robot testing, first working PoC of Atest * refactored helper, removed unnecessary logging * added edge representation acceptance test * Add subfolders to toml (#20) * Node redesign (#16) * Refactor node rendering to use rectangles for scenarios * Improve edge connection points and arrow positioning * Redesign self-loops for rectangle nodes * Fix self-loop arrow alignment and improve visual consistency * Fix visualization issues according to reviwer's feedback * magic fix from Jonathan * Simplify self-loop logic and remove invalid edge cases * redo part of Jonathan fix - makes ERROR:bokeh.core.validation.check:E-1001 (BAD_COLUMN_NAME) disappear * remove duplicate code from edge calculation --------- Co-authored-by: Diogo Silva * State graphs + graph switching + dependency checks (#21) * Update using final trace info internally * Add StateInfo abstraction * Implement StateGraph * cleaned up StateInfo.__init__ a little * Merge with main * Fixed oopsie * Implement abstract base class for graphs * Implement per-suite graph choice * Only generate graph if dependencies are installed * Don't run our tests without dependencies * Use empty string instead of None * doodoo * Merge and fix some issues * Run formatter * Fixed doc * Implement requested changes --------- Co-authored-by: tychodub * Restructure (#22) * Seperate network_visualiser from visualiser * - renamed pos to graph_layout - moved graph_layout from models to network_visualiser * refactor file name to not contain underscore * taking the graphs classes out of models.py * split unit tests * added test for scenariograph.set_final_trace * add arguments to atests * correct orientation for bfs * Updated design files according to implementation (#23) * fix line indent (#25) * Scenario state (#26) * implemented scenario-state (state after scenario has run variant) graph representation * removed hardcoded starting node id by keeping track of starting node info * added unit tests for scenariostategraph.py and some supporting code for the unit tests * renamed unit tests because I forgot initially * implemented suggestions by Jonathan (moved function into scenariostategraph as static method, de-indented 'if main' in test_visualise_scenariostategraph.py and moved is not None check outside for-loop) * removed end_node from scenariostategraph.py * moved is not None check outside of loop in scenariograph.py * Path Highlighting (#27) * Implement Feature Path Highlighting in Scenario and State Graphs * Implement State Graph Path highlighting with Stack * Clean-up of unused features, move some logic, fix empty state entry bug * Formatting * Formatting * Remove unused return value * Merge with main, fix some bugs, alter ScenarioStateGraph to be in line with the rest and fix up some of its unit tests * Add short explanation --------- Co-authored-by: Thomas * Unit tests and bug fixes (#28) * Implement Feature Path Highlighting in Scenario and State Graphs * Implement State Graph Path highlighting with Stack * Clean-up of unused features, move some logic, fix empty state entry bug * Formatting * Formatting * Remove unused return value * Fix bug * StateInfo unit tests * StateGraph unit tests * Merge with main, fix some bugs, alter ScenarioStateGraph to be in line with the rest and fix up some of its unit tests * Add short explanation * Test self-loops * Minor tweaks * Remove trailing newline * Fix nuking of stack * Better formatting of state info * Spam __update_visualisation all over the place for refinement * Fix nuking in scenario-state graph * Warn instead of crashing * Sanity check * Do add edge with start node * Expand tests * Merge mistake * Fix oopsie * Move helper to StateInfo * Protected helper * Forgot this one * added graph getter for resources --------- Co-authored-by: Diogo Silva Co-authored-by: Douwe Osinga * Sync fork (#30) * handle optional argument's default values * optional arguments stay hidden when not mentioned * fix argument text for POSITIONAL_OR_NAMED cases Now keeps the notation as used in the scenario (with or without the argument's name) in the generated keyword arguments. * fix failure on empty varargs * workaround for embedded argument name mismatch Accept that names of embedded arguments in the @keyword decorator must match their Python argument counterparts. * support step modifiers for non-embedded arguments * handling vararg modifiers * add tests for step modifier with free named arguments * add error message for vararg and free named arg modifiers * docu update for step modifiers on non-embedded arguments * promote random seed logging to info level Logging the seed at info level allows to run without debug logging for smaller log files, without losing the ability to rerun an interesting trace. * promote random seed logging to info level * make tests independent * Remove embedded-arguments-only restriction for modifiers * bump version to 0.10 * Implement Feature Path Highlighting in Scenario and State Graphs * Implement State Graph Path highlighting with Stack * Clean-up of unused features, move some logic, fix empty state entry bug * Formatting * Formatting * Remove unused return value * Fix bug * StateInfo unit tests * StateGraph unit tests * Merge with main, fix some bugs, alter ScenarioStateGraph to be in line with the rest and fix up some of its unit tests * Add short explanation * Test self-loops * Minor tweaks * Remove trailing newline * Fix nuking of stack * Better formatting of state info * Spam __update_visualisation all over the place for refinement * Fix nuking in scenario-state graph * Warn instead of crashing * Sanity check * Do add edge with start node * Expand tests * Merge mistake * Fix oopsie * Format and fix type * Move helper to StateInfo * Protected helper * Forgot this one * Fix nuked graphs * Implement feedback --------- Co-authored-by: JFoederer <32476108+JFoederer@users.noreply.github.com> Co-authored-by: Diogo Silva * Rework trace info and fix bugs (#32) * Clean-up of unused features, move some logic, fix empty state entry bug * Merge with main, fix some bugs, alter ScenarioStateGraph to be in line with the rest and fix up some of its unit tests * Merge mistake * Start of rework * Rework TraceInfo, extract common graph logic * Fix unit tests * Bug fixes and minor changes * Fix nuked graphs * Remove outdate acceptance tests * Generics * Proper type * Fix bug in Johan's code * More sanity, implement feedback * Design improvements (#33) * updated design with rework from Thomas * refactored design - Thomas comments * removed pdf version of 2025-12-08 render * refactored design - Thomas comments * add 'graph_type' to 'process_test_suite' * AutoPEP8 check on PRs (#29) * added initial autopep8 CI/CD * different autopep8 now, lets see... * new autopep8 that actually should work * now one that also adds a commit that fixes it * fix try 2 * revert to just check * remove redundant stuff * added max-line-length 120 to autopep8 check * formatted code with autopep8 (max-line-length 120) * made formatting issues check also pass for purely whitespace output * Scenario Delta Value + Reduced Scenario Delta Value graph (#38) * changed part of abstractgraph interface for delta graph development * implemented scenariodeltavaluegraph * implemented reduced-scenariodeltavaluegraph, but should still discuss how to label equivalence classes (currently just picks a representative). Fixed part of vertex info being indicated as edge info for scenariodeltavaluegraph. Trace highlighting is still broken for reducedSDV * removed redundant code in __init__ of reducedSDVgraph and added code to update final_trace to make trace highlighting work * initial test commit for SDVGraphs * documentation for SDV graphs * added TODO comment * implemented comments from Douwe (added comments and reset graph setting in suiteprocessors.py) --------- Co-authored-by: tychodub * Full Screen + Graph Title (#37) * Make HTML generation static * Added a fullscreen button in the graph visualization toolbar * Added the suite name as the graph title * Fixed generate_html method to use NetworkVisualiser class * Changed generate_visualisation return to use GRAPH_SIZE_PX constant --------- Co-authored-by: Thomas Kas * stategraph labeling: 1 edge with multiple scenarios (#39) * Major fix reducedSDV (#40) * changed part of abstractgraph interface for delta graph development * implemented scenariodeltavaluegraph * implemented reduced-scenariodeltavaluegraph, but should still discuss how to label equivalence classes (currently just picks a representative). Fixed part of vertex info being indicated as edge info for scenariodeltavaluegraph. Trace highlighting is still broken for reducedSDV * removed redundant code in __init__ of reducedSDVgraph and added code to update final_trace to make trace highlighting work * initial test commit for SDVGraphs * documentation for SDV graphs * added TODO comment * implemented comments from Douwe (added comments and reset graph setting in suiteprocessors.py) * fixed reducedSDV graph to have a properly functioning equivalence relation * Start node property (#41) * implemented scenariodeltavaluegraph * implemented reduced-scenariodeltavaluegraph, but should still discuss how to label equivalence classes (currently just picks a representative). Fixed part of vertex info being indicated as edge info for scenariodeltavaluegraph. Trace highlighting is still broken for reducedSDV * implemented comments from Douwe (added comments and reset graph setting in suiteprocessors.py) * fixed reducedSDV graph to have a properly functioning equivalence relation * fixed layout for reducedSDV graphs * Export json (#44) * export and import to/from json * export option in suiteprocessors * moved import from json from visualiser.py to suiteprocessors.py * [WIP] atest for importing * remove json file * revert gitignore to make json folder fully ignored * [WIP] generate test suite * complete atest for importing/exporting * renaming file and folder of .robot file * made test run with robotmbt and generate graph * updated test suite * fix line length * fixing comments not related to atest * deleting "JSON" as it is not specific to JSON * Added docstring and made a keyword more generic * Added documentation about why the usage of mkstemp instead of temporary file * Sugiyama layout (#45) * Make HTML generation static * Begin visualisation rework - nodes * Move some code * Import full screen and title from Diogo * Sugiyama layout * Rescale when zooming * Draw edges * Align edge labels center * Make HTML generation static * Begin visualisation rework - nodes * Move some code * Import full screen and title from Diogo * Sugiyama layout * Rescale when zooming * Draw edges * Align edge labels center * Fix minor merge issues * Format delta value the same as state * Inline mini static method * Make HTML generation static * Begin visualisation rework - nodes * Move some code * Import full screen and title from Diogo * Sugiyama layout * Rescale when zooming * Draw edges * Align edge labels center * Make HTML generation static * Begin visualisation rework - nodes * Move some code * Import full screen and title from Diogo * Sugiyama layout * Rescale when zooming * Draw edges * Align edge labels center * Fix minor merge issues * Format delta value the same as state * Inline mini static method * Fix fails * Remove unused dependency * Remove static method * Remove static method * Connect edges at the edge of nodes * Format * Remove unused constants * They weren't unused * Now they are * Extract functions * Self-loops * Extract constants * Scale properly on full-screen * Scale arrows as well * Color edges * Documentation * Add legend * Single comment * Change xrange based on aspect ratio * Floor font size to make it stay within nodes * Simplify resize callback * Include seed under graph title * Sort items to ensure equal ordering * Make HTML generation static * Begin visualisation rework - nodes * Move some code * Import full screen and title from Diogo * Sugiyama layout * Rescale when zooming * Draw edges * Align edge labels center * Make HTML generation static * Begin visualisation rework - nodes * Move some code * Import full screen and title from Diogo * Sugiyama layout * Rescale when zooming * Draw edges * Align edge labels center * Fix minor merge issues * Format delta value the same as state * Inline mini static method * Begin visualisation rework - nodes * Fix minor merge issues * Fix fails * Remove unused dependency * Remove static method * Remove static method * Connect edges at the edge of nodes * Format * Remove unused constants * They weren't unused * Now they are * Extract functions * Self-loops * Extract constants * Scale properly on full-screen * Scale arrows as well * Color edges * Documentation * Add legend * Single comment * Change xrange based on aspect ratio * Floor font size to make it stay within nodes * Simplify resize callback * Include seed under graph title * Sort items to ensure equal ordering * fix initial errors because of wrongly resolved rebase conflicts * remove duplicate method * first try at re-establishing visualization * revert "first try at ..." * Proper types * Proper types, remove duplicate definition * Access models directly from snapshots * Types * Turn all errors produced by visualiser into warnings * Re-implement delta-value changes * Re-implement reduced SDV changes * Forgor change * Implement requested changes, fix typehints * Whoops --------- Co-authored-by: JFoederer <32476108+JFoederer@users.noreply.github.com> Co-authored-by: Douwe Osinga Co-authored-by: D Osinga <46380170+osingaatje@users.noreply.github.com> Co-authored-by: tychodub Co-authored-by: tychodub <93142605+tychodub@users.noreply.github.com> Co-authored-by: jonathan <148167.jw@gmail.com> Co-authored-by: Jonathan <61787386+JWillegers@users.noreply.github.com> Co-authored-by: Diogo Silva --- .github/workflows/run-demo.yml | 35 ++ CONTRIBUTING.md | 116 ++++ README.md | 10 + .../birthday_cards_data_variation.resource | 2 +- atest/resources/birthday_cards_flat.resource | 2 +- .../02__repetition_with_identity_bogey.robot | 4 +- .../08__repetition_caused_by_refinement.robot | 29 + ...trace.robot => 09__impossible_trace.robot} | 4 +- ..._reject_refinement_on_exit_condition.robot | 47 ++ .../11__reject_double_refinement.robot | 86 +++ .../01__equivalence_partitioning.robot | 1 + ...equivalence_partitioning_double_data.robot | 1 + .../03__refinement_with_data_reuse.robot | 1 + .../04__refinement_with_data_fan_out.robot | 13 +- ...inement_with_data_fan_out_multi_part.robot | 64 +++ ...06__interacting_equivalence_classes.robot} | 1 + ...07__independent_equivalence_classes.robot} | 1 + ..._define_example_values_in_then_step.robot} | 1 + .../01__with_bonus_scenario_option.robot | 9 + ..._without_using_bonus_scenario_option.robot | 9 + .../__init__.robot | 8 + ...ct_setting_overrules_library_setting.robot | 9 + .../04__prior_overrule_does_not_persist.robot | 9 + .../__init__.robot | 7 + demo/Titanic/README.md | 8 +- demo/Titanic/run_demo.py | 16 +- demo/Titanic/simulation/location_on_grid.py | 2 +- demo/Titanic/simulation/map_animation.py | 3 +- robotmbt/modeller.py | 261 +++++++++ robotmbt/modelspace.py | 5 +- robotmbt/steparguments.py | 23 +- robotmbt/substitutionmap.py | 4 +- robotmbt/suitedata.py | 44 +- robotmbt/suiteprocessors.py | 497 ++++-------------- robotmbt/suitereplacer.py | 55 +- robotmbt/tracestate.py | 159 +++--- robotmbt/visualise/graphs/reducedSDVgraph.py | 4 +- robotmbt/visualise/models.py | 1 - robotmbt/visualise/visualiser.py | 38 +- utest/test_steparguments.py | 8 +- utest/test_substitutionmap.py | 3 +- utest/test_suitedata.py | 13 + utest/test_tracestate.py | 326 ++++++------ utest/test_tracestate_refinement.py | 192 ++++--- 44 files changed, 1330 insertions(+), 801 deletions(-) create mode 100644 .github/workflows/run-demo.yml create mode 100644 CONTRIBUTING.md create mode 100644 atest/robotMBT tests/05__repeating_scenarios/08__repetition_caused_by_refinement.robot rename atest/robotMBT tests/05__repeating_scenarios/{08__impossible_trace.robot => 09__impossible_trace.robot} (93%) create mode 100644 atest/robotMBT tests/05__repeating_scenarios/10__reject_refinement_on_exit_condition.robot create mode 100644 atest/robotMBT tests/05__repeating_scenarios/11__reject_double_refinement.robot create mode 100644 atest/robotMBT tests/06__data_variation/05__refinement_with_data_fan_out_multi_part.robot rename atest/robotMBT tests/06__data_variation/{05__interacting_equivalence_classes.robot => 06__interacting_equivalence_classes.robot} (99%) rename atest/robotMBT tests/06__data_variation/{06__independent_equivalence_classes.robot => 07__independent_equivalence_classes.robot} (99%) rename atest/robotMBT tests/06__data_variation/{07__define_example_values_in_then_step.robot => 08__define_example_values_in_then_step.robot} (99%) create mode 100644 atest/robotMBT tests/07__processor_options/option_handling/13__direct_settings_affect_current_suite_only/01__with_bonus_scenario_option.robot create mode 100644 atest/robotMBT tests/07__processor_options/option_handling/13__direct_settings_affect_current_suite_only/02__without_using_bonus_scenario_option.robot create mode 100644 atest/robotMBT tests/07__processor_options/option_handling/13__direct_settings_affect_current_suite_only/__init__.robot create mode 100644 atest/robotMBT tests/07__processor_options/option_handling/14__overruling_library_setting_affects_current_suite_only/03__direct_setting_overrules_library_setting.robot create mode 100644 atest/robotMBT tests/07__processor_options/option_handling/14__overruling_library_setting_affects_current_suite_only/04__prior_overrule_does_not_persist.robot create mode 100644 atest/robotMBT tests/07__processor_options/option_handling/14__overruling_library_setting_affects_current_suite_only/__init__.robot create mode 100644 robotmbt/modeller.py diff --git a/.github/workflows/run-demo.yml b/.github/workflows/run-demo.yml new file mode 100644 index 00000000..a4171e43 --- /dev/null +++ b/.github/workflows/run-demo.yml @@ -0,0 +1,35 @@ +# This workflow installs required Python dependencies and then runs the demo. +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +name: Run Demo + +on: + pull_request: + branches: [ "main" ] + +permissions: + contents: read + +defaults: + run: + working-directory: ./demo/Titanic + +jobs: + build: + + runs-on: windows-latest # Deliberately different OS compared to the test run. + + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: "3.14" # Deliberately different Python version compared to the test run. + - name: Install dependencies + run: | + python -m pip install --upgrade pip # upgrade pip to latest version + pip install -r requirements.txt # install demo dependencies + - name: Run basic demo (no repeating scenarios) + run: python run_demo.py miss + - name: Run extended demo + run: python run_demo.py extended diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..8c7559fe --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,116 @@ +# Contribution guidelines RobotMBT + +Welcome! Thank you for considering to contribute to this project. If you haven't already, because your contribution already starts when you use this software and share your experiences with the people around you. These guidelines will help you to further connect with the online community. + +## Communication channels + +### Slack + +If you want to ask or answer questions and participate in discussions, then the [Robot Framework Slack](http://slack.robotframework.org/) channels are a good place to do so. + +### GitHub + +If you want to get involved on GitHub, you can so by submitting issues or offering code improvements. These guidelines will help you to find your way. These guidelines expect readers to have a basic knowledge about open source as well as why and how to contribute to an open source project. If you are new to these topics, please have a look at the generic [Open Source Guides](https://opensource.guide/) first. + +## Code of Conduct + +If you want to be part of this community, then we expect you to respect our norms and values. These are in line with the [GitHub Code of Conduct](https://docs.github.com/en/site-policy/github-terms/github-community-code-of-conduct) and the [Slack Code of Conduct](https://docs.slack.dev/community-code-of-conduct/). In short, we expect you to: + +- Be welcoming. +- Be kind. +- Look out for each other. + +## Submitting issues + +Defects and enhancements are tracked in [GitHub Issues](https://github.com/JFoederer/robotframeworkMBT/issues). Before submitting an issue here, please make sure the issue is caused by this project in particular. If you are unsure if something is worth submitting, you can first ask on [Slack](http://slack.robotframework.org/). Before submitting a new issue, it is always a good idea to check if something similar was already reported. If it is, please add your comments to the existing issue instead of creating a new one. Communication in issues on GitHub is done in English. + +Take notice that issues do not get resolved by themselves. Someone will need to spend time on the topic. Be prepared to wait, contribute yourself or arrange budget to hire someone for the job. + +### Reporting defects + +When reporting a defect, be precise and concise in your description. Write in way that helps others understand and reproduce the issue. Screenshots can be very helpful, but when adding logging or other textual information, please keep the textual form. + +Note that all information in the issue tracker is public. *Do not include any confidential information there*. + +Be sure to add information about: + +- The applicable version(s) of RobotMBT (use `pip list` and check for `robotframework-mbt`) +- Your Robot Framework version (use `pip list` and check for `robotframework`) +- Your Python version (check using `python --version`) +- Your operating system +- Your custom settings for RobotMBT (at the library and test suite level) + +Version information about Robot Framework, Python and the operating system are also reported at the start of Robot's `output.xml` file. + +### Enhancement requests + +When proposing an enhancement, a feature request, be clear about the use cases. Who will benefit from the enhancement and in what way? Describe the expected behaviour and use concrete examples to illustrate the intent. + +## Code contributions + +If you have fixed a defect or implemented an enhancement, you can contribute your changes via a [GitHub Pull Request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request). This is not restricted to implementation code: on the contrary, fixes and enhancements to documentation and tests alone are also very valuable! + +### First steps + +- [Clone](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository) and/or [Fork](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/about-forks) the RobotMBT repo. If you are not a fan of command line tools, [GitHub Desktop](https://github.com/apps/desktop) can help you. +- [Run the tests](#running-tests) to check your starting point. +- Write new failing tests to cover your intended changes. +- Implement your changes. +- Verify that your tests pass with your implementation. + +### Definition of Done + +The Definition of Done for RobotMBT is when a pull request is merged. This is to ensure that pull requests are fully self-contained, and leave no open ends. + +In other words: when the pull request is merged, it is 100% done. This keeps the main branch ready for release at all times. + +This means that for each pull request you need to ensure that: + +- [No regression](#non-regression-criteria) is introduced. +- New functionality is covered by [tests](#guidelines-for-writing-new-tests). +- [Code style](#code-style) follows the standard. +- Documentation is up to date. +- The PR branch is 0 commits behind the main branch. + +### Running tests + +Tests can be executed from the command line by running `python run_tests.py`. This will run all unit tests, followed by the Robot acceptance tests. Use `--help` for additional info. + +### Non-regression criteria + +The criteria for proving non-regression are: + +- All automated regression tests pass +- All supported Python, Robot Framework and OS versions still work (see `pyproject.toml` for supported versions). +- The [demo](https://github.com/JFoederer/robotframeworkMBT/tree/main/demo/Titanic) still works. +- Manual checks are executed to cover the automation's blind spots and subjective elements (e.g. some visual inspection on layout and assessing overall look and feel). + +### Guidelines for writing new tests + +For this project, we are not maintaining separate requirements documentation. The user documentation explains the software's purpose and scope, while tests further specify its concrete behaviour. Keep this in mind when writing tests and pay extra attention to documenting your test cases: they are more than just bug catchers. If code exists due to a technical limitation rather than a requirement, be sure to document your design decision. + +Tests are located in the `atest` and `utest` folders, which stands for _acceptance test_ and _unit test_ respectively. The acceptance tests are Robot tests that cover user-visible behaviour using black-box testing techniques. They typically do not cover all details, unless some Robot Framework interaction is involved. The unit tests go more in-depth, including white box techniques to cover the _dark corners_ of the code. Choose the right type of test for what you are covering. + +A specific challenge for this project is that there is a lot of test case generation going on. Be wary that variations in the generation process do not alter the intended coverage of a test and do not yield false positives (passing results without proof for passing), such as checking "_all_" results in an empty list. Lastly: keep the resulting total number of test cases in a run deterministic. This allows for a quick check that all test cases are still being generated. + +### Code style + +Maintainability is the main driver for coding style. Always write your code with the mindset that you are writing it for someone else, and that this person's experience level is slightly below the average in the project. Code is written following the [PEP 8](https://peps.python.org/pep-0008) Style guide and [SOLID](https://en.wikipedia.org/wiki/SOLID) principles. + +#### Formatting + +Formatting follows the default rules of [autopep8](https://pypi.org/project/autopep8/) with the exception of the maximum line length (see https://github.com/JFoederer/robotframeworkMBT/tree/main/.github/workflows/autopep8.yml). Note however, that the extended line length is not an invite to always write long lines. + +Researchers have suggested that longer lines are better suited for cases when the information will likely be scanned, while shorter lines (45-75 characters) are appropriate when the information is meant to be read thoroughly [[ref.](https://www.academia.edu/6232736/The_influence_of_font_type_and_line_length_on_visual_search_and_information_retrieval_in_web_pages)]. Keep this in mind when writing code and documentation, taking the current indentation level into account. + +#### Docstrings, comments and logging + +- Docstrings are written using a black-box approach. One should not need to know the inside of a class or function in order to use it. +- Use comments to annotate code for maintainers. +- Prevent trivial comments and use descriptive names to make your code self-explanatory. +- When documenting external interfaces, also check whether the user documentation requires an update. +- Log useful information that is runtime-dependent. + - Information that is useful after a passing test run is logged at info-level. + - Information that is useful for analysing failed tests is logged at debug-level. + +- Be careful not to make assumptions in what you log: Recheck log statements if your changes affect the context in which the code is run, and only report about what you know to be true. diff --git a/README.md b/README.md index 9051a942..3eeb12de 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,16 @@ Treat this test suite model-based seed=eag-etou-cxi-leamv-jsi Using `seed=new` will force generation of a new reusable seed and is identical to omitting the seed argument. To completely bypass seed generation and use the system's random source, use `seed=None`. This has even more variation but does not produce a reusable seed. +### Option management + +If you want to set configuration options for use in multiple test suites without having to repeat them, the keywords __Set model-based options__ and __Update model-based options__ can be used to configure RobotMBT library options. _Set_ takes the provided options and discards any previously set options. _Update_ allows you to modify existing options or add new ones. Reset all options by calling _Set_ without arguments. Direct options provided to __Treat this test suite model-based__ take precedence over library options and affect only the current test suite. + +Tip: [Robot dictionaries](https://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#dictionary-variable) (`&{ }`) can be used to group related options and pass them as one set. + +## Contributing + +If you have feedback, ideas, or want to get involved in coding, then check out the [Contribution guidelines](https://github.com/JFoederer/robotframeworkMBT/blob/main/CONTRIBUTING.md). + ## Disclaimer Please note that this library is in a premature state and hasn't reached its first official (1.0) release yet. Developments are ongoing within the context of the [TiCToC](https://tictoc.cs.ru.nl/) research project. Interface changes are still frequent, and no deprecation warnings are being issued yet. diff --git a/atest/resources/birthday_cards_data_variation.resource b/atest/resources/birthday_cards_data_variation.resource index c3648b66..6096fa2d 100644 --- a/atest/resources/birthday_cards_data_variation.resource +++ b/atest/resources/birthday_cards_data_variation.resource @@ -61,7 +61,7 @@ the birthday card has ${n} different names written on it [Documentation] *model info* ... :IN: len(set(birthday_card.names)) == ${n} ... :OUT: len(set(birthday_card.names)) == ${n} - Length should be ${names} ${{int(${n})}} + Length should be ${{set(${names})}} ${{int(${n})}} ${person} signs the birthday card [Documentation] *model info* diff --git a/atest/resources/birthday_cards_flat.resource b/atest/resources/birthday_cards_flat.resource index 20dfabb8..d3976f70 100644 --- a/atest/resources/birthday_cards_flat.resource +++ b/atest/resources/birthday_cards_flat.resource @@ -60,7 +60,7 @@ ${person A} passes the birthday card ${back/on} to ${person B} Log Thank you ${person A}, I'll be sure to put my name on here. Set suite variable ${has_card} ${person B} -${person} refuses to write their name on the birthday card +${person} writes their name in invisible ink on the birthday card [Documentation] *model info* ... :IN: birthday_card ... :OUT: birthday_card diff --git a/atest/robotMBT tests/05__repeating_scenarios/02__repetition_with_identity_bogey.robot b/atest/robotMBT tests/05__repeating_scenarios/02__repetition_with_identity_bogey.robot index 964057eb..4b9b9cd3 100644 --- a/atest/robotMBT tests/05__repeating_scenarios/02__repetition_with_identity_bogey.robot +++ b/atest/robotMBT tests/05__repeating_scenarios/02__repetition_with_identity_bogey.robot @@ -19,10 +19,10 @@ Someone writes their name on the card when Someone writes their name on the birthday card then the birthday card has 'Someone' written on it -Refusing to share the birthday card +Signing the card in invisible ink Given there is a birthday card and the birthday card has 'Someone' written on it - when Johan refuses to write their name on the birthday card + when Johan writes their name in invisible ink on the birthday card then the birthday card has 'Someone' written on it but the birthday card does not have 'Johan' written on it diff --git a/atest/robotMBT tests/05__repeating_scenarios/08__repetition_caused_by_refinement.robot b/atest/robotMBT tests/05__repeating_scenarios/08__repetition_caused_by_refinement.robot new file mode 100644 index 00000000..14edf685 --- /dev/null +++ b/atest/robotMBT tests/05__repeating_scenarios/08__repetition_caused_by_refinement.robot @@ -0,0 +1,29 @@ +*** Settings *** +Documentation This suite has more low-level scenarios than high-level scenarios, +... meaning that the high-level scenario must be repeated in order for +... the second low-level scenario to be reached. +Suite Setup Treat this test suite Model-based +Resource ../../resources/birthday_cards_composed.resource +Library robotmbt + +*** Test Cases *** +Buying a card + When someone buys a birthday card + then there is a blank birthday card available + +high-level scenario + Given there is a birthday card + when Someone writes their name on the birthday card + then the birthday card has 'Someone' written on it + +low-level scenario A + Given there is a birthday card + when Someone writes their name in pen on the birthday card + then the birthday card has 'Someone' written on it + and there is text added in ink on the birthday card + +low-level scenario B + Given there is a birthday card + when Someone writes their name in pen on the birthday card + then the birthday card has 'Someone' written on it + and there is text added in ink on the birthday card diff --git a/atest/robotMBT tests/05__repeating_scenarios/08__impossible_trace.robot b/atest/robotMBT tests/05__repeating_scenarios/09__impossible_trace.robot similarity index 93% rename from atest/robotMBT tests/05__repeating_scenarios/08__impossible_trace.robot rename to atest/robotMBT tests/05__repeating_scenarios/09__impossible_trace.robot index 01a1f2a9..99f976fe 100644 --- a/atest/robotMBT tests/05__repeating_scenarios/08__impossible_trace.robot +++ b/atest/robotMBT tests/05__repeating_scenarios/09__impossible_trace.robot @@ -11,9 +11,9 @@ Buying a card When someone buys a birthday card then there is a blank birthday card available -Refusing to sign the birthday card +Signing the card in invisible ink Given there is a birthday card - when everybody refuses to write their name on the birthday card + when everybody writes their name in invisible ink on the birthday card then the birthday card has 0 names written on it At least 42 people can write their name on the card diff --git a/atest/robotMBT tests/05__repeating_scenarios/10__reject_refinement_on_exit_condition.robot b/atest/robotMBT tests/05__repeating_scenarios/10__reject_refinement_on_exit_condition.robot new file mode 100644 index 00000000..59e423d7 --- /dev/null +++ b/atest/robotMBT tests/05__repeating_scenarios/10__reject_refinement_on_exit_condition.robot @@ -0,0 +1,47 @@ +*** Settings *** +Documentation This suite confirms that a scenario that can be inserted at the place +... of refinement based on its entry conditions, but afterwards does not +... satisfy the high-level scenario's exit conditions, is rejected. +Suite Setup Expect failing suite processing +Resource ../../resources/birthday_cards_flat.resource +Library robotmbt + + +*** Test Cases *** +Buying a card + When someone buys a birthday card + then there is a blank birthday card available + +high-level scenario + Given there is a birthday card + when Two people write their name on the birthday card + then the birthday card has 2 names written on it + +low-level scenario + Given there is a birthday card + when Someone writes their name on the birthday card + then the birthday card has 'Someone' written on it + + +*** Keywords *** +Two people write their name on the birthday card + [Documentation] + ... *model info* + ... :IN: scenario.count = len(birthday_card.names) + ... :OUT: len(birthday_card.names) == scenario.count+2 + Skip when unreachable + Length should be ${names} ${2} + +Expect failing suite processing + Run keyword and expect error Unable to compose* Treat this test suite Model-based + Set suite variable ${expected_error_detected} ${True} + +Skip when unreachable + [Documentation] + ... If the scenario is inserted after proper detection of the expected error, + ... then this keyword causes the remainder of the scenario to be skipped and + ... the test passes. When inserted without detected error, the scenario will + ... fail. + IF ${expected_error_detected} + Pass execution Accepting intentionally unreachable scenario + END diff --git a/atest/robotMBT tests/05__repeating_scenarios/11__reject_double_refinement.robot b/atest/robotMBT tests/05__repeating_scenarios/11__reject_double_refinement.robot new file mode 100644 index 00000000..d6063437 --- /dev/null +++ b/atest/robotMBT tests/05__repeating_scenarios/11__reject_double_refinement.robot @@ -0,0 +1,86 @@ +*** Settings *** +Documentation This suite covers a special case where an incomplete rollback inside a +... scenario which is split up for refinement, could cause two scenarios +... to be inserted for a single refinement. To get into that situation the +... high-level scenario has two steps that require refinement. The first +... one only checks that a certain name is inserted, it does not check the +... number of names. The second one checks that the total number of names +... is three. This should be an unreachable situation, because refinements +... are always a single scenario and each of the scenarios only inserts a +... single name. If the rollback of the middle part (2.2 in the trace) was +... incomplete, i.e. its rollback did not include the refinement scenario +... as well, then inserting the second low-level scenario would satisfy +... the first exit conditions and inserting another low-level scenario for +... the second refinement would satisfy exit conditions for both steps and +... incorrectly complete the suite. +Suite Setup Expect failing suite processing +Resource ../../resources/birthday_cards_flat.resource +Library robotmbt + + +*** Test Cases *** +Buying a card + When someone buys a birthday card + then there is a blank birthday card available + +high-level scenario + Given there is a birthday card + when The first person writes their name on the birthday card + and Two more people write their name on the birthday card + then the birthday card has 3 names written on it + +low-level scenario A + Given there is a birthday card + and we are in refinement + when Someone writes their name on the birthday card + then the birthday card has 'Someone' written on it + +low-level scenario B + Given there is a birthday card + and we are in refinement + when Someone writes their name on the birthday card + then the birthday card has 'Someone' written on it + +low-level scenario C + Given there is a birthday card + and we are in refinement + when Someone writes their name on the birthday card + then the birthday card has 'Someone' written on it + + +*** Keywords *** +The first person writes their name on the birthday card + [Documentation] *model info* + ... :IN: scenario.count = len(birthday_card.names) + ... :OUT: Someone in birthday_card.names + Skip when unreachable + Should Contain ${names} Someone + +Two more people write their name on the birthday card + [Documentation] *model info* + ... :IN: scenario.count + ... :OUT: len(birthday_card.names) == scenario.count+3 + Skip when unreachable + Length should be ${names} ${2} + +we are in refinement + [Documentation] Helper to prevent lower-level scenarios from being valid + ... at the top-level. (Better for performance) + ... *model info* + ... :IN: scenario.count + ... :OUT: None + No Operation + +Expect failing suite processing + Run keyword and expect error Unable to compose* Treat this test suite Model-based + Set suite variable ${expected_error_detected} ${True} + +Skip when unreachable + [Documentation] + ... If the scenario is inserted after proper detection of the expected error, + ... then this keyword causes the remainder of the scenario to be skipped and + ... the test passes. When inserted without detected error, the scenario will + ... fail. + IF ${expected_error_detected} + Pass execution Accepting intentionally unreachable scenario + END diff --git a/atest/robotMBT tests/06__data_variation/01__equivalence_partitioning.robot b/atest/robotMBT tests/06__data_variation/01__equivalence_partitioning.robot index 84cc4082..6410f36e 100644 --- a/atest/robotMBT tests/06__data_variation/01__equivalence_partitioning.robot +++ b/atest/robotMBT tests/06__data_variation/01__equivalence_partitioning.robot @@ -8,6 +8,7 @@ Suite Setup Treat this test suite Model-based Resource ../../resources/birthday_cards_data_variation.resource Library robotmbt + *** Test Cases *** Background Given Bahar is having their birthday diff --git a/atest/robotMBT tests/06__data_variation/02__equivalence_partitioning_double_data.robot b/atest/robotMBT tests/06__data_variation/02__equivalence_partitioning_double_data.robot index 332ab48c..ae9c0929 100644 --- a/atest/robotMBT tests/06__data_variation/02__equivalence_partitioning_double_data.robot +++ b/atest/robotMBT tests/06__data_variation/02__equivalence_partitioning_double_data.robot @@ -9,6 +9,7 @@ Suite Setup Treat this test suite Model-based Resource ../../resources/birthday_cards_data_variation.resource Library robotmbt + *** Test Cases *** Background Given Bahar is having their birthday diff --git a/atest/robotMBT tests/06__data_variation/03__refinement_with_data_reuse.robot b/atest/robotMBT tests/06__data_variation/03__refinement_with_data_reuse.robot index 52762ce1..4e47bb95 100644 --- a/atest/robotMBT tests/06__data_variation/03__refinement_with_data_reuse.robot +++ b/atest/robotMBT tests/06__data_variation/03__refinement_with_data_reuse.robot @@ -9,6 +9,7 @@ Suite Setup Treat this test suite Model-based Resource ../../resources/birthday_cards_data_variation.resource Library robotmbt + *** Test Cases *** Background Given Bahar is having their birthday diff --git a/atest/robotMBT tests/06__data_variation/04__refinement_with_data_fan_out.robot b/atest/robotMBT tests/06__data_variation/04__refinement_with_data_fan_out.robot index dc8da64d..c0b67eab 100644 --- a/atest/robotMBT tests/06__data_variation/04__refinement_with_data_fan_out.robot +++ b/atest/robotMBT tests/06__data_variation/04__refinement_with_data_fan_out.robot @@ -1,16 +1,17 @@ *** Settings *** Documentation This suite uses refinement and equivalence partitioning. The high-level scenario ... requires just a single key example from the equivalence class and uses just one -... concrete example. This scenario is then refined by two more detailed examples -... that use 2 different actors with specific characters. One is concise, the other -... a bit more elaborate. This implies that for at least one set of examples the -... high-level scenario's example value does not match the low-level scenario's -... example value. They must however still be matched, and kept identical, under -... refinement. +... concrete example. This scenario can be refined by two more detailed examples +... that use 2 different actors. This implies that one refinement example will match +... the high-level scenario's example, the other does not. To complete the trace, +... the high-level scenario must be repeated, once with each possible refinement. +... The example values between the high- and low-level scenarios must be matched, +... and kept identical, under refinement. Suite Setup Treat this test suite Model-based Resource ../../resources/birthday_cards_data_variation.resource Library robotmbt + *** Test Cases *** Background Given Bahar is having their birthday diff --git a/atest/robotMBT tests/06__data_variation/05__refinement_with_data_fan_out_multi_part.robot b/atest/robotMBT tests/06__data_variation/05__refinement_with_data_fan_out_multi_part.robot new file mode 100644 index 00000000..7f82d960 --- /dev/null +++ b/atest/robotMBT tests/06__data_variation/05__refinement_with_data_fan_out_multi_part.robot @@ -0,0 +1,64 @@ +*** Settings *** +Documentation This suite is an extension to the 'data fan out' suite, which needed refinement +... for just a single step. Here, the high-level scenario needs refinement in three +... of its steps. The background defines three actors and there are three refinement +... scenarios available. One for each actor. However, the high-level scenario only +... uses two of the actors, forcing the model to match up two of the steps and to +... never use the third option. The test fails if the model does not properly keep +... its data choices over all its steps when splitting the high-level scenario, and +... picks data independently in each step. +Suite Setup Treat this test suite Model-based +Resource ../../resources/birthday_cards_data_variation.resource +Library robotmbt + + +*** Test Cases *** +Background + Given Bahar is having their birthday + and Johan is a friend of Bahar + and Tannaz is a friend of Bahar + and Frederique is a friend of Bahar + When Johan buys a birthday card + then there is a blank birthday card available + +A friend signs the birthday card + Given there is a birthday card + when Johan signs the birthday card + and Tannaz signs the birthday card + and Johan signs the birthday card again + then the birthday card has a personal touch + and the birthday card has 2 different names written on it + +Signing the birthday card with your name only + Given there is a birthday card + and Johan is signing the birthday card + when Johan writes their name on the birthday card + then the birthday card has 'Johan' written on it + +Signing the birthday card with: Happy birthday! + Given there is a birthday card + and Tannaz is signing the birthday card + when Tannaz writes their name on the birthday card + and Tannaz adds the wish 'Happy birthday!' to the birthday card + then the birthday card has 'Tannaz' written on it + and the birthday card proclaims: Happy birthday! + +Signing the birthday card with: Cheers! + Given there is a birthday card + and Frederique is signing the birthday card + when Frederique writes their name on the birthday card + and Frederique adds the wish 'Cheers!' to the birthday card + then the birthday card has 'Frederique' written on it + and the birthday card proclaims: Cheers! + + +*** Keywords *** +${person} signs the birthday card again + [Documentation] Similar to '${person} signs the birthday card', but + ... without the check that the name is not already on there. + ... + ... *model info* + ... :MOD: ${person}= [guest for guest in party.guests] + ... :IN: scenario.guest = ${person} | scenario.count = len(birthday_card.names) + ... :OUT: len(birthday_card.names) == scenario.count+1 + Should contain ${names} ${person} diff --git a/atest/robotMBT tests/06__data_variation/05__interacting_equivalence_classes.robot b/atest/robotMBT tests/06__data_variation/06__interacting_equivalence_classes.robot similarity index 99% rename from atest/robotMBT tests/06__data_variation/05__interacting_equivalence_classes.robot rename to atest/robotMBT tests/06__data_variation/06__interacting_equivalence_classes.robot index 87a5fa15..2c8f0f7c 100644 --- a/atest/robotMBT tests/06__data_variation/05__interacting_equivalence_classes.robot +++ b/atest/robotMBT tests/06__data_variation/06__interacting_equivalence_classes.robot @@ -12,6 +12,7 @@ Suite Setup Treat this test suite Model-based Resource ../../resources/birthday_cards_data_variation.resource Library robotmbt + *** Test Cases *** Background Given Johan is having their birthday diff --git a/atest/robotMBT tests/06__data_variation/06__independent_equivalence_classes.robot b/atest/robotMBT tests/06__data_variation/07__independent_equivalence_classes.robot similarity index 99% rename from atest/robotMBT tests/06__data_variation/06__independent_equivalence_classes.robot rename to atest/robotMBT tests/06__data_variation/07__independent_equivalence_classes.robot index 4899c0b7..bba90197 100644 --- a/atest/robotMBT tests/06__data_variation/06__independent_equivalence_classes.robot +++ b/atest/robotMBT tests/06__data_variation/07__independent_equivalence_classes.robot @@ -12,6 +12,7 @@ Suite Setup Treat this test suite Model-based Resource ../../resources/birthday_cards_data_variation.resource Library robotmbt + *** Test Cases *** Background Given Bahar is having their birthday diff --git a/atest/robotMBT tests/06__data_variation/07__define_example_values_in_then_step.robot b/atest/robotMBT tests/06__data_variation/08__define_example_values_in_then_step.robot similarity index 99% rename from atest/robotMBT tests/06__data_variation/07__define_example_values_in_then_step.robot rename to atest/robotMBT tests/06__data_variation/08__define_example_values_in_then_step.robot index db589cb4..c31de190 100644 --- a/atest/robotMBT tests/06__data_variation/07__define_example_values_in_then_step.robot +++ b/atest/robotMBT tests/06__data_variation/08__define_example_values_in_then_step.robot @@ -15,6 +15,7 @@ Documentation This test suite focuses on the initialisation of model data fr Suite Setup Treat this test suite Model-based Library robotmbt + *** Test Cases *** Background Given Bahar is throwing a party for their friends diff --git a/atest/robotMBT tests/07__processor_options/option_handling/13__direct_settings_affect_current_suite_only/01__with_bonus_scenario_option.robot b/atest/robotMBT tests/07__processor_options/option_handling/13__direct_settings_affect_current_suite_only/01__with_bonus_scenario_option.robot new file mode 100644 index 00000000..b7289d59 --- /dev/null +++ b/atest/robotMBT tests/07__processor_options/option_handling/13__direct_settings_affect_current_suite_only/01__with_bonus_scenario_option.robot @@ -0,0 +1,9 @@ +*** Settings *** +Suite Setup Run keywords Set suite variable ${test_count} ${0} +... AND Treat this test suite Model-based bonus_scenario=${True} +Suite Teardown Should be equal ${test_count} ${3} +Library robotmbt processor_lib=suiterepeater + +*** Test Cases *** +only test case + Set suite variable ${test_count} ${test_count+1} diff --git a/atest/robotMBT tests/07__processor_options/option_handling/13__direct_settings_affect_current_suite_only/02__without_using_bonus_scenario_option.robot b/atest/robotMBT tests/07__processor_options/option_handling/13__direct_settings_affect_current_suite_only/02__without_using_bonus_scenario_option.robot new file mode 100644 index 00000000..70f501af --- /dev/null +++ b/atest/robotMBT tests/07__processor_options/option_handling/13__direct_settings_affect_current_suite_only/02__without_using_bonus_scenario_option.robot @@ -0,0 +1,9 @@ +*** Settings *** +Suite Setup Run keywords Set suite variable ${test_count} ${0} +... AND Treat this test suite Model-based +Suite Teardown Should be equal ${test_count} ${2} +Library robotmbt processor_lib=suiterepeater + +*** Test Cases *** +only test case + Set suite variable ${test_count} ${test_count+1} diff --git a/atest/robotMBT tests/07__processor_options/option_handling/13__direct_settings_affect_current_suite_only/__init__.robot b/atest/robotMBT tests/07__processor_options/option_handling/13__direct_settings_affect_current_suite_only/__init__.robot new file mode 100644 index 00000000..882097c5 --- /dev/null +++ b/atest/robotMBT tests/07__processor_options/option_handling/13__direct_settings_affect_current_suite_only/__init__.robot @@ -0,0 +1,8 @@ +*** Settings *** +Documentation In this suite one of the processor options is set on the higher level suite, +... which is then reused in both sub suites. The first sub suite adds their own value +... for a second configuration option, the second suite does not use that option at +... all. The second suite should be unaffected by the option set in the preceeding +... suite. +Suite Setup Set model-based options repeat=2 +Library robotmbt processor_lib=suiterepeater diff --git a/atest/robotMBT tests/07__processor_options/option_handling/14__overruling_library_setting_affects_current_suite_only/03__direct_setting_overrules_library_setting.robot b/atest/robotMBT tests/07__processor_options/option_handling/14__overruling_library_setting_affects_current_suite_only/03__direct_setting_overrules_library_setting.robot new file mode 100644 index 00000000..f92713d2 --- /dev/null +++ b/atest/robotMBT tests/07__processor_options/option_handling/14__overruling_library_setting_affects_current_suite_only/03__direct_setting_overrules_library_setting.robot @@ -0,0 +1,9 @@ +*** Settings *** +Suite Setup Run keywords Set suite variable ${test_count} ${0} +... AND Treat this test suite Model-based repeat=3 +Suite Teardown Should be equal ${test_count} ${3} +Library robotmbt processor_lib=suiterepeater + +*** Test Cases *** +only test case + Set suite variable ${test_count} ${test_count+1} diff --git a/atest/robotMBT tests/07__processor_options/option_handling/14__overruling_library_setting_affects_current_suite_only/04__prior_overrule_does_not_persist.robot b/atest/robotMBT tests/07__processor_options/option_handling/14__overruling_library_setting_affects_current_suite_only/04__prior_overrule_does_not_persist.robot new file mode 100644 index 00000000..70f501af --- /dev/null +++ b/atest/robotMBT tests/07__processor_options/option_handling/14__overruling_library_setting_affects_current_suite_only/04__prior_overrule_does_not_persist.robot @@ -0,0 +1,9 @@ +*** Settings *** +Suite Setup Run keywords Set suite variable ${test_count} ${0} +... AND Treat this test suite Model-based +Suite Teardown Should be equal ${test_count} ${2} +Library robotmbt processor_lib=suiterepeater + +*** Test Cases *** +only test case + Set suite variable ${test_count} ${test_count+1} diff --git a/atest/robotMBT tests/07__processor_options/option_handling/14__overruling_library_setting_affects_current_suite_only/__init__.robot b/atest/robotMBT tests/07__processor_options/option_handling/14__overruling_library_setting_affects_current_suite_only/__init__.robot new file mode 100644 index 00000000..8ae64cf4 --- /dev/null +++ b/atest/robotMBT tests/07__processor_options/option_handling/14__overruling_library_setting_affects_current_suite_only/__init__.robot @@ -0,0 +1,7 @@ +*** Settings *** +Documentation In this suite one of the processor options is set on the higher level suite, +... which is then used in both sub suites. The first sub suite overrules the library +... setting with their own value, the second library doesn't. The second suite should +... be unaffected by the overruled option from the preceeding suite. +Suite Setup Set model-based options repeat=2 +Library robotmbt processor_lib=suiterepeater diff --git a/demo/Titanic/README.md b/demo/Titanic/README.md index 5b91f9f9..a1739ba1 100644 --- a/demo/Titanic/README.md +++ b/demo/Titanic/README.md @@ -1,10 +1,10 @@ -# robotMBT Titanic demo +# RobotMBT Titanic demo ## What is it? -The purpose of this demo is to showcase the Model-Based Testing concepts available from the [robotMBT](https://github.com/JFoederer/robotframeworkMBT) library using a [BDD](https://en.wikipedia.org/wiki/Behavior-driven_development) style project. It is based on the principle of [specification by example](https://en.wikipedia.org/wiki/Specification_by_example), using _given-when-then_ style scenarios. +The purpose of this demo is to showcase the Model-Based Testing concepts available from the [RobotMBT](https://github.com/JFoederer/robotframeworkMBT) library using a [BDD](https://en.wikipedia.org/wiki/Behavior-driven_development) style project. It is based on the principle of [specification by example](https://en.wikipedia.org/wiki/Specification_by_example), using _given-when-then_ style scenarios. -Given-steps typically describe preconditions, i.e. state, but classically given-steps are implemented as actions to get to that desired precondition. Now, consider using [specification by example](https://en.wikipedia.org/wiki/Specification_by_example). If your specification is complete, and your examples are consistent, then any given-state must be reachable by operating the system within specification, following the examples. In this demo we use [robotMBT](https://github.com/JFoederer/robotframeworkMBT) to specify a complete story, on varying levels of detail, using small consise scenarios. Then we let [robotMBT](https://github.com/JFoederer/robotframeworkMBT) construct a complete storyline, so we don't have to worry about how to reach all the correct preconditions. +Given-steps typically describe preconditions, i.e. state, but classically given-steps are implemented as actions to get to that desired precondition. Now, consider using [specification by example](https://en.wikipedia.org/wiki/Specification_by_example). If your specification is complete, and your examples are consistent, then any given-state must be reachable by operating the system within specification, following the examples. In this demo we use [RobotMBT](https://github.com/JFoederer/robotframeworkMBT) to specify a complete story, on varying levels of detail, using small consise scenarios. Then we let [RobotMBT](https://github.com/JFoederer/robotframeworkMBT) construct a complete storyline, so we don't have to worry about how to reach all the correct preconditions. Please keep in mind that the library and this demo are still in the early development phases and offered functionality is still limited. However, in good agile spirit, we still wanted to publish the results. @@ -18,7 +18,7 @@ There are a total of 7 scenarios in this demo, 10 if you use the extended varian It might seem odd at first, seeing the [story of Titanic](https://en.wikipedia.org/wiki/Sinking_of_the_Titanic) in a context that is typically used in more technical environments. However, the [BDD](https://en.wikipedia.org/wiki/Behavior-driven_development) process is mostly non-technical and using a topic like Titanic helps to prevent technical bias. Another important point is that we want to stick to writing and maintaining short, to-the-point scenarios. From these, we want to compose larger scenarios, describing behaviour of complex systems, start to end, like telling a story. What better use for that than a well known story? -The [story of Titanic](https://en.wikipedia.org/wiki/Sinking_of_the_Titanic) fits these criteria and, since it already happened, there should be little discussion on the specification. It will be interesting to see if the test case generation mechanism from [robotMBT](https://github.com/JFoederer/robotframeworkMBT) can reconstruct the familiar story, and then some variations thereof. After all, the maiden voyage of Titanic was just one example of what could have happened... +The [story of Titanic](https://en.wikipedia.org/wiki/Sinking_of_the_Titanic) fits these criteria and, since it already happened, there should be little discussion on the specification. It will be interesting to see if the test case generation mechanism from [RobotMBT](https://github.com/JFoederer/robotframeworkMBT) can reconstruct the familiar story, and then some variations thereof. After all, the maiden voyage of Titanic was just one example of what could have happened... ## Running the demo diff --git a/demo/Titanic/run_demo.py b/demo/Titanic/run_demo.py index d6936c06..37977ff8 100644 --- a/demo/Titanic/run_demo.py +++ b/demo/Titanic/run_demo.py @@ -21,10 +21,12 @@ # The base folder needs to be added to the python path to resolve the dependencies. You # will also need to add this path to your IDE options when running from there. - robot.run_cli(['--outputdir', OUTPUT_ROOT, - '--pythonpath', THIS_DIR, - '--exclude', HIT_MISS_TAG, - '--exclude', EXTENDED_TAG, - '--loglevel', 'DEBUG:INFO', - SCENARIO_FOLDER], - exit=False) + exitcode = robot.run_cli(['--outputdir', OUTPUT_ROOT, + '--pythonpath', THIS_DIR, + '--exclude', HIT_MISS_TAG, + '--exclude', EXTENDED_TAG, + '--loglevel', 'DEBUG:INFO', + SCENARIO_FOLDER], + exit=False) + + sys.exit(exitcode) diff --git a/demo/Titanic/simulation/location_on_grid.py b/demo/Titanic/simulation/location_on_grid.py index d44923d3..ee58ae5b 100644 --- a/demo/Titanic/simulation/location_on_grid.py +++ b/demo/Titanic/simulation/location_on_grid.py @@ -11,7 +11,7 @@ def __init__(self, longitude, latitude): def __str__(self): return f"{'N' if self.latitude >= 0 else 'S'}{abs(self.latitude):08.5f} "\ - f"{'E' if self.longitude >= 0 else 'W'}{abs(self.longitude):08.5f}" + f"{'E' if self.longitude >= 0 else 'W'}{abs(self.longitude):08.5f}" def distance_to(self, other_object: 'LocationOnGrid'): """ diff --git a/demo/Titanic/simulation/map_animation.py b/demo/Titanic/simulation/map_animation.py index 2cc8b500..4470246b 100644 --- a/demo/Titanic/simulation/map_animation.py +++ b/demo/Titanic/simulation/map_animation.py @@ -17,7 +17,8 @@ def _import_dependencies(self): def plot_static_elements(self, areas, locations): # Plot the areas as squares if not self.plot_initialized: - self._import_dependencies() # Import here, to avoid any missing dependency problems in case matplotlib is not installed + # Import here, to avoid any missing dependency problems in case matplotlib is not installed + self._import_dependencies() self.fig, self.ax = plt.subplots() self.ax.set_aspect('equal') diff --git a/robotmbt/modeller.py b/robotmbt/modeller.py new file mode 100644 index 00000000..3c9fd990 --- /dev/null +++ b/robotmbt/modeller.py @@ -0,0 +1,261 @@ +# -*- coding: utf-8 -*- + +# BSD 3-Clause License +# +# Copyright (c) 2026, J. Foederer +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from typing import Any + +from robot.api import logger +from robot.utils import is_list_like + +from .modelspace import ModelSpace +from .steparguments import StepArgument, StepArguments +from .substitutionmap import SubstitutionMap +from .suitedata import Scenario, Step +from .tracestate import TraceState, TraceSnapShot + + +def try_to_fit_in_scenario(candidate: Scenario, tracestate: TraceState): + """ + Tries to insert the candidate scenario into the trace (in full or partial) and + updates tracestate accordingly. + """ + model = tracestate.model if tracestate.model else ModelSpace() + model.new_scenario_scope() + inserted, remainder, extra_data = process_scenario(candidate, model) + if not inserted: # insertion failed + tracestate.reject_scenario(candidate.src_id) + logger.debug(extra_data['fail_msg']) + elif not remainder: # the scenario processed in full + model.end_scenario_scope() + tracestate.confirm_full_scenario(inserted.src_id, inserted, model) + logger.debug(f"Inserted scenario {inserted.src_id}, {inserted.name}") + if tracestate.is_refinement_active(): + handle_refinement_exit(inserted, tracestate) + else: # the scenario is split into two parts, ready for refinement + logger.debug(f"Partially inserted scenario {inserted.src_id}, {inserted.name}\n" + f"Refinement needed at step: {remainder.steps[1]}") + inserted.name = f"{inserted.name} (part {tracestate.highest_part(inserted.src_id)+1})" + tracestate.push_partial_scenario(inserted.src_id, inserted, model, remainder) + + +def process_scenario(scenario: Scenario, model: ModelSpace) -> tuple[Scenario | None, Scenario | None, dict[str, Any]]: + for step in scenario.steps: + if 'error' in step.model_info: + return None, None, dict(fail_masg=f"Error in scenario {scenario.name} " + f"at step {step}: {step.model_info['error']}") + for expr in _relevant_expressions(step): + try: + if model.process_expression(expr, step.args) is False: + if step.gherkin_kw in ['when', None] and expr in step.model_info['OUT']: + part1, part2 = split_for_refinement(scenario, step) + return part1, part2, dict() + else: + return None, None, dict(fail_msg=f"Unable to insert scenario {scenario.src_id}, " + f"{scenario.name}, due to step '{step}': [{expr}] is False") + except Exception as err: + return None, None, dict(fail_msg=f"Unable to insert scenario {scenario.src_id}, " + f"{scenario.name}, due to step '{step}': [{expr}] {err}") + return scenario.copy(), None, dict() + + +def _relevant_expressions(step: Step) -> list[str]: + if step.gherkin_kw is None and not step.model_info: + return [] # model info is optional for action keywords + expressions = [] + if 'IN' not in step.model_info or 'OUT' not in step.model_info: + raise Exception(f"Model info incomplete for step: {step}") + if step.gherkin_kw in ['given', 'when', None]: + expressions += step.model_info['IN'] + if step.gherkin_kw in ['when', 'then', None]: + expressions += step.model_info['OUT'] + return expressions + + +def split_for_refinement(scenario: Scenario, step: Step) -> tuple[Scenario, Scenario]: + front, back = scenario.split_at_step(scenario.steps.index(step)) + remaining_steps = '\n\t'.join([step.full_keyword, '- '*35] + [s.full_keyword for s in back.steps[1:]]) + remaining_steps = _escape_robot_vars(remaining_steps) + edge_step = Step('Log', f"Refinement follows for step:\n\t{remaining_steps}", parent=scenario) + edge_step.gherkin_kw = step.gherkin_kw + edge_step.model_info = dict(IN=step.model_info['IN'], OUT=[]) + edge_step.detached = True + edge_step.args = StepArguments(step.args) + front.steps.append(edge_step) + back.steps.insert(0, Step('Log', f"Refinement ready, completing step", parent=scenario)) + back.steps[1] = back.steps[1].copy() + back.steps[1].model_info['IN'] = [] + return (front, back) + + +def _escape_robot_vars(text: str) -> str: + for seq in ("${", "@{", "%{", "&{", "*{"): + text = text.replace(seq, "\\" + seq) + return text + + +def handle_refinement_exit(inserted_refinement: Scenario, tracestate: TraceState): + refinement_tail = tracestate.get_remainder(tracestate.active_refinements[-1]) + exit_conditions = refinement_tail.steps[1].model_info['OUT'] + exit_conditions_processed = False + for expr in exit_conditions: + try: + if tracestate.model.process_expression(expr, refinement_tail.steps[1].args) is False: + break + except Exception: + break + else: + exit_conditions_processed = True + + if not exit_conditions_processed: + rewind(tracestate) # Reject insterted scenario. Even though it fits, it is not a refinement. + logger.debug(f"Reconsidering scenario {inserted_refinement.src_id}, {inserted_refinement.name}, " + f"did not meet refinement exit condition: {exit_conditions}") + return + + model = tracestate.model + tail_inserted, remainder, extra_data = process_scenario(refinement_tail, model) + if not tail_inserted: + logger.debug(extra_data['fail_msg']) + # Confirm then rewind, to roll back complete scenario, including its refiements + # Because that exit check passed, this is an error in the refined scenario itself + tracestate.confirm_full_scenario(refinement_tail.src_id, refinement_tail, model) + tail = rewind(tracestate) + logger.debug(f"Having to roll back up to {tail.scenario.name if tail else 'the beginning'}") + elif not remainder: + model.end_scenario_scope() + tracestate.confirm_full_scenario(tail_inserted.src_id, tail_inserted, model) + logger.debug(f"Scenario '{tail_inserted.name}' completed after refinement") + if tracestate.is_refinement_active(): + handle_refinement_exit(tail_inserted, tracestate) + else: + logger.debug(f"Partially inserted remainder of scenario {tail_inserted.src_id}, {tail_inserted.name}\n" + f"refinement needed at step: {remainder.steps[1]}") + tail_inserted.name = f"{tail_inserted.name} (part {tracestate.highest_part(tail_inserted.src_id)+1})" + tracestate.push_partial_scenario(tail_inserted.src_id, tail_inserted, model, remainder) + + +def generate_scenario_variant(scenario: Scenario, model: ModelSpace) -> Scenario | None: + scenario = scenario.copy() + # collect set of constraints + subs = SubstitutionMap() + try: + for step in scenario.steps: + for expr in step.model_info.get('MOD', []): + modded_arg, constraint = _parse_modifier_expression(expr, step.args) + if step.args[modded_arg].is_default: + continue + if step.args[modded_arg].kind in [StepArgument.EMBEDDED, StepArgument.POSITIONAL, StepArgument.NAMED]: + org_example = step.args[modded_arg].org_value + if step.gherkin_kw == 'then': + constraint = None # No new constraints are processed for then-steps + if org_example not in subs.substitutions: + # if a then-step signals the first use of an example value, it is considered a new definition + subs.substitute(org_example, [org_example]) + continue + if not constraint and org_example not in subs.substitutions: + raise ValueError(f"No options to choose from at first assignment to {org_example}") + if constraint and constraint != '.*': + options = model.process_expression(constraint, step.args) + if options == 'exec': + raise ValueError(f"Invalid constraint for argument substitution: {expr}") + if not options: + raise ValueError(f"Constraint on modifer did not yield any options: {expr}") + if not is_list_like(options): + raise ValueError(f"Constraint on modifer did not yield a set of options: {expr}") + else: + options = None + subs.substitute(org_example, options) + elif step.args[modded_arg].kind == StepArgument.VAR_POS: + if step.args[modded_arg].value: + modded_varargs = model.process_expression(constraint, step.args) + if not is_list_like(modded_varargs): + raise ValueError(f"Modifying varargs must yield a list of arguments") + # Varargs are not added to the substitution map, but are used directly as-is. A modifier can + # change the number of arguments in the list, making it impossible to decide which values to + # match and which to drop and/or duplicate. + step.args[modded_arg].value = modded_varargs + elif step.args[modded_arg].kind == StepArgument.FREE_NAMED: + if step.args[modded_arg].value: + modded_free_args = model.process_expression(constraint, step.args) + if not isinstance(modded_free_args, dict): + raise ValueError("Modifying free named arguments must yield a dict") + # Similar to varargs, modified free named arguments are used directly as-is. + step.args[modded_arg].value = modded_free_args + else: + raise AssertionError(f"Unknown argument kind for {modded_arg}") + except Exception as err: + logger.debug(f"Unable to insert scenario {scenario.src_id}, {scenario.name}, due to modifier\n" + f" In step {step}: {err}") + return None + + try: + subs.solve() + except ValueError as err: + logger.debug(f"Unable to insert scenario {scenario.src_id}, {scenario.name}, due to modifier\n" + f" {err}: {subs}") + return None + + # Update scenario with generated values + if subs.solution: + logger.debug(f"Example variant generated with argument substitution: {subs}") + scenario.data_choices = subs + for step in scenario.steps: + if 'MOD' in step.model_info: + for expr in step.model_info['MOD']: + modded_arg, _ = _parse_modifier_expression(expr, step.args) + if step.args[modded_arg].is_default: + continue + org_example = step.args[modded_arg].org_value + if step.args[modded_arg].kind in [StepArgument.EMBEDDED, StepArgument.POSITIONAL, StepArgument.NAMED]: + step.args[modded_arg].value = subs.solution[org_example] + return scenario + + +def _parse_modifier_expression(expression: str, args: StepArguments) -> tuple[str, str]: + if expression.startswith('${'): + for var in args: + if expression.casefold().startswith(var.arg.casefold()): + assignment_expr = expression.replace(var.arg, '', 1).strip() + if not assignment_expr.startswith('=') or assignment_expr.startswith('=='): + break # not an assignment + constraint = assignment_expr.replace('=', '', 1).strip() + return var.arg, constraint + raise ValueError(f"Invalid argument substitution: {expression}") + + +def rewind(tracestate: TraceState, drought_recovery: bool = False) -> TraceSnapShot | None: + if tracestate[-1].remainder and tracestate.highest_part(tracestate[-1].remainder.src_id) > 1: + # When rewinding an 'in between' part, rewind both the part and the refinement + tracestate.rewind() + tail = tracestate.rewind() + while drought_recovery and tracestate.coverage_drought: + tail = tracestate.rewind() + return tail diff --git a/robotmbt/modelspace.py b/robotmbt/modelspace.py index f30c9012..1504a7fd 100644 --- a/robotmbt/modelspace.py +++ b/robotmbt/modelspace.py @@ -31,6 +31,7 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import copy +from typing import Any from .steparguments import StepArguments @@ -46,7 +47,7 @@ def __init__(self, reference_id=None): self.props: dict[str, RecursiveScope | ModelSpace] = dict() # For using literals without having to use quotes (abc='abc') - self.values: dict[str, any] = dict() + self.values: dict[str, Any] = dict() self.scenario_vars: list[RecursiveScope] = [] self.std_attrs = dir(self) @@ -96,7 +97,7 @@ def end_scenario_scope(self): else: self.props.pop('scenario') - def process_expression(self, expression: str, step_args: StepArguments = StepArguments()) -> any: + def process_expression(self, expression: str, step_args: StepArguments = StepArguments()) -> Any: expr = step_args.fill_in_args(expression.strip(), as_code=True) if self._is_new_vocab_expression(expr): self.add_prop(self._vocab_term(expr)) diff --git a/robotmbt/steparguments.py b/robotmbt/steparguments.py index 42489581..06980151 100644 --- a/robotmbt/steparguments.py +++ b/robotmbt/steparguments.py @@ -32,6 +32,7 @@ from keyword import iskeyword import builtins +from typing import Any class StepArguments(list): @@ -64,27 +65,27 @@ class StepArgument: NAMED = 'NAMED' FREE_NAMED = 'FREE_NAMED' - def __init__(self, arg_name: str, value: any, kind: str | None = None, is_default: bool = False): + def __init__(self, arg_name: str, value: Any, kind: str | None = None, is_default: bool = False): self.name: str = arg_name - self.org_value: any = value + self.org_value: Any = value self.kind: str | None = kind # one of the values from the kind list - self._value: any = None + self._value: Any = None self._codestr: str | None = None - self.value: any = value - self.is_default: bool = is_default # indicates that the argument was not - # filled in from the scenario. This argment's value is taken - # from the keyword's default as provided by Robot. + self.value: Any = value + # is_default indicates that the argument was not filled in from the scenario. This + # argment's value is taken from the keyword's default as provided by Robot. + self.is_default: bool = is_default @property def arg(self) -> str: return "${%s}" % self.name @property - def value(self) -> any: + def value(self) -> Any: return self._value @value.setter - def value(self, value: any): + def value(self, value: Any): self._value = value self._codestr = self.make_codestring(value) self.is_default = False @@ -107,7 +108,7 @@ def __str__(self): return f"{self.name}={self.value}" @staticmethod - def make_codestring(text: any) -> str: + def make_codestring(text: Any) -> str: codestr = str(text) if codestr.title() in ['None', 'True', 'False']: return codestr.title() @@ -118,7 +119,7 @@ def make_codestring(text: any) -> str: return codestr @staticmethod - def make_identifier(s: any) -> str: + def make_identifier(s: Any) -> str: _s = str(s).replace(' ', '_') if _s.isidentifier(): return f"{_s}_" if iskeyword(_s) or _s in dir(builtins) else _s diff --git a/robotmbt/substitutionmap.py b/robotmbt/substitutionmap.py index b47ada71..ea7648af 100644 --- a/robotmbt/substitutionmap.py +++ b/robotmbt/substitutionmap.py @@ -56,9 +56,7 @@ def __str__(self): def copy(self): # -> Self new = SubstitutionMap() - new.substitutions = {k: v.copy() - for k, v in self.substitutions.items()} - + new.substitutions = {k: v.copy() for k, v in self.substitutions.items()} new.solution = self.solution.copy() return new diff --git a/robotmbt/suitedata.py b/robotmbt/suitedata.py index bfa41c74..11a796d9 100644 --- a/robotmbt/suitedata.py +++ b/robotmbt/suitedata.py @@ -31,6 +31,7 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import copy +from typing import Any from robot.running.arguments.argumentvalidator import ArgumentValidator import robot.utils.notset @@ -40,7 +41,7 @@ class Suite: - def __init__(self, name: str, parent=None): + def __init__(self, name: str, parent: Any = None): self.name: str = name self.filename: str = '' self.parent: Suite | None = parent @@ -62,30 +63,22 @@ def has_error(self) -> bool: # list[Step | str | None], Step needs to be moved up def steps_with_errors(self): return (([self.setup] if self.setup and self.setup.has_error() else []) - + [e for s in map(Suite.steps_with_errors, self.suites) - for e in s] - + [e for s in map(Scenario.steps_with_errors, - self.scenarios) for e in s] + + [e for s in map(Suite.steps_with_errors, self.suites) for e in s] + + [e for s in map(Scenario.steps_with_errors, self.scenarios) for e in s] + ([self.teardown] if self.teardown and self.teardown.has_error() else [])) class Scenario: - def __init__(self, name: str, parent=None): + def __init__(self, name: str, parent: Suite | None = None): self.name: str = name - - # Parent scenario for easy searching, processing and referencing + # Parent scenario is kept for easy searching, processing and referencing # after steps and scenarios have been potentially moved around self.parent: Suite | None = parent - - # Can be a single step or None, may also be a str in tests self.setup: Step | None = None - - # Can be a single step or None, may also be a str in tests self.teardown: Step | None = None - self.steps: list[Step] = [] self.src_id: int | None = None - self.data_choices: dict | SubstitutionMap = {} # may be Dummy type in a test + self.data_choices: dict | SubstitutionMap = {} # may be Dummy type in a test @property def longname(self) -> str: @@ -128,30 +121,33 @@ def split_at_step(self, stepindex: int): class Step: def __init__(self, steptext: str, *args, parent: Suite | Scenario, assign: tuple[str] = (), prev_gherkin_kw: str | None = None): - # first keyword cell of the Robot line, including step_kw, + # org_step is the first keyword cell of the Robot line, including step_kw, # excluding positional args, excluding variable assignment. self.org_step: str = steptext - # positional and named arguments as parsed from Robot text ('posA' , 'posB', 'named1=namedA') + + # org_pn_args are the positional and named arguments as parsed + # from the Robot text ('posA' , 'posB', 'named1=namedA') self.org_pn_args = args # Parent scenario for easy searching and processing. self.parent: Suite | Scenario = parent # For when a keyword's return value is assigned to a variable. # Taken directly from Robot. self.assign: tuple[str] = assign - # 'given', 'when', 'then' or None for non-bdd keywords. + + # gherkin_kw is one of 'given', 'when', 'then', or None for non-bdd keywords. self.gherkin_kw: str | None = self.step_kw \ if str(self.step_kw).lower() in ['given', 'when', 'then', 'none'] \ else prev_gherkin_kw + # Robot keyword with its embedded arguments in ${...} notation. self.signature: str | None = None # embedded arguments list of StepArgument objects. self.args: StepArguments = StepArguments() # Decouples StepArguments from the step text (refinement use case) self.detached: bool = False - # Modelling information is available as a dictionary. - # TODO: Maybe use a data structure for this instead of a dict with specific keys. - # The standard format is dict(IN=[], OUT=[]) and can - # optionally contain an error field. + + # model_info contains modelling information as a dictionary. The standard format is + # dict(IN=[], OUT=[]) and can optionally contain an error field. # IN and OUT are lists of Python evaluatable expressions. # The `new vocab` form can be used to create new domain objects. # The `vocab.attribute` form can then be used to express relations @@ -194,11 +190,11 @@ def keyword(self) -> str: return self.args.fill_in_args(s) @property - def posnom_args_str(self) -> tuple[any]: + def posnom_args_str(self) -> tuple[Any]: """A tuple with all arguments in Robot accepted text format ('posA' , 'posB', 'named1=namedA')""" if self.detached or not self.args.modified: return self.org_pn_args - result: list[any] = [] + result: list[Any] = [] for arg in self.args: if arg.is_default: continue @@ -246,7 +242,7 @@ def add_robot_dependent_data(self, robot_kw): self.args += self.__handle_non_embedded_arguments(robot_kw.args) self.signature = robot_kw.name - self.model_info = self.__parse_model_info(robot_kw._doc) + self.model_info: dict[str, list[str] | str] = self.__parse_model_info(robot_kw._doc) except Exception as ex: self.model_info['error'] = str(ex) diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index be08ee94..c8064434 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -32,15 +32,14 @@ import copy import random +from typing import Any from robot.api import logger -from robot.utils import is_list_like -from .substitutionmap import SubstitutionMap +from . import modeller from .modelspace import ModelSpace -from .suitedata import Suite, Scenario, Step -from .tracestate import TraceState, TraceSnapShot -from .steparguments import StepArgument, StepArguments +from .suitedata import Suite, Scenario +from .tracestate import TraceState try: from .visualise.visualiser import Visualiser @@ -88,7 +87,7 @@ def flatten(self, in_suite: Suite) -> Suite: out_suite.suites = [] return out_suite - def process_test_suite(self, in_suite: Suite, *, seed: any = 'new', graph: str = '', + def process_test_suite(self, in_suite: Suite, *, seed: Any = 'new', graph: str = '', to_json: bool = False, from_json: str = 'false') -> Suite: self.out_suite = Suite(in_suite.name) self.out_suite.filename = in_suite.filename @@ -109,10 +108,9 @@ def process_test_suite(self, in_suite: Suite, *, seed: any = 'new', graph: str = def _load_graph(self, graph:str, suite_name: str, from_json: str): traceinfo = TraceInfo() traceinfo = traceinfo.import_graph(from_json) - self.visualiser = Visualiser( - graph, suite_name, trace_info=traceinfo) + self.visualiser = Visualiser(graph, suite_name, trace_info=traceinfo) - def _run_test_suite(self, seed: any, graph: str, suite_name: str, to_json: bool): + def _run_test_suite(self, seed: Any, graph: str, suite_name: str, to_json: bool): for id, scenario in enumerate(self.flat_suite.scenarios, start=1): scenario.src_id = id self.scenarios = self.flat_suite.scenarios[:] @@ -120,88 +118,105 @@ def _run_test_suite(self, seed: any, graph: str, suite_name: str, to_json: bool) "\n\t".join([f"{s.src_id}: {s.name}" for s in self.scenarios])) init_seed = self._init_randomiser(seed) - random.shuffle(self.scenarios) + self.shuffled = [s.src_id for s in self.scenarios] + random.shuffle(self.shuffled) # Keep a single shuffle for all TraceStates (non-essential) self.visualiser = None if graph != '' and VISUALISE: - self.visualiser = Visualiser(graph, suite_name, init_seed, to_json) # Pass suite name + try: + self.visualiser = Visualiser(graph, suite_name, init_seed, to_json) + except Exception as e: + self.visualiser = None + logger.warn(f'Could not initialise visualiser due to error!\n{e}') + elif graph != '' and not VISUALISE: logger.warn(f'Visualisation {graph} requested, but required dependencies are not installed.' - 'Install them with `pip install .[visualization]`.') + 'Refer to the README on how to install these dependencies.') # a short trace without the need for repeating scenarios is preferred - self._try_to_reach_full_coverage(allow_duplicate_scenarios=False) + tracestate = self._try_to_reach_full_coverage(allow_duplicate_scenarios=False) - if not self.tracestate.coverage_reached(): + if not tracestate.coverage_reached(): logger.debug("Direct trace not available. Allowing repetition of scenarios") - self._try_to_reach_full_coverage(allow_duplicate_scenarios=True) - if not self.tracestate.coverage_reached(): - self.__write_visualisation() + tracestate = self._try_to_reach_full_coverage(allow_duplicate_scenarios=True) + if not tracestate.coverage_reached(): raise Exception("Unable to compose a consistent suite") - self.out_suite.scenarios = self.tracestate.get_trace() - self._report_tracestate_wrapup() - - def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool): - self.tracestate = TraceState(len(self.scenarios)) - self.active_model = ModelSpace() - while not self.tracestate.coverage_reached(): - i_candidate = self.tracestate.next_candidate( - retry=allow_duplicate_scenarios) - if i_candidate is None: - if not self.tracestate.can_rewind(): - break - tail = self._rewind() - logger.debug("Having to roll back up to " - f"{tail.scenario.name if tail else 'the beginning'}") - self._report_tracestate_to_user() + self.out_suite.scenarios = tracestate.get_trace() + def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool) -> TraceState: + tracestate = TraceState(self.shuffled) + while not tracestate.coverage_reached(): + candidate_id = tracestate.next_candidate(retry=allow_duplicate_scenarios) + self.__update_visualisation(tracestate) + if candidate_id is None: # No more candidates remaining for this level + if not tracestate.can_rewind(): + break + tail = modeller.rewind(tracestate) + logger.debug(f"Having to roll back up to {tail.scenario.name if tail else 'the beginning'}") + self._report_tracestate_to_user(tracestate) + self.__update_visualisation(tracestate) else: - self.active_model.new_scenario_scope() - inserted = self._try_to_fit_in_scenario(i_candidate, self._scenario_with_repeat_counter(i_candidate), - retry_flag=allow_duplicate_scenarios) - - self.__update_visualisation() - - if inserted: + candidate = self._select_scenario_variant(candidate_id, tracestate) + if not candidate: # No valid variant available in the current state + tracestate.reject_scenario(candidate_id) + self.__update_visualisation(tracestate) + continue + previous_len = len(tracestate) + modeller.try_to_fit_in_scenario(candidate, tracestate) + self.__update_visualisation(tracestate) + self._report_tracestate_to_user(tracestate) + if len(tracestate) > previous_len: + logger.debug(f"last state:\n{tracestate.model.get_status_text()}") self.DROUGHT_LIMIT = 50 - if self.__last_candidate_changed_nothing(): - logger.debug( - "Repeated scenario did not change the model's state. Stop trying.") - self._rewind() - - elif self.tracestate.coverage_drought > self.DROUGHT_LIMIT: + if self.__last_candidate_changed_nothing(tracestate): + logger.debug("Repeated scenario did not change the model's state. Stop trying.") + modeller.rewind(tracestate) + self.__update_visualisation(tracestate) + elif tracestate.coverage_drought > self.DROUGHT_LIMIT: logger.debug(f"Went too long without new coverage (>{self.DROUGHT_LIMIT}x). " "Roll back to last coverage increase and try something else.") - self._rewind(drought_recovery=True) - self._report_tracestate_to_user() - logger.debug( - f"last state:\n{self.active_model.get_status_text()}") - self.__update_visualisation() + modeller.rewind(tracestate, drought_recovery=True) + self.__update_visualisation(tracestate) + self._report_tracestate_to_user(tracestate) + logger.debug(f"last state:\n{tracestate.model.get_status_text()}") + return tracestate + - def __update_visualisation(self): + def __update_visualisation(self, tracestate: TraceState): if self.visualiser is not None: - self.visualiser.update_trace(self.tracestate, self.active_model) + try: + self.visualiser.update_trace(tracestate) + except Exception as e: + logger.warn(f'Could not update visualisation due to error!\n{e}') def __write_visualisation(self): if self.visualiser is not None: - logger.info(self.visualiser.generate_visualisation(), html=True) + try: + logger.info(self.visualiser.generate_visualisation(), html=True) + except Exception as e: + logger.warn(f'Could not generate visualisation due to error!\n{e}') - def __last_candidate_changed_nothing(self) -> bool: - if len(self.tracestate) < 2: + @staticmethod + def __last_candidate_changed_nothing(tracestate: TraceState) -> bool: + if len(tracestate) < 2: return False - - if self.tracestate[-1].id != self.tracestate[-2].id: + if tracestate[-1].id != tracestate[-2].id: return False + return tracestate[-1].model == tracestate[-2].model - return self.tracestate[-1].model == self.tracestate[-2].model - - def _scenario_with_repeat_counter(self, index: int) -> Scenario: - """Fetches the scenario by index and, if this scenario is already used in the trace, - adds a repetition counter to its name.""" - candidate = self.scenarios[index] - rep_count = self.tracestate.count(index) + def _select_scenario_variant(self, candidate_id: int, tracestate: TraceState) -> Scenario: + candidate = self._scenario_with_repeat_counter(candidate_id, tracestate) + candidate = modeller.generate_scenario_variant(candidate, tracestate.model or ModelSpace()) + return candidate + def _scenario_with_repeat_counter(self, index: int, tracestate: TraceState) -> Scenario: + """ + Fetches the scenario by index and, if this scenario is already + used in the trace, adds a repetition counter to its name. + """ + candidate = next(s for s in self.scenarios if s.src_id == index) + rep_count = tracestate.count(index) if rep_count: candidate = candidate.copy() candidate.name = f"{candidate.name} (rep {rep_count + 1})" @@ -217,346 +232,20 @@ def _fail_on_step_errors(suite: Suite): for s in error_list]) raise Exception(err_msg) - def _try_to_fit_in_scenario(self, index: int, candidate: Scenario, retry_flag: bool) -> bool: - candidate = self._generate_scenario_variant( - candidate, self.active_model) - - if not candidate: - self.active_model.end_scenario_scope() - self.tracestate.reject_scenario(index) - self._report_tracestate_to_user() - return False - - confirmed_candidate, new_model = self._process_scenario( - candidate, self.active_model) - - if confirmed_candidate: - self.active_model = new_model - self.active_model.end_scenario_scope() - self.tracestate.confirm_full_scenario( - index, confirmed_candidate, self.active_model) - logger.debug( - f"Inserted scenario {confirmed_candidate.src_id}, {confirmed_candidate.name}") - self._report_tracestate_to_user() - logger.debug(f"last state:\n{self.active_model.get_status_text()}") - self.__update_visualisation() - return True - - part1, part2 = self._split_candidate_if_refinement_needed( - candidate, self.active_model) - if part2: - exit_conditions = part2.steps[1].model_info['OUT'] - part1.name = f"{part1.name} (part {self.tracestate.highest_part(index) + 1})" - part1, new_model = self._process_scenario(part1, self.active_model) - self.tracestate.push_partial_scenario(index, part1, new_model) - self.active_model = new_model - self._report_tracestate_to_user() - logger.debug(f"last state:\n{self.active_model.get_status_text()}") - - i_refine = self.tracestate.next_candidate(retry=retry_flag) - if i_refine is None: - logger.debug( - "Refinement needed, but there are no scenarios left") - self._rewind() - self._report_tracestate_to_user() - self.__update_visualisation() - return False - - while i_refine is not None: - self.__update_visualisation() - self.active_model.new_scenario_scope() - m_inserted = self._try_to_fit_in_scenario(i_refine, self._scenario_with_repeat_counter(i_refine), - retry_flag) - self.__update_visualisation() - if m_inserted: - insert_valid_here = True - try: - # Check exit condition before finalizing refinement and inserting the tail part - model_scratchpad = self.active_model.copy() - for expr in exit_conditions: - if model_scratchpad.process_expression(expr, part2.steps[1].args) is False: - insert_valid_here = False - break - except Exception: - insert_valid_here = False - - if insert_valid_here: - m_finished = self._try_to_fit_in_scenario( - index, part2, retry_flag) - if m_finished: - self.__update_visualisation() - return True - else: - logger.debug( - f"Scenario did not meet refinement conditions {exit_conditions}") - logger.debug( - f"last state:\n{self.active_model.get_status_text()}") - logger.debug( - f"Reconsidering {self.scenarios[i_refine].name}, scenario excluded") - self._rewind() - self._report_tracestate_to_user() - - self.__update_visualisation() - i_refine = self.tracestate.next_candidate(retry=retry_flag) - - self.__update_visualisation() - - self._rewind() - self._report_tracestate_to_user() - self.__update_visualisation() - return False - - self.active_model.end_scenario_scope() - self.tracestate.reject_scenario(index) - self._report_tracestate_to_user() - self.__update_visualisation() - return False - - def _rewind(self, drought_recovery: bool = False) -> TraceSnapShot | None: - tail = self.tracestate.rewind() - - while drought_recovery and self.tracestate.coverage_drought: - tail = self.tracestate.rewind() - - self.active_model = self.tracestate.model or ModelSpace() - return tail - - @staticmethod - def _split_candidate_if_refinement_needed(scenario: Scenario, model: ModelSpace) \ - -> tuple[Scenario, Scenario | None]: - m = model.copy() - scenario = scenario.copy() - no_split = (scenario, None) - for step in scenario.steps: - if 'error' in step.model_info: - return no_split - - if step.gherkin_kw in ['given', 'when', None]: - for expr in step.model_info.get('IN', []): - try: - if m.process_expression(expr, step.args) is False: - return no_split - except Exception: - return no_split - - if step.gherkin_kw in ['when', 'then', None]: - for expr in step.model_info.get('OUT', []): - refine_here = False - try: - if m.process_expression(expr, step.args) is False: - if step.gherkin_kw in ['when', None]: - logger.debug( - f"Refinement needed for scenario: {scenario.name}\nat step: {step}") - refine_here = True - else: - return no_split - - except Exception: - return no_split - - if refine_here: - front, back = scenario.split_at_step( - scenario.steps.index(step)) - remaining_steps = '\n\t'.join( - [step.full_keyword, '- ' * 35] + [s.full_keyword for s in back.steps[1:]]) - remaining_steps = SuiteProcessors.escape_robot_vars( - remaining_steps) - edge_step = Step( - 'Log', f"Refinement follows for step:\n\t{remaining_steps}", parent=scenario) - edge_step.gherkin_kw = step.gherkin_kw - edge_step.model_info = dict( - IN=step.model_info['IN'], OUT=[]) - edge_step.detached = True - edge_step.args = StepArguments(step.args) - front.steps.append(edge_step) - back.steps.insert( - 0, Step('Log', f"Refinement ready, completing step", parent=scenario)) - back.steps[1] = back.steps[1].copy() - back.steps[1].model_info['IN'] = [] - return (front, back) - - return no_split - - @staticmethod - def escape_robot_vars(text: str) -> str: - for seq in ("${", "@{", "%{", "&{", "*{"): - text = text.replace(seq, "\\" + seq) - - return text - @staticmethod - def _process_scenario(scenario: Scenario, model: ModelSpace) -> tuple[Scenario, ModelSpace] | tuple[None, None]: - m = model.copy() - scenario = scenario.copy() - for step in scenario.steps: - if 'error' in step.model_info: - logger.debug( - f"Error in scenario {scenario.name} at step {step}: {step.model_info['error']}") - return None, None - - for expr in SuiteProcessors._relevant_expressions(step): - try: - if m.process_expression(expr, step.args) is False: - raise Exception(False) - except Exception as err: - logger.debug(f"Unable to insert scenario {scenario.src_id}, {scenario.name}, " - f"due to step '{step}': [{expr}] {err}") - return None, None - - return scenario, m + def _report_tracestate_to_user(tracestate: TraceState): + user_trace = f"[{', '.join(tracestate.id_trace)}]" + logger.debug(f"Trace: {user_trace} Reject: {list(tracestate.tried)}") @staticmethod - def _relevant_expressions(step: Step) -> list[str | list[str]]: - if step.gherkin_kw is None and not step.model_info: - return [] # model info is optional for action keywords - - expressions = [] - if 'IN' not in step.model_info or 'OUT' not in step.model_info: - raise Exception(f"Model info incomplete for step: {step}") - - if step.gherkin_kw in ['given', 'when', None]: - expressions += step.model_info['IN'] - - if step.gherkin_kw in ['when', 'then', None]: - expressions += step.model_info['OUT'] - - return expressions - - def _generate_scenario_variant(self, scenario: Scenario, model: ModelSpace) -> Scenario | None: - m = model.copy() - scenario = scenario.copy() - scenarios_in_refinement = self.tracestate.find_scenarios_with_active_refinement() - - # reuse previous solution for all parts in split-up scenario - for sir in scenarios_in_refinement: - if sir.src_id == scenario.src_id: - return scenario - - # collect set of constraints - subs = SubstitutionMap() - try: - for step in scenario.steps: - if 'MOD' in step.model_info: - for expr in step.model_info['MOD']: - modded_arg, constraint = self._parse_modifier_expression( - expr, step.args) - if step.args[modded_arg].is_default: - continue - if step.args[modded_arg].kind in [StepArgument.EMBEDDED, StepArgument.POSITIONAL, - StepArgument.NAMED]: - org_example = step.args[modded_arg].org_value - if step.gherkin_kw == 'then': - constraint = None # No new constraints are processed for then-steps - if org_example not in subs.substitutions: - # if a then-step signals the first use of an example value, it is considered a new definition - subs.substitute(org_example, [org_example]) - continue - if not constraint and org_example not in subs.substitutions: - raise ValueError( - f"No options to choose from at first assignment to {org_example}") - if constraint and constraint != '.*': - options = m.process_expression( - constraint, step.args) - if options == 'exec': - raise ValueError( - f"Invalid constraint for argument substitution: {expr}") - if not options: - raise ValueError( - f"Constraint on modifer did not yield any options: {expr}") - if not is_list_like(options): - raise ValueError( - f"Constraint on modifer did not yield a set of options: {expr}") - else: - options = None - subs.substitute(org_example, options) - elif step.args[modded_arg].kind == StepArgument.VAR_POS: - if step.args[modded_arg].value: - modded_varargs = m.process_expression( - constraint, step.args) - if not is_list_like(modded_varargs): - raise ValueError( - f"Modifying varargs must yield a list of arguments") - # Varargs are not added to the substitution map, but are used directly as-is. A modifier can - # change the number of arguments in the list, making it impossible to decide which values to - # match and which to drop and/or duplicate. - step.args[modded_arg].value = modded_varargs - elif step.args[modded_arg].kind == StepArgument.FREE_NAMED: - if step.args[modded_arg].value: - modded_free_args = m.process_expression( - constraint, step.args) - if not isinstance(modded_free_args, dict): - raise ValueError( - "Modifying free named arguments must yield a dict") - # Similar to varargs, modified free named arguments are used directly as-is. - step.args[modded_arg].value = modded_free_args - else: - raise AssertionError( - f"Unknown argument kind for {modded_arg}") - except Exception as err: - logger.debug(f"Unable to insert scenario {scenario.src_id}, {scenario.name}, due to modifier\n" - f" In step {step}: {err}") - return None - - try: - subs.solve() - except ValueError as err: - logger.debug( - f"Unable to insert scenario {scenario.src_id}, {scenario.name}, due to modifier\n {err}: {subs}") - return None - - # Update scenario with generated values - if subs.solution: - logger.debug( - f"Example variant generated with argument substitution: {subs}") - scenario.data_choices = subs - - for step in scenario.steps: - if 'MOD' in step.model_info: - for expr in step.model_info['MOD']: - modded_arg, _ = self._parse_modifier_expression( - expr, step.args) - if step.args[modded_arg].is_default: - continue - org_example = step.args[modded_arg].org_value - if step.args[modded_arg].kind in [StepArgument.EMBEDDED, StepArgument.POSITIONAL, - StepArgument.NAMED]: - step.args[modded_arg].value = subs.solution[org_example] - return scenario - - @staticmethod - def _parse_modifier_expression(expression: str, args: StepArguments) -> tuple[str, str]: - if expression.startswith('${'): - for var in args: - if expression.casefold().startswith(var.arg.casefold()): - assignment_expr = expression.replace( - var.arg, '', 1).strip() - - if not assignment_expr.startswith('=') or assignment_expr.startswith('=='): - break # not an assignment - - constraint = assignment_expr.replace('=', '', 1).strip() - return var.arg, constraint - - raise ValueError(f"Invalid argument substitution: {expression}") - - def _report_tracestate_to_user(self): - user_trace = "[" - for snapshot in self.tracestate: - part = f".{snapshot.id.split('.')[1]}" if '.' in snapshot.id else "" - user_trace += f"{snapshot.scenario.src_id}{part}, " - - user_trace = user_trace[:-2] + "]" if ',' in user_trace else "[]" - reject_trace = [self.scenarios[i].src_id for i in self.tracestate.tried] - logger.debug(f"Trace: {user_trace} Reject: {reject_trace}") - - def _report_tracestate_wrapup(self): + def _report_tracestate_wrapup(tracestate: TraceState): logger.info("Trace composed:") - for step in self.tracestate: - logger.info(step.scenario.name) - logger.debug(f"model\n{step.model.get_status_text()}\n") + for progression in tracestate: + logger.info(progression.scenario.name) + logger.debug(f"model\n{progression.model.get_status_text()}\n") @staticmethod - def _init_randomiser(seed: any) -> str: + def _init_randomiser(seed: Any) -> str: if isinstance(seed, str): seed = seed.strip() @@ -578,17 +267,15 @@ def _init_randomiser(seed: any) -> str: def _generate_seed() -> str: """Creates a random string of 5 words between 3 and 6 letters long""" vowels = ['a', 'e', 'i', 'o', 'u', 'y'] - consonants = ['b', 'c', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'm', 'n', 'p', 'q', 'r', 's', 't', 'v', 'w', 'x', - 'z'] + consonants = ['b', 'c', 'd', 'f', 'g', 'h', 'j', 'k', 'l', + 'm', 'n', 'p', 'q', 'r', 's', 't', 'v', 'w', 'x', 'z'] words = [] for word in range(5): prior_choice = random.choice([vowels, consonants]) last_choice = random.choice([vowels, consonants]) - - # add first two letters - string = random.choice(prior_choice) + random.choice(last_choice) - for letter in range(random.randint(1, 4)): # add 1 to 4 more letters + string = random.choice(prior_choice) + random.choice(last_choice) # add first two letters + for letter in range(random.randint(1, 4)): # add 1 to 4 more letters if prior_choice is last_choice: new_choice = consonants if prior_choice is vowels else vowels else: diff --git a/robotmbt/suitereplacer.py b/robotmbt/suitereplacer.py index 1ed38507..d44b83d2 100644 --- a/robotmbt/suitereplacer.py +++ b/robotmbt/suitereplacer.py @@ -1,9 +1,4 @@ # -*- coding: utf-8 -*- -from .suitedata import Suite, Scenario, Step -from .suiteprocessors import SuiteProcessors -import robot.running.model as rmodel -from robot.api import logger -from robot.api.deco import keyword # BSD 3-Clause License # @@ -35,6 +30,15 @@ # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +from typing import Any + +import robot.model + +from .suitedata import Suite, Scenario, Step +from .suiteprocessors import SuiteProcessors +import robot.running.model as rmodel +from robot.api import logger +from robot.api.deco import keyword from robot.libraries.BuiltIn import BuiltIn Robot = BuiltIn() @@ -46,13 +50,13 @@ class SuiteReplacer: def __init__(self, processor: str = 'process_test_suite', processor_lib: str | None = None): self.ROBOT_LIBRARY_LISTENER = self # : Self - self.current_suite: Suite | None = None - self.robot_suite: Suite | None = None + self.current_suite: robot.model.TestSuite | None = None + self.robot_suite: robot.model.TestSuite | None = None self.processor_lib_name: str | None = processor_lib self.processor_name: str = processor self._processor_lib: SuiteProcessors | None = None - self._processor_method = None - self.processor_options = {} + self._processor_method: Any = None + self.processor_options: dict[str, Any] = {} @property def processor_lib(self) -> SuiteProcessors: @@ -67,10 +71,7 @@ def processor_method(self): if not hasattr(self.processor_lib, self.processor_name): Robot.fail( f"Processor '{self.processor_name}' not available for model-based processor library {self.processor_lib_name}") - - self._processor_method = getattr( - self._processor_lib, self.processor_name) - + self._processor_method = getattr(self._processor_lib, self.processor_name) return self._processor_method @keyword(name="Treat this test suite Model-based") @@ -83,22 +84,18 @@ def treat_model_based(self, **kwargs): model info that is included in the test steps, the test cases are modifed, mixed and matched to create unique traces and achieve more test coverage quicker. - Any arguments are handled only locally. To apply arguments to subsequent suites as well, - use `Set model-based options` or `Update model-based options`. + Any arguments must be named arguments. They are passed on as options to the selected model-based + processor. If an option was already set on library level (See: `Set model-based options` and + `Update model-based options`, then these arguments take precedence over the library option and + affect only the current test suite. """ self.robot_suite = self.current_suite - logger.info( - f"Analysing Robot test suite '{self.robot_suite.name}' for model-based execution.") - - temp = self.processor_options.copy() - temp.update(kwargs) - master_suite = self.__process_robot_suite( - self.robot_suite, parent=None) - - modelbased_suite = self.processor_method( - master_suite, **temp) - + logger.info(f"Analysing Robot test suite '{self.robot_suite.name}' for model-based execution.") + local_settings = self.processor_options.copy() + local_settings.update(kwargs) + master_suite = self.__process_robot_suite(self.robot_suite, parent=None) + modelbased_suite = self.processor_method(master_suite, **local_settings) self.__clearTestSuite(self.robot_suite) self.__generateRobotSuite(modelbased_suite, self.robot_suite) @@ -118,7 +115,7 @@ def update_model_based_options(self, **kwargs): """ self.processor_options.update(kwargs) - def __process_robot_suite(self, in_suite: Suite, parent: Suite | None) -> Suite: + def __process_robot_suite(self, in_suite: robot.model.TestSuite, parent: Suite | None) -> Suite: out_suite = Suite(in_suite.name, parent) out_suite.filename = in_suite.source @@ -180,11 +177,11 @@ def __process_robot_suite(self, in_suite: Suite, parent: Suite | None) -> Suite: return out_suite - def __clearTestSuite(self, suite: Suite): + def __clearTestSuite(self, suite: robot.model.TestSuite): suite.tests.clear() suite.suites.clear() - def __generateRobotSuite(self, suite_model: Suite, target_suite): + def __generateRobotSuite(self, suite_model: Suite, target_suite: robot.model.TestSuite): for subsuite in suite_model.suites: new_suite = target_suite.suites.create(name=subsuite.name) new_suite.resource = target_suite.resource diff --git a/robotmbt/tracestate.py b/robotmbt/tracestate.py index 03941059..770b0b43 100644 --- a/robotmbt/tracestate.py +++ b/robotmbt/tracestate.py @@ -1,7 +1,4 @@ # -*- coding: utf-8 -*- -from robotmbt.modelspace import ModelSpace -from robotmbt.suitedata import Scenario - # BSD 3-Clause License # @@ -33,149 +30,161 @@ # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +from robotmbt.modelspace import ModelSpace +from robotmbt.suitedata import Scenario + class TraceSnapShot: - def __init__(self, id: str, inserted_scenario: str | Scenario, model_state: ModelSpace, drought: int = 0): + def __init__(self, id: str, inserted_scenario: Scenario | str, model_state: ModelSpace, remainder: Scenario | None = None, drought: int = 0): self.id: str = id self.scenario: str | Scenario = inserted_scenario - self.model: ModelSpace = model_state.copy() + self.remainder: Scenario | None = remainder + self._model: ModelSpace = model_state.copy() self.coverage_drought: int = drought + @property + def model(self): + return self._model.copy() -class TraceState: - def __init__(self, n_scenarios: int): - # coverage pool: True means scenario is in trace - self._c_pool: list[bool] = [False] * n_scenarios +class TraceState: + def __init__(self, scenario_indexes: list[int]): + self.c_pool = {index: 0 for index in scenario_indexes} + if len(self.c_pool) != len(scenario_indexes): + raise ValueError("Scenarios must be uniquely identifiable") + # Keeps track of the scenarios already tried at each step in the trace - self._tried: list[list[int]] = [[]] - - # Choice trace, when was which scenario inserted (e.g. ['1', '2.1', '3', '2.0']) - self._trace: list[str] = [] - + self._tried: list[list[int]] = [[]] + # Keeps details for elements in trace - self._snapshots: list[TraceSnapShot] = [] + self._snapshots: list[TraceSnapShot] = [] self._open_refinements: list[int] = [] @property def model(self) -> ModelSpace | None: """returns the model as it is at the end of the current trace""" - return self._snapshots[-1].model.copy() if self._trace else None + return self._snapshots[-1].model if self._snapshots else None @property def tried(self) -> tuple[int, ...]: """returns the indices that were rejected or previously inserted at the current position""" return tuple(self._tried[-1]) - def coverage_reached(self) -> bool: - return all(self._c_pool) - @property def coverage_drought(self) -> int: """Number of scenarios since last new coverage""" return self._snapshots[-1].coverage_drought if self._snapshots else 0 + @property + def id_trace(self): + return [snap.id for snap in self._snapshots] + + @property + def active_refinements(self): + return self._open_refinements[:] + + def coverage_reached(self): + return all(self.c_pool.values()) + def get_trace(self) -> list[str | Scenario]: return [snap.scenario for snap in self._snapshots] - def next_candidate(self, retry: bool = False) -> int | None: - for i in range(len(self._c_pool)): - if i not in self._tried[-1] and not self._is_refinement_active(i) and self.count(i) == 0: + def next_candidate(self, retry: bool=False): + for i in self.c_pool: + if i not in self._tried[-1] and not self.is_refinement_active(i) and self.count(i) == 0: return i if not retry: return None - - for i in range(len(self._c_pool)): - if i not in self._tried[-1] and not self._is_refinement_active(i): + for i in self.c_pool: + if i not in self._tried[-1] and not self.is_refinement_active(i): return i return None def count(self, index: int) -> int: - """Count the number of times the index is present in the trace. - unfinished partial scenarios are excluded.""" - return self._trace.count(str(index)) + self._trace.count(str(f"{index}.0")) + """ + Count the number of times the index is present in the trace. + unfinished partial scenarios are excluded. + """ + return self.c_pool[index] def highest_part(self, index: int) -> int: - """Given the current trace and an index, returns the highest part number of an ongoing - refinement for the related scenario. Returns 0 when there is no refinement active.""" - for i in range(1, len(self._trace) + 1): - if self._trace[-i] == f'{index}': + """ + Given the current trace and an index, returns the highest part number of an ongoing + refinement for the related scenario. Returns 0 when there is no refinement active. + """ + for i in range(1, len(self.id_trace)+1): + if self.id_trace[-i] == f'{index}': return 0 - - if self._trace[-i].startswith(f'{index}.'): - return int(self._trace[-i].split('.')[1]) - + if self.id_trace[-i].startswith(f'{index}.'): + return int(self.id_trace[-i].split('.')[1]) return 0 - def _is_refinement_active(self, index: int) -> bool: - return self.highest_part(index) != 0 - - def find_scenarios_with_active_refinement(self) -> list[str | Scenario]: - scenarios = [] - for i in self._open_refinements: - index = -self._trace[::-1].index(f'{i}.1') - 1 - scenarios.append(self._snapshots[index].scenario) + def is_refinement_active(self, index: int = None) -> bool: + """ + When called with an index, returns True if that scenario is currently being refined + When index is ommitted, return True if any refinement is active + """ + if index is None: + return self._open_refinements != [] + else: + return self.highest_part(index) != 0 - return scenarios + def get_remainder(self, index: int): + """ + When pushing a partial scenario, the remainder can be passed along for safe keeping. + This method retrieves the remainder for the last part that was pushed. + """ + last_part = self.highest_part(index) + index = -self.id_trace[::-1].index(f'{index}.{last_part}')-1 + return self._snapshots[index].remainder def reject_scenario(self, i_scenario: int): """Trying a scenario excludes it from further cadidacy on this level""" self._tried[-1].append(i_scenario) - def confirm_full_scenario(self, index: int, scenario: str, model: ModelSpace): - if not self._c_pool[index]: - self._c_pool[index] = True - c_drought = 0 - else: - c_drought = self.coverage_drought + 1 - - if self._is_refinement_active(index): + def confirm_full_scenario(self, index: int, scenario: Scenario | str, model: ModelSpace): + c_drought = 0 if self.c_pool[index] == 0 else self.coverage_drought+1 + self.c_pool[index] += 1 + if self.is_refinement_active(index): id = f"{index}.0" self._open_refinements.pop() else: id = str(index) self._tried[-1].append(index) self._tried.append([]) + self._snapshots.append(TraceSnapShot(id, scenario, model, drought=c_drought)) - self._trace.append(id) - self._snapshots.append(TraceSnapShot(id, scenario, model, c_drought)) - - def push_partial_scenario(self, index: int, scenario: str, model: ModelSpace): - if self._is_refinement_active(index): + def push_partial_scenario(self, index: int, scenario: Scenario | str, model: ModelSpace, remainder=None): + if self.is_refinement_active(index): id = f"{index}.{self.highest_part(index) + 1}" else: id = f"{index}.1" self._tried[-1].append(index) - self._tried.append([]) self._open_refinements.append(index) - self._trace.append(id) - self._snapshots.append(TraceSnapShot( - id, scenario, model, self.coverage_drought)) + self._tried.append([]) + self._snapshots.append(TraceSnapShot(id, scenario, model, remainder, self.coverage_drought)) def can_rewind(self) -> bool: - return len(self._trace) > 0 + return len(self._snapshots) > 0 def rewind(self) -> TraceSnapShot | None: - id = self._trace.pop() + id = self._snapshots[-1].id index = int(id.split('.')[0]) + self._snapshots.pop() if id.endswith('.0'): - self._snapshots.pop() + self.c_pool[index] -= 1 self._open_refinements.append(index) - while self._trace[-1] != f"{index}.1": + while self._snapshots[-1].id != f"{index}.1": self.rewind() return self.rewind() - self._snapshots.pop() - if '.' not in id or id.endswith('.1'): - if self.count(index) == 0: - self._c_pool[index] = False - self._tried.pop() - - if id.endswith('.1'): - self._open_refinements.pop() - + self._tried.pop() + if '.' not in id: + self.c_pool[index] -= 1 + if id.endswith('.1'): + self._open_refinements.pop() return self._snapshots[-1] if self._snapshots else None def __iter__(self): diff --git a/robotmbt/visualise/graphs/reducedSDVgraph.py b/robotmbt/visualise/graphs/reducedSDVgraph.py index b7147bef..f1662d9c 100644 --- a/robotmbt/visualise/graphs/reducedSDVgraph.py +++ b/robotmbt/visualise/graphs/reducedSDVgraph.py @@ -30,14 +30,14 @@ def _generate_equiv_class_label(equiv_class, old_labels): if len(equiv_class) == 1: return old_labels[set(equiv_class).pop()] else: - return "(merged: "+str(len(equiv_class))+")\n"+old_labels[set(equiv_class).pop()] + return "(merged: " + str(len(equiv_class)) + ")\n" + old_labels[set(equiv_class).pop()] def __init__(self, info: TraceInfo): super().__init__(info) old_labels = networkx.get_node_attributes(self.networkx, "label") self.networkx = networkx.quotient_graph(self.networkx, lambda x, y: self.chain_equiv(x, y), node_data=lambda equiv_class: { - 'label': self._generate_equiv_class_label(equiv_class, old_labels)}, + 'label': self._generate_equiv_class_label(equiv_class, old_labels)}, edge_data=lambda x, y: {'label': ''}) # TODO make generated label more obvious to be equivalence class nodes = self.networkx.nodes diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index c533dc76..3cb16790 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -1,4 +1,3 @@ -import logging from typing import Any from robot.api import logger diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py index faa9a2b9..af1b9cdb 100644 --- a/robotmbt/visualise/visualiser.py +++ b/robotmbt/visualise/visualiser.py @@ -25,7 +25,8 @@ def construct(cls, graph_type: str): # just calls __init__, but without having underscores etc. return cls(graph_type) - def __init__(self, graph_type: str, suite_name: str = "", seed: str = "", export: bool = False, trace_info: TraceInfo = None): + def __init__(self, graph_type: str, suite_name: str = "", seed: str = "", export: bool = False, + trace_info: TraceInfo = None): if graph_type != 'scenario' and graph_type != 'state' and graph_type != 'scenario-state' \ and graph_type != 'scenario-delta-value' and graph_type != 'reduced-sdv' \ and graph_type != 'delta-value': @@ -40,12 +41,35 @@ def __init__(self, graph_type: str, suite_name: str = "", seed: str = "", export self.export = export self.seed = seed - def update_trace(self, trace: TraceState, state: ModelSpace): - if len(trace.get_trace()) > 0: - self.trace_info.update_trace(ScenarioInfo( - trace.get_trace()[-1]), StateInfo(state), len(trace.get_trace())) + def update_trace(self, trace: TraceState): + """ + Uses the new snapshots from trace to update the trace info. + Multiple new snapshots can be pushed or popped at once. + """ + trace_len = len(trace._snapshots) + # We don't have any information + if trace_len == 0: + self.trace_info.update_trace(None, StateInfo(ModelSpace()), 0) + + # New snapshots have been pushed + elif trace_len > self.trace_info.previous_length: + prev = self.trace_info.previous_length + r = trace_len - prev + # Extract all snapshots that have been pushed and update our trace info with their scenario/model info + for i in range(r): + snap = trace._snapshots[prev + i] + scenario = snap.scenario + model = snap.model + if model is None: + model = ModelSpace + self.trace_info.update_trace(ScenarioInfo(scenario), StateInfo(model), prev + i + 1) + + # Snapshots have been removed else: - self.trace_info.update_trace(None, StateInfo(state), 0) + snap = trace._snapshots[-1] + scenario = snap.scenario + model = snap.model + self.trace_info.update_trace(ScenarioInfo(scenario), StateInfo(model), trace_len) def generate_visualisation(self) -> str: if self.export: @@ -66,4 +90,4 @@ def generate_visualisation(self) -> str: html_bokeh = networkvisualiser.NetworkVisualiser(graph, self.suite_name, self.seed).generate_html() - return f'' \ No newline at end of file + return f'' diff --git a/utest/test_steparguments.py b/utest/test_steparguments.py index 56dcdbf6..6c906ca6 100644 --- a/utest/test_steparguments.py +++ b/utest/test_steparguments.py @@ -160,10 +160,10 @@ def test_spaces_and_underscores_are_interchangable(self): self.assertEqual(arg1.codestring, arg2.codestring) def test_other_values_become_unique_identifiers(self): - valuelist = ['bar', 'foo bar', 'foo2bar', '${bar}', # strings - ' ', '\t', '\n', ' ', ' \n', '\a', # whitespace/non-printable - '#', '+-', '-+', '"', "'", 'パイ', # special characters - max, 'elif', 'import', 'new', 'del', # reserved words + valuelist = ['bar', 'foo bar', 'foo2bar', '${bar}', # strings + ' ', '\t', '\n', ' ', ' \n', '\a', # whitespace/non-printable + '#', '+-', '-+', '"', "'", 'パイ', # special characters + max, 'elif', 'import', 'new', 'del', # reserved words lambda x: x/2, self, unittest.TestCase] # functions and objects argsset = set() for v in valuelist: diff --git a/utest/test_substitutionmap.py b/utest/test_substitutionmap.py index 8c93171d..eb6df944 100644 --- a/utest/test_substitutionmap.py +++ b/utest/test_substitutionmap.py @@ -355,7 +355,8 @@ def test_adding_constraint_does_not_affect_undo_remove_stack(self): c.remove_option('four') c.add_constraint(['one', 'two']) self.assertCountEqual(c.optionset, ['two']) - c.undo_remove() # four was never in there, so isn't added, and three + # four was never in there, so isn't added, and three + c.undo_remove() # was removed by adding a constraint and is ignored. self.assertCountEqual(c.optionset, ['two']) c.undo_remove() diff --git a/utest/test_suitedata.py b/utest/test_suitedata.py index ef6e3866..ab9bb152 100644 --- a/utest/test_suitedata.py +++ b/utest/test_suitedata.py @@ -477,6 +477,19 @@ def map(x, y, z): return ([], []) def __iter__(_): return iter([]) +class StubStepArguments(list): + modified = True # trigger modified status to get arguments processed, rather then just echoed + + +class StubArgument(SimpleNamespace): + EMBEDDED = 'EMBEDDED' + POSITIONAL = 'POSITIONAL' + VAR_POS = 'VAR_POS' + NAMED = 'NAMED' + FREE_NAMED = 'FREE_NAMED' + + + class StubStepArguments(list): modified = True # trigger modified status to get arguments processed, rather then just echoed diff --git a/utest/test_tracestate.py b/utest/test_tracestate.py index 5bfb63a3..77c1462a 100644 --- a/utest/test_tracestate.py +++ b/utest/test_tracestate.py @@ -36,69 +36,72 @@ class TestTraceState(unittest.TestCase): def test_an_empty_tracestate_doesnt_do_so_much(self): - ts = TraceState(0) + ts = TraceState([]) self.assertIs(ts.next_candidate(), None) self.assertIs(ts.coverage_reached(), True) self.assertEqual(ts.get_trace(), []) self.assertIs(ts.can_rewind(), False) def test_completing_single_size_trace(self): - ts = TraceState(1) - self.assertEqual(ts.next_candidate(), 0) + ts = TraceState([1]) + self.assertEqual(ts.next_candidate(), 1) self.assertIs(ts.coverage_reached(), False) - ts.confirm_full_scenario(0, 'one', {}) + ts.confirm_full_scenario(1, 'one', {}) self.assertIs(ts.coverage_reached(), True) self.assertEqual(ts.get_trace(), ['one']) def test_confirming_excludes_scenario_from_candidacy(self): - ts = TraceState(1) - self.assertEqual(ts.next_candidate(), 0) - ts.confirm_full_scenario(0, 'one', {}) + ts = TraceState([1]) + self.assertEqual(ts.next_candidate(), 1) + ts.confirm_full_scenario(1, 'one', {}) self.assertIs(ts.next_candidate(), None) def test_trying_excludes_scenario_from_candidacy(self): - ts = TraceState(1) - self.assertEqual(ts.next_candidate(), 0) - ts.reject_scenario(0) + ts = TraceState([1]) + self.assertEqual(ts.next_candidate(), 1) + ts.reject_scenario(1) self.assertIs(ts.next_candidate(), None) def test_scenario_still_excluded_from_candidacy_after_rewind(self): - ts = TraceState(1) - self.assertEqual(ts.next_candidate(), 0) - ts.confirm_full_scenario(0, 'one', {}) + ts = TraceState([1]) + self.assertEqual(ts.next_candidate(), 1) + ts.confirm_full_scenario(1, 'one', {}) ts.rewind() self.assertIs(ts.next_candidate(), None) def test_candidates_come_in_order_when_accepted(self): - ts = TraceState(3) + ts = TraceState([10, 20, 30]) candidates = [] - for scenario in range(3): + for _ in range(3): candidates.append(ts.next_candidate()) - ts.confirm_full_scenario(candidates[-1], scenario, {}) + ts.confirm_full_scenario(candidates[-1], 'scenario', {}) candidates.append(ts.next_candidate()) - self.assertEqual(candidates, [0, 1, 2, None]) + self.assertEqual(candidates, [10, 20, 30, None]) + + def test_scenarios_must_be_uniquely_identifiable(self): + self.assertRaises(ValueError, TraceState, [1, 2, 3, 2]) def test_candidates_come_in_order_when_rejected(self): - ts = TraceState(3) + ts = TraceState([10, 20, 30]) candidates = [] for _ in range(3): candidates.append(ts.next_candidate()) ts.reject_scenario(candidates[-1]) candidates.append(ts.next_candidate()) - self.assertEqual(candidates, [0, 1, 2, None]) + self.assertEqual(candidates, [10, 20, 30, None]) def test_rejected_scenarios_are_candidates_for_new_positions(self): - ts = TraceState(3) + ts = TraceState([1, 2, 3]) candidates = [] - ts.reject_scenario(0) - for scenario in range(3): + ts.reject_scenario(1) + for _ in range(3): candidates.append(ts.next_candidate()) - ts.confirm_full_scenario(candidates[-1], scenario, {}) + ts.confirm_full_scenario(candidates[-1], 'scenario', {}) candidates.append(ts.next_candidate()) - self.assertEqual(candidates, [1, 0, 2, None]) + self.assertEqual(candidates, [2, 1, 3, None]) def test_previously_confirmed_scenarios_can_be_retried_if_no_new_candidates_exist(self): - ts = TraceState(3) + ts = TraceState(range(3)) first_candidate = ts.next_candidate(retry=True) ts.confirm_full_scenario(first_candidate, 'one', {}) ts.reject_scenario(ts.next_candidate(retry=True)) @@ -113,18 +116,18 @@ def test_previously_confirmed_scenarios_can_be_retried_if_no_new_candidates_exis self.assertEqual(ts.get_trace(), ['one', 'one', 'two', 'three']) def test_retry_can_continue_once_coverage_is_reached(self): - ts = TraceState(3) + ts = TraceState([1, 2, 3]) ts.confirm_full_scenario(ts.next_candidate(retry=True), 'one', {}) ts.confirm_full_scenario(ts.next_candidate(retry=True), 'two', {}) ts.confirm_full_scenario(ts.next_candidate(retry=True), 'three', {}) self.assertTrue(ts.coverage_reached()) - self.assertEqual(ts.next_candidate(retry=True), 0) - ts.reject_scenario(0) self.assertEqual(ts.next_candidate(retry=True), 1) + ts.reject_scenario(1) + self.assertEqual(ts.next_candidate(retry=True), 2) self.assertEqual(ts.next_candidate(retry=False), None) def test_count_scenario_repetitions(self): - ts = TraceState(2) + ts = TraceState([1, 2]) first = ts.next_candidate() self.assertEqual(ts.count(first), 0) ts.confirm_full_scenario(first, 'one', {}) @@ -133,8 +136,8 @@ def test_count_scenario_repetitions(self): self.assertEqual(ts.count(first), 2) def test_rewind_single_available_scenario(self): - ts = TraceState(1) - ts.confirm_full_scenario(0, 'one', {}) + ts = TraceState([1]) + ts.confirm_full_scenario(1, 'one', {}) self.assertIs(ts.coverage_reached(), True) self.assertIs(ts.can_rewind(), True) ts.rewind() @@ -144,38 +147,38 @@ def test_rewind_single_available_scenario(self): self.assertEqual(ts.get_trace(), []) def test_rewind_returns_none_after_rewinding_last_step(self): - ts = TraceState(1) - ts.confirm_full_scenario(0, 'one', {}) + ts = TraceState([1]) + ts.confirm_full_scenario(1, 'one', {}) self.assertIs(ts.rewind(), None) - def test_traces_can_have_multiple_sceanrios(self): - ts = TraceState(2) - ts.confirm_full_scenario(0, 'foo', dict(a=1)) + def test_traces_can_have_multiple_scenarios(self): + ts = TraceState([1, 2]) + ts.confirm_full_scenario(1, 'foo', dict(a=1)) self.assertIs(ts.coverage_reached(), False) - ts.confirm_full_scenario(1, 'bar', dict(b=2)) + ts.confirm_full_scenario(2, 'bar', dict(b=2)) self.assertIs(ts.coverage_reached(), True) self.assertEqual(ts.get_trace(), ['foo', 'bar']) def test_rewind_returns_snapshot_of_the_step_before(self): - ts = TraceState(2) - ts.confirm_full_scenario(0, 'foo', dict(a=1)) - ts.confirm_full_scenario(1, 'bar', dict(b=2)) + ts = TraceState([1, 2]) + ts.confirm_full_scenario(1, 'foo', dict(a=1)) + ts.confirm_full_scenario(2, 'bar', dict(b=2)) tail = ts.rewind() - self.assertEqual(tail.id, '0') + self.assertEqual(tail.id, '1') self.assertEqual(tail.scenario, 'foo') self.assertEqual(tail.model, dict(a=1)) def test_completing_size_three_trace(self): - ts = TraceState(3) - ts.confirm_full_scenario(ts.next_candidate(), 1, {}) - ts.confirm_full_scenario(ts.next_candidate(), 2, {}) + ts = TraceState(range(3)) + ts.confirm_full_scenario(ts.next_candidate(), 'one', {}) + ts.confirm_full_scenario(ts.next_candidate(), 'two', {}) self.assertIs(ts.coverage_reached(), False) - ts.confirm_full_scenario(ts.next_candidate(), 3, {}) + ts.confirm_full_scenario(ts.next_candidate(), 'three', {}) self.assertIs(ts.coverage_reached(), True) - self.assertEqual(ts.get_trace(), [1, 2, 3]) + self.assertEqual(ts.get_trace(), ['one', 'two', 'three']) def test_completing_size_three_trace_after_reject(self): - ts = TraceState(3) + ts = TraceState(range(3)) first = ts.next_candidate() ts.confirm_full_scenario(first, first, {}) rejected = ts.next_candidate() @@ -190,7 +193,7 @@ def test_completing_size_three_trace_after_reject(self): self.assertEqual(ts.get_trace(), [first, third, second]) def test_completing_size_three_trace_after_rewind(self): - ts = TraceState(3) + ts = TraceState(range(3)) first = ts.next_candidate() ts.confirm_full_scenario(first, first, {}) reject2 = ts.next_candidate() @@ -211,16 +214,16 @@ def test_completing_size_three_trace_after_rewind(self): self.assertEqual(ts.get_trace(), [retry_first, retry_second, retry_third]) def test_highest_part_when_index_not_present(self): - ts = TraceState(1) - self.assertEqual(ts.highest_part(0), 0) + ts = TraceState([1]) + self.assertEqual(ts.highest_part(1), 0) def test_highest_part_for_non_partial_sceanrio(self): - ts = TraceState(1) - ts.confirm_full_scenario(0, 'one', {}) - self.assertEqual(ts.highest_part(0), 0) + ts = TraceState([1]) + ts.confirm_full_scenario(1, 'one', {}) + self.assertEqual(ts.highest_part(1), 0) def test_model_property_takes_model_from_tail(self): - ts = TraceState(2) + ts = TraceState(range(2)) ts.confirm_full_scenario(ts.next_candidate(), 'one', dict(a=1)) ts.confirm_full_scenario(ts.next_candidate(), 'two', dict(b=2)) self.assertEqual(ts.model, dict(b=2)) @@ -228,35 +231,35 @@ def test_model_property_takes_model_from_tail(self): self.assertEqual(ts.model, dict(a=1)) def test_no_model_from_empty_trace(self): - ts = TraceState(1) + ts = TraceState([1]) self.assertIs(ts.model, None) - ts.confirm_full_scenario(0, 'one', {}) + ts.confirm_full_scenario(1, 'one', {}) self.assertIsNotNone(ts.model) ts.rewind() self.assertIs(ts.model, None) def test_tried_property_starts_empty(self): - ts = TraceState(1) + ts = TraceState([1]) self.assertEqual(ts.tried, ()) def test_rejected_scenarios_are_tried(self): - ts = TraceState(1) - ts.reject_scenario(0) - self.assertEqual(ts.tried, (0,)) + ts = TraceState([1]) + ts.reject_scenario(1) + self.assertEqual(ts.tried, (1,)) def test_confirmed_scenario_is_tried_and_triggers_next_step(self): - ts = TraceState(1) - ts.confirm_full_scenario(0, 'one', {}) + ts = TraceState([1]) + ts.confirm_full_scenario(1, 'one', {}) self.assertEqual(ts.tried, ()) ts.rewind() - self.assertEqual(ts.tried, (0,)) + self.assertEqual(ts.tried, (1,)) def test_can_iterate_over_tracestate_snapshots(self): - ts = TraceState(3) + ts = TraceState([1, 2, 3]) ts.confirm_full_scenario(ts.next_candidate(), 'one', dict(a=1)) ts.confirm_full_scenario(ts.next_candidate(), 'two', dict(b=2)) ts.confirm_full_scenario(ts.next_candidate(), 'three', dict(c=3)) - for act, exp in zip(ts, ['0', '1', '2']): + for act, exp in zip(ts, ['1', '2', '3']): self.assertEqual(act.id, exp) for act, exp in zip(ts, ['one', 'two', 'three']): self.assertEqual(act.scenario, exp) @@ -264,18 +267,18 @@ def test_can_iterate_over_tracestate_snapshots(self): self.assertEqual(act.model, exp) def test_can_index_tracestate_snapshots(self): - ts = TraceState(3) + ts = TraceState([1, 2, 3]) ts.confirm_full_scenario(ts.next_candidate(), 'one', dict(a=1)) ts.confirm_full_scenario(ts.next_candidate(), 'two', dict(b=2)) ts.confirm_full_scenario(ts.next_candidate(), 'three', dict(c=3)) - self.assertEqual(ts[0].id, '0') + self.assertEqual(ts[0].id, '1') self.assertEqual(ts[1].scenario, 'two') self.assertEqual(ts[2].model, dict(c=3)) self.assertEqual(ts[-1].scenario, 'three') - self.assertEqual([s.id for s in ts[1:]], ['1', '2']) + self.assertEqual([s.id for s in ts[1:]], ['2', '3']) def test_adding_coverage_prevents_drought(self): - ts = TraceState(3) + ts = TraceState(range(3)) ts.confirm_full_scenario(ts.next_candidate(), 'one', {}) self.assertEqual(ts.coverage_drought, 0) ts.confirm_full_scenario(ts.next_candidate(), 'two', {}) @@ -284,30 +287,30 @@ def test_adding_coverage_prevents_drought(self): self.assertEqual(ts.coverage_drought, 0) def test_repeated_scenarios_increases_drought(self): - ts = TraceState(2) - ts.confirm_full_scenario(0, 'one', {}) + ts = TraceState([1, 2]) + ts.confirm_full_scenario(1, 'one', {}) self.assertEqual(ts.coverage_drought, 0) - ts.confirm_full_scenario(0, 'one', {}) + ts.confirm_full_scenario(1, 'one', {}) self.assertEqual(ts.coverage_drought, 1) - ts.confirm_full_scenario(0, 'one', {}) + ts.confirm_full_scenario(1, 'one', {}) self.assertEqual(ts.coverage_drought, 2) def test_drought_is_reset_with_new_coverage(self): - ts = TraceState(2) - ts.confirm_full_scenario(0, 'one', {}) + ts = TraceState([1, 2]) + ts.confirm_full_scenario(1, 'one', {}) self.assertEqual(ts.coverage_drought, 0) - ts.confirm_full_scenario(0, 'one', {}) + ts.confirm_full_scenario(1, 'one', {}) self.assertEqual(ts.coverage_drought, 1) - ts.confirm_full_scenario(1, 'two', {}) + ts.confirm_full_scenario(2, 'two', {}) self.assertEqual(ts.coverage_drought, 0) def test_rewind_includes_drought_update(self): - ts = TraceState(2) - ts.confirm_full_scenario(0, 'one', {}) + ts = TraceState([1, 2]) + ts.confirm_full_scenario(1, 'one', {}) self.assertEqual(ts.coverage_drought, 0) - ts.confirm_full_scenario(0, 'one', {}) + ts.confirm_full_scenario(1, 'one', {}) self.assertEqual(ts.coverage_drought, 1) - ts.confirm_full_scenario(1, 'two', {}) + ts.confirm_full_scenario(2, 'two', {}) self.assertEqual(ts.coverage_drought, 0) ts.rewind() self.assertEqual(ts.coverage_drought, 1) @@ -317,40 +320,40 @@ def test_rewind_includes_drought_update(self): class TestPartialScenarios(unittest.TestCase): def test_push_partial_does_not_complete_coverage(self): - ts = TraceState(1) - ts.push_partial_scenario(0, 'part1', {}) + ts = TraceState([1]) + ts.push_partial_scenario(1, 'part1', {}) self.assertEqual(ts.get_trace(), ['part1']) self.assertIs(ts.coverage_reached(), False) def test_confirm_full_after_push_partial_completes_coverage(self): - ts = TraceState(1) - ts.push_partial_scenario(0, 'part1', {}) + ts = TraceState([1]) + ts.push_partial_scenario(1, 'part1', {}) self.assertIs(ts.coverage_reached(), False) - ts.push_partial_scenario(0, 'part2', {}) + ts.push_partial_scenario(1, 'part2', {}) self.assertIs(ts.coverage_reached(), False) - ts.confirm_full_scenario(0, 'remainder', {}) + ts.confirm_full_scenario(1, 'remainder', {}) self.assertEqual(ts.get_trace(), ['part1', 'part2', 'remainder']) self.assertIs(ts.coverage_reached(), True) def test_scenario_unavailble_once_pushed_partial(self): - ts = TraceState(1) + ts = TraceState([1]) candidate = ts.next_candidate() ts.push_partial_scenario(candidate, 'part1', {}) self.assertIs(ts.next_candidate(), None) def test_rewind_of_single_part(self): - ts = TraceState(1) - ts.push_partial_scenario(0, 'part1', {}) + ts = TraceState([1]) + ts.push_partial_scenario(1, 'part1', {}) self.assertEqual(ts.get_trace(), ['part1']) self.assertIs(ts.can_rewind(), True) ts.rewind() self.assertEqual(ts.get_trace(), []) def test_rewind_all_parts(self): - ts = TraceState(1) - ts.push_partial_scenario(0, 'part1', {}) + ts = TraceState([1]) + ts.push_partial_scenario(1, 'part1', {}) self.assertIs(ts.coverage_reached(), False) - ts.push_partial_scenario(0, 'part2', {}) + ts.push_partial_scenario(1, 'part2', {}) self.assertIs(ts.coverage_reached(), False) self.assertEqual(ts.get_trace(), ['part1', 'part2']) self.assertIs(ts.next_candidate(), None) @@ -363,84 +366,103 @@ def test_rewind_all_parts(self): self.assertIs(ts.can_rewind(), False) def test_partial_scenario_still_excluded_from_candidacy_after_rewind(self): - ts = TraceState(1) - self.assertEqual(ts.next_candidate(), 0) - ts.push_partial_scenario(0, 'part1', {}) + ts = TraceState([1]) + self.assertEqual(ts.next_candidate(), 1) + ts.push_partial_scenario(1, 'part1', {}) ts.rewind() self.assertIs(ts.next_candidate(), None) def test_rewind_to_partial_scenario(self): - ts = TraceState(1) - ts.push_partial_scenario(0, 'part1', dict(a=1)) - ts.push_partial_scenario(0, 'part2', dict(b=2)) + ts = TraceState([1]) + ts.push_partial_scenario(1, 'part1', dict(a=1)) + ts.push_partial_scenario(1, 'part2', dict(b=2)) snapshot = ts.rewind() - self.assertEqual(snapshot.id, '0.1') + self.assertEqual(snapshot.id, '1.1') self.assertEqual(snapshot.scenario, 'part1') self.assertEqual(snapshot.model, dict(a=1)) def test_rewind_last_part(self): - ts = TraceState(2) - ts.confirm_full_scenario(0, 'one', dict(a=1)) - ts.push_partial_scenario(1, 'part1', dict(b=2)) + ts = TraceState([1, 2]) + ts.confirm_full_scenario(1, 'one', dict(a=1)) + ts.push_partial_scenario(2, 'part1', dict(b=2)) snapshot = ts.rewind() self.assertEqual(ts.get_trace(), ['one']) - self.assertEqual(snapshot.id, '0') + self.assertEqual(snapshot.id, '1') self.assertEqual(snapshot.scenario, 'one') self.assertEqual(snapshot.model, dict(a=1)) def test_rewind_all_parts_of_completed_scenario_at_once(self): - ts = TraceState(1) - ts.push_partial_scenario(0, 'part1', dict(a=1)) - ts.push_partial_scenario(0, 'part2', dict(b=2)) - ts.confirm_full_scenario(0, 'remainder', {}) + ts = TraceState([1]) + ts.push_partial_scenario(1, 'part1', dict(a=1)) + ts.push_partial_scenario(1, 'part2', dict(b=2)) + ts.confirm_full_scenario(1, 'remainder', {}) tail = ts.rewind() self.assertEqual(ts.get_trace(), []) self.assertIs(ts.next_candidate(), None) self.assertIs(tail, None) + def test_tried_entries_after_rewind(self): + ts = TraceState([1, 2, 10, 11, 12, 20, 21]) + ts.push_partial_scenario(1, 'part1', {}) + ts.reject_scenario(10) + ts.reject_scenario(11) + ts.confirm_full_scenario(2, 'two', {}) + ts.push_partial_scenario(1, 'part2', {}) + ts.reject_scenario(20) + ts.reject_scenario(21) + self.assertEqual(ts.tried, (20, 21)) + ts.rewind() + self.assertEqual(ts.tried, ()) + ts.rewind() + self.assertEqual(ts.tried, (10, 11, 2)) + ts.reject_scenario(12) + self.assertEqual(ts.tried, (10, 11, 2, 12)) + ts.rewind() + self.assertEqual(ts.tried, (1,)) + def test_highest_part_after_first_part(self): - ts = TraceState(1) - ts.push_partial_scenario(0, 'part1', {}) - self.assertEqual(ts[-1].id, '0.1') - self.assertEqual(ts.highest_part(0), 1) + ts = TraceState([1]) + ts.push_partial_scenario(1, 'part1', {}) + self.assertEqual(ts[-1].id, '1.1') + self.assertEqual(ts.highest_part(1), 1) def test_highest_part_after_multiple_parts(self): - ts = TraceState(1) - ts.push_partial_scenario(0, 'part1', {}) - ts.push_partial_scenario(0, 'part2', {}) - self.assertEqual(ts[-1].id, '0.2') - self.assertEqual(ts.highest_part(0), 2) + ts = TraceState([1]) + ts.push_partial_scenario(1, 'part1', {}) + ts.push_partial_scenario(1, 'part2', {}) + self.assertEqual(ts[-1].id, '1.2') + self.assertEqual(ts.highest_part(1), 2) def test_highest_part_after_completing_multiple_parts(self): - ts = TraceState(1) - ts.push_partial_scenario(0, 'part1', {}) - ts.push_partial_scenario(0, 'part2', {}) - ts.confirm_full_scenario(0, 'remainder', {}) - self.assertEqual(ts[-1].id, '0.0') - self.assertEqual(ts.highest_part(0), 0) + ts = TraceState([1]) + ts.push_partial_scenario(1, 'part1', {}) + ts.push_partial_scenario(1, 'part2', {}) + ts.confirm_full_scenario(1, 'remainder', {}) + self.assertEqual(ts[-1].id, '1.0') + self.assertEqual(ts.highest_part(1), 0) def test_highest_part_after_partial_rewind(self): - ts = TraceState(1) - ts.push_partial_scenario(0, 'part1', {}) - ts.push_partial_scenario(0, 'part2', {}) - self.assertEqual(ts.highest_part(0), 2) + ts = TraceState([1]) + ts.push_partial_scenario(1, 'part1', {}) + ts.push_partial_scenario(1, 'part2', {}) + self.assertEqual(ts.highest_part(1), 2) ts.rewind() - self.assertEqual(ts.highest_part(0), 1) + self.assertEqual(ts.highest_part(1), 1) ts.rewind() - self.assertEqual(ts.highest_part(0), 0) + self.assertEqual(ts.highest_part(1), 0) def test_highest_part_is_0_when_no_refinement_is_ongoing(self): - ts = TraceState(1) - self.assertEqual(ts.highest_part(0), 0) - ts.push_partial_scenario(0, 'part1', {}) - ts.push_partial_scenario(0, 'part2', {}) - ts.confirm_full_scenario(0, 'remainder', {}) - self.assertEqual(ts.highest_part(0), 0) + ts = TraceState([1]) + self.assertEqual(ts.highest_part(1), 0) + ts.push_partial_scenario(1, 'part1', {}) + ts.push_partial_scenario(1, 'part2', {}) + ts.confirm_full_scenario(1, 'remainder', {}) + self.assertEqual(ts.highest_part(1), 0) ts.rewind() - self.assertEqual(ts.highest_part(0), 0) + self.assertEqual(ts.highest_part(1), 0) def test_count_scenario_repetitions_with_partials(self): - ts = TraceState(2) + ts = TraceState(range(2)) first = ts.next_candidate() self.assertEqual(ts.count(first), 0) ts.confirm_full_scenario(first, 'full', {}) @@ -458,36 +480,36 @@ def test_count_scenario_repetitions_with_partials(self): self.assertEqual(ts.count(second), 1) def test_partial_scenario_is_tried_without_finishing(self): - ts = TraceState(1) - ts.push_partial_scenario(0, 'part1', {}) + ts = TraceState([1]) + ts.push_partial_scenario(1, 'part1', {}) self.assertEqual(ts.tried, ()) ts.rewind() - self.assertEqual(ts.tried, (0,)) + self.assertEqual(ts.tried, (1,)) def test_get_last_snapshot_by_index(self): - ts = TraceState(1) - ts.push_partial_scenario(0, 'part1', dict(a=1)) - self.assertEqual(ts[-1].id, '0.1') + ts = TraceState([1]) + ts.push_partial_scenario(1, 'part1', dict(a=1)) + self.assertEqual(ts[-1].id, '1.1') self.assertEqual(ts[-1].scenario, 'part1') self.assertEqual(ts[-1].model, dict(a=1)) self.assertEqual(ts[-1].coverage_drought, 0) - ts.push_partial_scenario(0, 'part2', dict(b=2)) - ts.confirm_full_scenario(0, 'remainder', dict(c=3)) - self.assertEqual(ts[-1].id, '0.0') + ts.push_partial_scenario(1, 'part2', dict(b=2)) + ts.confirm_full_scenario(1, 'remainder', dict(c=3)) + self.assertEqual(ts[-1].id, '1.0') self.assertEqual(ts[-1].scenario, 'remainder') self.assertEqual(ts[-1].model, dict(c=3)) self.assertEqual(ts[-1].coverage_drought, 0) def test_only_completed_scenarios_affect_drought(self): - ts = TraceState(2) - ts.confirm_full_scenario(0, 'one full', {}) - ts.push_partial_scenario(0, 'one part1', {}) + ts = TraceState([1, 2]) + ts.confirm_full_scenario(1, 'one full', {}) + ts.push_partial_scenario(1, 'one part1', {}) self.assertEqual(ts.coverage_drought, 0) - ts.confirm_full_scenario(0, 'one remainder', {}) + ts.confirm_full_scenario(1, 'one remainder', {}) self.assertEqual(ts.coverage_drought, 1) - ts.push_partial_scenario(1, 'two part1', {}) + ts.push_partial_scenario(2, 'two part1', {}) self.assertEqual(ts.coverage_drought, 1) - ts.confirm_full_scenario(1, 'two remainder', {}) + ts.confirm_full_scenario(2, 'two remainder', {}) self.assertEqual(ts.coverage_drought, 0) diff --git a/utest/test_tracestate_refinement.py b/utest/test_tracestate_refinement.py index 2cbdca61..1b997e6b 100644 --- a/utest/test_tracestate_refinement.py +++ b/utest/test_tracestate_refinement.py @@ -36,7 +36,7 @@ class TestTraceStateRefinement(unittest.TestCase): def test_single_step_refinement(self): - ts = TraceState(2) + ts = TraceState(range(2)) candidate1 = ts.next_candidate() ts.push_partial_scenario(candidate1, 'T1.1', {}) candidate2 = ts.next_candidate() @@ -49,7 +49,7 @@ def test_single_step_refinement(self): self.assertEqual(ts.get_trace(), ['T1.1', 'B1', 'T1.0']) def test_rewind_step_with_refinement(self): - ts = TraceState(2) + ts = TraceState(range(2)) candidate1 = ts.next_candidate() ts.push_partial_scenario(candidate1, 'T1.1', {}) candidate2 = ts.next_candidate() @@ -62,7 +62,7 @@ def test_rewind_step_with_refinement(self): self.assertIs(ts.coverage_reached(), False) def test_rewind_refinement(self): - ts = TraceState(2) + ts = TraceState(range(2)) candidate1 = ts.next_candidate() ts.push_partial_scenario(candidate1, 'T1.1', {}) candidate2 = ts.next_candidate() @@ -74,7 +74,7 @@ def test_rewind_refinement(self): self.assertIs(ts.coverage_reached(), False) def test_refinement_at_two_steps(self): - ts = TraceState(3) + ts = TraceState(range(3)) outer = ts.next_candidate() ts.push_partial_scenario(outer, 'T1.1', {}) ts.confirm_full_scenario(ts.next_candidate(), 'B1', {}) @@ -88,7 +88,7 @@ def test_refinement_at_two_steps(self): self.assertEqual(ts.get_trace(), ['T1.1', 'B1', 'T1.2', 'B2', 'T1.0']) def test_rewind_to_swap_refinements(self): - ts = TraceState(3) + ts = TraceState(range(3)) outer = ts.next_candidate() ts.push_partial_scenario(outer, 'T1.1', {}) inner1 = ts.next_candidate() @@ -112,7 +112,7 @@ def test_rewind_to_swap_refinements(self): self.assertEqual(ts.get_trace(), ['T1.1', 'B2', 'T1.2', 'B1', 'T1.0']) def test_rewind_partial_scenario_to_before_outer(self): - ts = TraceState(4) + ts = TraceState(range(4)) head = ts.next_candidate() ts.confirm_full_scenario(head, 'HEAD', {}) outer = ts.next_candidate() @@ -130,7 +130,7 @@ def test_rewind_partial_scenario_to_before_outer(self): self.assertNotIn(ts.next_candidate(), [head, outer]) def test_rewind_scenario_with_double_refinement_as_one(self): - ts = TraceState(4) + ts = TraceState(range(4)) head = ts.next_candidate() ts.confirm_full_scenario(head, 'HEAD', {}) outer = ts.next_candidate() @@ -148,7 +148,7 @@ def test_rewind_scenario_with_double_refinement_as_one(self): self.assertNotEqual(ts.next_candidate(), [head, outer]) def test_nested_refinement(self): - ts = TraceState(3) + ts = TraceState(range(3)) top_level = ts.next_candidate() ts.push_partial_scenario(top_level, 'T1.1', {}) middle_level = ts.next_candidate() @@ -163,11 +163,11 @@ def test_nested_refinement(self): self.assertEqual(ts.get_trace(), ['T1.1', 'M1.1', 'B1', 'M1.0', 'T1.0']) def test_rewind_to_swap_nested_refinement(self): - ts = TraceState(3) + ts = TraceState(range(3)) top_level = ts.next_candidate() ts.push_partial_scenario(top_level, 'T1.1', {}) lower_level = ts.next_candidate() - ts.push_partial_scenario(lower_level, 'B1', {}) + ts.push_partial_scenario(lower_level, 'B1.1', {}) middle_level = ts.next_candidate() ts.reject_scenario(middle_level) self.assertIs(ts.next_candidate(), None) @@ -183,7 +183,7 @@ def test_rewind_to_swap_nested_refinement(self): self.assertEqual(ts.get_trace(), ['T1.1', 'M1.1', 'B1', 'M1.0', 'T1.0']) def test_rewind_nested_refinement_as_one(self): - ts = TraceState(4) + ts = TraceState(range(4)) ts.confirm_full_scenario(ts.next_candidate(), 'HEAD', {}) top_level = ts.next_candidate() ts.push_partial_scenario(top_level, 'T1.1', {}) @@ -199,7 +199,7 @@ def test_rewind_nested_refinement_as_one(self): self.assertEqual(ts.get_trace(), ['HEAD', 'T1.1']) def test_rewind_scenario_with_nested_refinement_as_one(self): - ts = TraceState(3) + ts = TraceState(range(3)) top_level = ts.next_candidate() ts.push_partial_scenario(top_level, 'T1.1', {}) middle_level = ts.next_candidate() @@ -215,7 +215,7 @@ def test_rewind_scenario_with_nested_refinement_as_one(self): self.assertEqual(ts.tried, (top_level,)) def test_highest_parts_from_refined_scenario(self): - ts = TraceState(4) + ts = TraceState(range(4)) top_level = ts.next_candidate() ts.push_partial_scenario(top_level, 'T1.1', {}) middle_level_1 = ts.next_candidate() @@ -245,7 +245,7 @@ def test_highest_parts_from_refined_scenario(self): 'T1.2', 'M2.1', 'M2.2', 'M2.3', 'M2.0', 'T1.0']) def test_refinement_can_resolve_drought(self): - ts = TraceState(2) + ts = TraceState(range(2)) candidate1 = ts.next_candidate() ts.confirm_full_scenario(candidate1, 'T1', {}) ts.confirm_full_scenario(candidate1, 'T1', {}) @@ -261,7 +261,7 @@ def test_refinement_can_resolve_drought(self): self.assertEqual(ts.coverage_drought, 2) def test_scenario_cannot_refine_itself(self): - ts = TraceState(2) + ts = TraceState(range(2)) candidate1 = ts.next_candidate() ts.push_partial_scenario(candidate1, 'T1.1', {}) candidate2 = ts.next_candidate() @@ -270,7 +270,7 @@ def test_scenario_cannot_refine_itself(self): self.assertIsNone(ts.next_candidate()) def test_scenario_cannot_refine_itself_with_repetition(self): - ts = TraceState(2) + ts = TraceState(range(2)) candidate1 = ts.next_candidate(retry=True) ts.push_partial_scenario(candidate1, 'T1.1', {}) candidate2 = ts.next_candidate(retry=True) @@ -279,66 +279,138 @@ def test_scenario_cannot_refine_itself_with_repetition(self): self.assertIsNone(ts.next_candidate(retry=True)) def test_initially_no_scenario_is_in_refinement(self): - ts = TraceState(1) - self.assertEqual(ts.find_scenarios_with_active_refinement(), []) + ts = TraceState([1]) + self.assertEqual(ts.active_refinements, []) def test_full_scenario_is_not_reported_as_refinement(self): - ts = TraceState(2) - ts.confirm_full_scenario(0, 'S1', {}) - self.assertEqual(ts.find_scenarios_with_active_refinement(), []) + ts = TraceState([1, 2]) + ts.confirm_full_scenario(1, 'S1', {}) + self.assertEqual(ts.active_refinements, []) def test_push_partial_opens_refinement(self): - ts = TraceState(4) - ts.push_partial_scenario(0, 'S1.1', {}) - self.assertEqual(ts.find_scenarios_with_active_refinement(), ['S1.1']) + ts = TraceState([1, 2]) + ts.push_partial_scenario(1, 'S1.1', {}) + self.assertEqual(ts.active_refinements, [1]) def test_nested_refinements_are_all_reported_as_in_refinement(self): - ts = TraceState(4) - ts.push_partial_scenario(0, 'T1.1', {}) - ts.push_partial_scenario(1, 'M1.1', {}) - ts.push_partial_scenario(2, 'B1.1', {}) - self.assertEqual(ts.find_scenarios_with_active_refinement(), ['T1.1', 'M1.1', 'B1.1']) + ts = TraceState([1, 2, 3, 4]) + ts.push_partial_scenario(1, 'T1.1', {}) + ts.push_partial_scenario(2, 'M1.1', {}) + ts.push_partial_scenario(3, 'B1.1', {}) + self.assertEqual(ts.active_refinements, [1, 2, 3]) def test_closing_refinement_removes_it_from_list(self): - ts = TraceState(4) - ts.push_partial_scenario(0, 'T1.1', {}) - ts.push_partial_scenario(1, 'M1.1', {}) - ts.push_partial_scenario(2, 'B1.1', {}) - self.assertEqual(ts.find_scenarios_with_active_refinement(), ['T1.1', 'M1.1', 'B1.1']) - ts.confirm_full_scenario(2, 'B1.0', {}) - self.assertEqual(ts.find_scenarios_with_active_refinement(), ['T1.1', 'M1.1']) + ts = TraceState([1, 2, 3, 4]) + ts.push_partial_scenario(1, 'T1.1', {}) + ts.push_partial_scenario(2, 'M1.1', {}) + ts.push_partial_scenario(3, 'B1.1', {}) + self.assertEqual(ts.active_refinements, [1, 2, 3]) + ts.confirm_full_scenario(3, 'B1.0', {}) + self.assertEqual(ts.active_refinements, [1, 2]) def test_multi_step_refinement_is_reported_only_once(self): - ts = TraceState(4) - ts.push_partial_scenario(0, 'T1.1', {}) - ts.push_partial_scenario(1, 'M1.1', {}) - ts.confirm_full_scenario(2, 'B1', {}) - ts.push_partial_scenario(1, 'M1.2', {}) - ts.confirm_full_scenario(3, 'B2', {}) - self.assertEqual(ts.find_scenarios_with_active_refinement(), ['T1.1', 'M1.1']) + ts = TraceState([1, 2, 3, 4]) + ts.push_partial_scenario(1, 'T1.1', {}) + ts.push_partial_scenario(2, 'M1.1', {}) + ts.confirm_full_scenario(3, 'B1', {}) + ts.push_partial_scenario(2, 'M1.2', {}) + ts.confirm_full_scenario(4, 'B2', {}) + self.assertEqual(ts.active_refinements, [1, 2]) def test_rewind_open_refinement_removes_it_from_list(self): - ts = TraceState(4) - ts.push_partial_scenario(0, 'T1.1', {}) - ts.push_partial_scenario(1, 'M1.1', {}) - ts.push_partial_scenario(2, 'B1.1', {}) - self.assertEqual(ts.find_scenarios_with_active_refinement(), ['T1.1', 'M1.1', 'B1.1']) + ts = TraceState([1, 2, 3, 4]) + ts.push_partial_scenario(1, 'T1.1', {}) + ts.push_partial_scenario(2, 'M1.1', {}) + ts.push_partial_scenario(3, 'B1.1', {}) + self.assertEqual(ts.active_refinements, [1, 2, 3]) ts.rewind() - self.assertEqual(ts.find_scenarios_with_active_refinement(), ['T1.1', 'M1.1']) + self.assertEqual(ts.active_refinements, [1, 2]) def test_rewind_finished_scenario_with_refinement_removes_enclosed_refinements(self): - ts = TraceState(5) - ts.confirm_full_scenario(0, 'T1', {}) - ts.push_partial_scenario(1, 'T2.1', {}) - ts.push_partial_scenario(2, 'M1.1', {}) - ts.push_partial_scenario(3, 'B1.1', {}) - ts.confirm_full_scenario(4, 'S1', {}) - self.assertEqual(ts.find_scenarios_with_active_refinement(), ['T2.1', 'M1.1', 'B1.1']) - ts.confirm_full_scenario(3, 'B1.0', {}) - ts.confirm_full_scenario(2, 'M1.0', {}) - self.assertEqual(ts.find_scenarios_with_active_refinement(), ['T2.1']) + ts = TraceState([1, 2, 3, 4, 5]) + ts.confirm_full_scenario(1, 'T1', {}) + ts.push_partial_scenario(2, 'T2.1', {}) + ts.push_partial_scenario(3, 'M1.1', {}) + ts.push_partial_scenario(4, 'B1.1', {}) + ts.confirm_full_scenario(5, 'S1', {}) + self.assertEqual(ts.active_refinements, [2, 3, 4]) + ts.confirm_full_scenario(4, 'B1.0', {}) + ts.confirm_full_scenario(3, 'M1.0', {}) + self.assertEqual(ts.active_refinements, [2]) ts.rewind() # Middle including its Bottom refinement - self.assertEqual(ts.find_scenarios_with_active_refinement(), ['T2.1']) + self.assertEqual(ts.active_refinements, [2]) + + def test_is_refinement_active_no_index(self): + ts = TraceState([1, 2, 3]) + self.assertFalse(ts.is_refinement_active()) + ts.confirm_full_scenario(1, 'one', {}) + self.assertFalse(ts.is_refinement_active()) + ts.push_partial_scenario(2, 'part1', {}) + self.assertTrue(ts.is_refinement_active()) + ts.confirm_full_scenario(3, 'refinment', {}) + self.assertTrue(ts.is_refinement_active()) + ts.push_partial_scenario(2, 'part2', {}) + self.assertTrue(ts.is_refinement_active()) + ts.confirm_full_scenario(2, 'remainder', {}) + self.assertFalse(ts.is_refinement_active()) + ts.rewind() + self.assertFalse(ts.is_refinement_active()) + ts.rewind() + self.assertFalse(ts.is_refinement_active()) + + def test_is_refinement_active_by_index(self): + ts = TraceState([1, 2, 3]) + self.assertFalse(ts.is_refinement_active(1)) + self.assertFalse(ts.is_refinement_active(2)) + ts.confirm_full_scenario(1, 'one', {}) + self.assertFalse(ts.is_refinement_active(1)) + self.assertFalse(ts.is_refinement_active(2)) + ts.push_partial_scenario(2, 'part1', {}) + self.assertFalse(ts.is_refinement_active(1)) + self.assertTrue(ts.is_refinement_active(2)) + self.assertFalse(ts.is_refinement_active(3)) + ts.push_partial_scenario(3, 'refinement head', {}) + self.assertFalse(ts.is_refinement_active(1)) + self.assertTrue(ts.is_refinement_active(2)) + self.assertTrue(ts.is_refinement_active(3)) + ts.confirm_full_scenario(3, 'refinement tail', {}) + self.assertFalse(ts.is_refinement_active(1)) + self.assertTrue(ts.is_refinement_active(2)) + self.assertFalse(ts.is_refinement_active(3)) + ts.push_partial_scenario(2, 'part2', {}) + self.assertFalse(ts.is_refinement_active(1)) + self.assertTrue(ts.is_refinement_active(2)) + ts.rewind() + self.assertFalse(ts.is_refinement_active(1)) + self.assertTrue(ts.is_refinement_active(2)) + ts.confirm_full_scenario(2, 'remainder', {}) + self.assertFalse(ts.is_refinement_active(1)) + self.assertFalse(ts.is_refinement_active(2)) + ts.rewind() + self.assertFalse(ts.is_refinement_active(1)) + self.assertFalse(ts.is_refinement_active(2)) + ts.rewind() + self.assertFalse(ts.is_refinement_active(1)) + self.assertFalse(ts.is_refinement_active(2)) + + def test_remainder_can_be_set_and_retrieved(self): + ts = TraceState([1, 2]) + ts.push_partial_scenario(1, 'one part1', {}, 'one part2') + ts.push_partial_scenario(2, 'two part1', {}, 'two parts 2+3') + self.assertEqual(ts.get_remainder(1), 'one part2') + self.assertEqual(ts.get_remainder(2), 'two parts 2+3') + ts.push_partial_scenario(2, 'two part2', {}, 'two part3') + self.assertEqual(ts.get_remainder(2), 'two part3') + ts.rewind() + self.assertEqual(ts.get_remainder(2), 'two parts 2+3') + ts.push_partial_scenario(2, 'two part2', {}, 'two part3B') + self.assertEqual(ts.get_remainder(2), 'two part3B') + ts.confirm_full_scenario(2, 'two', {}) + self.assertEqual(ts.get_remainder(1), 'one part2') + self.assertIsNone(ts.get_remainder(2)) + ts.confirm_full_scenario(1, 'one', {}) + self.assertIsNone(ts.get_remainder(1)) + self.assertIsNone(ts.get_remainder(2)) if __name__ == '__main__': From fb3cecce151cc8efea1519f901b122d17a39d92a Mon Sep 17 00:00:00 2001 From: D Osinga <46380170+osingaatje@users.noreply.github.com> Date: Thu, 8 Jan 2026 15:53:08 +0100 Subject: [PATCH 038/131] added svg export back (#50) --- robotmbt/visualise/networkvisualiser.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/robotmbt/visualise/networkvisualiser.py b/robotmbt/visualise/networkvisualiser.py index 4beedea5..6238de30 100644 --- a/robotmbt/visualise/networkvisualiser.py +++ b/robotmbt/visualise/networkvisualiser.py @@ -77,6 +77,7 @@ def __init__(self, graph: AbstractGraph, suite_name: str, seed: str): # Set up a Bokeh figure self.plot = Plot(width=OUTER_WINDOW_WIDTH, height=OUTER_WINDOW_HEIGHT) + self.plot.output_backend = "svg" # Create Sugiyama layout nodes, edges = self._create_layout() @@ -244,7 +245,7 @@ def _add_features(self, suite_name: str, seed: str): # A JS callback to scale text and arrows, and change aspect ratio. resize_cb = CustomJS(args=dict(xr=self.plot.x_range, yr=self.plot.y_range, plot=self.plot, arrows=self.arrows), - code=f""" + code=f""" // Initialize initial scale tag if (!plot.tags || plot.tags.length === 0) {{ plot.tags = [{{ @@ -406,7 +407,7 @@ def _get_connection_coordinates(nodes: list[Node], node_id: str) -> list[tuple[f def _minimize_distance(from_pos: list[tuple[float, float]], to_pos: list[tuple[float, float]]) -> tuple[ - float, float, float, float]: + float, float, float, float]: """ Find a pair of positions that minimizes their distance. """ From 1f32133847c5fe8ca888e12998ae2194ce148acc Mon Sep 17 00:00:00 2001 From: Thomas Kas Date: Fri, 9 Jan 2026 15:45:59 +0100 Subject: [PATCH 039/131] Minor tweaks (#49) * Scale arrows less aggressively * Split scenario names into separate lines --- robotmbt/visualise/models.py | 55 +++++++++++++++++++++++-- robotmbt/visualise/networkvisualiser.py | 2 +- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index 3cb16790..2089d443 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -9,6 +9,8 @@ import tempfile import os +DESIRED_NAME_LINE_LENGTH = 20 + class ScenarioInfo: """ @@ -20,7 +22,7 @@ class ScenarioInfo: def __init__(self, scenario: Scenario | str): if isinstance(scenario, Scenario): # default case - self.name = scenario.name + self.name = self._split_name(scenario.name) self.src_id = scenario.src_id else: # unit tests @@ -33,6 +35,55 @@ def __str__(self): def __eq__(self, other): return self.src_id == other.src_id + @staticmethod + def _split_name(name: str) -> str: + """ + Split a name into separate lines where each line is as close to the desired line length as possible. + """ + # Split into words + words = name.split(" ") + + # If any word is longer than the desired length, use that as the desired length instead + # (otherwise, we will always get a line (much) longer than the desired length, while the other lines will + # be constrained by the desired length) + desired_length = DESIRED_NAME_LINE_LENGTH + for i in words: + if len(i) > desired_length: + desired_length = len(i) + + res = "" + line = words[0] + for i in words[1:]: + # If the previous line was fully appended, simply take the current word as the new line + if line == '\n': + line += i + continue + + app_len = len(line + ' ' + i) + + # If the word fully fits into the line, simply append it + if app_len < desired_length: + line = line + ' ' + i + continue + + app_diff = abs(desired_length - app_len) + curr_diff = abs(desired_length - len(line)) + + # If the current line is closer to the desired length, use that + if curr_diff < app_diff: + res += line + line = '\n' + i + # If the current line with the new word is closer to the desired length, use that + else: + res += line + ' ' + i + line = '\n' + + # Append the final line if it wasn't empty + if line != '\n': + res += line + + return res + class StateInfo: """ @@ -75,7 +126,6 @@ def _dict_deep_diff(old_state: dict[str, any], new_state: dict[str, any]) -> dic res[key] = new_state[key] return res - def __init__(self, state: ModelSpace): self.domain = state.ref_id @@ -221,7 +271,6 @@ def import_graph(self, file_name: str): string = f.read() self = jsonpickle.decode(string) - @staticmethod def stringify_pair(pair: tuple[ScenarioInfo, StateInfo]) -> str: return f"Scenario={pair[0].name}, State={pair[1]}" diff --git a/robotmbt/visualise/networkvisualiser.py b/robotmbt/visualise/networkvisualiser.py index 6238de30..22acbed8 100644 --- a/robotmbt/visualise/networkvisualiser.py +++ b/robotmbt/visualise/networkvisualiser.py @@ -296,7 +296,7 @@ def _add_features(self, suite_name: str, seed: str): if (!a.properties.end._value.properties.size._value.value) continue; if (a._base_end_size == null) a._base_end_size = a.properties.end._value.properties.size._value.value; - a.properties.end._value.properties.size._value.value = a._base_end_size * scale; + a.properties.end._value.properties.size._value.value = a._base_end_size * Math.sqrt(scale); a.change.emit(); }}""") From 85b16fcda7c3aca9031439493bf0f3cfa42ba36c Mon Sep 17 00:00:00 2001 From: D Osinga <46380170+osingaatje@users.noreply.github.com> Date: Mon, 12 Jan 2026 15:22:44 +0100 Subject: [PATCH 040/131] Add _get_graph() in Visualiser for atesting in the future (#52) * add _get_graph() in Visualiser for atesting in the future * one space - I hate Python with a passion --- robotmbt/visualise/visualiser.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py index af1b9cdb..757929b4 100644 --- a/robotmbt/visualise/visualiser.py +++ b/robotmbt/visualise/visualiser.py @@ -71,10 +71,7 @@ def update_trace(self, trace: TraceState): model = snap.model self.trace_info.update_trace(ScenarioInfo(scenario), StateInfo(model), trace_len) - def generate_visualisation(self) -> str: - if self.export: - self.trace_info.export_graph(self.suite_name) - + def _get_graph(self) -> AbstractGraph: if self.graph_type == 'scenario': graph: AbstractGraph = ScenarioGraph(self.trace_info) elif self.graph_type == 'state': @@ -88,6 +85,14 @@ def generate_visualisation(self) -> str: else: graph: AbstractGraph = ScenarioStateGraph(self.trace_info) + return graph + + def generate_visualisation(self) -> str: + if self.export: + self.trace_info.export_graph(self.suite_name) + + graph: AbstractGraph = self._get_graph() + html_bokeh = networkvisualiser.NetworkVisualiser(graph, self.suite_name, self.seed).generate_html() return f'' From 7128ac600c96fbc271c23f06c38144159740b1d9 Mon Sep 17 00:00:00 2001 From: tychodub <93142605+tychodub@users.noreply.github.com> Date: Wed, 14 Jan 2026 10:37:36 +0100 Subject: [PATCH 041/131] Fix some spelling mistakes surrounding json export (#54) * implemented scenariodeltavaluegraph * implemented reduced-scenariodeltavaluegraph, but should still discuss how to label equivalence classes (currently just picks a representative). Fixed part of vertex info being indicated as edge info for scenariodeltavaluegraph. Trace highlighting is still broken for reducedSDV * implemented comments from Douwe (added comments and reset graph setting in suiteprocessors.py) * fixed reducedSDV graph to have a properly functioning equivalence relation * added merged annotation to labels generated by reducedSDV for merged nodes * fixed spelling mistakes export_graph documentation and applied PyCharm Pep8 formatting recommendations on modelgenerator.py --- atest/resources/helpers/modelgenerator.py | 39 +++++++++++------------ robotmbt/visualise/models.py | 4 +-- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/atest/resources/helpers/modelgenerator.py b/atest/resources/helpers/modelgenerator.py index 83270285..0123d05b 100644 --- a/atest/resources/helpers/modelgenerator.py +++ b/atest/resources/helpers/modelgenerator.py @@ -6,11 +6,11 @@ class ModelGenerator: - @keyword(name='Generate Trace Information') # type:ignore + @keyword(name='Generate Trace Information') # type:ignore def generate_trace_information(self) -> TraceInfo: return TraceInfo() - @keyword(name='Current Trace Contains') # type:ignore + @keyword(name='Current Trace Contains') # type:ignore def current_trace_contains(self, trace_info: TraceInfo, scenario_name: str, state_str: str) -> TraceInfo: ''' State should be of format @@ -20,16 +20,16 @@ def current_trace_contains(self, trace_info: TraceInfo, scenario_name: str, stat s = state_str.split(': ') key, item = s[1].split('=') state = StateInfo._create_state_with_prop(s[0], [(key, item)]) - trace_info.update_trace(scenario, state, trace_info.previous_length+1) + trace_info.update_trace(scenario, state, trace_info.previous_length + 1) return trace_info - - @keyword(name='All Traces Contains List') # type:ignore + + @keyword(name='All Traces Contains List') # type:ignore def all_traces_contains_list(self, trace_info: TraceInfo) -> TraceInfo: trace_info.all_traces.append([]) return trace_info - - @keyword(name='All Traces Contains') # type:ignore + + @keyword(name='All Traces Contains') # type:ignore def all_traces_contains(self, trace_info: TraceInfo, scenario_name: str, state_str: str) -> TraceInfo: ''' State should be of format @@ -39,16 +39,16 @@ def all_traces_contains(self, trace_info: TraceInfo, scenario_name: str, state_s s = state_str.split(': ') key, item = s[1].split('=') state = StateInfo._create_state_with_prop(s[0], [(key, item)]) - + trace_info.all_traces[0].append((scenario, state)) return trace_info - - @keyword(name='Export Graph') # type:ignore - def export_to_json(self, suite:str, trace_info: TraceInfo) -> str: + + @keyword(name='Export Graph') # type:ignore + def export_to_json(self, suite: str, trace_info: TraceInfo) -> str: return trace_info.export_graph(suite, True) - - @keyword(name='Import JSON File') # type:ignore + + @keyword(name='Import JSON File') # type:ignore def import_json_file(self, filepath: str) -> TraceInfo: with open(filepath, 'r') as f: string = f.read() @@ -56,7 +56,7 @@ def import_json_file(self, filepath: str) -> TraceInfo: visualiser = Visualiser('state', trace_info=decoded_instance) return visualiser.trace_info - @keyword(name='Check File Exists') # type:ignore + @keyword(name='Check File Exists') # type:ignore def check_file_exists(self, filepath: str) -> str: ''' Checks if file exists @@ -65,8 +65,8 @@ def check_file_exists(self, filepath: str) -> str: Expected != result ''' return 'file exists' if os.path.exists(filepath) else 'file does not exist' - - @keyword(name='Compare Trace Info') # type:ignore + + @keyword(name='Compare Trace Info') # type:ignore def compare_trace_info(self, t1: TraceInfo, t2: TraceInfo) -> str: ''' Checks if current trace and all traces of t1 and t2 are equal @@ -76,9 +76,8 @@ def compare_trace_info(self, t1: TraceInfo, t2: TraceInfo) -> str: ''' succes = 'imported model equals exported model' fail = 'imported models differs from exported model' - return succes if repr(t1) == repr(t2) else fail - - @keyword(name='Delete File') # type:ignore + return succes if repr(t1) == repr(t2) else fail + + @keyword(name='Delete File') # type:ignore def delete_json_file(self, filepath: str): os.remove(filepath) - diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index 2089d443..ffec518f 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -252,9 +252,9 @@ def export_graph(self, suite_name: str, atest: bool = False) -> str | None: name = suite_name.lower().replace(' ', '_') if atest: ''' - temporary file to not accidentaly overwrite an existing file + temporary file to not accidentally overwrite an existing file mkstemp() is not ideal but given Python's limitations this is the easiest solution - as temporary file, a different method, is problamatic on Windows + as temporary file, a different method, is problematic on Windows https://stackoverflow.com/a/57015383 ''' fd, path = tempfile.mkstemp() From 44526ce26596fc2725aabcc66e53498202f695b7 Mon Sep 17 00:00:00 2001 From: Thomas Kas Date: Wed, 14 Jan 2026 10:38:55 +0100 Subject: [PATCH 042/131] Documentation (#53) * Add graph creation documentation for contributors * Add documentation on enabling graphs and JSON importing/exporting --- CONTRIBUTING.md | 76 +++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 33 +++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c7559fe..daf829b1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -114,3 +114,79 @@ Researchers have suggested that longer lines are better suited for cases when th - Information that is useful for analysing failed tests is logged at debug-level. - Be careful not to make assumptions in what you log: Recheck log statements if your changes affect the context in which the code is run, and only report about what you know to be true. + +### Creating new graphs + +Extending the functionality of the visualizer with new graph types can result in better insights into created tests. The visualizer makes use of an abstract graph class that makes it easy to create new graph types. + +To create a new graph type, create an instance of AbstractGraph, instantiating the following methods: +- select_node_info: select the information you want to use to identify different nodes from all ScenarioInfo/StateInfo pairs that make up the different exploration steps. This info is also used to label nodes. Its return type has to match the first type used to instantiate AbstractGraph. +- select_edge_info: ditto but for edges, which is also used for labeling. Its type has to match the second type used to instantiate AbstractGraph. +- create_node_label: turn the selected information into a label for a node. +- create_edge_label: ditto but for edges. +- get_legend_info_final_trace_node: return the text you want to appear in the legend for nodes that appear in the final trace. +- get_legend_info_other_node: ditto but for nodes that have been backtracked. +- get_legend_info_final_trace_edge: ditto but for edges that appear in the final trace. +- get_legend_info_other_edge: ditto but for edges that have backtracked. + +It is recommended to create a new file for each graph type under `/robotmbt/visualise/graphs/`. + +A simple example is given below. In this graph type, nodes represent scenarios encountered in exploration, and edges show the flow between these scenarios. + +```python +class ScenarioGraph(AbstractGraph[ScenarioInfo, None]): + @staticmethod + def select_node_info(pairs: list[tuple[ScenarioInfo, StateInfo]], index: int) -> ScenarioInfo: + return pairs[index][0] + + @staticmethod + def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> None: + return None + + @staticmethod + def create_node_label(info: ScenarioInfo) -> str: + return info.name + + @staticmethod + def create_edge_label(info: None) -> str: + return '' + + @staticmethod + def get_legend_info_final_trace_node() -> str: + return "Executed Scenario (in final trace)" + + @staticmethod + def get_legend_info_other_node() -> str: + return "Executed Scenario (backtracked)" + + @staticmethod + def get_legend_info_final_trace_edge() -> str: + return "Execution Flow (final trace)" + + @staticmethod + def get_legend_info_other_edge() -> str: + return "Execution Flow (backtracked)" +``` + +Once you have created a new Graph class, you can direct the visualizer to use it when your type is selected. +Edit `/robotmbt/visualise/visualiser.py` to not reject your graph type in `__init__` and construct your graph in `generate_visualisation` like the others. For our example: +```python +def __init__(self, graph_type: str, suite_name: str = "", seed: str = "", export: bool = False, trace_info: TraceInfo = None): + if graph_type != 'scenario' and [...]: + raise ValueError(f"Unknown graph type: {graph_type}!") + + [...] +``` + +```python +def generate_visualisation(self) -> str: + [...] + + if self.graph_type == 'scenario': + graph: AbstractGraph = ScenarioGraph(self.trace_info) + + [...] +``` + +Now, when selecting your graph type (in our example `Treat this test suite Model-based graph=scenario`), your graph will get constructed! + diff --git a/README.md b/README.md index 3eeb12de..8ca95a89 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,39 @@ Treat this test suite model-based seed=eag-etou-cxi-leamv-jsi Using `seed=new` will force generation of a new reusable seed and is identical to omitting the seed argument. To completely bypass seed generation and use the system's random source, use `seed=None`. This has even more variation but does not produce a reusable seed. +### Graphs + +By default, no graphs are generated for test-runs. For development purposes, having a visual representation of the test-suite you are working on can be very useful. To have robotmbt generate a graph, ensure you have installed the optional dependencies (`pip install .[visualization]`) and pass the type as an argument: + +``` +Treat this test suite Model-based graph=[type] +``` + +Here, `[type]` can be any of the supported graph types. Currently, the types included are: +- `scenario-delta-value` + +Once the test suite has run, a graph will be included in the test's log, under the suite's `Treat this test suite Model-based` setup header. + +#### JSON exporting + +It is possible to extract the exploration data after the library has found a covering trace. To enable this feature, set the following argument to true: + +``` +Treat this test suite Model-based graph=[type] to_json=true +``` + +A JSON file named after the test suite will be created containing said information. + +#### JSON importing + +It is possible to skip running the exploration step and produce a graph (e.g. of another type) from previously exported data. This can be achieved by pointing the following argument to such a JSON file (just its name suffices, without the extension): + +``` +Treat this test suite Model-based graph=[type] from_json=[file_name] +``` + +A graph will be created from the imported data. + ### Option management If you want to set configuration options for use in multiple test suites without having to repeat them, the keywords __Set model-based options__ and __Update model-based options__ can be used to configure RobotMBT library options. _Set_ takes the provided options and discards any previously set options. _Update_ allows you to modify existing options or add new ones. Reset all options by calling _Set_ without arguments. Direct options provided to __Treat this test suite model-based__ take precedence over library options and affect only the current test suite. From a578c8ca8039e10ba6b147c3badebd9bbe533edc Mon Sep 17 00:00:00 2001 From: Jonathan <61787386+JWillegers@users.noreply.github.com> Date: Sun, 18 Jan 2026 17:49:57 +0100 Subject: [PATCH 043/131] Acceptance tests (must requirements) (#55) * refactor modelgenerator and visualisation.resource * in progress - TODO fix IN/OUT in .resource file. * :IN: :OUT: changes - waiting for Behaviour Document to update * add spaces to warning * vertex test for scenario-delta-value * Custom error messages * add domain to test cases * refactor modelgenerator and visualisation.resource * in progress - TODO fix IN/OUT in .resource file. * :IN: :OUT: changes - waiting for Behaviour Document to update * add spaces to warning * vertex test for scenario-delta-value * Custom error messages * add domain to test cases * delete unit tests that will be covered by atests * added dummy visualiser to visualise self-made graphs * fixed model generator and .resource file * added edge rules * partial update to export JSON atest * fully updated export to json atest * added vertical alignment test * refactor modelgenerator and visualisation.resource * in progress - TODO fix IN/OUT in .resource file. * :IN: :OUT: changes - waiting for Behaviour Document to update * add spaces to warning * vertex test for scenario-delta-value * Custom error messages * add domain to test cases * refactor modelgenerator and visualisation.resource * Convert reduced-sdv ids into tuples (#56) * Convert ids to string so we never worry about frozensets again * Add hashable ID requirement documentation * Remove unnecessary RawNodeInfo * Serializable, not hashable * Serializable AND hashable * minor changes (mostly name refactors) * consistency for graph _ has an edge from _ to _ * seperated get NodeID from get vertex y position * move where the y-axis inversion happens * removed: test suite s has a trace with 2 steps * fixed comments --------- Co-authored-by: Douwe Osinga Co-authored-by: Thomas Kas --- CONTRIBUTING.md | 2 + atest/resources/helpers/modelgenerator.py | 256 +++++++++++-- atest/resources/visualisation.resource | 215 +++++++---- .../0__setup.robot | 8 + .../1__scenario-delta-value.robot | 27 ++ .../__init__.robot | 5 + .../C.2.3__export to JSON.robot | 35 -- .../01__export to JSON.robot | 37 ++ robotmbt/suiteprocessors.py | 4 +- robotmbt/visualise/graphs/abstractgraph.py | 3 + robotmbt/visualise/graphs/reducedSDVgraph.py | 20 +- robotmbt/visualise/networkvisualiser.py | 93 ++--- utest/test_visualise_abstractgraph.py | 39 -- .../test_visualise_scenariodeltavaluegraph.py | 57 --- utest/test_visualise_scenariograph.py | 248 ------------- utest/test_visualise_scenariostategraph.py | 349 ------------------ utest/test_visualise_stategraph.py | 318 ---------------- 17 files changed, 507 insertions(+), 1209 deletions(-) create mode 100644 atest/robotMBT tests/10__visualisation_representations/0__setup.robot create mode 100644 atest/robotMBT tests/10__visualisation_representations/1__scenario-delta-value.robot create mode 100644 atest/robotMBT tests/10__visualisation_representations/__init__.robot delete mode 100644 atest/robotMBT tests/10__visualisation_tests/C.2.3__export to JSON.robot create mode 100644 atest/robotMBT tests/12__visualisation_export/01__export to JSON.robot delete mode 100644 utest/test_visualise_abstractgraph.py delete mode 100644 utest/test_visualise_scenariodeltavaluegraph.py delete mode 100644 utest/test_visualise_scenariograph.py delete mode 100644 utest/test_visualise_scenariostategraph.py delete mode 100644 utest/test_visualise_stategraph.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index daf829b1..f36b3751 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -131,6 +131,8 @@ To create a new graph type, create an instance of AbstractGraph, instantiating t It is recommended to create a new file for each graph type under `/robotmbt/visualise/graphs/`. +NOTE: when manually altering the networkx field, ensure its ids remain as a serializable and hashable type when the constructor finishes. + A simple example is given below. In this graph type, nodes represent scenarios encountered in exploration, and edges show the flow between these scenarios. ```python diff --git a/atest/resources/helpers/modelgenerator.py b/atest/resources/helpers/modelgenerator.py index 0123d05b..e18deed1 100644 --- a/atest/resources/helpers/modelgenerator.py +++ b/atest/resources/helpers/modelgenerator.py @@ -1,8 +1,11 @@ -import jsonpickle +import jsonpickle # type: ignore from robot.api.deco import keyword # type:ignore from robotmbt.visualise.models import TraceInfo, ScenarioInfo, StateInfo from robotmbt.visualise.visualiser import Visualiser +from robotmbt.visualise.graphs.abstractgraph import AbstractGraph import os +import networkx as nx +from robotmbt.visualise.networkvisualiser import NetworkVisualiser, Node class ModelGenerator: @@ -10,17 +13,15 @@ class ModelGenerator: def generate_trace_information(self) -> TraceInfo: return TraceInfo() - @keyword(name='Current Trace Contains') # type:ignore - def current_trace_contains(self, trace_info: TraceInfo, scenario_name: str, state_str: str) -> TraceInfo: + @keyword(name='The Algorithm Inserts') # type:ignore + def insert_trace_info(self, trace_info: TraceInfo, scenario_name: str, state_str: str | None = None) -> TraceInfo: ''' State should be of format - "name: key=value" + "name: key=value key2=value2 ..." ''' - scenario = ScenarioInfo(scenario_name) - s = state_str.split(': ') - key, item = s[1].split('=') - state = StateInfo._create_state_with_prop(s[0], [(key, item)]) - trace_info.update_trace(scenario, state, trace_info.previous_length + 1) + + (scen_info, state_info) = self.__convert_to_info_tuple(scenario_name, state_str) + trace_info.update_trace(scen_info, state_info, trace_info.previous_length+1) return trace_info @@ -30,54 +31,231 @@ def all_traces_contains_list(self, trace_info: TraceInfo) -> TraceInfo: return trace_info @keyword(name='All Traces Contains') # type:ignore - def all_traces_contains(self, trace_info: TraceInfo, scenario_name: str, state_str: str) -> TraceInfo: + def all_traces_contains(self, trace_info: TraceInfo, scenario_name: str, state_str: str | None) -> TraceInfo: ''' State should be of format - "name: key=value" + "scenario: key=value" ''' - scenario = ScenarioInfo(scenario_name) - s = state_str.split(': ') - key, item = s[1].split('=') - state = StateInfo._create_state_with_prop(s[0], [(key, item)]) + (scen_info, state_info) = self.__convert_to_info_tuple(scenario_name, state_str) - trace_info.all_traces[0].append((scenario, state)) + for trace in trace_info.all_traces: + trace.append((scen_info, state_info)) return trace_info + @keyword(name='Generate Graph') # type:ignore + def generate_graph(self, trace_info: TraceInfo, graph_type: str) -> AbstractGraph: + return Visualiser(graph_type=graph_type, trace_info=trace_info)._get_graph() + + @keyword(name="Generate Network Graph") + def generate_networkgraph(self, graph: AbstractGraph) -> NetworkVisualiser: + return NetworkVisualiser(graph=graph, suite_name="", seed="") + @keyword(name='Export Graph') # type:ignore - def export_to_json(self, suite: str, trace_info: TraceInfo) -> str: + def export_graph(self, suite: str, trace_info: TraceInfo) -> str: return trace_info.export_graph(suite, True) - @keyword(name='Import JSON File') # type:ignore - def import_json_file(self, filepath: str) -> TraceInfo: + @keyword(name='Import Graph') # type:ignore + def import_graph(self, filepath: str) -> TraceInfo: with open(filepath, 'r') as f: string = f.read() - decoded_instance = jsonpickle.decode(string) + decoded_instance: TraceInfo = jsonpickle.decode(string) # type: ignore visualiser = Visualiser('state', trace_info=decoded_instance) return visualiser.trace_info @keyword(name='Check File Exists') # type:ignore - def check_file_exists(self, filepath: str) -> str: - ''' - Checks if file exists - - Returns string for .resource error message in case values are not equal - Expected != result - ''' - return 'file exists' if os.path.exists(filepath) else 'file does not exist' + def check_file_exists(self, filepath: str) -> bool: + return os.path.exists(filepath) @keyword(name='Compare Trace Info') # type:ignore - def compare_trace_info(self, t1: TraceInfo, t2: TraceInfo) -> str: - ''' - Checks if current trace and all traces of t1 and t2 are equal - - Returns string for .resource error message in case values are not equal - Expected != result - ''' - succes = 'imported model equals exported model' - fail = 'imported models differs from exported model' - return succes if repr(t1) == repr(t2) else fail + def compare_trace_info(self, t1: TraceInfo, t2: TraceInfo) -> bool: + return repr(t1) == repr(t2) @keyword(name='Delete File') # type:ignore - def delete_json_file(self, filepath: str): + def delete_file(self, filepath: str): os.remove(filepath) + + @keyword(name='Graph Contains Vertex With No Text') # type:ignore + def graph_contains_no_text(self, graph: AbstractGraph, label: str) -> bool: + return label in graph.networkx.nodes() + + @keyword(name='Graph Contains Vertex With Text') # type:ignore + def graph_contains_vertex_with_text(self, graph: AbstractGraph, title: str, text: str | None = None) -> str | None: + """ + Returns the label of the complete node or None if it doesn't exist + """ + ATTRIBUTE = "label" + attr = nx.get_node_attributes(graph.networkx, ATTRIBUTE) + + (_, state_info) = self.__convert_to_info_tuple(title, text) + parts = state_info.properties[text.split(":")[0]] \ + if text is not None else [] + + for nodename, label in attr.items(): + if title in label: + if text is None: + # we sanitise because newlines in text go badly with eval() in Robot framework + return nodename + + count = 0 + for s in parts: + if f"{s}={parts[s]}" in label: + count += 1 + if count == len(parts): + # we sanitise because newlines in text go badly with eval() in Robot framework + return nodename + + return None + + @keyword(name="Vertices Are Connected") # type:ignore + def vertices_connected(self, graph: AbstractGraph, node_key1: str | None, node_key2: str | None) -> bool: + if node_key1 is None or node_key2 is None: + return False + return graph.networkx.has_edge(node_key1, node_key2) + + @keyword(name="Get NodeID") # type:ignore + def get_nodeid(self, graph: AbstractGraph, node_title:str) -> str | None: + return self.graph_contains_vertex_with_text(graph, node_title, text=None) + + @keyword(name="Get Vertex Y Position") # type:ignore + def get_y(self, network_vis: NetworkVisualiser, id: str) -> int | None: + try: + node: Node | None = network_vis.node_dict[id] + return node.y + except KeyError: + return None + + @keyword(name='Graph Contains Vertices Starting With') # type:ignore + def scen_graph_contains_vertices_starting_with(self, graph: AbstractGraph, vertices_str: str) -> bool | str: + ''' + vertices_str should be of format "'vertex1', 'vertex2'" etc + ''' + attr: dict[str, str] = nx.get_node_attributes(graph.networkx, "label") + + vertices = vertices_str.split("'") + for i in range(1, len(vertices), 2): + found = False + for _, label in attr.items(): + if label.startswith(vertices[i]): + found = True + break + + if not found: + return False + + return True + + @keyword(name='Backtrack') # type:ignore + def backtrack(self, trace_info: TraceInfo, steps: int) -> TraceInfo: + scenario, state = trace_info.current_trace[-steps - 1] + trace_info.update_trace(scenario, state, len(trace_info.current_trace) - steps) + return trace_info + + @keyword(name='Get Length Current Trace') # type:ignore + def get_length_current_trace(self, trace_info: TraceInfo) -> int: + return len(trace_info.current_trace) + + @keyword(name='Get Number of Backtracked Traces') # type:ignore + def get_number_of_backtracked_traces(self, trace_info: TraceInfo) -> int: + return len(trace_info.all_traces) + + # ============= # + # == HELPERS == # + # ============= # + + @staticmethod + def __convert_to_info_tuple(scenario_name: str, keyvaluestr: str | None) -> tuple[ScenarioInfo, StateInfo]: + """ + Format: + "domain1: key1=value1, key2=value2" + """ + scenario_name = scenario_name.strip() + if keyvaluestr is None: + return (ScenarioInfo(scenario_name), StateInfo._create_state_with_prop("", [])) + + keyvaluestr = keyvaluestr.strip() + + split_domain: list[str] = keyvaluestr.split(':') + domain = split_domain[0] + keyvaluestr = ":".join(split_domain[1:]) if len(split_domain) > 2 else split_domain[1] + + # contains ["key1", "value1 key2", "value2"]-like structure + split_eq: list[str] = keyvaluestr.split("=") + if len(split_eq) < 2: + raise ValueError( + "Please input a valid state information string of format \"scenario1: key1=value1 key2=value2\"" + ) + + keyvalues: list[tuple[str, str]] = [] + + prev_key = split_eq[0] + for index in range(1, len(split_eq)): + splits: list[str] = ModelGenerator.__split_top_level(split_eq[index]) + splits = [s.strip() for s in splits] + prev_value: str = " ".join(splits[:-1]) if len(splits) > 1 else splits[0] + new_key: str = splits[-1] + + keyvalues.append((prev_key, prev_value)) + prev_key = new_key + + return (ScenarioInfo(scenario_name), StateInfo._create_state_with_prop(domain, keyvalues)) + + @staticmethod + def __split_top_level(text): + parts = [] + buf = [] + + depth = 0 + string_char = None + escape = False + + i = 0 + n = len(text) + + while i < n: + ch = text[i] + + # Inside string + if string_char: + buf.append(ch) + if escape: + escape = False + elif ch == "\\": + escape = True + elif ch == string_char: + string_char = None + i += 1 + continue + + # Start of string + if ch in ("'", '"'): + string_char = ch + buf.append(ch) + i += 1 + continue + + # Nesting + if ch in "([{": + depth += 1 + buf.append(ch) + i += 1 + continue + + if ch in ")]}": + depth -= 1 + buf.append(ch) + i += 1 + continue + + # Split condition: ", " at top level + if ch == "," and i + 1 < n and text[i + 1] == " " and depth == 0: + parts.append("".join(buf)) + buf.clear() + i += 2 # skip ", " + continue + + buf.append(ch) + i += 1 + + parts.append("".join(buf)) + return parts diff --git a/atest/resources/visualisation.resource b/atest/resources/visualisation.resource index 1e99eac1..a20f2315 100644 --- a/atest/resources/visualisation.resource +++ b/atest/resources/visualisation.resource @@ -5,70 +5,160 @@ Library Collections *** Keywords *** -test suite ${suite} - [Documentation] *model info* - ... :IN: None - ... :OUT: new suite - Set Suite Variable ${suite} - test suite ${suite} has trace info ${trace} [Documentation] *model info* - ... :IN: suite - ... :OUT: new trace_info - Variable Should Exist ${suite} - - ${trace_info} = Generate Trace Information + ... :IN: new trace_info | trace_info.current_trace=0 | trace_info.all_traces=0 | trace_info.name = ${trace} + ... :OUT: None + ${trace_info} = Generate Trace Information Set Suite Variable ${trace_info} - -trace info ${trace} has current trace ${current} - [Documentation] *model info* - ... :IN: trace_info - ... :OUT: trace_info.current_trace=[] + +trace info ${trace} + [Documentation] *model info* + ... :IN: trace_info.name == ${trace} + ... :OUT: trace_info.name == ${trace} + No operation + +the algorithm inserts '${scenario_name}' with state "${state_str}" + [Documentation] *model info* + ... :IN: trace_info + ... :OUT: trace_info.current_trace+=1 Variable Should Exist ${trace_info} + ${trace_info} = The Algorithm Inserts ${trace_info} ${scenario_name} ${state_str} -current trace ${current_trace} has a tuple 'scenario ${scenario}, state ${state}' +test suite ${suite} has ${n} steps in its current trace [Documentation] *model info* - ... :IN: trace_info.current_trace - ... :OUT: trace_info.current_trace.append((${scenario}, ${state})) - Variable Should Exist ${trace_info} + ... :IN: trace_info.current_trace==${n} + ... :OUT: trace_info.current_trace==${n} + No operation - ${trace_info} = Current Trace Contains ${trace_info} ${scenario} ${state} - Set Suite Variable ${trace_info} +test suite ${suite} has ${n} total traces + [Documentation] *model info* + ... :IN: trace_info.current_trace | trace_info.all_traces==${n}-1 + ... :OUT: trace_info.current_trace | trace_info.all_traces==${n}-1 + No operation -trace info ${trace} has all traces ${all} - [Documentation] *model info* - ... :IN: trace_info - ... :OUT: trace_info.all_traces=[] +${graph_type} graph ${name} is generated + [Documentation] *model info* + ... :IN: trace_info + ... :OUT: graph.name=${name}, graph.type=${graph_type} Variable Should Exist ${trace_info} -all traces ${all} has list ${list} + ${graph} = Generate Graph ${trace_info} ${graph_type} + ${network_visualiser} = Generate Network Graph ${graph} + Set Suite Variable ${graph} + Set Suite Variable ${network_visualiser} + +graph ${name} contains vertex '${scenario}' [Documentation] *model info* - ... :IN: trace_info - ... :OUT: trace_info.all_traces.append([]) - Variable Should Exist ${trace_info} + ... :IN: graph.name=${name} + ... :OUT: graph.name=${name} + Variable Should Exist ${graph} - ${trace_info} = All Traces Contains List ${trace_info} - Set Suite Variable ${trace_info} + ${result} = Graph Contains Vertex With No Text ${graph} ${scenario} + Run Keyword If ${result} == False + ... Fail Fail: Graph does not contain '${scenario}' -list ${list} has a tuple 'scenario ${scenario}, state ${state}' +graph ${name} contains vertex '${scenario}' with text "${text}" [Documentation] *model info* - ... :IN: trace_info.all_traces - ... :OUT: trace_info.all_traces.append((${scenario}, ${state})) - Variable Should Exist ${trace_info} + ... :IN: graph.name=${name} + ... :OUT: graph.name=${name} + Variable Should Exist ${graph} - ${trace_info} = All Traces Contains ${trace_info} ${scenario} ${state} - Set Suite Variable ${trace_info} + ${result} = Graph Contains Vertex With Text ${graph} ${scenario} ${text} + Run Keyword If "${result}" == "None" + ... Fail Fail: Graph does not contain '${scenario}' with "${text}" -test suite ${suite} contains trace info ${ti} - [Documentation] *model info* - ... :IN: suite, trace_info - ... :OUT: suite, trace_info - Variable Should Exist ${suite} - Variable Should Exist ${trace_info} +graph ${name} does not contain vertex '${scenario}' with text "${text}" + [Documentation] *model info* + ... :IN: graph.name=${name} + ... :OUT: graph.name=${name} + Variable Should Exist ${graph} + + ${result} = Graph Contains Vertex With Text ${graph} ${scenario} ${text} + Run Keyword If "${result}" != "None" + ... Fail Fail: Graph contains '${scenario}' with "${text}" + +graph ${name} has an edge from '${start_title}' to '${end_title}' + [Documentation] *model_info* + ... :IN: graph.name=${name} + ... :OUT: graph.name=${name} + Variable Should Exist ${graph} + ${start_node} = Graph Contains Vertex With Text ${graph} ${start_title} + ${end_node} = Graph Contains Vertex With Text ${graph} ${end_title} + + Should Not Be Equal ${start_node} ${None} Start node with title '${start_title}' not found + Should Not Be Equal ${end_node} ${None} End node with title '${end_title}' not found + + ${result} = Vertices Are Connected ${graph} ${start_node} ${end_node} + Run Keyword If ${result} != True + ... Fail Fail: Edges '${start_title}' and '${end_title}' exist in graph but are not connected! + +graph ${name} does not have an edge from '${start_title}' to '${end_title}' + [Documentation] *model_info* + ... :IN: graph.name=${name} + ... :OUT: graph.name=${name} + Variable Should Exist ${graph} + ${start_node} = Graph Contains Vertex With Text ${graph} ${start_title} + ${end_node} = Graph Contains Vertex With Text ${graph} ${end_title} + + ${result} = Vertices Are Connected ${graph} ${start_node} ${end_node} + Run Keyword If ${result} == True + ... Fail Fail: Edges '${start_title}' and '${end_title}' exist in graph and are connected, but shouldn't be! + +graph ${name} has vertices ${vertices_string} + [Documentation] *model_info* + ... :IN: graph.name=${name} + ... :OUT: graph.name=${name} + Variable Should Exist ${graph} + ${result} = Graph Contains Vertices Starting With ${graph} ${vertices_string} + Run Keyword If ${result} != True + ... Fail Graph did not contain all vertices ${vertices_string} + +vertex '${start_vertex}' is placed above '${end_vertex}' + [Documentation] *model_info* + ... :IN: None + ... :OUT: None + Variable Should Exist ${graph} + Variable Should Exist ${network_visualiser} + ${node1} = Get NodeID ${graph} ${start_vertex} + + Run Keyword If "${node1}" == "None" + ... Fail Vertex '${start_vertex}' does not exist + + ${node2} = Get NodeID ${graph} ${end_vertex} + + Run Keyword If "${node2}" == "None" + ... Fail Vertex '${end_vertex}' does not exist + + ${result1} = Get Vertex Y position ${network_visualiser} ${node1} + ${result2} = Get Vertex Y position ${network_visualiser} ${node2} + + Run Keyword If ${result1} < ${result2} + ... Fail Vertex '${start_vertex}' is below '${end_vertex}' + +the algorithm backtracks by ${n} step(s) + [Documentation] *model info* + ... :IN: trace_info.current_trace >= ${n} + ... :OUT: trace_info.current_trace-=${n} | trace_info.all_traces+=1 + Variable Should Exist ${trace_info} + + ${a} = Get Length Current Trace ${trace_info} + ${b} = Get Number of Backtracked Traces ${trace_info} + + ${trace_info} = Backtrack ${trace_info} ${n} + + ${i} = Get Length Current Trace ${trace_info} + ${j} = Get Number of Backtracked Traces ${trace_info} + + Run Keyword If ${a} - ${n} != ${i} + ... Fail Fail: Backtracking did not shorten current trace correctly + + Run Keyword If ${b} + 1 != ${j} + ... Fail Fail: Backtracking did not add new trace to all traces test suite ${suite} is exported to json [Documentation] *model info* - ... :IN: suite, trace_info + ... :IN: trace_info.current_trace, trace_info.all_traces>0 ... :OUT: new filepath Variable Should Exist ${suite} Variable Should Exist ${trace_info} @@ -78,20 +168,21 @@ test suite ${suite} is exported to json the file ${filename} exists [Documentation] *model info* - ... :IN: suite, filepath - ... :OUT: suite, filepath + ... :IN: filepath + ... :OUT: filepath Variable Should Exist ${filepath} ${result} = Check File Exists ${filepath} - Should Be Equal ${result} file exists + Run Keyword If ${result} == False + ... Fail Fail: File does not exist ${filename} is imported [Documentation] *model info* - ... :IN: suite, filepath + ... :IN: filepath ... :OUT: new new_trace_info Variable Should Exist ${filepath} - ${new_trace_info} = Import JSON File ${filepath} + ${new_trace_info} = Import Graph ${filepath} Set Suite Variable ${new_trace_info} trace info from ${filename} is the same as trace info ${trace} @@ -102,7 +193,14 @@ trace info from ${filename} is the same as trace info ${trace} Variable Should Exist ${new_trace_info} ${result} = Compare Trace Info ${trace_info} ${new_trace_info} - Should Be Equal ${result} imported model equals exported model + Run Keyword If ${result} == False + ... Fail Fail: Exported and Imported trace info are not the same + +${file} has been imported + [Documentation] *model info* + ... :IN: flag_cleanup + ... :OUT: flag_cleanup + No operation ${filename} is deleted [Documentation] *model info* @@ -119,16 +217,5 @@ ${filename} does not exist Variable Should Exist ${filepath} ${result} = Check File Exists ${filepath} - Should Be Equal ${result} file does not exist - -flag cleanup is set - [Documentation] *model info* - ... :IN: suite - ... :OUT: suite - Set Suite Variable ${flag_cleanup} True - -flag cleanup has been set - [Documentation] *model info* - ... :IN: flag_cleanup - ... :OUT: flag_cleanup - Variable Should Exist ${flag_cleanup} + Run Keyword If ${result} == True + ... Fail Fail: File exist \ No newline at end of file diff --git a/atest/robotMBT tests/10__visualisation_representations/0__setup.robot b/atest/robotMBT tests/10__visualisation_representations/0__setup.robot new file mode 100644 index 00000000..2c2da0bf --- /dev/null +++ b/atest/robotMBT tests/10__visualisation_representations/0__setup.robot @@ -0,0 +1,8 @@ +*** Settings *** +Library robotmbt processor=flatten + +*** Test Cases *** +Feature Setup + Given test suite s has trace info t + Then the algorithm inserts 'A1' with state "attr: states = ['a1'], special='!'" + And the algorithm inserts 'A2' with state "attr: states = ['a1', 'a2'], special='!'" \ No newline at end of file diff --git a/atest/robotMBT tests/10__visualisation_representations/1__scenario-delta-value.robot b/atest/robotMBT tests/10__visualisation_representations/1__scenario-delta-value.robot new file mode 100644 index 00000000..4f7322ef --- /dev/null +++ b/atest/robotMBT tests/10__visualisation_representations/1__scenario-delta-value.robot @@ -0,0 +1,27 @@ +*** Settings *** +Library robotmbt processor=flatten + +*** Test Cases *** +Vertex Scenario-Delta-Value graph + Given trace info t + When scenario-delta-value graph g is generated + Then graph g contains vertex 'start' + And graph g contains vertex 'A1' with text "attr: states = ['a1'], special='!'" + And graph g contains vertex 'A2' with text "attr: states = ['a1', 'a2']" + And graph g does not contain vertex 'A2' with text "attr: states = ['a1', 'a2'], special='!'" + +Edge Scenario-Delta-Value graph + Given trace info t + When scenario-delta-value graph g is generated + Then graph g has an edge from 'start' to 'A1' + And graph g has an edge from 'A1' to 'A2' + And graph g does not have an edge from 'start' to 'A2' + And graph g does not have an edge from 'A2' to 'A1' + And graph g does not have an edge from 'A2' to 'start' + +Visual location of vertices scenario-delta-value + Given trace info t + When scenario-delta-value graph g is generated + Then graph g has vertices 'start', 'A1', 'A2' + And vertex 'start' is placed above 'A1' + And vertex 'A1' is placed above 'A2' diff --git a/atest/robotMBT tests/10__visualisation_representations/__init__.robot b/atest/robotMBT tests/10__visualisation_representations/__init__.robot new file mode 100644 index 00000000..05729ca8 --- /dev/null +++ b/atest/robotMBT tests/10__visualisation_representations/__init__.robot @@ -0,0 +1,5 @@ +*** Settings *** +Documentation Test correctness all graph representations +Suite Setup Treat this test suite Model-based +Resource ../../resources/visualisation.resource +Library robotmbt processor=flatten \ No newline at end of file diff --git a/atest/robotMBT tests/10__visualisation_tests/C.2.3__export to JSON.robot b/atest/robotMBT tests/10__visualisation_tests/C.2.3__export to JSON.robot deleted file mode 100644 index bf3e309a..00000000 --- a/atest/robotMBT tests/10__visualisation_tests/C.2.3__export to JSON.robot +++ /dev/null @@ -1,35 +0,0 @@ -*** Settings *** -Documentation Export and import a test suite from and to JSON -... and check that the imported suite equals the -... exported suite. -Suite Setup Treat this test suite Model-based graph=scenario -Resource ../../resources/visualisation.resource -Library robotmbt - -*** Test Cases *** -Create test suite - Given test suite s - Then test suite s has trace info t - and trace info t has current trace c - and current trace c has a tuple 'scenario i, state p: v=1' - and current trace c has a tuple 'scenario j, state p: v=2' - and trace info t has all traces a - and all traces a has list l - and list l has a tuple 'scenario i, state p: v=2' - -Export test suite to json file - Given test suite s contains trace info t - When test suite s is exported to json - Then the file s.json exists - -Load json file into robotmbt - Given the file s.json exists - When s.json is imported - Then trace info from s.json is the same as trace info t - and flag cleanup is set - -Cleanup - Given the file s.json exists - and flag cleanup has been set - When s.json is deleted - Then s.json does not exist \ No newline at end of file diff --git a/atest/robotMBT tests/12__visualisation_export/01__export to JSON.robot b/atest/robotMBT tests/12__visualisation_export/01__export to JSON.robot new file mode 100644 index 00000000..fd5157bb --- /dev/null +++ b/atest/robotMBT tests/12__visualisation_export/01__export to JSON.robot @@ -0,0 +1,37 @@ +*** Settings *** +Documentation Export and import a test suite from and to JSON +... and check that the imported suite equals the +... exported suite. +Suite Setup Treat this test suite Model-based graph=scenario-state +Resource ../../resources/visualisation.resource +Library robotmbt + +*** Test Cases *** +Setup + Given test suite s has trace info t + When the algorithm inserts 'A1' with state "attr: states = ['a1'], special='!'" + And the algorithm inserts 'A2' with state "attr: states = ['a1', 'a2'], special='!'" + Then test suite s has 2 steps in its current trace + +Backtrack and Insert + Given test suite s has 2 steps in its current trace + When the algorithm backtracks by 1 step(s) + And the algorithm inserts 'B1' with state "attr: states=['a1', 'b1'], special='!'" + Then test suite s has 2 total traces + And test suite s has 2 steps in its current trace + +Export test suite to json file + Given test suite s has 2 total traces + When test suite s is exported to json + Then the file s.json exists + +Load json file into robotmbt + Given the file s.json exists + When s.sjon is imported + Then trace info from s.json is the same as trace info t + +Clean-up + Given the file s.json exists + And s.json has been imported + When s.sjon is deleted + Then s.json does not exist \ No newline at end of file diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index c8064434..d97e7d37 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -130,8 +130,8 @@ def _run_test_suite(self, seed: Any, graph: str, suite_name: str, to_json: bool) logger.warn(f'Could not initialise visualiser due to error!\n{e}') elif graph != '' and not VISUALISE: - logger.warn(f'Visualisation {graph} requested, but required dependencies are not installed.' - 'Refer to the README on how to install these dependencies.') + logger.warn(f'Visualisation {graph} requested, but required dependencies are not installed. ' + 'Refer to the README on how to install these dependencies. ') # a short trace without the need for repeating scenarios is preferred tracestate = self._try_to_reach_full_coverage(allow_duplicate_scenarios=False) diff --git a/robotmbt/visualise/graphs/abstractgraph.py b/robotmbt/visualise/graphs/abstractgraph.py index ba69a157..28341bf2 100644 --- a/robotmbt/visualise/graphs/abstractgraph.py +++ b/robotmbt/visualise/graphs/abstractgraph.py @@ -11,6 +11,9 @@ class AbstractGraph(ABC, Generic[NodeInfo, EdgeInfo]): def __init__(self, info: TraceInfo): + """ + Note that networkx's ids have to be of a serializable and hashable type after construction. + """ # The underlying storage - a NetworkX DiGraph self.networkx: nx.DiGraph = nx.DiGraph() diff --git a/robotmbt/visualise/graphs/reducedSDVgraph.py b/robotmbt/visualise/graphs/reducedSDVgraph.py index f1662d9c..100702d6 100644 --- a/robotmbt/visualise/graphs/reducedSDVgraph.py +++ b/robotmbt/visualise/graphs/reducedSDVgraph.py @@ -41,12 +41,28 @@ def __init__(self, info: TraceInfo): edge_data=lambda x, y: {'label': ''}) # TODO make generated label more obvious to be equivalence class nodes = self.networkx.nodes + + new_networkx: networkx.DiGraph = networkx.DiGraph() + + for node_id in self.networkx.nodes: + new_id: tuple[str, ...] = tuple(sorted(node_id)) + new_networkx.add_node(new_id) + new_networkx.nodes[new_id]['label'] = self.networkx.nodes[node_id]['label'] + + for (from_id, to_id) in self.networkx.edges: + new_from_id: tuple[str, ...] = tuple(sorted(from_id)) + new_to_id: tuple[str, ...] = tuple(sorted(to_id)) + new_networkx.add_edge(new_from_id, new_to_id) + new_networkx.edges[(new_from_id, new_to_id)]['label'] = self.networkx.edges[(from_id, to_id)]['label'] + for i in range(len(self.final_trace)): current_node = self.final_trace[i] for new_node in nodes: if current_node in new_node: - self.final_trace[i] = new_node - self.start_node = frozenset(['start']) + self.final_trace[i] = tuple(sorted(new_node)) + + self.networkx = new_networkx + self.start_node: tuple[str, ...] = tuple(['start']) @staticmethod def select_node_info(pairs: list[tuple[ScenarioInfo, StateInfo]], index: int) \ diff --git a/robotmbt/visualise/networkvisualiser.py b/robotmbt/visualise/networkvisualiser.py index 22acbed8..715ff3cc 100644 --- a/robotmbt/visualise/networkvisualiser.py +++ b/robotmbt/visualise/networkvisualiser.py @@ -81,6 +81,9 @@ def __init__(self, graph: AbstractGraph, suite_name: str, seed: str): # Create Sugiyama layout nodes, edges = self._create_layout() + self.node_dict: dict[str, Node] = {} + for node in nodes: + self.node_dict[node.node_id] = node # Keep track of arrows in the graph for scaling self.arrows = [] @@ -212,14 +215,18 @@ def _create_layout(self) -> tuple[list[Node], list[Edge]]: label = self.networkx.nodes[node_id]['label'] (x, y) = v.view.xy (w, h) = _calculate_dimensions(label) - ns.append(Node(node_id, label, x, y, w, h)) + ns.append(Node(node_id, label, x, -y, w, h)) es = [] for e in g.C[0].sE: from_id = e.v[0].data to_id = e.v[1].data label = self.networkx.edges[(from_id, to_id)]['label'] - points = e.view.points + points = [] + # invert y axis + for p in e.view.points: + points.append((p[0], -p[1])) + es.append(Edge(from_id, to_id, label, points)) return ns, es @@ -446,16 +453,6 @@ def _add_edge_to_sources(nodes: list[Node], edge: Edge, final_trace: list[str], start_x, start_y = 0, 0 end_x, end_y = 0, 0 - if isinstance(edge.from_node, frozenset): - from_id = tuple(sorted(edge.from_node)) - else: - from_id = edge.from_node - - if isinstance(edge.to_node, frozenset): - to_id = tuple(sorted(edge.to_node)) - else: - to_id = edge.to_node - # Add edges going through the calculated points for i in range(len(edge.points) - 1): start_x, start_y = edge.points[i] @@ -477,28 +474,28 @@ def _add_edge_to_sources(nodes: list[Node], edge: Edge, final_trace: list[str], if i < len(edge.points) - 2: # Middle part of edge without arrow - edge_part_source.data['from'].append(from_id) - edge_part_source.data['to'].append(to_id) + edge_part_source.data['from'].append(edge.from_node) + edge_part_source.data['to'].append(edge.to_node) edge_part_source.data['start_x'].append(start_x) - edge_part_source.data['start_y'].append(-start_y) + edge_part_source.data['start_y'].append(start_y) edge_part_source.data['end_x'].append(end_x) - edge_part_source.data['end_y'].append(-end_y) + edge_part_source.data['end_y'].append(end_y) edge_part_source.data['color'].append(FINAL_TRACE_EDGE_COLOR if in_final_trace else OTHER_EDGE_COLOR) else: # End of edge with arrow - edge_arrow_source.data['from'].append(from_id) - edge_arrow_source.data['to'].append(to_id) + edge_arrow_source.data['from'].append(edge.from_node) + edge_arrow_source.data['to'].append(edge.to_node) edge_arrow_source.data['start_x'].append(start_x) - edge_arrow_source.data['start_y'].append(-start_y) + edge_arrow_source.data['start_y'].append(start_y) edge_arrow_source.data['end_x'].append(end_x) - edge_arrow_source.data['end_y'].append(-end_y) + edge_arrow_source.data['end_y'].append(end_y) edge_arrow_source.data['color'].append(FINAL_TRACE_EDGE_COLOR if in_final_trace else OTHER_EDGE_COLOR) # Add the label - edge_label_source.data['from'].append(from_id) - edge_label_source.data['to'].append(to_id) + edge_label_source.data['from'].append(edge.from_node) + edge_label_source.data['to'].append(edge.to_node) edge_label_source.data['x'].append((start_x + end_x) / 2) - edge_label_source.data['y'].append(- (start_y + end_y) / 2) + edge_label_source.data['y'].append((start_y + end_y) / 2) edge_label_source.data['label'].append(edge.label) @@ -509,45 +506,35 @@ def _add_self_loop_to_sources(nodes: list[Node], edge: Edge, in_final_trace: boo """ connection = _get_connection_coordinates(nodes, edge.from_node) - if isinstance(edge.from_node, frozenset): - from_id = tuple(sorted(edge.from_node)) - else: - from_id = edge.from_node - - if isinstance(edge.to_node, frozenset): - to_id = tuple(sorted(edge.to_node)) - else: - to_id = edge.to_node - right_x, right_y = connection[1] # Add the Bézier curve - edge_bezier_source.data['from'].append(from_id) - edge_bezier_source.data['to'].append(to_id) + edge_bezier_source.data['from'].append(edge.from_node) + edge_bezier_source.data['to'].append(edge.to_node) edge_bezier_source.data['start_x'].append(right_x) - edge_bezier_source.data['start_y'].append(-right_y + 5) + edge_bezier_source.data['start_y'].append(right_y + 5) edge_bezier_source.data['end_x'].append(right_x) - edge_bezier_source.data['end_y'].append(-right_y - 5) + edge_bezier_source.data['end_y'].append(right_y - 5) edge_bezier_source.data['control1_x'].append(right_x + 25) - edge_bezier_source.data['control1_y'].append(-right_y + 25) + edge_bezier_source.data['control1_y'].append(right_y + 25) edge_bezier_source.data['control2_x'].append(right_x + 25) - edge_bezier_source.data['control2_y'].append(-right_y - 25) + edge_bezier_source.data['control2_y'].append(right_y - 25) edge_bezier_source.data['color'].append(FINAL_TRACE_EDGE_COLOR if in_final_trace else OTHER_EDGE_COLOR) # Add the arrow - edge_arrow_source.data['from'].append(from_id) - edge_arrow_source.data['to'].append(to_id) + edge_arrow_source.data['from'].append(edge.from_node) + edge_arrow_source.data['to'].append(edge.to_node) edge_arrow_source.data['start_x'].append(right_x + 0.001) - edge_arrow_source.data['start_y'].append(-right_y - 5.001) + edge_arrow_source.data['start_y'].append(right_y - 5.001) edge_arrow_source.data['end_x'].append(right_x) - edge_arrow_source.data['end_y'].append(-right_y - 5) + edge_arrow_source.data['end_y'].append(right_y - 5) edge_arrow_source.data['color'].append(FINAL_TRACE_EDGE_COLOR if in_final_trace else OTHER_EDGE_COLOR) # Add the label - edge_label_source.data['from'].append(from_id) - edge_label_source.data['to'].append(to_id) + edge_label_source.data['from'].append(edge.from_node) + edge_label_source.data['to'].append(edge.to_node) edge_label_source.data['x'].append(right_x + 25) - edge_label_source.data['y'].append(-right_y) + edge_label_source.data['y'].append(right_y) edge_label_source.data['label'].append(edge.label) @@ -556,25 +543,19 @@ def _add_node_to_sources(node: Node, final_trace: list[str], node_source: Column """ Add a node to the ColumnDataSources. """ - if isinstance(node.node_id, frozenset): - node_id = tuple(sorted(node.node_id)) - else: - node_id = node.node_id - - node_source.data['id'].append(node_id) + node_source.data['id'].append(node.node_id) node_source.data['x'].append(node.x) - node_source.data['y'].append(-node.y) + node_source.data['y'].append(node.y) node_source.data['w'].append(node.width) node_source.data['h'].append(node.height) node_source.data['color'].append( FINAL_TRACE_NODE_COLOR if node.node_id in final_trace else OTHER_NODE_COLOR) - node_label_source.data['id'].append(node_id) + node_label_source.data['id'].append(node.node_id) node_label_source.data['x'].append(node.x - node.width / 2 + HORIZONTAL_PADDING_WITHIN_NODES) - node_label_source.data['y'].append(-node.y) + node_label_source.data['y'].append(node.y) node_label_source.data['label'].append(node.label) - def _calculate_dimensions(label: str) -> tuple[float, float]: """ Calculate a node's dimensions based on its label and the given font size constant. diff --git a/utest/test_visualise_abstractgraph.py b/utest/test_visualise_abstractgraph.py deleted file mode 100644 index 7d966511..00000000 --- a/utest/test_visualise_abstractgraph.py +++ /dev/null @@ -1,39 +0,0 @@ -import unittest - -try: - import networkx as nx - from robotmbt.visualise.graphs.stategraph import StateGraph - from robotmbt.visualise.models import * - - VISUALISE = True -except ImportError: - VISUALISE = False - -if VISUALISE: - class TestVisualiseAbstractGraph(unittest.TestCase): - def test_abstract_graph_add_edge_labels_for_state_graph_self_loop(self): - """ - Testing the case where on an edge "scenario2" occurs twice - Without the "(rep x)" present - """ - info = TraceInfo() - - scenario1 = ScenarioInfo('scenario1') - scenario2 = ScenarioInfo('scenario2') - scenario3 = ScenarioInfo('scenario3') - - space1 = StateInfo._create_state_with_prop( - "prop", [("value", "some_value")]) - - info.update_trace(scenario1, space1, 1) - info.update_trace(scenario2, space1, 2) - info.update_trace(scenario3, space1, 3) - info.update_trace(scenario2, space1, 4) - - sg = StateGraph(info) - labels = nx.get_edge_attributes(sg.networkx, 'label') - self.assertEqual(labels[('node0', 'node0')], - 'scenario2\nscenario3') - -if __name__ == '__main__': - unittest.main() diff --git a/utest/test_visualise_scenariodeltavaluegraph.py b/utest/test_visualise_scenariodeltavaluegraph.py deleted file mode 100644 index 395be7a7..00000000 --- a/utest/test_visualise_scenariodeltavaluegraph.py +++ /dev/null @@ -1,57 +0,0 @@ -import unittest - - -try: - from robotmbt.visualise.graphs.scenariodeltavaluegraph import ScenarioDeltaValueGraph - from robotmbt.visualise.models import * - - VISUALISE = True -except ImportError: - VISUALISE = False - -if VISUALISE: - class TestVisualiseScenarioDeltaValueGraph(unittest.TestCase): - def test_scenario_delta_value_graph_init(self): - info = TraceInfo() - stg = ScenarioDeltaValueGraph(info) - - self.assertEqual(len(stg.networkx.nodes), 1) - self.assertEqual(len(stg.networkx.edges), 0) - - self.assertIn('start', stg.networkx.nodes) - self.assertEqual(stg.networkx.nodes['start']['label'], 'start') - - def test_scenario_delta_value_graph_ids_empty(self): - info = TraceInfo() - stg = ScenarioDeltaValueGraph(info) - - scenario = ScenarioInfo('test') - - node_id = stg._get_or_create_id((scenario, set({'x': '1'}.items()))) - - self.assertEqual(node_id, 'node0') - - def test_scenario_delta_value_graph_ids_duplicate_scenario(self): - info = TraceInfo() - stg = ScenarioDeltaValueGraph(info) - - s0 = ScenarioInfo('test') - s1 = ScenarioInfo('test') - - id0 = stg._get_or_create_id((s0, set({'x': '1'}.items()))) - id1 = stg._get_or_create_id((s1, set({'x': '1'}.items()))) - - self.assertEqual(id0, id1) - - def test_scenario_delta_value_graph_merge_same_scenario_update(self): - info = TraceInfo() - sti = StateInfo._create_state_with_prop("prop", [("x", "1")]) - info.update_trace(ScenarioInfo("incr x"), sti, 1) - info.update_trace(ScenarioInfo("set y"), StateInfo._create_state_with_prop("prop", [("y", "True")]), 2) - info.update_trace(ScenarioInfo("incr x"), sti, 1) - info.update_trace(ScenarioInfo("incr x"), StateInfo._create_state_with_prop("prop", [("x", "2")]), 2) - info.update_trace(ScenarioInfo("set y"), StateInfo._create_state_with_prop("prop", [("y", "True")]), 3) - sdvg = ScenarioDeltaValueGraph(info) - self.assertEqual(len(sdvg.networkx.nodes), 4) - - # TODO add more tests diff --git a/utest/test_visualise_scenariograph.py b/utest/test_visualise_scenariograph.py deleted file mode 100644 index 0044878d..00000000 --- a/utest/test_visualise_scenariograph.py +++ /dev/null @@ -1,248 +0,0 @@ -import unittest - -try: - from robotmbt.visualise.graphs.scenariograph import ScenarioGraph - from robotmbt.visualise.models import * - - VISUALISE = True -except ImportError: - VISUALISE = False - -if VISUALISE: - class TestVisualiseScenarioGraph(unittest.TestCase): - def test_scenario_graph_init(self): - info = TraceInfo() - sg = ScenarioGraph(info) - - self.assertEqual(len(sg.networkx.nodes), 1) - self.assertEqual(len(sg.networkx.edges), 0) - - self.assertIn('start', sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes['start']['label'], 'start') - - def test_scenario_graph_ids_empty(self): - info = TraceInfo() - sg = ScenarioGraph(info) - - s = ScenarioInfo('test') - - node_id = sg._get_or_create_id(s) - - self.assertEqual(node_id, 'node0') - - def test_scenario_graph_ids_duplicate_scenario(self): - info = TraceInfo() - sg = ScenarioGraph(info) - - s0 = ScenarioInfo('test') - s1 = ScenarioInfo('test') - - id0 = sg._get_or_create_id(s0) - id1 = sg._get_or_create_id(s1) - - self.assertEqual(id0, id1) - - def test_scenario_graph_ids_different_scenarios(self): - info = TraceInfo() - sg = ScenarioGraph(info) - - si00 = ScenarioInfo('test0') - si01 = ScenarioInfo('test0') - si10 = ScenarioInfo('test1') - si11 = ScenarioInfo('test1') - - id00 = sg._get_or_create_id(si00) - id01 = sg._get_or_create_id(si01) - id10 = sg._get_or_create_id(si10) - id11 = sg._get_or_create_id(si11) - - self.assertEqual(id00, 'node0') - self.assertEqual(id01, 'node0') - self.assertEqual(id00, id01) - - self.assertEqual(id10, 'node1') - self.assertEqual(id11, 'node1') - self.assertEqual(id10, id11) - - self.assertNotEqual(id00, id10) - self.assertNotEqual(id00, id11) - self.assertNotEqual(id01, id10) - self.assertNotEqual(id01, id11) - - def test_scenario_graph_add_new_node(self): - info = TraceInfo() - sg = ScenarioGraph(info) - - sg.ids['test'] = ScenarioInfo('test') - sg._add_node('test') - - self.assertEqual(len(sg.networkx.nodes), 2) - - self.assertIn('start', sg.networkx.nodes) - self.assertIn('test', sg.networkx.nodes) - - self.assertEqual(sg.networkx.nodes['start']['label'], 'start') - self.assertEqual(sg.networkx.nodes['test']['label'], 'test') - - def test_scenario_graph_add_existing_node(self): - info = TraceInfo() - sg = ScenarioGraph(info) - - self.assertEqual(len(sg.networkx.nodes), 1) - self.assertIn('start', sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes['start']['label'], 'start') - - sg._add_node('start') - - self.assertEqual(len(sg.networkx.nodes), 1) - self.assertIn('start', sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes['start']['label'], 'start') - - def test_scenario_graph_update_nodes(self): - info = TraceInfo() - - scenario0 = ScenarioInfo('0') - scenario1 = ScenarioInfo('1') - scenario2 = ScenarioInfo('2') - - info.update_trace(scenario0, StateInfo(ModelSpace()), 1) - info.update_trace(scenario1, StateInfo(ModelSpace()), 2) - info.update_trace(scenario2, StateInfo(ModelSpace()), 3) - - sg = ScenarioGraph(info) - - self.assertEqual(len(sg.networkx.nodes), 4) - - self.assertIn('start', sg.networkx.nodes) - self.assertIn('node0', sg.networkx.nodes) - self.assertIn('node1', sg.networkx.nodes) - self.assertIn('node2', sg.networkx.nodes) - - self.assertEqual(sg.networkx.nodes['start']['label'], 'start') - self.assertEqual(sg.networkx.nodes['node0']['label'], '0') - self.assertEqual(sg.networkx.nodes['node1']['label'], '1') - self.assertEqual(sg.networkx.nodes['node2']['label'], '2') - - def test_scenario_graph_update_edges(self): - info = TraceInfo() - - scenario0 = ScenarioInfo('0') - scenario1 = ScenarioInfo('1') - scenario2 = ScenarioInfo('2') - - info.update_trace(scenario0, StateInfo(ModelSpace()), 1) - info.update_trace(scenario1, StateInfo(ModelSpace()), 2) - info.update_trace(scenario2, StateInfo(ModelSpace()), 3) - - sg = ScenarioGraph(info) - - self.assertEqual(len(sg.networkx.edges), 3) - - self.assertIn(('start', 'node0'), sg.networkx.edges) - self.assertIn(('node0', 'node1'), sg.networkx.edges) - self.assertIn(('node1', 'node2'), sg.networkx.edges) - - self.assertEqual(sg.networkx.edges[('start', 'node0')]['label'], '') - self.assertEqual(sg.networkx.edges[('node0', 'node1')]['label'], '') - self.assertEqual(sg.networkx.edges[('node1', 'node2')]['label'], '') - - def test_scenario_graph_update_single_node(self): - info = TraceInfo() - - scenario0 = ScenarioInfo('test') - - info.update_trace(scenario0, StateInfo(ModelSpace()), 1) - - sg = ScenarioGraph(info) - - self.assertEqual(len(sg.networkx.nodes), 2) - self.assertEqual(len(sg.networkx.edges), 1) - - self.assertIn('start', sg.networkx.nodes) - self.assertIn('node0', sg.networkx.nodes) - - self.assertEqual(sg.networkx.nodes['start']['label'], 'start') - self.assertEqual(sg.networkx.nodes['node0']['label'], 'test') - - self.assertIn(('start', 'node0'), sg.networkx.edges) - - self.assertEqual(sg.networkx.edges[('start', 'node0')]['label'], '') - - def test_scenario_graph_update_backtrack(self): - info = TraceInfo() - - scenario0 = ScenarioInfo('0') - scenario1 = ScenarioInfo('1') - scenario2 = ScenarioInfo('2') - scenario3 = ScenarioInfo('3') - - info.update_trace(scenario0, StateInfo(ModelSpace()), 1) - info.update_trace(scenario1, StateInfo(ModelSpace()), 2) - info.update_trace(scenario2, StateInfo(ModelSpace()), 3) - info.update_trace(scenario1, StateInfo(ModelSpace()), 2) - info.update_trace(scenario0, StateInfo(ModelSpace()), 1) - info.update_trace(scenario3, StateInfo(ModelSpace()), 2) - info.update_trace(scenario1, StateInfo(ModelSpace()), 3) - info.update_trace(scenario2, StateInfo(ModelSpace()), 4) - - sg = ScenarioGraph(info) - - self.assertEqual(len(sg.networkx.nodes), 5) - self.assertEqual(len(sg.networkx.edges), 5) - - def test_scenario_graph_final_trace_normal(self): - info = TraceInfo() - - scenario0 = ScenarioInfo('0') - scenario1 = ScenarioInfo('1') - scenario2 = ScenarioInfo('2') - - info.update_trace(scenario0, StateInfo(ModelSpace()), 1) - info.update_trace(scenario1, StateInfo(ModelSpace()), 2) - info.update_trace(scenario2, StateInfo(ModelSpace()), 3) - - sg = ScenarioGraph(info) - - trace = sg.get_final_trace() - - # confirm they are proper ids - for node in trace: - self.assertIn(node, sg.networkx.nodes) - - # confirm the edges exist - for i in range(0, len(trace) - 1): - self.assertIn((trace[i], trace[i + 1]), sg.networkx.edges) - - self.assertEqual(trace, ['start', 'node0', 'node1', 'node2']) - - def test_scenario_graph_final_trace_backtrack(self): - info = TraceInfo() - - scenario0 = ScenarioInfo('0') - scenario1 = ScenarioInfo('1') - scenario2 = ScenarioInfo('2') - - info.update_trace(scenario0, StateInfo(ModelSpace()), 1) - info.update_trace(scenario1, StateInfo(ModelSpace()), 2) - info.update_trace(scenario2, StateInfo(ModelSpace()), 3) - info.update_trace(scenario1, StateInfo(ModelSpace()), 2) - info.update_trace(scenario0, StateInfo(ModelSpace()), 1) - info.update_trace(scenario2, StateInfo(ModelSpace()), 2) - info.update_trace(scenario1, StateInfo(ModelSpace()), 3) - - sg = ScenarioGraph(info) - - trace = sg.get_final_trace() - - # confirm they are proper ids - for node in trace: - self.assertIn(node, sg.networkx.nodes) - - # confirm the edges exist - for i in range(0, len(trace) - 1): - self.assertIn((trace[i], trace[i + 1]), sg.networkx.edges) - - self.assertEqual(trace, ['start', 'node0', 'node2', 'node1']) - -if __name__ == '__main__': - unittest.main() diff --git a/utest/test_visualise_scenariostategraph.py b/utest/test_visualise_scenariostategraph.py deleted file mode 100644 index 9fd40dd7..00000000 --- a/utest/test_visualise_scenariostategraph.py +++ /dev/null @@ -1,349 +0,0 @@ -import unittest -from typing import Any - -try: - from robotmbt.visualise.graphs.scenariostategraph import ScenarioStateGraph - from robotmbt.visualise.models import * - - VISUALISE = True -except ImportError: - VISUALISE = False - -if VISUALISE: - class TestVisualiseScenarioGraph(unittest.TestCase): - def test_scenario_state_graph_init(self): - info = TraceInfo() - stg = ScenarioStateGraph(info) - - self.assertEqual(len(stg.networkx.nodes), 1) - self.assertEqual(len(stg.networkx.edges), 0) - - self.assertIn('start', stg.networkx.nodes) - self.assertEqual(stg.networkx.nodes['start']['label'], 'start') - - def test_scenario_state_graph_ids_empty(self): - info = TraceInfo() - stg = ScenarioStateGraph(info) - - scenario = ScenarioInfo('test') - state = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - - node_id = stg._get_or_create_id((scenario, state)) - - self.assertEqual(node_id, 'node0') - - def test_scenario_state_graph_ids_duplicate_scenario(self): - info = TraceInfo() - stg = ScenarioStateGraph(info) - - s0 = ScenarioInfo('test') - s1 = ScenarioInfo('test') - st0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - st1 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - - id0 = stg._get_or_create_id((s0, st0)) - id1 = stg._get_or_create_id((s1, st1)) - - self.assertEqual(id0, id1) - - def test_scenario_state_graph_ids_different_scenarios(self): - info = TraceInfo() - stg = ScenarioStateGraph(info) - - s00 = ScenarioInfo('test0') - s01 = ScenarioInfo('test0') - s10 = ScenarioInfo('test1') - s11 = ScenarioInfo('test1') - - state = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - - id00 = stg._get_or_create_id((s00, state)) - id01 = stg._get_or_create_id((s01, state)) - id10 = stg._get_or_create_id((s10, state)) - id11 = stg._get_or_create_id((s11, state)) - - self.assertEqual(id00, 'node0') - self.assertEqual(id01, 'node0') - self.assertEqual(id00, id01) - - self.assertEqual(id10, 'node1') - self.assertEqual(id11, 'node1') - self.assertEqual(id10, id11) - - self.assertNotEqual(id00, id10) - self.assertNotEqual(id00, id11) - self.assertNotEqual(id01, id10) - self.assertNotEqual(id01, id11) - - def test_scenario_state_graph_ids_different_states(self): - info = TraceInfo() - stg = ScenarioStateGraph(info) - - scenario = ScenarioInfo('test') - - s00 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - s01 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - s10 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) - s11 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) - - id00 = stg._get_or_create_id((scenario, s00)) - id01 = stg._get_or_create_id((scenario, s01)) - id10 = stg._get_or_create_id((scenario, s10)) - id11 = stg._get_or_create_id((scenario, s11)) - - self.assertEqual(id00, 'node0') - self.assertEqual(id01, 'node0') - self.assertEqual(id00, id01) - - self.assertEqual(id10, 'node1') - self.assertEqual(id11, 'node1') - self.assertEqual(id10, id11) - - self.assertNotEqual(id00, id10) - self.assertNotEqual(id00, id11) - self.assertNotEqual(id01, id10) - self.assertNotEqual(id01, id11) - - def test_scenario_state_graph_ids_different_scenario_state(self): - info = TraceInfo() - stg = ScenarioStateGraph(info) - - s00 = ScenarioInfo('test0') - s01 = ScenarioInfo('test1') - s10 = ScenarioInfo('test0') - s11 = ScenarioInfo('test1') - - st00 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - st01 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - st10 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) - st11 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) - - id00 = stg._get_or_create_id((s00, st00)) - id01 = stg._get_or_create_id((s01, st01)) - id10 = stg._get_or_create_id((s10, st10)) - id11 = stg._get_or_create_id((s11, st11)) - - self.assertEqual(id00, 'node0') - self.assertEqual(id01, 'node1') - self.assertEqual(id10, 'node2') - self.assertEqual(id11, 'node3') - - self.assertNotEqual(id00, id01) - self.assertNotEqual(id00, id10) - self.assertNotEqual(id00, id11) - self.assertNotEqual(id01, id10) - self.assertNotEqual(id01, id11) - self.assertNotEqual(id10, id11) - - def test_scenario_state_graph_add_new_node(self): - info = TraceInfo() - stg = ScenarioStateGraph(info) - - self.assertEqual(len(stg.networkx.nodes), 1) - - self.assertIn('start', stg.networkx.nodes) - self.assertNotIn('test', stg.networkx.nodes) - - scenario = ScenarioInfo('test') - state = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - - stg.ids['test'] = (scenario, state) - stg._add_node('test') - - self.assertEqual(len(stg.networkx.nodes), 2) - - self.assertIn('start', stg.networkx.nodes) - self.assertIn('test', stg.networkx.nodes) - - self.assertEqual(stg.networkx.nodes['start']['label'], 'start') - self.assertIn('test', stg.networkx.nodes['test']['label']) - self.assertIn('prop:', stg.networkx.nodes['test']['label']) - self.assertIn('value=some_value', stg.networkx.nodes['test']['label']) - - def test_scenario_state_graph_add_existing_node(self): - info = TraceInfo() - stg = ScenarioStateGraph(info) - - self.assertEqual(len(stg.networkx.nodes), 1) - self.assertIn('start', stg.networkx.nodes) - self.assertEqual(stg.networkx.nodes['start']['label'], 'start') - - stg._add_node('start') - - self.assertEqual(len(stg.networkx.nodes), 1) - self.assertIn('start', stg.networkx.nodes) - self.assertEqual(stg.networkx.nodes['start']['label'], 'start') - - def test_scenario_state_graph_update_single(self): - info = TraceInfo() - - scenario = ScenarioInfo('1') - - space = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - - info.update_trace(scenario, space, 1) - - stg = ScenarioStateGraph(info) - - self.assertEqual(len(stg.networkx.nodes), 2) - self.assertEqual(len(stg.networkx.edges), 1) - - self.assertEqual(stg.networkx.nodes['start']['label'], 'start') - - self.assertIn('1', stg.networkx.nodes['node0']['label']) - self.assertIn('prop:', stg.networkx.nodes['node0']['label']) - self.assertIn('value=some_value', stg.networkx.nodes['node0']['label']) - - self.assertIn(('start', 'node0'), stg.networkx.edges) - self.assertEqual(stg.networkx.edges[('start', 'node0')]['label'], '') - - def test_scenario_state_graph_update_multi_loop(self): - info = TraceInfo() - - scenario1 = ScenarioInfo('1') - scenario2 = ScenarioInfo('2') - - space1 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - space2 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) - - info.update_trace(scenario1, space1, 1) - info.update_trace(scenario2, space2, 2) - info.update_trace(scenario1, space1, 3) - - stg = ScenarioStateGraph(info) - - self.assertEqual(len(stg.networkx.nodes), 3) - self.assertEqual(len(stg.networkx.edges), 3) - - self.assertEqual(stg.networkx.nodes['start']['label'], 'start') - - self.assertIn('1', stg.networkx.nodes['node0']['label']) - self.assertIn('2', stg.networkx.nodes['node1']['label']) - self.assertIn('prop:', stg.networkx.nodes['node0']['label']) - self.assertIn('prop:', stg.networkx.nodes['node1']['label']) - self.assertIn('value=some_value', stg.networkx.nodes['node0']['label']) - self.assertIn('value=another_value', stg.networkx.nodes['node1']['label']) - - self.assertIn(('start', 'node0'), stg.networkx.edges) - self.assertIn(('node0', 'node1'), stg.networkx.edges) - self.assertIn(('node1', 'node0'), stg.networkx.edges) - - self.assertEqual(stg.networkx.edges[('start', 'node0')]['label'], '') - self.assertEqual(stg.networkx.edges[('node0', 'node1')]['label'], '') - self.assertEqual(stg.networkx.edges[('node1', 'node0')]['label'], '') - - def test_scenario_state_graph_update_self_loop(self): - info = TraceInfo() - - scenario = ScenarioInfo('1') - - space = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - - info.update_trace(scenario, space, 1) - info.update_trace(scenario, space, 2) - - stg = ScenarioStateGraph(info) - - self.assertEqual(len(stg.networkx.nodes), 2) - self.assertEqual(len(stg.networkx.edges), 2) - - self.assertEqual(stg.networkx.nodes['start']['label'], 'start') - - self.assertIn('1', stg.networkx.nodes['node0']['label']) - self.assertIn('prop:', stg.networkx.nodes['node0']['label']) - self.assertIn('value=some_value', stg.networkx.nodes['node0']['label']) - - self.assertIn(('start', 'node0'), stg.networkx.edges) - self.assertIn(('node0', 'node0'), stg.networkx.edges) - - self.assertEqual(stg.networkx.edges[('start', 'node0')]['label'], '') - self.assertEqual(stg.networkx.edges[('node0', 'node0')]['label'], '') - - def test_scenario_state_graph_update_backtrack(self): - info = TraceInfo() - - scenario0 = ScenarioInfo('0') - scenario1 = ScenarioInfo('1') - scenario2 = ScenarioInfo('2') - - space0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - space1 = StateInfo._create_state_with_prop("prop", [("value", "other_value")]) - space2 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) - space3 = StateInfo._create_state_with_prop("prop", [("value", "more_value")]) - space4 = StateInfo._create_state_with_prop("prop", [("value", "yet_another_value")]) - - info.update_trace(scenario0, space0, 1) - info.update_trace(scenario1, space1, 2) - info.update_trace(scenario2, space2, 3) - info.update_trace(scenario1, space1, 2) - info.update_trace(scenario0, space0, 1) - info.update_trace(scenario2, space3, 2) - info.update_trace(scenario1, space4, 3) - - stg = ScenarioStateGraph(info) - - self.assertEqual(len(stg.networkx.nodes), 6) - self.assertEqual(len(stg.networkx.edges), 5) - - def test_scenario_state_graph_final_trace_normal(self): - info = TraceInfo() - - scenario0 = ScenarioInfo('0') - scenario1 = ScenarioInfo('1') - scenario2 = ScenarioInfo('2') - - space0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - space1 = StateInfo._create_state_with_prop("prop", [("value", "other_value")]) - space2 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) - - info.update_trace(scenario0, space0, 1) - info.update_trace(scenario1, space1, 2) - info.update_trace(scenario2, space2, 3) - - stg = ScenarioStateGraph(info) - - trace = stg.get_final_trace() - - for node in trace: - self.assertIn(node, stg.networkx.nodes) - - for i in range(0, len(trace) - 1): - self.assertIn((trace[i], trace[i + 1]), stg.networkx.edges) - - self.assertEqual(trace, ['start', 'node0', 'node1', 'node2']) - - def test_scenario_state_graph_final_trace_backtrack(self): - info = TraceInfo() - - scenario0 = ScenarioInfo('0') - scenario1 = ScenarioInfo('1') - scenario2 = ScenarioInfo('2') - - space0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - space1 = StateInfo._create_state_with_prop("prop", [("value", "other_value")]) - space2 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) - space3 = StateInfo._create_state_with_prop("prop", [("value", "more_value")]) - space4 = StateInfo._create_state_with_prop("prop", [("value", "yet_another_value")]) - - info.update_trace(scenario0, space0, 1) - info.update_trace(scenario1, space1, 2) - info.update_trace(scenario2, space2, 3) - info.update_trace(scenario1, space1, 2) - info.update_trace(scenario0, space0, 1) - info.update_trace(scenario2, space3, 2) - info.update_trace(scenario1, space4, 3) - - stg = ScenarioStateGraph(info) - - trace = stg.get_final_trace() - - for node in trace: - self.assertIn(node, stg.networkx.nodes) - - for i in range(0, len(trace) - 1): - self.assertIn((trace[i], trace[i + 1]), stg.networkx.edges) - - self.assertEqual(trace, ['start', 'node0', 'node3', 'node4']) - -if __name__ == '__main__': - unittest.main() diff --git a/utest/test_visualise_stategraph.py b/utest/test_visualise_stategraph.py deleted file mode 100644 index 4bfdbe1e..00000000 --- a/utest/test_visualise_stategraph.py +++ /dev/null @@ -1,318 +0,0 @@ -import unittest - -try: - from robotmbt.visualise.graphs.stategraph import StateGraph - from robotmbt.visualise.models import * - - VISUALISE = True -except ImportError: - VISUALISE = False - -if VISUALISE: - class TestVisualiseStateGraph(unittest.TestCase): - def test_state_graph_init(self): - info = TraceInfo() - sg = StateGraph(info) - - self.assertEqual(len(sg.networkx.nodes), 1) - self.assertEqual(len(sg.networkx.edges), 0) - - self.assertIn('start', sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes['start']['label'], 'start') - - def test_state_graph_ids_empty(self): - info = TraceInfo() - sg = StateGraph(info) - - si = StateInfo(ModelSpace()) - - node_id = sg._get_or_create_id(si) - - self.assertEqual(node_id, 'node0') - - def test_state_graph_ids_duplicate_state(self): - info = TraceInfo() - sg = StateGraph(info) - - s0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - s1 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - - id0 = sg._get_or_create_id(s0) - id1 = sg._get_or_create_id(s1) - - self.assertEqual(id0, id1) - - def test_state_graph_ids_different_states(self): - info = TraceInfo() - sg = StateGraph(info) - - s00 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - s01 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - s10 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) - s11 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) - - id00 = sg._get_or_create_id(s00) - id01 = sg._get_or_create_id(s01) - id10 = sg._get_or_create_id(s10) - id11 = sg._get_or_create_id(s11) - - self.assertEqual(id00, 'node0') - self.assertEqual(id01, 'node0') - self.assertEqual(id00, id01) - - self.assertEqual(id10, 'node1') - self.assertEqual(id11, 'node1') - self.assertEqual(id10, id11) - - self.assertNotEqual(id00, id10) - self.assertNotEqual(id00, id11) - self.assertNotEqual(id01, id10) - self.assertNotEqual(id01, id11) - - def test_state_graph_add_new_node(self): - info = TraceInfo() - sg = StateGraph(info) - - self.assertEqual(len(sg.networkx.nodes), 1) - - self.assertIn('start', sg.networkx.nodes) - self.assertNotIn('test', sg.networkx.nodes) - - sg.ids['test'] = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - sg._add_node('test') - - self.assertEqual(len(sg.networkx.nodes), 2) - - self.assertIn('start', sg.networkx.nodes) - self.assertIn('test', sg.networkx.nodes) - - self.assertEqual(sg.networkx.nodes['start']['label'], 'start') - self.assertIn('prop:', sg.networkx.nodes['test']['label']) - self.assertIn('value=some_value', sg.networkx.nodes['test']['label']) - - def test_state_graph_add_existing_node(self): - info = TraceInfo() - sg = StateGraph(info) - - self.assertEqual(len(sg.networkx.nodes), 1) - self.assertIn('start', sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes['start']['label'], 'start') - - sg._add_node('start') - - self.assertEqual(len(sg.networkx.nodes), 1) - self.assertIn('start', sg.networkx.nodes) - self.assertEqual(sg.networkx.nodes['start']['label'], 'start') - - def test_state_graph_update_single(self): - info = TraceInfo() - - scenario = ScenarioInfo('1') - space = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - info.update_trace(scenario, space, 1) - - sg = StateGraph(info) - - self.assertEqual(sg.networkx.nodes['start']['label'], 'start') - - self.assertIn('prop:', sg.networkx.nodes['node0']['label']) - self.assertIn('value=some_value', sg.networkx.nodes['node0']['label']) - - self.assertIn(('start', 'node0'), sg.networkx.edges) - self.assertEqual(sg.networkx.edges[('start', 'node0')]['label'], '1') - - def test_state_graph_update_multi(self): - info = TraceInfo() - - scenario1 = ScenarioInfo('1') - scenario2 = ScenarioInfo('2') - scenario3 = ScenarioInfo('3') - - space1 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - space2 = StateInfo._create_state_with_prop("prop", [("value", "other_value")]) - space3 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) - - info.update_trace(scenario1, space1, 1) - info.update_trace(scenario2, space2, 2) - info.update_trace(scenario3, space3, 3) - - sg = StateGraph(info) - - self.assertEqual(len(sg.networkx.nodes), 4) - self.assertEqual(len(sg.networkx.edges), 3) - - self.assertEqual(sg.networkx.nodes['start']['label'], 'start') - self.assertIn('prop:', sg.networkx.nodes['node0']['label']) - self.assertIn('prop:', sg.networkx.nodes['node1']['label']) - self.assertIn('prop:', sg.networkx.nodes['node2']['label']) - self.assertIn('value=some_value', sg.networkx.nodes['node0']['label']) - self.assertIn('value=other_value', sg.networkx.nodes['node1']['label']) - self.assertIn('value=another_value', sg.networkx.nodes['node2']['label']) - - self.assertIn(('start', 'node0'), sg.networkx.edges) - self.assertIn(('node0', 'node1'), sg.networkx.edges) - self.assertIn(('node1', 'node2'), sg.networkx.edges) - - self.assertEqual(sg.networkx.edges[('start', 'node0')]['label'], '1') - self.assertEqual(sg.networkx.edges[('node0', 'node1')]['label'], '2') - self.assertEqual(sg.networkx.edges[('node1', 'node2')]['label'], '3') - - def test_state_graph_update_multi_loop(self): - info = TraceInfo() - - scenario0 = ScenarioInfo('0') - scenario1 = ScenarioInfo('1') - scenario2 = ScenarioInfo('2') - - space0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - space1 = StateInfo._create_state_with_prop("prop", [("value", "other_value")]) - - info.update_trace(scenario0, space0, 1) - info.update_trace(scenario1, space1, 2) - info.update_trace(scenario2, space0, 3) - - sg = StateGraph(info) - - self.assertEqual(len(sg.networkx.nodes), 3) - self.assertEqual(len(sg.networkx.edges), 3) - - self.assertEqual(sg.networkx.nodes['start']['label'], 'start') - self.assertIn('prop:', sg.networkx.nodes['node0']['label']) - self.assertIn('prop:', sg.networkx.nodes['node1']['label']) - self.assertIn('value=some_value', sg.networkx.nodes['node0']['label']) - self.assertIn('value=other_value', sg.networkx.nodes['node1']['label']) - - self.assertIn(('start', 'node0'), sg.networkx.edges) - self.assertIn(('node0', 'node1'), sg.networkx.edges) - self.assertIn(('node1', 'node0'), sg.networkx.edges) - - self.assertEqual(sg.networkx.edges[('start', 'node0')]['label'], '0') - self.assertEqual(sg.networkx.edges[('node0', 'node1')]['label'], '1') - self.assertEqual(sg.networkx.edges[('node1', 'node0')]['label'], '2') - - def test_state_graph_update_self_loop(self): - info = TraceInfo() - - scenario1 = ScenarioInfo('1') - scenario2 = ScenarioInfo('2') - - space = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - - info.update_trace(scenario1, space, 1) - info.update_trace(scenario2, space, 2) - - sg = StateGraph(info) - - self.assertEqual(len(sg.networkx.nodes), 2) - self.assertEqual(len(sg.networkx.edges), 2) - - self.assertEqual(sg.networkx.nodes['start']['label'], 'start') - self.assertIn('prop:', sg.networkx.nodes['node0']['label']) - self.assertIn('value=some_value', sg.networkx.nodes['node0']['label']) - - self.assertIn(('start', 'node0'), sg.networkx.edges) - self.assertIn(('node0', 'node0'), sg.networkx.edges) - - self.assertEqual(sg.networkx.edges[('start', 'node0')]['label'], '1') - self.assertEqual(sg.networkx.edges[('node0', 'node0')]['label'], '2') - - def test_state_graph_update_backtrack(self): - info = TraceInfo() - - scenario0 = ScenarioInfo('0') - scenario1 = ScenarioInfo('1') - scenario2 = ScenarioInfo('2') - - space0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - space1 = StateInfo._create_state_with_prop("prop", [("value", "other_value")]) - space2 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) - space3 = StateInfo._create_state_with_prop("prop", [("value", "more_value")]) - space4 = StateInfo._create_state_with_prop("prop", [("value", "yet_another_value")]) - - info.update_trace(scenario0, space0, 1) - info.update_trace(scenario1, space1, 2) - info.update_trace(scenario2, space2, 3) - info.update_trace(scenario1, space1, 2) - info.update_trace(scenario0, space0, 1) - info.update_trace(scenario2, space3, 2) - info.update_trace(scenario1, space4, 3) - - sg = StateGraph(info) - - self.assertEqual(len(sg.networkx.nodes), 6) - self.assertEqual(len(sg.networkx.edges), 5) - - def test_state_graph_final_trace_normal(self): - info = TraceInfo() - - scenario0 = ScenarioInfo('0') - scenario1 = ScenarioInfo('1') - scenario2 = ScenarioInfo('2') - - space0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - space1 = StateInfo._create_state_with_prop("prop", [("value", "other_value")]) - space2 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) - - info.update_trace(scenario0, space0, 1) - info.update_trace(scenario1, space1, 2) - info.update_trace(scenario2, space2, 3) - - sg = StateGraph(info) - trace = sg.get_final_trace() - - for node in trace: - self.assertIn(node, sg.networkx.nodes) - - for i in range(0, len(trace) - 1): - self.assertIn((trace[i], trace[i + 1]), sg.networkx.edges) - if i > 0: - self.assertEqual(sg.networkx.edges[(trace[i], trace[i + 1])]['label'], str(i)) - - self.assertEqual(sg.networkx.nodes[trace[0]]['label'], 'start') - self.assertIn('prop:', sg.networkx.nodes[trace[1]]['label']) - self.assertIn('value=some_value', sg.networkx.nodes[trace[1]]['label']) - self.assertIn('prop:', sg.networkx.nodes[trace[2]]['label']) - self.assertIn('value=other_value', sg.networkx.nodes[trace[2]]['label']) - self.assertIn('prop:', sg.networkx.nodes[trace[3]]['label']) - self.assertIn('value=another_value', sg.networkx.nodes[trace[3]]['label']) - - def test_state_graph_final_trace_backtrack(self): - info = TraceInfo() - - scenario0 = ScenarioInfo('0') - scenario1 = ScenarioInfo('1') - scenario2 = ScenarioInfo('2') - - space0 = StateInfo._create_state_with_prop("prop", [("value", "some_value")]) - space1 = StateInfo._create_state_with_prop("prop", [("value", "other_value")]) - space2 = StateInfo._create_state_with_prop("prop", [("value", "another_value")]) - space3 = StateInfo._create_state_with_prop("prop", [("value", "more_value")]) - space4 = StateInfo._create_state_with_prop("prop", [("value", "yet_another_value")]) - - info.update_trace(scenario0, space0, 1) - info.update_trace(scenario1, space1, 2) - info.update_trace(scenario2, space2, 3) - info.update_trace(scenario1, space1, 2) - info.update_trace(scenario0, space0, 1) - info.update_trace(scenario2, space3, 2) - info.update_trace(scenario1, space4, 3) - - sg = StateGraph(info) - trace = sg.get_final_trace() - - for node in trace: - self.assertIn(node, sg.networkx.nodes) - - for i in range(0, len(trace) - 1): - self.assertIn((trace[i], trace[i + 1]), sg.networkx.edges) - - self.assertEqual(sg.networkx.nodes[trace[0]]['label'], 'start') - self.assertIn('prop:', sg.networkx.nodes[trace[1]]['label']) - self.assertIn('value=some_value', sg.networkx.nodes[trace[1]]['label']) - self.assertIn('prop:', sg.networkx.nodes[trace[2]]['label']) - self.assertIn('value=more_value', sg.networkx.nodes[trace[2]]['label']) - self.assertIn('prop:', sg.networkx.nodes[trace[3]]['label']) - self.assertIn('value=yet_another_value', sg.networkx.nodes[trace[3]]['label']) - -if __name__ == '__main__': - unittest.main() From f00b564dbd494091220558facd117d43cf3daa6a Mon Sep 17 00:00:00 2001 From: Jonathan <61787386+JWillegers@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:05:31 +0100 Subject: [PATCH 044/131] Zoombox (#59) * add boxzoomtool; wheelzoomtool active by default --- robotmbt/visualise/networkvisualiser.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/robotmbt/visualise/networkvisualiser.py b/robotmbt/visualise/networkvisualiser.py index 715ff3cc..12af0791 100644 --- a/robotmbt/visualise/networkvisualiser.py +++ b/robotmbt/visualise/networkvisualiser.py @@ -2,7 +2,7 @@ from bokeh.core.property.vectorization import value from bokeh.embed import file_html from bokeh.models import ColumnDataSource, Rect, Text, ResetTool, SaveTool, WheelZoomTool, PanTool, Plot, Range1d, \ - Title, FullscreenTool, CustomJS, Segment, Arrow, NormalHead, Bezier, Legend + Title, FullscreenTool, CustomJS, Segment, Arrow, NormalHead, Bezier, Legend, BoxZoomTool from grandalf.graphs import Vertex as GVertex, Edge as GEdge, Graph as GGraph from grandalf.layouts import SugiyamaLayout @@ -240,9 +240,11 @@ def _add_features(self, suite_name: str, seed: str): self.plot.add_layout(Title(text=suite_name, align="center"), "above") # Add the different tools + wheel_zoom = WheelZoomTool() self.plot.add_tools(ResetTool(), SaveTool(), - WheelZoomTool(), PanTool(), - FullscreenTool()) + wheel_zoom, PanTool(), + FullscreenTool(), BoxZoomTool()) + self.plot.toolbar.active_scroll = wheel_zoom # Specify the default range - these values represent the aspect ratio of the actual view in the window self.plot.x_range = Range1d(-INNER_WINDOW_WIDTH / 2, INNER_WINDOW_WIDTH / 2) From f8f8c9250df1c076f73270434160fd1999458a40 Mon Sep 17 00:00:00 2001 From: Jonathan <61787386+JWillegers@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:07:03 +0100 Subject: [PATCH 045/131] Atest/remaining (#58) * test cases scenario graph * renaming files to be consistent with other atests * repetition tests --- atest/resources/visualisation.resource | 53 ++++++++++++------- .../{0__setup.robot => 01__setup.robot} | 0 .../02__scenario.robot | 26 +++++++++ ...e.robot => 03__scenario-delta-value.robot} | 0 .../01_repetition.robot | 28 ++++++++++ .../01__export to JSON.robot | 2 +- 6 files changed, 88 insertions(+), 21 deletions(-) rename atest/robotMBT tests/10__visualisation_representations/{0__setup.robot => 01__setup.robot} (100%) create mode 100644 atest/robotMBT tests/10__visualisation_representations/02__scenario.robot rename atest/robotMBT tests/10__visualisation_representations/{1__scenario-delta-value.robot => 03__scenario-delta-value.robot} (100%) create mode 100644 atest/robotMBT tests/11__visualisation_execution_path/01_repetition.robot diff --git a/atest/resources/visualisation.resource b/atest/resources/visualisation.resource index a20f2315..b9985bb2 100644 --- a/atest/resources/visualisation.resource +++ b/atest/resources/visualisation.resource @@ -7,7 +7,8 @@ Library Collections *** Keywords *** test suite ${suite} has trace info ${trace} [Documentation] *model info* - ... :IN: new trace_info | trace_info.current_trace=0 | trace_info.all_traces=0 | trace_info.name = ${trace} + ... :IN: new trace_info | trace_info.current_trace=0 | trace_info.all_traces=0 | + ... trace_info.name = ${trace} | new graph ... :OUT: None ${trace_info} = Generate Trace Information Set Suite Variable ${trace_info} @@ -40,38 +41,50 @@ test suite ${suite} has ${n} total traces ${graph_type} graph ${name} is generated [Documentation] *model info* ... :IN: trace_info - ... :OUT: graph.name=${name}, graph.type=${graph_type} + ... :OUT: graph.name=${name} | graph.type=${graph_type} Variable Should Exist ${trace_info} ${graph} = Generate Graph ${trace_info} ${graph_type} ${network_visualiser} = Generate Network Graph ${graph} Set Suite Variable ${graph} - Set Suite Variable ${network_visualiser} + Set Suite Variable ${network_visualiser} graph ${name} contains vertex '${scenario}' [Documentation] *model info* - ... :IN: graph.name=${name} - ... :OUT: graph.name=${name} + ... :IN: graph.name==${name} + ... :OUT: graph.name==${name} Variable Should Exist ${graph} - ${result} = Graph Contains Vertex With No Text ${graph} ${scenario} + ${id} = Get NodeID ${graph} ${scenario} + ${result} = Graph Contains Vertex With No Text ${graph} ${id} Run Keyword If ${result} == False ... Fail Fail: Graph does not contain '${scenario}' graph ${name} contains vertex '${scenario}' with text "${text}" [Documentation] *model info* - ... :IN: graph.name=${name} - ... :OUT: graph.name=${name} + ... :IN: graph.name==${name} + ... :OUT: graph.name==${name} Variable Should Exist ${graph} ${result} = Graph Contains Vertex With Text ${graph} ${scenario} ${text} Run Keyword If "${result}" == "None" ... Fail Fail: Graph does not contain '${scenario}' with "${text}" +graph ${name} does not contain vertex '${scenario}' + [Documentation] *model info* + ... :IN: graph.name==${name} + ... :OUT: graph.name==${name} + Variable Should Exist ${graph} + + ${id} = Get NodeID ${graph} ${scenario} + ${result} = Graph Contains Vertex With No Text ${graph} ${id} + Run Keyword If "${result}" != "None" + ... Fail Fail: Graph contains '${scenario}'" + graph ${name} does not contain vertex '${scenario}' with text "${text}" [Documentation] *model info* - ... :IN: graph.name=${name} - ... :OUT: graph.name=${name} + ... :IN: graph.name==${name} + ... :OUT: graph.name==${name} Variable Should Exist ${graph} ${result} = Graph Contains Vertex With Text ${graph} ${scenario} ${text} @@ -79,9 +92,9 @@ graph ${name} does not contain vertex '${scenario}' with text "${text}" ... Fail Fail: Graph contains '${scenario}' with "${text}" graph ${name} has an edge from '${start_title}' to '${end_title}' - [Documentation] *model_info* - ... :IN: graph.name=${name} - ... :OUT: graph.name=${name} + [Documentation] *model info* + ... :IN: graph.name==${name} + ... :OUT: graph.name==${name} Variable Should Exist ${graph} ${start_node} = Graph Contains Vertex With Text ${graph} ${start_title} ${end_node} = Graph Contains Vertex With Text ${graph} ${end_title} @@ -94,9 +107,9 @@ graph ${name} has an edge from '${start_title}' to '${end_title}' ... Fail Fail: Edges '${start_title}' and '${end_title}' exist in graph but are not connected! graph ${name} does not have an edge from '${start_title}' to '${end_title}' - [Documentation] *model_info* - ... :IN: graph.name=${name} - ... :OUT: graph.name=${name} + [Documentation] *model info* + ... :IN: graph.name==${name} + ... :OUT: graph.name==${name} Variable Should Exist ${graph} ${start_node} = Graph Contains Vertex With Text ${graph} ${start_title} ${end_node} = Graph Contains Vertex With Text ${graph} ${end_title} @@ -106,16 +119,16 @@ graph ${name} does not have an edge from '${start_title}' to '${end_title}' ... Fail Fail: Edges '${start_title}' and '${end_title}' exist in graph and are connected, but shouldn't be! graph ${name} has vertices ${vertices_string} - [Documentation] *model_info* - ... :IN: graph.name=${name} - ... :OUT: graph.name=${name} + [Documentation] *model info* + ... :IN: graph.name==${name} + ... :OUT: graph.name==${name} Variable Should Exist ${graph} ${result} = Graph Contains Vertices Starting With ${graph} ${vertices_string} Run Keyword If ${result} != True ... Fail Graph did not contain all vertices ${vertices_string} vertex '${start_vertex}' is placed above '${end_vertex}' - [Documentation] *model_info* + [Documentation] *model info* ... :IN: None ... :OUT: None Variable Should Exist ${graph} diff --git a/atest/robotMBT tests/10__visualisation_representations/0__setup.robot b/atest/robotMBT tests/10__visualisation_representations/01__setup.robot similarity index 100% rename from atest/robotMBT tests/10__visualisation_representations/0__setup.robot rename to atest/robotMBT tests/10__visualisation_representations/01__setup.robot diff --git a/atest/robotMBT tests/10__visualisation_representations/02__scenario.robot b/atest/robotMBT tests/10__visualisation_representations/02__scenario.robot new file mode 100644 index 00000000..34d159e5 --- /dev/null +++ b/atest/robotMBT tests/10__visualisation_representations/02__scenario.robot @@ -0,0 +1,26 @@ +*** Settings *** +Library robotmbt processor=flatten + +*** Test Cases *** +Vertex Scenario graph + Given trace info t + When scenario graph g is generated + Then graph g contains vertex 'start' + And graph g contains vertex 'A1' + And graph g contains vertex 'A2' + +Edge Scenario graph + Given trace info t + When scenario graph g is generated + Then graph g has an edge from 'start' to 'A1' + And graph g has an edge from 'A1' to 'A2' + And graph g does not have an edge from 'start' to 'A2' + And graph g does not have an edge from 'A2' to 'A1' + And graph g does not have an edge from 'A2' to 'start' + +Visual location of vertices scenario + Given trace info t + When scenario graph g is generated + Then graph g has vertices 'start', 'A1', 'A2' + And vertex 'start' is placed above 'A1' + And vertex 'A1' is placed above 'A2' diff --git a/atest/robotMBT tests/10__visualisation_representations/1__scenario-delta-value.robot b/atest/robotMBT tests/10__visualisation_representations/03__scenario-delta-value.robot similarity index 100% rename from atest/robotMBT tests/10__visualisation_representations/1__scenario-delta-value.robot rename to atest/robotMBT tests/10__visualisation_representations/03__scenario-delta-value.robot diff --git a/atest/robotMBT tests/11__visualisation_execution_path/01_repetition.robot b/atest/robotMBT tests/11__visualisation_execution_path/01_repetition.robot new file mode 100644 index 00000000..0baee96b --- /dev/null +++ b/atest/robotMBT tests/11__visualisation_execution_path/01_repetition.robot @@ -0,0 +1,28 @@ +*** Settings *** +Documentation +Suite Setup Treat this test suite Model-based +Resource ../../resources/visualisation.resource +Library robotmbt + +*** Test Cases *** +Setup + Given test suite s has trace info t + Then the algorithm inserts 'A1' with state "attr: states=['a1'],special='!'" + And the algorithm inserts 'A1' with state "attr: states=['a1'],special='!'" + And the algorithm inserts 'B1' with state "attr: states=['a1','b1'], special='!'" + And the algorithm inserts 'B2' with state "attr: states=['a1','b1','b2'], special='!'" + And the algorithm inserts 'B1' with state "attr: states=['a1','b1','b2'], special='!'" + +Self-loop + Given trace info t + When scenario graph g is generated + Then graph g contains vertex 'A1' + And graph g has an edge from 'A1' to 'A1' + +Two-vertex loop + Given trace info t + When scenario graph g is generated + Then graph g contains vertex 'B1' + And graph g contains vertex 'B2' + And graph g has an edge from 'B1' to 'B2' + And graph g has an edge from 'B2' to 'B1' \ No newline at end of file diff --git a/atest/robotMBT tests/12__visualisation_export/01__export to JSON.robot b/atest/robotMBT tests/12__visualisation_export/01__export to JSON.robot index fd5157bb..ad70635e 100644 --- a/atest/robotMBT tests/12__visualisation_export/01__export to JSON.robot +++ b/atest/robotMBT tests/12__visualisation_export/01__export to JSON.robot @@ -2,7 +2,7 @@ Documentation Export and import a test suite from and to JSON ... and check that the imported suite equals the ... exported suite. -Suite Setup Treat this test suite Model-based graph=scenario-state +Suite Setup Treat this test suite Model-based Resource ../../resources/visualisation.resource Library robotmbt From f4e8628841f3d3b09c65da492b1791e242ba07c1 Mon Sep 17 00:00:00 2001 From: Thomas Kas Date: Tue, 20 Jan 2026 15:04:16 +0100 Subject: [PATCH 046/131] Remove seed from graph title (#60) --- atest/resources/helpers/modelgenerator.py | 2 +- robotmbt/suiteprocessors.py | 11 ++++------- robotmbt/visualise/networkvisualiser.py | 12 +++++------- robotmbt/visualise/visualiser.py | 7 +++---- 4 files changed, 13 insertions(+), 19 deletions(-) diff --git a/atest/resources/helpers/modelgenerator.py b/atest/resources/helpers/modelgenerator.py index e18deed1..6741ca58 100644 --- a/atest/resources/helpers/modelgenerator.py +++ b/atest/resources/helpers/modelgenerator.py @@ -49,7 +49,7 @@ def generate_graph(self, trace_info: TraceInfo, graph_type: str) -> AbstractGrap @keyword(name="Generate Network Graph") def generate_networkgraph(self, graph: AbstractGraph) -> NetworkVisualiser: - return NetworkVisualiser(graph=graph, suite_name="", seed="") + return NetworkVisualiser(graph=graph, suite_name="") @keyword(name='Export Graph') # type:ignore def export_graph(self, suite: str, trace_info: TraceInfo) -> str: diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index d97e7d37..1edaec39 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -117,14 +117,14 @@ def _run_test_suite(self, seed: Any, graph: str, suite_name: str, to_json: bool) logger.debug("Use these numbers to reference scenarios from traces\n\t" + "\n\t".join([f"{s.src_id}: {s.name}" for s in self.scenarios])) - init_seed = self._init_randomiser(seed) + self._init_randomiser(seed) self.shuffled = [s.src_id for s in self.scenarios] random.shuffle(self.shuffled) # Keep a single shuffle for all TraceStates (non-essential) self.visualiser = None if graph != '' and VISUALISE: try: - self.visualiser = Visualiser(graph, suite_name, init_seed, to_json) + self.visualiser = Visualiser(graph, suite_name, to_json) except Exception as e: self.visualiser = None logger.warn(f'Could not initialise visualiser due to error!\n{e}') @@ -245,23 +245,20 @@ def _report_tracestate_wrapup(tracestate: TraceState): logger.debug(f"model\n{progression.model.get_status_text()}\n") @staticmethod - def _init_randomiser(seed: Any) -> str: + def _init_randomiser(seed: Any): if isinstance(seed, str): seed = seed.strip() if str(seed).lower() == 'none': logger.info( - f"Using system's random seed for trace generation. This trace cannot be rerun. Use `seed=new` to generate a reusable seed.") - return "" + "Using system's random seed for trace generation. This trace cannot be rerun. Use `seed=new` to generate a reusable seed.") elif str(seed).lower() == 'new': new_seed = SuiteProcessors._generate_seed() logger.info(f"seed={new_seed} (use seed to rerun this trace)") random.seed(new_seed) - return new_seed else: logger.info(f"seed={seed} (as provided)") random.seed(seed) - return seed @staticmethod def _generate_seed() -> str: diff --git a/robotmbt/visualise/networkvisualiser.py b/robotmbt/visualise/networkvisualiser.py index 12af0791..fd48670f 100644 --- a/robotmbt/visualise/networkvisualiser.py +++ b/robotmbt/visualise/networkvisualiser.py @@ -69,7 +69,7 @@ class NetworkVisualiser: A container for a Bokeh graph, which can be created from any abstract graph. """ - def __init__(self, graph: AbstractGraph, suite_name: str, seed: str): + def __init__(self, graph: AbstractGraph, suite_name: str): # Extract what we need from the graph self.networkx: DiGraph = graph.networkx self.final_trace = graph.get_final_trace() @@ -98,7 +98,7 @@ def __init__(self, graph: AbstractGraph, suite_name: str, seed: str): self._add_legend(graph) # Add our features to the graph (e.g. tools) - self._add_features(suite_name, seed) + self._add_features(suite_name) def generate_html(self): """ @@ -231,12 +231,10 @@ def _create_layout(self) -> tuple[list[Node], list[Edge]]: return ns, es - def _add_features(self, suite_name: str, seed: str): + def _add_features(self, suite_name: str): """ Add our features to the graph such as tools, titles, and JavaScript callbacks. """ - if seed != "": - self.plot.add_layout(Title(text="seed=" + seed, align="center", text_color="#999999"), "above") self.plot.add_layout(Title(text=suite_name, align="center"), "above") # Add the different tools @@ -248,9 +246,9 @@ def _add_features(self, suite_name: str, seed: str): # Specify the default range - these values represent the aspect ratio of the actual view in the window self.plot.x_range = Range1d(-INNER_WINDOW_WIDTH / 2, INNER_WINDOW_WIDTH / 2) - self.plot.y_range = Range1d(-INNER_WINDOW_HEIGHT + (0 if seed == '' else 20), 0) + self.plot.y_range = Range1d(-INNER_WINDOW_HEIGHT, 0) self.plot.x_range.tags = [{"initial_span": INNER_WINDOW_WIDTH}] - self.plot.y_range.tags = [{"initial_span": INNER_WINDOW_HEIGHT - (0 if seed == '' else 20)}] + self.plot.y_range.tags = [{"initial_span": INNER_WINDOW_HEIGHT}] # A JS callback to scale text and arrows, and change aspect ratio. resize_cb = CustomJS(args=dict(xr=self.plot.x_range, yr=self.plot.y_range, plot=self.plot, arrows=self.arrows), diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py index 757929b4..9069c370 100644 --- a/robotmbt/visualise/visualiser.py +++ b/robotmbt/visualise/visualiser.py @@ -25,7 +25,7 @@ def construct(cls, graph_type: str): # just calls __init__, but without having underscores etc. return cls(graph_type) - def __init__(self, graph_type: str, suite_name: str = "", seed: str = "", export: bool = False, + def __init__(self, graph_type: str, suite_name: str = "", export: bool = False, trace_info: TraceInfo = None): if graph_type != 'scenario' and graph_type != 'state' and graph_type != 'scenario-state' \ and graph_type != 'scenario-delta-value' and graph_type != 'reduced-sdv' \ @@ -33,13 +33,12 @@ def __init__(self, graph_type: str, suite_name: str = "", seed: str = "", export raise ValueError(f"Unknown graph type: {graph_type}!") self.graph_type: str = graph_type - if trace_info == None: + if trace_info is None: self.trace_info: TraceInfo = TraceInfo() else: self.trace_info = trace_info self.suite_name = suite_name self.export = export - self.seed = seed def update_trace(self, trace: TraceState): """ @@ -93,6 +92,6 @@ def generate_visualisation(self) -> str: graph: AbstractGraph = self._get_graph() - html_bokeh = networkvisualiser.NetworkVisualiser(graph, self.suite_name, self.seed).generate_html() + html_bokeh = networkvisualiser.NetworkVisualiser(graph, self.suite_name).generate_html() return f'' From 9795a8d3b0c793d969f72744cecd0e0066301e7c Mon Sep 17 00:00:00 2001 From: D Osinga <46380170+osingaatje@users.noreply.github.com> Date: Tue, 20 Jan 2026 16:28:43 +0100 Subject: [PATCH 047/131] Requirements checks for visualisation atests (#61) * added explicit requirements check for each visualisation atest * changed to SKIP instead of FAIL * fixed rebase error * added explicit run for missing requirements * CI/CD rename + fixes * various CI/CD / naming fixes * merge nodeps test run into original test run * refactor naming * fully spell out abbrevations + capitalise 'Check dependencies' * fixed import problems - type warnings remain but it looks less indented * renamed visualisation dependency flags to have less warnings with Pylance --- .github/workflows/autopep8.yml | 2 +- .github/workflows/run-tests.yml | 22 +++++++----- atest/resources/helpers/modelgenerator.py | 36 ++++++++++++++----- atest/resources/visualisation.resource | 8 +++++ .../__init__.robot | 10 ++++-- .../01_repetition.robot | 8 ++++- .../01__export to JSON.robot | 7 +++- robotmbt/suiteprocessors.py | 13 ++++--- 8 files changed, 78 insertions(+), 28 deletions(-) diff --git a/.github/workflows/autopep8.yml b/.github/workflows/autopep8.yml index 14a62fa9..51b0c7ca 100644 --- a/.github/workflows/autopep8.yml +++ b/.github/workflows/autopep8.yml @@ -1,4 +1,4 @@ -name: autopep8 Check +name: autopep8 check on: [pull_request] diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index f989c58b..82a6d088 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -1,7 +1,7 @@ # This workflow will install Python dependencies, run tests and lint with a single version of Python # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python -name: Run Acceptance and Unit tests +name: Run Acceptance and Unit tests (with and without visualisation dependencies) on: # push: @@ -23,12 +23,18 @@ jobs: uses: actions/setup-python@v3 with: python-version: "3.10" - - name: Install dependencies + + - name: Install dependencies (without extra visualisation dependencies) run: | - python -m pip install --upgrade pip # upgrade pip to latest version - pip install ".[visualization]" # install PyProject.toml dependencies (including optional dependencies) - - name: Test with pytest - run: | - python run_tests.py - #pytest # test unit tests only + python -m pip install --upgrade pip # upgrade pip to latest version + pip install . # no additional [visualization] dependencies + + - name: Run tests (without visualisation dependencies) + run: python run_tests.py + + - name: Install extra visualisation dependencies + run: pip install ".[visualization]" # extra [visualization] dependencies in pyproject.toml + + - name: Run Tests (with visualisation dependencies) + run: python run_tests.py diff --git a/atest/resources/helpers/modelgenerator.py b/atest/resources/helpers/modelgenerator.py index 6741ca58..c35fdacc 100644 --- a/atest/resources/helpers/modelgenerator.py +++ b/atest/resources/helpers/modelgenerator.py @@ -1,14 +1,34 @@ -import jsonpickle # type: ignore from robot.api.deco import keyword # type:ignore -from robotmbt.visualise.models import TraceInfo, ScenarioInfo, StateInfo -from robotmbt.visualise.visualiser import Visualiser -from robotmbt.visualise.graphs.abstractgraph import AbstractGraph import os -import networkx as nx -from robotmbt.visualise.networkvisualiser import NetworkVisualiser, Node + +visualisation_deps_present = True +try: + import jsonpickle # type: ignore + import networkx as nx + from robotmbt.visualise.models import TraceInfo, ScenarioInfo, StateInfo + from robotmbt.visualise.visualiser import Visualiser + from robotmbt.visualise.graphs.abstractgraph import AbstractGraph + from robotmbt.visualise.networkvisualiser import NetworkVisualiser, Node + +except ImportError: + visualisation_deps_present = False + + jsonpickle = None + nx = None + TraceInfo = None + ScenarioInfo = None + StateInfo = None + Visualiser = None + AbstractGraph = None + NetworkVisualiser = None + Node = None class ModelGenerator: + @keyword(name='Requirements Present') # type: ignore + def check_requirements(self) -> bool: + return visualisation_deps_present + @keyword(name='Generate Trace Information') # type:ignore def generate_trace_information(self) -> TraceInfo: return TraceInfo() @@ -112,9 +132,9 @@ def vertices_connected(self, graph: AbstractGraph, node_key1: str | None, node_k if node_key1 is None or node_key2 is None: return False return graph.networkx.has_edge(node_key1, node_key2) - + @keyword(name="Get NodeID") # type:ignore - def get_nodeid(self, graph: AbstractGraph, node_title:str) -> str | None: + def get_nodeid(self, graph: AbstractGraph, node_title: str) -> str | None: return self.graph_contains_vertex_with_text(graph, node_title, text=None) @keyword(name="Get Vertex Y Position") # type:ignore diff --git a/atest/resources/visualisation.resource b/atest/resources/visualisation.resource index b9985bb2..9a3c01fa 100644 --- a/atest/resources/visualisation.resource +++ b/atest/resources/visualisation.resource @@ -5,6 +5,14 @@ Library Collections *** Keywords *** +Check requirements + [Documentation] *model info* + ... :IN: None + ... :OUT: None + ${result} = Requirements Present + Run Keyword If ${result} == ${False} + ... Skip Visualisation requirements not installed. Please read the README for information on how to do this. + test suite ${suite} has trace info ${trace} [Documentation] *model info* ... :IN: new trace_info | trace_info.current_trace=0 | trace_info.all_traces=0 | diff --git a/atest/robotMBT tests/10__visualisation_representations/__init__.robot b/atest/robotMBT tests/10__visualisation_representations/__init__.robot index 05729ca8..79850615 100644 --- a/atest/robotMBT tests/10__visualisation_representations/__init__.robot +++ b/atest/robotMBT tests/10__visualisation_representations/__init__.robot @@ -1,5 +1,11 @@ *** Settings *** Documentation Test correctness all graph representations -Suite Setup Treat this test suite Model-based +Suite Setup Enter test suite Resource ../../resources/visualisation.resource -Library robotmbt processor=flatten \ No newline at end of file +Library robotmbt processor=flatten + + +*** Keywords *** +Enter test suite + Check requirements + Treat this test suite Model-based \ No newline at end of file diff --git a/atest/robotMBT tests/11__visualisation_execution_path/01_repetition.robot b/atest/robotMBT tests/11__visualisation_execution_path/01_repetition.robot index 0baee96b..fd1e2959 100644 --- a/atest/robotMBT tests/11__visualisation_execution_path/01_repetition.robot +++ b/atest/robotMBT tests/11__visualisation_execution_path/01_repetition.robot @@ -1,10 +1,16 @@ *** Settings *** Documentation -Suite Setup Treat this test suite Model-based +Suite Setup Enter test suite Resource ../../resources/visualisation.resource Library robotmbt +*** Keywords *** +Enter test suite + Check requirements + Treat this test suite Model-based + *** Test Cases *** + Setup Given test suite s has trace info t Then the algorithm inserts 'A1' with state "attr: states=['a1'],special='!'" diff --git a/atest/robotMBT tests/12__visualisation_export/01__export to JSON.robot b/atest/robotMBT tests/12__visualisation_export/01__export to JSON.robot index ad70635e..e845a759 100644 --- a/atest/robotMBT tests/12__visualisation_export/01__export to JSON.robot +++ b/atest/robotMBT tests/12__visualisation_export/01__export to JSON.robot @@ -2,10 +2,15 @@ Documentation Export and import a test suite from and to JSON ... and check that the imported suite equals the ... exported suite. -Suite Setup Treat this test suite Model-based +Suite Setup Enter test suite Resource ../../resources/visualisation.resource Library robotmbt +*** Keywords *** +Enter test suite + Check requirements + Treat this test suite Model-based + *** Test Cases *** Setup Given test suite s has trace info t diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index 1edaec39..9f67c6b9 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -45,11 +45,11 @@ from .visualise.visualiser import Visualiser from .visualise.models import TraceInfo - VISUALISE = True + visualisation_deps_present = True except ImportError: Visualiser = None TraceInfo = None - VISUALISE = False + visualisation_deps_present = False class SuiteProcessors: @@ -105,7 +105,7 @@ def process_test_suite(self, in_suite: Suite, *, seed: Any = 'new', graph: str = return self.out_suite - def _load_graph(self, graph:str, suite_name: str, from_json: str): + def _load_graph(self, graph: str, suite_name: str, from_json: str): traceinfo = TraceInfo() traceinfo = traceinfo.import_graph(from_json) self.visualiser = Visualiser(graph, suite_name, trace_info=traceinfo) @@ -115,21 +115,21 @@ def _run_test_suite(self, seed: Any, graph: str, suite_name: str, to_json: bool) scenario.src_id = id self.scenarios = self.flat_suite.scenarios[:] logger.debug("Use these numbers to reference scenarios from traces\n\t" + - "\n\t".join([f"{s.src_id}: {s.name}" for s in self.scenarios])) + "\n\t".join([f"{s.src_id}: {s.name}" for s in self.scenarios])) self._init_randomiser(seed) self.shuffled = [s.src_id for s in self.scenarios] random.shuffle(self.shuffled) # Keep a single shuffle for all TraceStates (non-essential) self.visualiser = None - if graph != '' and VISUALISE: + if graph != '' and visualisation_deps_present: try: self.visualiser = Visualiser(graph, suite_name, to_json) except Exception as e: self.visualiser = None logger.warn(f'Could not initialise visualiser due to error!\n{e}') - elif graph != '' and not VISUALISE: + elif graph != '' and not visualisation_deps_present: logger.warn(f'Visualisation {graph} requested, but required dependencies are not installed. ' 'Refer to the README on how to install these dependencies. ') @@ -182,7 +182,6 @@ def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool) -> TraceS logger.debug(f"last state:\n{tracestate.model.get_status_text()}") return tracestate - def __update_visualisation(self, tracestate: TraceState): if self.visualiser is not None: try: From 4f1a7e761889e9c4edac4482c68ad14832825c85 Mon Sep 17 00:00:00 2001 From: Jonathan <61787386+JWillegers@users.noreply.github.com> Date: Wed, 21 Jan 2026 11:07:02 +0100 Subject: [PATCH 048/131] replace boxzoomtool for zoomintool and zoomouttool (#63) --- robotmbt/visualise/networkvisualiser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/robotmbt/visualise/networkvisualiser.py b/robotmbt/visualise/networkvisualiser.py index fd48670f..04f1f725 100644 --- a/robotmbt/visualise/networkvisualiser.py +++ b/robotmbt/visualise/networkvisualiser.py @@ -2,7 +2,7 @@ from bokeh.core.property.vectorization import value from bokeh.embed import file_html from bokeh.models import ColumnDataSource, Rect, Text, ResetTool, SaveTool, WheelZoomTool, PanTool, Plot, Range1d, \ - Title, FullscreenTool, CustomJS, Segment, Arrow, NormalHead, Bezier, Legend, BoxZoomTool + Title, FullscreenTool, CustomJS, Segment, Arrow, NormalHead, Bezier, Legend, ZoomInTool, ZoomOutTool from grandalf.graphs import Vertex as GVertex, Edge as GEdge, Graph as GGraph from grandalf.layouts import SugiyamaLayout @@ -241,7 +241,7 @@ def _add_features(self, suite_name: str): wheel_zoom = WheelZoomTool() self.plot.add_tools(ResetTool(), SaveTool(), wheel_zoom, PanTool(), - FullscreenTool(), BoxZoomTool()) + FullscreenTool(), ZoomInTool(factor=0.4), ZoomOutTool(factor=0.4)) self.plot.toolbar.active_scroll = wheel_zoom # Specify the default range - these values represent the aspect ratio of the actual view in the window From 5603ae2ab4bc9d835442967b7ee6453a786e07ff Mon Sep 17 00:00:00 2001 From: Thomas Kas Date: Wed, 21 Jan 2026 11:12:09 +0100 Subject: [PATCH 049/131] Split name utests (#64) --- utest/test_visualise_models.py | 41 ++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/utest/test_visualise_models.py b/utest/test_visualise_models.py index d07958dc..4ec9941f 100644 --- a/utest/test_visualise_models.py +++ b/utest/test_visualise_models.py @@ -29,6 +29,47 @@ def test_scenarioInfo_Scenario(self): self.assertEqual(si.name, 'test') self.assertEqual(si.src_id, 0) + def test_split_name_empty_string(self): + result = ScenarioInfo._split_name("") + self.assertEqual(result, "") + self.assertNotIn('\n', result) + + def test_split_name_single_short_word(self): + result = ScenarioInfo._split_name("Hello") + self.assertEqual(result, "Hello") + self.assertNotIn('\n', result) + + def test_split_name_single_exact_length_word(self): + exact_20 = "abcdefghijklmnopqrst" + result = ScenarioInfo._split_name(exact_20) + self.assertEqual(result, exact_20) + self.assertNotIn('\n', result) + + def test_split_name_single_long_word(self): + name = "ThisIsAReallyLongNameWithoutAnySpacesAtAll" + result = ScenarioInfo._split_name(name) + self.assertEqual(result, name) + self.assertNotIn('\n', result) + + def test_split_name_two_words_short(self): + result = ScenarioInfo._split_name("Hello World") + self.assertEqual(result, "Hello World") + self.assertNotIn('\n', result) + + def test_split_name_two_words_exceeds_limit(self): + name = "Supercalifragilistic Hello" + result = ScenarioInfo._split_name(name) + + self.assertEqual(result.replace('\n', ' '), name) + self.assertIn('\n', result) + + def test_split_name_multiple_words_need_split(self): + name = "This is a very long scenario name that should be split" + result = ScenarioInfo._split_name(name) + + self.assertEqual(result.replace('\n', ' '), name) + self.assertIn('\n', result) + """ Class: StateInfo """ From aa8c4823d9af257172ef0b8ad60205fae179fbbf Mon Sep 17 00:00:00 2001 From: Thomas Kas Date: Wed, 21 Jan 2026 11:14:23 +0100 Subject: [PATCH 050/131] Smaller margins (#62) * Smaller margins between vertices --- robotmbt/visualise/networkvisualiser.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/robotmbt/visualise/networkvisualiser.py b/robotmbt/visualise/networkvisualiser.py index 04f1f725..f2dd4ff8 100644 --- a/robotmbt/visualise/networkvisualiser.py +++ b/robotmbt/visualise/networkvisualiser.py @@ -203,8 +203,14 @@ def _create_layout(self) -> tuple[list[Node], list[Edge]]: # Feed the info to grandalf and get the layout. g = GGraph(vertices, edges) - sugiyama = SugiyamaLayout(g.C[0]) + + # Set specific margins as these values worked best in user-testing + sugiyama.xspace = 10 + sugiyama.yspace = 15 + sugiyama.dw = 2 + sugiyama.dh = 2 + sugiyama.init_all(roots=[start], inverted_edges=flips) sugiyama.draw() From 37b60266ab8ad074c068b4a909d87ecd7e926bc2 Mon Sep 17 00:00:00 2001 From: Thomas Kas Date: Wed, 21 Jan 2026 11:31:56 +0100 Subject: [PATCH 051/131] Documentation (#65) * implemented scenariodeltavaluegraph * implemented reduced-scenariodeltavaluegraph, but should still discuss how to label equivalence classes (currently just picks a representative). Fixed part of vertex info being indicated as edge info for scenariodeltavaluegraph. Trace highlighting is still broken for reducedSDV * implemented comments from Douwe (added comments and reset graph setting in suiteprocessors.py) * fixed reducedSDV graph to have a properly functioning equivalence relation * added merged annotation to labels generated by reducedSDV for merged nodes * added documentation to StateInfo.difference * Reduce size --------- Co-authored-by: tychodub --- robotmbt/visualise/models.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index ffec518f..067c157f 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -102,6 +102,10 @@ def _create_state_with_prop(cls, name: str, attrs: list[tuple[str, Any]]): return cls(space) def difference(self, new_state) -> set[tuple[str, str]]: + """ + new_state: the new StateInfo to be compared to the self. + returns: a set of tuples with properties and their assignment. + """ old: dict[str, dict | str] = self.properties.copy() new: dict[str, dict | str] = new_state.properties.copy() temp = StateInfo._dict_deep_diff(old, new) From a0ab46d460e58fd005ef445a8db1cb8126556091 Mon Sep 17 00:00:00 2001 From: Jonathan <61787386+JWillegers@users.noreply.github.com> Date: Wed, 21 Jan 2026 12:46:14 +0100 Subject: [PATCH 052/131] Export path (#66) * change json from bool to providing path * rename to_json and from_json * export without needing to generate a graph * remove graph=... from existing atests * remove export from this test case --- CONTRIBUTING.md | 15 ++----- README.md | 6 +-- atest/resources/helpers/modelgenerator.py | 2 +- .../01__then_to_given_linking.robot | 2 +- .../02__swapped_then_to_given_linking.robot | 2 +- ...single_when_to_given_step_refinement.robot | 2 +- .../05__composed_scenario.robot | 2 +- .../01__single_repetition.robot | 2 +- .../04__multi_repetition.robot | 2 +- .../05__paired_repetition.robot | 2 +- .../06__repetition_twin_with_tail.robot | 2 +- .../07__repetition_with_refinement.robot | 2 +- robotmbt/suiteprocessors.py | 16 +++---- robotmbt/visualise/models.py | 20 ++++++--- robotmbt/visualise/visualiser.py | 45 ++++++++++--------- 15 files changed, 61 insertions(+), 61 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f36b3751..9969f32d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -171,21 +171,14 @@ class ScenarioGraph(AbstractGraph[ScenarioInfo, None]): ``` Once you have created a new Graph class, you can direct the visualizer to use it when your type is selected. -Edit `/robotmbt/visualise/visualiser.py` to not reject your graph type in `__init__` and construct your graph in `generate_visualisation` like the others. For our example: -```python -def __init__(self, graph_type: str, suite_name: str = "", seed: str = "", export: bool = False, trace_info: TraceInfo = None): - if graph_type != 'scenario' and [...]: - raise ValueError(f"Unknown graph type: {graph_type}!") - - [...] -``` +Edit `/robotmbt/visualise/visualiser.py` to construct your graph in `_get_graph` like the others. For our example: ```python -def generate_visualisation(self) -> str: +def _get_graph(self) -> AbstractGraph | None: [...] - if self.graph_type == 'scenario': - graph: AbstractGraph = ScenarioGraph(self.trace_info) + case 'scenario': + return ScenarioGraph(self.trace_info) [...] ``` diff --git a/README.md b/README.md index 8ca95a89..6f832f68 100644 --- a/README.md +++ b/README.md @@ -217,17 +217,17 @@ Once the test suite has run, a graph will be included in the test's log, under t It is possible to extract the exploration data after the library has found a covering trace. To enable this feature, set the following argument to true: ``` -Treat this test suite Model-based graph=[type] to_json=true +Treat this test suite Model-based graph=[type] export_graph_data=[directory] ``` A JSON file named after the test suite will be created containing said information. #### JSON importing -It is possible to skip running the exploration step and produce a graph (e.g. of another type) from previously exported data. This can be achieved by pointing the following argument to such a JSON file (just its name suffices, without the extension): +It is possible to skip running the exploration step and produce a graph (e.g. of another type) from previously exported data. ``` -Treat this test suite Model-based graph=[type] from_json=[file_name] +Treat this test suite Model-based graph=[type] import_graph_data=[directory+file_name.json] ``` A graph will be created from the imported data. diff --git a/atest/resources/helpers/modelgenerator.py b/atest/resources/helpers/modelgenerator.py index c35fdacc..41cd4d73 100644 --- a/atest/resources/helpers/modelgenerator.py +++ b/atest/resources/helpers/modelgenerator.py @@ -73,7 +73,7 @@ def generate_networkgraph(self, graph: AbstractGraph) -> NetworkVisualiser: @keyword(name='Export Graph') # type:ignore def export_graph(self, suite: str, trace_info: TraceInfo) -> str: - return trace_info.export_graph(suite, True) + return trace_info.export_graph(suite, atest=True) @keyword(name='Import Graph') # type:ignore def import_graph(self, filepath: str) -> TraceInfo: diff --git a/atest/robotMBT tests/04__scenario_linking/01__then_to_given_linking.robot b/atest/robotMBT tests/04__scenario_linking/01__then_to_given_linking.robot index 13075c71..b0ecf40d 100644 --- a/atest/robotMBT tests/04__scenario_linking/01__then_to_given_linking.robot +++ b/atest/robotMBT tests/04__scenario_linking/01__then_to_given_linking.robot @@ -4,7 +4,7 @@ Documentation This test suite demonstrates direct one-on-one linking of scen ... the trailing scenario. ... ... Note that this test suite would also pass when run in Robot Framework without additional processing. -Suite Setup Treat this test suite Model-based graph=scenario +Suite Setup Treat this test suite Model-based Resource ../../resources/birthday_cards_flat.resource Library robotmbt diff --git a/atest/robotMBT tests/04__scenario_linking/02__swapped_then_to_given_linking.robot b/atest/robotMBT tests/04__scenario_linking/02__swapped_then_to_given_linking.robot index e587223e..c8619356 100644 --- a/atest/robotMBT tests/04__scenario_linking/02__swapped_then_to_given_linking.robot +++ b/atest/robotMBT tests/04__scenario_linking/02__swapped_then_to_given_linking.robot @@ -5,7 +5,7 @@ Documentation This test suite demonstrates direct one-on-one linking of scen ... reverse order, this test suite would fail in a regular Robot Framework ... test run. It passes when the model figures out the dependency between ... the test cases and swaps their order. -Suite Setup Treat this test suite Model-based graph=scenario +Suite Setup Treat this test suite Model-based Resource ../../resources/birthday_cards_flat.resource Library robotmbt diff --git a/atest/robotMBT tests/04__scenario_linking/04__single_when_to_given_step_refinement.robot b/atest/robotMBT tests/04__scenario_linking/04__single_when_to_given_step_refinement.robot index d2cef617..2fc8c87e 100644 --- a/atest/robotMBT tests/04__scenario_linking/04__single_when_to_given_step_refinement.robot +++ b/atest/robotMBT tests/04__scenario_linking/04__single_when_to_given_step_refinement.robot @@ -7,7 +7,7 @@ Documentation This suite demonstrates step refinement in its simplest form. ... the _WHEN_ step. For this to be successful, the _WHEN_ step from the ... high-level scenario must match the _GIVEN_ step of the refinement ... scenario. -Suite Setup Treat this test suite Model-based graph=scenario +Suite Setup Treat this test suite Model-based Resource ../../resources/birthday_cards_composed.resource Library robotmbt diff --git a/atest/robotMBT tests/04__scenario_linking/05__composed_scenario.robot b/atest/robotMBT tests/04__scenario_linking/05__composed_scenario.robot index 2ed8d4a0..a079c13b 100644 --- a/atest/robotMBT tests/04__scenario_linking/05__composed_scenario.robot +++ b/atest/robotMBT tests/04__scenario_linking/05__composed_scenario.robot @@ -3,7 +3,7 @@ Documentation This is a composed scenario where there is a sequence of three ... scenarios on the highest level. The middle of these three scenarios ... has multiple steps that require refinement. One of those refinement ... steps needs refinement of it own, yielding double-layerd refinement. -Suite Setup Treat this test suite Model-based graph=scenario +Suite Setup Treat this test suite Model-based Resource ../../resources/birthday_cards_composed.resource Library robotmbt diff --git a/atest/robotMBT tests/05__repeating_scenarios/01__single_repetition.robot b/atest/robotMBT tests/05__repeating_scenarios/01__single_repetition.robot index 401cc7ce..1f41fd95 100644 --- a/atest/robotMBT tests/05__repeating_scenarios/01__single_repetition.robot +++ b/atest/robotMBT tests/05__repeating_scenarios/01__single_repetition.robot @@ -2,7 +2,7 @@ Documentation This suite consists of 3 scenarios. After inserting the leading ... scenario, the trailing scenario cannot be reached, unless the middle ... scenario is inserted twice. -Suite Setup Treat this test suite Model-based graph=scenario +Suite Setup Treat this test suite Model-based Resource ../../resources/birthday_cards_flat.resource Library robotmbt diff --git a/atest/robotMBT tests/05__repeating_scenarios/04__multi_repetition.robot b/atest/robotMBT tests/05__repeating_scenarios/04__multi_repetition.robot index 88c8629c..a08567a8 100644 --- a/atest/robotMBT tests/05__repeating_scenarios/04__multi_repetition.robot +++ b/atest/robotMBT tests/05__repeating_scenarios/04__multi_repetition.robot @@ -1,7 +1,7 @@ *** Settings *** Documentation This suite requires a larger amount of repetitions to reach the final ... scenario. -Suite Setup Treat this test suite Model-based graph=scenario +Suite Setup Treat this test suite Model-based Resource ../../resources/birthday_cards_flat.resource Library robotmbt diff --git a/atest/robotMBT tests/05__repeating_scenarios/05__paired_repetition.robot b/atest/robotMBT tests/05__repeating_scenarios/05__paired_repetition.robot index 5d9ac59d..39f6c65c 100644 --- a/atest/robotMBT tests/05__repeating_scenarios/05__paired_repetition.robot +++ b/atest/robotMBT tests/05__repeating_scenarios/05__paired_repetition.robot @@ -2,7 +2,7 @@ Documentation This suite cannot be completed by repeating a single scenario. Two ... scenarios are linked in such a way that they must be repeated in ... pairs to reach the final scenario. -Suite Setup Treat this test suite Model-based graph=scenario +Suite Setup Treat this test suite Model-based Resource ../../resources/birthday_cards_flat.resource Library robotmbt diff --git a/atest/robotMBT tests/05__repeating_scenarios/06__repetition_twin_with_tail.robot b/atest/robotMBT tests/05__repeating_scenarios/06__repetition_twin_with_tail.robot index 51b479e2..f9ec9211 100644 --- a/atest/robotMBT tests/05__repeating_scenarios/06__repetition_twin_with_tail.robot +++ b/atest/robotMBT tests/05__repeating_scenarios/06__repetition_twin_with_tail.robot @@ -3,7 +3,7 @@ Documentation This suite includes multiplicity on multiple ends. Most notabl ... - Two scenarios that are equaly valid to include in the repetition part ... - Two scenarios at the tail end that cannot be reached without the ... \ \ repetitions, but each has a different style entry condition. -Suite Setup Treat this test suite Model-based graph=scenario +Suite Setup Treat this test suite Model-based Resource ../../resources/birthday_cards_flat.resource Library robotmbt diff --git a/atest/robotMBT tests/05__repeating_scenarios/07__repetition_with_refinement.robot b/atest/robotMBT tests/05__repeating_scenarios/07__repetition_with_refinement.robot index d7e552e6..54169443 100644 --- a/atest/robotMBT tests/05__repeating_scenarios/07__repetition_with_refinement.robot +++ b/atest/robotMBT tests/05__repeating_scenarios/07__repetition_with_refinement.robot @@ -2,7 +2,7 @@ Documentation This suite is similar to the Single repetition scenario, but ... with the difference that this time the repeated scenario has ... a step that requires refinement. -Suite Setup Treat this test suite Model-based graph=scenario +Suite Setup Treat this test suite Model-based Resource ../../resources/birthday_cards_composed.resource Library robotmbt diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index 9f67c6b9..94b9c75b 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -88,18 +88,18 @@ def flatten(self, in_suite: Suite) -> Suite: return out_suite def process_test_suite(self, in_suite: Suite, *, seed: Any = 'new', graph: str = '', - to_json: bool = False, from_json: str = 'false') -> Suite: + export_graph_data: str = '', import_graph_data: str = '') -> Suite: self.out_suite = Suite(in_suite.name) self.out_suite.filename = in_suite.filename self.out_suite.parent = in_suite.parent self._fail_on_step_errors(in_suite) self.flat_suite = self.flatten(in_suite) - if from_json != 'false': - self._load_graph(graph, in_suite.name, from_json) + if import_graph_data != '': + self._load_graph(graph, in_suite.name, import_graph_data) else: - self._run_test_suite(seed, graph, in_suite.name, to_json) + self._run_test_suite(seed, graph, in_suite.name, export_graph_data) self.__write_visualisation() @@ -110,7 +110,7 @@ def _load_graph(self, graph: str, suite_name: str, from_json: str): traceinfo = traceinfo.import_graph(from_json) self.visualiser = Visualiser(graph, suite_name, trace_info=traceinfo) - def _run_test_suite(self, seed: Any, graph: str, suite_name: str, to_json: bool): + def _run_test_suite(self, seed: Any, graph: str, suite_name: str, export_dir: str): for id, scenario in enumerate(self.flat_suite.scenarios, start=1): scenario.src_id = id self.scenarios = self.flat_suite.scenarios[:] @@ -122,14 +122,14 @@ def _run_test_suite(self, seed: Any, graph: str, suite_name: str, to_json: bool) random.shuffle(self.shuffled) # Keep a single shuffle for all TraceStates (non-essential) self.visualiser = None - if graph != '' and visualisation_deps_present: + if visualisation_deps_present: try: - self.visualiser = Visualiser(graph, suite_name, to_json) + self.visualiser = Visualiser(graph, suite_name, export_dir) except Exception as e: self.visualiser = None logger.warn(f'Could not initialise visualiser due to error!\n{e}') - elif graph != '' and not visualisation_deps_present: + elif (not graph or not export_dir) and not visualisation_deps_present: logger.warn(f'Visualisation {graph} requested, but required dependencies are not installed. ' 'Refer to the README on how to install these dependencies. ') diff --git a/robotmbt/visualise/models.py b/robotmbt/visualise/models.py index 067c157f..366a6e52 100644 --- a/robotmbt/visualise/models.py +++ b/robotmbt/visualise/models.py @@ -177,7 +177,6 @@ def __init__(self): self.all_traces: list[list[tuple[ScenarioInfo, StateInfo]]] = [] self.previous_length: int = 0 self.pushed: bool = False - self.path = "json/" def update_trace(self, scenario: ScenarioInfo | None, state: StateInfo, length: int): if length > self.previous_length: @@ -251,7 +250,7 @@ def _sanity_check(self, scen: ScenarioInfo, state: StateInfo, after: str): logger.warn( f'TraceInfo got out of sync after {after}\nExpected state: {prev_state}\nActual state: {state}') - def export_graph(self, suite_name: str, atest: bool = False) -> str | None: + def export_graph(self, suite_name: str, dir: str = '', atest: bool = False) -> str | None: encoded_instance = jsonpickle.encode(self) name = suite_name.lower().replace(' ', '_') if atest: @@ -261,17 +260,24 @@ def export_graph(self, suite_name: str, atest: bool = False) -> str | None: as temporary file, a different method, is problematic on Windows https://stackoverflow.com/a/57015383 ''' - fd, path = tempfile.mkstemp() + fd, dir = tempfile.mkstemp() with os.fdopen(fd, "w") as f: f.write(encoded_instance) - return path + return dir - with open(f"{self.path}{name}.json", "w") as f: + if dir[-1] != '/': + dir += '/' + + # create folders if they do not exist + if not os.path.exists(dir): + os.makedirs(dir) + + with open(f"{dir}{name}.json", "w") as f: f.write(encoded_instance) return None - def import_graph(self, file_name: str): - with open(f"{self.path}{file_name}.json", "r") as f: + def import_graph(self, file_path: str): + with open(f"{file_path}", "r") as f: string = f.read() self = jsonpickle.decode(string) diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py index 9069c370..92d9e30c 100644 --- a/robotmbt/visualise/visualiser.py +++ b/robotmbt/visualise/visualiser.py @@ -25,12 +25,8 @@ def construct(cls, graph_type: str): # just calls __init__, but without having underscores etc. return cls(graph_type) - def __init__(self, graph_type: str, suite_name: str = "", export: bool = False, - trace_info: TraceInfo = None): - if graph_type != 'scenario' and graph_type != 'state' and graph_type != 'scenario-state' \ - and graph_type != 'scenario-delta-value' and graph_type != 'reduced-sdv' \ - and graph_type != 'delta-value': - raise ValueError(f"Unknown graph type: {graph_type}!") + def __init__(self, graph_type: str, suite_name: str = "", export: str = '', + trace_info: TraceInfo = None): self.graph_type: str = graph_type if trace_info is None: @@ -70,27 +66,32 @@ def update_trace(self, trace: TraceState): model = snap.model self.trace_info.update_trace(ScenarioInfo(scenario), StateInfo(model), trace_len) - def _get_graph(self) -> AbstractGraph: - if self.graph_type == 'scenario': - graph: AbstractGraph = ScenarioGraph(self.trace_info) - elif self.graph_type == 'state': - graph: AbstractGraph = StateGraph(self.trace_info) - elif self.graph_type == 'scenario-delta-value': - graph: AbstractGraph = ScenarioDeltaValueGraph(self.trace_info) - elif self.graph_type == 'reduced-sdv': - graph: AbstractGraph = ReducedSDVGraph(self.trace_info) - elif self.graph_type == 'delta-value': - graph: AbstractGraph = DeltaValueGraph(self.trace_info) - else: - graph: AbstractGraph = ScenarioStateGraph(self.trace_info) - - return graph + def _get_graph(self) -> AbstractGraph | None: + match self.graph_type: + case 'scenario': + return ScenarioGraph(self.trace_info) + case 'state': + return StateGraph(self.trace_info) + case 'scenario-state': + return ScenarioStateGraph(self.trace_info) + case 'delta-value': + return DeltaValueGraph(self.trace_info) + case 'scenario-delta-value': + return ScenarioDeltaValueGraph(self.trace_info) + case 'recuded-sdv': + return ReducedSDVGraph(self.trace_info) + + return None def generate_visualisation(self) -> str: if self.export: - self.trace_info.export_graph(self.suite_name) + self.trace_info.export_graph(self.suite_name, self.export) graph: AbstractGraph = self._get_graph() + if graph is None: + if not self.export: + raise ValueError(f"Unknown graph type: {self.graph_type}") + return html_bokeh = networkvisualiser.NetworkVisualiser(graph, self.suite_name).generate_html() From 2802d002c84f534a9c43af75f15b19079dc5942d Mon Sep 17 00:00:00 2001 From: Thomas Kas Date: Fri, 23 Jan 2026 15:57:11 +0100 Subject: [PATCH 053/131] Sync fork and minor changes (#67) * handle optional argument's default values * optional arguments stay hidden when not mentioned * fix argument text for POSITIONAL_OR_NAMED cases Now keeps the notation as used in the scenario (with or without the argument's name) in the generated keyword arguments. * fix failure on empty varargs * workaround for embedded argument name mismatch Accept that names of embedded arguments in the @keyword decorator must match their Python argument counterparts. * support step modifiers for non-embedded arguments * handling vararg modifiers * add tests for step modifier with free named arguments * add error message for vararg and free named arg modifiers * docu update for step modifiers on non-embedded arguments * promote random seed logging to info level Logging the seed at info level allows to run without debug logging for smaller log files, without losing the ability to rerun an interesting trace. * promote random seed logging to info level * make tests independent * Remove embedded-arguments-only restriction for modifiers * bump version to 0.10 * New tests for keeping direct setting's effects local * keep direct model options local * How to use configuration options * added cicd * update keyword documentation * added error propagation to run_tests.py * removed commented out comment in run-tests.yml CI/CD, fixed 'pip install' * apply pep8 Python formatting at max 120 char/line * added cicd * added error propagation to run_tests.py * removed commented out comment in run-tests.yml CI/CD, fixed 'pip install' * format with autopep8 (default line length) * reformat with new max line length setting * Use consistent casing in naming RobotMBT * Add contribution guidelines * add workflow to check demo * added exit code propagation to demo * install local requirements.txt for demo * refactor comments and yaml syntax * adjusted contributing (style, formatting, sentence structure * Text clarifications CONTRIBUTING.md * fix md linting issues * access TraceSnapShot model as copy * use exit code to pass/fail autopep8 * test: intentional pep8 violation * undo pep8 violation * reuse scenario indexes for TraceState * keep original scenario list unshuffled * refactor processing to reach full coverage Open issue: rewind under refinement * delegate refinement stack to TraceState * more tests for refinement and rewinds * new test for data consistency in split-up scenarios * move scenario processing to its own file * remove redundant return value (model) * restore logging tweak * Adding type hints (#44) Included type hints for: - arguments - return types, except return None - member variables Excluded: - local variables - warnings on optional variables (... | None) - stub/mock type warnings in test files - Self type (pending dropping Python 3.10 support) * Fix creation of graph if none is requested * Support RecursiveScope objects * Update contribution file * Implement feedback * Formatting --------- Co-authored-by: JFoederer <32476108+JFoederer@users.noreply.github.com> Co-authored-by: Douwe Osinga Co-authored-by: tychodub <93142605+tychodub@users.noreply.github.com> --- .github/workflows/autopep8.yml | 15 +- .github/workflows/run-tests.yml | 14 +- CONTRIBUTING.md | 62 ++++++- README.md | 60 ++----- robotmbt/modeller.py | 10 +- robotmbt/modelspace.py | 38 +++-- robotmbt/steparguments.py | 30 ++-- robotmbt/substitutionmap.py | 34 ++-- robotmbt/suitedata.py | 87 +++++----- robotmbt/suiteprocessors.py | 23 +-- robotmbt/suitereplacer.py | 56 ++----- robotmbt/tracestate.py | 37 ++--- robotmbt/visualise/networkvisualiser.py | 5 +- robotmbt/visualise/visualiser.py | 53 +++--- run_tests.py | 2 +- utest/test_steparguments.py | 10 +- utest/test_substitutionmap.py | 2 +- utest/test_suitedata.py | 61 ++++--- utest/test_tracestate.py | 210 ++++++++++++------------ 19 files changed, 388 insertions(+), 421 deletions(-) diff --git a/.github/workflows/autopep8.yml b/.github/workflows/autopep8.yml index 51b0c7ca..999b02cb 100644 --- a/.github/workflows/autopep8.yml +++ b/.github/workflows/autopep8.yml @@ -1,4 +1,4 @@ -name: autopep8 check +name: autopep8 Check on: [pull_request] @@ -20,15 +20,4 @@ jobs: id: check run: | # Check if autopep8 would make changes - formatting_issues=$(autopep8 --diff --recursive --max-line-length 120 .) - if [[ formatting_issues = *[$" \t\n"]* ]] then - echo "No formatting issues found." - else - echo "Formatting issues found:" - printf "%s\n" "$formatting_issues" - echo "------------------------------" - echo "-- Formatting issues found! --" - echo "------------------------------" - exit 1 - fi - + autopep8 --diff --recursive --max-line-length 120 --exit-code . diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 82a6d088..f63a0551 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -1,11 +1,9 @@ -# This workflow will install Python dependencies, run tests and lint with a single version of Python +# This workflow installs required Python dependencies and then runs the available tests. # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python name: Run Acceptance and Unit tests (with and without visualisation dependencies) on: - # push: - # branches: [ "main" ] pull_request: branches: [ "main" ] @@ -22,19 +20,19 @@ jobs: - name: Set up Python uses: actions/setup-python@v3 with: - python-version: "3.10" - + python-version: "3.10" # Only the oldest supported Python version is included here + - name: Install dependencies (without extra visualisation dependencies) run: | python -m pip install --upgrade pip # upgrade pip to latest version pip install . # no additional [visualization] dependencies - + - name: Run tests (without visualisation dependencies) run: python run_tests.py - + - name: Install extra visualisation dependencies run: pip install ".[visualization]" # extra [visualization] dependencies in pyproject.toml - + - name: Run Tests (with visualisation dependencies) run: python run_tests.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9969f32d..303948a0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -120,7 +120,7 @@ Researchers have suggested that longer lines are better suited for cases when th Extending the functionality of the visualizer with new graph types can result in better insights into created tests. The visualizer makes use of an abstract graph class that makes it easy to create new graph types. To create a new graph type, create an instance of AbstractGraph, instantiating the following methods: -- select_node_info: select the information you want to use to identify different nodes from all ScenarioInfo/StateInfo pairs that make up the different exploration steps. This info is also used to label nodes. Its return type has to match the first type used to instantiate AbstractGraph. +- select_node_info: select the information you want to use to identify different nodes from all ScenarioInfo/StateInfo pairs that make up the different exploration steps. This info is also used to label nodes. Its return type has to match the first type argument passed to AbstractGraph. - select_edge_info: ditto but for edges, which is also used for labeling. Its type has to match the second type used to instantiate AbstractGraph. - create_node_label: turn the selected information into a label for a node. - create_edge_label: ditto but for edges. @@ -129,11 +129,11 @@ To create a new graph type, create an instance of AbstractGraph, instantiating t - get_legend_info_final_trace_edge: ditto but for edges that appear in the final trace. - get_legend_info_other_edge: ditto but for edges that have backtracked. -It is recommended to create a new file for each graph type under `/robotmbt/visualise/graphs/`. +Please create a new file for each graph type under `/robotmbt/visualise/graphs/`. NOTE: when manually altering the networkx field, ensure its ids remain as a serializable and hashable type when the constructor finishes. -A simple example is given below. In this graph type, nodes represent scenarios encountered in exploration, and edges show the flow between these scenarios. +As an example, we show the implementation of the scenario graph below. In this graph type, nodes represent scenarios encountered in exploration, and edges show the flow between these scenarios. ```python class ScenarioGraph(AbstractGraph[ScenarioInfo, None]): @@ -171,17 +171,61 @@ class ScenarioGraph(AbstractGraph[ScenarioInfo, None]): ``` Once you have created a new Graph class, you can direct the visualizer to use it when your type is selected. -Edit `/robotmbt/visualise/visualiser.py` to construct your graph in `_get_graph` like the others. For our example: +Simply add your class to the `GRAPHS` dictionary in `robotmbt/visualise/visualiser.py` like the others. For our example: ```python -def _get_graph(self) -> AbstractGraph | None: +GRAPHS = { [...] - - case 'scenario': - return ScenarioGraph(self.trace_info) - + 'scenario': ScenarioGraph, [...] +} ``` Now, when selecting your graph type (in our example `Treat this test suite Model-based graph=scenario`), your graph will get constructed! + +## Development Tips +### Python virtual environment +Installing the proper virtual environment can be done with the default `python -m venv ./.venv` command built into python. However, if you have another version of python on your system, this might break dependencies. + +#### Pipenv+Pyenv (verified on Windows and Linux) +For the optimal experience (at least on Linux), we suggest installing the following packages: +- [`pyenv`](https://github.com/pyenv/pyenv) (Linux/Mac) or [`pyenv-win`](https://github.com/pyenv-win/pyenv-win) (Windows) +- [`pipenv`](https://github.com/pypa/pipenv) + +Then, you can install a python virtual environment with: + +```bash +pipenv --python +``` +..where the python version can be found in the `pyproject.toml`. For example, for 3.10: `pipenv --python 3.10`. + +You might need to manually make the folder `.venv` by doing `mkdir .venv`. + +You can verify if the installation went correctly with: +```bash +pipenv check +``` +This should return `Passed!` + +Errors related to minor versions (for example `3.10.0rc2` != `3.10.0`) can be ignored. + +Now activate the virtual environment by running +```bash +pipenv shell +``` + +..and you should have a virtual env! If you run +```bash +python --version +``` +..while in your virtual environment, it should show the `` from before. + + +### Installing dependencies +***NOTE: making sure that you are in the virtual environment***. + +It is recommended that you also include the optional dependencies for visualisation, e.g.: +```bash +pip install ".[visualization]" +``` diff --git a/README.md b/README.md index 6f832f68..2d4c132f 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,16 @@ Treat this test suite model-based seed=eag-etou-cxi-leamv-jsi Using `seed=new` will force generation of a new reusable seed and is identical to omitting the seed argument. To completely bypass seed generation and use the system's random source, use `seed=None`. This has even more variation but does not produce a reusable seed. +### Option management + +If you want to set configuration options for use in multiple test suites without having to repeat them, the keywords __Set model-based options__ and __Update model-based options__ can be used to configure RobotMBT library options. _Set_ takes the provided options and discards any previously set options. _Update_ allows you to modify existing options or add new ones. Reset all options by calling _Set_ without arguments. Direct options provided to __Treat this test suite model-based__ take precedence over library options and affect only the current test suite. + +Tip: [Robot dictionaries](https://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#dictionary-variable) (`&{ }`) can be used to group related options and pass them as one set. + +## Contributing + +If you have feedback, ideas, or want to get involved in coding, then check out the [Contribution guidelines](https://github.com/JFoederer/robotframeworkMBT/blob/main/CONTRIBUTING.md). + ### Graphs By default, no graphs are generated for test-runs. For development purposes, having a visual representation of the test-suite you are working on can be very useful. To have robotmbt generate a graph, ensure you have installed the optional dependencies (`pip install .[visualization]`) and pass the type as an argument: @@ -245,53 +255,3 @@ If you have feedback, ideas, or want to get involved in coding, then check out t ## Disclaimer Please note that this library is in a premature state and hasn't reached its first official (1.0) release yet. Developments are ongoing within the context of the [TiCToC](https://tictoc.cs.ru.nl/) research project. Interface changes are still frequent, and no deprecation warnings are being issued yet. - - -## Development -### Python virtual environment -Installing the proper virtual environment can be done with the default `python -m venv ./.venv` command built into python. However, if you have another version of python on your system, this might break dependencies. - -#### Pipenv+Pyenv (verified on Windows and Linux) -For the optimal experience (at least on Linux), we suggest installing the following packages: -- [`pyenv`](https://github.com/pyenv/pyenv) (Linux/Mac) or [`pyenv-win`](https://github.com/pyenv-win/pyenv-win) (Windows) -- [`pipenv`](https://github.com/pypa/pipenv) - -Then, you can install a python virtual environment with: - -```bash -pipenv --python -``` -..where the python version can be found in the `pyproject.toml`. For example, for 3.10: `pipenv --python 3.10`. - -You might need to manually make the folder `.venv` by doing `mkdir .venv`. - -You can verify if the install went correctly with: -```bash -pipenv check -``` -This should return `Passed!` - -Errors related to minor versions (for example `3.10.0rc2` != `3.10.0`) can be ignored. - -Now activate the virtual environment by running -```bash -pipenv shell -``` - -..and you should have a virtual env! If you run -```bash -python --version -``` -..while in your virtual environment, it should show the `` from before. - - -### Installing dependencies -***NOTE: making sure that you are in the virtual environment***. - -It is recommended that you also include the optional depedencies for visualisation, e.g.: -```bash -pip install ".[visualization]" -``` - - - diff --git a/robotmbt/modeller.py b/robotmbt/modeller.py index 3c9fd990..e92e8c66 100644 --- a/robotmbt/modeller.py +++ b/robotmbt/modeller.py @@ -36,7 +36,7 @@ from robot.utils import is_list_like from .modelspace import ModelSpace -from .steparguments import StepArgument, StepArguments +from .steparguments import StepArguments, ArgKind from .substitutionmap import SubstitutionMap from .suitedata import Scenario, Step from .tracestate import TraceState, TraceSnapShot @@ -172,7 +172,7 @@ def generate_scenario_variant(scenario: Scenario, model: ModelSpace) -> Scenario modded_arg, constraint = _parse_modifier_expression(expr, step.args) if step.args[modded_arg].is_default: continue - if step.args[modded_arg].kind in [StepArgument.EMBEDDED, StepArgument.POSITIONAL, StepArgument.NAMED]: + if step.args[modded_arg].kind in [ArgKind.EMBEDDED, ArgKind.POSITIONAL, ArgKind.NAMED]: org_example = step.args[modded_arg].org_value if step.gherkin_kw == 'then': constraint = None # No new constraints are processed for then-steps @@ -193,7 +193,7 @@ def generate_scenario_variant(scenario: Scenario, model: ModelSpace) -> Scenario else: options = None subs.substitute(org_example, options) - elif step.args[modded_arg].kind == StepArgument.VAR_POS: + elif step.args[modded_arg].kind == ArgKind.VAR_POS: if step.args[modded_arg].value: modded_varargs = model.process_expression(constraint, step.args) if not is_list_like(modded_varargs): @@ -202,7 +202,7 @@ def generate_scenario_variant(scenario: Scenario, model: ModelSpace) -> Scenario # change the number of arguments in the list, making it impossible to decide which values to # match and which to drop and/or duplicate. step.args[modded_arg].value = modded_varargs - elif step.args[modded_arg].kind == StepArgument.FREE_NAMED: + elif step.args[modded_arg].kind == ArgKind.FREE_NAMED: if step.args[modded_arg].value: modded_free_args = model.process_expression(constraint, step.args) if not isinstance(modded_free_args, dict): @@ -234,7 +234,7 @@ def generate_scenario_variant(scenario: Scenario, model: ModelSpace) -> Scenario if step.args[modded_arg].is_default: continue org_example = step.args[modded_arg].org_value - if step.args[modded_arg].kind in [StepArgument.EMBEDDED, StepArgument.POSITIONAL, StepArgument.NAMED]: + if step.args[modded_arg].kind in [ArgKind.EMBEDDED, ArgKind.POSITIONAL, ArgKind.NAMED]: step.args[modded_arg].value = subs.solution[org_example] return scenario diff --git a/robotmbt/modelspace.py b/robotmbt/modelspace.py index 1504a7fd..e5cac2a9 100644 --- a/robotmbt/modelspace.py +++ b/robotmbt/modelspace.py @@ -45,9 +45,7 @@ def __init__(self, reference_id=None): self.ref_id: str = str(reference_id) self.std_attrs: list[str] = [] self.props: dict[str, RecursiveScope | ModelSpace] = dict() - - # For using literals without having to use quotes (abc='abc') - self.values: dict[str, Any] = dict() + self.values: dict[str, Any] = dict() # For using literals without having to use quotes (abc='abc') self.scenario_vars: list[RecursiveScope] = [] self.std_attrs = dir(self) @@ -55,7 +53,6 @@ def __repr__(self): return self.ref_id if self.ref_id else super().__repr__() def copy(self): - # -> Self return copy.deepcopy(self) def __eq__(self, other): @@ -84,13 +81,11 @@ def __dir__(self, recurse=True): return self.__dict__.keys() def new_scenario_scope(self): - self.scenario_vars.append(RecursiveScope( - self.scenario_vars[-1] if len(self.scenario_vars) else None)) + self.scenario_vars.append(RecursiveScope(self.scenario_vars[-1] if len(self.scenario_vars) else None)) self.props['scenario'] = self.scenario_vars[-1] def end_scenario_scope(self): - assert len( - self.scenario_vars) > 0, ".end_scenario_scope() called, but there is no scenario scope open." + assert len(self.scenario_vars) > 0, ".end_scenario_scope() called, but there is no scenario scope open." self.scenario_vars.pop() if len(self.scenario_vars): self.props['scenario'] = self.scenario_vars[-1] @@ -111,8 +106,7 @@ def process_expression(self, expression: str, step_args: StepArguments = StepArg for p in self.props: exec(f"{p} = self.props['{p}']", local_locals) for v in self.values: - value = f"'{self.values[v]}'" if isinstance( - self.values[v], str) else self.values[v] + value = f"'{self.values[v]}'" if isinstance(self.values[v], str) else self.values[v] exec(f"{v} = {value}", local_locals) try: result = eval(expr, local_locals) @@ -138,7 +132,7 @@ def process_expression(self, expression: str, step_args: StepArguments = StepArg return result - def __handle_attribute_error(self, err): + def __handle_attribute_error(self, err: AttributeError): if isinstance(err.obj, str) and err.obj in self.values: # This situation occurs when using e.g. 'foo.bar' in the model before calling 'new foo'. # The NameError on foo is handled by adding its alias, which results in an AttributeError @@ -146,18 +140,16 @@ def __handle_attribute_error(self, err): raise ModellingError(f"{err.obj} used before definition") raise ModellingError(f"{err.name} used before assignment") - def __add_alias(self, missing_name: str, step_args): + def __add_alias(self, missing_name: str, step_args: StepArguments): if missing_name == 'scenario': raise ModellingError("Accessing scenario scope while there is no scenario active.\n" "If you intended this to be a literal, please use quotes ('scenario' or \"scenario\").") - matching_args = [ - arg.value for arg in step_args if arg.codestring == missing_name] + matching_args = [arg.value for arg in step_args if arg.codestring == missing_name] value = matching_args[0] if matching_args else missing_name if isinstance(value, str): for esc_char in "$@&=": # Prevent "Syntaxwarning: invalid escape sequence" on Robot escapes like '\$' and '\=' value = value.replace(f'\\{esc_char}', f'\\\\{esc_char}') - # Needed because we use single quotes in low level processing later on - value = value.replace("'", r"\'") + value = value.replace("'", r"\'") # Needed because we use single quotes in low level processing later on self.values[missing_name] = value @staticmethod @@ -223,3 +215,17 @@ def __iter__(self): def __bool__(self): return any(True for _ in self) + + def __eq__(self, other): + self_set = set([(attr, getattr(self, attr)) for attr in dir(self._outer_scope) + dir(self) + if not attr.startswith('__') and attr != '_outer_scope']) + other_set = set([(attr, getattr(other, attr)) for attr in dir(other._outer_scope) + dir(other) + if not attr.startswith('__') and attr != '_outer_scope']) + return self_set == other_set + + def __str__(self): + res = "{" + for k, v in self: + res += f"{k}={v}, " + res += "}" + return res diff --git a/robotmbt/steparguments.py b/robotmbt/steparguments.py index 06980151..c4f89866 100644 --- a/robotmbt/steparguments.py +++ b/robotmbt/steparguments.py @@ -30,9 +30,10 @@ # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +from enum import Enum, auto from keyword import iskeyword -import builtins from typing import Any +import builtins class StepArguments(list): @@ -57,24 +58,26 @@ def modified(self) -> bool: return any([arg.modified for arg in self]) +class ArgKind(Enum): + EMBEDDED = auto() + POSITIONAL = auto() + VAR_POS = auto() + NAMED = auto() + FREE_NAMED = auto() + UNKNOWN = auto() + + class StepArgument: - # kind list - EMBEDDED = 'EMBEDDED' - POSITIONAL = 'POSITIONAL' - VAR_POS = 'VAR_POS' - NAMED = 'NAMED' - FREE_NAMED = 'FREE_NAMED' - - def __init__(self, arg_name: str, value: Any, kind: str | None = None, is_default: bool = False): + def __init__(self, arg_name: str, value: Any, kind: ArgKind = ArgKind.UNKNOWN, is_default: bool = False): self.name: str = arg_name self.org_value: Any = value - self.kind: str | None = kind # one of the values from the kind list + self.kind: ArgKind = kind self._value: Any = None self._codestr: str | None = None - self.value: Any = value + self.value = value # is_default indicates that the argument was not filled in from the scenario. This # argment's value is taken from the keyword's default as provided by Robot. - self.is_default: bool = is_default + self.is_default: bool = is_default @property def arg(self) -> str: @@ -86,7 +89,7 @@ def value(self) -> Any: @value.setter def value(self, value: Any): - self._value = value + self._value: Any = value self._codestr = self.make_codestring(value) self.is_default = False @@ -99,7 +102,6 @@ def codestring(self) -> str | None: return self._codestr def copy(self): - # -> Self cp = StepArgument(self.arg.strip('${}'), self.value, self.kind, self.is_default) cp.org_value = self.org_value return cp diff --git a/robotmbt/substitutionmap.py b/robotmbt/substitutionmap.py index ea7648af..f2494ec4 100644 --- a/robotmbt/substitutionmap.py +++ b/robotmbt/substitutionmap.py @@ -31,6 +31,7 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import random +from typing import Any class SubstitutionMap: @@ -43,24 +44,20 @@ class SubstitutionMap: """ def __init__(self): - # {example_value:Constraint} - self.substitutions: dict[str, Constraint] = {} - - # {example_value:solution_value} - self.solution: dict[str, int | str] = {} + self.substitutions = {} # {example_value:Constraint} + self.solution = {} # {example_value:solution_value} def __str__(self): src = self.solution or self.substitutions return ", ".join([f"{k} ⤝ {v}" for k, v in src.items()]) def copy(self): - # -> Self new = SubstitutionMap() new.substitutions = {k: v.copy() for k, v in self.substitutions.items()} new.solution = self.solution.copy() return new - def substitute(self, example_value: str, constraint: list[int]): + def substitute(self, example_value: str, constraint: list[Any]): self.solution = {} if example_value in self.substitutions: self.substitutions[example_value].add_constraint(constraint) @@ -77,8 +74,7 @@ def solve(self) -> dict[str, str]: while unsolved_subs: unsolved_subs.sort(key=lambda i: len(substitutions[i].optionset)) example_value = unsolved_subs[0] - solution[example_value] = random.choice( - substitutions[example_value].optionset) + solution[example_value] = random.choice(substitutions[example_value].optionset) subs_stack.append(example_value) others_list = [] @@ -103,15 +99,13 @@ def solve(self) -> dict[str, str]: subs_stack.pop() except IndexError: # nothing left to roll back, no options remaining - raise ValueError( - "No solution found within the set of given constraints") + raise ValueError("No solution found within the set of given constraints") last_item = subs_stack[-1] unsolved_subs.insert(0, last_item) for other in [e for e in substitutions if e != last_item]: substitutions[other].undo_remove() try: - substitutions[last_item].remove_option( - solution.pop(last_item)) + substitutions[last_item].remove_option(solution.pop(last_item)) rollback_done = True except ValueError: # next level must also be rolled back @@ -122,17 +116,15 @@ def solve(self) -> dict[str, str]: class Constraint: - def __init__(self, constraint): + def __init__(self, constraint: list[Any]): try: # Keep the items in optionset unique. Refrain from using Python sets # due to non-deterministic behaviour when using random seeding. - self.optionset: list | None = list(dict.fromkeys(constraint)) + self.optionset: list[Any] | None = list(dict.fromkeys(constraint)) except: - self.optionset: list | None = None + self.optionset: list[Any] | None = None if not self.optionset or isinstance(constraint, str): - raise ValueError( - f"Invalid option set for initial constraint: {constraint}" - ) + raise ValueError(f"Invalid option set for initial constraint: {constraint}") self.removed_stack: list[str | Placeholder] = [] @@ -143,13 +135,11 @@ def __iter__(self): return iter(self.optionset) def copy(self): - # -> Self return Constraint(self.optionset) - def add_constraint(self, constraint): + def add_constraint(self, constraint: list[Any] | None): if constraint is None: return - self.optionset = [opt for opt in self.optionset if opt in constraint] if not len(self.optionset): raise ValueError('No options left after adding constraint') diff --git a/robotmbt/suitedata.py b/robotmbt/suitedata.py index 11a796d9..885f2022 100644 --- a/robotmbt/suitedata.py +++ b/robotmbt/suitedata.py @@ -31,24 +31,26 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import copy -from typing import Any +from typing import Literal, Any +from robot.running.arguments.argumentspec import ArgumentSpec from robot.running.arguments.argumentvalidator import ArgumentValidator +from robot.running.keywordimplementation import KeywordImplementation import robot.utils.notset -from .steparguments import StepArgument, StepArguments +from .steparguments import StepArgument, StepArguments, ArgKind from .substitutionmap import SubstitutionMap class Suite: - def __init__(self, name: str, parent: Any = None): + def __init__(self, name: str, parent=None): self.name: str = name self.filename: str = '' self.parent: Suite | None = parent self.suites: list[Suite] = [] self.scenarios: list[Scenario] = [] - self.setup: Step | str | None = None # Can be a single step or None - self.teardown: Step | str | None = None # Can be a single step or None + self.setup: Step | None = None # Can be a single step or None + self.teardown: Step | None = None # Can be a single step or None @property def longname(self) -> str: @@ -60,7 +62,6 @@ def has_error(self) -> bool: or any([s.has_error() for s in self.scenarios]) or (self.teardown.has_error() if self.teardown else False)) - # list[Step | str | None], Step needs to be moved up def steps_with_errors(self): return (([self.setup] if self.setup and self.setup.has_error() else []) + [e for s in map(Suite.steps_with_errors, self.suites) for e in s] @@ -74,11 +75,11 @@ def __init__(self, name: str, parent: Suite | None = None): # Parent scenario is kept for easy searching, processing and referencing # after steps and scenarios have been potentially moved around self.parent: Suite | None = parent - self.setup: Step | None = None - self.teardown: Step | None = None + self.setup: Step | None = None # Can be a single step or None + self.teardown: Step | None = None # Can be a single step or None self.steps: list[Step] = [] self.src_id: int | None = None - self.data_choices: dict | SubstitutionMap = {} # may be Dummy type in a test + self.data_choices: SubstitutionMap = SubstitutionMap() @property def longname(self) -> str: @@ -89,20 +90,18 @@ def has_error(self) -> bool: or any([s.has_error() for s in self.steps]) or (self.teardown.has_error() if self.teardown else False)) - def steps_with_errors(self): # list[Step | None] + def steps_with_errors(self): return (([self.setup] if self.setup and self.setup.has_error() else []) + [s for s in self.steps if s.has_error()] + ([self.teardown] if self.teardown and self.teardown.has_error() else [])) def copy(self): - # -> Self duplicate = copy.copy(self) duplicate.steps = [step.copy() for step in self.steps] duplicate.data_choices = self.data_choices.copy() return duplicate def split_at_step(self, stepindex: int): - # -> tuple[Self, Self] """Returns 2 partial scenarios. With stepindex 0 the first part has no steps and all steps are in the last part. With @@ -120,35 +119,26 @@ def split_at_step(self, stepindex: int): class Step: def __init__(self, steptext: str, *args, parent: Suite | Scenario, assign: tuple[str] = (), - prev_gherkin_kw: str | None = None): + prev_gherkin_kw: Literal['given', 'when', 'then'] | None = None): # org_step is the first keyword cell of the Robot line, including step_kw, # excluding positional args, excluding variable assignment. self.org_step: str = steptext - # org_pn_args are the positional and named arguments as parsed # from the Robot text ('posA' , 'posB', 'named1=namedA') - self.org_pn_args = args - # Parent scenario for easy searching and processing. - self.parent: Suite | Scenario = parent + self.org_pn_args: tuple[str, ...] = args + self.parent: Suite | Scenario = parent # Parent scenario for easy searching and processing. # For when a keyword's return value is assigned to a variable. # Taken directly from Robot. self.assign: tuple[str] = assign - # gherkin_kw is one of 'given', 'when', 'then', or None for non-bdd keywords. - self.gherkin_kw: str | None = self.step_kw \ - if str(self.step_kw).lower() in ['given', 'when', 'then', 'none'] \ - else prev_gherkin_kw - - # Robot keyword with its embedded arguments in ${...} notation. - self.signature: str | None = None - # embedded arguments list of StepArgument objects. - self.args: StepArguments = StepArguments() - # Decouples StepArguments from the step text (refinement use case) - self.detached: bool = False - + self.gherkin_kw = self.step_kw if \ + str(self.step_kw).lower() in ['given', 'when', 'then', 'none'] else prev_gherkin_kw + self.signature: str | None = None # Robot keyword with its embedded arguments in ${...} notation. + self.args: StepArguments = StepArguments() # embedded arguments list of StepArgument objects. + self.detached: bool = False # Decouples StepArguments from the step text (refinement use case) # model_info contains modelling information as a dictionary. The standard format is # dict(IN=[], OUT=[]) and can optionally contain an error field. - # IN and OUT are lists of Python evaluatable expressions. + # The values of IN and OUT are lists of Python evaluatable expressions. # The `new vocab` form can be used to create new domain objects. # The `vocab.attribute` form can then be used to express relations # between properties from the domain vocabulaire. @@ -162,7 +152,6 @@ def __repr__(self): return f"Step: '{self}' with model info: {self.model_info}" def copy(self): - # -> Self cp = Step(self.org_step, *self.org_pn_args, parent=self.parent, assign=self.assign) cp.gherkin_kw = self.gherkin_kw cp.signature = self.signature @@ -190,7 +179,7 @@ def keyword(self) -> str: return self.args.fill_in_args(s) @property - def posnom_args_str(self) -> tuple[Any]: + def posnom_args_str(self) -> tuple[str, ...]: """A tuple with all arguments in Robot accepted text format ('posA' , 'posB', 'named1=namedA')""" if self.detached or not self.args.modified: return self.org_pn_args @@ -198,26 +187,25 @@ def posnom_args_str(self) -> tuple[Any]: for arg in self.args: if arg.is_default: continue - if arg.kind == arg.POSITIONAL: + if arg.kind == ArgKind.POSITIONAL: result.append(arg.value) - elif arg.kind == arg.VAR_POS: + elif arg.kind == ArgKind.VAR_POS: for vararg in arg.value: result.append(vararg) - elif arg.kind == arg.NAMED: + elif arg.kind == ArgKind.NAMED: result.append(f"{arg.name}={arg.value}") - elif arg.kind == arg.FREE_NAMED: + elif arg.kind == ArgKind.FREE_NAMED: for name, value in arg.value.items(): result.append(f"{name}={value}") - else: # TODO: remove this - has no impact on the control flow. - continue return tuple(result) @property - def gherkin_kw(self) -> str | None: + def gherkin_kw(self) -> Literal['given', 'when', 'then'] | None: return self._gherkin_kw @gherkin_kw.setter def gherkin_kw(self, value: str | None): + """if value is type str, it must be a case insensitive variant of given, when, then""" self._gherkin_kw = value.lower() if value else None @property @@ -230,23 +218,22 @@ def kw_wo_gherkin(self) -> str: """The keyword without its Gherkin keyword. I.e., as it is known in Robot framework.""" return self.keyword.replace(self.step_kw, '', 1).strip() if self.step_kw else self.keyword - def add_robot_dependent_data(self, robot_kw): + def add_robot_dependent_data(self, robot_kw: KeywordImplementation): """robot_kw must be Robot Framework's keyword object from Robot's runner context""" try: if robot_kw.error: raise ValueError(robot_kw.error) if robot_kw.embedded: - self.args = StepArguments([StepArgument(*match, kind=StepArgument.EMBEDDED) for match in + self.args = StepArguments([StepArgument(*match, kind=ArgKind.EMBEDDED) for match in zip(robot_kw.embedded.args, robot_kw.embedded.parse_args(self.kw_wo_gherkin))]) - self.args += self.__handle_non_embedded_arguments(robot_kw.args) self.signature = robot_kw.name - self.model_info: dict[str, list[str] | str] = self.__parse_model_info(robot_kw._doc) + self.model_info = self.__parse_model_info(robot_kw._doc) except Exception as ex: self.model_info['error'] = str(ex) - def __handle_non_embedded_arguments(self, robot_argspec) -> list[StepArgument]: + def __handle_non_embedded_arguments(self, robot_argspec: ArgumentSpec) -> list[StepArgument]: result = [] p_args = [a for a in self.org_pn_args if '=' not in a or r'\=' in a] n_args = [a.split('=', 1) for a in self.org_pn_args if '=' in a and r'\=' not in a] @@ -257,19 +244,19 @@ def __handle_non_embedded_arguments(self, robot_argspec) -> list[StepArgument]: for arg in robot_argspec: if not p_args or (arg.kind != arg.POSITIONAL_ONLY and arg.kind != arg.POSITIONAL_OR_NAMED): break - result.append(StepArgument(argument_names.pop(0), p_args.pop(0), kind=StepArgument.POSITIONAL)) + result.append(StepArgument(argument_names.pop(0), p_args.pop(0), kind=ArgKind.POSITIONAL)) robot_args.pop(0) if p_args and robot_args[0].kind == robot_args[0].VAR_POSITIONAL: - result.append(StepArgument(argument_names.pop(0), p_args, kind=StepArgument.VAR_POS)) + result.append(StepArgument(argument_names.pop(0), p_args, kind=ArgKind.VAR_POS)) free = {} for name, value in n_args: if name in argument_names: - result.append(StepArgument(name, value, kind=StepArgument.NAMED)) + result.append(StepArgument(name, value, kind=ArgKind.NAMED)) argument_names.remove(name) else: free[name] = value if free: - result.append(StepArgument(argument_names.pop(-1), free, kind=StepArgument.FREE_NAMED)) + result.append(StepArgument(argument_names.pop(-1), free, kind=ArgKind.FREE_NAMED)) for unmentioned_arg in argument_names: arg = next(arg for arg in robot_args if arg.name == unmentioned_arg) default_value = arg.default @@ -283,7 +270,7 @@ def __handle_non_embedded_arguments(self, robot_argspec) -> list[StepArgument]: # but use different names in the method signature. Robot Framework implementation is incomplete for this # aspect and differs between library and user keywords. assert False, f"No default argument expected to be needed for '{unmentioned_arg}' here" - result.append(StepArgument(unmentioned_arg, default_value, kind=StepArgument.NAMED, is_default=True)) + result.append(StepArgument(unmentioned_arg, default_value, kind=ArgKind.NAMED, is_default=True)) return result @staticmethod @@ -293,8 +280,8 @@ def __validate_arguments(spec, positionals, nameds): # Robot's mapping favours positional when possible, even when the name is used # in the keyword call. The validator is sensitive to these differences. p, n = spec.map(positionals, nameds) - # for some reason .map() returns [None] instead of the empty list when there are no arguments if p == [None]: + # for some reason .map() returns [None] instead of the empty list when there are no arguments p = [] # Use the Robot mechanism for validation to yield familiar error messages ArgumentValidator(spec).validate(p, n) diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index 94b9c75b..31377b4b 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -41,6 +41,7 @@ from .suitedata import Suite, Scenario from .tracestate import TraceState + try: from .visualise.visualiser import Visualiser from .visualise.models import TraceInfo @@ -54,7 +55,7 @@ class SuiteProcessors: @staticmethod - def echo(in_suite): + def echo(in_suite: Suite) -> Suite: return in_suite def flatten(self, in_suite: Suite) -> Suite: @@ -87,8 +88,8 @@ def flatten(self, in_suite: Suite) -> Suite: out_suite.suites = [] return out_suite - def process_test_suite(self, in_suite: Suite, *, seed: Any = 'new', graph: str = '', - export_graph_data: str = '', import_graph_data: str = '') -> Suite: + def process_test_suite(self, in_suite: Suite, *, seed: str | int | bytes | bytearray = 'new', + graph: str = '', export_graph_data: str = '', import_graph_data: str = '') -> Suite: self.out_suite = Suite(in_suite.name) self.out_suite.filename = in_suite.filename self.out_suite.parent = in_suite.parent @@ -110,19 +111,19 @@ def _load_graph(self, graph: str, suite_name: str, from_json: str): traceinfo = traceinfo.import_graph(from_json) self.visualiser = Visualiser(graph, suite_name, trace_info=traceinfo) - def _run_test_suite(self, seed: Any, graph: str, suite_name: str, export_dir: str): + def _run_test_suite(self, seed: str | int | bytes | bytearray, graph: str, suite_name: str, export_dir: str): for id, scenario in enumerate(self.flat_suite.scenarios, start=1): scenario.src_id = id - self.scenarios = self.flat_suite.scenarios[:] + self.scenarios: list[Scenario] = self.flat_suite.scenarios[:] logger.debug("Use these numbers to reference scenarios from traces\n\t" + "\n\t".join([f"{s.src_id}: {s.name}" for s in self.scenarios])) self._init_randomiser(seed) - self.shuffled = [s.src_id for s in self.scenarios] + self.shuffled: list[int] = [s.src_id for s in self.scenarios] random.shuffle(self.shuffled) # Keep a single shuffle for all TraceStates (non-essential) self.visualiser = None - if visualisation_deps_present: + if visualisation_deps_present and (graph or export_dir): try: self.visualiser = Visualiser(graph, suite_name, export_dir) except Exception as e: @@ -143,6 +144,7 @@ def _run_test_suite(self, seed: Any, graph: str, suite_name: str, export_dir: st raise Exception("Unable to compose a consistent suite") self.out_suite.scenarios = tracestate.get_trace() + self._report_tracestate_wrapup(tracestate) def _try_to_reach_full_coverage(self, allow_duplicate_scenarios: bool) -> TraceState: tracestate = TraceState(self.shuffled) @@ -192,7 +194,8 @@ def __update_visualisation(self, tracestate: TraceState): def __write_visualisation(self): if self.visualiser is not None: try: - logger.info(self.visualiser.generate_visualisation(), html=True) + text, html = self.visualiser.generate_visualisation() + logger.info(text, html=html) except Exception as e: logger.warn(f'Could not generate visualisation due to error!\n{e}') @@ -218,7 +221,7 @@ def _scenario_with_repeat_counter(self, index: int, tracestate: TraceState) -> S rep_count = tracestate.count(index) if rep_count: candidate = candidate.copy() - candidate.name = f"{candidate.name} (rep {rep_count + 1})" + candidate.name = f"{candidate.name} (rep {rep_count+1})" return candidate @staticmethod @@ -244,7 +247,7 @@ def _report_tracestate_wrapup(tracestate: TraceState): logger.debug(f"model\n{progression.model.get_status_text()}\n") @staticmethod - def _init_randomiser(seed: Any): + def _init_randomiser(seed: str | int | bytes | bytearray): if isinstance(seed, str): seed = seed.strip() diff --git a/robotmbt/suitereplacer.py b/robotmbt/suitereplacer.py index d44b83d2..2f0debb8 100644 --- a/robotmbt/suitereplacer.py +++ b/robotmbt/suitereplacer.py @@ -30,31 +30,25 @@ # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -from typing import Any - import robot.model - +import robot.running.model as rmodel from .suitedata import Suite, Scenario, Step from .suiteprocessors import SuiteProcessors -import robot.running.model as rmodel from robot.api import logger -from robot.api.deco import keyword +from robot.api.deco import library, keyword +from typing import Any from robot.libraries.BuiltIn import BuiltIn - Robot = BuiltIn() +@library(scope="GLOBAL", listener='SELF') class SuiteReplacer: - ROBOT_LIBRARY_SCOPE: str = 'GLOBAL' - ROBOT_LISTENER_API_VERSION: int = 3 - def __init__(self, processor: str = 'process_test_suite', processor_lib: str | None = None): - self.ROBOT_LIBRARY_LISTENER = self # : Self self.current_suite: robot.model.TestSuite | None = None self.robot_suite: robot.model.TestSuite | None = None self.processor_lib_name: str | None = processor_lib self.processor_name: str = processor - self._processor_lib: SuiteProcessors | None = None + self._processor_lib: SuiteProcessors | None | object = None self._processor_method: Any = None self.processor_options: dict[str, Any] = {} @@ -120,37 +114,27 @@ def __process_robot_suite(self, in_suite: robot.model.TestSuite, parent: Suite | out_suite.filename = in_suite.source if in_suite.setup and parent is not None: - step_info = Step(in_suite.setup.name, * - in_suite.setup.args, parent=out_suite) - step_info.add_robot_dependent_data( - Robot._namespace.get_runner(step_info.org_step).keyword) + step_info = Step(in_suite.setup.name, *in_suite.setup.args, parent=out_suite) + step_info.add_robot_dependent_data(Robot._namespace.get_runner(step_info.org_step).keyword) out_suite.setup = step_info if in_suite.teardown and parent is not None: - step_info = Step(in_suite.teardown.name, * - in_suite.teardown.args, parent=out_suite) - step_info.add_robot_dependent_data( - Robot._namespace.get_runner(step_info.org_step).keyword) + step_info = Step(in_suite.teardown.name, *in_suite.teardown.args, parent=out_suite) + step_info.add_robot_dependent_data(Robot._namespace.get_runner(step_info.org_step).keyword) out_suite.teardown = step_info for st in in_suite.suites: - out_suite.suites.append( - self.__process_robot_suite(st, parent=out_suite)) - + out_suite.suites.append(self.__process_robot_suite(st, parent=out_suite)) for tc in in_suite.tests: scenario = Scenario(tc.name, parent=out_suite) if tc.setup: - step_info = Step( - tc.setup.name, *tc.setup.args, parent=scenario) - step_info.add_robot_dependent_data( - Robot._namespace.get_runner(step_info.org_step).keyword) + step_info = Step(tc.setup.name, *tc.setup.args, parent=scenario) + step_info.add_robot_dependent_data(Robot._namespace.get_runner(step_info.org_step).keyword) scenario.setup = step_info if tc.teardown: - step_info = Step(tc.teardown.name, * - tc.teardown.args, parent=scenario) - step_info.add_robot_dependent_data( - Robot._namespace.get_runner(step_info.org_step).keyword) + step_info = Step(tc.teardown.name, *tc.teardown.args, parent=scenario) + step_info.add_robot_dependent_data(Robot._namespace.get_runner(step_info.org_step).keyword) scenario.teardown = step_info last_gwt = None @@ -158,16 +142,14 @@ def __process_robot_suite(self, in_suite: robot.model.TestSuite, parent: Suite | if isinstance(step_def, rmodel.Keyword): step_info = Step(step_def.name, *step_def.args, parent=scenario, assign=step_def.assign, prev_gherkin_kw=last_gwt) - step_info.add_robot_dependent_data( - Robot._namespace.get_runner(step_info.org_step).keyword) + step_info.add_robot_dependent_data(Robot._namespace.get_runner(step_info.org_step).keyword) scenario.steps.append(step_info) if step_info.gherkin_kw: last_gwt = step_info.gherkin_kw elif isinstance(step_def, rmodel.Var): - scenario.steps.append( - Step('VAR', step_def.name, *step_def.value, parent=scenario)) + scenario.steps.append(Step('VAR', step_def.name, *step_def.value, parent=scenario)) else: unsupported_step = Step(str(step_def), parent=scenario) unsupported_step.model_info['error'] = f"Robot construct not supported" @@ -206,11 +188,9 @@ def __generateRobotSuite(self, suite_model: Suite, target_suite: robot.model.Tes type='teardown') for step in tc.steps: if step.keyword == 'VAR': - new_tc.body.create_var( - step.posnom_args_str[0], step.posnom_args_str[1:]) + new_tc.body.create_var(step.posnom_args_str[0], step.posnom_args_str[1:]) else: - new_tc.body.create_keyword( - name=step.keyword, assign=step.assign, args=step.posnom_args_str) + new_tc.body.create_keyword(name=step.keyword, assign=step.assign, args=step.posnom_args_str) def _start_suite(self, suite: Suite | None, result): self.current_suite = suite diff --git a/robotmbt/tracestate.py b/robotmbt/tracestate.py index 770b0b43..4161662e 100644 --- a/robotmbt/tracestate.py +++ b/robotmbt/tracestate.py @@ -33,30 +33,28 @@ from robotmbt.modelspace import ModelSpace from robotmbt.suitedata import Scenario + class TraceSnapShot: - def __init__(self, id: str, inserted_scenario: Scenario | str, model_state: ModelSpace, remainder: Scenario | None = None, drought: int = 0): + def __init__(self, id: str, inserted_scenario: Scenario, model_state: ModelSpace, + remainder: Scenario | None = None, drought: int = 0): self.id: str = id - self.scenario: str | Scenario = inserted_scenario + self.scenario: Scenario = inserted_scenario self.remainder: Scenario | None = remainder self._model: ModelSpace = model_state.copy() self.coverage_drought: int = drought @property - def model(self): + def model(self) -> ModelSpace: return self._model.copy() class TraceState: def __init__(self, scenario_indexes: list[int]): - self.c_pool = {index: 0 for index in scenario_indexes} + self.c_pool: dict[int, int] = {index: 0 for index in scenario_indexes} if len(self.c_pool) != len(scenario_indexes): raise ValueError("Scenarios must be uniquely identifiable") - - # Keeps track of the scenarios already tried at each step in the trace - self._tried: list[list[int]] = [[]] - - # Keeps details for elements in trace - self._snapshots: list[TraceSnapShot] = [] + self._tried: list[list[int]] = [[]] # Keeps track of the scenarios already tried at each step in the trace + self._snapshots: list[TraceSnapShot] = [] # Keeps details for elements in trace self._open_refinements: list[int] = [] @property @@ -85,10 +83,10 @@ def active_refinements(self): def coverage_reached(self): return all(self.c_pool.values()) - def get_trace(self) -> list[str | Scenario]: + def get_trace(self) -> list[Scenario]: return [snap.scenario for snap in self._snapshots] - def next_candidate(self, retry: bool=False): + def next_candidate(self, retry: bool = False): for i in self.c_pool: if i not in self._tried[-1] and not self.is_refinement_active(i) and self.count(i) == 0: return i @@ -113,14 +111,14 @@ def highest_part(self, index: int) -> int: Given the current trace and an index, returns the highest part number of an ongoing refinement for the related scenario. Returns 0 when there is no refinement active. """ - for i in range(1, len(self.id_trace)+1): + for i in range(1, len(self.id_trace) + 1): if self.id_trace[-i] == f'{index}': return 0 if self.id_trace[-i].startswith(f'{index}.'): return int(self.id_trace[-i].split('.')[1]) return 0 - def is_refinement_active(self, index: int = None) -> bool: + def is_refinement_active(self, index: int | None = None) -> bool: """ When called with an index, returns True if that scenario is currently being refined When index is ommitted, return True if any refinement is active @@ -130,21 +128,21 @@ def is_refinement_active(self, index: int = None) -> bool: else: return self.highest_part(index) != 0 - def get_remainder(self, index: int): + def get_remainder(self, index: int) -> Scenario | None: """ When pushing a partial scenario, the remainder can be passed along for safe keeping. This method retrieves the remainder for the last part that was pushed. """ last_part = self.highest_part(index) - index = -self.id_trace[::-1].index(f'{index}.{last_part}')-1 + index = -self.id_trace[::-1].index(f'{index}.{last_part}') - 1 return self._snapshots[index].remainder def reject_scenario(self, i_scenario: int): """Trying a scenario excludes it from further cadidacy on this level""" self._tried[-1].append(i_scenario) - def confirm_full_scenario(self, index: int, scenario: Scenario | str, model: ModelSpace): - c_drought = 0 if self.c_pool[index] == 0 else self.coverage_drought+1 + def confirm_full_scenario(self, index: int, scenario: Scenario, model: ModelSpace): + c_drought = 0 if self.c_pool[index] == 0 else self.coverage_drought + 1 self.c_pool[index] += 1 if self.is_refinement_active(index): id = f"{index}.0" @@ -155,10 +153,9 @@ def confirm_full_scenario(self, index: int, scenario: Scenario | str, model: Mod self._tried.append([]) self._snapshots.append(TraceSnapShot(id, scenario, model, drought=c_drought)) - def push_partial_scenario(self, index: int, scenario: Scenario | str, model: ModelSpace, remainder=None): + def push_partial_scenario(self, index: int, scenario: Scenario, model: ModelSpace, remainder=None): if self.is_refinement_active(index): id = f"{index}.{self.highest_part(index) + 1}" - else: id = f"{index}.1" self._tried[-1].append(index) diff --git a/robotmbt/visualise/networkvisualiser.py b/robotmbt/visualise/networkvisualiser.py index f2dd4ff8..a97eeca4 100644 --- a/robotmbt/visualise/networkvisualiser.py +++ b/robotmbt/visualise/networkvisualiser.py @@ -204,13 +204,13 @@ def _create_layout(self) -> tuple[list[Node], list[Edge]]: # Feed the info to grandalf and get the layout. g = GGraph(vertices, edges) sugiyama = SugiyamaLayout(g.C[0]) - + # Set specific margins as these values worked best in user-testing sugiyama.xspace = 10 sugiyama.yspace = 15 sugiyama.dw = 2 sugiyama.dh = 2 - + sugiyama.init_all(roots=[start], inverted_edges=flips) sugiyama.draw() @@ -562,6 +562,7 @@ def _add_node_to_sources(node: Node, final_trace: list[str], node_source: Column node_label_source.data['y'].append(node.y) node_label_source.data['label'].append(node.label) + def _calculate_dimensions(label: str) -> tuple[float, float]: """ Calculate a node's dimensions based on its label and the given font size constant. diff --git a/robotmbt/visualise/visualiser.py b/robotmbt/visualise/visualiser.py index 92d9e30c..efb1bf6e 100644 --- a/robotmbt/visualise/visualiser.py +++ b/robotmbt/visualise/visualiser.py @@ -11,6 +11,15 @@ from robotmbt.visualise.models import TraceInfo, StateInfo, ScenarioInfo import html +GRAPHS = { + 'scenario': ScenarioGraph, + 'state': StateGraph, + 'scenario-state': ScenarioStateGraph, + 'delta-value': DeltaValueGraph, + 'scenario-delta-value': ScenarioDeltaValueGraph, + 'reduced-sdv': ReducedSDVGraph, +} + class Visualiser: """ @@ -25,14 +34,17 @@ def construct(cls, graph_type: str): # just calls __init__, but without having underscores etc. return cls(graph_type) - def __init__(self, graph_type: str, suite_name: str = "", export: str = '', - trace_info: TraceInfo = None): + def __init__(self, graph_type: str, suite_name: str = "", export: str = '', trace_info: TraceInfo = None): + if not export and not graph_type in GRAPHS.keys(): + raise ValueError(f"Unknown graph type: {graph_type}") self.graph_type: str = graph_type + if trace_info is None: self.trace_info: TraceInfo = TraceInfo() else: self.trace_info = trace_info + self.suite_name = suite_name self.export = export @@ -67,32 +79,25 @@ def update_trace(self, trace: TraceState): self.trace_info.update_trace(ScenarioInfo(scenario), StateInfo(model), trace_len) def _get_graph(self) -> AbstractGraph | None: - match self.graph_type: - case 'scenario': - return ScenarioGraph(self.trace_info) - case 'state': - return StateGraph(self.trace_info) - case 'scenario-state': - return ScenarioStateGraph(self.trace_info) - case 'delta-value': - return DeltaValueGraph(self.trace_info) - case 'scenario-delta-value': - return ScenarioDeltaValueGraph(self.trace_info) - case 'recuded-sdv': - return ReducedSDVGraph(self.trace_info) - - return None - - def generate_visualisation(self) -> str: + if self.graph_type not in GRAPHS.keys(): + return None + + return GRAPHS[self.graph_type](self.trace_info) + + def generate_visualisation(self) -> tuple[str, bool]: + """ + Finalize the visualisation. Exports the graph to JSON if requested, and generates HTML if requested. + The boolean signals whether the output is in HTML format or not. + """ if self.export: self.trace_info.export_graph(self.suite_name, self.export) graph: AbstractGraph = self._get_graph() - if graph is None: - if not self.export: - raise ValueError(f"Unknown graph type: {self.graph_type}") - return + if graph is None and self.export: + return f"Successfully exported to {self.export}!", False + elif graph is None: + raise ValueError(f"Unknown graph type: {self.graph_type}") html_bokeh = networkvisualiser.NetworkVisualiser(graph, self.suite_name).generate_html() - return f'' + return f'', True diff --git a/run_tests.py b/run_tests.py index 3eb8bba0..5387e5c8 100644 --- a/run_tests.py +++ b/run_tests.py @@ -40,7 +40,7 @@ # Adding the robotframeworkMBT folder to the python path forces the development # version to be used instead of the one installed on your system. You will also # need to add this path to your IDE options when running from there. - exit_code: int = robot.run_cli(['--outputdir', OUTPUT_ROOT, # type: ignore + exit_code: int = robot.run_cli(['--outputdir', OUTPUT_ROOT, '--pythonpath', THIS_DIR] + sys.argv[1:], exit=False) if utest: diff --git a/utest/test_steparguments.py b/utest/test_steparguments.py index 6c906ca6..5c73903f 100644 --- a/utest/test_steparguments.py +++ b/utest/test_steparguments.py @@ -31,7 +31,7 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import unittest -from robotmbt.steparguments import StepArgument, StepArguments +from robotmbt.steparguments import StepArgument, StepArguments, ArgKind class TestStepArgument(unittest.TestCase): @@ -94,7 +94,7 @@ def test_is_default_property(self): self.assertFalse(arg2.is_default) def test_copies_are_the_same(self): - arg1 = StepArgument('foo', 7, kind=StepArgument.NAMED, is_default=True) + arg1 = StepArgument('foo', 7, kind=ArgKind.NAMED, is_default=True) arg2 = arg1.copy() self.assertEqual(arg1.arg, arg2.arg) self.assertEqual(arg1.value, arg2.value) @@ -107,7 +107,7 @@ def test_copies_are_the_same(self): self.assertEqual(arg2.arg, '${foo}') self.assertEqual(arg2.value, 8) self.assertEqual(arg2.org_value, 7) - self.assertEqual(arg2.kind, StepArgument.NAMED) + self.assertEqual(arg2.kind, ArgKind.NAMED) self.assertEqual(arg2.is_default, False) def test_original_value_is_kept_when_copying(self): @@ -118,11 +118,11 @@ def test_original_value_is_kept_when_copying(self): self.assertEqual(arg2.value, 8) def test_copies_are_independent(self): - arg1 = StepArgument('foo', 7, StepArgument.POSITIONAL) + arg1 = StepArgument('foo', 7, ArgKind.POSITIONAL) arg1.value = 8 arg2 = arg1.copy() arg2.value = 13 - arg2.kind = StepArgument.NAMED + arg2.kind = ArgKind.NAMED self.assertEqual(arg2.value, 13) self.assertEqual(arg1.value, 8) self.assertEqual(arg1.org_value, arg2.org_value) diff --git a/utest/test_substitutionmap.py b/utest/test_substitutionmap.py index eb6df944..b8e78a47 100644 --- a/utest/test_substitutionmap.py +++ b/utest/test_substitutionmap.py @@ -355,8 +355,8 @@ def test_adding_constraint_does_not_affect_undo_remove_stack(self): c.remove_option('four') c.add_constraint(['one', 'two']) self.assertCountEqual(c.optionset, ['two']) - # four was never in there, so isn't added, and three c.undo_remove() + # four was never in there, so isn't added, and three # was removed by adding a constraint and is ignored. self.assertCountEqual(c.optionset, ['two']) c.undo_remove() diff --git a/utest/test_suitedata.py b/utest/test_suitedata.py index ab9bb152..2623703a 100644 --- a/utest/test_suitedata.py +++ b/utest/test_suitedata.py @@ -33,6 +33,7 @@ import unittest from unittest.mock import patch +from enum import Enum, auto from types import SimpleNamespace from robotmbt.suitedata import Suite, Scenario, Step @@ -285,16 +286,25 @@ def test_copies_are_independent(self): def test_exteranally_determined_attributes_are_copied_along(self): self.scenario.src_id = 7 - class Dummy: + class SubstitutionMap: def copy(self): return 'dummy' - self.scenario.data_choices = Dummy() + self.scenario.data_choices = SubstitutionMap() dup = self.scenario.copy() self.assertEqual(dup.src_id, self.scenario.src_id) self.assertEqual(dup.data_choices, 'dummy') +class ArgKind(Enum): + EMBEDDED = auto() + POSITIONAL = auto() + VAR_POS = auto() + NAMED = auto() + FREE_NAMED = auto() + + @patch('robotmbt.suitedata.ArgumentValidator') +@patch('robotmbt.suitedata.ArgKind', new=ArgKind) class TestSteps(unittest.TestCase): def setUp(self): self.steps = self.create_steps() @@ -397,33 +407,37 @@ def test_return_value_multi_assignment_is_part_of_the_full_keyword_text(self, mo def test_argument_with_default_is_omitted_from_keyword_when_not_mentioned_named(self, mock): step = Step(RobotKwStub.STEPTEXT, 'posA', 'pos2=posB', parent=None) - step.args = StubStepArguments([StubArgument(name='pos1', value='posA', is_default=False, kind='POSITIONAL'), - StubArgument(name='pos2', value='posB', is_default=False, kind='NAMED'), - StubArgument(name='named1', value='namedA', is_default=True, kind='NAMED')]) + step.args = StubStepArguments( + [StubArgument(name='pos1', value='posA', is_default=False, kind=ArgKind.POSITIONAL), + StubArgument(name='pos2', value='posB', is_default=False, kind=ArgKind.NAMED), + StubArgument(name='named1', value='namedA', is_default=True, kind=ArgKind.NAMED)]) self.assertTupleEqual(step.posnom_args_str, ('posA', 'pos2=posB')) self.assertEqual(step.full_keyword, f"{RobotKwStub.STEPTEXT} posA pos2=posB") def test_argument_with_default_is_omitted_from_keyword_when_not_mentioned_positional(self, mock): step = Step(RobotKwStub.STEPTEXT, 'posA', 'posB', parent=None) - step.args = StubStepArguments([StubArgument(name='pos1', value='posA', is_default=False, kind='POSITIONAL'), - StubArgument(name='pos2', value='posB', is_default=False, kind='POSITIONAL'), - StubArgument(name='named1', value='namedA', is_default=True, kind='POSITIONAL')]) + step.args = StubStepArguments( + [StubArgument(name='pos1', value='posA', is_default=False, kind=ArgKind.POSITIONAL), + StubArgument(name='pos2', value='posB', is_default=False, kind=ArgKind.POSITIONAL), + StubArgument(name='named1', value='namedA', is_default=True, kind=ArgKind.POSITIONAL)]) self.assertTupleEqual(step.posnom_args_str, ('posA', 'posB')) self.assertEqual(step.full_keyword, f"{RobotKwStub.STEPTEXT} posA posB") def test_argument_with_default_is_included_in_keyword_when_mentioned_named(self, mock): step = Step(RobotKwStub.STEPTEXT, 'posA', 'pos2=posB', 'named1=namedA', parent=None) - step.args = StubStepArguments([StubArgument(name='pos1', value='posA', is_default=False, kind='POSITIONAL'), - StubArgument(name='pos2', value='posB', is_default=False, kind='NAMED'), - StubArgument(name='named1', value='namedA', is_default=False, kind='NAMED')]) + step.args = StubStepArguments( + [StubArgument(name='pos1', value='posA', is_default=False, kind=ArgKind.POSITIONAL), + StubArgument(name='pos2', value='posB', is_default=False, kind=ArgKind.NAMED), + StubArgument(name='named1', value='namedA', is_default=False, kind=ArgKind.NAMED)]) self.assertTupleEqual(step.posnom_args_str, ('posA', 'pos2=posB', 'named1=namedA')) self.assertEqual(step.full_keyword, f"{RobotKwStub.STEPTEXT} posA pos2=posB named1=namedA") def test_argument_with_default_is_included_in_keyword_when_mentioned_positional(self, mock): step = Step(RobotKwStub.STEPTEXT, 'posA', 'posB', 'namedA', parent=None) - step.args = StubStepArguments([StubArgument(name='pos1', value='posA', is_default=False, kind='POSITIONAL'), - StubArgument(name='pos2', value='posB', is_default=False, kind='POSITIONAL'), - StubArgument(name='named1', value='namedA', is_default=False, kind='POSITIONAL')]) + step.args = StubStepArguments( + [StubArgument(name='pos1', value='posA', is_default=False, kind=ArgKind.POSITIONAL), + StubArgument(name='pos2', value='posB', is_default=False, kind=ArgKind.POSITIONAL), + StubArgument(name='named1', value='namedA', is_default=False, kind=ArgKind.POSITIONAL)]) self.assertTupleEqual(step.posnom_args_str, ('posA', 'posB', 'namedA')) self.assertEqual(step.full_keyword, f"{RobotKwStub.STEPTEXT} posA posB namedA") @@ -482,24 +496,7 @@ class StubStepArguments(list): class StubArgument(SimpleNamespace): - EMBEDDED = 'EMBEDDED' - POSITIONAL = 'POSITIONAL' - VAR_POS = 'VAR_POS' - NAMED = 'NAMED' - FREE_NAMED = 'FREE_NAMED' - - - -class StubStepArguments(list): - modified = True # trigger modified status to get arguments processed, rather then just echoed - - -class StubArgument(SimpleNamespace): - EMBEDDED = 'EMBEDDED' - POSITIONAL = 'POSITIONAL' - VAR_POS = 'VAR_POS' - NAMED = 'NAMED' - FREE_NAMED = 'FREE_NAMED' + pass if __name__ == '__main__': diff --git a/utest/test_tracestate.py b/utest/test_tracestate.py index 77c1462a..29cb29ae 100644 --- a/utest/test_tracestate.py +++ b/utest/test_tracestate.py @@ -46,14 +46,14 @@ def test_completing_single_size_trace(self): ts = TraceState([1]) self.assertEqual(ts.next_candidate(), 1) self.assertIs(ts.coverage_reached(), False) - ts.confirm_full_scenario(1, 'one', {}) + ts.confirm_full_scenario(1, ScenarioStub('one'), ModelStub()) self.assertIs(ts.coverage_reached(), True) self.assertEqual(ts.get_trace(), ['one']) def test_confirming_excludes_scenario_from_candidacy(self): ts = TraceState([1]) self.assertEqual(ts.next_candidate(), 1) - ts.confirm_full_scenario(1, 'one', {}) + ts.confirm_full_scenario(1, ScenarioStub('one'), ModelStub()) self.assertIs(ts.next_candidate(), None) def test_trying_excludes_scenario_from_candidacy(self): @@ -65,7 +65,7 @@ def test_trying_excludes_scenario_from_candidacy(self): def test_scenario_still_excluded_from_candidacy_after_rewind(self): ts = TraceState([1]) self.assertEqual(ts.next_candidate(), 1) - ts.confirm_full_scenario(1, 'one', {}) + ts.confirm_full_scenario(1, ScenarioStub('one'), ModelStub()) ts.rewind() self.assertIs(ts.next_candidate(), None) @@ -74,7 +74,7 @@ def test_candidates_come_in_order_when_accepted(self): candidates = [] for _ in range(3): candidates.append(ts.next_candidate()) - ts.confirm_full_scenario(candidates[-1], 'scenario', {}) + ts.confirm_full_scenario(candidates[-1], ScenarioStub(), ModelStub()) candidates.append(ts.next_candidate()) self.assertEqual(candidates, [10, 20, 30, None]) @@ -96,30 +96,30 @@ def test_rejected_scenarios_are_candidates_for_new_positions(self): ts.reject_scenario(1) for _ in range(3): candidates.append(ts.next_candidate()) - ts.confirm_full_scenario(candidates[-1], 'scenario', {}) + ts.confirm_full_scenario(candidates[-1], ScenarioStub(), ModelStub()) candidates.append(ts.next_candidate()) self.assertEqual(candidates, [2, 1, 3, None]) def test_previously_confirmed_scenarios_can_be_retried_if_no_new_candidates_exist(self): ts = TraceState(range(3)) first_candidate = ts.next_candidate(retry=True) - ts.confirm_full_scenario(first_candidate, 'one', {}) + ts.confirm_full_scenario(first_candidate, ScenarioStub('one'), ModelStub()) ts.reject_scenario(ts.next_candidate(retry=True)) ts.reject_scenario(ts.next_candidate(retry=True)) retry_candidate = ts.next_candidate(retry=True) self.assertEqual(first_candidate, retry_candidate) - ts.confirm_full_scenario(retry_candidate, 'one', {}) - ts.confirm_full_scenario(ts.next_candidate(retry=True), 'two', {}) + ts.confirm_full_scenario(retry_candidate, ScenarioStub('one'), ModelStub()) + ts.confirm_full_scenario(ts.next_candidate(retry=True), ScenarioStub('two'), ModelStub()) self.assertFalse(ts.coverage_reached()) - ts.confirm_full_scenario(ts.next_candidate(retry=True), 'three', {}) + ts.confirm_full_scenario(ts.next_candidate(retry=True), ScenarioStub('three'), ModelStub()) self.assertTrue(ts.coverage_reached()) self.assertEqual(ts.get_trace(), ['one', 'one', 'two', 'three']) def test_retry_can_continue_once_coverage_is_reached(self): ts = TraceState([1, 2, 3]) - ts.confirm_full_scenario(ts.next_candidate(retry=True), 'one', {}) - ts.confirm_full_scenario(ts.next_candidate(retry=True), 'two', {}) - ts.confirm_full_scenario(ts.next_candidate(retry=True), 'three', {}) + ts.confirm_full_scenario(ts.next_candidate(retry=True), ScenarioStub('one'), ModelStub()) + ts.confirm_full_scenario(ts.next_candidate(retry=True), ScenarioStub('two'), ModelStub()) + ts.confirm_full_scenario(ts.next_candidate(retry=True), ScenarioStub('three'), ModelStub()) self.assertTrue(ts.coverage_reached()) self.assertEqual(ts.next_candidate(retry=True), 1) ts.reject_scenario(1) @@ -130,14 +130,14 @@ def test_count_scenario_repetitions(self): ts = TraceState([1, 2]) first = ts.next_candidate() self.assertEqual(ts.count(first), 0) - ts.confirm_full_scenario(first, 'one', {}) + ts.confirm_full_scenario(first, ScenarioStub('one'), ModelStub()) self.assertEqual(ts.count(first), 1) - ts.confirm_full_scenario(first, 'one', {}) + ts.confirm_full_scenario(first, ScenarioStub('one'), ModelStub()) self.assertEqual(ts.count(first), 2) def test_rewind_single_available_scenario(self): ts = TraceState([1]) - ts.confirm_full_scenario(1, 'one', {}) + ts.confirm_full_scenario(1, ScenarioStub('one'), ModelStub()) self.assertIs(ts.coverage_reached(), True) self.assertIs(ts.can_rewind(), True) ts.rewind() @@ -148,21 +148,21 @@ def test_rewind_single_available_scenario(self): def test_rewind_returns_none_after_rewinding_last_step(self): ts = TraceState([1]) - ts.confirm_full_scenario(1, 'one', {}) + ts.confirm_full_scenario(1, ScenarioStub('one'), ModelStub()) self.assertIs(ts.rewind(), None) def test_traces_can_have_multiple_scenarios(self): ts = TraceState([1, 2]) - ts.confirm_full_scenario(1, 'foo', dict(a=1)) + ts.confirm_full_scenario(1, ScenarioStub('foo'), ModelStub(a=1)) self.assertIs(ts.coverage_reached(), False) - ts.confirm_full_scenario(2, 'bar', dict(b=2)) + ts.confirm_full_scenario(2, ScenarioStub('bar'), ModelStub(b=2)) self.assertIs(ts.coverage_reached(), True) self.assertEqual(ts.get_trace(), ['foo', 'bar']) def test_rewind_returns_snapshot_of_the_step_before(self): ts = TraceState([1, 2]) - ts.confirm_full_scenario(1, 'foo', dict(a=1)) - ts.confirm_full_scenario(2, 'bar', dict(b=2)) + ts.confirm_full_scenario(1, ScenarioStub('foo'), ModelStub(a=1)) + ts.confirm_full_scenario(2, ScenarioStub('bar'), ModelStub(b=2)) tail = ts.rewind() self.assertEqual(tail.id, '1') self.assertEqual(tail.scenario, 'foo') @@ -170,32 +170,32 @@ def test_rewind_returns_snapshot_of_the_step_before(self): def test_completing_size_three_trace(self): ts = TraceState(range(3)) - ts.confirm_full_scenario(ts.next_candidate(), 'one', {}) - ts.confirm_full_scenario(ts.next_candidate(), 'two', {}) + ts.confirm_full_scenario(ts.next_candidate(), ScenarioStub('one'), ModelStub()) + ts.confirm_full_scenario(ts.next_candidate(), ScenarioStub('two'), ModelStub()) self.assertIs(ts.coverage_reached(), False) - ts.confirm_full_scenario(ts.next_candidate(), 'three', {}) + ts.confirm_full_scenario(ts.next_candidate(), ScenarioStub('three'), ModelStub()) self.assertIs(ts.coverage_reached(), True) self.assertEqual(ts.get_trace(), ['one', 'two', 'three']) def test_completing_size_three_trace_after_reject(self): ts = TraceState(range(3)) first = ts.next_candidate() - ts.confirm_full_scenario(first, first, {}) + ts.confirm_full_scenario(first, ScenarioStub('one'), ModelStub()) rejected = ts.next_candidate() ts.reject_scenario(rejected) third = ts.next_candidate() - ts.confirm_full_scenario(third, third, {}) + ts.confirm_full_scenario(third, ScenarioStub('three'), ModelStub()) self.assertIs(ts.coverage_reached(), False) second = ts.next_candidate() self.assertEqual(rejected, second) - ts.confirm_full_scenario(second, second, {}) + ts.confirm_full_scenario(second, ScenarioStub('two'), ModelStub()) self.assertIs(ts.coverage_reached(), True) - self.assertEqual(ts.get_trace(), [first, third, second]) + self.assertEqual(ts.get_trace(), ['one', 'three', 'two']) def test_completing_size_three_trace_after_rewind(self): ts = TraceState(range(3)) first = ts.next_candidate() - ts.confirm_full_scenario(first, first, {}) + ts.confirm_full_scenario(first, ScenarioStub('one'), ModelStub()) reject2 = ts.next_candidate() ts.reject_scenario(reject2) reject3 = ts.next_candidate() @@ -205,13 +205,13 @@ def test_completing_size_three_trace_after_rewind(self): self.assertEqual(len(ts.get_trace()), 0) retry_first = ts.next_candidate() self.assertNotEqual(first, retry_first) - ts.confirm_full_scenario(retry_first, retry_first, {}) + ts.confirm_full_scenario(retry_first, ScenarioStub('two'), ModelStub()) retry_second = ts.next_candidate() - ts.confirm_full_scenario(retry_second, retry_second, {}) + ts.confirm_full_scenario(retry_second, ScenarioStub('one'), ModelStub()) retry_third = ts.next_candidate() - ts.confirm_full_scenario(retry_third, retry_third, {}) + ts.confirm_full_scenario(retry_third, ScenarioStub('three'), ModelStub()) self.assertIs(ts.coverage_reached(), True) - self.assertEqual(ts.get_trace(), [retry_first, retry_second, retry_third]) + self.assertEqual(ts.get_trace(), ['two', 'one', 'three']) def test_highest_part_when_index_not_present(self): ts = TraceState([1]) @@ -219,13 +219,13 @@ def test_highest_part_when_index_not_present(self): def test_highest_part_for_non_partial_sceanrio(self): ts = TraceState([1]) - ts.confirm_full_scenario(1, 'one', {}) + ts.confirm_full_scenario(1, ScenarioStub(), ModelStub()) self.assertEqual(ts.highest_part(1), 0) def test_model_property_takes_model_from_tail(self): ts = TraceState(range(2)) - ts.confirm_full_scenario(ts.next_candidate(), 'one', dict(a=1)) - ts.confirm_full_scenario(ts.next_candidate(), 'two', dict(b=2)) + ts.confirm_full_scenario(ts.next_candidate(), ScenarioStub('one'), ModelStub(a=1)) + ts.confirm_full_scenario(ts.next_candidate(), ScenarioStub('two'), ModelStub(b=2)) self.assertEqual(ts.model, dict(b=2)) ts.rewind() self.assertEqual(ts.model, dict(a=1)) @@ -233,7 +233,7 @@ def test_model_property_takes_model_from_tail(self): def test_no_model_from_empty_trace(self): ts = TraceState([1]) self.assertIs(ts.model, None) - ts.confirm_full_scenario(1, 'one', {}) + ts.confirm_full_scenario(1, ScenarioStub('one'), ModelStub()) self.assertIsNotNone(ts.model) ts.rewind() self.assertIs(ts.model, None) @@ -249,16 +249,16 @@ def test_rejected_scenarios_are_tried(self): def test_confirmed_scenario_is_tried_and_triggers_next_step(self): ts = TraceState([1]) - ts.confirm_full_scenario(1, 'one', {}) + ts.confirm_full_scenario(1, ScenarioStub('one'), ModelStub()) self.assertEqual(ts.tried, ()) ts.rewind() self.assertEqual(ts.tried, (1,)) def test_can_iterate_over_tracestate_snapshots(self): ts = TraceState([1, 2, 3]) - ts.confirm_full_scenario(ts.next_candidate(), 'one', dict(a=1)) - ts.confirm_full_scenario(ts.next_candidate(), 'two', dict(b=2)) - ts.confirm_full_scenario(ts.next_candidate(), 'three', dict(c=3)) + ts.confirm_full_scenario(ts.next_candidate(), ScenarioStub('one'), ModelStub(a=1)) + ts.confirm_full_scenario(ts.next_candidate(), ScenarioStub('two'), ModelStub(b=2)) + ts.confirm_full_scenario(ts.next_candidate(), ScenarioStub('three'), ModelStub(c=3)) for act, exp in zip(ts, ['1', '2', '3']): self.assertEqual(act.id, exp) for act, exp in zip(ts, ['one', 'two', 'three']): @@ -268,9 +268,9 @@ def test_can_iterate_over_tracestate_snapshots(self): def test_can_index_tracestate_snapshots(self): ts = TraceState([1, 2, 3]) - ts.confirm_full_scenario(ts.next_candidate(), 'one', dict(a=1)) - ts.confirm_full_scenario(ts.next_candidate(), 'two', dict(b=2)) - ts.confirm_full_scenario(ts.next_candidate(), 'three', dict(c=3)) + ts.confirm_full_scenario(ts.next_candidate(), ScenarioStub('one'), ModelStub(a=1)) + ts.confirm_full_scenario(ts.next_candidate(), ScenarioStub('two'), ModelStub(b=2)) + ts.confirm_full_scenario(ts.next_candidate(), ScenarioStub('three'), ModelStub(c=3)) self.assertEqual(ts[0].id, '1') self.assertEqual(ts[1].scenario, 'two') self.assertEqual(ts[2].model, dict(c=3)) @@ -279,38 +279,38 @@ def test_can_index_tracestate_snapshots(self): def test_adding_coverage_prevents_drought(self): ts = TraceState(range(3)) - ts.confirm_full_scenario(ts.next_candidate(), 'one', {}) + ts.confirm_full_scenario(ts.next_candidate(), ScenarioStub('one'), ModelStub()) self.assertEqual(ts.coverage_drought, 0) - ts.confirm_full_scenario(ts.next_candidate(), 'two', {}) + ts.confirm_full_scenario(ts.next_candidate(), ScenarioStub('two'), ModelStub()) self.assertEqual(ts.coverage_drought, 0) - ts.confirm_full_scenario(ts.next_candidate(), 'three', {}) + ts.confirm_full_scenario(ts.next_candidate(), ScenarioStub('three'), ModelStub()) self.assertEqual(ts.coverage_drought, 0) def test_repeated_scenarios_increases_drought(self): ts = TraceState([1, 2]) - ts.confirm_full_scenario(1, 'one', {}) + ts.confirm_full_scenario(1, ScenarioStub('one'), ModelStub()) self.assertEqual(ts.coverage_drought, 0) - ts.confirm_full_scenario(1, 'one', {}) + ts.confirm_full_scenario(1, ScenarioStub('one'), ModelStub()) self.assertEqual(ts.coverage_drought, 1) - ts.confirm_full_scenario(1, 'one', {}) + ts.confirm_full_scenario(1, ScenarioStub('one'), ModelStub()) self.assertEqual(ts.coverage_drought, 2) def test_drought_is_reset_with_new_coverage(self): ts = TraceState([1, 2]) - ts.confirm_full_scenario(1, 'one', {}) + ts.confirm_full_scenario(1, ScenarioStub('one'), ModelStub()) self.assertEqual(ts.coverage_drought, 0) - ts.confirm_full_scenario(1, 'one', {}) + ts.confirm_full_scenario(1, ScenarioStub('one'), ModelStub()) self.assertEqual(ts.coverage_drought, 1) - ts.confirm_full_scenario(2, 'two', {}) + ts.confirm_full_scenario(2, ScenarioStub('two'), ModelStub()) self.assertEqual(ts.coverage_drought, 0) def test_rewind_includes_drought_update(self): ts = TraceState([1, 2]) - ts.confirm_full_scenario(1, 'one', {}) + ts.confirm_full_scenario(1, ScenarioStub('one'), ModelStub()) self.assertEqual(ts.coverage_drought, 0) - ts.confirm_full_scenario(1, 'one', {}) + ts.confirm_full_scenario(1, ScenarioStub('one'), ModelStub()) self.assertEqual(ts.coverage_drought, 1) - ts.confirm_full_scenario(2, 'two', {}) + ts.confirm_full_scenario(2, ScenarioStub('two'), ModelStub()) self.assertEqual(ts.coverage_drought, 0) ts.rewind() self.assertEqual(ts.coverage_drought, 1) @@ -321,29 +321,29 @@ def test_rewind_includes_drought_update(self): class TestPartialScenarios(unittest.TestCase): def test_push_partial_does_not_complete_coverage(self): ts = TraceState([1]) - ts.push_partial_scenario(1, 'part1', {}) + ts.push_partial_scenario(1, ScenarioStub('part1'), ModelStub()) self.assertEqual(ts.get_trace(), ['part1']) self.assertIs(ts.coverage_reached(), False) def test_confirm_full_after_push_partial_completes_coverage(self): ts = TraceState([1]) - ts.push_partial_scenario(1, 'part1', {}) + ts.push_partial_scenario(1, ScenarioStub('part1'), ModelStub()) self.assertIs(ts.coverage_reached(), False) - ts.push_partial_scenario(1, 'part2', {}) + ts.push_partial_scenario(1, ScenarioStub('part2'), ModelStub()) self.assertIs(ts.coverage_reached(), False) - ts.confirm_full_scenario(1, 'remainder', {}) + ts.confirm_full_scenario(1, ScenarioStub('remainder'), ModelStub()) self.assertEqual(ts.get_trace(), ['part1', 'part2', 'remainder']) self.assertIs(ts.coverage_reached(), True) def test_scenario_unavailble_once_pushed_partial(self): ts = TraceState([1]) candidate = ts.next_candidate() - ts.push_partial_scenario(candidate, 'part1', {}) + ts.push_partial_scenario(candidate, ScenarioStub('part1'), ModelStub()) self.assertIs(ts.next_candidate(), None) def test_rewind_of_single_part(self): ts = TraceState([1]) - ts.push_partial_scenario(1, 'part1', {}) + ts.push_partial_scenario(1, ScenarioStub('part1'), ModelStub()) self.assertEqual(ts.get_trace(), ['part1']) self.assertIs(ts.can_rewind(), True) ts.rewind() @@ -351,9 +351,9 @@ def test_rewind_of_single_part(self): def test_rewind_all_parts(self): ts = TraceState([1]) - ts.push_partial_scenario(1, 'part1', {}) + ts.push_partial_scenario(1, ScenarioStub('part1'), ModelStub()) self.assertIs(ts.coverage_reached(), False) - ts.push_partial_scenario(1, 'part2', {}) + ts.push_partial_scenario(1, ScenarioStub('part2'), ModelStub()) self.assertIs(ts.coverage_reached(), False) self.assertEqual(ts.get_trace(), ['part1', 'part2']) self.assertIs(ts.next_candidate(), None) @@ -368,14 +368,14 @@ def test_rewind_all_parts(self): def test_partial_scenario_still_excluded_from_candidacy_after_rewind(self): ts = TraceState([1]) self.assertEqual(ts.next_candidate(), 1) - ts.push_partial_scenario(1, 'part1', {}) + ts.push_partial_scenario(1, ScenarioStub('part1'), ModelStub()) ts.rewind() self.assertIs(ts.next_candidate(), None) def test_rewind_to_partial_scenario(self): ts = TraceState([1]) - ts.push_partial_scenario(1, 'part1', dict(a=1)) - ts.push_partial_scenario(1, 'part2', dict(b=2)) + ts.push_partial_scenario(1, ScenarioStub('part1'), ModelStub(a=1)) + ts.push_partial_scenario(1, ScenarioStub('part2'), ModelStub(b=2)) snapshot = ts.rewind() self.assertEqual(snapshot.id, '1.1') self.assertEqual(snapshot.scenario, 'part1') @@ -383,8 +383,8 @@ def test_rewind_to_partial_scenario(self): def test_rewind_last_part(self): ts = TraceState([1, 2]) - ts.confirm_full_scenario(1, 'one', dict(a=1)) - ts.push_partial_scenario(2, 'part1', dict(b=2)) + ts.confirm_full_scenario(1, ScenarioStub('one'), ModelStub(a=1)) + ts.push_partial_scenario(2, ScenarioStub('part1'), ModelStub(b=2)) snapshot = ts.rewind() self.assertEqual(ts.get_trace(), ['one']) self.assertEqual(snapshot.id, '1') @@ -393,9 +393,9 @@ def test_rewind_last_part(self): def test_rewind_all_parts_of_completed_scenario_at_once(self): ts = TraceState([1]) - ts.push_partial_scenario(1, 'part1', dict(a=1)) - ts.push_partial_scenario(1, 'part2', dict(b=2)) - ts.confirm_full_scenario(1, 'remainder', {}) + ts.push_partial_scenario(1, ScenarioStub('part1'), ModelStub(a=1)) + ts.push_partial_scenario(1, ScenarioStub('part2'), ModelStub(b=2)) + ts.confirm_full_scenario(1, ScenarioStub('remainder'), ModelStub()) tail = ts.rewind() self.assertEqual(ts.get_trace(), []) self.assertIs(ts.next_candidate(), None) @@ -403,11 +403,11 @@ def test_rewind_all_parts_of_completed_scenario_at_once(self): def test_tried_entries_after_rewind(self): ts = TraceState([1, 2, 10, 11, 12, 20, 21]) - ts.push_partial_scenario(1, 'part1', {}) + ts.push_partial_scenario(1, ScenarioStub('part1'), ModelStub()) ts.reject_scenario(10) ts.reject_scenario(11) - ts.confirm_full_scenario(2, 'two', {}) - ts.push_partial_scenario(1, 'part2', {}) + ts.confirm_full_scenario(2, ScenarioStub('two'), ModelStub()) + ts.push_partial_scenario(1, ScenarioStub('part2'), ModelStub()) ts.reject_scenario(20) ts.reject_scenario(21) self.assertEqual(ts.tried, (20, 21)) @@ -422,29 +422,29 @@ def test_tried_entries_after_rewind(self): def test_highest_part_after_first_part(self): ts = TraceState([1]) - ts.push_partial_scenario(1, 'part1', {}) + ts.push_partial_scenario(1, ScenarioStub('part1'), ModelStub()) self.assertEqual(ts[-1].id, '1.1') self.assertEqual(ts.highest_part(1), 1) def test_highest_part_after_multiple_parts(self): ts = TraceState([1]) - ts.push_partial_scenario(1, 'part1', {}) - ts.push_partial_scenario(1, 'part2', {}) + ts.push_partial_scenario(1, ScenarioStub('part1'), ModelStub()) + ts.push_partial_scenario(1, ScenarioStub('part2'), ModelStub()) self.assertEqual(ts[-1].id, '1.2') self.assertEqual(ts.highest_part(1), 2) def test_highest_part_after_completing_multiple_parts(self): ts = TraceState([1]) - ts.push_partial_scenario(1, 'part1', {}) - ts.push_partial_scenario(1, 'part2', {}) - ts.confirm_full_scenario(1, 'remainder', {}) + ts.push_partial_scenario(1, ScenarioStub('part1'), ModelStub()) + ts.push_partial_scenario(1, ScenarioStub('part2'), ModelStub()) + ts.confirm_full_scenario(1, ScenarioStub('remainder'), ModelStub()) self.assertEqual(ts[-1].id, '1.0') self.assertEqual(ts.highest_part(1), 0) def test_highest_part_after_partial_rewind(self): ts = TraceState([1]) - ts.push_partial_scenario(1, 'part1', {}) - ts.push_partial_scenario(1, 'part2', {}) + ts.push_partial_scenario(1, ScenarioStub('part1'), ModelStub()) + ts.push_partial_scenario(1, ScenarioStub('part2'), ModelStub()) self.assertEqual(ts.highest_part(1), 2) ts.rewind() self.assertEqual(ts.highest_part(1), 1) @@ -454,9 +454,9 @@ def test_highest_part_after_partial_rewind(self): def test_highest_part_is_0_when_no_refinement_is_ongoing(self): ts = TraceState([1]) self.assertEqual(ts.highest_part(1), 0) - ts.push_partial_scenario(1, 'part1', {}) - ts.push_partial_scenario(1, 'part2', {}) - ts.confirm_full_scenario(1, 'remainder', {}) + ts.push_partial_scenario(1, ScenarioStub('part1'), ModelStub()) + ts.push_partial_scenario(1, ScenarioStub('part2'), ModelStub()) + ts.confirm_full_scenario(1, ScenarioStub('remainder'), ModelStub()) self.assertEqual(ts.highest_part(1), 0) ts.rewind() self.assertEqual(ts.highest_part(1), 0) @@ -465,36 +465,36 @@ def test_count_scenario_repetitions_with_partials(self): ts = TraceState(range(2)) first = ts.next_candidate() self.assertEqual(ts.count(first), 0) - ts.confirm_full_scenario(first, 'full', {}) + ts.confirm_full_scenario(first, ScenarioStub('full'), ModelStub()) self.assertEqual(ts.count(first), 1) - ts.push_partial_scenario(first, 'part1', {}) - ts.push_partial_scenario(first, 'part2', {}) + ts.push_partial_scenario(first, ScenarioStub('part1'), ModelStub()) + ts.push_partial_scenario(first, ScenarioStub('part2'), ModelStub()) self.assertEqual(ts.count(first), 1) - ts.confirm_full_scenario(first, 'remainder', {}) + ts.confirm_full_scenario(first, ScenarioStub('remainder'), ModelStub()) self.assertEqual(ts.count(first), 2) second = ts.next_candidate() - ts.push_partial_scenario(second, 'part1', {}) + ts.push_partial_scenario(second, ScenarioStub('part1'), ModelStub()) self.assertEqual(ts.count(second), 0) - ts.push_partial_scenario(second, 'part2', {}) - ts.confirm_full_scenario(second, 'remainder', {}) + ts.push_partial_scenario(second, ScenarioStub('part2'), ModelStub()) + ts.confirm_full_scenario(second, ScenarioStub('remainder'), ModelStub()) self.assertEqual(ts.count(second), 1) def test_partial_scenario_is_tried_without_finishing(self): ts = TraceState([1]) - ts.push_partial_scenario(1, 'part1', {}) + ts.push_partial_scenario(1, ScenarioStub('part1'), ModelStub()) self.assertEqual(ts.tried, ()) ts.rewind() self.assertEqual(ts.tried, (1,)) def test_get_last_snapshot_by_index(self): ts = TraceState([1]) - ts.push_partial_scenario(1, 'part1', dict(a=1)) + ts.push_partial_scenario(1, ScenarioStub('part1'), ModelStub(a=1)) self.assertEqual(ts[-1].id, '1.1') self.assertEqual(ts[-1].scenario, 'part1') self.assertEqual(ts[-1].model, dict(a=1)) self.assertEqual(ts[-1].coverage_drought, 0) - ts.push_partial_scenario(1, 'part2', dict(b=2)) - ts.confirm_full_scenario(1, 'remainder', dict(c=3)) + ts.push_partial_scenario(1, ScenarioStub('part2'), ModelStub(b=2)) + ts.confirm_full_scenario(1, ScenarioStub('remainder'), ModelStub(c=3)) self.assertEqual(ts[-1].id, '1.0') self.assertEqual(ts[-1].scenario, 'remainder') self.assertEqual(ts[-1].model, dict(c=3)) @@ -502,16 +502,24 @@ def test_get_last_snapshot_by_index(self): def test_only_completed_scenarios_affect_drought(self): ts = TraceState([1, 2]) - ts.confirm_full_scenario(1, 'one full', {}) - ts.push_partial_scenario(1, 'one part1', {}) + ts.confirm_full_scenario(1, ScenarioStub('one full'), ModelStub()) + ts.push_partial_scenario(1, ScenarioStub('one part1'), ModelStub()) self.assertEqual(ts.coverage_drought, 0) - ts.confirm_full_scenario(1, 'one remainder', {}) + ts.confirm_full_scenario(1, ScenarioStub('one remainder'), ModelStub()) self.assertEqual(ts.coverage_drought, 1) - ts.push_partial_scenario(2, 'two part1', {}) + ts.push_partial_scenario(2, ScenarioStub('two part1'), ModelStub()) self.assertEqual(ts.coverage_drought, 1) - ts.confirm_full_scenario(2, 'two remainder', {}) + ts.confirm_full_scenario(2, ScenarioStub('two remainder'), ModelStub()) self.assertEqual(ts.coverage_drought, 0) +class ScenarioStub(str): + """Stub for suitedata.Scenario""" + + +class ModelStub(dict): + """Stub for modelspace.ModelSpace""" + + if __name__ == '__main__': unittest.main() From 9309ff86f36aa12ef4de9992e270037a85fa4cf2 Mon Sep 17 00:00:00 2001 From: Thomas Kas Date: Fri, 23 Jan 2026 17:02:13 +0100 Subject: [PATCH 054/131] Tooltips (#68) * Add tooltip with full state * Tooltip in contribution info * Formatting * Only show tooltip for nodes, not labels * Rework description selection * Update documentation --- CONTRIBUTING.md | 19 +++++++-- robotmbt/visualise/graphs/abstractgraph.py | 39 +++++++++++++------ robotmbt/visualise/graphs/deltavaluegraph.py | 14 +++++-- robotmbt/visualise/graphs/reducedSDVgraph.py | 15 +++++-- .../graphs/scenariodeltavaluegraph.py | 14 +++++-- robotmbt/visualise/graphs/scenariograph.py | 12 +++++- .../visualise/graphs/scenariostategraph.py | 12 +++++- robotmbt/visualise/graphs/stategraph.py | 8 ++++ robotmbt/visualise/networkvisualiser.py | 35 ++++++++++++++--- 9 files changed, 134 insertions(+), 34 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 303948a0..80bd72d8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -121,30 +121,37 @@ Extending the functionality of the visualizer with new graph types can result in To create a new graph type, create an instance of AbstractGraph, instantiating the following methods: - select_node_info: select the information you want to use to identify different nodes from all ScenarioInfo/StateInfo pairs that make up the different exploration steps. This info is also used to label nodes. Its return type has to match the first type argument passed to AbstractGraph. -- select_edge_info: ditto but for edges, which is also used for labeling. Its type has to match the second type used to instantiate AbstractGraph. +- select_edge_info: ditto but for edges, which is also used for labeling. Its return type has to match the second type argument passed to AbstractGraph. +- create_node_description: create a description for a node to be shown in a tooltip (if enabled). - create_node_label: turn the selected information into a label for a node. - create_edge_label: ditto but for edges. - get_legend_info_final_trace_node: return the text you want to appear in the legend for nodes that appear in the final trace. - get_legend_info_other_node: ditto but for nodes that have been backtracked. - get_legend_info_final_trace_edge: ditto but for edges that appear in the final trace. - get_legend_info_other_edge: ditto but for edges that have backtracked. +- get_tooltip_name: the title of a tooltip that appears when hovering over nodes. Setting to an empty string disables the tooltip. Please create a new file for each graph type under `/robotmbt/visualise/graphs/`. NOTE: when manually altering the networkx field, ensure its ids remain as a serializable and hashable type when the constructor finishes. As an example, we show the implementation of the scenario graph below. In this graph type, nodes represent scenarios encountered in exploration, and edges show the flow between these scenarios. +It does not enable tooltips. ```python class ScenarioGraph(AbstractGraph[ScenarioInfo, None]): @staticmethod - def select_node_info(pairs: list[tuple[ScenarioInfo, StateInfo]], index: int) -> ScenarioInfo: - return pairs[index][0] + def select_node_info(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) -> ScenarioInfo: + return trace[index][0] @staticmethod def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> None: return None - + + @staticmethod + def create_node_description(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) -> str: + return '' + @staticmethod def create_node_label(info: ScenarioInfo) -> str: return info.name @@ -168,6 +175,10 @@ class ScenarioGraph(AbstractGraph[ScenarioInfo, None]): @staticmethod def get_legend_info_other_edge() -> str: return "Execution Flow (backtracked)" + + @staticmethod + def get_tooltip_name() -> str: + return "" ``` Once you have created a new Graph class, you can direct the visualizer to use it when your type is selected. diff --git a/robotmbt/visualise/graphs/abstractgraph.py b/robotmbt/visualise/graphs/abstractgraph.py index 28341bf2..ebebe37d 100644 --- a/robotmbt/visualise/graphs/abstractgraph.py +++ b/robotmbt/visualise/graphs/abstractgraph.py @@ -18,10 +18,10 @@ def __init__(self, info: TraceInfo): self.networkx: nx.DiGraph = nx.DiGraph() # Keep track of node IDs - self.ids: dict[str, NodeInfo] = {} + self.ids: dict[str, tuple[NodeInfo, str]] = {} # Add the start node - self.networkx.add_node('start', label='start') + self.networkx.add_node('start', label='start', description='') self.start_node = 'start' # Add nodes and edges for all traces @@ -29,11 +29,13 @@ def __init__(self, info: TraceInfo): for i in range(len(trace)): if i > 0: from_node = self._get_or_create_id( - self.select_node_info(trace, i - 1)) + self.select_node_info(trace, i - 1), + self.create_node_description(trace, i - 1)) else: from_node = 'start' to_node = self._get_or_create_id( - self.select_node_info(trace, i)) + self.select_node_info(trace, i), + self.create_node_description(trace, i)) self._add_node(from_node) self._add_node(to_node) self._add_edge(from_node, to_node, @@ -44,11 +46,13 @@ def __init__(self, info: TraceInfo): for i in range(len(info.current_trace)): if i > 0: from_node = self._get_or_create_id( - self.select_node_info(info.current_trace, i - 1)) + self.select_node_info(info.current_trace, i - 1), + self.create_node_description(info.current_trace, i - 1)) else: from_node = 'start' to_node = self._get_or_create_id( - self.select_node_info(info.current_trace, i)) + self.select_node_info(info.current_trace, i), + self.create_node_description(info.current_trace, i)) self.final_trace.append(to_node) self._add_node(from_node) self._add_node(to_node) @@ -62,16 +66,16 @@ def get_final_trace(self) -> list[str]: """ return self.final_trace - def _get_or_create_id(self, info: NodeInfo) -> str: + def _get_or_create_id(self, info: NodeInfo, description: str) -> str: """ Get the ID for a state that has been added before, or create and store a new one. """ for i in self.ids.keys(): - if self.ids[i] == info: + if self.ids[i][0] == info: return i new_id = f"node{len(self.ids)}" - self.ids[new_id] = info + self.ids[new_id] = info, description return new_id def _add_node(self, node: str): @@ -80,7 +84,7 @@ def _add_node(self, node: str): """ if node not in self.networkx.nodes: self.networkx.add_node( - node, label=self.create_node_label(self.ids[node])) + node, label=self.create_node_label(self.ids[node][0]), description=self.ids[node][1]) def _add_edge(self, from_node: str, to_node: str, label: str): """ @@ -103,7 +107,7 @@ def _add_edge(self, from_node: str, to_node: str, label: str): @staticmethod @abstractmethod - def select_node_info(pair: list[tuple[ScenarioInfo, StateInfo]], index: int) -> NodeInfo: + def select_node_info(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) -> NodeInfo: """ Select the info to use to compare nodes and generate their labels for a specific graph type. """ @@ -117,6 +121,14 @@ def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> EdgeInfo: """ pass + @staticmethod + @abstractmethod + def create_node_description(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) -> str: + """ + Create the description to be shown in a tooltip for a node given the full trace and its index. + """ + pass + @staticmethod @abstractmethod def create_node_label(info: NodeInfo) -> str: @@ -164,3 +176,8 @@ def get_legend_info_other_edge() -> str: Get the information to include in the legend for edges that do not appear in the final trace. """ pass + + @staticmethod + @abstractmethod + def get_tooltip_name() -> str: + pass diff --git a/robotmbt/visualise/graphs/deltavaluegraph.py b/robotmbt/visualise/graphs/deltavaluegraph.py index 7a77aa7a..8dfebe1f 100644 --- a/robotmbt/visualise/graphs/deltavaluegraph.py +++ b/robotmbt/visualise/graphs/deltavaluegraph.py @@ -10,16 +10,20 @@ class DeltaValueGraph(AbstractGraph[set[tuple[str, str]], ScenarioInfo]): """ @staticmethod - def select_node_info(pairs: list[tuple[ScenarioInfo, StateInfo]], index: int) -> set[tuple[str, str]]: + def select_node_info(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) -> set[tuple[str, str]]: if index == 0: - return StateInfo(ModelSpace()).difference(pairs[0][1]) + return StateInfo(ModelSpace()).difference(trace[0][1]) else: - return pairs[index-1][1].difference(pairs[index][1]) + return trace[index-1][1].difference(trace[index][1]) @staticmethod def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> ScenarioInfo: return pair[0] + @staticmethod + def create_node_description(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) -> str: + return str(trace[index][1]).replace('\n', '
') + @staticmethod def create_node_label(info: set[tuple[str, str]]) -> str: res = "" @@ -46,3 +50,7 @@ def get_legend_info_final_trace_edge() -> str: @staticmethod def get_legend_info_other_edge() -> str: return "Executed Scenario (backtracked)" + + @staticmethod + def get_tooltip_name() -> str: + return "Full state" diff --git a/robotmbt/visualise/graphs/reducedSDVgraph.py b/robotmbt/visualise/graphs/reducedSDVgraph.py index 100702d6..15c639e0 100644 --- a/robotmbt/visualise/graphs/reducedSDVgraph.py +++ b/robotmbt/visualise/graphs/reducedSDVgraph.py @@ -6,7 +6,6 @@ from robotmbt.visualise.models import ScenarioInfo, StateInfo, TraceInfo -# TODO add tests for this graph representation class ReducedSDVGraph(AbstractGraph[tuple[ScenarioInfo, set[tuple[str, str]]], None]): """ The reduced Scenario-delta-Value graph keeps track of both the scenarios and state updates encountered. @@ -65,17 +64,21 @@ def __init__(self, info: TraceInfo): self.start_node: tuple[str, ...] = tuple(['start']) @staticmethod - def select_node_info(pairs: list[tuple[ScenarioInfo, StateInfo]], index: int) \ + def select_node_info(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) \ -> tuple[ScenarioInfo, set[tuple[str, str]]]: if index == 0: - return pairs[0][0], StateInfo(ModelSpace()).difference(pairs[0][1]) + return trace[0][0], StateInfo(ModelSpace()).difference(trace[0][1]) else: - return pairs[index][0], pairs[index - 1][1].difference(pairs[index][1]) + return trace[index][0], trace[index - 1][1].difference(trace[index][1]) @staticmethod def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> None: return None + @staticmethod + def create_node_description(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) -> str: + return str(trace[index][1]).replace('\n', '
') + @staticmethod def create_node_label(info: tuple[ScenarioInfo, set[tuple[str, str]]]) -> str: return ScenarioDeltaValueGraph.create_node_label(info) @@ -99,3 +102,7 @@ def get_legend_info_final_trace_edge() -> str: @staticmethod def get_legend_info_other_edge() -> str: return "Execution Flow (backtracked)" + + @staticmethod + def get_tooltip_name() -> str: + return "Full state" diff --git a/robotmbt/visualise/graphs/scenariodeltavaluegraph.py b/robotmbt/visualise/graphs/scenariodeltavaluegraph.py index 6534386c..a9d822ae 100644 --- a/robotmbt/visualise/graphs/scenariodeltavaluegraph.py +++ b/robotmbt/visualise/graphs/scenariodeltavaluegraph.py @@ -12,17 +12,21 @@ class ScenarioDeltaValueGraph(AbstractGraph[tuple[ScenarioInfo, set[tuple[str, s """ @staticmethod - def select_node_info(pairs: list[tuple[ScenarioInfo, StateInfo]], index: int) \ + def select_node_info(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) \ -> tuple[ScenarioInfo, set[tuple[str, str]]]: if index == 0: - return pairs[0][0], StateInfo(ModelSpace()).difference(pairs[0][1]) + return trace[0][0], StateInfo(ModelSpace()).difference(trace[0][1]) else: - return pairs[index][0], pairs[index-1][1].difference(pairs[index][1]) + return trace[index][0], trace[index-1][1].difference(trace[index][1]) @staticmethod def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> None: return None + @staticmethod + def create_node_description(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) -> str: + return str(trace[index][1]).replace('\n', '
') + @staticmethod def create_node_label(info: tuple[ScenarioInfo, set[tuple[str, str]]]) -> str: res = "" @@ -49,3 +53,7 @@ def get_legend_info_final_trace_edge() -> str: @staticmethod def get_legend_info_other_edge() -> str: return "Execution Flow (backtracked)" + + @staticmethod + def get_tooltip_name() -> str: + return "Full state" diff --git a/robotmbt/visualise/graphs/scenariograph.py b/robotmbt/visualise/graphs/scenariograph.py index f36a0911..4d1442c9 100644 --- a/robotmbt/visualise/graphs/scenariograph.py +++ b/robotmbt/visualise/graphs/scenariograph.py @@ -9,13 +9,17 @@ class ScenarioGraph(AbstractGraph[ScenarioInfo, None]): """ @staticmethod - def select_node_info(pairs: list[tuple[ScenarioInfo, StateInfo]], index: int) -> ScenarioInfo: - return pairs[index][0] + def select_node_info(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) -> ScenarioInfo: + return trace[index][0] @staticmethod def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> None: return None + @staticmethod + def create_node_description(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) -> str: + return '' + @staticmethod def create_node_label(info: ScenarioInfo) -> str: return info.name @@ -39,3 +43,7 @@ def get_legend_info_final_trace_edge() -> str: @staticmethod def get_legend_info_other_edge() -> str: return "Execution Flow (backtracked)" + + @staticmethod + def get_tooltip_name() -> str: + return "" diff --git a/robotmbt/visualise/graphs/scenariostategraph.py b/robotmbt/visualise/graphs/scenariostategraph.py index 460a1634..aebc7db2 100644 --- a/robotmbt/visualise/graphs/scenariostategraph.py +++ b/robotmbt/visualise/graphs/scenariostategraph.py @@ -10,13 +10,17 @@ class ScenarioStateGraph(AbstractGraph[tuple[ScenarioInfo, StateInfo], None]): """ @staticmethod - def select_node_info(pairs: list[tuple[ScenarioInfo, StateInfo]], index: int) -> tuple[ScenarioInfo, StateInfo]: - return pairs[index] + def select_node_info(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) -> tuple[ScenarioInfo, StateInfo]: + return trace[index] @staticmethod def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> None: return None + @staticmethod + def create_node_description(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) -> str: + return '' + @staticmethod def create_node_label(info: tuple[ScenarioInfo, StateInfo]) -> str: return f"{info[0].name}\n\n{str(info[1])}" @@ -40,3 +44,7 @@ def get_legend_info_final_trace_edge() -> str: @staticmethod def get_legend_info_other_edge() -> str: return "Execution Flow (backtracked)" + + @staticmethod + def get_tooltip_name() -> str: + return "" diff --git a/robotmbt/visualise/graphs/stategraph.py b/robotmbt/visualise/graphs/stategraph.py index 61d4455f..aab07ba5 100644 --- a/robotmbt/visualise/graphs/stategraph.py +++ b/robotmbt/visualise/graphs/stategraph.py @@ -16,6 +16,10 @@ def select_node_info(pairs: list[tuple[ScenarioInfo, StateInfo]], index: int) -> def select_edge_info(pair: tuple[ScenarioInfo, StateInfo]) -> ScenarioInfo: return pair[0] + @staticmethod + def create_node_description(trace: list[tuple[ScenarioInfo, StateInfo]], index: int) -> str: + return '' + @staticmethod def create_node_label(info: StateInfo) -> str: return str(info) @@ -39,3 +43,7 @@ def get_legend_info_final_trace_edge() -> str: @staticmethod def get_legend_info_other_edge() -> str: return "Executed Scenario (backtracked)" + + @staticmethod + def get_tooltip_name() -> str: + return "" diff --git a/robotmbt/visualise/networkvisualiser.py b/robotmbt/visualise/networkvisualiser.py index a97eeca4..6c33f31f 100644 --- a/robotmbt/visualise/networkvisualiser.py +++ b/robotmbt/visualise/networkvisualiser.py @@ -2,7 +2,7 @@ from bokeh.core.property.vectorization import value from bokeh.embed import file_html from bokeh.models import ColumnDataSource, Rect, Text, ResetTool, SaveTool, WheelZoomTool, PanTool, Plot, Range1d, \ - Title, FullscreenTool, CustomJS, Segment, Arrow, NormalHead, Bezier, Legend, ZoomInTool, ZoomOutTool + Title, FullscreenTool, CustomJS, Segment, Arrow, NormalHead, Bezier, Legend, ZoomInTool, ZoomOutTool, HoverTool from grandalf.graphs import Vertex as GVertex, Edge as GEdge, Graph as GGraph from grandalf.layouts import SugiyamaLayout @@ -43,13 +43,14 @@ class Node: Contains the information we need to add a node to the graph. """ - def __init__(self, node_id: str, label: str, x: int, y: int, width: float, height: float): + def __init__(self, node_id: str, label: str, x: int, y: int, width: float, height: float, description: str): self.node_id = node_id self.label = label self.x = x self.y = y self.width = width self.height = height + self.description = description class Edge: @@ -88,6 +89,22 @@ def __init__(self, graph: AbstractGraph, suite_name: str): # Keep track of arrows in the graph for scaling self.arrows = [] + # Create the hover tool to show tooltips + tooltip_name = graph.get_tooltip_name() + if tooltip_name: + self.hover = HoverTool() + tooltips = f""" +