From 3afb6288462010489fb11b14d1aa13817cdde188 Mon Sep 17 00:00:00 2001
From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com>
Date: Thu, 21 May 2026 14:19:41 +0200
Subject: [PATCH 1/9] add missing PyQtWebEngine to requirements
---
requirements.txt | 1 +
1 file changed, 1 insertion(+)
diff --git a/requirements.txt b/requirements.txt
index 54bfee5..b8e825e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,5 @@
PyQt5
+PyQtWebEngine
guidata
pylint
black
From 0af63f6557c26ac77f1338f8f5c728a9a16470f4 Mon Sep 17 00:00:00 2001
From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com>
Date: Thu, 21 May 2026 14:20:27 +0200
Subject: [PATCH 2/9] change launch configuration type from python to debugpy
---
.vscode/launch.json | 7 ++-----
1 file changed, 2 insertions(+), 5 deletions(-)
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",
From 10feb858a5d3e9bfb5d0261df1211cf50519aa87 Mon Sep 17 00:00:00 2001
From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com>
Date: Thu, 21 May 2026 14:29:20 +0200
Subject: [PATCH 3/9] add pypandoc_binary to requirements
---
requirements.txt | 1 +
1 file changed, 1 insertion(+)
diff --git a/requirements.txt b/requirements.txt
index b8e825e..2c51579 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -11,4 +11,5 @@ pydata_sphinx_theme
build
twine
pypandoc
+pypandoc_binary
jinja2
\ No newline at end of file
From 83e0083135f87dcabe7a36b1fee3e18e33966840 Mon Sep 17 00:00:00 2001
From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com>
Date: Thu, 21 May 2026 14:31:58 +0200
Subject: [PATCH 4/9] set fixed font for result label in ResultError and
ResultOutput widgets
---
moduletester/gui/widgets/result_error_widget.py | 1 +
moduletester/gui/widgets/result_output_widget.py | 1 +
2 files changed, 2 insertions(+)
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)
From 46952f6017f4a0c050d453a7ed064c869a7d17bf Mon Sep 17 00:00:00 2001
From: Thomas MALLET
Date: Thu, 21 May 2026 17:22:34 +0200
Subject: [PATCH 5/9] bump version to 1.0.1
---
CHANGELOG.md | 10 ++++++++++
doc/changelog.rst | 8 ++++++++
moduletester/__init__.py | 2 +-
3 files changed, 19 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b62cfe1..de09456 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,15 @@
# ModuleTester Releases #
+## Version 1.0.1 ##
+
+### Bug fixes
+
+*To be documented*
+
+### Improvements
+
+*To be documented*
+
## Version 1.0.0 ##
First stable release of ModuleTester.
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/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__ = (
From ad1fa88bf2200f55d8013673313664935d951321 Mon Sep 17 00:00:00 2001
From: Thomas MALLET
Date: Thu, 21 May 2026 17:45:08 +0200
Subject: [PATCH 6/9] update changelog and add deps requirement sync
script/task
---
.vscode/tasks.json | 24 +++++++++++++
CHANGELOG.md | 7 ++--
pyproject.toml | 2 +-
requirements.txt | 22 ++++++------
scripts/update_requirements.py | 61 ++++++++++++++++++++++++++++++++++
5 files changed, 102 insertions(+), 14 deletions(-)
create mode 100644 scripts/update_requirements.py
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index ccdf3a1..203be85 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -368,6 +368,30 @@
"๐งน 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": "โ Untracked files",
"type": "shell",
diff --git a/CHANGELOG.md b/CHANGELOG.md
index de09456..490e2c2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,11 +4,14 @@
### Bug fixes
-*To be documented*
+- Set fixed font for result label in `ResultError` and `ResultOutput` widgets
+- Change launch configuration type from `python` to `debugpy`
### Improvements
-*To be documented*
+- Add `pypandoc_binary` to requirements
+- Add missing `PyQtWebEngine` to requirements
+- Add deps requirements sync script `scripts\update_requirements.py`
## Version 1.0.0 ##
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 2c51579..dc8f1b0 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,15 +1,15 @@
-PyQt5
-PyQtWebEngine
+beautifulsoup4
+build
+Coverage
guidata
-pylint
-black
-coverage
-pycodestyle
-pyinstaller
-sphinx
+jinja2
pydata_sphinx_theme
-build
-twine
+pylint
pypandoc
pypandoc_binary
-jinja2
\ No newline at end of file
+PyQt5
+pyqtwebengine
+pytest
+QtPy
+ruff
+sphinx
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.")
From 48bedf2c30ff77b913118d633203c04683bc8a61 Mon Sep 17 00:00:00 2001
From: Thomas MALLET
Date: Thu, 21 May 2026 18:50:19 +0200
Subject: [PATCH 7/9] implement example
---
.vscode/tasks.json | 25 ++
example/README.md | 138 ++++++
.../performance/performance_report.txt | 19 +
.../2026-05-21/precision/precision_report.txt | 23 +
example/example_calculator/__init__.py | 13 +
example/example_calculator/app.py | 166 +++++++
example/example_calculator/converter.py | 63 +++
example/example_calculator/moduletester.ini | 83 ++++
example/example_calculator/operations.py | 60 +++
.../Test Plan/Manual GUI Tests/test-001.py | 51 +++
.../Test Plan/Manual GUI Tests/test-002.py | 45 ++
.../Test Plan/Manual GUI Tests/test-003.py | 44 ++
.../Qualification Tests/001-precision.py | 121 ++++++
.../Qualification Tests/002-performance.py | 125 ++++++
.../Test Plan/Unit Tests/001-operations.py | 64 +++
.../Test Plan/Unit Tests/002-converter.py | 64 +++
example/example_calculator/tests/__init__.py | 1 +
.../tests/moduletester_launcher.py | 76 ++++
.../tests/processing/test_converter.py | 57 +++
.../tests/processing/test_operations.py | 89 ++++
.../tests/templates/custom-reference.docx | Bin 0 -> 21744 bytes
.../tests/templates/custom-reference.odt | Bin 0 -> 9589 bytes
.../tests/templates/default_style.css | 408 ++++++++++++++++++
.../tests/templates/test_list_template.j2 | 39 ++
.../tests/templates/test_results_template.j2 | 116 +++++
example/pyproject.toml | 22 +
scripts/run_example.py | 45 ++
27 files changed, 1957 insertions(+)
create mode 100644 example/README.md
create mode 100644 example/example_calculator/TestPlan/reports/2026-05-21/performance/performance_report.txt
create mode 100644 example/example_calculator/TestPlan/reports/2026-05-21/precision/precision_report.txt
create mode 100644 example/example_calculator/__init__.py
create mode 100644 example/example_calculator/app.py
create mode 100644 example/example_calculator/converter.py
create mode 100644 example/example_calculator/moduletester.ini
create mode 100644 example/example_calculator/operations.py
create mode 100644 example/example_calculator/tests/Test Plan/Manual GUI Tests/test-001.py
create mode 100644 example/example_calculator/tests/Test Plan/Manual GUI Tests/test-002.py
create mode 100644 example/example_calculator/tests/Test Plan/Manual GUI Tests/test-003.py
create mode 100644 example/example_calculator/tests/Test Plan/Qualification Tests/001-precision.py
create mode 100644 example/example_calculator/tests/Test Plan/Qualification Tests/002-performance.py
create mode 100644 example/example_calculator/tests/Test Plan/Unit Tests/001-operations.py
create mode 100644 example/example_calculator/tests/Test Plan/Unit Tests/002-converter.py
create mode 100644 example/example_calculator/tests/__init__.py
create mode 100644 example/example_calculator/tests/moduletester_launcher.py
create mode 100644 example/example_calculator/tests/processing/test_converter.py
create mode 100644 example/example_calculator/tests/processing/test_operations.py
create mode 100644 example/example_calculator/tests/templates/custom-reference.docx
create mode 100644 example/example_calculator/tests/templates/custom-reference.odt
create mode 100644 example/example_calculator/tests/templates/default_style.css
create mode 100644 example/example_calculator/tests/templates/test_list_template.j2
create mode 100644 example/example_calculator/tests/templates/test_results_template.j2
create mode 100644 example/pyproject.toml
create mode 100644 scripts/run_example.py
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index 203be85..24143e8 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -392,6 +392,31 @@
},
"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/example/README.md b/example/README.md
new file mode 100644
index 0000000..8ab9ebe
--- /dev/null
+++ b/example/README.md
@@ -0,0 +1,138 @@
+# 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
+ โโโ 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
+ โโโ processing/
+ โโโ test_operations.py # Actual pytest test cases
+ โโโ test_converter.py # Actual pytest test cases
+```
+
+## 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 0000000000000000000000000000000000000000..045165cc66d715de1b1584aacc9878d01ce1a9cb
GIT binary patch
literal 21744
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
literal 0
HcmV?d00001
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 0000000000000000000000000000000000000000..63ad93d59d9caddaf967061fc30893685f009423
GIT binary patch
literal 9589
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
4Bz)5ltEm*iGRf^S3V&Dj;CLq=j;s
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
YRpj9y>L&n169o^@gETPVsQ(`QFAYw&ssI20
literal 0
HcmV?d00001
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) %}
+
+ {% endfor %}
+
+
+
+
+ {% endfor %}
+
+ {% endfor %}
+
+
+
+ Summary
+ {{_("%s test results summary") % doc_obj.test_suite.package.last_name.capitalize() }}
+
+
+ | Test |
+ {{ _("No result") }} |
+ {{ _("Accepted") }} |
+ {{ _("Accepted with reserve") }} |
+ {{ _("Skipped") }} |
+ {{ _("Rejected") }} |
+ Execution date |
+
+ {% for test in doc_obj.test_suite.tests %}
+
+ {% set result_bin = test.result_binary_label() %}
+ | {{ 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 }} |
+
+ {% endfor %}
+
+ {{_("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/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()
From b2e119bdf8e1780d7d832fd4819757d223de848a Mon Sep 17 00:00:00 2001
From: Thomas MALLET
Date: Fri, 22 May 2026 11:55:00 +0200
Subject: [PATCH 8/9] update changelog and add specific step-by-step
implementation documentation throught the new example given
---
CHANGELOG.md | 9 +
README.md | 9 +-
doc/example.rst | 501 +++++++++++++++++++++++++++++++++++++++++++++-
example/README.md | 28 +--
4 files changed, 522 insertions(+), 25 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 490e2c2..0e09419 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,14 @@
# 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
diff --git a/README.md b/README.md
index a34892c..06651e4 100644
--- a/README.md
+++ b/README.md
@@ -75,9 +75,12 @@ 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.

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/example/README.md b/example/README.md
index 8ab9ebe..c4ec6fe 100644
--- a/example/README.md
+++ b/example/README.md
@@ -20,19 +20,21 @@ example/
โโโ tests/
โโโ __init__.py
โโโ moduletester_launcher.py # Launcher for ModuleTester GUI
- โโโ 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
- โโโ processing/
- โโโ test_operations.py # Actual pytest test cases
- โโโ test_converter.py # Actual pytest test cases
+ โโโ 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
From 8693491fb58caba18a9dd0535d9dc7bb65504223 Mon Sep 17 00:00:00 2001
From: Thomas MALLET
Date: Fri, 22 May 2026 12:02:02 +0200
Subject: [PATCH 9/9] update and add doc image screenshot with example project
---
README.md | 2 ++
doc/cli.rst | 4 ++--
doc/images/shots/example.moduletester.png | Bin 0 -> 75974 bytes
doc/user_guide.rst | 14 +++++++++++---
4 files changed, 15 insertions(+), 5 deletions(-)
create mode 100644 doc/images/shots/example.moduletester.png
diff --git a/README.md b/README.md
index 06651e4..8ad357c 100644
--- a/README.md
+++ b/README.md
@@ -82,6 +82,8 @@ 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.
+
+

## Documentation
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/images/shots/example.moduletester.png b/doc/images/shots/example.moduletester.png
new file mode 100644
index 0000000000000000000000000000000000000000..67adee786f5156e866bcfe902f952d47670200d5
GIT binary patch
literal 75974
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