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.
+
+

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
+ 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 @@
+
+
+
+
+
{{ _("Last execution date:") }} {{ doc_obj.test_suite.last_run }}
++
{{ _("Last execution date:") }} {{ doc_obj.test_suite.last_run }}
+{{ test.command or "-" }}
+ {{ test.result.result_name }}, executed on {{ + test.result.last_run or "-"}}
+No result found.
+ {% endif %} +{{ test.comment|safe }}
+ +| 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 }} | +
| {{ _("No result") }} | +{{ _("Accepted") }} | +{{ _("Accepted with reserve") }} | +{{ _("Skipped") }} | +{{ _("Rejected") }} | +
|---|---|---|---|---|
| {{ result_count[0] }} | +{{ result_count[1] }} | +{{ result_count[2] }} | +{{ result_count[3] }} | +{{ result_count[4] }} | +