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>
This commit is contained in:
2026-04-30 23:23:31 +02:00
parent be540cd304
commit 1b2d427ced
22 changed files with 1258 additions and 558 deletions

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