diff --git a/.github/workflows/autopep8.yml b/.github/workflows/autopep8.yml new file mode 100644 index 00000000..a866977f --- /dev/null +++ b/.github/workflows/autopep8.yml @@ -0,0 +1,34 @@ +name: autopep8 Check + +on: [pull_request] + +jobs: + autopep8-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install autopep8 + run: pip install autopep8 + + - name: Check formatting with autopep8 + id: check + run: | + # Check if autopep8 would make changes + formatting_issues=$(autopep8 --diff --recursive --max-line-length 120 .) + if [[ formatting_issues ]] then + echo "Formatting issues found:" + printf "%s\n" "$formatting_issues" + echo "------------------------------" + echo "-- Formatting issues found! --" + echo "------------------------------" + exit 1 + else + echo "No formatting issues found." + fi + 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/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 00000000..8e75f096 --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,30 @@ +# 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 + +on: + 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.10" # Only the oldest supported Python version is included here + - name: Install dependencies + run: | + python -m pip install --upgrade pip # upgrade pip to latest version + pip install . # install pyproject.toml dependencies - excludes optional dependencies (such as visualisation) + - name: Run tests + run: | + python run_tests.py 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 009e20f2..6c3e9309 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,10 @@ 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). + ## 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/robotMBT tests/03__parse_model_info/MyProcessor.py b/atest/robotMBT tests/03__parse_model_info/MyProcessor.py index 1c954b70..09cb13bb 100644 --- a/atest/robotMBT tests/03__parse_model_info/MyProcessor.py +++ b/atest/robotMBT tests/03__parse_model_info/MyProcessor.py @@ -15,5 +15,5 @@ def _fail_on_step_errors(self): if self.in_suite.has_error(): msg = "\n".join(["Error(s) detected in at least one step"] + [f"{step.kw_wo_gherkin} FAILED: {step.model_info['error']}" - for step in self.in_suite.steps_with_errors()]) + for step in self.in_suite.steps_with_errors()]) raise Exception(msg) diff --git a/atest/robotMBT tests/07__processor_options/option_handling/suiterepeater.py b/atest/robotMBT tests/07__processor_options/option_handling/suiterepeater.py index ddc609f6..bdbfa9fb 100644 --- a/atest/robotMBT tests/07__processor_options/option_handling/suiterepeater.py +++ b/atest/robotMBT tests/07__processor_options/option_handling/suiterepeater.py @@ -2,6 +2,7 @@ from robot.api.deco import library + @library(auto_keywords=None, listener=True) class SuiteRepeater: """ @@ -9,10 +10,11 @@ class SuiteRepeater: Setting bonus_scenario=${True} repeats 1 additional time sub-suites are ignored """ + def process_test_suite(self, in_suite, repeat=1, **kwargs): n_repeats = int(repeat) if kwargs.get('bonus_scenario', False): - n_repeats +=1 + n_repeats += 1 out_suite = copy.deepcopy(in_suite) out_suite.scenarios = n_repeats*out_suite.scenarios for i in range(len(out_suite.scenarios)): 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 e9322442..8736b866 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,12 +1,14 @@ 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/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/domain_lib/JourneyLib.py b/demo/Titanic/domain_lib/JourneyLib.py index cbe009b6..9c02c08c 100644 --- a/demo/Titanic/domain_lib/JourneyLib.py +++ b/demo/Titanic/domain_lib/JourneyLib.py @@ -10,6 +10,7 @@ from simulation.titanic_in_ocean import TitanicInOcean from simulation.journey import Journey + class JourneyLib: _journey = None diff --git a/demo/Titanic/run_demo.py b/demo/Titanic/run_demo.py index f5cb07ac..37977ff8 100644 --- a/demo/Titanic/run_demo.py +++ b/demo/Titanic/run_demo.py @@ -17,14 +17,16 @@ OUTPUT_ROOT = os.path.join(THIS_DIR, 'results') SCENARIO_FOLDER = os.path.join(THIS_DIR, 'Titanic_scenarios') HIT_MISS_TAG = 'hit' if len(sys.argv) == 1 or sys.argv[1].casefold() != 'hit' else 'miss' - EXTENDED_TAG = 'extended' if len(sys.argv) == 1 or sys.argv[1].casefold() != 'extended' else 'dummy' + EXTENDED_TAG = 'extended' if len(sys.argv) == 1 or sys.argv[1].casefold() != 'extended' else 'dummy' # 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/run_game.py b/demo/Titanic/run_game.py index 6ae20920..bd46fd2f 100644 --- a/demo/Titanic/run_game.py +++ b/demo/Titanic/run_game.py @@ -24,6 +24,7 @@ atlantic_area = AreaOnGrid(LocationOnGrid(latitude=35, longitude=-1.41), LocationOnGrid(latitude=65, longitude=-74)) + def run_game(map_animation, journey, tio: TitanicInOcean, atlantic_area): import curses @@ -35,7 +36,8 @@ def main_game_loop(stdscr): # Set up the window stdscr.nodelay(True) # Non-blocking input stdscr.timeout(100) # Refresh every 100 milliseconds - stdscr.addstr(0, 0, "Q=Quit. 0=Stop Titanic. WASD-controls (WS control speed, AD control rotation, no need to press and hold)") + stdscr.addstr( + 0, 0, "Q=Quit. 0=Stop Titanic. WASD-controls (WS control speed, AD control rotation, no need to press and hold)") objective = 1 iceberg_alley_reached = False @@ -97,7 +99,6 @@ def main_game_loop(stdscr): # Continue with the rest of the game logic - # Initialize curses stdscr = curses.initscr() curses.noecho() # Disable automatic echoing of pressed keys @@ -138,4 +139,3 @@ def main_game_loop(stdscr): map_animation.update_floating_objects(ocean.floating_objects) run_game(map_animation, journey, tio, atlantic_area) - diff --git a/demo/Titanic/simulation/journey.py b/demo/Titanic/simulation/journey.py index 252f3cfc..44c4235f 100644 --- a/demo/Titanic/simulation/journey.py +++ b/demo/Titanic/simulation/journey.py @@ -6,6 +6,8 @@ class StatusOfJourney(Enum): ON_THE_WAY = 2 ARRIVED = 3 SUNK = 4 + + class Journey: _instance = None 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 1483dfa6..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') @@ -25,10 +26,11 @@ def plot_static_elements(self, areas, locations): for area_name, area in areas.items(): width = abs(area.upper_left_bound.latitude - area.lower_right_bound.latitude) height = abs(area.upper_left_bound.longitude - area.lower_right_bound.longitude) - rect = Rectangle((area.lower_right_bound.longitude, area.upper_left_bound.latitude), height, width, alpha=0.4) + rect = Rectangle((area.lower_right_bound.longitude, area.upper_left_bound.latitude), + height, width, alpha=0.4) self.ax.add_patch(rect) - self.ax.annotate(area_name, (area.lower_right_bound.longitude, area.upper_left_bound.latitude), color='black') - + self.ax.annotate(area_name, (area.lower_right_bound.longitude, + area.upper_left_bound.latitude), color='black') # Plot the locations colors = [ @@ -85,7 +87,7 @@ def update_floating_objects(self, floating_objects): # Plot the floating objects for obj in floating_objects: - if isinstance(obj, TitanicInOcean):# Set the rotation angle in degrees + if isinstance(obj, TitanicInOcean): # Set the rotation angle in degrees angle_degrees = obj.direction # Convert angle to radians @@ -98,7 +100,7 @@ def update_floating_objects(self, floating_objects): # Draw the arrow self.ax.annotate("", xy=(obj.longitude + dx, obj.latitude + dy), xytext=(obj.longitude, obj.latitude), - arrowprops=dict(arrowstyle="->"), gid='floating_object') + arrowprops=dict(arrowstyle="->"), gid='floating_object') if obj.sunk: icon = 'rs' # red square @@ -106,7 +108,7 @@ def update_floating_objects(self, floating_objects): icon = 'ys' # yellow square # Draw the arrow self.ax.annotate("", xy=(obj.longitude + dx, obj.latitude + dy), xytext=(obj.longitude, obj.latitude), - arrowprops=dict(arrowstyle='->'), gid='floating_object') + arrowprops=dict(arrowstyle='->'), gid='floating_object') self.ax.plot(obj.longitude, obj.latitude, icon, label='Titanic', gid='floating_object') elif isinstance(obj, Iceberg): self.ax.plot(obj.longitude, obj.latitude, 'w^', label='Iceberg', gid='floating_object') diff --git a/demo/Titanic/simulation/ocean.py b/demo/Titanic/simulation/ocean.py index 68319189..7b62e193 100644 --- a/demo/Titanic/simulation/ocean.py +++ b/demo/Titanic/simulation/ocean.py @@ -3,10 +3,11 @@ from simulation.floating_object import FloatingObject from simulation.location_on_grid import LocationOnGrid, AreaOnGrid -SECONDS_IN_MINUTE = 60 +SECONDS_IN_MINUTE = 60 COLLISION_INTERVAL = 10 # Following should hold True; SECONDS_IN_MINUTE % COLLISION_INTERVAL == 0 COLLISION_THRESHOLD = 0.6 + class Ocean: _instance = None diff --git a/demo/Titanic/simulation/titanic_in_ocean.py b/demo/Titanic/simulation/titanic_in_ocean.py index 10990711..b41dd042 100644 --- a/demo/Titanic/simulation/titanic_in_ocean.py +++ b/demo/Titanic/simulation/titanic_in_ocean.py @@ -41,4 +41,3 @@ def __str__(self): def __repr__(self): return super().__repr__() + f", sunk={self.sunk}))" - diff --git a/robotmbt/__init__.py b/robotmbt/__init__.py index 6ea102f8..15f9b3e0 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 diff --git a/robotmbt/modelspace.py b/robotmbt/modelspace.py index b01d2a66..51f9edec 100644 --- a/robotmbt/modelspace.py +++ b/robotmbt/modelspace.py @@ -34,15 +34,17 @@ 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.values = dict() # For using literals without having to use quotes (abc='abc') self.scenario_vars = [] self.std_attrs = dir(self) @@ -118,7 +120,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: @@ -144,9 +146,9 @@ def __add_alias(self, missing_name, step_args): 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 + value = value.replace("'", r"\'") # Needed because we use single quotes in low level processing later on self.values[missing_name] = value @staticmethod @@ -177,6 +179,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,6 +193,7 @@ 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) @@ -206,7 +210,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 aa62d604..3f9d918c 100644 --- a/robotmbt/steparguments.py +++ b/robotmbt/steparguments.py @@ -55,6 +55,7 @@ def __getitem__(self, key): def modified(self): return any([arg.modified for arg in self]) + class StepArgument: # kind list EMBEDDED = 'EMBEDDED' @@ -66,13 +67,13 @@ class StepArgument: def __init__(self, arg_name, value, kind=None, is_default=False): self.name = arg_name self.org_value = value - self.kind = kind # one of the values from the kind list + self.kind = kind # one of the values from the kind list self._value = None self._codestr = None self.value = value - self.is_default = 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. + # 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 = is_default @property def arg(self): diff --git a/robotmbt/substitutionmap.py b/robotmbt/substitutionmap.py index 29ba0a25..55419702 100644 --- a/robotmbt/substitutionmap.py +++ b/robotmbt/substitutionmap.py @@ -41,9 +41,10 @@ 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} + self.substitutions = {} # {example_value:Constraint} + self.solution = {} # {example_value:solution_value} def __str__(self): src = self.solution or self.substitutions @@ -51,7 +52,7 @@ def __str__(self): def copy(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 @@ -132,7 +133,8 @@ def copy(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') diff --git a/robotmbt/suitedata.py b/robotmbt/suitedata.py index b9ca651a..113c6c49 100644 --- a/robotmbt/suitedata.py +++ b/robotmbt/suitedata.py @@ -36,6 +36,7 @@ from .steparguments import StepArgument, StepArguments + class Suite: def __init__(self, name, parent=None): self.name = name @@ -43,32 +44,34 @@ def __init__(self, name, parent=None): 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 + self.setup = None # Can be a single step or None + self.teardown = None # Can be a single step or None @property def longname(self): 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)) + 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 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 + # Parent scenario is kept for easy searching, processing and referencing + # after steps and scenarios have been potentially moved around + self.parent = parent + 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 = {} @@ -79,13 +82,13 @@ def longname(self): def has_error(self): 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 [])) + 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): duplicate = copy.copy(self) @@ -108,27 +111,31 @@ 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. + # org_step is the first keyword cell of the Robot line, including step_kw, + # excluding positional args, excluding variable assignment. + self.org_step = 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 + 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. + # 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 = 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) + # 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 + # between properties from the domain vocabulaire. + # Custom processors can define their own attributes. + self.model_info = dict() def __str__(self): return self.keyword @@ -197,7 +204,7 @@ def gherkin_kw(self, value): @property def step_kw(self): 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): @@ -216,7 +223,7 @@ def add_robot_dependent_data(self, robot_kw): 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): result = [] @@ -260,14 +267,16 @@ def __handle_non_embedded_arguments(self, robot_argspec): @staticmethod def __validate_arguments(spec, positionals, nameds): - p, n = spec.map(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. + # 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 = [] - ArgumentValidator(spec).validate(p, n) # Use the Robot mechanism for validation to yield familiar error messages + # Use the Robot mechanism for validation to yield familiar error messages + ArgumentValidator(spec).validate(p, n) def __parse_model_info(self, docu): model_info = dict() diff --git a/robotmbt/suiteprocessors.py b/robotmbt/suiteprocessors.py index c66ab1e3..669b64bd 100644 --- a/robotmbt/suiteprocessors.py +++ b/robotmbt/suiteprocessors.py @@ -42,6 +42,7 @@ from .tracestate import TraceState from .steparguments import StepArgument, StepArguments + class SuiteProcessors: def echo(self, in_suite): return in_suite @@ -85,7 +86,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) @@ -113,7 +114,7 @@ def _try_to_reach_full_coverage(self, allow_duplicate_scenarios): 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() @@ -154,7 +155,7 @@ def _fail_on_step_errors(suite): 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): @@ -193,7 +194,8 @@ def _try_to_fit_in_scenario(self, index, candidate, retry_flag): 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,8 +203,8 @@ 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: @@ -261,7 +263,8 @@ def _split_candidate_if_refinement_needed(scenario, model): 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 = '\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 @@ -302,7 +305,7 @@ def _process_scenario(scenario, model): @staticmethod def _relevant_expressions(step): 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}") @@ -334,7 +337,7 @@ def _generate_scenario_variant(self, scenario, model): 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 + 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]) @@ -342,7 +345,7 @@ def _generate_scenario_variant(self, scenario, model): 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) + options = m.process_expression(constraint, step.args) if options == 'exec': raise ValueError(f"Invalid constraint for argument substitution: {expr}") if not options: @@ -378,7 +381,8 @@ 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 @@ -403,7 +407,7 @@ def _parse_modifier_expression(expression, 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 + break # not an assignment constraint = assignment_expr.replace('=', '', 1).strip() return var.arg, constraint raise ValueError(f"Invalid argument substitution: {expression}") @@ -428,7 +432,8 @@ def _init_randomiser(seed): 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.") + 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.info(f"seed={new_seed} (use seed to rerun this trace)") @@ -441,14 +446,15 @@ def _init_randomiser(seed): def _generate_seed(): """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]) + 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 8a2e79fc..943b36ca 100644 --- a/robotmbt/suitereplacer.py +++ b/robotmbt/suitereplacer.py @@ -30,13 +30,14 @@ # 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 +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() -from .suiteprocessors import SuiteProcessors -from .suitedata import Suite, Scenario, Step class SuiteReplacer: ROBOT_LIBRARY_SCOPE = 'GLOBAL' @@ -56,14 +57,15 @@ def __init__(self, processor='process_test_suite', processor_lib=None): def processor_lib(self): 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}") + 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 @@ -117,7 +119,7 @@ def __process_robot_suite(self, in_suite, parent): 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 = 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: @@ -170,13 +172,13 @@ 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:]) diff --git a/robotmbt/tracestate.py b/robotmbt/tracestate.py index 4d0e526c..9aaff77b 100644 --- a/robotmbt/tracestate.py +++ b/robotmbt/tracestate.py @@ -32,10 +32,10 @@ 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._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 = [] @property @@ -157,6 +157,7 @@ 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 diff --git a/run_tests.py b/run_tests.py index c0c66bea..5387e5c8 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,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, - '--pythonpath', THIS_DIR] - + sys.argv[1:], exit=False) + exit_code: int = robot.run_cli(['--outputdir', OUTPUT_ROOT, + '--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_modelspace.py b/utest/test_modelspace.py index 7e8e537e..2c34e6f8 100644 --- a/utest/test_modelspace.py +++ b/utest/test_modelspace.py @@ -222,7 +222,7 @@ def test_fail_when_assigning_to_undefined_name(self): if sys.version_info >= (3, 13): self.assertEqual(str(cm.exception), "foo used before definition") else: - self.assertEqual(str(cm.exception), "None used before assignment") # <-- Known issue in Python 3.10/11/12 + self.assertEqual(str(cm.exception), "None used before assignment") # <-- Known issue in Python 3.10/11/12 def test_fail_when_comparing_unknown_property(self): self.m.add_prop('foo') @@ -380,8 +380,8 @@ def test_scenario_vars_appear_last_in_status_text(self): self.m.process_expression('scenario.foo = bar') self.m.process_expression('new zz') self.m.process_expression('zz.Z = 26') - self.assertTrue(self.m.get_status_text().endswith( "scenario:\n" - " foo=bar\n")) + self.assertTrue(self.m.get_status_text().endswith("scenario:\n" + " foo=bar\n")) def test_exclude_scenario_vars_from_status_text_when_empty(self): self.m.new_scenario_scope() diff --git a/utest/test_steparguments.py b/utest/test_steparguments.py index da1bdce5..6c906ca6 100644 --- a/utest/test_steparguments.py +++ b/utest/test_steparguments.py @@ -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,5 +290,6 @@ 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_substitutionmap.py b/utest/test_substitutionmap.py index 02e05137..b8e78a47 100644 --- a/utest/test_substitutionmap.py +++ b/utest/test_substitutionmap.py @@ -44,8 +44,8 @@ def test_single_distinct_options_are_the_solution(self): sm = SubstitutionMap() sm.substitute('A', [1]) sm.substitute('B', [2]) - self.assertEqual(sm.solve(), {'A':1, 'B':2}) - self.assertEqual(sm.solution, {'A':1, 'B':2}) + self.assertEqual(sm.solve(), {'A': 1, 'B': 2}) + self.assertEqual(sm.solution, {'A': 1, 'B': 2}) def test_single_overlapping_options_have_no_solution(self): sm = SubstitutionMap() @@ -124,14 +124,14 @@ def test_wrong_choice_blocks_solution_repeated(self): with varying data helps to detect randomisation flukes and algorithmic blind spots, like order preference. """ - variations = [{'A':[1, 2], 'B':[2, 3], 'C':[3, 2]}, - {'A':[2, 1], 'B':[2, 3], 'C':[2, 3]}, - {'A':[1, 2], 'B':[3, 2], 'C':[2, 3]}, - {'A':[2, 1], 'B':[3, 2], 'C':[3, 2]}, - {'A':[2, 3], 'B':[1, 2], 'C':[2, 3]}, - {'A':[2, 3], 'B':[2, 3], 'C':[1, 2]}, - {'A':[3, 2], 'B':[2, 1], 'C':[2, 3]}, - {'A':[3, 2], 'B':[3, 2], 'C':[2, 1]}] + variations = [{'A': [1, 2], 'B': [2, 3], 'C': [3, 2]}, + {'A': [2, 1], 'B': [2, 3], 'C': [2, 3]}, + {'A': [1, 2], 'B': [3, 2], 'C': [2, 3]}, + {'A': [2, 1], 'B': [3, 2], 'C': [3, 2]}, + {'A': [2, 3], 'B': [1, 2], 'C': [2, 3]}, + {'A': [2, 3], 'B': [2, 3], 'C': [1, 2]}, + {'A': [3, 2], 'B': [2, 1], 'C': [2, 3]}, + {'A': [3, 2], 'B': [3, 2], 'C': [2, 1]}] for variant in variations: sm = SubstitutionMap() for example_value, constraint in variant.items(): @@ -355,8 +355,9 @@ 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 - # was removed by adding a constraint and is ignored. + 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() self.assertCountEqual(c.optionset, ['one', 'two']) diff --git a/utest/test_suitedata.py b/utest/test_suitedata.py index a8bc1025..ef6e3866 100644 --- a/utest/test_suitedata.py +++ b/utest/test_suitedata.py @@ -78,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') @@ -105,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') @@ -156,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): @@ -173,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): - p = lambda:None # Create an object to assign the name attribute to + def p(): return None # Create an object to assign the name attribute to p.longname = 'long' scenario = Scenario('name', p) self.assertEqual(scenario.longname, 'long.name') @@ -185,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']) @@ -222,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()], @@ -284,6 +284,7 @@ def test_copies_are_independent(self): def test_exteranally_determined_attributes_are_copied_along(self): self.scenario.src_id = 7 + class Dummy: def copy(self): return 'dummy' @@ -304,15 +305,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): @@ -337,8 +338,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 @@ -347,8 +348,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) @@ -446,7 +447,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): @@ -461,22 +462,23 @@ 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 = [] - map = lambda x,y,z: ([], []) - __iter__ = lambda _: iter([]) + 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 + modified = True # trigger modified status to get arguments processed, rather then just echoed class StubArgument(SimpleNamespace): diff --git a/utest/test_suiteprocessors.py b/utest/test_suiteprocessors.py index 2b4f3e24..6989470e 100644 --- a/utest/test_suiteprocessors.py +++ b/utest/test_suiteprocessors.py @@ -79,8 +79,8 @@ def test_generated_seeds_have_max_2_consecutive_vowels_or_consonants(self, mock) SuiteProcessors._init_randomiser("new") new_seed = mock.call_args.args[0] self._is_generated_seed(new_seed) - self.assertNotIn('***', new_seed.translate({ord(c):'*' for c in 'aeiouy'})) - self.assertNotIn('***', new_seed.translate({ord(c):'*' for c in 'bcdfghjklmnpqrstvwxz'})) + self.assertNotIn('***', new_seed.translate({ord(c): '*' for c in 'aeiouy'})) + self.assertNotIn('***', new_seed.translate({ord(c): '*' for c in 'bcdfghjklmnpqrstvwxz'})) def _is_generated_seed(self, arg): """ diff --git a/utest/test_tracestate.py b/utest/test_tracestate.py index 3352b743..5bfb63a3 100644 --- a/utest/test_tracestate.py +++ b/utest/test_tracestate.py @@ -72,7 +72,7 @@ def test_scenario_still_excluded_from_candidacy_after_rewind(self): def test_candidates_come_in_order_when_accepted(self): ts = TraceState(3) candidates = [] - for scenario in range(3): + for scenario in range(3): candidates.append(ts.next_candidate()) ts.confirm_full_scenario(candidates[-1], scenario, {}) candidates.append(ts.next_candidate()) @@ -81,7 +81,7 @@ def test_candidates_come_in_order_when_accepted(self): def test_candidates_come_in_order_when_rejected(self): ts = TraceState(3) candidates = [] - for _ in range(3): + for _ in range(3): candidates.append(ts.next_candidate()) ts.reject_scenario(candidates[-1]) candidates.append(ts.next_candidate()) @@ -91,7 +91,7 @@ def test_rejected_scenarios_are_candidates_for_new_positions(self): ts = TraceState(3) candidates = [] ts.reject_scenario(0) - for scenario in range(3): + for scenario in range(3): candidates.append(ts.next_candidate()) ts.confirm_full_scenario(candidates[-1], scenario, {}) candidates.append(ts.next_candidate()) diff --git a/utest/test_tracestate_refinement.py b/utest/test_tracestate_refinement.py index a64b86d7..2cbdca61 100644 --- a/utest/test_tracestate_refinement.py +++ b/utest/test_tracestate_refinement.py @@ -337,7 +337,7 @@ def test_rewind_finished_scenario_with_refinement_removes_enclosed_refinements(s 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.rewind() # Middle including its Bottom refinement + ts.rewind() # Middle including its Bottom refinement self.assertEqual(ts.find_scenarios_with_active_refinement(), ['T2.1'])