diff --git a/.vscode/launch.json b/.vscode/launch.json index 89a2cf2..3f7bd2f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,15 +6,12 @@ "configurations": [ { "name": "Run ModuleTester", - "type": "python", + "type": "debugpy", "request": "launch", "module": "moduletester.gui.main", "console": "integratedTerminal", "envFile": "${workspaceFolder}/.env", "justMyCode": false, - "args": [ - "--unattended", - ], "env": { "LANG": "en", "QT_COLOR_MODE": "light", @@ -22,7 +19,7 @@ }, { "name": "Run current file", - "type": "python", + "type": "debugpy", "request": "launch", "program": "${file}", "console": "integratedTerminal", diff --git a/.vscode/tasks.json b/.vscode/tasks.json index ccdf3a1..24143e8 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -368,6 +368,55 @@ "๐Ÿงน Clean Up", ], }, + { + "label": "๐Ÿ“‹ Update requirements.txt", + "type": "shell", + "command": "${command:python.interpreterPath}", + "args": [ + "scripts/update_requirements.py", + ], + "options": { + "cwd": "${workspaceFolder}", + }, + "group": { + "kind": "build", + "isDefault": false, + }, + "presentation": { + "clear": true, + "echo": true, + "focus": false, + "panel": "dedicated", + "reveal": "always", + "showReuseMessage": true, + }, + "problemMatcher": [], + }, + { + "label": "๐Ÿงช Run Example Calculator (ModuleTester)", + "type": "shell", + "command": "${command:python.interpreterPath}", + "args": [ + "scripts/run_with_env.py", + "${command:python.interpreterPath}", + "scripts/run_example.py", + ], + "options": { + "cwd": "${workspaceFolder}", + }, + "group": { + "kind": "build", + "isDefault": true, + }, + "presentation": { + "clear": true, + "echo": true, + "focus": false, + "panel": "dedicated", + "reveal": "always", + "showReuseMessage": true, + }, + }, { "label": "โ” Untracked files", "type": "shell", diff --git a/CHANGELOG.md b/CHANGELOG.md index b62cfe1..0e09419 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # ModuleTester Releases # +## Version 1.1.0 ## + +### New features + +- Add Example Calculator reference implementation (`example/`) demonstrating + full ModuleTester integration with manual GUI tests, unit tests (pytest + + coverage), and qualification scripts +- Add step-by-step integration guide in documentation (`doc/example.rst`) + +## Version 1.0.1 ## + +### Bug fixes + +- Set fixed font for result label in `ResultError` and `ResultOutput` widgets +- Change launch configuration type from `python` to `debugpy` + +### Improvements + +- Add `pypandoc_binary` to requirements +- Add missing `PyQtWebEngine` to requirements +- Add deps requirements sync script `scripts\update_requirements.py` + ## Version 1.0.0 ## First stable release of ModuleTester. diff --git a/README.md b/README.md index a34892c..8ad357c 100644 --- a/README.md +++ b/README.md @@ -75,9 +75,14 @@ and is used to test [PlotPyStack](https://github.com/PlotPyStack) libraries. ## Example -Using ModuleTester on the `guidata` Python package โ€” the tree view shows test -hierarchy and execution status, while dockable panels display test properties, -output, and errors: +ModuleTester ships with a complete **Example Calculator** project in the +[`example/`](example/) directory that demonstrates all three test categories: +manual GUI tests, unit tests with coverage, and qualification scripts. + +See the [integration guide](https://moduletester.readthedocs.io/en/latest/example.html) +for a step-by-step tutorial on adding ModuleTester to your own project. + +![ModuleTester โ€” example tests](https://raw.githubusercontent.com/Codra-Ingenierie-Informatique/ModuleTester/main/doc/images/shots/example.moduletester.png) ![ModuleTester โ€” guidata tests](https://raw.githubusercontent.com/Codra-Ingenierie-Informatique/ModuleTester/main/doc/images/shots/guidata.moduletester.png) diff --git a/doc/changelog.rst b/doc/changelog.rst index 3a34dc5..d2b74a7 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -4,6 +4,14 @@ Changelog See the full changelog on `GitHub `_. +Version 1.0.1 +------------- + +Bug fixes and improvements โ€” see the +`CHANGELOG.md `_ +for the complete list. + + Version 1.0.0 ------------- diff --git a/doc/cli.rst b/doc/cli.rst index 690bc47..a85af0c 100644 --- a/doc/cli.rst +++ b/doc/cli.rst @@ -28,7 +28,7 @@ Launch the graphical interface. - Python package to load on startup. The package must be importable. * - ``-f``, ``--file`` - str - - Path to a ``.mt`` project file to open on startup. + - Path to a ``.moduletester`` project file to open on startup. **Examples** @@ -41,7 +41,7 @@ Launch the graphical interface. $ moduletester -p guidata # Open a saved project file - $ moduletester -f /path/to/project.mt + $ moduletester -f /path/to/project.moduletester .. note:: diff --git a/doc/example.rst b/doc/example.rst index 965fec5..904172d 100644 --- a/doc/example.rst +++ b/doc/example.rst @@ -1,13 +1,496 @@ -Example -======= +Example โ€” Integrating ModuleTester into your project +===================================================== -.. figure:: images/shots/empty.png - :align: center +This guide walks you through integrating ModuleTester into an existing Python +project, step by step. It uses the **Example Calculator** shipped in the +``example/`` directory of the ModuleTester repository as a reference +implementation. By the end, you will have a fully functional ModuleTester +setup with manual GUI tests, automated unit tests with coverage, and +qualification scripts. - ModuleTester main window with dockable panels and tree view navigation +.. contents:: Steps + :local: + :depth: 1 -.. figure:: images/shots/guidata.moduletester.png - :align: center - Running tests on the ``guidata`` package โ€” tree view with status icons, test - properties, and execution results panels +Overview of the example project +-------------------------------- + +The Example Calculator is a minimal Python package with a Qt GUI that +demonstrates the three test categories ModuleTester supports: + +- **Manual GUI Tests** โ€” launch the application and follow step-by-step + instructions displayed in ModuleTester. +- **Unit Tests** โ€” wrapper scripts that run ``pytest`` with ``coverage`` + and generate HTML coverage reports. +- **Qualification Tests** โ€” standalone scripts that verify numerical + precision and performance against reference values. + +.. code-block:: text + + example/ + โ”œโ”€โ”€ pyproject.toml + โ”œโ”€โ”€ README.md + โ””โ”€โ”€ example_calculator/ + โ”œโ”€โ”€ __init__.py + โ”œโ”€โ”€ app.py # Qt GUI (QMainWindow) + โ”œโ”€โ”€ operations.py # Arithmetic functions + โ”œโ”€โ”€ converter.py # Unit conversion functions + โ”œโ”€โ”€ moduletester.ini # ModuleTester configuration + โ””โ”€โ”€ tests/ + โ”œโ”€โ”€ __init__.py + โ”œโ”€โ”€ moduletester_launcher.py # Launches ModuleTester GUI + โ”œโ”€โ”€ templates/ # Export templates and assets + โ”œโ”€โ”€ processing/ # Actual pytest test files + โ”‚ โ”œโ”€โ”€ test_operations.py + โ”‚ โ””โ”€โ”€ test_converter.py + โ””โ”€โ”€ Test Plan/ + โ”œโ”€โ”€ Manual GUI Tests/ + โ”‚ โ”œโ”€โ”€ test-001.py + โ”‚ โ”œโ”€โ”€ test-002.py + โ”‚ โ””โ”€โ”€ test-003.py + โ”œโ”€โ”€ Unit Tests/ + โ”‚ โ”œโ”€โ”€ 001-operations.py + โ”‚ โ””โ”€โ”€ 002-converter.py + โ””โ”€โ”€ Qualification Tests/ + โ”œโ”€โ”€ 001-precision.py + โ””โ”€โ”€ 002-performance.py + + +Step 1 โ€” Make your package importable +-------------------------------------- + +ModuleTester discovers tests by importing your package and scanning its +sub-modules. Your package must be importable from the Python environment +where ModuleTester runs. + +For the example project, install it in editable mode: + +.. code-block:: console + + $ cd example + $ pip install -e ".[test]" + +.. tip:: + + If your project is already installed in your environment (via ``pip install + -e .`` or similar), you can skip this step. + + +Step 2 โ€” Organise your tests +------------------------------ + +Create a ``tests/`` sub-package inside your main package. ModuleTester scans +this sub-package recursively and groups tests by directory. + +Use the ``# guitest:`` directive at the top of each Python file to control +how ModuleTester treats it: + +.. list-table:: + :header-rows: 1 + :widths: 25 75 + + * - Directive + - Effect + * - ``# guitest: show`` + - The script appears in the ModuleTester tree view. Use this for all + test files that should be visible to testers. + * - ``# guitest: skip`` + - The script is completely ignored during discovery. Use this for + utility modules, ``pytest`` files, and launchers. + * - ``# guitest: hide`` + - The script is discovered but hidden from the default "visible" + category. Useful for batch-only tests. + +In the example, the directory structure under ``Test Plan/`` determines how +tests are grouped in the tree view. You are free to choose any directory +names โ€” ModuleTester uses them as-is. + + +Step 3 โ€” Write manual GUI tests +--------------------------------- + +Manual GUI tests launch your application and display step-by-step +instructions to the tester. ModuleTester renders the module docstring as +HTML, so write it in reStructuredText with a ``.. list-table::`` describing +actions and expected results. + +Here is an annotated example from the calculator project: + +.. code-block:: python + + """ + Example Calculator โ€” Manual GUI Test + + TEST-001: Application startup + + This test verifies that the application starts correctly. + + .. list-table:: Test steps + :header-rows: 1 + :widths: 50 50 + + * - Action + - Expected result + * - Launch the application. + - The main window appears with the title "Example Calculator". + * - Verify that both tabs ("Operations" and "Converter") are present. + - Both tabs are visible and selectable. + * - Close the application. + - The application closes without errors. + """ + + # guitest: show + + import example_calculator.app as app + + if __name__ == "__main__": + app.run() + +Key points: + +- The docstring must come **before** the ``# guitest: show`` directive. +- The ``if __name__ == "__main__":`` guard is required โ€” ModuleTester + executes each test in a subprocess. +- Your application must expose a ``run()`` function (or equivalent) that + starts the Qt event loop. + + +Step 4 โ€” Write unit test wrappers with coverage +------------------------------------------------- + +You could add ``# guitest: show`` and a ``if __name__`` block directly to +your existing ``pytest`` files, but a cleaner approach is to keep them +untouched and create thin **wrapper scripts** instead. This way your test +files remain standard ``pytest`` modules โ€” the only change is adding +``# guitest: skip`` so ModuleTester ignores them during discovery. The +wrappers call ``pytest.main()`` and optionally collect code coverage. + +Wrapper example (``Unit Tests/001-operations.py``): + +.. code-block:: python + + """ + UT-001: Arithmetic operations (pytest + coverage) + + .. list-table:: Test steps + :header-rows: 1 + :widths: 50 50 + + * - Action + - Expected result + * - Launch the test script. + - Unit test results and a coverage report are generated. + """ + + # guitest: show + + import os + from datetime import datetime + import coverage + import pytest + + if __name__ == "__main__": + current_dir = os.path.dirname(os.path.abspath(__file__)) + # Navigate to the project root + project_root = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.dirname(current_dir))) + ) + test_dir = os.path.join(project_root, "example_calculator", "tests") + + cov = coverage.Coverage( + include=["*/example_calculator/operations.py"], + ) + cov.start() + pytest.main([ + os.path.join(test_dir, "processing", "test_operations.py"), + "-v", + ]) + cov.stop() + cov.save() + cov.report(show_missing=False) + cov.html_report(directory=os.path.join( + project_root, "TestPlan", "reports", + datetime.now().strftime("%Y-%m-%d"), "operations", + )) + +The actual ``pytest`` file (``processing/test_operations.py``) must start +with ``# guitest: skip`` to stay hidden from ModuleTester: + +.. code-block:: python + + # guitest: skip + + import pytest + from example_calculator.operations import add, divide + + class TestAdd: + def test_positive_numbers(self): + assert add(2, 3) == 5 + + def test_negative_numbers(self): + assert add(-1, -2) == -3 + + class TestDivide: + def test_divide_by_zero(self): + with pytest.raises(ZeroDivisionError): + divide(1, 0) + + +Step 5 โ€” Write qualification scripts +-------------------------------------- + +Qualification tests are standalone scripts that run computations, compare +results against reference values, and generate reports. They are useful for +performance benchmarks, numerical accuracy checks, or any test that does not +fit the ``pytest`` model. + +Because ModuleTester executes every test as a **subprocess**, it is entirely +agnostic to what the script does internally. This makes it straightforward to +integrate any custom test script your project already has โ€” numerical +simulations, hardware-in-the-loop checks, data-processing pipelines, etc. โ€” +without modifying them beyond adding the ``# guitest: show`` directive and a +docstring. This approach even extends to **non-Python projects** (C++, C#, +web applications, โ€ฆ): the wrapper script just needs to call the external +tool via ``subprocess.run()`` or equivalent. The only requirement is that the +wrapper itself is a ``.py`` file so ModuleTester can discover it. + +Example (``Qualification Tests/001-precision.py``): + +.. code-block:: python + + """ + QUAL-001: Arithmetic precision verification + + .. list-table:: Test steps + :header-rows: 1 + :widths: 50 50 + + * - Action + - Expected result + * - Launch the qualification script. + - The script displays results and saves a report. + """ + + # guitest: show + + import os + from example_calculator import operations + + REFERENCE_DATA = [ + ("add(0.1, 0.2)", operations.add, (0.1, 0.2), 0.3, 1e-15), + # ... more test cases + ] + + def run(mode="print", save_path=None): + results = [] + for desc, func, args, expected, tol in REFERENCE_DATA: + computed = func(*args) + error = abs(computed - expected) + results.append((desc, computed, expected, error, error <= tol)) + # ... build and save report + + if __name__ == "__main__": + run("print_save", save_path="TestPlan/reports/precision") + + +Step 6 โ€” Customise export templates +-------------------------------------- + +ModuleTester uses **Jinja2 templates** to generate reports. The default +templates are shipped in ``moduletester/default_templates/`` โ€” copy them into +your project's ``tests/templates/`` directory so you can customise them. + +Two templates control the exported documents: + +- ``test_list_template.j2`` โ€” the **test list** report (test catalogue with + descriptions only, no results). +- ``test_results_template.j2`` โ€” the **test results** report (full campaign + output with descriptions, statuses, comments, images, and a summary table). + +Both templates have access to the ``doc_obj`` context object, which exposes +the full ``test_suite`` โ€” including the package description, grouped tests, +results, and execution dates. + +**Typical customisations:** + +- **Project description tracking** โ€” edit the ``

Description

`` + section to include project-specific metadata (version, author, release + date, reference documents, โ€ฆ). +- **Choosing which information to display** โ€” add or remove sections in each + test block: description, command, result status, execution date, comments, + screenshots, etc. +- **Summary table columns** โ€” adjust the summary table at the end of the + results template to match your reporting needs (e.g. add a "Duration" + column, remove unused result categories). +- **Styling** โ€” edit ``default_style.css`` to match your organisation's + branding. + +When exporting to **DOCX** or **ODT**, ModuleTester first renders the +Jinja2 template to HTML, then converts that HTML via Pandoc using a +``--reference-doc`` flag. The reference files (``custom-reference.docx`` and +``custom-reference.odt``) act as **style templates**: Pandoc extracts fonts, +heading styles, page layout, headers/footers, and margins from them, then +applies those styles to the generated content. In other words, the +**structure and data** come from the ``.j2`` templates, while the **visual +formatting** comes from the reference document. To match your organisation's +formatting, open the reference file in Word or LibreOffice, adjust the +styles (e.g. ``Heading 1``, ``Normal``, page margins), and save it back. + +For the Example Calculator, the templates are used as-is from the defaults. +See :doc:`templates` for the full template API reference. + + +Step 7 โ€” Create the configuration file +---------------------------------------- + +Place a ``moduletester.ini`` file next to your package's ``__init__.py``. +This file must declare **all** options in every section โ€” ModuleTester raises +a ``ConfigConflictError`` if any field is missing. + +The easiest way to start is to copy the example configuration file and +adapt it: + +.. code-block:: ini + + [general] + docstring_fmt = rst + category = visible + + [export] + template_dir = tests/templates + test_results_template_name = test_results_template.j2 + test_list_template_name = test_list_template.j2 + docx_reference = custom-reference.docx + odt_reference = custom-reference.odt + css_style = default_style.css + export_fmts = html + reload_templates_on_export = 0 + docstrings_header_shift = 3 + toc_depth = 2 + + [gui] + test_list_visible = 1 + test_list_pos = left + test_props_visible = 0 + test_props_pos = right + result_tab_visible = 1 + result_tab_pos = bottom + result_props_visible = 1 + result_props_pos = bottom + cli_visible = 0 + cli_pos = bottom + toolbox_visible = 0 + toolbox_pos = bottom + +The ``template_dir`` path is relative to the package directory. You need to +provide the template and asset files in that directory (see +:doc:`templates`). The simplest approach is to copy ModuleTester's default +templates from ``moduletester/default_templates/``. + +See :doc:`configuration` for the full reference of all options. + + +Step 8 โ€” Create a launcher script (optional) +----------------------------------------------- + +The launcher script is a convenience tool that generates a ``.moduletester`` +template file by discovering all tests in your package, then opens the +ModuleTester GUI. It is typically used by developers to keep the test plan +up to date or to prepare a test campaign before a milestone release. + +.. note:: + + This step is **optional**. You can achieve the same result by launching + ModuleTester directly on your package: + + .. code-block:: console + + $ moduletester -p my_package + + Or by opening an existing ``.moduletester`` file: + + .. code-block:: console + + $ moduletester -f TestPlan/my_package_v1.0.0_.moduletester + + The launcher script simply automates the template generation step and can + be wired to a VS Code task or a CI script for convenience. + +.. code-block:: python + + # guitest: skip + + import os + import sys + from importlib import import_module + + from qtpy import QtWidgets as QW + from moduletester.gui.main import run + from moduletester.manager import TestManager + from moduletester.model import Module + + from my_package import __version__ + + def create_template(): + mod = import_module("my_package") + project_dir = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + test_plan_dir = os.path.join(project_dir, "TestPlan") + os.makedirs(test_plan_dir, exist_ok=True) + + output_path = os.path.join( + test_plan_dir, + f"my_package_v{__version__}_.moduletester", + ) + manager = TestManager( + Module(mod), _template_path=output_path, _category="visible" + ) + print(f"Found {len(manager.test_suite.tests)} tests") + return output_path + + if __name__ == "__main__": + app = QW.QApplication.instance() + if not app: + app = QW.QApplication(sys.argv) + + if len(sys.argv) > 1: + moduletester_file = sys.argv[1] + else: + moduletester_file = create_template() + + moduletester = run(path=moduletester_file) + moduletester.window.show() + app.exec_() + +Run it to launch ModuleTester: + +.. code-block:: console + + $ python my_package/tests/moduletester_launcher.py + +The launcher generates a ``.moduletester`` file in ``TestPlan/`` and opens +the GUI. You can also pass an existing ``.moduletester`` file as an argument +to reload a previous test session. + +.. note:: + + The launcher must use ``# guitest: skip`` to avoid appearing in the test + tree itself. + + +Running the example +-------------------- + +To try the full example shipped with ModuleTester: + +.. code-block:: console + + $ cd example + $ pip install -e ".[test]" + $ python example_calculator/tests/moduletester_launcher.py + +This discovers 7 tests (3 manual GUI, 2 unit, 2 qualification) and opens +the ModuleTester GUI. You can run each test, inspect its output, and export +a report. diff --git a/doc/images/shots/example.moduletester.png b/doc/images/shots/example.moduletester.png new file mode 100644 index 0000000..67adee7 Binary files /dev/null and b/doc/images/shots/example.moduletester.png differ diff --git a/doc/user_guide.rst b/doc/user_guide.rst index 065d77e..5f5a439 100644 --- a/doc/user_guide.rst +++ b/doc/user_guide.rst @@ -90,6 +90,14 @@ the dockable panels: - **CLI** โ€” displays the exact command line used to run the test. +.. figure:: images/shots/example.moduletester.png + :align: center + + Running tests on the ``example`` package available in the moduletester source code + โ€” tree view with status icons, test properties, and execution results panels + + + .. figure:: images/shots/guidata.moduletester.png :align: center @@ -100,11 +108,11 @@ the dockable panels: Working with project files -------------------------- -ModuleTester can save and load project files (**.mt** format) that persist +ModuleTester can save and load project files (**.moduletester** format) that persist the test list, results, and comments. - **Save**: *File โ†’ Save* (or *Save As* for a new file). -- **Open**: *File โ†’ Open* and select an existing ``.mt`` file. +- **Open**: *File โ†’ Open* and select an existing ``.moduletester`` file. - **Reload**: *File โ†’ Reload* to refresh the test list from the package without losing saved results. @@ -112,7 +120,7 @@ You can also open a project file directly from the command line: .. code-block:: console - $ moduletester -f /path/to/project.mt + $ moduletester -f /path/to/project.moduletester Exporting reports diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..c4ec6fe --- /dev/null +++ b/example/README.md @@ -0,0 +1,140 @@ +# Example Calculator โ€” ModuleTester Reference Implementation + +This project is a minimal, self-contained example demonstrating how to integrate +[ModuleTester](https://github.com/Codra-Ingenierie-Informatique/ModuleTester/) +into a Python project with a Qt GUI. It mirrors the integration pattern used in +[X-GRID](https://github.com/Codra-Ingenierie-Informatique/X-GRID/). + +## Project structure + +``` +example/ +โ”œโ”€โ”€ pyproject.toml # Project metadata & dependencies +โ”œโ”€โ”€ README.md # This file +โ””โ”€โ”€ example_calculator/ + โ”œโ”€โ”€ __init__.py # Package metadata + โ”œโ”€โ”€ app.py # Qt GUI application (QMainWindow) + โ”œโ”€โ”€ operations.py # Arithmetic functions + โ”œโ”€โ”€ converter.py # Unit conversion functions + โ”œโ”€โ”€ moduletester.ini # ModuleTester configuration + โ””โ”€โ”€ tests/ + โ”œโ”€โ”€ __init__.py + โ”œโ”€โ”€ moduletester_launcher.py # Launcher for ModuleTester GUI + โ”œโ”€โ”€ templates/ # Export templates (copied from defaults) + โ”œโ”€โ”€ processing/ + โ”‚ โ”œโ”€โ”€ test_operations.py # Actual pytest test cases + โ”‚ โ””โ”€โ”€ test_converter.py # Actual pytest test cases + โ””โ”€โ”€ Test Plan/ + โ”œโ”€โ”€ Manual GUI Tests/ + โ”‚ โ”œโ”€โ”€ test-001.py # Application startup + โ”‚ โ”œโ”€โ”€ test-002.py # Arithmetic operations via GUI + โ”‚ โ””โ”€โ”€ test-003.py # Unit conversions via GUI + โ”œโ”€โ”€ Unit Tests/ + โ”‚ โ”œโ”€โ”€ 001-operations.py # pytest + coverage wrapper + โ”‚ โ””โ”€โ”€ 002-converter.py # pytest + coverage wrapper + โ””โ”€โ”€ Qualification Tests/ + โ”œโ”€โ”€ 001-precision.py # Numerical precision verification + โ””โ”€โ”€ 002-performance.py # Performance benchmark +``` + +## Prerequisites + +- Python >= 3.9 +- A Qt binding (PyQt5, PyQt6, PySide2, or PySide6) +- ModuleTester installed (from the parent directory: `pip install ..`) + +## Installation + +```bash +cd example +pip install -e ".[test]" +``` + +## Running the application + +```bash +python -m example_calculator.app +``` + +## Running tests + +### With pytest directly + +```bash +pytest example_calculator/tests/processing/ -v +``` + +### With ModuleTester + +```bash +python example_calculator/tests/moduletester_launcher.py +``` + +This will: +1. Discover all tests in `example_calculator.tests` (manual GUI, unit, qualification) +2. Generate a `.moduletester` template in `TestPlan/` +3. Open the ModuleTester GUI where you can run and manage tests + +## How to add ModuleTester to your own project + +### Step 1: Install ModuleTester + +```bash +pip install moduletester +``` + +### Step 2: Organize your tests + +Create a `tests/` subpackage inside your main package with subdirectories for +each test category. Use the `# guitest:` directives at the top of each test file: + +- `# guitest: show` โ€” Test is visible in ModuleTester GUI (manual GUI tests, + wrapper scripts) +- `# guitest: skip` โ€” File is ignored by ModuleTester (actual pytest files, + utility modules) +- `# guitest: hide` โ€” Test exists but is hidden from the default "visible" + category + +### Step 3: Write test docstrings in RST + +ModuleTester extracts the module docstring to display test instructions. +Use RST `.. list-table::` for step-by-step instructions: + +```python +""" +TEST-001: My test description + +.. list-table:: Test steps + :header-rows: 1 + :widths: 50 50 + + * - Action + - Expected result + * - Do something + - Something happens +""" +# guitest: show +``` + +### Step 4: Create a `moduletester.ini` + +Place a `moduletester.ini` file next to your package's `__init__.py`. See +[example_calculator/moduletester.ini](example_calculator/moduletester.ini) for a +fully commented reference. + +### Step 5: Create a launcher + +Write a launcher script that uses `TestManager` to discover tests and open the +ModuleTester GUI. See +[moduletester_launcher.py](example_calculator/tests/moduletester_launcher.py). + +### Step 6: Create pytest wrappers (for unit tests) + +For unit tests, create wrapper scripts that call `pytest.main()` with the +`coverage` API. These wrappers use `# guitest: show` so they appear in +ModuleTester, while the actual pytest files use `# guitest: skip`. + +### Step 7: Create qualification scripts (optional) + +For qualification or acceptance tests, create standalone scripts that run +computations, compare results to references, and generate text or HTML reports. diff --git a/example/example_calculator/TestPlan/reports/2026-05-21/performance/performance_report.txt b/example/example_calculator/TestPlan/reports/2026-05-21/performance/performance_report.txt new file mode 100644 index 0000000..709f814 --- /dev/null +++ b/example/example_calculator/TestPlan/reports/2026-05-21/performance/performance_report.txt @@ -0,0 +1,19 @@ +================================================================================ +QUALIFICATION REPORT โ€” Performance Benchmark +Date: 2026-05-21 18:42:45 +================================================================================ + +Function Iterations Total (s) Per call Max (s) Status +------------------------------------------------------------------------------------- +add 100,000 0.0042 4.19e-08 1.0 PASS +subtract 100,000 0.0042 4.17e-08 1.0 PASS +multiply 100,000 0.0045 4.54e-08 1.0 PASS +divide 100,000 0.0062 6.24e-08 1.0 PASS +power 100,000 0.0068 6.83e-08 1.0 PASS +sqrt 100,000 0.0082 8.16e-08 1.0 PASS +factorial(20) 100,000 0.0113 1.13e-07 2.0 PASS +celsius_to_fahrenheit 100,000 0.0054 5.41e-08 1.0 PASS +km_to_miles 100,000 0.0039 3.94e-08 1.0 PASS +------------------------------------------------------------------------------------- +Results: 9/9 passed +Overall: PASS diff --git a/example/example_calculator/TestPlan/reports/2026-05-21/precision/precision_report.txt b/example/example_calculator/TestPlan/reports/2026-05-21/precision/precision_report.txt new file mode 100644 index 0000000..18a7231 --- /dev/null +++ b/example/example_calculator/TestPlan/reports/2026-05-21/precision/precision_report.txt @@ -0,0 +1,23 @@ +======================================================================== +QUALIFICATION REPORT โ€” Arithmetic Precision +Date: 2026-05-21 18:42:32 +======================================================================== + +Test Computed Expected Error Status +------------------------------------------------------------------------------------------ +add(0.1, 0.2) 0.3 0.3 5.55e-17 PASS +add(1e15, 1e-15) 1e+15 1e+15 0.00e+00 PASS +subtract(1.0, 0.9) 0.1 0.1 2.78e-17 PASS +multiply(0.1, 0.1) 0.01 0.01 1.73e-18 PASS +multiply(1e8, 1e8) 1e+16 1e+16 0.00e+00 PASS +divide(1, 3) 0.3333333333 0.3333333333 0.00e+00 PASS +divide(1e-10, 1e10) 1e-20 1e-20 1.50e-36 PASS +power(2, 10) 1024 1024 0.00e+00 PASS +power(2, 0.5) 1.414213562 1.414213562 0.00e+00 PASS +sqrt(2) 1.414213562 1.414213562 0.00e+00 PASS +sqrt(1e-20) 1e-10 1e-10 0.00e+00 PASS +factorial(10) 3628800 3628800 0.00e+00 PASS +factorial(20) 2.432902008e+18 2.432902008e+18 0.00e+00 PASS +------------------------------------------------------------------------------------------ +Results: 13/13 passed +Overall: PASS diff --git a/example/example_calculator/__init__.py b/example/example_calculator/__init__.py new file mode 100644 index 0000000..08fb80a --- /dev/null +++ b/example/example_calculator/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- + +""" +Example Calculator +------------------ + +A minimal Python package with a Qt GUI, used as a reference implementation +for ModuleTester integration. This example demonstrates how to set up +ModuleTester with manual GUI tests, unit tests (pytest + coverage), +and qualification tests (custom scripts). +""" + +__version__ = "1.0.0" diff --git a/example/example_calculator/app.py b/example/example_calculator/app.py new file mode 100644 index 0000000..66c5adc --- /dev/null +++ b/example/example_calculator/app.py @@ -0,0 +1,166 @@ +# -*- coding: utf-8 -*- + +""" +Example Calculator โ€” Qt GUI Application +---------------------------------------- + +A minimal QMainWindow-based calculator demonstrating a typical Qt application +that can be tested with ModuleTester manual GUI tests. +""" + +import sys + +from qtpy import QtCore as QC +from qtpy import QtWidgets as QW + +from example_calculator import converter, operations + + +class CalculatorWindow(QW.QMainWindow): + """Main window for the Example Calculator application.""" + + def __init__(self): + super().__init__() + self.setWindowTitle("Example Calculator") + self.setMinimumSize(400, 350) + + central = QW.QWidget() + self.setCentralWidget(central) + layout = QW.QVBoxLayout(central) + + # --- Calculator tab --- + tabs = QW.QTabWidget() + layout.addWidget(tabs) + + # Tab 1: Arithmetic operations + calc_widget = QW.QWidget() + calc_layout = QW.QFormLayout(calc_widget) + + self.input_a = QW.QDoubleSpinBox() + self.input_a.setRange(-1e9, 1e9) + self.input_a.setDecimals(6) + calc_layout.addRow("A:", self.input_a) + + self.input_b = QW.QDoubleSpinBox() + self.input_b.setRange(-1e9, 1e9) + self.input_b.setDecimals(6) + calc_layout.addRow("B:", self.input_b) + + self.operation_combo = QW.QComboBox() + self.operation_combo.addItems( + ["Add", "Subtract", "Multiply", "Divide", "Power"] + ) + calc_layout.addRow("Operation:", self.operation_combo) + + self.calc_button = QW.QPushButton("Compute") + self.calc_button.clicked.connect(self._on_compute) + calc_layout.addRow(self.calc_button) + + self.result_label = QW.QLabel("Result: โ€”") + self.result_label.setAlignment(QC.Qt.AlignCenter) + self.result_label.setStyleSheet("font-size: 16px; font-weight: bold;") + calc_layout.addRow(self.result_label) + + tabs.addTab(calc_widget, "Operations") + + # Tab 2: Unit converter + conv_widget = QW.QWidget() + conv_layout = QW.QFormLayout(conv_widget) + + self.conv_input = QW.QDoubleSpinBox() + self.conv_input.setRange(-1e9, 1e9) + self.conv_input.setDecimals(6) + conv_layout.addRow("Value:", self.conv_input) + + self.conv_combo = QW.QComboBox() + self.conv_combo.addItems( + [ + "Celsius โ†’ Fahrenheit", + "Fahrenheit โ†’ Celsius", + "Celsius โ†’ Kelvin", + "Kelvin โ†’ Celsius", + "Meters โ†’ Feet", + "Feet โ†’ Meters", + "Km โ†’ Miles", + "Miles โ†’ Km", + ] + ) + conv_layout.addRow("Conversion:", self.conv_combo) + + self.conv_button = QW.QPushButton("Convert") + self.conv_button.clicked.connect(self._on_convert) + conv_layout.addRow(self.conv_button) + + self.conv_result_label = QW.QLabel("Result: โ€”") + self.conv_result_label.setAlignment(QC.Qt.AlignCenter) + self.conv_result_label.setStyleSheet("font-size: 16px; font-weight: bold;") + conv_layout.addRow(self.conv_result_label) + + tabs.addTab(conv_widget, "Converter") + + # Status bar + self.statusBar().showMessage("Ready") + + def _on_compute(self): + """Execute the selected arithmetic operation.""" + a = self.input_a.value() + b = self.input_b.value() + op = self.operation_combo.currentText() + + op_map = { + "Add": operations.add, + "Subtract": operations.subtract, + "Multiply": operations.multiply, + "Divide": operations.divide, + "Power": operations.power, + } + + try: + result = op_map[op](a, b) + self.result_label.setText(f"Result: {result}") + self.statusBar().showMessage(f"{a} {op} {b} = {result}") + except (ZeroDivisionError, ValueError, OverflowError) as e: + self.result_label.setText(f"Error: {e}") + self.statusBar().showMessage(f"Error: {e}") + + def _on_convert(self): + """Execute the selected unit conversion.""" + value = self.conv_input.value() + conversion = self.conv_combo.currentText() + + conv_map = { + "Celsius โ†’ Fahrenheit": converter.celsius_to_fahrenheit, + "Fahrenheit โ†’ Celsius": converter.fahrenheit_to_celsius, + "Celsius โ†’ Kelvin": converter.celsius_to_kelvin, + "Kelvin โ†’ Celsius": converter.kelvin_to_celsius, + "Meters โ†’ Feet": converter.meters_to_feet, + "Feet โ†’ Meters": converter.feet_to_meters, + "Km โ†’ Miles": converter.km_to_miles, + "Miles โ†’ Km": converter.miles_to_km, + } + + try: + result = conv_map[conversion](value) + self.conv_result_label.setText(f"Result: {result:.6f}") + self.statusBar().showMessage(f"{value} โ†’ {result:.6f}") + except ValueError as e: + self.conv_result_label.setText(f"Error: {e}") + self.statusBar().showMessage(f"Error: {e}") + + +def run(): + """Launch the Example Calculator application.""" + app = QW.QApplication.instance() + standalone = app is None + if standalone: + app = QW.QApplication(sys.argv) + + window = CalculatorWindow() + window.show() + + if standalone: + app.exec() + + +if __name__ == "__main__": + run() diff --git a/example/example_calculator/converter.py b/example/example_calculator/converter.py new file mode 100644 index 0000000..92b5439 --- /dev/null +++ b/example/example_calculator/converter.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- + +"""Unit conversion functions (temperature, distance).""" + +from __future__ import annotations + +# --- Temperature conversions --- + + +def celsius_to_fahrenheit(celsius: float) -> float: + """Convert Celsius to Fahrenheit.""" + return celsius * 9.0 / 5.0 + 32.0 + + +def fahrenheit_to_celsius(fahrenheit: float) -> float: + """Convert Fahrenheit to Celsius.""" + return (fahrenheit - 32.0) * 5.0 / 9.0 + + +def celsius_to_kelvin(celsius: float) -> float: + """Convert Celsius to Kelvin. + + Raises: + ValueError: If the result would be below absolute zero. + """ + kelvin = celsius + 273.15 + if kelvin < 0: + raise ValueError("Temperature below absolute zero is not physical") + return kelvin + + +def kelvin_to_celsius(kelvin: float) -> float: + """Convert Kelvin to Celsius. + + Raises: + ValueError: If kelvin is negative. + """ + if kelvin < 0: + raise ValueError("Kelvin temperature cannot be negative") + return kelvin - 273.15 + + +# --- Distance conversions --- + + +def meters_to_feet(meters: float) -> float: + """Convert meters to feet.""" + return meters * 3.28084 + + +def feet_to_meters(feet: float) -> float: + """Convert feet to meters.""" + return feet / 3.28084 + + +def km_to_miles(km: float) -> float: + """Convert kilometers to miles.""" + return km * 0.621371 + + +def miles_to_km(miles: float) -> float: + """Convert miles to kilometers.""" + return miles / 0.621371 diff --git a/example/example_calculator/moduletester.ini b/example/example_calculator/moduletester.ini new file mode 100644 index 0000000..1a1d5df --- /dev/null +++ b/example/example_calculator/moduletester.ini @@ -0,0 +1,83 @@ +# ModuleTester configuration for the Example Calculator project. +# +# This file is read by ModuleTester when loading a test suite for the +# example_calculator package. Place it at the root of the package directory +# (next to __init__.py). +# +# See moduletester/config.py for the full list of configuration options. + +[general] +# Format used for parsing test docstrings. +# Supported values: "rst" (reStructuredText), "md" (Markdown) +docstring_fmt = rst + +# Default test category filter used when discovering tests. +# Supported values: "all", "visible", "batch" +# - "all" : every discovered test script +# - "visible" : tests marked with "# guitest: show" (default) +# - "batch" : tests intended for unattended execution +category = visible + +[export] +# Relative path (from this .ini file) to the directory containing +# Jinja2 templates and assets used for report generation. +template_dir = tests/templates + +# Name of the Jinja2 template used for test results (RTV) reports. +test_results_template_name = test_results_template.j2 + +# Name of the Jinja2 template used for test list (DTV) reports. +test_list_template_name = test_list_template.j2 + +# Reference document for DOCX export (styles and formatting). +docx_reference = custom-reference.docx + +# Reference document for ODT export (styles and formatting). +odt_reference = custom-reference.odt + +# CSS stylesheet applied to HTML exports. +css_style = default_style.css + +# Comma-separated list of export formats to generate. +# Supported values: html, docx +export_fmts = html + +# If 1, Jinja2 templates are reloaded from disk on every export +# (useful during template development). +reload_templates_on_export = 0 + +# Number of heading levels to shift when embedding docstrings in reports. +# For example, 3 means an RST "=" heading becomes an

. +docstrings_header_shift = 3 + +# Depth of the table of contents in generated reports. +toc_depth = 2 + +[gui] +# Visibility and position of each ModuleTester GUI panel. +# Visibility: 1 = visible, 0 = hidden +# Position: "left", "right", "top", "bottom" + +# Test list panel (tree of discovered tests) +test_list_visible = 1 +test_list_pos = left + +# Test properties panel (metadata of the selected test) +test_props_visible = 0 +test_props_pos = right + +# Result tab panel (execution output) +result_tab_visible = 1 +result_tab_pos = bottom + +# Result properties panel (details of test result) +result_props_visible = 1 +result_props_pos = bottom + +# CLI output panel +cli_visible = 0 +cli_pos = bottom + +# Toolbox panel +toolbox_visible = 0 +toolbox_pos = bottom diff --git a/example/example_calculator/operations.py b/example/example_calculator/operations.py new file mode 100644 index 0000000..84443c0 --- /dev/null +++ b/example/example_calculator/operations.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- + +"""Basic arithmetic and mathematical operations.""" + +from __future__ import annotations + +import math + + +def add(a: float, b: float) -> float: + """Return the sum of two numbers.""" + return a + b + + +def subtract(a: float, b: float) -> float: + """Return the difference of two numbers.""" + return a - b + + +def multiply(a: float, b: float) -> float: + """Return the product of two numbers.""" + return a * b + + +def divide(a: float, b: float) -> float: + """Return the quotient of two numbers. + + Raises: + ZeroDivisionError: If b is zero. + """ + if b == 0: + raise ZeroDivisionError("Cannot divide by zero") + return a / b + + +def power(base: float, exponent: float) -> float: + """Return base raised to the power of exponent.""" + return math.pow(base, exponent) + + +def sqrt(value: float) -> float: + """Return the square root of a non-negative number. + + Raises: + ValueError: If value is negative. + """ + if value < 0: + raise ValueError("Cannot compute square root of a negative number") + return math.sqrt(value) + + +def factorial(n: int) -> int: + """Return the factorial of a non-negative integer. + + Raises: + ValueError: If n is negative. + """ + if n < 0: + raise ValueError("Factorial is not defined for negative numbers") + return math.factorial(n) diff --git a/example/example_calculator/tests/Test Plan/Manual GUI Tests/test-001.py b/example/example_calculator/tests/Test Plan/Manual GUI Tests/test-001.py new file mode 100644 index 0000000..5a8d57f --- /dev/null +++ b/example/example_calculator/tests/Test Plan/Manual GUI Tests/test-001.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- + +""" +Example Calculator โ€” Manual GUI Test + +Basic tests and application launch + +TEST-001: Application startup + +This test verifies that the Example Calculator application starts correctly +and that the main window is displayed with all expected UI components. + +.. list-table:: Test steps + :header-rows: 1 + :widths: 50 50 + + * - Action + - Expected result + * - Launch the Example Calculator application (in ModuleTester, select + Manual GUI Tests/test-001 and click "Run Script"). + - The application starts and the main window appears with the title + "Example Calculator". + * - Verify that the main window contains two tabs: "Operations" and + "Converter". + - Both tabs are visible and can be selected. + * - On the "Operations" tab, verify the presence of: + + - Two numeric input fields (A and B) + - An operation selector (Add, Subtract, Multiply, Divide, Power) + - A "Compute" button + - A result label + - All components are present and the result label shows "Result: โ€”". + * - On the "Converter" tab, verify the presence of: + + - One numeric input field (Value) + - A conversion selector (8 conversions available) + - A "Convert" button + - A result label + - All components are present and the result label shows "Result: โ€”". + * - Verify the status bar at the bottom of the window. + - The status bar displays "Ready". + * - Close the application. + - The application closes without errors. +""" + +# guitest: show + +import example_calculator.app as app + +if __name__ == "__main__": + app.run() diff --git a/example/example_calculator/tests/Test Plan/Manual GUI Tests/test-002.py b/example/example_calculator/tests/Test Plan/Manual GUI Tests/test-002.py new file mode 100644 index 0000000..d2803a0 --- /dev/null +++ b/example/example_calculator/tests/Test Plan/Manual GUI Tests/test-002.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- + +""" +Example Calculator โ€” Manual GUI Test + +Arithmetic operations + +TEST-002: Basic arithmetic operations via the GUI + +This test verifies that the calculator correctly performs basic arithmetic +operations through the graphical user interface. + +.. list-table:: Test steps + :header-rows: 1 + :widths: 50 50 + + * - Action + - Expected result + * - Launch the application (in ModuleTester, select + Manual GUI Tests/test-002 and click "Run Script"). + - The application starts and the "Operations" tab is displayed. + * - Set A = 10 and B = 5. Select "Add" and click "Compute". + - The result label displays "Result: 15.0". + * - Select "Subtract" and click "Compute". + - The result label displays "Result: 5.0". + * - Select "Multiply" and click "Compute". + - The result label displays "Result: 50.0". + * - Select "Divide" and click "Compute". + - The result label displays "Result: 2.0". + * - Select "Power" and click "Compute". + - The result label displays "Result: 100000.0". + * - Set B = 0 and select "Divide". Click "Compute". + - The result label displays "Error: Cannot divide by zero". + * - Verify the status bar after each operation. + - The status bar shows a summary of the last operation or error. + * - Close the application. + - The application closes without errors. +""" + +# guitest: show + +import example_calculator.app as app + +if __name__ == "__main__": + app.run() diff --git a/example/example_calculator/tests/Test Plan/Manual GUI Tests/test-003.py b/example/example_calculator/tests/Test Plan/Manual GUI Tests/test-003.py new file mode 100644 index 0000000..c1b4c11 --- /dev/null +++ b/example/example_calculator/tests/Test Plan/Manual GUI Tests/test-003.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- + +""" +Example Calculator โ€” Manual GUI Test + +Unit conversions + +TEST-003: Unit conversions via the GUI + +This test verifies that the calculator correctly performs unit conversions +through the graphical user interface. + +.. list-table:: Test steps + :header-rows: 1 + :widths: 50 50 + + * - Action + - Expected result + * - Launch the application (in ModuleTester, select + Manual GUI Tests/test-003 and click "Run Script"). + - The application starts. Navigate to the "Converter" tab. + * - Set Value = 100. Select "Celsius โ†’ Fahrenheit" and click "Convert". + - The result label displays "Result: 212.000000". + * - Select "Fahrenheit โ†’ Celsius" and click "Convert". + - The result label displays "Result: 37.777778". + * - Set Value = 0. Select "Celsius โ†’ Kelvin" and click "Convert". + - The result label displays "Result: 273.150000". + * - Set Value = 1. Select "Km โ†’ Miles" and click "Convert". + - The result label displays "Result: 0.621371". + * - Set Value = 1. Select "Meters โ†’ Feet" and click "Convert". + - The result label displays "Result: 3.280840". + * - Set Value = -300. Select "Celsius โ†’ Kelvin" and click "Convert". + - The result label displays "Error: Temperature below absolute zero + is not physical". + * - Close the application. + - The application closes without errors. +""" + +# guitest: show + +import example_calculator.app as app + +if __name__ == "__main__": + app.run() diff --git a/example/example_calculator/tests/Test Plan/Qualification Tests/001-precision.py b/example/example_calculator/tests/Test Plan/Qualification Tests/001-precision.py new file mode 100644 index 0000000..48c6c1e --- /dev/null +++ b/example/example_calculator/tests/Test Plan/Qualification Tests/001-precision.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- + +""" +Example Calculator โ€” Qualification Test + +QUAL-001: Arithmetic precision verification + +This qualification test verifies the numerical precision of all arithmetic +operations by comparing computed results against known reference values. +A detailed report is generated. + +.. list-table:: Test steps + :header-rows: 1 + :widths: 50 50 + + * - Action + - Expected result + * - Launch the qualification script (in ModuleTester, select + Qualification Tests/001-precision and click "Run Script"). + - The script runs and displays results in the console. A text report + is saved in the ``TestPlan/reports/YYYY-MM-DD/precision`` folder. +""" + +# guitest: show + +import math +import os +from datetime import datetime + +from example_calculator import operations + +# Reference values: (description, function, args, expected, tolerance) +REFERENCE_DATA = [ + ("add(0.1, 0.2)", operations.add, (0.1, 0.2), 0.3, 1e-15), + ("add(1e15, 1e-15)", operations.add, (1e15, 1e-15), 1e15, 1.0), + ("subtract(1.0, 0.9)", operations.subtract, (1.0, 0.9), 0.1, 1e-15), + ("multiply(0.1, 0.1)", operations.multiply, (0.1, 0.1), 0.01, 1e-16), + ("multiply(1e8, 1e8)", operations.multiply, (1e8, 1e8), 1e16, 1.0), + ("divide(1, 3)", operations.divide, (1, 3), 1 / 3, 1e-15), + ("divide(1e-10, 1e10)", operations.divide, (1e-10, 1e10), 1e-20, 1e-35), + ("power(2, 10)", operations.power, (2, 10), 1024.0, 1e-10), + ("power(2, 0.5)", operations.power, (2, 0.5), math.sqrt(2), 1e-15), + ("sqrt(2)", operations.sqrt, (2,), math.sqrt(2), 1e-15), + ("sqrt(1e-20)", operations.sqrt, (1e-20,), 1e-10, 1e-25), + ("factorial(10)", operations.factorial, (10,), 3628800, 0), + ("factorial(20)", operations.factorial, (20,), 2432902008176640000, 0), +] + + +def run(mode="print", save_path=None): + """Run precision qualification tests. + + Args: + mode: "print", "save", or "print_save" + save_path: directory where the report will be saved + """ + results = [] + all_passed = True + + for desc, func, args, expected, tolerance in REFERENCE_DATA: + computed = func(*args) + error = abs(computed - expected) + passed = error <= tolerance + if not passed: + all_passed = False + results.append((desc, computed, expected, error, tolerance, passed)) + + # Build report + lines = [] + lines.append("=" * 72) + lines.append("QUALIFICATION REPORT โ€” Arithmetic Precision") + lines.append(f"Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + lines.append("=" * 72) + lines.append("") + lines.append(f"{'Test':<30} {'Computed':>18} {'Expected':>18} {'Error':>12} {'Status':>8}") + lines.append("-" * 90) + + for desc, computed, expected, error, tolerance, passed in results: + status = "PASS" if passed else "FAIL" + lines.append( + f"{desc:<30} {computed:>18.10g} {expected:>18.10g} {error:>12.2e} {status:>8}" + ) + + lines.append("-" * 90) + total = len(results) + passed_count = sum(1 for *_, p in results if p) + lines.append(f"Results: {passed_count}/{total} passed") + lines.append(f"Overall: {'PASS' if all_passed else 'FAIL'}") + lines.append("") + + report_text = "\n".join(lines) + + if "print" in mode: + print(report_text) + + if "save" in mode and save_path is not None: + os.makedirs(save_path, exist_ok=True) + report_file = os.path.join(save_path, "precision_report.txt") + with open(report_file, "w", encoding="utf-8") as f: + f.write(report_text) + print(f"Report saved to: {report_file}") + + return all_passed + + +if __name__ == "__main__": + project_root = os.path.dirname( + os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + ) + report_path = os.path.join( + project_root, + "TestPlan", + "reports", + datetime.now().strftime("%Y-%m-%d"), + "precision", + ) + success = run("print_save", save_path=report_path) + if not success: + print("\n*** QUALIFICATION FAILED ***") diff --git a/example/example_calculator/tests/Test Plan/Qualification Tests/002-performance.py b/example/example_calculator/tests/Test Plan/Qualification Tests/002-performance.py new file mode 100644 index 0000000..7971ea1 --- /dev/null +++ b/example/example_calculator/tests/Test Plan/Qualification Tests/002-performance.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- + +""" +Example Calculator โ€” Qualification Test + +QUAL-002: Performance benchmark + +This qualification test measures the execution time of all arithmetic and +conversion operations to verify they remain within acceptable performance +thresholds. + +.. list-table:: Test steps + :header-rows: 1 + :widths: 50 50 + + * - Action + - Expected result + * - Launch the qualification script (in ModuleTester, select + Qualification Tests/002-performance and click "Run Script"). + - The script runs and displays benchmark results in the console. + A text report is saved in the + ``TestPlan/reports/YYYY-MM-DD/performance`` folder. +""" + +# guitest: show + +import os +import time +from datetime import datetime + +from example_calculator import converter, operations + +# Benchmark definitions: (name, function, args, iterations, max_time_seconds) +BENCHMARKS = [ + ("add", operations.add, (1.5, 2.5), 100_000, 1.0), + ("subtract", operations.subtract, (10.0, 3.0), 100_000, 1.0), + ("multiply", operations.multiply, (3.0, 4.0), 100_000, 1.0), + ("divide", operations.divide, (10.0, 3.0), 100_000, 1.0), + ("power", operations.power, (2.0, 10.0), 100_000, 1.0), + ("sqrt", operations.sqrt, (144.0,), 100_000, 1.0), + ("factorial(20)", operations.factorial, (20,), 100_000, 2.0), + ("celsius_to_fahrenheit", converter.celsius_to_fahrenheit, (100.0,), 100_000, 1.0), + ("km_to_miles", converter.km_to_miles, (42.195,), 100_000, 1.0), +] + + +def run(mode="print", save_path=None): + """Run performance benchmark. + + Args: + mode: "print", "save", or "print_save" + save_path: directory where the report will be saved + """ + results = [] + all_passed = True + + for name, func, args, iterations, max_time in BENCHMARKS: + start = time.perf_counter() + for _ in range(iterations): + func(*args) + elapsed = time.perf_counter() - start + passed = elapsed <= max_time + if not passed: + all_passed = False + per_call = elapsed / iterations + results.append((name, iterations, elapsed, per_call, max_time, passed)) + + # Build report + lines = [] + lines.append("=" * 80) + lines.append("QUALIFICATION REPORT โ€” Performance Benchmark") + lines.append(f"Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + lines.append("=" * 80) + lines.append("") + lines.append( + f"{'Function':<25} {'Iterations':>12} {'Total (s)':>12} " + f"{'Per call':>14} {'Max (s)':>10} {'Status':>8}" + ) + lines.append("-" * 85) + + for name, iterations, elapsed, per_call, max_time, passed in results: + status = "PASS" if passed else "FAIL" + lines.append( + f"{name:<25} {iterations:>12,} {elapsed:>12.4f} " + f"{per_call:>14.2e} {max_time:>10.1f} {status:>8}" + ) + + lines.append("-" * 85) + total = len(results) + passed_count = sum(1 for *_, p in results if p) + lines.append(f"Results: {passed_count}/{total} passed") + lines.append(f"Overall: {'PASS' if all_passed else 'FAIL'}") + lines.append("") + + report_text = "\n".join(lines) + + if "print" in mode: + print(report_text) + + if "save" in mode and save_path is not None: + os.makedirs(save_path, exist_ok=True) + report_file = os.path.join(save_path, "performance_report.txt") + with open(report_file, "w", encoding="utf-8") as f: + f.write(report_text) + print(f"Report saved to: {report_file}") + + return all_passed + + +if __name__ == "__main__": + project_root = os.path.dirname( + os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + ) + report_path = os.path.join( + project_root, + "TestPlan", + "reports", + datetime.now().strftime("%Y-%m-%d"), + "performance", + ) + success = run("print_save", save_path=report_path) + if not success: + print("\n*** QUALIFICATION FAILED ***") diff --git a/example/example_calculator/tests/Test Plan/Unit Tests/001-operations.py b/example/example_calculator/tests/Test Plan/Unit Tests/001-operations.py new file mode 100644 index 0000000..06bfb0e --- /dev/null +++ b/example/example_calculator/tests/Test Plan/Unit Tests/001-operations.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- + +""" +Example Calculator โ€” Unit Tests + +UT-001: Arithmetic operations (pytest + coverage) + +These unit tests verify the arithmetic functions in the +``example_calculator.operations`` module using pytest. Code coverage is +collected and an HTML report is generated. + +.. list-table:: Test steps + :header-rows: 1 + :widths: 50 50 + + * - Action + - Expected result + * - Launch the test script (in ModuleTester, select + Unit Tests/001-operations and click "Run Script"). + - The test script runs. Unit test results are displayed in the console. + An HTML coverage report is generated in the + ``TestPlan/reports/YYYY-MM-DD/operations`` folder. +""" + +# guitest: show + +import os +from datetime import datetime + +import coverage +import pytest + +if __name__ == "__main__": + current_dir = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.dirname(current_dir))) + ) + test_dir = os.path.join(project_root, "example_calculator", "tests") + + cov = coverage.Coverage( + include=["*/example_calculator/operations.py"], + ) + cov.start() + + pytest.main( + [ + os.path.join(test_dir, "processing", "test_operations.py"), + "-v", + ] + ) + + cov.stop() + cov.save() + cov.report(show_missing=False) + + report_dir = os.path.join( + project_root, + "TestPlan", + "reports", + datetime.now().strftime("%Y-%m-%d"), + "operations", + ) + cov.html_report(directory=report_dir) + print(f"\nCoverage HTML report saved to: {report_dir}") diff --git a/example/example_calculator/tests/Test Plan/Unit Tests/002-converter.py b/example/example_calculator/tests/Test Plan/Unit Tests/002-converter.py new file mode 100644 index 0000000..c2b6c3f --- /dev/null +++ b/example/example_calculator/tests/Test Plan/Unit Tests/002-converter.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- + +""" +Example Calculator โ€” Unit Tests + +UT-002: Unit converter (pytest + coverage) + +These unit tests verify the conversion functions in the +``example_calculator.converter`` module using pytest. Code coverage is +collected and an HTML report is generated. + +.. list-table:: Test steps + :header-rows: 1 + :widths: 50 50 + + * - Action + - Expected result + * - Launch the test script (in ModuleTester, select + Unit Tests/002-converter and click "Run Script"). + - The test script runs. Unit test results are displayed in the console. + An HTML coverage report is generated in the + ``TestPlan/reports/YYYY-MM-DD/converter`` folder. +""" + +# guitest: show + +import os +from datetime import datetime + +import coverage +import pytest + +if __name__ == "__main__": + current_dir = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.dirname(current_dir))) + ) + test_dir = os.path.join(project_root, "example_calculator", "tests") + + cov = coverage.Coverage( + include=["*/example_calculator/converter.py"], + ) + cov.start() + + pytest.main( + [ + os.path.join(test_dir, "processing", "test_converter.py"), + "-v", + ] + ) + + cov.stop() + cov.save() + cov.report(show_missing=False) + + report_dir = os.path.join( + project_root, + "TestPlan", + "reports", + datetime.now().strftime("%Y-%m-%d"), + "converter", + ) + cov.html_report(directory=report_dir) + print(f"\nCoverage HTML report saved to: {report_dir}") diff --git a/example/example_calculator/tests/__init__.py b/example/example_calculator/tests/__init__.py new file mode 100644 index 0000000..a3b3a2f --- /dev/null +++ b/example/example_calculator/tests/__init__.py @@ -0,0 +1 @@ +# guitest: skip diff --git a/example/example_calculator/tests/moduletester_launcher.py b/example/example_calculator/tests/moduletester_launcher.py new file mode 100644 index 0000000..1d9183d --- /dev/null +++ b/example/example_calculator/tests/moduletester_launcher.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- + +""" +ModuleTester Integration Launcher for Example Calculator + +This script creates a .moduletester template file for the example_calculator +package and launches the ModuleTester GUI. It follows the same pattern as +the X-GRID launcher (xgrid/tests/module_tester/moduleTester_launcher.py). + +Usage:: + + # Generate a new template and launch ModuleTester GUI + python moduletester_launcher.py + + # Open an existing .moduletester file + python moduletester_launcher.py path/to/file.moduletester +""" + +# guitest: skip + +import os +import sys +from importlib import import_module + +from qtpy import QtWidgets as QW + +from example_calculator import __version__ +from moduletester.gui.main import run +from moduletester.manager import TestManager +from moduletester.model import Module + + +def create_template(): + """Create a .moduletester template file for Example Calculator tests. + + Returns: + str: Path to the generated .moduletester file. + """ + mod = import_module("example_calculator") + + project_dir = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + test_plan_dir = os.path.join(project_dir, "TestPlan") + os.makedirs(test_plan_dir, exist_ok=True) + + output_path = os.path.join( + test_plan_dir, + f"example_calculator_v{__version__}_.moduletester", + ) + print(f"Creating template at: {output_path}") + + manager = TestManager(Module(mod), _template_path=output_path, _category="visible") + + print(f"\nTemplate created successfully: {output_path}") + print(f"Found {len(manager.test_suite.tests)} tests") + + return output_path + + +if __name__ == "__main__": + app = QW.QApplication.instance() + if not app: + app = QW.QApplication(sys.argv) + + if len(sys.argv) > 1: + moduletester_file = sys.argv[1] + if not os.path.exists(moduletester_file): + print(f"Error: File not found: {moduletester_file}") + sys.exit(1) + else: + moduletester_file = create_template() + + moduletester = run(path=moduletester_file) + moduletester.window.show() + app.exec_() diff --git a/example/example_calculator/tests/processing/test_converter.py b/example/example_calculator/tests/processing/test_converter.py new file mode 100644 index 0000000..7472c25 --- /dev/null +++ b/example/example_calculator/tests/processing/test_converter.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- + +# guitest: skip + +"""Pytest unit tests for example_calculator.converter.""" + +import pytest +from example_calculator.converter import ( + celsius_to_fahrenheit, + celsius_to_kelvin, + fahrenheit_to_celsius, + feet_to_meters, + kelvin_to_celsius, + km_to_miles, + meters_to_feet, + miles_to_km, +) + + +class TestTemperature: + def test_celsius_to_fahrenheit(self): + assert celsius_to_fahrenheit(0) == 32.0 + assert celsius_to_fahrenheit(100) == 212.0 + + def test_fahrenheit_to_celsius(self): + assert fahrenheit_to_celsius(32) == 0.0 + assert fahrenheit_to_celsius(212) == 100.0 + + def test_celsius_to_kelvin(self): + assert celsius_to_kelvin(0) == 273.15 + assert celsius_to_kelvin(-273.15) == 0.0 + + def test_celsius_to_kelvin_below_absolute_zero(self): + with pytest.raises(ValueError, match="absolute zero"): + celsius_to_kelvin(-300) + + def test_kelvin_to_celsius(self): + assert kelvin_to_celsius(273.15) == 0.0 + assert kelvin_to_celsius(0) == -273.15 + + def test_kelvin_negative(self): + with pytest.raises(ValueError, match="negative"): + kelvin_to_celsius(-1) + + +class TestDistance: + def test_meters_to_feet(self): + assert abs(meters_to_feet(1) - 3.28084) < 1e-4 + + def test_feet_to_meters(self): + assert abs(feet_to_meters(3.28084) - 1.0) < 1e-4 + + def test_km_to_miles(self): + assert abs(km_to_miles(1) - 0.621371) < 1e-4 + + def test_miles_to_km(self): + assert abs(miles_to_km(0.621371) - 1.0) < 1e-4 diff --git a/example/example_calculator/tests/processing/test_operations.py b/example/example_calculator/tests/processing/test_operations.py new file mode 100644 index 0000000..7845313 --- /dev/null +++ b/example/example_calculator/tests/processing/test_operations.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- + +# guitest: skip + +"""Pytest unit tests for example_calculator.operations.""" + +import math + +import pytest +from example_calculator.operations import ( + add, + divide, + factorial, + multiply, + power, + sqrt, + subtract, +) + + +class TestAdd: + def test_positive_numbers(self): + assert add(2, 3) == 5 + + def test_negative_numbers(self): + assert add(-1, -2) == -3 + + def test_zero(self): + assert add(0, 0) == 0 + + +class TestSubtract: + def test_basic(self): + assert subtract(10, 4) == 6 + + def test_negative_result(self): + assert subtract(3, 7) == -4 + + +class TestMultiply: + def test_basic(self): + assert multiply(3, 4) == 12 + + def test_by_zero(self): + assert multiply(5, 0) == 0 + + +class TestDivide: + def test_basic(self): + assert divide(10, 2) == 5.0 + + def test_float_result(self): + assert divide(7, 2) == 3.5 + + def test_divide_by_zero(self): + with pytest.raises(ZeroDivisionError, match="Cannot divide by zero"): + divide(1, 0) + + +class TestPower: + def test_basic(self): + assert power(2, 3) == 8.0 + + def test_zero_exponent(self): + assert power(5, 0) == 1.0 + + +class TestSqrt: + def test_basic(self): + assert sqrt(9) == 3.0 + + def test_zero(self): + assert sqrt(0) == 0.0 + + def test_negative(self): + with pytest.raises(ValueError, match="negative"): + sqrt(-1) + + +class TestFactorial: + def test_basic(self): + assert factorial(5) == 120 + + def test_zero(self): + assert factorial(0) == 1 + + def test_negative(self): + with pytest.raises(ValueError, match="negative"): + factorial(-1) diff --git a/example/example_calculator/tests/templates/custom-reference.docx b/example/example_calculator/tests/templates/custom-reference.docx new file mode 100644 index 0000000..045165c Binary files /dev/null and b/example/example_calculator/tests/templates/custom-reference.docx differ diff --git a/example/example_calculator/tests/templates/custom-reference.odt b/example/example_calculator/tests/templates/custom-reference.odt new file mode 100644 index 0000000..63ad93d Binary files /dev/null and b/example/example_calculator/tests/templates/custom-reference.odt differ diff --git a/example/example_calculator/tests/templates/default_style.css b/example/example_calculator/tests/templates/default_style.css new file mode 100644 index 0000000..bb40de9 --- /dev/null +++ b/example/example_calculator/tests/templates/default_style.css @@ -0,0 +1,408 @@ +/* + * I add this to html files generated with pandoc. + */ + +html { + font-size: 100%; + overflow-y: scroll; + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; +} + +body { + color: #444; + font-family: Georgia, Palatino, 'Palatino Linotype', Times, 'Times New Roman', serif; + font-size: 12px; + line-height: 1.7; + padding: 2em; + margin: auto; + background: #fefefe; +} + +a { + color: #0645ad; + text-decoration: none; +} + +a:visited { + color: #0b0080; +} + +a:hover { + color: #06e; +} + +a:active { + color: #faa700; +} + +a:focus { + outline: thin dotted; +} + +*::-moz-selection { + background: rgba(255, 255, 0, 0.3); + color: #000; +} + +*::selection { + background: rgba(255, 255, 0, 0.3); + color: #000; +} + +a::-moz-selection { + background: rgba(255, 255, 0, 0.3); + color: #0645ad; +} + +a::selection { + background: rgba(255, 255, 0, 0.3); + color: #0645ad; +} + +p { + margin: 1em 0; +} + +img { + max-width: 100%; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + color: #111; + line-height: 125%; + margin-top: 2em; + font-weight: normal; +} + +h4, +h5, +h6 { + font-weight: bold; +} + +h1 { + font-size: 2.5em; +} + +h2 { + font-size: 2em; +} + +h3 { + font-size: 1.5em; +} + +h4 { + font-size: 1.2em; +} + +h5 { + font-size: 1em; +} + +h6 { + font-size: 0.9em; +} + +blockquote { + color: #666666; + margin: 0; + padding-left: 3em; + border-left: 0.5em #EEE solid; +} + +hr { + display: block; + height: 2px; + border: 0; + border-top: 1px solid #aaa; + border-bottom: 1px solid #eee; + margin: 1em 0; + padding: 0; +} + +pre, +code, +kbd, +samp { + color: #000; + font-family: monospace, monospace; + _font-family: 'courier new', monospace; + font-size: 0.98em; +} + +pre { + white-space: pre; + white-space: pre-wrap; + word-wrap: break-word; +} + +b, +strong { + font-weight: bold; +} + +dfn { + font-style: italic; +} + +ins { + background: #ff9; + color: #000; + text-decoration: none; +} + +mark { + background: #ff0; + color: #000; + font-style: italic; + font-weight: bold; +} + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sup { + top: -0.5em; +} + +sub { + bottom: -0.25em; +} + +ul, +ol { + margin: 1em 0; + padding: 0 0 0 2em; +} + +li p:last-child { + margin-bottom: 0; +} + +ul ul, +ol ol { + margin: .3em 0; +} + +dl { + margin-bottom: 1em; +} + +dt { + font-weight: bold; + margin-bottom: .8em; +} + +dd { + margin: 0 0 .8em 2em; +} + +dd:last-child { + margin-bottom: 0; +} + +img { + border: 0; + -ms-interpolation-mode: bicubic; + vertical-align: middle; +} + +figure { + display: block; + text-align: center; + margin: 1em 0; +} + +figure img { + border: none; + margin: 0 auto; +} + +figcaption { + font-size: 0.8em; + font-style: italic; + margin: 0 0 .8em; +} + +table { + margin-bottom: 2em; + border-bottom: 1px solid #ddd; + border-right: 1px solid #ddd; + border-spacing: 0; + border-collapse: collapse; + width: 100%; +} + +table th { + padding: .2em 1em; + background-color: #eee; + border-top: 1px solid #ddd; + border-left: 1px solid #ddd; +} + +table td { + padding: .2em 1em; + border-top: 1px solid #ddd; + border-left: 1px solid #ddd; + vertical-align: top; +} + +.author { + font-size: 1.2em; + text-align: center; +} + +@media only screen and (min-width: 480px) { + body { + font-size: 14px; + } +} + +@media only screen and (min-width: 768px) { + body { + font-size: 16px; + } +} + +@media print { + * { + background: transparent !important; + color: black !important; + filter: none !important; + -ms-filter: none !important; + } + + body { + font-size: 12pt; + max-width: 100%; + } + + a, + a:visited { + text-decoration: underline; + } + + hr { + height: 1px; + border: 0; + border-bottom: 1px solid black; + } + + a[href]:after { + content: " (" attr(href) ")"; + } + + abbr[title]:after { + content: " (" attr(title) ")"; + } + + .ir a:after, + a[href^="javascript:"]:after, + a[href^="#"]:after { + content: ""; + } + + pre, + blockquote { + border: 1px solid #999; + padding-right: 1em; + page-break-inside: avoid; + } + + tr, + img { + page-break-inside: avoid; + } + + img { + max-width: 100% !important; + } + + @page :left { + margin: 15mm 20mm 15mm 10mm; + } + + @page :right { + margin: 15mm 10mm 15mm 20mm; + } + + p, + h2, + h3 { + orphans: 3; + widows: 3; + } + + h2, + h3 { + page-break-after: avoid; + } +} + +/* ADDITIONAL CUSTOM STYLING */ + +.section-block { + border: 1px solid #ccc; + border-radius: 5px; + padding: 1em; + margin: 1em 0; + box-shadow: 5px 5px 10px lightgrey; +} + +.result-block { + display: flex; + align-items: center; +} + +.result-block img { + margin-right: 1em; +} + +.new-page { + page-break-before: always; +} + + +nav#TOC { + border: 1px solid #ccc; + border-radius: 5px; + padding: 1em; + margin: 1em 0; + box-shadow: 5px 5px 10px lightgrey; +} + +/* Common code properties */ +code { + background-color: WhiteSmoke; +} + +/* Code block */ +:not(p):not(td)>code { + box-shadow: 5px 5px 10px lightgrey; + border-radius: 10px; + display: block; + padding: 1em; +} + +/* Inline code */ +p code { + border-radius: 5px; + padding: 0.2em; +} + +td code { + border-radius: 5px; + padding: 0em; +} \ No newline at end of file diff --git a/example/example_calculator/tests/templates/test_list_template.j2 b/example/example_calculator/tests/templates/test_list_template.j2 new file mode 100644 index 0000000..dd1cad3 --- /dev/null +++ b/example/example_calculator/tests/templates/test_list_template.j2 @@ -0,0 +1,39 @@ + + + + + {{ _("Test List Document") }}: {{ doc_obj.test_suite.package.full_name.capitalize() }} + + + +

+

{{ doc_obj.test_suite.author or "Unknown author " }}
+

+

{{ _("Last execution date:") }} {{ doc_obj.test_suite.last_run }}

+

Description

+
+ {{ doc_obj.test_suite.get_fmt_description("html").strip() or "No package description found."}}
+ {% for section_name, tests in doc_obj.test_suite.group_tests().items() %} +
+ +

Test sub-module: {{ section_name.rsplit(".", 1)[-1] }}

+ + {% for test in tests %} +
+ {% set test_name_split = test.package.last_name.split("_") %} +

{{test_name_split[0].capitalize()}}: {{" ".join(test_name_split[1:])}}

+
+

Description

+ {{ test.get_html_description(standalone=False, embeded=True, + shift_header=doc_obj.docstrings_header_shift)|safe + }} + +
+
+
+ {% endfor %} +
+ {% endfor %} + + + \ No newline at end of file diff --git a/example/example_calculator/tests/templates/test_results_template.j2 b/example/example_calculator/tests/templates/test_results_template.j2 new file mode 100644 index 0000000..59241d0 --- /dev/null +++ b/example/example_calculator/tests/templates/test_results_template.j2 @@ -0,0 +1,116 @@ + + + + + {{ _("Test Results Document") }}: {{ doc_obj.test_suite.package.full_name.capitalize() }} + + + +

+

{{ doc_obj.test_suite.author or "Unknown author " }}
+

+

{{ _("Last execution date:") }} {{ doc_obj.test_suite.last_run }}

+

Description

+
+ {% set extra_desc_gen_args = ["--shift-heading-level-by={s}".format(s=doc_obj.docstrings_header_shift)] %} + {{ doc_obj.test_suite.get_fmt_description("html", extra_desc_gen_args + ).strip() or "No package description found."}} +
+ {% for section_name, tests in doc_obj.test_suite.group_tests().items() %} +
+ +

Test sub-module: {{ section_name.rsplit(".", 1)[-1] }}

+ + {% for test in tests %} +
+ {% set test_name_split = test.package.last_name.split("_") %} +

{{test_name_split[0].capitalize()}}: {{" ".join(test_name_split[1:])}}

+
+

Description

+ {{ test.get_html_description(standalone=False, embeded=True, + shift_header=doc_obj.docstrings_header_shift)|safe + }} + +
+ +
+

Command

+ {{ test.command or "-" }} +
+ +
+

Result

+ {% if test.result != None %} +
+ +

{{ test.result.result_name }}, executed on {{ + test.result.last_run or "-"}}

+
+ {% else %} +

No result found.

+ {% endif %} +

{{ test.comment|safe }}

+ +
+ {% for image_path in doc_obj.get_images_paths(test) %} + Test Image + {% endfor %} +
+
+
+
+ {% endfor %} +
+ {% endfor %} + +
+
+

Summary

+

{{_("%s test results summary") % doc_obj.test_suite.package.last_name.capitalize() }}

+ + + + + + + + + + + {% for test in doc_obj.test_suite.tests %} + + {% set result_bin = test.result_binary_label() %} + + + + + + + + + {% endfor %} +
Test{{ _("No result") }}{{ _("Accepted") }}{{ _("Accepted with reserve") }}{{ _("Skipped") }}{{ _("Rejected") }}Execution date
{{ test.package.full_name }}{{ "X" if result_bin[0] == 1 else "" }}{{ "X" if result_bin[1] == 1 else "" }}{{ "X" if result_bin[2] == 1 else "" }}{{ "X" if result_bin[3] == 1 else "" }}{{ "X" if result_bin[4] == 1 else "" }}{{ test.result.last_run }}
+

{{_("Count by result cateogry")}}

+ {% set result_count = doc_obj.test_suite.results_count() %} + + + + + + + + + + + + + + + +
{{ _("No result") }}{{ _("Accepted") }}{{ _("Accepted with reserve") }}{{ _("Skipped") }}{{ _("Rejected") }}
{{ result_count[0] }}{{ result_count[1] }}{{ result_count[2] }}{{ result_count[3] }}{{ result_count[4] }}
+
+
+ + + + \ No newline at end of file diff --git a/example/pyproject.toml b/example/pyproject.toml new file mode 100644 index 0000000..fa95b83 --- /dev/null +++ b/example/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "example-calculator" +version = "1.0.0" +description = "A minimal calculator project demonstrating ModuleTester integration" +readme = "README.md" +requires-python = ">=3.9" +dependencies = [ + "QtPy >= 1.9", +] + +[project.optional-dependencies] +test = [ + "pytest", + "coverage", +] + +[tool.setuptools.packages.find] +include = ["example_calculator*"] diff --git a/moduletester/__init__.py b/moduletester/__init__.py index 3c49739..65c310f 100644 --- a/moduletester/__init__.py +++ b/moduletester/__init__.py @@ -16,7 +16,7 @@ .. _PlotPyStack: https://github.com/PlotPyStack """ -__version__ = "1.0.0" +__version__ = "1.0.1" __docurl__ = "https://moduletester.readthedocs.io/en/latest/" __homeurl__ = "https://codra-ingenierie-informatique.github.io/moduletester/" __supporturl__ = ( diff --git a/moduletester/gui/widgets/result_error_widget.py b/moduletester/gui/widgets/result_error_widget.py index 54f2a77..76ded72 100644 --- a/moduletester/gui/widgets/result_error_widget.py +++ b/moduletester/gui/widgets/result_error_widget.py @@ -37,6 +37,7 @@ def __init__(self, parent: Optional[QW.QWidget] = None): ) self.label.setAlignment(QC.Qt.AlignmentFlag.AlignTop) self.label.setFrameStyle(0) + self.label.setFont(QG.QFontDatabase.systemFont(QG.QFontDatabase.FixedFont)) self.icon.setFixedWidth(32) self.icon.setAlignment(QC.Qt.AlignmentFlag.AlignTop) diff --git a/moduletester/gui/widgets/result_output_widget.py b/moduletester/gui/widgets/result_output_widget.py index 60f7b2d..055be03 100644 --- a/moduletester/gui/widgets/result_output_widget.py +++ b/moduletester/gui/widgets/result_output_widget.py @@ -29,6 +29,7 @@ def __init__(self, parent: Optional[QW.QWidget] = None): self.label.setTextInteractionFlags(QC.Qt.TextSelectableByMouse) self.label.setFrameStyle(0) self.label.setAlignment(QC.Qt.AlignTop) + self.label.setFont(QG.QFontDatabase.systemFont(QG.QFontDatabase.FixedFont)) self.icon.setFixedWidth(32) self.icon.setAlignment(QC.Qt.AlignTop) diff --git a/pyproject.toml b/pyproject.toml index 2b13163..f1b58db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ moduletester-cli = "moduletester.manager:cli" moduletester = "moduletester.gui.main:run_gui" [project.optional-dependencies] -dev = ["ruff", "pylint", "pytest", "Coverage", "build"] +dev = ["ruff", "pylint", "pytest", "Coverage", "build", "pypandoc_binary"] doc = ["PyQt5", "sphinx>6", "pydata_sphinx_theme"] [tool.setuptools.packages.find] diff --git a/requirements.txt b/requirements.txt index 54bfee5..dc8f1b0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,15 @@ -PyQt5 +beautifulsoup4 +build +Coverage guidata -pylint -black -coverage -pycodestyle -pyinstaller -sphinx +jinja2 pydata_sphinx_theme -build -twine +pylint pypandoc -jinja2 \ No newline at end of file +pypandoc_binary +PyQt5 +pyqtwebengine +pytest +QtPy +ruff +sphinx diff --git a/scripts/run_example.py b/scripts/run_example.py new file mode 100644 index 0000000..db62183 --- /dev/null +++ b/scripts/run_example.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- + +"""Launch the ModuleTester GUI with the Example Calculator test suite. + +This script mirrors the pattern used in X-GRID's ``run_test_plan.bat`` / +``moduleTester_launcher.py``, but as a standalone Python script suitable +for use as a VS Code task. + +Usage:: + + python scripts/run_example.py +""" + +import subprocess +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +PROJECT_ROOT = SCRIPT_DIR.parent + +LAUNCHER = ( + PROJECT_ROOT + / "example" + / "example_calculator" + / "tests" + / "moduletester_launcher.py" +) + + +def main(): + """Run the Example Calculator ModuleTester launcher.""" + if not LAUNCHER.exists(): + print(f"Error: launcher not found at {LAUNCHER}") + sys.exit(1) + + print(f"Launching ModuleTester example from: {LAUNCHER}") + result = subprocess.run( + [sys.executable, str(LAUNCHER)], + cwd=str(PROJECT_ROOT / "example"), + ) + sys.exit(result.returncode) + + +if __name__ == "__main__": + main() diff --git a/scripts/update_requirements.py b/scripts/update_requirements.py new file mode 100644 index 0000000..1b14417 --- /dev/null +++ b/scripts/update_requirements.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- + +"""Update requirements.txt from pyproject.toml. + +Combines [project.dependencies] and all [project.optional-dependencies] +groups into a single requirements.txt file at the repository root. +""" + +import os +import re +import sys + +if sys.version_info >= (3, 11): + import tomllib +else: + try: + import tomllib + except ImportError: + import tomli as tomllib # type: ignore + + +def strip_version_specifiers(dep: str) -> str: + """Return the package name without version specifiers. + + Example: 'guidata >= 3.14' -> 'guidata' + """ + return re.split(r"[><=!~\s;]", dep)[0].strip() + + +def generate_requirements_txt(pyproject_path: str, output_path: str) -> None: + """Generate requirements.txt from pyproject.toml.""" + with open(pyproject_path, "rb") as f: + data = tomllib.load(f) + + project = data.get("project", {}) + all_deps: dict[str, str] = {} + + # Collect main dependencies + for dep in project.get("dependencies", []): + name = strip_version_specifiers(dep) + all_deps[name.lower()] = name + + # Collect all optional-dependencies groups + for group, deps in project.get("optional-dependencies", {}).items(): + for dep in deps: + name = strip_version_specifiers(dep) + all_deps[name.lower()] = name + + # Sort case-insensitively and write + sorted_deps = sorted(all_deps.values(), key=str.lower) + with open(output_path, "w", encoding="utf-8") as f: + f.write("\n".join(sorted_deps) + "\n") + + +if __name__ == "__main__": + root_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + pyproject_path = os.path.join(root_dir, "pyproject.toml") + output_path = os.path.join(root_dir, "requirements.txt") + print("Updating requirements.txt from pyproject.toml...", end=" ") + generate_requirements_txt(pyproject_path, output_path) + print("done.")