From a3b8f671e8814774abea4b062f0cb2862e2db801 Mon Sep 17 00:00:00 2001 From: Joscha Schmiedt Date: Tue, 18 Aug 2026 13:11:46 +0200 Subject: [PATCH] test(e2e): a swept stimulus has no fixed position to compare against MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two flakes left in the real-renderer suite after #130, both found by running it on repeat. **The demo comparison compared a moving number.** `_canonical` already drops the runtime state a tutorial cannot be held responsible for — `enabled` for a stimulus an animation owns, `phase_cycles` for a drifting grating — but not the position of a stimulus a sweep is moving. `moving_target` arms a MoveAlongSegments2D that runs forever, so by the time the test retrieves the config the target has travelled some way along its path, and how far depends on how many frames passed. It drops `pos_px` for any stimulus driven by an animation that writes position every frame. **The teardown could still fail the last test.** #130 escalated a slow exit to SIGKILL; on a busy machine even the post-kill reap outran its five seconds, and the error landed on whichever test happened to run last. SIGKILL is not negotiable, so a process still there is stuck in the kernel handing the display back: the teardown now says so with a warning instead of turning cleanup time into a test result. Real renderer: 141 passed, 6 xfailed, twice in a row, and the demo group 3/3 on repeat. Null unchanged. Co-Authored-By: Claude Opus 5 --- .../tests/e2e/cases/test_demo_examples.py | 26 +++++++++++++++++++ client/python/tests/e2e/test_e2e.py | 13 +++++++--- .../python/tests/e2e/test_psychopy_visual.py | 13 +++++++--- 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/client/python/tests/e2e/cases/test_demo_examples.py b/client/python/tests/e2e/cases/test_demo_examples.py index 0cc20af4..2ebb4ea9 100644 --- a/client/python/tests/e2e/cases/test_demo_examples.py +++ b/client/python/tests/e2e/cases/test_demo_examples.py @@ -65,6 +65,21 @@ def _driven_handles(anim: dict) -> list[int]: return target.get("handles", []) if target.get("kind") == "Stimuli" else [] +# Animations that move their target every frame, so its position is a runtime +# value rather than something the tutorial script sets. +_MOVING_ANIMATIONS = frozenset( + {"MoveAlongPath2D", "MoveAlongSegments2D", "ExternalPosition2D"} +) + + +def _moves_its_target(anim: dict) -> bool: + """True when the animation writes its target's position on every frame.""" + body = anim.get("animation") + if isinstance(body, dict): + return bool(_MOVING_ANIMATIONS & body.keys()) + return body in _MOVING_ANIMATIONS + + def _canonical(cfg: dict) -> dict: """Reduce a config to the parts a tutorial is responsible for reproducing.""" scene = cfg["scene"] @@ -77,6 +92,12 @@ def _canonical(cfg: dict) -> dict: by_handle = sorted(entries, key=int) name_of = {h: entries[h]["name"] for h in by_handle} driven = {str(h) for a in animations.values() for h in _driven_handles(a)} + moved = { + str(h) + for a in animations.values() + if _moves_its_target(a) + for h in _driven_handles(a) + } stimuli = [] for handle in by_handle: @@ -86,6 +107,11 @@ def _canonical(cfg: dict) -> dict: # either state at the moment we look. stim["common"]["flags"].pop("enabled", None) body = stim["body"] + if handle in moved: + # A sweep owns this stimulus's position: by the time the config is + # retrieved the animation has already moved it some way along its + # path, and where exactly depends on how many frames have passed. + body.get("transform", {}).pop("pos_px", None) if body["type"] == "Grating" and body["params"]["drift_speed_hz"] != 0.0: body["params"].pop("phase_cycles", None) # advances every frame stimuli.append([entries[handle]["name"], _round(stim)]) diff --git a/client/python/tests/e2e/test_e2e.py b/client/python/tests/e2e/test_e2e.py index 5b411925..c250f438 100644 --- a/client/python/tests/e2e/test_e2e.py +++ b/client/python/tests/e2e/test_e2e.py @@ -12,6 +12,7 @@ import sys import tempfile import time +import warnings import pytest @@ -68,9 +69,15 @@ def server_process(server_address: str): proc.wait(timeout=20) except subprocess.TimeoutExpired: # A fullscreen Vulkan renderer can take longer than a polite terminate - # allows to hand the display back. The suite is over either way, so the - # teardown kills rather than failing the last test that ran. + # allows to hand the display back, so escalate rather than fail the last + # test that ran. proc.kill() - proc.wait(timeout=5) + try: + proc.wait(timeout=30) + except subprocess.TimeoutExpired: + # SIGKILL is not negotiable, so a process still here is stuck in the + # kernel giving the display back. Say so and let the suite finish: + # how long cleanup took is not a result about the code under test. + warnings.warn(f"vstimd (pid {proc.pid}) has not exited after SIGKILL") log_file.close() print(f"\nServer log: {log_path}") diff --git a/client/python/tests/e2e/test_psychopy_visual.py b/client/python/tests/e2e/test_psychopy_visual.py index cd414fb1..283b88a6 100644 --- a/client/python/tests/e2e/test_psychopy_visual.py +++ b/client/python/tests/e2e/test_psychopy_visual.py @@ -12,6 +12,7 @@ import sys import tempfile import time +import warnings import pytest @@ -75,10 +76,16 @@ def server_process(server_address: str): proc.wait(timeout=20) except subprocess.TimeoutExpired: # A fullscreen Vulkan renderer can take longer than a polite terminate - # allows to hand the display back. The suite is over either way, so the - # teardown kills rather than failing the last test that ran. + # allows to hand the display back, so escalate rather than fail the last + # test that ran. proc.kill() - proc.wait(timeout=5) + try: + proc.wait(timeout=30) + except subprocess.TimeoutExpired: + # SIGKILL is not negotiable, so a process still here is stuck in the + # kernel giving the display back. Say so and let the suite finish: + # how long cleanup took is not a result about the code under test. + warnings.warn(f"vstimd (pid {proc.pid}) has not exited after SIGKILL") log_file.close() print(f"\nServer log: {log_path}")