24 Commits

Author SHA1 Message Date
9db0f89522 removed reference to "terminal" mode. Corrected errors in doc generation. 2026-05-01 08:20:29 +02:00
f38a24190d Adopt EUPL-1.2 licence for the project
Add LICENSE (full EUPL-1.2 text + project copyright), CONTRIBUTING.md
with the inbound = outbound rule, and declare the licence in
pyproject.toml and README.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 07:58:22 +02:00
b16494ef6d Preserve test tree check and fold state across reload
Reload (re-loading the same .tum file) was rebuilding the GUI tree
from scratch, resetting every checkbox and unfolding everything.
Snapshot the user's selection and fold state via the existing
getCheckList/restoreCheckList and getFoldList/restoreFoldList methods
(already used for session persistence through prefs), so a same-file
reload keeps both as well. A change in the total item count (file
edited between loads) skips the restore.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 23:51:36 +02:00
b175ff4189 Add distinct icon for parallel branch tree items
A parallel branch now displays a single-arrow icon (parallel_branch.png)
distinct from the parallel container's three-arrow icon, making the
tree hierarchy easier to read at a glance.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 23:41:31 +02:00
d66a46736f Hide branch steps in parallel item F1 panel
The 'branches' list shown in the F1 panel previously contained each
branch's full 'steps', duplicating what is already visible in the test
tree. Strip 'steps' inside each branch dict while keeping 'branches'
itself so per-branch attributes (name, wait_for, condition, ...) stay
visible.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 23:35:43 +02:00
1b2d427ced Add parallel test item with thread-aware stdout routing
The parallel item runs branches concurrently with sync:all or sync:any
policy and optional per-branch wait_for synchronization. Each branch
runs in its own daemon thread and produces a clean per-item entry in
the SQLite report; the live output is prefixed [<branch_name>] so
concurrent branches stay readable.

Supporting changes:
- StdoutProxy (lib/stdout_redirect.py): thread-aware sys.stdout/stderr
  with per-thread capture buffers and per-branch live-output prefix.
  Adds writeln() for Python 3.14 unittest compatibility.
- TestItemContainer: shared base extracted from Group/Cycle for the
  sequential children execution pattern.
- TestItemSleep: interruptible loop polling _is_stopped so sync:any
  can cancel slow branches quickly.
- TestReport: thread-safe SQLite (check_same_thread=False + lock).

Also drops the unused -m/--terminal mode and its module.

Validation: 11 scenarios in test/validation/items/parallel covering
sync:all/any, no_fail, wait_for + timeout, conditions, multi-branch,
nested parallel, parallel inside loop, real branch failure.

Documentation: new parallel_test_item.rst added to the manual.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 23:23:31 +02:00
be540cd304 text mode effort finished on batch. 2026-04-27 17:20:15 +02:00
476b59c6f7 icons 2026-04-27 17:19:14 +02:00
bcafbfae18 Changed the "run" icon by testium. 2026-04-27 12:04:19 +02:00
e56a1f72c8 requirements changed for doc in readme. 2026-04-27 08:07:53 +02:00
83411482b2 Rename unittest_file item to unittest
- constants.py: TYPE_UNITTEST_FILE → TYPE_UNITTEST, cmd "unittest_file" → "unittest"
- All Python files updated: test_item_unittest.py, test_set.py, test_init.py,
  terminal.py, report_export_txt.py, test_tree_item.py
- All .tum files updated (examples, validation, doc)
- Sphinx doc: unittest_file_test_item.rst → unittest_test_item.rst,
  all references updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 08:05:40 +02:00
a28e644621 doc: use PASS/FAIL terminology in run item doc
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 07:59:54 +02:00
4a4a70b5f6 pdf doc updated 2026-04-27 07:56:43 +02:00
06c4cc62c6 doc: update run item documentation
Clarify result semantics (SUCCESS on launch, not on sub-test result),
batch vs GUI mode behaviour, and clean up attribute descriptions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 07:53:26 +02:00
60dbcf0252 Fix run item and batch mode robustness
- run item: rename tum_fime→tum, remove stdout=PIPE (deadlock with
  spawn), support batch mode (-b), SUCCESS on any completed subprocess
  regardless of sub-test result
- batch.py: fix control("loaded") deadlock via daemon thread + Event +
  is_alive() polling; fix premature finish on gd_update messages;
  propagate success flag from finished message; guard control("close")
- process.py: include success flag in send_finished message
- py_process/lua_process: add stdout/stderr=DEVNULL to Popen
- test_run.py: fix finished detection ("id" in m and m["id"] is None)
- testium_win.py: track run_exit_code, SIGABRT handler, clean exit
- __init__.py: sys.exit with batch success flag
- Add run item validation tests and CLAUDE.md documentation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 07:49:16 +02:00
a3e449cc7d in batch mode, the dialog allways return FAIL, except if auto_result is defined (validation only).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 11:52:52 +02:00
95107117fa Color is automatically adapted to the theme of the console.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 11:50:28 +02:00
88cc410eed fix of blocking of the text output in batch mode.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 11:49:52 +02:00
fa7f8cef7c Text mode upgrade (to be fixed). 2026-04-26 09:20:39 +02:00
5a065128be Fix lua delgd test: use sentinel default instead of nil comparison
cjson decodes JSON null as cjson.null, not Lua nil, so == nil always fails.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 10:25:30 +02:00
b7b930aab1 Add validation tests for OS, get_main_dir, timestamp, timestamp_as_sec helper functions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 10:21:56 +02:00
609ca57202 Add delgd validation test for py_func and lua_func
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 10:18:31 +02:00
d26b60435b Add auto_result param to dialog items for automated validation
Each dialog test item now accepts an optional auto_result parameter
(ok/cancel/yes/no) and auto_value for text dialogs. When set, the dialog
window opens, stays visible 2 seconds, then closes automatically with the
specified result — allowing the validation suite to run without manual
interaction.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 10:07:07 +02:00
de143b6cc3 Fix EOFError crash when dialog subprocess exits without sending a result
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 10:06:55 +02:00
101 changed files with 3455 additions and 879 deletions

169
CLAUDE.md Normal file
View File

@@ -0,0 +1,169 @@
# Testium — Claude Context
## What is testium
Testium is a test sequencer/runner written in Python. It executes YAML-based test scripts ("`.tum`" files) and supports two execution modes:
- **GUI mode** (default, no flag): PySide6 Qt application (`src/testium/main_win/`)
- **Batch mode** (`-b` / `--batch-execution`): headless, non-interactive, runs tests and exits
Run from repo root: `./run.sh` (Linux) or `run.bat` / `run.ps1` (Windows).
Direct invocation: `python3 -m src/testium [-b] <test_file.tum>`
## Architecture
### Entry point
`src/testium/__init__.py` — parses CLI args, dispatches to the two modes.
`multiprocessing.set_start_method('spawn')` is called early (required for Linux dialog subprocesses).
### Core execution
- `src/testium/interpreter/process.py``TestProcess(multiprocessing.Process)`: runs the test in a child process. Stdout is redirected via a `StringQueue` → pipe → parent thread (`capture_stdout`) that writes to real stdout.
- `src/testium/interpreter/batch.py``Batch`: parent-side orchestrator for `-b` mode. Creates the `msg_queue`, starts `TestProcess`, waits for the "finished" signal.
- `src/testium/interpreter/test_set.py``TestSet`: builds and executes the tree of test items.
- `src/testium/interpreter/test_items/test_item*.py` — one file per test item type (check, cycle, group, let, unittest, py_func, lua_func, console, git, dialogs, report, parallel, …).
### Communication channels (parent ↔ child process)
- `msg_queue` (`multiprocessing.Queue`): carries status messages from child to parent.
- Item status: `{"id": <non-None>, "name": ..., "status": "started"|"finished", ...}`
- Global dict updates: `{"type": "gd_update"|"gd_delete", "key": ..., "value": ...}`**no "id" key**
- Process finished: `{"id": None, "name": "test_process", "status": "finished"}` — id key present but `None`
- `tst_ctrl` (`TestSetController`): sends control commands (execute, stop, pause, close, …) from parent to child.
- stdout pipe (`multiprocessing.Pipe`): streams test output from child back to parent's `capture_stdout` thread.
### Stdout pipeline (batch mode)
```
test item print()
→ sys.stdout (StringQueue, in child)
→ send_stdout thread (child) → pipe → capture_stdout thread (parent)
→ print() → sys.stdout (TermLog wrapping real stdout, in parent)
→ terminal
```
### Global dictionary
`src/testium/interpreter/utils/globdict.py` — shared state accessible from test scripts via `tm.gd()` / `tm.setgd()`. When `set_update_queue()` is active (during test execution), every `setgd`/`delgd` on a non-`_`-prefixed key pushes a message to `msg_queue`.
### Coloring (`-o` disables it)
`src/testium/interpreter/utils/termlog.py``TermLog` wraps stdout with colorama-based line coloring (PASS=green, FAIL=red, WARN=yellow, …). Applied in parent process for batch mode. Auto-detects light/dark terminal background via (in order): `COLORFGBG` env var, OSC 11 query, default dark.
### Dialog items in batch mode
All dialog items (`dialog_image`, `dialog_question`, `dialog_references`, `dialog_value`, `dialog_message`, `dialog_choices`, `dialog_note`) follow this rule in non-interactive text mode (`-b`):
- `auto_result` defined in the `.tum` → result controlled by it (`ok`/`yes` → SUCCESS, `cancel`/`no` → FAIL)
- `auto_result` absent → FAIL with `"Dialog not supported in batch mode"`
- `sleep dialog: true` → exception: just sleeps normally, no GUI, no failure
`auto_result` (and `auto_value` for value/note dialogs) is intended for the validation test suite (`test/validation/`) only.
### `parallel` item
`src/testium/interpreter/test_items/test_item_parallel.py` — runs multiple branches concurrently.
```yaml
- parallel:
name: My parallel block
sync: all # all: wait for all; any: stop as soon as one finishes
no_fail: true # (optional) don't propagate branch failures to parent
branches:
- name: Branch A
wait_for: # (optional) poll condition before starting
condition: <| expr |>
timeout: 10
steps:
- ...
- name: Branch B
steps:
- ...
```
- `TestItemParallel(TestItemContainer)`: mutates `dict_item["steps"]` to inject synthetic `parallel_branch` items so `load_test_recursively` loads branches normally as children.
- `TestItemParallelBranch(TestItemContainer)`: container for one branch. `wait_for` polls every 0.1s up to `timeout` seconds before running steps.
- `sync: any` calls `_stop_branch_recursively()` on all other branches when one *actually runs* (SUCCESS/FAILURE). A `NORUN` branch (disabled, condition not met) never wins the race.
- Each branch runs in a daemon thread; the parent waits with `.join()`.
- Branches stopped late (e.g. user disabled them in the GUI, or another sync:any branch already won) go through the normal `branch.stop() + branch.execute()` path so they always produce a clean DB entry via `addTest()`.
- Exceptions raised in a branch's `execute()` are caught by `run_branch`, logged to stdout, and converted to a `FAILURE` result so they never disappear silently.
- `sync: all` ignores `NORUN` branches when computing success (matches Group/Cycle semantics): only an actual `FAILURE` fails the parallel.
- `TestItemSleep` is interruptible (polls `self._is_stopped` in a loop) so `sync: any` can stop slow branches quickly. `py_func` and `console` items are not interruptible; their full duration is observed before the branch returns.
### `TestItemContainer` base class
`src/testium/interpreter/test_items/test_item_container.py` — shared base for Group, Cycle, Parallel, and ParallelBranch. Provides `_run_children_sequentially()` which handles stop-on-failure, `executedOnStop` items, and returns `(TestResult, stopped_bool)`.
### Report threading
`src/testium/interpreter/test_report/test_report.py` — SQLite report with thread-safe writes:
- `sqlite3.connect(..., check_same_thread=False)`
- `self._lock = threading.Lock()` guards the SQLite `INSERT` only.
- Per-item log capture (`stdio_redir.read()`) is naturally race-free thanks to per-thread buffers (see `StdoutProxy`).
### Thread-aware stdout (`StdoutProxy`)
`src/lib/stdout_redirect.py` — when `log_stored: True`, `intercept()` installs a `StdoutProxy` as `sys.stdout`/`sys.stderr` instead of a single shared `StringQueue`. The proxy:
- Holds one `StringQueue` per thread (registered via `register_thread(buffer=...)`). The main thread uses a default buffer; each parallel branch's thread registers its own at start and unregisters at end. `stdio_redir.read()` reads the calling thread's buffer → `addTest()` of an item running in branch X reads X's clean, non-interleaved output.
- For the live stream (terminal in batch / GUI panel), prefixes every line emitted from a branch's thread with `[<branch_name>] ` so concurrent branches stay readable.
- Exposes `write` / `writeln` / `flush` (Python 3.14's `unittest` calls `stream.writeln()` directly without `_WritelnDecorator`).
## Key files
| Path | Role |
|------|------|
| `src/testium/__init__.py` | CLI entry, mode dispatch |
| `src/testium/interpreter/batch.py` | `-b` mode orchestrator |
| `src/testium/interpreter/process.py` | Child test process |
| `src/testium/interpreter/test_set.py` | Test tree builder/executor |
| `src/testium/interpreter/test_items/test_item_container.py` | Base class for container items |
| `src/testium/interpreter/test_items/test_item_parallel.py` | `parallel` and `parallel_branch` items |
| `src/testium/interpreter/utils/globdict.py` | Global variable dict |
| `src/testium/interpreter/utils/termlog.py` | Terminal color output |
| `src/lib/stdout_redirect.py` | `StdioRedirect` singleton (`stdio_redir`) |
| `src/lib/string_queue.py` | Thread-safe string buffer used for stdout redirection |
| `src/testium/libs/testium.py` | Public API for test scripts (`tm.*`) |
## GUI icons (main_win)
Icons live in `src/testium/main_win/resources/` with three theme variants:
| Folder | Theme index | Usage |
|--------|-------------|-------|
| `color/` | 0 (default) | Coloured icons |
| `black/` | 1 | Black silhouette on transparent |
| `white/` | 2 | White silhouette on transparent (LA mode) |
Icons are **64×64 PNG**. Black variants: RGBA with RGB=`(0,0,0)`, alpha varies. White variants: LA with luminance=`255`, alpha varies.
The mapping item-type → icon filename is in `_ITEM_CONFIG` (`src/testium/main_win/test_tree_items/test_tree_item.py`). At runtime, `icon_prefix()` returns `:/color`, `:/black`, or `:/white` (Qt resource prefix) based on the user preference.
All icons must be declared in `src/testium/main_win/resources/testium_core_win.qrc` (one entry per theme section). After any QRC change, regenerate the compiled resource file:
```
cd src/testium/main_win/resources
pyside6-rcc testium_core_win.qrc -o testium_core_win_rc.py
```
Icons are assigned once when the test file is loaded (not updated live on theme change — a file reload is required).
### `run` item
`src/testium/interpreter/test_items/test_item_run.py` — launches a `.tum` file in a new testium instance (`-b` in batch mode, `-r` in GUI mode). Result:
- **SUCCESS** if the sub-instance launched and ran to completion (exit code is ignored)
- **FAILURE** if the file is not found, `wait_for_exec` is set without `start_time`/`end_time`, the time window was not reached, or any other launch error
The sub-test's own pass/fail result is intentionally not propagated.
## Recent fixes (branch `parallel_execution`)
- `test_item_parallel.py`: new `parallel` item with `sync: all|any`, `wait_for`, daemon threads, `_stop_branch_recursively()`. Each branch thread registers a per-thread stdout buffer with `stdio_redir.register_thread(...)` so its log capture and live-output prefix work in isolation.
- `test_item_container.py`: new `TestItemContainer` base class extracted from Group/Cycle patterns
- `test_item_sleep.py`: interruptible loop (checks `self._is_stopped`) instead of blocking `time.sleep()` so `sync: any` can stop slow branches quickly
- `stdout_redirect.py`: rewrote `intercept()` to install a `StdoutProxy` (thread-aware: per-thread capture buffers + branch-prefixed live output). Adds `writeln()` for Python 3.14 unittest compatibility.
- `test_report.py`: `check_same_thread=False` + lock around the SQLite `INSERT` for parallel branch concurrency. Log capture itself is race-free thanks to per-thread buffers.
- `__init__.py`: removed `-m`/`--terminal` mode
- `terminal.py`: deleted
## Recent fixes (branch `text_no_pyside`)
- `batch.py`: premature loop exit when `gd_update` messages (no `"id"` key) were mistaken for the "finished" signal — fix: `"id" in m and m["id"] is None`
- `batch.py`: `control("loaded")` deadlock if `TestProcess` crashed before `cmd_th` started — fix: daemon thread + `threading.Event` + `is_alive()` polling
- `termlog.py`: `COLOR_DEFAULT = Fore.WHITE` invisible on light terminals; added auto-detection + light palette. Also fixed `write()` residue accumulation bug (`s[pos:]``s[pos+1:]`).
- Dialog items: `auto_result`/`auto_value` now used in non-interactive text mode; dialogs without `auto_result` FAIL immediately in batch mode.
- `run` item: removed `stdout=PIPE` (caused deadlock with `multiprocessing` spawn); simplified result to SUCCESS on any completed subprocess.
## Validation tests
Located in `test/validation/`. Run with `-b` flag:
```
./run.sh -b -l mon_log.log -- test/validation/main.tum
```
Parallel item tests: `test/validation/items/parallel/test.tum`
## Dependencies
See `src/requirements.txt`. Key ones: `pyside6`, `pyyaml`, `jinja2`, `colorama`, `gitpython`, `pexpect`, `matplotlib`.

66
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,66 @@
# Contributing to testium
Thank you for your interest in contributing to testium.
## License of contributions
testium is licensed under the **European Union Public Licence v. 1.2 (EUPL-1.2)**
see the [LICENSE](LICENSE) file at the repository root.
By submitting a contribution to this project (pull request, patch, issue
attachment, or any other form of code, documentation or media), you agree
that your contribution is licensed to the project and to the public under the
**same EUPL-1.2** terms (or any later version of the EUPL approved by the
European Commission), and you certify that:
- you are the author of the contribution, or you have the right to submit it
under the EUPL-1.2;
- to the best of your knowledge, the contribution does not infringe any
third-party intellectual-property rights;
- the contribution may be redistributed by the project under the EUPL-1.2 and
any compatible licence listed in the EUPL-1.2 Appendix.
This is the **inbound = outbound** rule: contributions come in under the same
licence the project ships under.
You retain copyright on your contribution. The project does **not** ask you
to sign a CLA or assign your copyright.
## SPDX header in new source files
When creating a new source file, please include the following header at the
top of the file (adjust the comment marker to the file's language):
```python
# SPDX-License-Identifier: EUPL-1.2
# Copyright (c) <year> <your name>
```
For existing files, keep the header that is already there.
## How to contribute
1. Open an issue describing the change you want to make (bug, feature, doc).
2. Fork the repository, create a topic branch.
3. Commit with a clear message (one logical change per commit).
4. Make sure the validation suite still passes:
```
./run.sh -b -l mon_log.log -- test/validation/main.tum
```
5. Open a pull request against `main`.
## Coding conventions
- Python ≥ 3.11
- Follow existing style in the file you are modifying
- Add or update tests in `test/validation/` for new test items or behaviours
- Update `CLAUDE.md` and the Sphinx manual for user-visible changes
## Reporting security issues
Please do **not** report security vulnerabilities through public GitHub
issues. Instead, send an email to the project maintainer directly.
## Questions
Open a GitHub Discussion or an issue tagged `question`.

315
LICENSE Normal file
View File

@@ -0,0 +1,315 @@
Copyright (c) 2025-2026 François Dausseur
Licensed under the EUPL
EUROPEAN UNION PUBLIC LICENCE v. 1.2
EUPL © the European Union 2007, 2016
This European Union Public Licence (the 'EUPL') applies to the Work (as
defined below) which is provided under the terms of this Licence. Any use of
the Work, other than as authorised under this Licence is prohibited (to the
extent such use is covered by a right of the copyright holder of the Work).
The Work is provided under the terms of this Licence when the Licensor (as
defined below) has placed the following notice immediately following the
copyright notice for the Work:
Licensed under the EUPL
or has expressed by any other means his willingness to license under the EUPL.
1. Definitions
In this Licence, the following terms have the following meaning:
- 'The Licence': this Licence.
- 'The Original Work': the work or software distributed or communicated by the
Licensor under this Licence, available as Source Code and also as Executable
Code as the case may be.
- 'Derivative Works': the works or software that could be created by the
Licensee, based upon the Original Work or modifications thereof. This Licence
does not define the extent of modification or dependence on the Original
Work required in order to classify a work as a Derivative Work; this extent
is determined by copyright law applicable in the country mentioned in
Article 15.
- 'The Work': the Original Work or its Derivative Works.
- 'The Source Code': the human-readable form of the Work which is the most
convenient for people to study and modify.
- 'The Executable Code': any code which has generally been compiled and which
is meant to be interpreted by a computer as a program.
- 'The Licensor': the natural or legal person that distributes or communicates
the Work under the Licence.
- 'Contributor(s)': any natural or legal person who modifies the Work under
the Licence, or otherwise contributes to the creation of a Derivative Work.
- 'The Licensee' or 'You': any natural or legal person who makes any usage of
the Work under the terms of the Licence.
- 'Distribution' or 'Communication': any act of selling, giving, lending,
renting, distributing, communicating, transmitting, or otherwise making
available, online or offline, copies of the Work or providing access to its
essential functionalities at the disposal of any other natural or legal
person.
2. Scope of the rights granted by the Licence
The Licensor hereby grants You a worldwide, royalty-free, non-exclusive,
sublicensable licence to do the following, for the duration of copyright
vested in the Original Work:
- use the Work in any circumstance and for all usage,
- reproduce the Work,
- modify the Work, and make Derivative Works based upon the Work,
- communicate to the public, including the right to make available or display
the Work or copies thereof to the public and perform publicly, as the case
may be, the Work,
- distribute the Work or copies thereof,
- lend and rent the Work or copies thereof,
- sublicense rights in the Work or copies thereof.
Those rights can be exercised on any media, supports and formats, whether now
known or later invented, as far as the applicable law permits so.
In the countries where moral rights apply, the Licensor waives his right to
exercise his moral right to the extent allowed by law in order to make
effective the licence of the economic rights here above listed.
The Licensor grants to the Licensee royalty-free, non-exclusive usage rights
to any patents held by the Licensor, to the extent necessary to make use of
the rights granted on the Work under this Licence.
3. Communication of the Source Code
The Licensor may provide the Work either in its Source Code form, or as
Executable Code. If the Work is provided as Executable Code, the Licensor
provides in addition a machine-readable copy of the Source Code of the Work
along with each copy of the Work that the Licensor distributes or indicates,
in a notice following the copyright notice attached to the Work, a repository
where the Source Code is easily and freely accessible for as long as the
Licensor continues to distribute or communicate the Work.
4. Limitations on copyright
Nothing in this Licence is intended to deprive the Licensee of the benefits
from any exception or limitation to the exclusive rights of the rights owners
in the Work, of the exhaustion of those rights or of other applicable
limitations thereto.
5. Obligations of the Licensee
The grant of the rights mentioned above is subject to some restrictions and
obligations imposed on the Licensee. Those obligations are the following:
Attribution right: The Licensee shall keep intact all copyright, patent or
trademarks notices and all notices that refer to the Licence and to the
disclaimer of warranties. The Licensee must include a copy of such notices
and a copy of the Licence with every copy of the Work he/she distributes or
communicates. The Licensee must cause any Derivative Work to carry prominent
notices stating that the Work has been modified and the date of modification.
Copyleft clause: If the Licensee distributes or communicates copies of the
Original Works or Derivative Works, this Distribution or Communication will
be done under the terms of this Licence or of a later version of this Licence
unless the Original Work is expressly distributed only under this version of
the Licence — for example by communicating 'EUPL v. 1.2 only'. The Licensee
(becoming Licensor) cannot offer or impose any additional terms or conditions
on the Work or Derivative Work that alter or restrict the terms of the
Licence.
Compatibility clause: If the Licensee Distributes or Communicates Derivative
Works or copies thereof based upon both the Work and another work licensed
under a Compatible Licence, this Distribution or Communication can be done
under the terms of this Compatible Licence. For the sake of this clause,
'Compatible Licence' refers to the licences listed in the appendix attached
to this Licence. Should the Licensee's obligations under the Compatible
Licence conflict with his/her obligations under this Licence, the obligations
of the Compatible Licence shall prevail.
Provision of Source Code: When distributing or communicating copies of the
Work, the Licensee will provide a machine-readable copy of the Source Code or
indicate a repository where this Source will be easily and freely available
for as long as the Licensee continues to distribute or communicate the Work.
Legal Protection: This Licence does not grant permission to use the trade
names, trademarks, service marks, or names of the Licensor, except as
required for reasonable and customary use in describing the origin of the
Work and reproducing the content of the copyright notice.
6. Chain of Authorship
The original Licensor warrants that the copyright in the Original Work
granted hereunder is owned by him/her or licensed to him/her and that he/she
has the power and authority to grant the Licence.
Each Contributor warrants that the copyright in the modifications he/she
brings to the Work are owned by him/her or licensed to him/her and that
he/she has the power and authority to grant the Licence.
Each time You accept the Licence, the original Licensor and subsequent
Contributors grant You a licence to their contributions to the Work, under
the terms of this Licence.
7. Disclaimer of Warranty
The Work is a work in progress, which is continuously improved by numerous
Contributors. It is not a finished work and may therefore contain defects or
'bugs' inherent to this type of development.
For the above reason, the Work is provided under the Licence on an 'as is'
basis and without warranties of any kind concerning the Work, including
without limitation merchantability, fitness for a particular purpose, absence
of defects or errors, accuracy, non-infringement of intellectual property
rights other than copyright as stated in Article 6 of this Licence.
This disclaimer of warranty is an essential part of the Licence and a
condition for the grant of any rights to the Work.
8. Disclaimer of Liability
Except in the cases of wilful misconduct or damages directly caused to
natural persons, the Licensor will in no event be liable for any direct or
indirect, material or moral, damages of any kind, arising out of the Licence
or of the use of the Work, including without limitation, damages for loss of
goodwill, work stoppage, computer failure or malfunction, loss of data or any
commercial damage, even if the Licensor has been advised of the possibility
of such damage. However, the Licensor will be liable under statutory product
liability laws as far such laws apply to the Work.
9. Additional agreements
While distributing the Work, You may choose to conclude an additional
agreement, defining obligations or services consistent with this Licence.
However, if accepting obligations, You may act only on your own behalf and on
your sole responsibility, not on behalf of the original Licensor or any other
Contributor, and only if You agree to indemnify, defend, and hold each
Contributor harmless for any liability incurred by, or claims asserted
against such Contributor by the fact You have accepted any warranty or
additional liability.
10. Acceptance of the Licence
The provisions of this Licence can be accepted by clicking on an icon 'I
agree' placed under the bottom of a window displaying the text of this
Licence or by affirming consent in any other similar way, in accordance with
the rules of applicable law. Clicking on that icon indicates your clear and
irrevocable acceptance of this Licence and all of its terms and conditions.
Similarly, you irrevocably accept this Licence and all of its terms and
conditions by exercising any rights granted to You by Article 2 of this
Licence, such as the use of the Work, the creation by You of a Derivative
Work or the Distribution or Communication by You of the Work or copies
thereof.
11. Information to the public
In case of any Distribution or Communication of the Work by means of
electronic communication by You (for example, by offering to download the
Work from a remote location) the distribution channel or media (for example,
a website) must at least provide to the public the information requested by
the applicable law regarding the Licensor, the Licence and the way it may be
accessible, concluded, stored and reproduced by the Licensee.
12. Termination of the Licence
The Licence and the rights granted hereunder will terminate automatically
upon any breach by the Licensee of the terms of the Licence.
Such a termination will not terminate the licences of any person who has
received the Work from the Licensee under the Licence, provided such persons
remain in full compliance with the Licence.
13. Miscellaneous
Without prejudice of Article 9 above, the Licence represents the complete
agreement between the Parties as to the Work.
If any provision of the Licence is invalid or unenforceable under applicable
law, this will not affect the validity or enforceability of the Licence as a
whole. Such provision will be construed or reformed so as necessary to make
it valid and enforceable.
The European Commission may publish other linguistic versions or new versions
of this Licence or updated versions of the Appendix, so far this is required
and reasonable, without reducing the scope of the rights granted by the
Licence. New versions of the Licence will be published with a unique version
number.
All linguistic versions of this Licence, approved by the European Commission,
have identical value. Parties can take advantage of the linguistic version of
their choice.
14. Jurisdiction
Without prejudice to specific agreement between parties,
- any litigation resulting from the interpretation of this License, arising
between the European Union institutions, bodies, offices or agencies, as a
Licensor, and any Licensee, will be subject to the jurisdiction of the
Court of Justice of the European Union, as laid down in article 272 of the
Treaty on the Functioning of the European Union,
- any litigation arising between other parties and resulting from the
interpretation of this License, will be subject to the exclusive
jurisdiction of the competent court where the Licensor resides or conducts
its primary business.
15. Applicable Law
Without prejudice to specific agreement between parties,
- this Licence shall be governed by the law of the European Union Member
State where the Licensor has his seat, resides or has his registered
office,
- this licence shall be governed by Belgian law if the Licensor has no seat,
residence or registered office inside a European Union Member State.
Appendix
'Compatible Licences' according to Article 5 EUPL are:
- GNU General Public License (GPL) v. 2, v. 3
- GNU Affero General Public License (AGPL) v. 3
- Open Software License (OSL) v. 2.1, v. 3.0
- Eclipse Public License (EPL) v. 1.0
- CeCILL v. 2.0, v. 2.1
- Mozilla Public Licence (MPL) v. 2
- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3
- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for
works other than software
- European Union Public Licence (EUPL) v. 1.1, v. 1.2
- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong
Reciprocity (LiLiQ-R+).
The European Commission may update this Appendix to later versions of the
above licences without producing a new version of the EUPL, as long as they
provide the rights granted in Article 2 of this Licence and protect the
covered Source Code from exclusive appropriation.
All other changes or additions to this Appendix require the production of a
new EUPL version.

View File

@@ -2,6 +2,18 @@
[See here](doc/manual/testium_manual.pdf). [See here](doc/manual/testium_manual.pdf).
# License
Copyright (c) 2025-2026 François Dausseur.
testium is distributed under the **European Union Public Licence v. 1.2
(EUPL-1.2)** — see the [LICENSE](LICENSE) file for the full text.
SPDX identifier: `EUPL-1.2`
Contributions are accepted under the same licence (inbound = outbound). See
[CONTRIBUTING.md](CONTRIBUTING.md) for details.
# run testium # run testium
From the root path, on windows `cmd`: From the root path, on windows `cmd`:
@@ -52,7 +64,7 @@ from the testium path, execute
## Install sphinx ## Install sphinx
pip install sphinx pip install sphinx linuxdoc
## Generate the doc ## Generate the doc

View File

@@ -1,4 +1,4 @@
- unittest_file: - unittest:
name: Test 5 name: Test 5
test_file: dummy.py test_file: dummy.py

View File

@@ -3,7 +3,7 @@ sequence: &endurance_test
!include endurance.tum !include endurance.tum
sequence: sequence:
- unittest_file: - unittest:
name: Test 3 name: Test 3
test_file: dummy.py test_file: dummy.py
test_method: test_01_pass test_method: test_01_pass
@@ -11,6 +11,6 @@ sequence:
iterator: 10 iterator: 10
steps: steps:
*endurance_test *endurance_test
- unittest_file: - unittest:
name: Test 4 name: Test 4
test_file: dummy.py test_file: dummy.py

View File

@@ -15,7 +15,7 @@ main:
- $(reference_1) - $(reference_1)
- $(reference_2) - $(reference_2)
report_show_success: true report_show_success: true
- unittest_file: - unittest:
name: Test 1 name: Test 1
test_file: dummy.py test_file: dummy.py
doc: | doc: |
@@ -23,7 +23,7 @@ main:
Voilà... Voilà...
- sleep: - sleep:
{name: Sleep between one and two, timeout: 10, dialog: true} {name: Sleep between one and two, timeout: 10, dialog: true}
- unittest_file: - unittest:
{name: Test 2, test_file: dummy.py,execute_on_stop: true} {name: Test 2, test_file: dummy.py,execute_on_stop: true}
- loop: - loop:
name: Cycle Temperature name: Cycle Temperature

View File

@@ -14,7 +14,7 @@ main:
key: report-key-2 key: report-key-2
stop_on_failure: True stop_on_failure: True
steps: steps:
- unittest_file: - unittest:
name: unittest item name: unittest item
doc: | doc: |
The purpose of this unittest test item is to demonstrate The purpose of this unittest test item is to demonstrate
@@ -41,7 +41,7 @@ main:
param: param:
- 123 - 123
- unittest_file: - unittest:
name: Unittest item name: Unittest item
test_file: dummy/dummy.py test_file: dummy/dummy.py
test_method: test_method:
@@ -98,7 +98,7 @@ main:
name: Infine loop unittest step crashes name: Infine loop unittest step crashes
stop_on_failure: True stop_on_failure: True
steps: steps:
- unittest_file: - unittest:
name: Unittest item name: Unittest item
test_file: dummy/dummy.py test_file: dummy/dummy.py
test_method: test_method:
@@ -243,7 +243,7 @@ main:
name: Infinite loop name: Infinite loop
skipped: True skipped: True
steps: steps:
- unittest_file: - unittest:
name: Unittest item name: Unittest item
test_file: dummy/dummy.py test_file: dummy/dummy.py
test_method: test_01_pass test_method: test_01_pass

View File

@@ -9,7 +9,7 @@ main:
name: Test Sample number one name: Test Sample number one
version: 0.1 version: 0.1
steps: steps:
- unittest_file: - unittest:
name: Unittest item name: Unittest item
test_file: dummy/unittest_str.py test_file: dummy/unittest_str.py
doc: Unittest test doc: Unittest test
@@ -88,7 +88,7 @@ main:
name: cycle item name: cycle item
iterator : 3 iterator : 3
steps: steps:
- unittest_file: - unittest:
name: Unittest item name: Unittest item
test_file: dummy/dummy.py test_file: dummy/dummy.py
test_method: test_01_pass test_method: test_01_pass
@@ -99,7 +99,7 @@ main:
name: cycle item name: cycle item
iterator : 3 iterator : 3
steps: steps:
- unittest_file: - unittest:
name: Unittest item name: Unittest item
test_file: dummy/dummy.py test_file: dummy/dummy.py
test_method: test_01_pass test_method: test_01_pass

View File

@@ -3,7 +3,7 @@ Command Line Interface
.. code-block:: text .. code-block:: text
usage: testium.pyw [-h] [--version] [-b] [-m] [-c CONFIG_FILE [CONFIG_FILE ...]] [-r] [-l LOG_FILE] usage: testium.pyw [-h] [--version] [-b] [-c CONFIG_FILE [CONFIG_FILE ...]] [-r] [-l LOG_FILE]
[-d DEFINE [DEFINE ...]] [-p REPORT_FILE] [-t {sqlite,json,junit,html,text}] [-d DEFINE [DEFINE ...]] [-p REPORT_FILE] [-t {sqlite,json,junit,html,text}]
[-n REPORT_PATTERN [REPORT_PATTERN ...]] [-i INCLUDE_PATH [INCLUDE_PATH ...]] [-o] [-g] [-n REPORT_PATTERN [REPORT_PATTERN ...]] [-i INCLUDE_PATH [INCLUDE_PATH ...]] [-o] [-g]
[test_file] [test_file]
@@ -16,9 +16,8 @@ Command Line Interface
--version Returns the version of testium --version Returns the version of testium
-b, --batch-execution -b, --batch-execution
Executes the test in batch mode Executes the test in batch mode
-m, --terminal Starts terminal mode
-c CONFIG_FILE [CONFIG_FILE ...], --config-file CONFIG_FILE [CONFIG_FILE ...] -c CONFIG_FILE [CONFIG_FILE ...], --config-file CONFIG_FILE [CONFIG_FILE ...]
-o, --no-color Deactivates stdout colors in batch and terminal mode -o, --no-color Deactivates stdout colors in batch mode
Configuration file Configuration file
-r, --run-and-close Runs the test then closes the application -r, --run-and-close Runs the test then closes the application
-l LOG_FILE, --log-file LOG_FILE -l LOG_FILE, --log-file LOG_FILE
@@ -45,17 +44,10 @@ Returns what's in the previous section.
Executes the test in text mode. No need to have QT installed in that case. Executes the test in text mode. No need to have QT installed in that case.
``-m, --terminal``
------------------
Starts a testium interactive console. It allows to run commands and sub-tests manually
in a console.
``-o, --no-color`` ``-o, --no-color``
------------------ ------------------
Switch allowing to disable the colored output in terminal or batch modes. Switch allowing to disable the colored output in batch mode.
``-c, --config-file`` ``-c, --config-file``
--------------------- ---------------------

View File

@@ -23,23 +23,3 @@ graphical interface.
:caption: call a test in batch mode :caption: call a test in batch mode
testium -b test/my_test/main.tum testium -b test/my_test/main.tum
Terminal mode
-------------
The terminal mode starts *testium* in interactive mode. From this console, some tests and
sequences of tests can be called interactively.
.. code-block:: text
:caption: call a test in terminal mode
$ testium -m
Configuration file loaded: /my/execution/path/param.yaml
[...]
================================================================================
====== Test configuration
================================================================================
Test executed with testium : 2.4.0 (binary release)
(testium)~

View File

@@ -75,7 +75,7 @@ a tooltip on the test row.
name: Test example name: Test example
steps: steps:
- unittest_file: - unittest:
name: unittest item name: unittest item
doc: | doc: |
The purpose of this unittest test item is to demonstrate The purpose of this unittest test item is to demonstrate
@@ -93,4 +93,4 @@ See illustration in :numref:`Figure %s<doc-illustration>`.
Unittest Unittest
^^^^^^^^^ ^^^^^^^^^
For ``unittest_file`` type test items, the python docstring of the test method is used as documentation. For ``unittest`` type test items, the python docstring of the test method is used as documentation.

View File

@@ -6,7 +6,7 @@ This software is developed in python and it implements the Qt6 graphical framewo
It has been developed since 2013 with production and development testing in mind. It has been developed since 2013 with production and development testing in mind.
It's function is to automate the execution of tests. It can be invoked either as command line terminal application or as a graphical interface application. It's function is to automate the execution of tests. It can be invoked either as command line application or as a graphical interface application.
Tests reports generation and customization are also in this tool's scope. Tests reports generation and customization are also in this tool's scope.

View File

@@ -10,7 +10,7 @@ This element is of the following form:
name: Group Item name: Group Item
condition: <| "$(OS)" == "Linux" |> condition: <| "$(OS)" == "Linux" |>
steps: steps:
- unittest_file: - unittest:
test_file: test_prod_alpha_13.py test_file: test_prod_alpha_13.py
test_method: test_method:
... ...

View File

@@ -33,7 +33,7 @@ if not provided is given in the table as well.
| | | It depends on the test item to take it | | | | It depends on the test item to take it |
| | | into account or not. | | | | into account or not. |
| | | For example it makes sense to use it | | | | For example it makes sense to use it |
| | | for ``unittest_file`` test type | | | | for ``unittest`` test type |
| | | because it can contain many sub-tests, | | | | because it can contain many sub-tests, |
| | | but not for sleep test type. | | | | but not for sleep test type. |
| | | In cycles, it means that the child | | | | In cycles, it means that the child |

View File

@@ -12,7 +12,7 @@ This element is of the following form:
name: Cycle Temperature name: Cycle Temperature
iterator: 10 iterator: 10
steps: steps:
- unittest_file: - unittest:
test_file: test_prod_rio6_8093.py test_file: test_prod_rio6_8093.py
- py_func: - py_func:
name: function test item name: function test item

View File

@@ -0,0 +1,97 @@
.. _sec_parallel_item:
**parallel** test item
============================================================
This element is of the following form:
.. code-block:: yaml
:caption: ``parallel`` test item usage example
- parallel:
name: My parallel block
sync: all
branches:
- name: Branch A
steps:
- py_func:
name: Long operation
file: long_op.py
func_name: do_work
- name: Branch B
wait_for:
condition: <| "$(ready_flag)" == "True" |>
timeout: 30
steps:
- let:
name: Mark done
values:
- branch_b_done: true
The ``parallel`` element runs several sequences of items concurrently. Each
inner sequence is called a *branch* and runs in its own thread. The parent
test item waits for branches to finish according to the ``sync`` policy.
Attributes
--------------------
* ``branches``: required. A list of branches to execute concurrently. Each
branch has a ``name`` and a ``steps`` list (same structure as a ``group``
item). It can also declare a ``wait_for`` precondition (see below).
* ``sync``: optional, defaults to ``all``.
* ``all``: the parallel item completes when *every* branch has finished.
The result is ``PASS`` if no branch returned ``FAIL`` (skipped or
disabled branches are ignored, like in ``group``); otherwise ``FAIL``.
* ``any``: the parallel item completes as soon as the *first* branch
finishes. The remaining branches are stopped (their next test items
are not executed). The result is ``PASS`` if at least one branch
succeeded.
* ``no_fail``: optional. When ``true``, a ``FAIL`` result is forced to
``PASS`` for the parallel item itself (same semantics as for any test
item). Branches keep their own result.
Branch attributes
--------------------
Each entry of ``branches`` is a dict with the following attributes:
* ``name``: required. The branch name. Used in reports and as a prefix
in the live log output (each line printed by the branch is prefixed
with the branch name in square brackets, e.g. ``[Branch A]``, so
concurrent branches stay readable).
* ``steps``: required. The list of test items executed sequentially
inside the branch.
* ``wait_for``: optional. Forces the branch to wait until a condition is
met before running its steps. If the timeout elapses, the branch
returns ``FAIL`` (the steps are not run). Sub-attributes:
* ``condition``: a testium expression evaluated repeatedly (every
100 ms) until it returns ``True``.
* ``timeout``: maximum wait, in seconds. Defaults to 30.
Reporting
--------------------
Each branch produces its own row in the SQLite report (with type
``Parallel branch``), in addition to the parent ``Parallel`` row. The
``log`` column of each row contains only the output emitted from that
branch's thread, so logs are never mixed between concurrent branches.
In the live (terminal / GUI) output, lines emitted from a branch are
prefixed with the branch name in square brackets (e.g. ``[Branch A]``).
The prefix is not stored in the SQLite log column.
Notes
--------------------
* A ``sleep`` item inside a branch is interruptible: if another
``sync: any`` branch wins the race, slow ``sleep`` items are aborted
within ~50 ms.
* A ``py_func`` or ``console`` item inside a branch is **not**
interruptible: a ``sync: any`` stop will only take effect after the
current item returns. The branch will then skip its remaining steps.
* When a user disables a branch in the GUI tree, the branch returns
``SKIP`` instantly without affecting the others (it does *not* win a
``sync: any`` race).

View File

@@ -1,16 +1,23 @@
**run** test item **run** test item
============================================================ ============================================================
This test item executes a new instance of testium. This test item executes a new instance of testium with the specified ``.tum`` file.
* In **batch mode** (``-b``): the sub-instance is started with ``-b``.
* In **GUI mode**: the sub-instance is started with ``-r`` (run and close).
The item result is **PASS** if the sub-instance launched and ran to completion,
regardless of whether the sub-tests passed or failed.
It is **FAIL** if the file could not be found, the sub-instance could not be
launched, or the time window was not reached (see ``start_time`` / ``end_time``).
.. code-block:: yaml .. code-block:: yaml
:caption: ``run`` test item usage example :caption: ``run`` test item usage example
- run: - run:
name: Execute TUM name: Execute TUM
tum_fime: example_cycle.tum tum: example_cycle.tum
python_bin: python3 python_bin: python3
testium_path: /home/francois/projets/testium-new-report/testium.pyw
log_file: $(home)/reports/test.log log_file: $(home)/reports/test.log
report_file: $(home)/reports/test.rep report_file: $(home)/reports/test.rep
@@ -19,12 +26,12 @@ Attributes
run test item has the following specific attributes: run test item has the following specific attributes:
* ``tum_fime``: mandatory the path of the file to execute, it can be relative to current execution folder, * ``tum``: mandatory, the path of the file to execute. Can be relative to the current execution folder.
* ``param_file`` (optional) the path of the parameter file to use, otherwise default parameter file is used. * ``param_file`` (optional): the path of the parameter file to use; otherwise the default parameter file is used.
* ``python_bin`` (optional) the path of a specific python to run your scripts, * ``python_bin`` (optional): the path of a specific Python interpreter to use.
* ``testium_path`` (optional) the path of a specific testium to run your scripts, * ``testium_path`` (optional): the path of a specific testium executable to use.
* ``log_file`` (optional) the path of log file to register, if not provided a file is created with timestamp at the location of TUM file. * ``log_file`` (optional): the path of the log file. In GUI mode, if not provided, a file is created with a timestamp next to the ``.tum`` file. Not used in batch mode.
* ``report_file`` (optional), the path of report file to create * ``report_file`` (optional): the path of the report file to create.
* ``start_time`` (optional), start time for the script execution, in HH:MM format. * ``start_time`` (optional): earliest time to execute the sub-instance, in ``HH:MM`` format.
* ``end_time`` (optional), end time for an execution within a time frame, in HH:MM format. * ``end_time`` (optional): latest time for execution within a time frame, in ``HH:MM`` format.
* ``wait_for_exec`` (optional). True or False, wait to be in the execution window defined by start_time and end_time to run the script. * ``wait_for_exec`` (optional): ``true`` to wait until the time window defined by ``start_time`` and ``end_time`` is reached before running. Requires both ``start_time`` and ``end_time``.

View File

@@ -1,15 +1,15 @@
**unittest_file** test item **unittest** test item
============================================================ ============================================================
unittest_file test item allows the execution of unittest test script which unittest test item allows the execution of unittest test script which
is part of python standard libraries. is part of python standard libraries.
The tum file prototype is as followed: The tum file prototype is as followed:
.. code-block:: yaml .. code-block:: yaml
:caption: ``unittest_file`` test item usage example :caption: ``unittest`` test item usage example
- unittest_file: - unittest:
name: unitTest test item name: unitTest test item
test_file: unitTestScript.py test_file: unitTestScript.py
test_method: test_method:
@@ -23,7 +23,7 @@ Beside common test items attributes, unittest test item has specific attribute,
* ``test_file``: it is the name (and eventually path) of the unittest file * ``test_file``: it is the name (and eventually path) of the unittest file
to be processed. to be processed.
* ``test_method``: it is an optional unittest_file test sub-item. If one or more * ``test_method``: it is an optional unittest test sub-item. If one or more
elements are present, the unittest python script file is parsed and only elements are present, the unittest python script file is parsed and only
the corresponding methods are included in the test tree. Otherwise, all the corresponding methods are included in the test tree. Otherwise, all
the test methods are included in the test tree. the test methods are included in the test tree.

View File

@@ -255,11 +255,12 @@ step list attributes.
test_items/let_test_item.rst test_items/let_test_item.rst
test_items/loop_test_item.rst test_items/loop_test_item.rst
test_items/lua_func_test_item.rst test_items/lua_func_test_item.rst
test_items/parallel_test_item.rst
test_items/plot_test_item.rst test_items/plot_test_item.rst
test_items/report_test_item.rst test_items/report_test_item.rst
test_items/run_test_item.rst test_items/run_test_item.rst
test_items/sleep_test_item.rst test_items/sleep_test_item.rst
test_items/unittest_file_test_item.rst test_items/unittest_test_item.rst

Binary file not shown.

View File

@@ -4,6 +4,7 @@ SUPPORTED_API = [
"setgd", "setgd",
"delgd", "delgd",
"add_plot_values", "add_plot_values",
"last_plot_value" "last_plot_value",
"text_mode",
] ]

View File

@@ -1,8 +1,88 @@
import sys import sys
import threading
from threading import (Thread, Event) from threading import (Thread, Event)
from lib.string_queue import StringQueue from lib.string_queue import StringQueue
from time import (sleep) from time import (sleep)
class StdoutProxy:
"""Thread-aware stdout proxy.
Each writing thread can be associated with:
- a per-thread buffer (StringQueue) where its writes are captured for the
per-item SQLite log column;
- a 'branch' label, used to prefix each line in the live (parent-visible)
output stream so concurrent branches are easy to read.
Threads with no association fall back to the default buffer (the "main"
thread's buffer) and write to live output without prefix.
"""
def __init__(self, live_stream, default_buffer):
self.live_stream = live_stream
self.default_buffer = default_buffer
self._buffers = {}
self._branches = {}
self._lock = threading.Lock()
def register(self, tid=None, buffer=None, branch=None):
if tid is None:
tid = threading.get_ident()
with self._lock:
if buffer is not None:
self._buffers[tid] = buffer
if branch is not None:
self._branches[tid] = branch
def unregister(self, tid=None):
if tid is None:
tid = threading.get_ident()
with self._lock:
self._buffers.pop(tid, None)
self._branches.pop(tid, None)
def get_buffer(self, tid=None):
if tid is None:
tid = threading.get_ident()
with self._lock:
return self._buffers.get(tid, self.default_buffer)
def write(self, s):
if not s:
return
tid = threading.get_ident()
with self._lock:
buf = self._buffers.get(tid, self.default_buffer)
branch = self._branches.get(tid)
# Per-thread capture: clean, no prefix
buf.write(s)
# Live stream: prefix each line with the branch label
if branch:
self.live_stream.write(self._prefix(s, f'[{branch}] '))
else:
self.live_stream.write(s)
@staticmethod
def _prefix(s, prefix):
ends_nl = s.endswith('\n')
body = s[:-1] if ends_nl else s
if body == '':
return s
prefixed = '\n'.join(prefix + line for line in body.split('\n'))
if ends_nl:
prefixed += '\n'
return prefixed
def writeln(self, s=''):
self.write(s + '\n')
def flush(self):
try:
self.live_stream.flush()
except AttributeError:
pass
class StdioRedirect: class StdioRedirect:
def __init__(self): def __init__(self):
@@ -28,48 +108,38 @@ class StdioRedirect:
def intercept(self): def intercept(self):
if not self.spy_enabled: if not self.spy_enabled:
self.thr_started = Event() self.log_buf = StringQueue() # default buffer (main thread)
self.log_buf = StringQueue() self.proxy = StdoutProxy(self.out_stream, self.log_buf)
self.in_stream = StringQueue() sys.stdout = self.proxy
self.stop_output = Event() sys.stderr = self.proxy
self.thrd_out = Thread(target=self.interceptStdOut) self.stream = self.proxy
self.thrd_out.daemon = True
sys.stdout = self.in_stream
sys.stderr = self.in_stream
self.stream = self.in_stream
self.thrd_out.start()
self.thr_started.wait()
self.spy_enabled = True self.spy_enabled = True
def stop(self): def stop(self):
if self.spy_enabled: if self.spy_enabled:
sys.stdout = self.out_stream sys.stdout = self.out_stream
sys.stderr = self.out_stream sys.stderr = self.out_stream
self.stream = self.out_stream self.stream = self.out_stream
self.stop_output.set()
self.thrd_out.join()
del self.log_buf del self.log_buf
del self.in_stream del self.proxy
del self.stop_output
del self.thrd_out
del self.thr_started
self.spy_enabled = False self.spy_enabled = False
def interceptStdOut(self):
self.thr_started.set()
while not self.stop_output.is_set():
data = self.in_stream.read()
self.log_buf.write(data)
self.out_stream.write(data)
if data == '':
sleep(0.1)
def read(self): def read(self):
ret = '' """Read accumulated content from the calling thread's buffer."""
if not self.spy_enabled:
return ''
return self.proxy.get_buffer().read()
def register_thread(self, buffer=None, branch=None):
"""Register the calling thread's per-thread buffer and/or branch label."""
if self.spy_enabled: if self.spy_enabled:
ret = self.log_buf.read() self.proxy.register(buffer=buffer, branch=branch)
return ret
def unregister_thread(self):
"""Drop the calling thread's registration."""
if self.spy_enabled:
self.proxy.unregister()
stdio_redir = StdioRedirect() stdio_redir = StdioRedirect()

View File

@@ -1,8 +1,5 @@
# from io import (StringIO, SEEK_SET, SEEK_CUR, SEEK_END)
from multiprocessing import Queue
from queue import (Empty)
from threading import (Thread, Event, Condition) from threading import (Thread, Event, Condition)
from threading import Lock
class StringQueue(object): class StringQueue(object):
""" Class used to store the buffered consoles data: """ Class used to store the buffered consoles data:

View File

@@ -8,9 +8,12 @@ requires-python = ">=3.11"
authors = [ authors = [
{name = "François Dausseur", email = "francois@beafrancois.fr"}, {name = "François Dausseur", email = "francois@beafrancois.fr"},
] ]
license = "EUPL-1.2"
license-files = ["../LICENSE"]
classifiers = [ classifiers = [
"Development Status :: 5 - Production/Stable", "Development Status :: 5 - Production/Stable",
"Programming Language :: Python" "Programming Language :: Python",
"License :: OSI Approved :: European Union Public Licence 1.2 (EUPL 1.2)",
] ]
dependencies = [ dependencies = [
"setuptools", "setuptools",

View File

@@ -21,10 +21,8 @@ def main():
help="Returns the version of testium", action='store_true') help="Returns the version of testium", action='store_true')
parser.add_argument("-b", "--batch-execution", parser.add_argument("-b", "--batch-execution",
help="Executes the test in batch mode", action='store_true') help="Executes the test in batch mode", action='store_true')
parser.add_argument("-m", "--terminal",
help="Starts terminal mode", action='store_true')
parser.add_argument("-o", "--no-color", parser.add_argument("-o", "--no-color",
help="Deactivates stdout colors in batch and terminal mode", action='store_true') help="Deactivates stdout colors in batch mode", action='store_true')
parser.add_argument("-c", "--config-file", help="Configuration file", parser.add_argument("-c", "--config-file", help="Configuration file",
nargs='+', nargs='+',
default=[]) default=[])
@@ -95,36 +93,13 @@ def main():
from interpreter.utils.version import get_testium_version from interpreter.utils.version import get_testium_version
print(get_testium_version()) print(get_testium_version())
elif args.terminal:
import select
from interpreter.terminal import Terminal
if (lf != '') or (rf != '') or (tf != '') or (pn != []):
print('"-l", "-p", "-t", "-n" options are not supported in this mode.')
t = Terminal(os.getcwd(), cf, defines, args.no_color)
loop = 1
while loop:
try:
loop = 0
t.cmdloop()
except KeyboardInterrupt:
print("\n<ctrl-c>")
loop = 1
except Exception as exc:
if str(exc) == 'quit':
break
print(exc)
loop = 1
elif args.batch_execution: elif args.batch_execution:
if (lf != ''): if (lf != ''):
print('"-l" option is not supported in this mode.') print('"-l" option is not supported in this mode.')
from interpreter.batch import Batch from interpreter.batch import Batch
b = Batch(tf, cf, defines, rf, args.report_type, pn, args.no_color) b = Batch(tf, cf, defines, rf, args.report_type, pn, args.no_color, text_mode=True)
sys.exit(0 if b.success else 1)
else: else:
from main_win.testium_win import MainWin from main_win.testium_win import MainWin

View File

@@ -1,6 +1,7 @@
import os import os
import sys import sys
import platform import platform
import threading
from time import sleep from time import sleep
from signal import signal, SIGINT from signal import signal, SIGINT
from queue import Empty from queue import Empty
@@ -8,7 +9,7 @@ from multiprocessing import Queue
from interpreter.process import TestProcess from interpreter.process import TestProcess
from interpreter.utils.test_ctrl import TestSetController from interpreter.utils.test_ctrl import TestSetController
from lib.tum_except import ETUMFileError from lib.tum_except import ETUMFileError, ETUMRuntimeError
from lib.stdout_redirect import stdio_redir from lib.stdout_redirect import stdio_redir
@@ -22,6 +23,7 @@ class Batch:
report_type, report_type,
report_pattern, report_pattern,
no_color, no_color,
text_mode=False,
): ):
try: try:
try: try:
@@ -51,6 +53,7 @@ class Batch:
signal(SIGINT, self.sigint_handler) signal(SIGINT, self.sigint_handler)
self._success = False
msg_queue = Queue() msg_queue = Queue()
self.tst_ctrl = TestSetController() self.tst_ctrl = TestSetController()
tst_proc = TestProcess( tst_proc = TestProcess(
@@ -59,11 +62,21 @@ class Batch:
self.tst_ctrl, self.tst_ctrl,
config_files, config_files,
defines, defines,
text_mode=text_mode,
) )
tst_proc.start() tst_proc.start()
while not self.tst_ctrl.control("loaded"): # Wait for TestProcess to finish loading.
sleep(0.1) # Run the blocking control("loaded") in a daemon thread so we
# can watch for unexpected process death in the main thread.
_loaded_event = threading.Event()
def _wait_loaded():
self.tst_ctrl.control("loaded")
_loaded_event.set()
threading.Thread(target=_wait_loaded, daemon=True).start()
while not _loaded_event.wait(timeout=0.1):
if not tst_proc.is_alive():
raise ETUMRuntimeError("TestProcess terminated unexpectedly during load")
self.tst_ctrl.control( self.tst_ctrl.control(
"report", "report",
@@ -78,13 +91,17 @@ class Batch:
while True: while True:
try: try:
m = msg_queue.get(timeout=0.2) m = msg_queue.get(timeout=0.2)
if m.get("id", None) is None: if "id" in m and m["id"] is None:
# No id -> finished # id key present and None -> finished
self._success = m.get("success", False)
break break
except Empty: except Empty:
if not tst_proc.is_alive():
break
continue continue
# Close the process and wait for termination # Close the process and wait for termination
if tst_proc.is_alive():
self.tst_ctrl.control("close") self.tst_ctrl.control("close")
tst_proc.join() tst_proc.join()
@@ -94,5 +111,12 @@ class Batch:
finally: finally:
stdio_redir.restore() stdio_redir.restore()
@property
def success(self):
return self._success
def sigint_handler(self, signal_received, frame): def sigint_handler(self, signal_received, frame):
self.tst_ctrl.control("stop") try:
self.tst_ctrl.control("stop", timeout=5)
except Exception:
pass

View File

@@ -1,4 +1,5 @@
import os import os
import signal
from multiprocessing import Process, Queue, Pipe from multiprocessing import Process, Queue, Pipe
from queue import Empty from queue import Empty
from threading import Thread from threading import Thread
@@ -41,6 +42,7 @@ class TestProcess(Process):
config_files, config_files,
defines, defines,
gui_defaults={}, gui_defaults={},
text_mode=False,
) -> None: ) -> None:
super().__init__() super().__init__()
self.__fname = file_name self.__fname = file_name
@@ -49,6 +51,7 @@ class TestProcess(Process):
self.__cfgf = config_files self.__cfgf = config_files
self.__defs = defines self.__defs = defines
self.__gui_defaults = gui_defaults # default values coming from GUI prefs self.__gui_defaults = gui_defaults # default values coming from GUI prefs
self.__text_mode = text_mode
self.__exec = False self.__exec = False
self.__loaded = False self.__loaded = False
self.__closed = False self.__closed = False
@@ -194,6 +197,7 @@ class TestProcess(Process):
def run(self): def run(self):
signal.signal(signal.SIGINT, signal.SIG_IGN)
try: try:
try: try:
# Thread for stdout redirection # Thread for stdout redirection
@@ -224,6 +228,10 @@ Is the python exec path correct ?"""
# Load the test file # Load the test file
test_dict, param_files = self._load_test(init_param_files, glob_variables) test_dict, param_files = self._load_test(init_param_files, glob_variables)
if self.__text_mode:
tm.setgd("_text_mode", True)
tm.setgd("_interactive", False)
# Backup the global dict in case of restart of the test # Backup the global dict in case of restart of the test
gdict = backup_gd() gdict = backup_gd()
@@ -275,7 +283,7 @@ Is the python exec path correct ?"""
engine.stop() engine.stop()
engine.join() engine.join()
# Sends signal to the GUI # Sends signal to the GUI
self.send_finished() self.send_finished(success=test_set.success())
globdict.set_update_queue(None) globdict.set_update_queue(None)
restore_gd(gdict) restore_gd(gdict)
except Exception as e: except Exception as e:
@@ -331,8 +339,10 @@ Is the python exec path correct ?"""
stdio_redir.restore() stdio_redir.restore()
stdio_redir.stop() stdio_redir.stop()
def send_finished(self): def send_finished(self, success=None):
status = {"id": None, "name": "test_process", "status": "finished"} status = {"id": None, "name": "test_process", "status": "finished"}
if success is not None:
status["success"] = success
self.__squeue.put(status) self.__squeue.put(status)
def execute(self): def execute(self):
@@ -421,7 +431,7 @@ Is the python exec path correct ?"""
try: try:
# read the pipe data # read the pipe data
data = cconn.recv() data = cconn.recv()
print(data, end="") print(data, end="", flush=True)
except EOFError: except EOFError:
# exit the loop is the pipe is closed # exit the loop is the pipe is closed
break break

View File

@@ -1,244 +0,0 @@
try:
import readline
except:
pass
from cmd import Cmd
import os
import sys
from yaml import load, Loader
import functools
import platform
import types
import inspect
# test modules
from interpreter.utils.test_init import (
env_init, prepare_global, set_standard_gd_keys,
update_global, test_run_init, test_run_header, load_test)
from interpreter.utils.globdict import (global_dict)
import libs.testium as tm
from interpreter.utils.constants import TestItemType as cst
from interpreter.test_report.test_report import TestReport
class FakeQueue:
def put(self, arg):
pass
def func(self, args):
if not args.startswith("{"):
args = "{"+args+"}"
y = load(args, Loader)
obj = self.current_item(y, status_queue=FakeQueue())
obj.report = self.report
res = obj.execute()
if not (res.value is None):
print('result : {}'.format(res.value))
print(res.test_result)
class Terminal(Cmd):
SUPPORTED_TESTS = [
cst.TYPE_SLEEP,
cst.TYPE_LET,
cst.TYPE_PY_FUNCTION,
cst.TYPE_LUA_FUNCTION,
cst.TYPE_CONSOLE,
cst.TYPE_IMAGE_DLG,
cst.TYPE_MESSAGE_DLG,
cst.TYPE_QUESTION_DLG,
cst.TYPE_VALUE_DLG,
]
SUPPORTED_GROUPS = [
cst.TYPE_GROUP,
cst.TYPE_CYCLE
]
def __init__(self, working_dir, config_files, defines, no_color):
super().__init__()
self.working_dir = working_dir
self.config_files = config_files
self.current_item = None
report = TestReport(None)
self.report = report
env_init()
prepare_global()
# Define the builtin variables
set_standard_gd_keys("Unnamed", self.working_dir, '', config_files)
update_global([], defines)
# creation of the functions
for tst in self.SUPPORTED_TESTS:
meth_name = "do_" + tst.item_cmd
# copy of the function
f = types.FunctionType(func.__code__, func.__globals__, name=meth_name,
argdefs=func.__defaults__,
closure=func.__closure__)
f = functools.update_wrapper(f, func)
f.__kwdefaults__ = func.__kwdefaults__
f.__doc__ = tst.item_class.__doc__
setattr(self, meth_name, types.MethodType(f, self))
test_run_init()
self.prompt = "(testium)~ "
# display header
print(test_run_header())
# redirect output
if 'Linux' in platform.system() and not no_color:
from lib.stdout_redirect import stdio_redir
try:
from interpreter.utils.termlog import TermLog
stdio_redir.redirect(TermLog(sys.stdout))
except ModuleNotFoundError:
tm.print_info('Colored console not supported by the system.' +
' If you want it, please install colorama module')
def precmd(self, line: str) -> str:
c = line.split(" ", 1)[0].strip()
self.current_item = None
for tst in self.SUPPORTED_TESTS:
if c == tst.item_cmd:
self.current_item = tst.item_class
break
return line
def load_test_recursively(self, tree_parent, parent_seq, status_queue):
try:
parent_seq_name = parent_seq['name']
except KeyError:
parent_seq['name'] = "sequence"
except TypeError:
raise Exception("Syntax error in an item of type {} which is a child of {}".format(
tree_parent.type(), tree_parent.parent().name()))
try:
parent_seq_actions = parent_seq['steps']
except KeyError:
raise Exception(' No action list found for "%s" sequence'
% (parent_seq_name))
# if action is a dictionary , we assume it is a single action
# that has not been nested in a list, so do it
if isinstance(parent_seq_actions, (dict)):
parent_seq_actions = [parent_seq_actions]
if not isinstance(parent_seq_actions, (list, tuple)):
raise Exception('Actions list not valid.')
# first we merged to the same level 'sequence dict entries and list within the list
counter = 0
test_dir = tm.gd('test_directory')
while (counter < len(parent_seq_actions)):
action = parent_seq_actions[counter]
# if action is a list raise up to the the same level,
# ie insert action element into the parent_seq_actions
if isinstance(action, (list, tuple)):
parent_seq_actions[counter:counter+1] = action
continue
# if action is a NoneType skip and continue
# (when pointing to an unused alias for instance)
if action is None:
counter += 1
continue
# if action is a sequence we insert its entry into the action list
if 'sequence' in action:
parent_seq_actions[counter:counter+1] = action['sequence']
continue
else:
executed = False
for it in [*self.SUPPORTED_TESTS, *self.SUPPORTED_GROUPS]:
if it.item_cmd in action:
executed = True
item = (it.item_class)(action[it.item_cmd],
tree_parent,
status_queue)
# check for sequence type:
if it.item_cmd == cst.TYPE_UNITTEST_FILE.item_cmd:
item.setTestDir(test_dir)
item.load()
elif ((it.item_cmd == cst.TYPE_CYCLE.item_cmd) or
(it.item_cmd == cst.TYPE_GROUP.item_cmd)):
self.load_test_recursively(
item, action[it.item_cmd], status_queue)
if not executed:
raise Exception('action type is not known "{}"'.format(
list(action.keys())[0]))
counter += 1
def __setReportRecursively(self, parent):
for i in range(parent.childCount()):
parent.child(i).report = self.report
self.__setReportRecursively(parent.child(i))
def setReport(self, root_item):
root_item.report = self.report
self.__setReportRecursively(root_item)
def get_names(self):
memb = inspect.getmembers(self)
return [n[0] for n in memb if (inspect.ismethod(n[1]) and n[0].startswith("do_"))]
def do_load(self, args):
"""load function.
This function loads and executes a testium sub-script.
The loaded sequence can't be a main testium script ("testium -b" option is
defined for such a usage).
Accepted files are with extension "*.tum".
usage:
load path/to/my/sequence.tum
"""
file = args.strip()
suff = file[-4:]
if not suff in ['.tum']:
raise Exception('Wrong input file extension')
if not (os.path.exists(file) and os.path.isfile(file)):
raise Exception(
'"{}" does not exist or is not a file.'.format(file))
d, _ = load_test(file)
if not isinstance(d, list):
raise Exception(
"The file root object must be a list. A \"main\" tum can't be loaded from here (use batch mode instead).")
if (len(d) == 1) and isinstance(d[0], dict) and (not d[0].get('sequence', None) is None):
d = d[0]['sequence']
sq = FakeQueue()
root_item = (cst.TYPE_ROOT.item_class)(
dict_item={'steps': d}, status_queue=sq)
self.load_test_recursively(root_item, {'steps': d}, sq)
self.setReport(root_item)
res = root_item.execute()
if not (res.value is None):
print('"{}" execution overall result: {}'.format(file, res.value))
print(res.test_result)
def do_gd(self, args):
"""Variables lists and values.
usage:
gd
gd home
"""
if args != '':
res = tm.gd(args, None)
if res is None:
raise Exception(
'the variable: "{}" has not been found.'.format(args))
print(res)
return
for k in global_dict.keys():
print('{}: {}'.format(str(k), str(global_dict[k])))
def do_quit(self, args):
'''Quit the application.'''
raise Exception('quit')

View File

@@ -5,7 +5,7 @@ from itertools import chain
from PySide6.QtGui import QIcon, QPixmap from PySide6.QtGui import QIcon, QPixmap
from PySide6.QtWidgets import QApplication, QDialog, QDialogButtonBox from PySide6.QtWidgets import QApplication, QDialog, QDialogButtonBox
from PySide6.QtCore import Qt, QSettings, QSize from PySide6.QtCore import Qt, QSettings, QTimer, QSize
from PySide6.QtGui import QFont, QFontInfo from PySide6.QtGui import QFont, QFontInfo
from PySide6.QtWidgets import QTreeWidgetItem from PySide6.QtWidgets import QTreeWidgetItem
@@ -207,6 +207,9 @@ def main(args, conn=None):
d.connect_checked() d.connect_checked()
d.choicesView.setFocus() d.choicesView.setFocus()
auto_result = args[4] if len(args) > 4 else None
if auto_result is not None:
QTimer.singleShot(2000, lambda: d.accept() if auto_result.lower() == 'ok' else d.reject())
dres = d.exec() dres = d.exec()
if dres == QDialog.Rejected: if dres == QDialog.Rejected:

View File

@@ -3,7 +3,7 @@
################################################################################ ################################################################################
## Form generated from reading UI file 'choices_dialog_win.ui' ## Form generated from reading UI file 'choices_dialog_win.ui'
## ##
## Created by: Qt User Interface Compiler version 6.10.1 ## Created by: Qt User Interface Compiler version 6.11.0
## ##
## WARNING! All changes made in this file will be lost when recompiling UI file! ## WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################ ################################################################################

View File

@@ -1,6 +1,6 @@
import sys import sys
from PySide6.QtCore import (Qt) from PySide6.QtCore import Qt, QTimer
from PySide6.QtWidgets import (QApplication, QDialog) from PySide6.QtWidgets import (QApplication, QDialog)
from PySide6 import (QtGui) from PySide6 import (QtGui)
@@ -38,6 +38,10 @@ def main(args, conn):
d.labelImage.setPixmap(QtGui.QPixmap.fromImage(image2)) d.labelImage.setPixmap(QtGui.QPixmap.fromImage(image2))
auto_result = args[3] if len(args) > 3 else None
if auto_result is not None:
QTimer.singleShot(2000, lambda: d.accept() if auto_result.lower() == 'ok' else d.reject())
dres = d.exec() dres = d.exec()
if dres == QDialog.Rejected: if dres == QDialog.Rejected:

View File

@@ -3,7 +3,7 @@
################################################################################ ################################################################################
## Form generated from reading UI file 'dialog_image_win.ui' ## Form generated from reading UI file 'dialog_image_win.ui'
## ##
## Created by: Qt User Interface Compiler version 6.10.1 ## Created by: Qt User Interface Compiler version 6.11.0
## ##
## WARNING! All changes made in this file will be lost when recompiling UI file! ## WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################ ################################################################################

View File

@@ -2,7 +2,7 @@ import sys
from multiprocessing import freeze_support from multiprocessing import freeze_support
from PySide6.QtWidgets import (QApplication, QMessageBox) from PySide6.QtWidgets import (QApplication, QMessageBox)
from PySide6.QtCore import Qt from PySide6.QtCore import Qt, QTimer
def main(args): def main(args):
@@ -15,6 +15,8 @@ def main(args):
msg.setText(args[1]) msg.setText(args[1])
msg.setIcon(QMessageBox.Information) msg.setIcon(QMessageBox.Information)
msg.setStandardButtons(QMessageBox.Ok) msg.setStandardButtons(QMessageBox.Ok)
if len(args) > 2:
QTimer.singleShot(2000, lambda: msg.button(QMessageBox.Ok).click())
msg.exec() msg.exec()
if hasattr(sys, "frozen"): if hasattr(sys, "frozen"):

View File

@@ -3,7 +3,7 @@
################################################################################ ################################################################################
## Form generated from reading UI file 'dialog_note_win.ui' ## Form generated from reading UI file 'dialog_note_win.ui'
## ##
## Created by: Qt User Interface Compiler version 6.10.1 ## Created by: Qt User Interface Compiler version 6.11.0
## ##
## WARNING! All changes made in this file will be lost when recompiling UI file! ## WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################ ################################################################################

View File

@@ -2,7 +2,7 @@ import sys
import os import os
from PySide6.QtWidgets import (QApplication, QDialog) from PySide6.QtWidgets import (QApplication, QDialog)
from PySide6.QtCore import (Qt) from PySide6.QtCore import Qt, QTimer
from interpreter.test_items.dialog_note_files import dialog_note_win from interpreter.test_items.dialog_note_files import dialog_note_win
from multiprocessing import freeze_support from multiprocessing import freeze_support
@@ -23,6 +23,14 @@ def main(args, conn=None):
d.setWindowTitle(args[0]) d.setWindowTitle(args[0])
d.labelDialog.setText(args[1]) d.labelDialog.setText(args[1])
d.textEdit.setFocus() d.textEdit.setFocus()
auto_result = args[2] if len(args) > 2 else None
if auto_result is not None:
auto_value = args[3] if len(args) > 3 else None
def _auto_close():
if auto_value is not None:
d.textEdit.setPlainText(auto_value)
d.accept() if auto_result.lower() == 'ok' else d.reject()
QTimer.singleShot(2000, _auto_close)
dres = d.exec() dres = d.exec()
if dres == QDialog.Rejected: if dres == QDialog.Rejected:

View File

@@ -2,7 +2,7 @@ import sys
from multiprocessing import freeze_support from multiprocessing import freeze_support
from PySide6.QtWidgets import (QApplication, QMessageBox) from PySide6.QtWidgets import (QApplication, QMessageBox)
from PySide6.QtCore import Qt from PySide6.QtCore import Qt, QTimer
def main(args, conn): def main(args, conn):
@@ -16,6 +16,10 @@ def main(args, conn):
msg.setText(args[1]) msg.setText(args[1])
msg.setIcon(QMessageBox.Question) msg.setIcon(QMessageBox.Question)
msg.setStandardButtons(QMessageBox.Yes | QMessageBox.No) msg.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
auto_result = args[2] if len(args) > 2 else None
if auto_result is not None:
btn = QMessageBox.Yes if auto_result.lower() == 'yes' else QMessageBox.No
QTimer.singleShot(2000, lambda: msg.button(btn).click())
reply = msg.exec() reply = msg.exec()
conn.send(reply) conn.send(reply)
except Exception as e: except Exception as e:

View File

@@ -3,7 +3,7 @@
################################################################################ ################################################################################
## Form generated from reading UI file 'dialog_sleep_win.ui' ## Form generated from reading UI file 'dialog_sleep_win.ui'
## ##
## Created by: Qt User Interface Compiler version 6.10.1 ## Created by: Qt User Interface Compiler version 6.11.0
## ##
## WARNING! All changes made in this file will be lost when recompiling UI file! ## WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################ ################################################################################

View File

@@ -3,7 +3,7 @@
################################################################################ ################################################################################
## Form generated from reading UI file 'dialog_value_win.ui' ## Form generated from reading UI file 'dialog_value_win.ui'
## ##
## Created by: Qt User Interface Compiler version 6.10.1 ## Created by: Qt User Interface Compiler version 6.11.0
## ##
## WARNING! All changes made in this file will be lost when recompiling UI file! ## WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################ ################################################################################

View File

@@ -2,7 +2,7 @@ import sys
import os import os
from PySide6.QtWidgets import (QApplication, QDialog) from PySide6.QtWidgets import (QApplication, QDialog)
from PySide6.QtCore import (Qt) from PySide6.QtCore import Qt, QTimer
from interpreter.test_items.dialog_value_files import dialog_value_win from interpreter.test_items.dialog_value_files import dialog_value_win
from multiprocessing import freeze_support from multiprocessing import freeze_support
@@ -25,6 +25,14 @@ def main(args, conn=None):
d.labelDialog.setText(args[1]) d.labelDialog.setText(args[1])
d.lineEdit.setText(args[2]) d.lineEdit.setText(args[2])
d.lineEdit.setFocus() d.lineEdit.setFocus()
auto_result = args[3] if len(args) > 3 else None
if auto_result is not None:
auto_value = args[4] if len(args) > 4 else None
def _auto_close():
if auto_value is not None:
d.lineEdit.setText(auto_value)
d.accept() if auto_result.lower() == 'ok' else d.reject()
QTimer.singleShot(2000, _auto_close)
dres = d.exec() dres = d.exec()
if dres == QDialog.Rejected: if dres == QDialog.Rejected:

View File

@@ -1,7 +1,6 @@
from interpreter.test_items.test_item import test_run from interpreter.test_items.test_item import test_run
from interpreter.test_items.test_result import TestValue from interpreter.test_items.test_result import TestValue
from interpreter.test_items.dialog_choices_files import choices_dialog from interpreter.test_items.test_item_dialog_base import TestItemDialogBase, _is_text_mode, _is_interactive
from interpreter.test_items.test_item_dialog_base import TestItemDialogBase
from interpreter.utils.constants import TestItemType as cst from interpreter.utils.constants import TestItemType as cst
from lib.tum_except import item_load_context from lib.tum_except import item_load_context
import libs.testium as tm import libs.testium as tm
@@ -17,13 +16,69 @@ class TestItemChoicesDialog(TestItemDialogBase):
self._question = self._prms.getParam("question", required=True) self._question = self._prms.getParam("question", required=True)
self._choices = self._prms.getParam("choices", required=True) self._choices = self._prms.getParam("choices", required=True)
self._default_icon = self._prms.getParam("icon", required=False, default=None) self._default_icon = self._prms.getParam("icon", required=False, default=None)
self._auto_result = self._prms.getParam("auto_result", required=False, default=None)
def _print_choices(self, choices, indent=0):
if not isinstance(choices, list):
return
for choice in choices:
name = choice.get("name", "")
desc = choice.get("description", "")
line = " " * indent + f"- {name}"
if desc:
line += f": {desc}"
print(line)
sub = choice.get("choices", None)
if sub:
self._print_choices(sub, indent + 1)
def _all_checked(self, choices):
result = []
if not isinstance(choices, list):
return result
for choice in choices:
item = {"name": choice.get("name", ""), "checked": True}
sub = choice.get("choices", None)
if sub is not None:
item["choices"] = self._all_checked(sub)
result.append(item)
return result
@test_run @test_run
def execute(self): def execute(self):
q = self._prms.expanse(self._question) q = self._prms.expanse(self._question)
choices = self._prms.expanse(self._choices) choices = self._prms.expanse(self._choices)
icon = self._prms.expanse(self._default_icon) icon = self._prms.expanse(self._default_icon)
result = self._run_dialog_with_result(choices_dialog.main, [self.name(), q, choices, icon]) if _is_text_mode():
print(f"Choices: {q}")
self._print_choices(choices)
if _is_interactive():
ans = input("Accept all? (y/n) [default: y]: ").strip().lower()
if ans in ('n', 'no'):
tm.delgd("cs_" + self._name)
self.result.set(TestValue.FAILURE, "Cancelled")
else:
val = self._all_checked(choices)
self.result.value = val
tm.setgd("cs_" + self._name, val)
self.result.set(TestValue.SUCCESS, str(val))
else:
ar = self._prms.expanse(self._auto_result) if self._auto_result is not None else None
if ar is None:
self.result.set(TestValue.FAILURE, 'Dialog not supported in batch mode')
elif ar == 'cancel':
tm.delgd("cs_" + self._name)
self.result.set(TestValue.FAILURE, "Cancelled")
else:
val = self._all_checked(choices)
self.result.value = val
tm.setgd("cs_" + self._name, val)
self.result.set(TestValue.SUCCESS, str(val))
return
from interpreter.test_items.dialog_choices_files import choices_dialog
ar = self._prms.expanse(self._auto_result) if self._auto_result is not None else None
args = [self.name(), q, choices, icon] + ([ar] if ar is not None else [])
result = self._run_dialog_with_result(choices_dialog.main, args)
if result is None: if result is None:
self.result.set(TestValue.FAILURE, "Dialog subprocess exited without returning a result") self.result.set(TestValue.FAILURE, "Dialog subprocess exited without returning a result")
return return

View File

@@ -0,0 +1,37 @@
from interpreter.test_items.test_item import TestItem, test_run
from interpreter.test_items.test_result import TestResult, TestValue
class TestItemContainer(TestItem):
"""Base class for items that run a sequence of children sequentially."""
def __init__(self, item_type, dict_item, parent=None, status_queue=None, filename=""):
self._name = item_type.item_name
super().__init__(dict_item, parent, status_queue, filename=filename)
self._type = item_type
self.is_container = True
def _run_children_sequentially(self):
"""Execute all children in order, respecting stop_on_failure and stop requests.
Returns a TestResult aggregating all children outcomes."""
i = 0
to_be_stopped = False
while not self.isStopped() and i < self.childCount() and not to_be_stopped:
result = self.child(i).execute()
if result.test_result == TestValue.FAILURE and self._stop_on_failure:
to_be_stopped = True
i += 1
if self.isStopped() or to_be_stopped:
for j in range(self.childCount()):
if self.child(j).executedOnStop() and j >= i:
self.child(j).execute()
success = TestValue.SUCCESS
for j in range(i):
if self.child(j).result.test_result == TestValue.FAILURE:
success = TestValue.FAILURE
break
stopped = self.isStopped() or to_be_stopped
return TestResult(None, success, ""), stopped

View File

@@ -1,7 +1,16 @@
import multiprocessing import multiprocessing
import libs.testium as tm
from interpreter.test_items.test_item import TestItem from interpreter.test_items.test_item import TestItem
def _is_text_mode():
return tm.text_mode()
def _is_interactive():
return bool(tm.gd("_interactive", True))
_spawn_ctx = multiprocessing.get_context('spawn') _spawn_ctx = multiprocessing.get_context('spawn')
@@ -40,7 +49,10 @@ class TestItemDialogBase(TestItem):
result = None result = None
while p.is_alive() and not self._is_stopped: while p.is_alive() and not self._is_stopped:
if parent_conn.poll(0.5): if parent_conn.poll(0.5):
try:
result = parent_conn.recv() result = parent_conn.recv()
except EOFError:
pass
break break
self._cleanup_process(p) self._cleanup_process(p)
return result return result

View File

@@ -2,8 +2,7 @@ import os
from interpreter.test_items.test_item import test_run from interpreter.test_items.test_item import test_run
from interpreter.test_items.test_result import TestValue from interpreter.test_items.test_result import TestValue
from interpreter.test_items.dialog_image_files import dialog_image from interpreter.test_items.test_item_dialog_base import TestItemDialogBase, _is_text_mode, _is_interactive
from interpreter.test_items.test_item_dialog_base import TestItemDialogBase
from interpreter.utils.constants import TestItemType as cst from interpreter.utils.constants import TestItemType as cst
from lib.tum_except import item_load_context from lib.tum_except import item_load_context
import libs.testium as tm import libs.testium as tm
@@ -21,6 +20,7 @@ class TestItemImageDialog(TestItemDialogBase):
with item_load_context(self.cmd(), self.name(), self.seqFilename()): with item_load_context(self.cmd(), self.name(), self.seqFilename()):
self._question = self._prms.getParam("question", required=True) self._question = self._prms.getParam("question", required=True)
self._filename = self._prms.getParam("filename", required=True) self._filename = self._prms.getParam("filename", required=True)
self._auto_result = self._prms.getParam("auto_result", required=False, default=None)
@test_run @test_run
def execute(self): def execute(self):
@@ -31,7 +31,23 @@ class TestItemImageDialog(TestItemDialogBase):
image_path = os.path.normpath( image_path = os.path.normpath(
os.path.join(tm.gd("test_directory"), image_path) os.path.join(tm.gd("test_directory"), image_path)
) )
succ = self._run_dialog_with_result(dialog_image.main, [self.name(), q, image_path]) if _is_text_mode():
if _is_interactive():
ans = input("Accept? (y/n) [default: y]: ").strip().lower()
self.result.set(TestValue.FAILURE if ans in ('n', 'no') else TestValue.SUCCESS)
else:
ar = self._prms.expanse(self._auto_result) if self._auto_result is not None else None
if ar is None:
self.result.set(TestValue.FAILURE, 'Dialog not supported in batch mode')
elif ar == 'cancel':
self.result.set(TestValue.FAILURE)
else:
self.result.set(TestValue.SUCCESS)
return
from interpreter.test_items.dialog_image_files import dialog_image
ar = self._prms.expanse(self._auto_result) if self._auto_result is not None else None
args = [self.name(), q, image_path] + ([ar] if ar is not None else [])
succ = self._run_dialog_with_result(dialog_image.main, args)
if succ is None: if succ is None:
self.result.set(TestValue.FAILURE, "Dialog subprocess exited without returning a result") self.result.set(TestValue.FAILURE, "Dialog subprocess exited without returning a result")
elif succ: elif succ:

View File

@@ -3,8 +3,7 @@ import sys
from interpreter.test_items.test_item import test_run from interpreter.test_items.test_item import test_run
from interpreter.test_items.test_result import TestValue from interpreter.test_items.test_result import TestValue
from interpreter.test_items.dialog_msg_files import msg_dialog from interpreter.test_items.test_item_dialog_base import TestItemDialogBase, _is_text_mode, _is_interactive
from interpreter.test_items.test_item_dialog_base import TestItemDialogBase
from interpreter.utils.constants import TestItemType as cst from interpreter.utils.constants import TestItemType as cst
from lib.tum_except import item_load_context from lib.tum_except import item_load_context
@@ -20,12 +19,27 @@ class TestItemMsgDialog(TestItemDialogBase):
self.is_container = False self.is_container = False
with item_load_context(self.cmd(), self.name(), self.seqFilename()): with item_load_context(self.cmd(), self.name(), self.seqFilename()):
self._question = self._prms.getParam('question', required=True) self._question = self._prms.getParam('question', required=True)
self._auto_result = self._prms.getParam('auto_result', required=False, default=None)
@test_run @test_run
def execute(self): def execute(self):
q = self._prms.expanse(self._question) q = self._prms.expanse(self._question)
print("Message Displayed:\n" + q) print("Message Displayed:\n" + q)
exitcode = self._run_dialog(msg_dialog.main, [self.name(), q]) if _is_text_mode():
if _is_interactive():
input("Press Enter to continue...")
self.result.set(TestValue.SUCCESS)
else:
ar = self._prms.expanse(self._auto_result) if self._auto_result is not None else None
if ar is not None:
self.result.set(TestValue.SUCCESS)
else:
self.result.set(TestValue.FAILURE, 'Dialog not supported in batch mode')
return
from interpreter.test_items.dialog_msg_files import msg_dialog
ar = self._prms.expanse(self._auto_result) if self._auto_result is not None else None
args = [self.name(), q] + ([ar] if ar is not None else [])
exitcode = self._run_dialog(msg_dialog.main, args)
if exitcode == 0: if exitcode == 0:
self.result.set(TestValue.SUCCESS) self.result.set(TestValue.SUCCESS)
else: else:

View File

@@ -1,7 +1,6 @@
from interpreter.test_items.test_item import test_run from interpreter.test_items.test_item import test_run
from interpreter.test_items.test_result import TestValue from interpreter.test_items.test_result import TestValue
from interpreter.test_items.dialog_note_files import test_dialog from interpreter.test_items.test_item_dialog_base import TestItemDialogBase, _is_text_mode, _is_interactive
from interpreter.test_items.test_item_dialog_base import TestItemDialogBase
from interpreter.utils.constants import TestItemType as cst from interpreter.utils.constants import TestItemType as cst
from lib.tum_except import item_load_context from lib.tum_except import item_load_context
import libs.testium as tm import libs.testium as tm
@@ -15,12 +14,50 @@ class TestItemNoteDialog(TestItemDialogBase):
self.is_container = False self.is_container = False
with item_load_context(self.cmd(), self.name(), self.seqFilename()): with item_load_context(self.cmd(), self.name(), self.seqFilename()):
self._question = self._prms.getParam('question', required=True) self._question = self._prms.getParam('question', required=True)
self._auto_result = self._prms.getParam('auto_result', required=False, default=None)
self._auto_value = self._prms.getParam('auto_value', required=False, default=None)
@test_run @test_run
def execute(self): def execute(self):
q = self._prms.expanse(self._question) q = self._prms.expanse(self._question)
print("Question:\n" + q) print("Question:\n" + q)
result = self._run_dialog_with_result(test_dialog.main, [self.name(), q]) if _is_text_mode():
if _is_interactive():
print("Enter your note (type '.' on a new line to finish, empty line to cancel):")
lines = []
while True:
line = input()
if line == '.':
break
lines.append(line)
val = '\n'.join(lines)
else:
ar = self._prms.expanse(self._auto_result) if self._auto_result is not None else None
av = self._prms.expanse(self._auto_value) if self._auto_value is not None else None
if ar is None:
self.result.set(TestValue.FAILURE, 'Dialog not supported in batch mode')
return
if ar == 'cancel':
self.result.set(TestValue.FAILURE, 'Dialog cancelled')
return
val = av if av is not None else ''
tm.setgd(self.name(), val)
print("\n" + ("-" * 80) + "\n")
print("- Test note\n")
print("-" * 80 + "\n")
print(val)
print("-" * 80 + "\n")
self.result.reported = {'note': val}
if val:
self.result.set(TestValue.SUCCESS, val)
else:
self.result.set(TestValue.FAILURE, val)
return
from interpreter.test_items.dialog_note_files import test_dialog
ar = self._prms.expanse(self._auto_result) if self._auto_result is not None else None
av = self._prms.expanse(self._auto_value) if self._auto_value is not None else None
args = [self.name(), q] + ([ar, av] if ar is not None else [])
result = self._run_dialog_with_result(test_dialog.main, args)
if result is None: if result is None:
self.result.set(TestValue.FAILURE, "Dialog subprocess exited without returning a result") self.result.set(TestValue.FAILURE, "Dialog subprocess exited without returning a result")
return return

View File

@@ -0,0 +1,193 @@
import threading
from time import sleep, time
from interpreter.test_items.test_item_container import TestItemContainer
from interpreter.test_items.test_item import test_run
from interpreter.test_items.test_result import TestResult, TestValue
from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.eval import eval_to_boolean
from lib.tum_except import ETUMSyntaxError
from lib.string_queue import StringQueue
from lib.stdout_redirect import stdio_redir
class TestItemParallelBranch(TestItemContainer):
"""One branch of a parallel item. Runs its children sequentially,
optionally waiting for a condition before starting."""
def __init__(self, dict_item, parent=None, status_queue=None, filename=""):
super().__init__(cst.TYPE_PARALLEL_BRANCH, dict_item, parent, status_queue, filename=filename)
self._wait_condition = None
self._wait_timeout = 30
if "wait_for" in dict_item:
wf = dict_item["wait_for"]
if not isinstance(wf, dict):
raise ETUMSyntaxError(
f"'wait_for' in branch '{self.name()}' must be a dict with 'condition' and optional 'timeout'",
self.seqFilename(),
)
self._wait_condition = wf.get("condition", None)
self._wait_timeout = float(wf.get("timeout", 30))
def _wait_start(self):
"""Block until wait_for condition is True, or timeout. Returns False on timeout."""
if self._wait_condition is None:
return True
deadline = time() + self._wait_timeout
while time() < deadline:
if self.isStopped():
return False
try:
c = self._prms.expanse(self._wait_condition)
if eval_to_boolean(c):
return True
except Exception:
pass
sleep(0.1)
return False
@test_run
def execute(self):
if not self._wait_start():
self.result.set(
TestValue.FAILURE,
f"wait_for timeout ({self._wait_timeout}s): condition '{self._wait_condition}' not met",
)
return
result, stopped = self._run_children_sequentially()
if stopped:
if result.test_result == TestValue.FAILURE:
self.result.set(TestValue.FAILURE, "Branch aborted on failure")
else:
self.result.set(TestValue.NORUN, "Branch aborted on user request")
else:
self.result.set(result.test_result, "")
class TestItemParallel(TestItemContainer):
"""Runs multiple branches concurrently.
YAML:
parallel:
name: ...
sync: all # all (default): wait for every branch
# any: stop as soon as one branch finishes
stop_on_failure: false
branches:
- name: Branch A
wait_for:
condition: "'$(ready)' == 'True'"
timeout: 30
steps:
- ...
- name: Branch B
steps:
- ...
"""
def __init__(self, dict_item, parent=None, status_queue=None, filename=""):
branches = dict_item.get("branches", [])
if not branches:
raise ETUMSyntaxError(
f"'parallel' item requires at least one branch in 'branches'",
dict_item.get("seq_filename", ""),
)
# Inject a synthetic 'steps' key so load_test_recursively can load branches
# as TestItemParallelBranch children. The base class' _filter_dict_item
# drops 'steps'; we also drop 'branches' (overridden below) so the F1
# panel shows only the parallel's own attributes, not the duplicated
# tree of branches/steps already displayed in the test tree.
dict_item["steps"] = [{"parallel_branch": b} for b in branches]
super().__init__(cst.TYPE_PARALLEL, dict_item, parent, status_queue, filename=filename)
self._sync = str(dict_item.get("sync", "all")).lower()
if self._sync not in ("all", "any"):
raise ETUMSyntaxError(
f"'sync' must be 'all' or 'any', got '{self._sync}'",
self.seqFilename(),
)
def _filter_dict_item(self, dict_item):
c = super()._filter_dict_item(dict_item)
# Keep 'branches' so the F1 panel shows the branch list and their
# per-branch attributes (name, wait_for, condition, ...), but strip
# the 'steps' inside each branch — the steps are already visible as
# children in the test tree and would just duplicate the information.
if isinstance(c, dict) and isinstance(c.get("branches"), list):
stripped = []
for b in c["branches"]:
if isinstance(b, dict):
stripped.append({k: v for k, v in b.items() if k != "steps"})
else:
stripped.append(b)
c["branches"] = stripped
return c
def _stop_branch_recursively(self, item):
item.stop()
for i in range(item.childCount()):
self._stop_branch_recursively(item.child(i))
@test_run
def execute(self):
branch_results = [None] * self.childCount()
any_done = threading.Event()
def run_branch(idx):
branch = self.child(idx)
stdio_redir.register_thread(buffer=StringQueue(), branch=branch.name())
try:
# sync:any: if another branch already won the race, mark this
# branch as stopped so its execute() skips children but still
# goes through the normal addTest path (clean DB entry).
if self._sync == "any" and any_done.is_set():
branch.stop()
try:
result = branch.execute()
except Exception as e:
import traceback
print(f"[parallel] Branch '{branch.name()}' crashed: {e}")
traceback.print_exc()
branch.result.set(TestValue.FAILURE, f"Branch crashed: {e}")
result = branch.result
branch_results[idx] = result
# Only a branch that actually ran (SUCCESS or FAILURE) wins the
# sync:any race. A disabled or skipped branch returns NORUN
# almost instantly and must not stop legitimate branches.
if self._sync == "any" and result.test_result != TestValue.NORUN:
any_done.set()
for j in range(self.childCount()):
if j != idx:
self._stop_branch_recursively(self.child(j))
finally:
stdio_redir.unregister_thread()
threads = [
threading.Thread(target=run_branch, args=(i,), daemon=True)
for i in range(self.childCount())
]
for t in threads:
t.start()
for t in threads:
t.join()
if self._sync == "all":
# Pass if no branch failed; disabled/skipped branches (NORUN) are
# ignored, matching how Group/Cycle treat disabled children.
success = all(
r is not None and r.test_result != TestValue.FAILURE
for r in branch_results
)
else:
# Pass if at least one branch ran and succeeded.
success = any(
r is not None and r.test_result == TestValue.SUCCESS
for r in branch_results
)
self.result.set(
TestValue.SUCCESS if success else TestValue.FAILURE,
f"parallel sync={self._sync}",
)

View File

@@ -1,9 +1,6 @@
from PySide6.QtWidgets import QMessageBox
from interpreter.test_items.test_item import test_run from interpreter.test_items.test_item import test_run
from interpreter.test_items.test_result import TestValue from interpreter.test_items.test_result import TestValue
from interpreter.test_items.dialog_question_files import question_dialog from interpreter.test_items.test_item_dialog_base import TestItemDialogBase, _is_text_mode, _is_interactive
from interpreter.test_items.test_item_dialog_base import TestItemDialogBase
from interpreter.utils.constants import TestItemType as cst from interpreter.utils.constants import TestItemType as cst
from lib.tum_except import item_load_context from lib.tum_except import item_load_context
@@ -19,15 +16,40 @@ class TestItemQuestionDialog(TestItemDialogBase):
self.is_container = False self.is_container = False
with item_load_context(self.cmd(), self.name(), self.seqFilename()): with item_load_context(self.cmd(), self.name(), self.seqFilename()):
self._question = self._prms.getParam('question', required=True) self._question = self._prms.getParam('question', required=True)
self._auto_result = self._prms.getParam('auto_result', required=False, default=None)
@test_run @test_run
def execute(self): def execute(self):
q = self._prms.expanse(self._question) q = self._prms.expanse(self._question)
print('Question asked:\n' + q + '\n') print('Question asked:\n' + q + '\n')
succ = self._run_dialog_with_result(question_dialog.main, [self.name(), q]) if _is_text_mode():
if _is_interactive():
ans = input("Answer yes (y) or no (n) [default: y]: ").strip().lower()
if ans in ('n', 'no'):
self.result.set(TestValue.FAILURE)
print('Answer: NO\n')
else:
self.result.set(TestValue.SUCCESS)
print('Answer: YES\n')
else:
ar = self._prms.expanse(self._auto_result) if self._auto_result is not None else None
if ar is None:
self.result.set(TestValue.FAILURE, 'Dialog not supported in batch mode')
elif ar in ('no', 'cancel'):
self.result.set(TestValue.FAILURE)
print('Answer: NO\n')
else:
self.result.set(TestValue.SUCCESS)
print('Answer: YES\n')
return
from interpreter.test_items.dialog_question_files import question_dialog
ar = self._prms.expanse(self._auto_result) if self._auto_result is not None else None
args = [self.name(), q] + ([ar] if ar is not None else [])
succ = self._run_dialog_with_result(question_dialog.main, args)
if succ is None: if succ is None:
self.result.set(TestValue.FAILURE, "Dialog subprocess exited without returning a result") self.result.set(TestValue.FAILURE, "Dialog subprocess exited without returning a result")
return return
from PySide6.QtWidgets import QMessageBox
if succ == QMessageBox.Yes: if succ == QMessageBox.Yes:
self.result.set(TestValue.SUCCESS) self.result.set(TestValue.SUCCESS)
print('Answer: YES\n') print('Answer: YES\n')

View File

@@ -31,7 +31,7 @@ class TestItemRun(TestItem):
self._type = cst.TYPE_RUN self._type = cst.TYPE_RUN
self.is_container = False self.is_container = False
with item_load_context(self.cmd(), self.name(), self.seqFilename()): with item_load_context(self.cmd(), self.name(), self.seqFilename()):
self.tum_fime = self._prms.getParam('tum_fime', required=True) self.tum_file = self._prms.getParam('tum', required=True)
self.param_file = self._prms.getParam('param_file', default='') self.param_file = self._prms.getParam('param_file', default='')
self.python_bin = self._prms.getParam('python_bin', default='') self.python_bin = self._prms.getParam('python_bin', default='')
self.testium_path = self._prms.getParam('testium_path', default='') self.testium_path = self._prms.getParam('testium_path', default='')
@@ -43,39 +43,43 @@ class TestItemRun(TestItem):
@test_run @test_run
def execute(self): def execute(self):
res = -1
try: try:
file_path = self._prms.expanse(self.tum_fime) file_path = self._prms.expanse(self.tum_file)
if not os.path.exists(file_path) and not os.path.isabs(file_path): if not os.path.exists(file_path) and not os.path.isabs(file_path):
file_path = os.path.join(tm.gd('test_directory'), self.tum_fime) file_path = os.path.join(tm.gd('test_directory'), file_path)
if not os.path.isfile(file_path): if not os.path.isfile(file_path):
raise ETUMRuntimeError( raise ETUMRuntimeError(
'"{}" file could not be found'.format(file_path)) '"{}" file could not be found'.format(file_path))
self.tum_fime = file_path self.tum_file = file_path
pf = self._prms.expanse(self.param_file) pf = self._prms.expanse(self.param_file)
pp = self._prms.expanse(self.python_bin) pp = self._prms.expanse(self.python_bin)
sp = self._prms.expanse(self.testium_path) sp = self._prms.expanse(self.testium_path)
lp = self._prms.expanse(self.log_path) lp = self._prms.expanse(self.log_path)
rp = self._prms.expanse(self.report_path) rp = self._prms.expanse(self.report_path)
cmd = [] cmd = []
if sp == '':
sp = sys.argv[0]
if pp != '': if pp != '':
cmd.append(pp) cmd.append(pp)
if sp == '': elif not os.path.isfile(sp) or not os.access(sp, os.X_OK):
sp = os.path.join(tm.get_main_dir(), "testium.pyw") cmd.append(sys.executable)
cmd.append(sp) cmd.append(sp)
if lp == '': if tm.text_mode():
lp = os.path.splitext(self.tum_fime)[0] + "_" + \ cmd.append("-b")
datetime.utcnow().isoformat(timespec='seconds') + '.log' else:
cmd.append("-r") cmd.append("-r")
if lp == '':
lp = os.path.splitext(self.tum_file)[0] + "_" + \
datetime.utcnow().isoformat(timespec='seconds') + '.log'
cmd.append("-l")
cmd.append('"' + lp + '"')
if pf != '': if pf != '':
cmd.append("-c") cmd.append("-c")
cmd.append('"' + pf + '"') cmd.append('"' + pf + '"')
cmd.append("-l")
cmd.append('"' + lp + '"')
if rp != '': if rp != '':
cmd.append("-p") cmd.append("-p")
cmd.append('"' + rp + '"') cmd.append('"' + rp + '"')
cmd.append(self.tum_fime) cmd.append(self.tum_file)
for c in cmd: for c in cmd:
print(c, end = ' ') print(c, end = ' ')
@@ -90,31 +94,23 @@ class TestItemRun(TestItem):
raise ETUMRuntimeError( raise ETUMRuntimeError(
'"wait_for_exec" set but not start_time or end_time') '"wait_for_exec" set but not start_time or end_time')
r = None
if self.wait_for_exec: if self.wait_for_exec:
while not nowInBetween(self.start_time, self.end_time): while not nowInBetween(self.start_time, self.end_time):
sleep(60) sleep(60)
r = subprocess.run( r = subprocess.run(cmd)
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
elif self.start_time is not None and self.end_time is not None: elif self.start_time is not None and self.end_time is not None:
if nowInBetween(self.start_time, self.end_time): if nowInBetween(self.start_time, self.end_time):
r = subprocess.run( r = subprocess.run(cmd)
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
elif self.start_time is not None: elif self.start_time is not None:
if self.start_time < datetime.now().time(): if self.start_time < datetime.now().time():
r = subprocess.run( r = subprocess.run(cmd)
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
else: else:
r = subprocess.run( r = subprocess.run(cmd)
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if isinstance(r, subprocess.CompletedProcess): if isinstance(r, subprocess.CompletedProcess):
print((r.stdout).decode())
print(r.stderr.decode())
res = r.returncode
if res >= 0:
self.result.set(TestValue.SUCCESS) self.result.set(TestValue.SUCCESS)
else: else:
self.result.set(TestValue.FAILURE, self.result.set(TestValue.FAILURE, 'Sub-test did not execute')
'Test execution returned negative value.')
except: except:
traceback.print_exception(*sys.exc_info()) traceback.print_exception(*sys.exc_info())
self.result.set(TestValue.FAILURE, 'Unrecoverable "run" item error') self.result.set(TestValue.FAILURE, 'Unrecoverable "run" item error')

View File

@@ -3,9 +3,9 @@ from time import sleep
from datetime import timedelta from datetime import timedelta
from multiprocessing import Process, Pipe from multiprocessing import Process, Pipe
import libs.testium as tm
from interpreter.test_items.test_item import (TestItem, test_run) from interpreter.test_items.test_item import (TestItem, test_run)
from interpreter.test_items.test_result import (TestValue) from interpreter.test_items.test_result import (TestValue)
from interpreter.test_items.dialog_sleep_files import dialog_sleep
from interpreter.utils.constants import TestItemType as cst from interpreter.utils.constants import TestItemType as cst
from lib.tum_except import ETUMSyntaxError, ETUMRuntimeError, item_load_context from lib.tum_except import ETUMSyntaxError, ETUMRuntimeError, item_load_context
@@ -43,6 +43,20 @@ class TestItemSleep(TestItem):
#test core function #test core function
if has_dialog: if has_dialog:
if tm.text_mode():
import time as _time
print(f"Sleep {timeout}s (press Ctrl+C to abort)...")
end_time = _time.time() + float(timeout)
while _time.time() < end_time and not self._is_stopped:
sleep(0.2)
if self._is_stopped:
print("Aborted")
self.result.set(TestValue.FAILURE, 'Sleep aborted')
else:
self.result.set(TestValue.SUCCESS, f'Sleep {timeout} sec')
return
from interpreter.test_items.dialog_sleep_files import dialog_sleep
parent_conn, child_conn = Pipe() parent_conn, child_conn = Pipe()
p=Process(target=dialog_sleep.main, args=([self.name(), timeout],child_conn)) p=Process(target=dialog_sleep.main, args=([self.name(), timeout],child_conn))
p.start() p.start()
@@ -62,5 +76,8 @@ class TestItemSleep(TestItem):
else: else:
if not isinstance(timeout, (int, float)): if not isinstance(timeout, (int, float)):
raise ETUMRuntimeError(f"Timeout value of sleep test item \"{self.name}\" is not valid: \"{timeout}\".") raise ETUMRuntimeError(f"Timeout value of sleep test item \"{self.name}\" is not valid: \"{timeout}\".")
sleep(timeout) import time as _time
end_time = _time.time() + float(timeout)
while _time.time() < end_time and not self._is_stopped:
sleep(min(0.05, end_time - _time.time()))
self.result.set(TestValue.SUCCESS, 'Sleep %s sec' % (str(timeout))) self.result.set(TestValue.SUCCESS, 'Sleep %s sec' % (str(timeout)))

View File

@@ -1,7 +1,6 @@
from interpreter.test_items.test_item import test_run from interpreter.test_items.test_item import test_run
from interpreter.test_items.test_result import TestValue from interpreter.test_items.test_result import TestValue
from interpreter.test_items.tested_references_files import tested_refs_dialog from interpreter.test_items.test_item_dialog_base import TestItemDialogBase, _is_text_mode, _is_interactive
from interpreter.test_items.test_item_dialog_base import TestItemDialogBase
from interpreter.utils.constants import TestItemType as cst from interpreter.utils.constants import TestItemType as cst
from lib.tum_except import item_load_context from lib.tum_except import item_load_context
import libs.testium as tm import libs.testium as tm
@@ -16,12 +15,40 @@ class TestItemTestedRefsDialog(TestItemDialogBase):
with item_load_context(self.cmd(), self.name(), self.seqFilename()): with item_load_context(self.cmd(), self.name(), self.seqFilename()):
self._question = self._prms.getParam('question', required=True) self._question = self._prms.getParam('question', required=True)
self._init_values = self._prms.getParamAll('reference', required=False, processed=True) self._init_values = self._prms.getParamAll('reference', required=False, processed=True)
self._auto_result = self._prms.getParam('auto_result', required=False, default=None)
@test_run @test_run
def execute(self): def execute(self):
q = self._prms.expanse(self._question) q = self._prms.expanse(self._question)
init_values = ','.join(self._init_values) init_values = ','.join(self._init_values)
result = self._run_dialog_with_result(tested_refs_dialog.main, [self.name(), q, init_values]) if _is_text_mode():
print(f"References: {q}")
rows = init_values.split(',') if init_values else ['']
result_rows = []
for i, row in enumerate(rows):
parts = (row.split('/') + ['', '', ''])[:3]
if _is_interactive():
ref = input(f"Row {i+1} - Reference [{parts[0]}]: ").strip() or parts[0]
rev = input(f"Row {i+1} - Revision [{parts[1]}]: ").strip() or parts[1]
serial = input(f"Row {i+1} - Serial [{parts[2]}]: ").strip() or parts[2]
else:
ref, rev, serial = parts[0], parts[1], parts[2]
result_rows.append(f"{ref}/{rev}/{serial}")
val = ','.join(result_rows)
if _is_interactive():
succ = True
else:
ar = self._prms.expanse(self._auto_result) if self._auto_result is not None else None
if ar is None:
self.result.set(TestValue.FAILURE, 'Dialog not supported in batch mode')
return
succ = ar != 'cancel'
result = [val, succ]
else:
from interpreter.test_items.tested_references_files import tested_refs_dialog
ar = self._prms.expanse(self._auto_result) if self._auto_result is not None else None
args = [self.name(), q, init_values] + ([ar] if ar is not None else [])
result = self._run_dialog_with_result(tested_refs_dialog.main, args)
if result is None: if result is None:
self.result.set(TestValue.FAILURE, "Dialog subprocess exited without returning a result") self.result.set(TestValue.FAILURE, "Dialog subprocess exited without returning a result")
return return

View File

@@ -96,10 +96,10 @@ class TestItemUnittestElement(TestItem):
class TestItemUnittestFile(TestItem): class TestItemUnittestFile(TestItem):
def __init__(self, dict_item, parent = None, status_queue=None, filename=""): def __init__(self, dict_item, parent = None, status_queue=None, filename=""):
self._name = cst.TYPE_UNITTEST_FILE.item_name self._name = cst.TYPE_UNITTEST.item_name
super().__init__(dict_item, parent, status_queue, filename=filename) super().__init__(dict_item, parent, status_queue, filename=filename)
self.is_container = True self.is_container = True
self._type = cst.TYPE_UNITTEST_FILE self._type = cst.TYPE_UNITTEST
self._fileName = self._prms.getParam('test_file', required = True, processed = True) self._fileName = self._prms.getParam('test_file', required = True, processed = True)
self._testDir = '' self._testDir = ''
self._test_methods = self._prms.getParamAll('test_method', processed=True) self._test_methods = self._prms.getParamAll('test_method', processed=True)
@@ -161,7 +161,7 @@ class TestItemUnittestFile(TestItem):
if self.isStopped(): if self.isStopped():
self.result.set(TestValue.NORUN, 'Group execution aborted on user request') self.result.set(TestValue.NORUN, 'Group execution aborted on user request')
else: else:
self.result.set(result.test_result, 'unittest file ' + str(result.test_result)) self.result.set(result.test_result, 'unittest ' + str(result.test_result))
def load(self): def load(self):
ret = {} ret = {}

View File

@@ -1,7 +1,6 @@
from interpreter.test_items.test_item import test_run from interpreter.test_items.test_item import test_run
from interpreter.test_items.test_result import TestValue from interpreter.test_items.test_result import TestValue
from interpreter.test_items.dialog_value_files import test_dialog from interpreter.test_items.test_item_dialog_base import TestItemDialogBase, _is_text_mode, _is_interactive
from interpreter.test_items.test_item_dialog_base import TestItemDialogBase
from interpreter.utils.constants import TestItemType as cst from interpreter.utils.constants import TestItemType as cst
from lib.tum_except import item_load_context from lib.tum_except import item_load_context
import libs.testium as tm import libs.testium as tm
@@ -19,13 +18,45 @@ class TestItemValueDialog(TestItemDialogBase):
with item_load_context(self.cmd(), self.name(), self.seqFilename()): with item_load_context(self.cmd(), self.name(), self.seqFilename()):
self._question = self._prms.getParam('question', required=True) self._question = self._prms.getParam('question', required=True)
self._default = self._prms.getParam('default', '') self._default = self._prms.getParam('default', '')
self._auto_result = self._prms.getParam('auto_result', required=False, default=None)
self._auto_value = self._prms.getParam('auto_value', required=False, default=None)
@test_run @test_run
def execute(self): def execute(self):
q = self._prms.expanse(self._question) q = self._prms.expanse(self._question)
d = self._prms.expanse(self._default) d = self._prms.expanse(self._default)
print("Question:\n" + q) print("Question:\n" + q)
result = self._run_dialog_with_result(test_dialog.main, [self.name(), q, d]) if _is_text_mode():
if _is_interactive():
prompt = f"Enter value [{d}]: " if d else "Enter value: "
ans = input(prompt).strip()
else:
ar = self._prms.expanse(self._auto_result) if self._auto_result is not None else None
av = self._prms.expanse(self._auto_value) if self._auto_value is not None else None
if ar is None:
print("Answer: \nDialog not supported in batch mode")
self.result.set(TestValue.FAILURE, 'Dialog not supported in batch mode')
return
if ar == 'cancel':
print("Answer: \nDialog cancelled")
self.result.set(TestValue.FAILURE, 'Dialog cancelled')
return
ans = av if av is not None else ''
val = ans if ans else d
tm.setgd(self.name(), val)
print("Answer: " + str(val))
if val:
self.result.reported = {'question': q, 'answer': val}
self.result.value = val
self.result.set(TestValue.SUCCESS, val)
else:
self.result.set(TestValue.FAILURE, 'No value entered')
return
from interpreter.test_items.dialog_value_files import test_dialog
ar = self._prms.expanse(self._auto_result) if self._auto_result is not None else None
av = self._prms.expanse(self._auto_value) if self._auto_value is not None else None
args = [self.name(), q, d] + ([ar, av] if ar is not None else [])
result = self._run_dialog_with_result(test_dialog.main, args)
if result is None: if result is None:
self.result.set(TestValue.FAILURE, "Dialog subprocess exited without returning a result") self.result.set(TestValue.FAILURE, "Dialog subprocess exited without returning a result")
return return

View File

@@ -2,7 +2,7 @@ import sys
from multiprocessing import freeze_support from multiprocessing import freeze_support
from PySide6.QtWidgets import (QApplication, QDialog, QTableWidgetItem) from PySide6.QtWidgets import (QApplication, QDialog, QTableWidgetItem)
from PySide6.QtCore import (Qt, QSettings) from PySide6.QtCore import Qt, QSettings, QTimer
try: try:
from interpreter.test_items.tested_references_files import tested_refs_win from interpreter.test_items.tested_references_files import tested_refs_win
@@ -52,6 +52,9 @@ def main(args, conn=None):
i += 1 i += 1
d.tableReferences.setFocus() d.tableReferences.setFocus()
auto_result = args[3] if len(args) > 3 else None
if auto_result is not None:
QTimer.singleShot(2000, lambda: d.accept() if auto_result.lower() == 'ok' else d.reject())
dres = d.exec() dres = d.exec()
if dres == QDialog.Rejected: if dres == QDialog.Rejected:

View File

@@ -3,7 +3,7 @@
################################################################################ ################################################################################
## Form generated from reading UI file 'tested_refs_win.ui' ## Form generated from reading UI file 'tested_refs_win.ui'
## ##
## Created by: Qt User Interface Compiler version 6.10.1 ## Created by: Qt User Interface Compiler version 6.11.0
## ##
## WARNING! All changes made in this file will be lost when recompiling UI file! ## WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################ ################################################################################
@@ -44,13 +44,13 @@ class Ui_Dialog(object):
font1 = QFont() font1 = QFont()
font1.setPointSize(10) font1.setPointSize(10)
__qtablewidgetitem = QTableWidgetItem() __qtablewidgetitem = QTableWidgetItem()
__qtablewidgetitem.setFont(font1); __qtablewidgetitem.setFont(font1)
self.tableReferences.setHorizontalHeaderItem(0, __qtablewidgetitem) self.tableReferences.setHorizontalHeaderItem(0, __qtablewidgetitem)
__qtablewidgetitem1 = QTableWidgetItem() __qtablewidgetitem1 = QTableWidgetItem()
__qtablewidgetitem1.setFont(font1); __qtablewidgetitem1.setFont(font1)
self.tableReferences.setHorizontalHeaderItem(1, __qtablewidgetitem1) self.tableReferences.setHorizontalHeaderItem(1, __qtablewidgetitem1)
__qtablewidgetitem2 = QTableWidgetItem() __qtablewidgetitem2 = QTableWidgetItem()
__qtablewidgetitem2.setFont(font1); __qtablewidgetitem2.setFont(font1)
self.tableReferences.setHorizontalHeaderItem(2, __qtablewidgetitem2) self.tableReferences.setHorizontalHeaderItem(2, __qtablewidgetitem2)
self.tableReferences.setObjectName(u"tableReferences") self.tableReferences.setObjectName(u"tableReferences")
self.tableReferences.setGeometry(QRect(10, 130, 461, 211)) self.tableReferences.setGeometry(QRect(10, 130, 461, 211))
@@ -70,10 +70,10 @@ class Ui_Dialog(object):
Dialog.setWindowTitle(QCoreApplication.translate("Dialog", u"Dialog", None)) Dialog.setWindowTitle(QCoreApplication.translate("Dialog", u"Dialog", None))
self.labelDialog.setText(QCoreApplication.translate("Dialog", u"TextLabel", None)) self.labelDialog.setText(QCoreApplication.translate("Dialog", u"TextLabel", None))
___qtablewidgetitem = self.tableReferences.horizontalHeaderItem(0) ___qtablewidgetitem = self.tableReferences.horizontalHeaderItem(0)
___qtablewidgetitem.setText(QCoreApplication.translate("Dialog", u"Reference", None)); ___qtablewidgetitem.setText(QCoreApplication.translate("Dialog", u"Reference", None))
___qtablewidgetitem1 = self.tableReferences.horizontalHeaderItem(1) ___qtablewidgetitem1 = self.tableReferences.horizontalHeaderItem(1)
___qtablewidgetitem1.setText(QCoreApplication.translate("Dialog", u"Revision", None)); ___qtablewidgetitem1.setText(QCoreApplication.translate("Dialog", u"Revision", None))
___qtablewidgetitem2 = self.tableReferences.horizontalHeaderItem(2) ___qtablewidgetitem2 = self.tableReferences.horizontalHeaderItem(2)
___qtablewidgetitem2.setText(QCoreApplication.translate("Dialog", u"Serial number", None)); ___qtablewidgetitem2.setText(QCoreApplication.translate("Dialog", u"Serial number", None))
# retranslateUi # retranslateUi

View File

@@ -10,7 +10,7 @@ class ReportExportTxt(rpe.ReportExport):
no_value_types = [cst_type.TYPE_CONSOLE.item_name, cst_type.TYPE_SLEEP.item_name, no_value_types = [cst_type.TYPE_CONSOLE.item_name, cst_type.TYPE_SLEEP.item_name,
cst_type.TYPE_IMAGE_DLG.item_name, cst_type.TYPE_LET.item_name, cst_type.TYPE_CHECK, cst_type.TYPE_IMAGE_DLG.item_name, cst_type.TYPE_LET.item_name, cst_type.TYPE_CHECK,
cst_type.TYPE_CYCLE.item_name, cst_type.TYPE_GROUP.item_name, cst_type.TYPE_CYCLE.item_name, cst_type.TYPE_GROUP.item_name,
cst_type.TYPE_UNITTEST_FILE.item_name, cst_type.TYPE_MESSAGE_DLG.item_name, cst_type.TYPE_UNITTEST.item_name, cst_type.TYPE_MESSAGE_DLG.item_name,
cst_type.TYPE_QUESTION_DLG.item_name] cst_type.TYPE_QUESTION_DLG.item_name]
def __init__(self, name, report_db, report_file, pattern, key, no_header=False): def __init__(self, name, report_db, report_file, pattern, key, no_header=False):

View File

@@ -1,4 +1,5 @@
import os import os
import threading
from functools import wraps from functools import wraps
import sqlite3 import sqlite3
from time import (time, sleep) from time import (time, sleep)
@@ -143,6 +144,7 @@ class TestReport:
self._level = 0 self._level = 0
self._log_stored = False self._log_stored = False
self._con = None self._con = None
self._lock = threading.Lock()
if dict_report is None: if dict_report is None:
self._active = False self._active = False
@@ -231,7 +233,7 @@ class TestReport:
prepare_file_to_save(rep_path) prepare_file_to_save(rep_path)
if not os.path.exists(os.path.dirname(rep_path)): if not os.path.exists(os.path.dirname(rep_path)):
raise ETUMRuntimeError("Report path does not exist: " + rep_path) raise ETUMRuntimeError("Report path does not exist: " + rep_path)
self._con = sqlite3.connect(rep_path) self._con = sqlite3.connect(rep_path, check_same_thread=False)
self.createHeader(header) self.createHeader(header)
self.createTestTable() self.createTestTable()
self._con.commit() self._con.commit()
@@ -334,6 +336,7 @@ class TestReport:
req = req + '?,' req = req + '?,'
req = req[:-1] + ')' req = req[:-1] + ')'
with self._lock:
self._con.execute(req, param) self._con.execute(req, param)
def incLevel(self): def incLevel(self):

View File

@@ -504,7 +504,7 @@ class TestSet:
item.is_folded = is_folded item.is_folded = is_folded
child = {} child = {}
# case where the test item loads itself its descendants # case where the test item loads itself its descendants
if it == cst_type.TYPE_UNITTEST_FILE: if it == cst_type.TYPE_UNITTEST:
item.setTestDir(test_dir) item.setTestDir(test_dir)
child = item.load() child = item.load()
elif issubclass(it.item_class, TestItemActions): elif issubclass(it.item_class, TestItemActions):

View File

@@ -8,7 +8,7 @@ class TestItemEnum():
self.item_class = item_class self.item_class = item_class
class TestItemType(Enum): class TestItemType(Enum):
TYPE_UNITTEST_FILE = TestItemEnum("unittest_file", "unittest file") TYPE_UNITTEST = TestItemEnum("unittest", "unittest")
TYPE_UNITTEST_STEP = TestItemEnum("unittest_step", "unittest step") TYPE_UNITTEST_STEP = TestItemEnum("unittest_step", "unittest step")
TYPE_CONSOLE = TestItemEnum("console", "Console") TYPE_CONSOLE = TestItemEnum("console", "Console")
TYPE_CONSOLE_ACTION = TestItemEnum("console_action", "Console action") TYPE_CONSOLE_ACTION = TestItemEnum("console_action", "Console action")
@@ -33,6 +33,8 @@ class TestItemType(Enum):
TYPE_RUN = TestItemEnum("run", "Run tum") TYPE_RUN = TestItemEnum("run", "Run tum")
TYPE_JSON_RPC = TestItemEnum("json_rpc", "JSON-RPC") TYPE_JSON_RPC = TestItemEnum("json_rpc", "JSON-RPC")
TYPE_JSON_RPC_ACTION = TestItemEnum("json_rpc_action", "JSON-RPC action") TYPE_JSON_RPC_ACTION = TestItemEnum("json_rpc_action", "JSON-RPC action")
TYPE_PARALLEL = TestItemEnum("parallel", "Parallel")
TYPE_PARALLEL_BRANCH = TestItemEnum("parallel_branch", "Parallel branch")
TYPE_ROOT = TestItemEnum("default", "default") TYPE_ROOT = TestItemEnum("default", "default")
@staticmethod @staticmethod

View File

@@ -189,7 +189,13 @@ class LuaProcessBase:
if tm.debug_enabled() and tm.gd("debug_rpc", False): if tm.debug_enabled() and tm.gd("debug_rpc", False):
params.append("--verbose") params.append("--verbose")
self._process = subprocess.Popen(params, env=env, cwd=func_proc_path) self._process = subprocess.Popen(
params, env=env, cwd=func_proc_path,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
restore_signals=False,
)
self._rpc = JsonRpcClient( self._rpc = JsonRpcClient(
"localhost", self._port, req_handler=self._req_handler "localhost", self._port, req_handler=self._req_handler

View File

@@ -158,7 +158,13 @@ class PyProcessBase:
if tm.debug_enabled() and tm.gd("debug_rpc", False): if tm.debug_enabled() and tm.gd("debug_rpc", False):
params.append("-v") params.append("-v")
self._process = subprocess.Popen(params, env=env, cwd=func_proc_path) self._process = subprocess.Popen(
params, env=env, cwd=func_proc_path,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
restore_signals=False,
)
self._rpc = JsonRpcClient( self._rpc = JsonRpcClient(
"localhost", self._port, req_handler=self._req_handler "localhost", self._port, req_handler=self._req_handler

View File

@@ -1,32 +1,102 @@
import colorama import os
import re import re
import sys
import colorama
from colorama import Fore, Style from colorama import Fore, Style
COLOR_DEFAULT = Fore.WHITE
COLOR_RESET = Fore.RESET + Style.RESET_ALL + COLOR_DEFAULT
def _detect_dark_background() -> bool:
"""Detect whether the terminal has a dark background.
def colored_string(string: str, inputs: list) -> None: Tries the following methods in order:
"""Function which calculate the coloring of strings with many layers. 1. ``COLORFGBG`` environment variable (Konsole, rxvt, …)
Overlap of layers and inner layers are managed. 2. OSC 11 terminal query — reads the actual background colour from the
terminal emulator (xterm, VTE, kitty, WezTerm, …)
3. ``darkdetect`` module — OS-level dark-mode preference (optional dep)
Returns ``True`` for a dark background (default assumption).
""" """
cols = [COLOR_DEFAULT for i in range(len(string))] # --- Method 1: COLORFGBG ---
for input in inputs: colorfgbg = os.environ.get("COLORFGBG", "")
for i in range(input[0][0], input[0][1]): if colorfgbg:
cols[i] = input[1] try:
bg = int(colorfgbg.split(";")[-1])
# 0-6: dark palette entries, 7-15: light palette entries
return bg < 7
except (ValueError, IndexError):
pass
# --- Method 2: OSC 11 terminal query ---
if sys.stdin.isatty() and sys.stdout.isatty():
try:
import select
import termios
import tty
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
tty.setraw(fd)
# Query background colour
sys.stdout.write("\033]11;?\007")
sys.stdout.flush()
ready, _, _ = select.select([sys.stdin], [], [], 0.2)
if ready:
response = ""
while True:
r2, _, _ = select.select([sys.stdin], [], [], 0.05)
if not r2:
break
chunk = os.read(fd, 64).decode("latin-1", errors="replace")
response += chunk
# Terminal answers with ESC]11;rgb:RR../GG../BB..<BEL|ST>
if response.endswith("\007") or response.endswith("\033\\"):
break
m = re.search(
r"rgb:([0-9a-fA-F]+)/([0-9a-fA-F]+)/([0-9a-fA-F]+)",
response,
)
if m:
# Components are 8- or 16-bit hex; normalise to 0-255
def _norm(h: str) -> float:
return int(h[:2], 16)
r_v = _norm(m.group(1))
g_v = _norm(m.group(2))
b_v = _norm(m.group(3))
luminance = 0.299 * r_v + 0.587 * g_v + 0.114 * b_v
return luminance < 128
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
except Exception:
pass
# Default: assume dark terminal
return True
def _colored_string(string: str, inputs: list, color_default: str, color_reset: str) -> str:
"""Return *string* with ANSI colour codes applied according to *inputs*.
*inputs* is a list of ``[[start, end], color_code]`` pairs.
Overlapping layers are handled: the last listed colour wins.
"""
cols = [color_default for _ in range(len(string))]
for span, color in inputs:
for i in range(span[0], span[1]):
cols[i] = color
# construction of the string
s = "" s = ""
ilast = 0 ilast = 0
last_col = COLOR_DEFAULT last_col = color_default
for i in range(len(string)): for i in range(len(string)):
if last_col != cols[i]: if last_col != cols[i]:
s = s + string[ilast:i] + COLOR_RESET + cols[i] s = s + string[ilast:i] + color_reset + cols[i]
ilast = i ilast = i
last_col = cols[i] last_col = cols[i]
return s + string[ilast:] + COLOR_RESET return s + string[ilast:] + color_reset
class TermLog: class TermLog:
@@ -37,46 +107,74 @@ class TermLog:
DEBUG = ["DEBUG"] DEBUG = ["DEBUG"]
BOOL = ["False", "True", "false", "true", "FALSE", "TRUE"] BOOL = ["False", "True", "false", "true", "FALSE", "TRUE"]
def __init__(self, out) -> None: def __init__(self, out, dark_bg: bool = None) -> None:
"""Class used to color the stdout in batch and terminal mode.""" """Class used to colour the stdout in batch and terminal mode.
:param out: Underlying output stream.
:param dark_bg: ``True`` for dark background, ``False`` for light.
``None`` (default) triggers auto-detection.
"""
colorama.init() colorama.init()
self.out = out self.out = out
self.pats = [] self.residue = ""
self.pats = self.pats + [
[re.compile('(\\"[^\\"]+\\")'), Fore.LIGHTBLUE_EX + Style.BRIGHT], if dark_bg is None:
[re.compile("(\\'[^\\']+\\')"), Fore.LIGHTBLUE_EX + Style.BRIGHT], dark_bg = _detect_dark_background()
[re.compile("(<-----|----->) step"), Fore.BLUE],
[ if dark_bg:
re.compile( color_default = Fore.WHITE
r"([\d\.]+)", color_string = Fore.LIGHTBLUE_EX + Style.BRIGHT
), color_number = Fore.MAGENTA
Fore.MAGENTA, color_bool = Fore.MAGENTA
], color_step = Fore.BLUE
[re.compile(r"(@@\d+@@)"), Fore.BLACK], color_marker = Fore.BLACK
color_warn = Fore.YELLOW
color_info = Style.BRIGHT
color_debug = Fore.BLUE + Style.BRIGHT
color_pass = Fore.GREEN + Style.BRIGHT
color_fail = Fore.RED + Style.BRIGHT
else:
color_default = Fore.RESET
color_string = Fore.BLUE
color_number = Fore.MAGENTA
color_bool = Fore.MAGENTA
color_step = Fore.BLUE
color_marker = Fore.RESET
color_warn = Fore.YELLOW + Style.BRIGHT
color_info = Fore.CYAN
color_debug = Fore.BLUE
color_pass = Fore.GREEN
color_fail = Fore.RED + Style.BRIGHT
self._color_default = color_default
self._color_reset = Fore.RESET + Style.RESET_ALL + color_default
self.pats = [
[re.compile(r'("(?:[^"]+)")'), color_string],
[re.compile(r"('(?:[^']+)')"), color_string],
[re.compile(r"(<-----|----->) step"), color_step],
[re.compile(r"([\d\.]+)"), color_number],
[re.compile(r"(@@\d+@@)"), color_marker],
] ]
for word in self.BOOL: for word in self.BOOL:
self.pats.append([re.compile("({})".format(word)), Fore.MAGENTA]) self.pats.append([re.compile(r"({})".format(word)), color_bool])
for word in self.WARN: for word in self.WARN:
self.pats.append([re.compile("({})".format(word)), Fore.YELLOW]) self.pats.append([re.compile(r"({})".format(word)), color_warn])
for word in self.INFO: for word in self.INFO:
self.pats.append([re.compile("({})".format(word)), Style.BRIGHT]) self.pats.append([re.compile(r"({})".format(word)), color_info])
for word in self.DEBUG: for word in self.DEBUG:
self.pats.append([re.compile("({})".format(word)), Fore.BLUE + Style.BRIGHT]) self.pats.append([re.compile(r"({})".format(word)), color_debug])
for word in self.PASS: for word in self.PASS:
self.pats.append( self.pats.append([re.compile(r"({})".format(word)), color_pass])
[re.compile("({})".format(word)), Fore.GREEN + Style.BRIGHT]
)
for word in self.FAIL: for word in self.FAIL:
self.pats.append([re.compile("({})".format(word)), Fore.RED + Style.BRIGHT]) self.pats.append([re.compile(r"({})".format(word)), color_fail])
self.residue = ""
def find_pats(self, line): def find_pats(self, line):
spans = [] spans = []
for p in self.pats: for p, color in self.pats:
it = p[0].finditer(line) for m in p.finditer(line):
for m in it:
if m: if m:
spans.append([m.span(), p[1]]) spans.append([m.span(), color])
return spans return spans
def write(self, s: str) -> None: def write(self, s: str) -> None:
@@ -87,15 +185,19 @@ class TermLog:
if s[-1:] != "\n": if s[-1:] != "\n":
pos = s.rfind("\n") pos = s.rfind("\n")
if pos >= 0: if pos >= 0:
self.residue = s[pos:] self.residue = s[pos + 1:]
s = s[:pos] s = s[:pos + 1]
else: else:
# only one line # single incomplete line — output immediately
self.out.write(colored_string(s, self.find_pats(s))) self.out.write(_colored_string(s, self.find_pats(s),
self._color_default, self._color_reset))
return return
# multiline case # one or more complete lines
for l in s.splitlines(): for line in s.splitlines():
self.out.write(colored_string(l, self.find_pats(l)) + "\n") self.out.write(
_colored_string(line, self.find_pats(line),
self._color_default, self._color_reset) + "\n"
)
def flush(self): def flush(self):
if self.residue != "": if self.residue != "":

View File

@@ -44,6 +44,7 @@ from interpreter.test_items.test_item_choices_dialog import TestItemChoicesDialo
from interpreter.test_items.test_item_console import TestItemConsole from interpreter.test_items.test_item_console import TestItemConsole
from interpreter.test_items.test_item_run import TestItemRun from interpreter.test_items.test_item_run import TestItemRun
from interpreter.test_items.test_item_report import TestItemReport from interpreter.test_items.test_item_report import TestItemReport
from interpreter.test_items.test_item_parallel import TestItemParallel, TestItemParallelBranch
def _constants_init(): def _constants_init():
@@ -67,8 +68,10 @@ def _constants_init():
cst.TYPE_ROOT.item_class = TestItem cst.TYPE_ROOT.item_class = TestItem
cst.TYPE_RUN.item_class = TestItemRun cst.TYPE_RUN.item_class = TestItemRun
cst.TYPE_SLEEP.item_class = TestItemSleep cst.TYPE_SLEEP.item_class = TestItemSleep
cst.TYPE_UNITTEST_FILE.item_class = TestItemUnittestFile cst.TYPE_UNITTEST.item_class = TestItemUnittestFile
cst.TYPE_VALUE_DLG.item_class = TestItemValueDialog cst.TYPE_VALUE_DLG.item_class = TestItemValueDialog
cst.TYPE_PARALLEL.item_class = TestItemParallel
cst.TYPE_PARALLEL_BRANCH.item_class = TestItemParallelBranch
def locate_report_file(rep_file): def locate_report_file(rep_file):

View File

@@ -209,6 +209,15 @@ def OS():
return platform.system() return platform.system()
def text_mode():
"""Whether testium is running in text mode (batch ``-b`` or terminal ``-m``).
:return: ``True`` if running in text mode, ``False`` otherwise.
:rtype: bool
"""
return bool(globdict.gd("_text_mode", False))
def sys_encoding(): def sys_encoding():
if OS() == "Windows": if OS() == "Windows":
enc = "oem" enc = "oem"

View File

@@ -3,7 +3,7 @@
################################################################################ ################################################################################
## Form generated from reading UI file 'about_win.ui' ## Form generated from reading UI file 'about_win.ui'
## ##
## Created by: Qt User Interface Compiler version 6.10.1 ## Created by: Qt User Interface Compiler version 6.11.0
## ##
## WARNING! All changes made in this file will be lost when recompiling UI file! ## WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################ ################################################################################

View File

@@ -3,7 +3,7 @@
################################################################################ ################################################################################
## Form generated from reading UI file 'preference_core_win.ui' ## Form generated from reading UI file 'preference_core_win.ui'
## ##
## Created by: Qt User Interface Compiler version 6.10.1 ## Created by: Qt User Interface Compiler version 6.11.0
## ##
## WARNING! All changes made in this file will be lost when recompiling UI file! ## WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################ ################################################################################

View File

@@ -1,6 +1,6 @@
# Resource object code (Python 3) # Resource object code (Python 3)
# Created by: object code # Created by: object code
# Created by: The Resource Compiler for Qt version 6.10.1 # Created by: The Resource Compiler for Qt version 6.11.0
# WARNING! All changes made in this file will be lost! # WARNING! All changes made in this file will be lost!
from PySide6 import QtCore from PySide6 import QtCore
@@ -1826,7 +1826,7 @@ qt_resource_struct = b"\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\ \x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\
\x00\x00\x00\x00\x00\x00\x00\x00\ \x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ \x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\
\x00\x00\x01\x9bi\x96\x0e\x1c\ \x00\x00\x01\x9b\x8f'M\xbb\
" "
def qInitResources(): def qInitResources():

Binary file not shown.

After

Width:  |  Height:  |  Size: 654 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@@ -1,6 +1,6 @@
# Resource object code (Python 3) # Resource object code (Python 3)
# Created by: object code # Created by: object code
# Created by: The Resource Compiler for Qt version 6.10.1 # Created by: The Resource Compiler for Qt version 6.11.0
# WARNING! All changes made in this file will be lost! # WARNING! All changes made in this file will be lost!
from PySide6 import QtCore from PySide6 import QtCore
@@ -1832,7 +1832,7 @@ qt_resource_struct = b"\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\ \x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\
\x00\x00\x00\x00\x00\x00\x00\x00\ \x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00\x10\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ \x00\x00\x00\x10\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\
\x00\x00\x01\x9bi\x96\x0e\x1c\ \x00\x00\x01\x9b\x8f'M\xbb\
" "
def qInitResources(): def qInitResources():

View File

@@ -1,6 +1,7 @@
<RCC> <RCC>
<qresource prefix="/black"> <qresource prefix="/black">
<file alias="testium_logo.png">testium_logo.png</file> <file alias="testium_logo.png">testium_logo.png</file>
<file alias="run.png">black/run.png</file>
<file alias="red.png">red.png</file> <file alias="red.png">red.png</file>
<file alias="gray.png">gray.png</file> <file alias="gray.png">gray.png</file>
<file alias="green.png">green.png</file> <file alias="green.png">green.png</file>
@@ -44,9 +45,12 @@
<file alias="lua.png">black/lua.png</file> <file alias="lua.png">black/lua.png</file>
<file alias="verif.png">black/verif.png</file> <file alias="verif.png">black/verif.png</file>
<file alias="view-refresh.png">black/view-refresh.png</file> <file alias="view-refresh.png">black/view-refresh.png</file>
<file alias="parallel.png">black/parallel.png</file>
<file alias="parallel_branch.png">black/parallel_branch.png</file>
</qresource> </qresource>
<qresource prefix="/white"> <qresource prefix="/white">
<file alias="testium_logo.png">testium_logo.png</file> <file alias="testium_logo.png">testium_logo.png</file>
<file alias="run.png">white/run.png</file>
<file alias="red.png">red.png</file> <file alias="red.png">red.png</file>
<file alias="gray.png">gray.png</file> <file alias="gray.png">gray.png</file>
<file alias="green.png">green.png</file> <file alias="green.png">green.png</file>
@@ -90,9 +94,12 @@
<file alias="lua.png">white/lua.png</file> <file alias="lua.png">white/lua.png</file>
<file alias="verif.png">white/verif.png</file> <file alias="verif.png">white/verif.png</file>
<file alias="view-refresh.png">white/view-refresh.png</file> <file alias="view-refresh.png">white/view-refresh.png</file>
<file alias="parallel.png">white/parallel.png</file>
<file alias="parallel_branch.png">white/parallel_branch.png</file>
</qresource> </qresource>
<qresource prefix="/color"> <qresource prefix="/color">
<file alias="testium_logo.png">testium_logo.png</file> <file alias="testium_logo.png">testium_logo.png</file>
<file alias="run.png">color/run.png</file>
<file alias="red.png">red.png</file> <file alias="red.png">red.png</file>
<file alias="gray.png">gray.png</file> <file alias="gray.png">gray.png</file>
<file alias="green.png">green.png</file> <file alias="green.png">green.png</file>
@@ -136,5 +143,7 @@
<file alias="lua.png">color/lua.png</file> <file alias="lua.png">color/lua.png</file>
<file alias="verif.png">color/verif.png</file> <file alias="verif.png">color/verif.png</file>
<file alias="view-refresh.png">color/view-refresh.png</file> <file alias="view-refresh.png">color/view-refresh.png</file>
<file alias="parallel.png">color/parallel.png</file>
<file alias="parallel_branch.png">color/parallel_branch.png</file>
</qresource> </qresource>
</RCC> </RCC>

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -48,8 +48,16 @@ class TestFileManager:
def reload(self, file_name: str): def reload(self, file_name: str):
w = self._win w = self._win
w.disconnect_signals() w.disconnect_signals()
# Snapshot user-selected checkboxes and fold state so they survive a
# reload of the same file (same logic as session-restore through prefs).
previous_check_list = w.treeTests.getCheckList()
previous_fold_list = w.treeTests.getFoldList()
previous_count = w.treeTests.getItemCount()
self.clear_process() self.clear_process()
self.load(file_name) if self.load(file_name) and w.test_service is not None:
if w.treeTests.getItemCount() == previous_count:
w.treeTests.restoreCheckList(previous_check_list, w.test_service)
w.treeTests.restoreFoldList(previous_fold_list)
w.reconnect_signals() w.reconnect_signals()
def _make_progress(self, w): def _make_progress(self, w):

View File

@@ -28,7 +28,7 @@ class ThreadTestStatus(QThread):
self.gdUpdated.emit(m["key"], m["value"]) self.gdUpdated.emit(m["key"], m["value"])
elif msg_type == "gd_delete": elif msg_type == "gd_delete":
self.gdDeleted.emit(m["key"]) self.gdDeleted.emit(m["key"])
elif m.get("id", None) is None: elif "id" in m and m["id"] is None:
self.testSetIsFinished.emit() self.testSetIsFinished.emit()
else: else:
self.statusToBeUpdated.emit(m) self.statusToBeUpdated.emit(m)

View File

@@ -118,6 +118,7 @@ class TestRunner:
self.logFileHandler = None self.logFileHandler = None
w.textLog.appendPlainText("Test is finished") w.textLog.appendPlainText("Test is finished")
w.run_exit_code = 0 if w.treeTests.getGlobalSuccess() else 1
if w.runandclose: if w.runandclose:
w.on_actionExit_triggered() w.on_actionExit_triggered()

View File

@@ -10,7 +10,7 @@ from libs.testium import print_warn
# Maps item_name (from TestItemType.item_name) to visual config. # Maps item_name (from TestItemType.item_name) to visual config.
# Keys: icon (required), icon_on (optional 2nd state), expanded, unfoldable, no_breakpoint # Keys: icon (required), icon_on (optional 2nd state), expanded, unfoldable, no_breakpoint
_ITEM_CONFIG = { _ITEM_CONFIG = {
"unittest file": {"icon": "folder.png", "icon_on": "folder-open.png", "expanded": True, "no_breakpoint": True}, "unittest": {"icon": "folder.png", "icon_on": "folder-open.png", "expanded": True, "no_breakpoint": True},
"unittest step": {"icon": "document.png", "no_breakpoint": True}, "unittest step": {"icon": "document.png", "no_breakpoint": True},
"Console": {"icon": "terminal.png", "unfoldable": False}, "Console": {"icon": "terminal.png", "unfoldable": False},
"Console action": {"icon": "terminal.png"}, "Console action": {"icon": "terminal.png"},
@@ -32,9 +32,11 @@ _ITEM_CONFIG = {
"References Dialog": {"icon": "label.png"}, "References Dialog": {"icon": "label.png"},
"Value Dialog": {"icon": "question.png"}, "Value Dialog": {"icon": "question.png"},
"Choices Dialog": {"icon": "label.png"}, "Choices Dialog": {"icon": "label.png"},
"Run tum": {"icon": "testium_logo.svg"}, "Run tum": {"icon": "run.png"},
"JSON-RPC": {"icon": "json.png", "unfoldable": False}, "JSON-RPC": {"icon": "json.png", "unfoldable": False},
"JSON-RPC action": {"icon": "json.png"}, "JSON-RPC action": {"icon": "json.png"},
"Parallel": {"icon": "parallel.png", "expanded": True},
"Parallel branch": {"icon": "parallel_branch.png", "expanded": True},
} }

View File

@@ -3,7 +3,7 @@
################################################################################ ################################################################################
## Form generated from reading UI file 'testium_core_win.ui' ## Form generated from reading UI file 'testium_core_win.ui'
## ##
## Created by: Qt User Interface Compiler version 6.10.1 ## Created by: Qt User Interface Compiler version 6.11.0
## ##
## WARNING! All changes made in this file will be lost when recompiling UI file! ## WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################ ################################################################################

View File

@@ -1,18 +1,14 @@
import sys import sys
import os import os
import subprocess
import traceback
import webbrowser import webbrowser
from time import sleep
from multiprocessing import Queue from multiprocessing import Queue
from queue import Empty
from threading import Thread from threading import Thread
import shutil import shutil
# Qt # Qt
from PySide6 import QtGui, QtWidgets from PySide6 import QtGui
from PySide6.QtGui import QAction, QShortcut, QIcon, QPixmap, QTextCursor, QDesktopServices, QTextCursor from PySide6.QtGui import QAction, QShortcut, QIcon, QPixmap, QTextCursor, QDesktopServices, QTextCursor
from PySide6.QtCore import Slot, QUrl, Qt, QTimer, QDateTime from PySide6.QtCore import Slot, QUrl, Qt, QTimer
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QApplication,
@@ -92,6 +88,7 @@ class MainWindow(QMainWindow, Ui_MainWindow):
self.test_service = None self.test_service = None
self.threadTestStatus = None self.threadTestStatus = None
self._signals_connected = False self._signals_connected = False
self.run_exit_code = -1 # -1 = test not yet completed
self.timer = QTimer() self.timer = QTimer()
self.timer.setSingleShot(False) self.timer.setSingleShot(False)
@@ -359,10 +356,16 @@ class MainWindow(QMainWindow, Ui_MainWindow):
self.treeTests.saveSizes() self.treeTests.saveSizes()
prefs.settings.sync() prefs.settings.sync()
def closeEvent(self, event):
self.on_exiting()
event.accept()
def on_exiting(self): def on_exiting(self):
try:
if self.runner.state == TestState.IDLE: if self.runner.state == TestState.IDLE:
self.save_settings() self.save_settings()
self.file_manager.clear_process() self.file_manager.clear_process()
finally:
self.threadTestStatus.stop() self.threadTestStatus.stop()
self.threadOutput.stop() self.threadOutput.stop()
self.threadOutput.wait() self.threadOutput.wait()
@@ -685,5 +688,17 @@ def MainWin(
debug, debug,
) )
import signal
import os as _os
def _sigabrt_handler(signum, frame):
# Qt crash: exit with the test result if known, -1 if test never completed
_os._exit(ui.run_exit_code)
signal.signal(signal.SIGABRT, _sigabrt_handler)
ui.show() ui.show()
sys.exit(app.exec_()) app.exec_()
exit_code = ui.run_exit_code if ui.run_exit_code >= 0 else 0
del ui
sys.exit(exit_code)

View File

@@ -0,0 +1,29 @@
import libs.testium as libtm
def check_os(expected_os):
result = libtm.OS()
assert result == expected_os, f"Expected {expected_os!r}, got {result!r}"
return 0
def check_get_main_dir():
d = libtm.get_main_dir()
assert isinstance(d, str) and len(d) > 0
return 0
def check_timestamp_as_sec_conversion():
assert libtm.timestamp_as_sec(0) == 0.0
assert libtm.timestamp_as_sec(10000) == 1.0
assert libtm.timestamp_as_sec(5000) == 0.5
return 0
def check_timestamp():
libtm.init_timestamp()
t = libtm.timestamp()
assert isinstance(t, int) and t >= 0
ts = libtm.timestamp_as_sec()
assert isinstance(ts, float) and ts >= 0.0
return 0

View File

@@ -10,3 +10,28 @@
name : Various syntax robustness name : Various syntax robustness
steps: steps:
- !include syntax_robustness/test.tum - !include syntax_robustness/test.tum
- group:
name: Helper lib functions
steps:
- py_func:
name: OS
key: $(test)_PASS
file: $(test_path)$(psep)helper_lib.py
func_name: check_os
param:
- $(os)
- py_func:
name: get_main_dir
key: $(test)_PASS
file: $(test_path)$(psep)helper_lib.py
func_name: check_get_main_dir
- py_func:
name: timestamp_as_sec conversion
key: $(test)_PASS
file: $(test_path)$(psep)helper_lib.py
func_name: check_timestamp_as_sec_conversion
- py_func:
name: timestamp and timestamp_as_sec
key: $(test)_PASS
file: $(test_path)$(psep)helper_lib.py
func_name: check_timestamp

View File

@@ -2,6 +2,7 @@
name: dialog image PASS name: dialog image PASS
condition: $(validation_dialogs) condition: $(validation_dialogs)
question: click ok if you see the image question: click ok if you see the image
auto_result: "ok"
key: $(test)_PASS key: $(test)_PASS
filename: $(test_path)$(psep)IMG_20140213_171455.jpg filename: $(test_path)$(psep)IMG_20140213_171455.jpg
@@ -9,6 +10,7 @@
name: dialog image FAIL name: dialog image FAIL
condition: $(validation_dialogs) condition: $(validation_dialogs)
question: click cancel question: click cancel
auto_result: "cancel"
key: $(test)_FAIL key: $(test)_FAIL
filename: $(test_path)$(psep)IMG_20140213_171455.jpg filename: $(test_path)$(psep)IMG_20140213_171455.jpg
@@ -17,45 +19,54 @@
condition: $(validation_dialogs) condition: $(validation_dialogs)
key: $(test)_PASS key: $(test)_PASS
question: click ok question: click ok
auto_result: "ok"
- dialog_references: - dialog_references:
name: dialog_reference FAIL name: dialog_reference FAIL
condition: $(validation_dialogs) condition: $(validation_dialogs)
key: $(test)_FAIL key: $(test)_FAIL
question: click cancel question: click cancel
auto_result: "cancel"
- dialog_value: - dialog_value:
name: dialog_value PASS name: dialog_value PASS
condition: $(validation_dialogs) condition: $(validation_dialogs)
key: $(test)_PASS key: $(test)_PASS
question: enter 123 and click ok question: enter 123 and click ok
auto_result: "ok"
auto_value: "123"
- dialog_value: - dialog_value:
name: dialog_value empty FAIL name: dialog_value empty FAIL
condition: $(validation_dialogs) condition: $(validation_dialogs)
key: $(test)_FAIL key: $(test)_FAIL
question: enter nothing and click ok question: enter nothing and click ok
auto_result: "ok"
- dialog_value: - dialog_value:
name: dialog_value canceled FAIL name: dialog_value canceled FAIL
condition: $(validation_dialogs) condition: $(validation_dialogs)
key: $(test)_FAIL key: $(test)_FAIL
question: enter nothing and click cancel question: enter nothing and click cancel
auto_result: "cancel"
- dialog_message: - dialog_message:
name: dialog_message PASS name: dialog_message PASS
condition: $(validation_dialogs) condition: $(validation_dialogs)
key: $(test)_PASS key: $(test)_PASS
question: click ok question: click ok
auto_result: "ok"
- dialog_question: - dialog_question:
name: dialog_question PASS name: dialog_question PASS
condition: $(validation_dialogs) condition: $(validation_dialogs)
key: $(test)_PASS key: $(test)_PASS
question: click yes question: click yes
auto_result: "yes"
- dialog_question: - dialog_question:
name: dialog_question FAIL name: dialog_question FAIL
condition: $(validation_dialogs) condition: $(validation_dialogs)
key: $(test)_FAIL key: $(test)_FAIL
question: click no question: click no
auto_result: "no"

View File

@@ -41,4 +41,12 @@ function module.get_context_value()
return tm.gd("_lua_ctx_test_value") return tm.gd("_lua_ctx_test_value")
end end
function module.test_delgd()
tm.setgd("_lua_delgd_test", 42)
assert(tm.gd("_lua_delgd_test") == 42)
tm.delgd("_lua_delgd_test")
assert(tm.gd("_lua_delgd_test", "__deleted__") == "__deleted__")
return 0
end
return module return module

View File

@@ -180,6 +180,12 @@
func_name: tuple_return func_name: tuple_return
param: [ 0, "OK" ] param: [ 0, "OK" ]
- lua_func:
name: delgd test
key: $(test)_PASS
file: $(test_path)$(psep)lua_func.lua
func_name: test_delgd
- group: - group:
name: context_id tests name: context_id tests
steps: steps:

View File

@@ -0,0 +1,16 @@
import time
import libs.testium as tm
def sleep_func(duration):
time.sleep(float(duration))
return 0
def check_duration(item_name, max_duration):
t0 = tm.gd(f"ts_start_{item_name}")
t1 = tm.gd(f"ts_end_{item_name}")
duration = tm.timestamp_as_sec(t1 - t0)
if duration < float(max_duration):
return 0
return 1

View File

@@ -0,0 +1 @@
no_param: Null

View File

@@ -0,0 +1,343 @@
# --- Test 1: both branches succeed, sync:all ---
- parallel:
name: Both branches pass
key: $(test)_PASS
sync: all
branches:
- name: Branch A
steps:
- let:
name: Set A done
values:
- branch_a_done: true
- name: Branch B
steps:
- let:
name: Set B done
values:
- branch_b_done: true
- check:
name: Both branches ran
key: $(test)_PASS
values:
- <| $(branch_a_done) == True |>
- <| $(branch_b_done) == True |>
# --- Test 2: one branch fails, sync:all + no_fail → parallel forced to PASS ---
- parallel:
name: One branch fails
key: $(test)_PASS
sync: all
no_fail: true
branches:
- name: Pass branch
steps:
- let:
name: Set pass flag
values:
- pass_branch_ran: true
- name: Fail branch
steps:
- py_func:
name: Raise exception
file: $(test_path)$(psep)parallel.py
func_name: sleep_func
param: [0]
expected_result: fail
- check:
name: Pass branch still ran
key: $(test)_PASS
values:
- <| $(pass_branch_ran) == True |>
# --- Test 3: sync:any — first branch done stops the rest ---
- let:
name: Reset slow flag
values:
- slow_done: false
- parallel:
name: sync any - first wins
key: $(test)_PASS
sync: any
branches:
- name: Fast branch
steps:
- let:
name: Fast done
values:
- fast_done: true
- name: Slow branch
steps:
- py_func:
name: Sleep 2s
file: $(test_path)$(psep)parallel.py
func_name: sleep_func
param: [2]
- let:
name: Slow done
values:
- slow_done: true
- check:
name: Fast branch ran, slow branch was stopped
key: $(test)_PASS
values:
- <| $(fast_done) == True |>
- <| $(slow_done) == False |>
# --- Test 4: wait_for — branch B waits for A to set a flag ---
- let:
name: Reset sync flag
values:
- sync_flag: ""
- waiter_ran: false
- parallel:
name: wait_for synchronization
key: $(test)_PASS
sync: all
branches:
- name: Setter branch
steps:
- py_func:
name: Sleep 0.3s then set flag
file: $(test_path)$(psep)parallel.py
func_name: sleep_func
param: [0.3]
- let:
name: Set sync flag
values:
- sync_flag: ready
- name: Waiter branch
wait_for:
condition: <| "$(sync_flag)" == "ready" |>
timeout: 10
steps:
- let:
name: Got flag
values:
- waiter_ran: true
- check:
name: Waiter branch ran after flag was set
key: $(test)_PASS
values:
- <| $(waiter_ran) == True |>
# --- Test 5: parallel is faster than sequential (timing) ---
# Two 1s sleeps in parallel → ~1s total, not ~2s sequential
- parallel:
name: Timing test
key: $(test)_PASS
sync: all
branches:
- name: Sleep A
steps:
- sleep:
name: Sleep 1s A
timeout: 1
- name: Sleep B
steps:
- sleep:
name: Sleep 1s B
timeout: 1
- let:
name: Capture parallel duration
values:
- parallel_duration: $(ts_duration_Timing test)
- check:
name: Duration < 1.8s (would be 2s if sequential)
key: $(test)_PASS
values:
- <| float("$(parallel_duration)") < 1.8 |>
# --- Test 6: more than two branches ---
- let:
name: Reset N flags
values:
- n_a: false
- n_b: false
- n_c: false
- n_d: false
- parallel:
name: Four branches
key: $(test)_PASS
sync: all
branches:
- name: NA
steps:
- let: {name: set n_a, values: [{n_a: true}]}
- name: NB
steps:
- let: {name: set n_b, values: [{n_b: true}]}
- name: NC
steps:
- let: {name: set n_c, values: [{n_c: true}]}
- name: ND
steps:
- let: {name: set n_d, values: [{n_d: true}]}
- check:
name: Four branches all set their flag
key: $(test)_PASS
values:
- <| $(n_a) == True |>
- <| $(n_b) == True |>
- <| $(n_c) == True |>
- <| $(n_d) == True |>
# --- Test 7: nested parallel ---
- let:
name: Reset nested flags
values:
- outer_x: false
- inner_x_1: false
- inner_x_2: false
- parallel:
name: Outer parallel
key: $(test)_PASS
sync: all
branches:
- name: Outer X
steps:
- let: {name: set outer_x, values: [{outer_x: true}]}
- parallel:
name: Inner parallel
sync: all
branches:
- name: Inner X1
steps:
- let: {name: set inner_x_1, values: [{inner_x_1: true}]}
- name: Inner X2
steps:
- let: {name: set inner_x_2, values: [{inner_x_2: true}]}
- name: Outer Y
steps:
- sleep:
name: brief sleep
timeout: 0
- check:
name: Nested parallel set all flags
key: $(test)_PASS
values:
- <| $(outer_x) == True |>
- <| $(inner_x_1) == True |>
- <| $(inner_x_2) == True |>
# --- Test 9: wait_for timeout ---
- let:
name: Reset waiter timeout flag
values:
- waiter_timeout_ran: false
- parallel:
name: wait_for timeout
key: $(test)_PASS
sync: all
no_fail: true
branches:
- name: Quick branch
steps:
- sleep:
name: brief sleep
timeout: 0
- name: Doomed waiter
wait_for:
condition: <| "never" == "ready" |>
timeout: 1
steps:
- let: {name: should not run, values: [{waiter_timeout_ran: true}]}
- check:
name: Doomed waiter never ran its steps
key: $(test)_PASS
values:
- <| $(waiter_timeout_ran) == False |>
# --- Test 10: sync:all with a real branch failure (parallel must FAIL) ---
- parallel:
name: One branch really fails
key: $(test)_FAIL
sync: all
branches:
- name: ok branch
steps:
- let: {name: noop, values: [{noop_var: 1}]}
- name: broken branch
steps:
- py_func:
name: Forced fail
file: $(test_path)$(psep)parallel.py
func_name: sleep_func
param: [0]
expected_result: fail
# --- Test 11: branch with unmet condition is skipped, not failing the parallel ---
- let:
name: Reset branch condition flag
values:
- cond_branch_ran: false
- other_branch_ran: false
- parallel:
name: Condition-skipped branch
key: $(test)_PASS
sync: all
branches:
- name: Skipped branch
condition: <| "always" == "false" |>
steps:
- let: {name: should not run, values: [{cond_branch_ran: true}]}
- name: Other branch
steps:
- let: {name: ran, values: [{other_branch_ran: true}]}
- check:
name: Skipped condition branch did not run
key: $(test)_PASS
values:
- <| $(cond_branch_ran) == False |>
- <| $(other_branch_ran) == True |>
# --- Test 8: parallel inside loop (re-execution) ---
- let:
name: Reset loop counters
values:
- loop_count_a: 0
- loop_count_b: 0
- loop:
name: Loop wrapping parallel
iterator: 3
steps:
- parallel:
name: Per-iteration parallel
sync: all
branches:
- name: LA
steps:
- let:
name: bump A
values:
- loop_count_a: <| int("$(loop_count_a)") + 1 |>
- name: LB
steps:
- let:
name: bump B
values:
- loop_count_b: <| int("$(loop_count_b)") + 1 |>
- check:
name: Both branches ran 3 times
key: $(test)_PASS
values:
- <| int("$(loop_count_a)") == 3 |>
- <| int("$(loop_count_b)") == 3 |>

View File

@@ -1,15 +1,18 @@
- plot: - group:
name: Plot test
condition: <| $(validation_dialogs) and not tm.text_mode() |>
steps:
- plot:
name: Open the plot name: Open the plot
condition: $(validation_dialogs)
key: $(test)_PASS key: $(test)_PASS
plot_name: Mon Plot plot_name: Mon Plot
steps: steps:
- open: - open:
log_path: $(validation_report_path) log_path: $(validation_report_path)
- plot: - plot:
name: Add periodic to the plot name: Add periodic to the plot
condition: $(validation_dialogs)
key: $(test)_PASS key: $(test)_PASS
plot_name: Mon Plot plot_name: Mon Plot
steps: steps:
@@ -19,15 +22,13 @@
func_name: random_value func_name: random_value
eval: '{"periodic": $(result)}' eval: '{"periodic": $(result)}'
- sleep: - sleep:
name: sleep name: sleep
condition: $(validation_dialogs)
dialog: true dialog: true
timeout: 3 timeout: 3
- loop: - loop:
name: Add of other data in the plot name: Add of other data in the plot
condition: $(validation_dialogs)
iterator: 10 iterator: 10
steps: steps:
@@ -52,23 +53,21 @@
param: param:
- Mon Plot - Mon Plot
- plot: - plot:
name: Export name: Export
execute_on_stop: True execute_on_stop: True
condition: $(validation_dialogs)
key: $(test)_PASS key: $(test)_PASS
plot_name: Mon Plot plot_name: Mon Plot
steps: steps:
- export: $(validation_report_path)/plot_export.pdf - export: $(validation_report_path)/plot_export.pdf
- export: $(validation_report_path)/plot_export.csv - export: $(validation_report_path)/plot_export.csv
- plot: - plot:
name: Close the plot name: Close the plot
execute_on_stop: True execute_on_stop: True
condition: $(validation_dialogs)
key: $(test)_PASS key: $(test)_PASS
plot_name: Mon Plot plot_name: Mon Plot
steps: steps:
- close: - close:
wait_dialog_exit: True wait_dialog_exit: True
timeout: 60 timeout: 2

View File

@@ -47,3 +47,10 @@ def set_ns_value(val):
def get_ns_value(): def get_ns_value():
obj = tm.gd("_py_ctx_ns_value", None) obj = tm.gd("_py_ctx_ns_value", None)
return obj.val if obj is not None else None return obj.val if obj is not None else None
def test_delgd():
tm.setgd("_py_delgd_test", 42)
assert tm.gd("_py_delgd_test") == 42
tm.delgd("_py_delgd_test")
assert tm.gd("_py_delgd_test", None) is None
return 0

View File

@@ -190,6 +190,12 @@
param: [ 0, "OK" ] param: [ 0, "OK" ]
expected_result: [0, "OK"] expected_result: [0, "OK"]
- py_func:
name: delgd test
key: $(test)_PASS
file: $(test_path)$(psep)py_func.py
func_name: test_delgd
- group: - group:
name: context_id tests name: context_id tests
steps: steps:

View File

@@ -0,0 +1 @@
no_param: Null

View File

@@ -0,0 +1,7 @@
main:
name: run sub-test (always fail)
steps:
- check:
name: fail
values:
- false

View File

@@ -0,0 +1,7 @@
main:
name: run sub-test (always pass)
steps:
- check:
name: pass
values:
- true

View File

@@ -0,0 +1,25 @@
# run item: launches a .tum file in a new testium instance.
# In batch mode the sub-instance runs with -b; in GUI mode with -r.
# The run item result is SUCCESS if the sub-instance launched successfully,
# regardless of its own test result.
- run:
name: run PASS (valid file, passing sub-test)
key: $(test)_PASS
tum: $(test_path)$(psep)sub_pass.tum
- run:
name: run PASS (valid file, failing sub-test)
key: $(test)_PASS
tum: $(test_path)$(psep)sub_fail.tum
- run:
name: run FAIL (file not found)
key: $(test)_FAIL
tum: $(test_path)$(psep)non_existent.tum
- run:
name: run FAIL (wait_for_exec without time window)
key: $(test)_FAIL
tum: $(test_path)$(psep)sub_pass.tum
wait_for_exec: true

Some files were not shown because too many files have changed in this diff Show More