Use `pytest-qt` ownership for gap overlay tests

Let `pytest-qt` own each real `PlotWidget` and input source so
fixture teardown closes them exactly once.

Also,
- select `PyQt6` and offscreen `Qt` before widget imports
- isolate `QSettings` and activate `pyproject.toml` config
- lock `pytest-qt` 4.5.0 and record verified harness commands
- retain real `tractor` dialog coverage beside `Qt` regressions

Prompt-IO: ai/prompt-io/opencode/20260831T005712Z_09ddcf50_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
wkt/replay_provider_e2e
Gud Boi 2026-08-31 16:18:35 -04:00
parent 46ee5a8c4c
commit 233fa590f9
8 changed files with 249 additions and 106 deletions

View File

@ -10,30 +10,34 @@ here.
- Project/import: `piker` - Project/import: `piker`
- Test root: `tests/` - Test root: `tests/`
- Supported Python: `>=3.12,<3.14` - Supported Python: `>=3.12,<3.14`
- Preferred complete environment: worktree-local `py313` inside the current - Preferred verified environment: worktree-local `.venv`.
`nix develop` shell - The testing group includes pytest and the locked `pytest-qt==4.5.0`.
- The flake shell pins CPython 3.13 and sets - Verify the interpreter, local package resolution, and dependency versions
`UV_PROJECT_ENVIRONMENT=py313`. before running tests.
- Verify the interpreter, package resolution, and dependency import before
running tests. A bare `py313` may lack the Qt binding supplied by Nix.
Use an already-provisioned `py313` only when `import piker` succeeds: This worktree's environment was provisioned with:
```text ```text
py313/bin/python -m pytest -p no:xonsh env UV_PROJECT_ENVIRONMENT=.venv uv sync --group testing --frozen
``` ```
If direct environment paths are unavailable, an existing uv environment can Do not rerun provisioning without approval. Use the already-provisioned
be used without changing it, subject to the same import check: environment directly:
```text ```text
UV_PROJECT_ENVIRONMENT=py313 uv run --frozen --no-sync python -m pytest -p no:xonsh .venv/bin/python -m pytest
``` ```
Ask before running provisioning commands such as: If direct environment paths are unavailable, use uv without changing the
environment, subject to the same import check:
```text
env UV_PROJECT_ENVIRONMENT=.venv uv run --frozen --no-sync python -m pytest
```
Ask before entering or provisioning alternate environments such as:
```text ```text
UV_PROJECT_ENVIRONMENT=py313 uv sync --dev --all-extras --no-group lint
nix develop nix develop
nix-shell default.nix nix-shell default.nix
``` ```
@ -42,43 +46,41 @@ nix-shell default.nix
X11 shell. Do not use `develop.nix` for current testing; it retains the old X11 shell. Do not use `develop.nix` for current testing; it retains the old
Python 3.11, Poetry, and Qt 5 stack. Python 3.11, Poetry, and Qt 5 stack.
The current root-checkout `py313` resolves `piker` locally but fails Do not enter `nix develop` without approval: its shell hook may provision its
`import piker` outside Nix because PyQtGraph cannot import PyQt or PySide. Do own configured environment. Plain `uv sync` does not include the testing
not treat that environment as test-ready and do not enter `nix develop` group; use `--group testing` when provisioning is explicitly authorized. The
without approval: its shell hook may recreate and sync `py313`. `dbs` dependency group is also absent from normal dev-shell provisioning.
Plain `uv sync` does not include the testing group. The `dbs` dependency group
is also absent from normal dev-shell provisioning.
## Commands ## Commands
Base command in the preferred environment: Base command in the preferred environment:
```text ```text
py313/bin/python -m pytest -p no:xonsh .venv/bin/python -m pytest
``` ```
The explicit `-p no:xonsh` is required. The tracked comments-only The comments-only `pytest.ini` has been deleted. The authoritative
`pytest.ini` takes precedence over `pyproject.toml`, so the intended `[tool.pytest.ini_options]` in `pyproject.toml` sets `testpaths = ["tests"]`,
`addopts = "-p no:xonsh"` and `testpaths = ["tests"]` are inactive. `addopts = "-p no:xonsh"`, and `qt_api = "pyqt6"`; do not repeat
Always pass a test path or node ID explicitly. `-p no:xonsh` in ordinary commands. Still pass a test path or node ID when a
deterministic scope is required.
Package-resolution check that does not import Piker's dependencies: Package-resolution check that does not import Piker's dependencies:
```text ```text
py313/bin/python -c 'import importlib.util, pathlib, sys; root = pathlib.Path.cwd().resolve(); spec = importlib.util.find_spec("piker"); mod = pathlib.Path(spec.origin).resolve(); print(sys.executable); print(mod); assert mod.is_relative_to(root)' .venv/bin/python -c 'import importlib.util, pathlib, sys; root = pathlib.Path.cwd().resolve(); spec = importlib.util.find_spec("piker"); mod = pathlib.Path(spec.origin).resolve(); print(sys.executable); print(mod); assert mod.is_relative_to(root)'
``` ```
Dependency import check, required before collection or execution: Dependency import check, required before collection or execution:
```text ```text
py313/bin/python -c 'import pathlib, piker, sys; root = pathlib.Path.cwd().resolve(); mod = pathlib.Path(piker.__file__).resolve(); print(sys.executable); print(mod); assert mod.is_relative_to(root)' .venv/bin/python -c 'from importlib.metadata import version; import pathlib, piker, pytestqt, sys; root = pathlib.Path.cwd().resolve(); mod = pathlib.Path(piker.__file__).resolve(); qt_ver = version("pytest-qt"); print(sys.executable); print(mod); print(qt_ver); assert mod.is_relative_to(root); assert qt_ver == "4.5.0"'
``` ```
Safe core collection check: Safe core collection check:
```text ```text
py313/bin/python -m pytest -p no:xonsh -q --collect-only tests/test_watchlists.py tests/test_accounting.py tests/test_services.py tests/test_ems.py tests/test_feeds.py tests/test_cli.py .venv/bin/python -m pytest -q --collect-only tests/test_watchlists.py tests/test_accounting.py tests/test_services.py tests/test_ems.py tests/test_feeds.py tests/test_cli.py
``` ```
Default first-pass flags are `-q -x --tb=short --no-header` unless the user Default first-pass flags are `-q -x --tb=short --no-header` unless the user
@ -86,7 +88,7 @@ requests otherwise. For actor-heavy tests, use one file or node per process
with an outer timeout: with an outer timeout:
```text ```text
timeout -k 5 300 py313/bin/python -m pytest -p no:xonsh -q <one-file-or-node> timeout -k 5 300 .venv/bin/python -m pytest -q <one-file-or-node>
``` ```
If an actor-heavy command exits `124` or `143`, retry that exact command once If an actor-heavy command exits `124` or `143`, retry that exact command once
@ -111,6 +113,8 @@ Deterministic or local first-pass targets:
- `tests/test_ib_method_proxy.py` - `tests/test_ib_method_proxy.py`
- `tests/test_history_backfill.py` - `tests/test_history_backfill.py`
- `tests/test_ldshm.py` - `tests/test_ldshm.py`
- `tests/test_dpi_font.py`
- `tests/test_gap_overlays.py`
- `tests/test_accounting.py::test_account_file_default_empty` - `tests/test_accounting.py::test_account_file_default_empty`
- `tests/test_services.py::test_runtime_boot` - `tests/test_services.py::test_runtime_boot`
- `tests/test_services.py::test_datad_spawn` - `tests/test_services.py::test_datad_spawn`
@ -128,7 +132,6 @@ Require explicit authorization before running:
fixtures plus possible live symcache generation; fixtures plus possible live symcache generation;
- `tests/test_accounting.py::test_ib_account_with_duplicated_mktids` - active - `tests/test_accounting.py::test_ib_account_with_duplicated_mktids` - active
broker/account configuration and state writes; broker/account configuration and state writes;
- `tests/test_dpi_font.py` - Qt/UI import and user-config side effects;
- `tests/test_docker_services.py` - optional dependencies and containers; - `tests/test_docker_services.py` - optional dependencies and containers;
- `tests/test_questrade.py` - obsolete credentialed imports. - `tests/test_questrade.py` - obsolete credentialed imports.
@ -169,9 +172,9 @@ whole suite supports UDS merely because the Tractor plugin exposes it.
`tests/_inputs/account.binance.paper.toml` are used in place. Accounting `tests/_inputs/account.binance.paper.toml` are used in place. Accounting
contexts can write them on exit. Inspect `git diff -- tests/_inputs` after contexts can write them on exit. Inspect `git diff -- tests/_inputs` after
any selected accounting case. any selected accounting case.
- Importing `tests/test_dpi_font.py` constructs module-global font objects - `tests/conftest.py` selects offscreen PyQt6 defaults before test modules
before fixtures can isolate config. If explicitly requested, isolate import Qt, and its `qapp_args` fixture isolates `XDG_CONFIG_HOME` before
`XDG_CONFIG_HOME` before Python starts and use the proper Qt/Nix shell. pytest-qt creates the application.
- Piker and the installed Tractor pytest plugin do not provide a - Piker and the installed Tractor pytest plugin do not provide a
repository-local process or socket reaper. Never apply historical broad repository-local process or socket reaper. Never apply historical broad
`pkill -f tractor._child` guidance automatically. `pkill -f tractor._child` guidance automatically.
@ -181,6 +184,44 @@ whole suite supports UDS merely because the Tractor plugin exposes it.
unlinks exact surviving names before failing the leaking test. It never unlinks exact surviving names before failing the leaking test. It never
scans `/dev/shm` or unlinks attachments created by another process. scans `/dev/shm` or unlinks attachments created by another process.
## Qt/UI Tier
The default pytest process environment is `QT_QPA_PLATFORM=offscreen` and
`PYTEST_QT_API=pyqt6`. `tests/conftest.py` installs both with `setdefault`
before Qt imports, so an explicit caller environment still wins. The
authoritative pytest config also selects `qt_api = "pyqt6"`, and
`pytest-qt==4.5.0` is locked.
The session `qapp_args` fixture assigns a temporary `XDG_CONFIG_HOME` before
pytest-qt creates its `QApplication` and uses `piker-tests` as the application
argument. Deterministic offscreen Qt tests are normal local targets; they are
not opt-in merely because they import Qt or might otherwise read user config.
Exact verified gap-overlay commands:
```text
.venv/bin/python -m pytest -q --collect-only tests/test_gap_overlays.py
.venv/bin/python -m pytest -q -x --tb=short --no-header tests/test_gap_overlays.py
```
The first command collected 9 tests. The second passed all 9 in 2.00s with
one Tractor `trio.Event` boolean-use deprecation warning.
- Let `qtbot` own every widget registered with `qtbot.addWidget()` through
teardown. Do not manually close or delete the same widget a second time.
- Keep production `MainWindow` out of this tier: its `closeEvent()` sends
`SIGINT` to the pytest process.
- A real compositor is an explicit process override, for example:
```text
QT_QPA_PLATFORM=wayland .venv/bin/python -m pytest -q -x --tb=short --no-header tests/test_gap_overlays.py
QT_QPA_PLATFORM=xcb .venv/bin/python -m pytest -q -x --tb=short --no-header tests/test_gap_overlays.py
```
Real-compositor, visual, manual, and live UI tests remain opt-in. Do not
replace the offscreen default or infer compositor availability without
explicit authorization.
## Test Layout ## Test Layout
```text ```text
@ -193,6 +234,7 @@ tests/
test_dpi_font.py Qt DPI/font behavior test_dpi_font.py Qt DPI/font behavior
test_ems.py actor, EMS, and paper-position behavior test_ems.py actor, EMS, and paper-position behavior
test_feeds.py live Binance/Kraken feeds and shared memory test_feeds.py live Binance/Kraken feeds and shared memory
test_gap_overlays.py typed gap logic, offscreen Qt, and Tractor IPC
test_ib_history.py deterministic IB history request formatting test_ib_history.py deterministic IB history request formatting
test_ib_method_proxy.py deterministic IB asyncio proxy routing test_ib_method_proxy.py deterministic IB asyncio proxy routing
test_history_backfill.py deterministic history/SHM orchestration test_history_backfill.py deterministic history/SHM orchestration
@ -220,7 +262,8 @@ tests/
| `piker/storage/cli.py` SHM null-slot guard | `tests/test_ldshm.py` | synthetic timestamps, no SHM mutation | | `piker/storage/cli.py` SHM null-slot guard | `tests/test_ldshm.py` | synthetic timestamps, no SHM mutation |
| `piker/config.py` | `test_account_file_default_empty` | root-network test has a known mismatch | | `piker/config.py` | `test_account_file_default_empty` | root-network test has a known mismatch |
| `piker/accounting/` | targeted accounting node | some cases use live/configured state | | `piker/accounting/` | targeted accounting node | some cases use live/configured state |
| `piker/ui/_style.py`, `piker/ui/qt.py` | `tests/test_dpi_font.py` | GUI/config-isolated opt-in | | `piker/ui/_gaps.py`, `_annotate.py`, `_display.py`, `_interaction.py`, `_remote_ctl.py` | `tests/test_gap_overlays.py` | offscreen PyQt6 plus one local Tractor actor |
| `piker/ui/_style.py`, `piker/ui/qt.py` | `tests/test_dpi_font.py` | deterministic offscreen Qt |
| `piker/service/_actor_runtime.py`, `_registry.py`, `_mngr.py` | `test_runtime_boot` | then `test_datad_spawn` | | `piker/service/_actor_runtime.py`, `_registry.py`, `_mngr.py` | `test_runtime_boot` | then `test_datad_spawn` |
| `piker/service/`, `piker/data/_daemon.py` | `test_datad_spawn` | feed lifecycle cases are live | | `piker/service/`, `piker/data/_daemon.py` | `test_datad_spawn` | feed lifecycle cases are live |
| `piker/data/feed.py`, `flows.py`, `_sharedmem.py`, `_sampling.py` | collect first | feed execution needs live permission | | `piker/data/feed.py`, `flows.py`, `_sharedmem.py`, `_sampling.py` | collect first | feed execution needs live permission |
@ -230,26 +273,28 @@ tests/
| Docker/service adapters | `tests/test_docker_services.py` | optional deps and containers | | Docker/service adapters | `tests/test_docker_services.py` | optional deps and containers |
| project, lock, or Nix files | import check and safe collection | full collection is not safe by default | | project, lock, or Nix files | import check and safe collection | full collection is not safe by default |
Prefer deterministic filesystem/config tests, then local actor-runtime nodes, Prefer deterministic filesystem/config and offscreen Qt tests, then local
then explicitly approved live broker, GUI, or container coverage. actor-runtime nodes, then explicitly approved live broker, visual UI, or
container coverage.
## Quick Checks ## Quick Checks
```text ```text
py313/bin/python -c 'import importlib.util, pathlib, sys; root = pathlib.Path.cwd().resolve(); spec = importlib.util.find_spec("piker"); mod = pathlib.Path(spec.origin).resolve(); print(sys.executable); print(mod); assert mod.is_relative_to(root)' .venv/bin/python -c 'import importlib.util, pathlib, sys; root = pathlib.Path.cwd().resolve(); spec = importlib.util.find_spec("piker"); mod = pathlib.Path(spec.origin).resolve(); print(sys.executable); print(mod); assert mod.is_relative_to(root)'
py313/bin/python -c 'import pathlib, piker, sys; root = pathlib.Path.cwd().resolve(); mod = pathlib.Path(piker.__file__).resolve(); print(sys.executable); print(mod); assert mod.is_relative_to(root)' .venv/bin/python -c 'from importlib.metadata import version; import pathlib, piker, pytestqt, sys; root = pathlib.Path.cwd().resolve(); mod = pathlib.Path(piker.__file__).resolve(); qt_ver = version("pytest-qt"); print(sys.executable); print(mod); print(qt_ver); assert mod.is_relative_to(root); assert qt_ver == "4.5.0"'
py313/bin/python -m pytest -p no:xonsh -q tests/test_watchlists.py .venv/bin/python -m pytest -q --collect-only tests/test_gap_overlays.py
py313/bin/python -m pytest -p no:xonsh -q tests/test_accounting.py::test_account_file_default_empty .venv/bin/python -m pytest -q -x --tb=short --no-header tests/test_gap_overlays.py
timeout -k 5 300 py313/bin/python -m pytest -p no:xonsh -q tests/test_services.py::test_runtime_boot .venv/bin/python -m pytest -q tests/test_watchlists.py
timeout -k 5 300 py313/bin/python -m pytest -p no:xonsh -q tests/test_services.py::test_datad_spawn .venv/bin/python -m pytest -q tests/test_accounting.py::test_account_file_default_empty
timeout -k 5 300 py313/bin/python -m pytest -p no:xonsh -q tests/test_ems.py::test_ems_err_on_bad_broker timeout -k 5 300 .venv/bin/python -m pytest -q tests/test_services.py::test_runtime_boot
timeout -k 5 300 .venv/bin/python -m pytest -q tests/test_services.py::test_datad_spawn
timeout -k 5 300 .venv/bin/python -m pytest -q tests/test_ems.py::test_ems_err_on_bad_broker
``` ```
## Known Outcomes ## Known Outcomes
- The current root-checkout `py313` fails `import piker` outside the Nix shell - The verified `.venv` import check resolves `piker` beneath this worktree and
with `ImportError: PyQtGraph requires one of PyQt5, PyQt6, PySide2 or imports the locked `pytest-qt` 4.5.0.
PySide6`. This is an incomplete environment, not an application regression.
- `tests/test_accounting.py::test_root_conf_networking_section` currently - `tests/test_accounting.py::test_root_conf_networking_section` currently
expects `network.tsdb`, which is absent from the tracked config template. expects `network.tsdb`, which is absent from the tracked config template.
Match the current `KeyError: 'tsdb'` before classifying it as the known Match the current `KeyError: 'tsdb'` before classifying it as the known

View File

@ -0,0 +1,44 @@
---
model: openai/gpt-5.6-sol
service: opencode
session: unavailable
timestamp: 2026-08-31T00:57:12Z
git_ref: 09ddcf50
scope: tests
substantive: true
raw_file: 20260831T005712Z_09ddcf50_prompt_io.raw.md
---
## Prompt
Deeply evaluate Piker's lacking suites and build a comprehensive plan for
non-mocked, human-driven E2E coverage, beginning with the pytest-qt plan on
`chart_local_gapper`. Work in `/open-wkt`, build the first feasible GUI test
slice, add minimal adjacent CLI and Qt guide docs, keep the `/run-tests`
reference current, and stop before the stated Eastern deadline.
## Response summary
Audits the existing suite and human-facing surfaces, creates a risk-ranked
cross-interface E2E plan, activates pytest-qt, migrates the real-Qt gap suite
to pytest-qt widget ownership, and records verified commands and minimal user
guides. Full `MainWindow` E2E remains deferred behind safe shutdown, guest
outcome, symbol-session ownership, and deterministic provider seams.
## Files changed
- `pyproject.toml`, `uv.lock`, `pytest.ini` - activate pytest-qt config.
- `tests/conftest.py` - establish the Qt process and settings contract.
- `tests/test_gap_overlays.py` - migrate real widgets to `qtbot` ownership.
- `piker/cli/README.rst` - add the minimal command-line guide.
- `piker/ui/README.rst` - add the minimal chart UI guide.
- `plans/opencode/human-facing-e2e-coverage.md` - persist the suite plan.
- `.claude/skills/run-tests/test-harness-reference.md` - record verified
test commands and boundaries.
## Human edits
The human requires isolated worktree execution, adjacent guide-style docs,
continuous `/run-tests` reference maintenance, non-mocked Qt interactions,
and a hard time cutoff. These constraints materially determine the patch
scope and defer unsafe full-window work.

View File

@ -0,0 +1,45 @@
---
model: openai/gpt-5.6-sol
service: opencode
timestamp: 2026-08-31T00:57:12Z
git_ref: 09ddcf50
diff_cmd: git diff HEAD
---
## Generated changes
> `git diff HEAD -- pyproject.toml pytest.ini uv.lock`
Activates pytest-qt with PyQt6 through the repository's authoritative
pytest configuration and locks pytest-qt 4.5.0.
> `git diff HEAD -- tests/conftest.py tests/test_gap_overlays.py`
Selects the offscreen PyQt6 test process before Qt imports, isolates Qt
configuration, migrates real chart-gap widgets to pytest-qt ownership,
and removes the conflicting manual widget teardown path.
> `git diff HEAD -- piker/cli/README.rst piker/ui/README.rst`
Adds minimal adjacent guides for the installed command and Qt chart
interfaces without freezing volatile subsystem details.
> `git diff HEAD -- plans/opencode/human-facing-e2e-coverage.md`
Records the evidence-based test-suite audit and phased human-facing E2E
strategy, distinguishing component, application, system, and live
qualification boundaries.
> `git diff HEAD -- .claude/skills/run-tests/test-harness-reference.md`
Records the verified environment, commands, Qt ownership rules, current
tier boundaries, and test outcomes.
## Verification
- Local package and pytest-qt 4.5.0 import check passes.
- Nine gap-overlay tests collect.
- The targeted file passes: 9 tests, one upstream Tractor warning.
- A duplicated same-process run passes: 18 tests, two equivalent
upstream Tractor warnings.
- `git diff --check` passes.

View File

@ -149,6 +149,7 @@ repl = [
] ]
testing = [ testing = [
"pytest", "pytest",
"pytest-qt",
] ]
de = [ # (linux) specific DEs de = [ # (linux) specific DEs
"i3ipc>=2.2.1", "i3ipc>=2.2.1",
@ -177,6 +178,7 @@ console_output_style = 'progress'
# https://docs.pytest.org/en/stable/how-to/plugins.html#disabling-plugins-from-autoloading # https://docs.pytest.org/en/stable/how-to/plugins.html#disabling-plugins-from-autoloading
# https://docs.pytest.org/en/stable/how-to/plugins.html#deactivating-unregistering-a-plugin-by-name # https://docs.pytest.org/en/stable/how-to/plugins.html#deactivating-unregistering-a-plugin-by-name
addopts = '-p no:xonsh' addopts = '-p no:xonsh'
qt_api = 'pyqt6'
# ------ tool.pytest ------ # ------ tool.pytest ------

View File

@ -1,3 +0,0 @@
#[pytest]
#trio_mode=True
#log_cli=1

View File

@ -9,6 +9,10 @@ from weakref import (
ref, ref,
) )
# These must be selected before test modules import Qt.
os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen')
os.environ.setdefault('PYTEST_QT_API', 'pyqt6')
import pytest import pytest
import tractor import tractor
from piker import ( from piker import (
@ -26,6 +30,20 @@ pytest_plugins: tuple[str] = (
) )
@pytest.fixture(scope='session')
def qapp_args(
tmp_path_factory: pytest.TempPathFactory,
) -> list[str]:
'''
Isolate Qt settings before pytest-qt creates `QApplication`.
'''
config_home: Path = tmp_path_factory.mktemp('qt-config')
os.environ['XDG_CONFIG_HOME'] = str(config_home)
return ['piker-tests']
def pytest_addoption(parser): def pytest_addoption(parser):
parser.addoption("--ll", action="store", dest='loglevel', parser.addoption("--ll", action="store", dest='loglevel',
default=None, help="logging level to set when testing") default=None, help="logging level to set when testing")

View File

@ -4,20 +4,17 @@ Typed chart-local gap-overlay regressions.
''' '''
from collections.abc import ( from collections.abc import (
Callable, Callable,
Iterator,
) )
from contextlib import AsyncExitStack from contextlib import AsyncExitStack
import os
from types import SimpleNamespace from types import SimpleNamespace
os.environ['QT_QPA_PLATFORM'] = 'offscreen'
import msgspec import msgspec
import numpy as np import numpy as np
from PyQt6.QtGui import QKeyEvent from PyQt6.QtGui import QKeyEvent
from PyQt6.QtWidgets import QGraphicsScene from PyQt6.QtWidgets import QGraphicsScene
import pyqtgraph as pg import pyqtgraph as pg
import pytest import pytest
from pytestqt.qtbot import QtBot
import tractor import tractor
from tractor._testing import tractor_test from tractor._testing import tractor_test
import trio import trio
@ -59,10 +56,12 @@ class _ChartStub:
self, self,
fqme: str, fqme: str,
array: np.ndarray, array: np.ndarray,
qtbot: QtBot,
) -> None: ) -> None:
self.fqme: str = fqme self.fqme: str = fqme
self.widget: pg.PlotWidget = pg.PlotWidget() self.widget: pg.PlotWidget = pg.PlotWidget()
qtbot.addWidget(self.widget)
self.viz: SimpleNamespace = SimpleNamespace( self.viz: SimpleNamespace = SimpleNamespace(
plot=self.widget.plotItem, plot=self.widget.plotItem,
shm=SimpleNamespace(array=array), shm=SimpleNamespace(array=array),
@ -115,6 +114,7 @@ def _ohlcv_array(
def _display_state( def _display_state(
fqme: str, fqme: str,
qtbot: QtBot,
) -> tuple[ ) -> tuple[
SimpleNamespace, SimpleNamespace,
@ -127,10 +127,12 @@ def _display_state(
rt_chart: _ChartStub = _ChartStub( rt_chart: _ChartStub = _ChartStub(
fqme, fqme,
_ohlcv_array((1, 2, 4, 5)), _ohlcv_array((1, 2, 4, 5)),
qtbot,
) )
hist_chart: _ChartStub = _ChartStub( hist_chart: _ChartStub = _ChartStub(
fqme, fqme,
_ohlcv_array((60, 120, 300, 360)), _ohlcv_array((60, 120, 300, 360)),
qtbot,
) )
ds: SimpleNamespace = SimpleNamespace( ds: SimpleNamespace = SimpleNamespace(
fqme=fqme, fqme=fqme,
@ -142,22 +144,6 @@ def _display_state(
return ds, (rt_chart, hist_chart) return ds, (rt_chart, hist_chart)
@pytest.fixture(scope='session')
def qapp() -> Iterator[QApplication]:
'''
Keep one offscreen Qt application alive for graphics tests.
'''
app: QApplication|None = QApplication.instance()
if app is None:
app = QApplication(['piker-gap-tests'])
app.setQuitOnLastWindowClosed(False)
yield app
app.processEvents()
def test_gap_specs_and_wire_roundtrip() -> None: def test_gap_specs_and_wire_roundtrip() -> None:
''' '''
Local detection and remote IPC share one typed request model. Local detection and remote IPC share one typed request model.
@ -291,7 +277,7 @@ def test_gap_overlay_unknown_fqme_returns_typed_error() -> None:
def test_gap_manager_real_qt_lifecycle( def test_gap_manager_real_qt_lifecycle(
qapp: QApplication, qtbot: QtBot,
) -> None: ) -> None:
''' '''
@ -307,7 +293,7 @@ def test_gap_manager_real_qt_lifecycle(
''' '''
ds: SimpleNamespace ds: SimpleNamespace
charts: tuple[_ChartStub, _ChartStub] charts: tuple[_ChartStub, _ChartStub]
ds, charts = _display_state(FQME) ds, charts = _display_state(FQME, qtbot)
annots: dict[int, GapAnnotations] = {} annots: dict[int, GapAnnotations] = {}
gapman: GapOverlayMngr = GapOverlayMngr( gapman: GapOverlayMngr = GapOverlayMngr(
dss={FQME: ds}, dss={FQME: ds},
@ -403,14 +389,11 @@ def test_gap_manager_real_qt_lifecycle(
gapman.remove_owner('chart-local') gapman.remove_owner('chart-local')
assert annots == {} assert annots == {}
finally: finally:
chart: _ChartStub gapman.remove_owner('chart-local')
for chart in charts:
chart.close()
qapp.processEvents()
def test_gap_annotations_reposition_after_prepend( def test_gap_annotations_reposition_after_prepend(
qapp: QApplication, qtbot: QtBot,
) -> None: ) -> None:
''' '''
@ -426,7 +409,7 @@ def test_gap_annotations_reposition_after_prepend(
''' '''
ds: SimpleNamespace ds: SimpleNamespace
charts: tuple[_ChartStub, _ChartStub] charts: tuple[_ChartStub, _ChartStub]
ds, charts = _display_state(FQME) ds, charts = _display_state(FQME, qtbot)
annots: dict[int, GapAnnotations] = {} annots: dict[int, GapAnnotations] = {}
gapman: GapOverlayMngr = GapOverlayMngr( gapman: GapOverlayMngr = GapOverlayMngr(
dss={FQME: ds}, dss={FQME: ds},
@ -445,7 +428,7 @@ def test_gap_annotations_reposition_after_prepend(
old_scene_point: QPointF = item.mapToScene( old_scene_point: QPointF = item.mapToScene(
old_br.center() old_br.center()
) )
qapp.processEvents() QApplication.processEvents()
assert item in scene.items(old_scene_point) assert item in scene.items(old_scene_point)
old_rects: np.ndarray = ( old_rects: np.ndarray = (
item._rectarray.ndarray().copy() item._rectarray.ndarray().copy()
@ -458,7 +441,7 @@ def test_gap_annotations_reposition_after_prepend(
fqme=FQME, fqme=FQME,
timeframe=60, timeframe=60,
) )
qapp.processEvents() QApplication.processEvents()
new_rects: np.ndarray = item._rectarray.ndarray() new_rects: np.ndarray = item._rectarray.ndarray()
np.testing.assert_allclose( np.testing.assert_allclose(
@ -476,14 +459,10 @@ def test_gap_annotations_reposition_after_prepend(
assert item.scene() is charts[1].widget.scene() assert item.scene() is charts[1].widget.scene()
finally: finally:
gapman.remove_owner('chart-local') gapman.remove_owner('chart-local')
chart: _ChartStub
for chart in charts:
chart.close()
qapp.processEvents()
def test_duplicate_fqme_layers_use_local_chart_identity( def test_duplicate_fqme_layers_use_local_chart_identity(
qapp: QApplication, qtbot: QtBot,
) -> None: ) -> None:
''' '''
@ -499,10 +478,10 @@ def test_duplicate_fqme_layers_use_local_chart_identity(
''' '''
first_ds: SimpleNamespace first_ds: SimpleNamespace
first_charts: tuple[_ChartStub, _ChartStub] first_charts: tuple[_ChartStub, _ChartStub]
first_ds, first_charts = _display_state(FQME) first_ds, first_charts = _display_state(FQME, qtbot)
second_ds: SimpleNamespace second_ds: SimpleNamespace
second_charts: tuple[_ChartStub, _ChartStub] second_charts: tuple[_ChartStub, _ChartStub]
second_ds, second_charts = _display_state(FQME) second_ds, second_charts = _display_state(FQME, qtbot)
annots: dict[int, GapAnnotations] = {} annots: dict[int, GapAnnotations] = {}
gapman: GapOverlayMngr = GapOverlayMngr( gapman: GapOverlayMngr = GapOverlayMngr(
dss={FQME: second_ds}, dss={FQME: second_ds},
@ -534,14 +513,10 @@ def test_duplicate_fqme_layers_use_local_chart_identity(
assert annots[second.aid].scene() is not None assert annots[second.aid].scene() is not None
finally: finally:
gapman.remove_owner('chart-local') gapman.remove_owner('chart-local')
chart: _ChartStub
for chart in (*first_charts, *second_charts):
chart.close()
qapp.processEvents()
def test_startup_and_focused_toggle_use_real_qt( def test_startup_and_focused_toggle_use_real_qt(
qapp: QApplication, qtbot: QtBot,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
@ -560,10 +535,16 @@ def test_startup_and_focused_toggle_use_real_qt(
first_ds: SimpleNamespace first_ds: SimpleNamespace
first_charts: tuple[_ChartStub, _ChartStub] first_charts: tuple[_ChartStub, _ChartStub]
first_ds, first_charts = _display_state('first.test') first_ds, first_charts = _display_state(
'first.test',
qtbot,
)
second_ds: SimpleNamespace second_ds: SimpleNamespace
second_charts: tuple[_ChartStub, _ChartStub] second_charts: tuple[_ChartStub, _ChartStub]
second_ds, second_charts = _display_state('second.test') second_ds, second_charts = _display_state(
'second.test',
qtbot,
)
dss: dict[str, SimpleNamespace] = { dss: dict[str, SimpleNamespace] = {
first_ds.fqme: first_ds, first_ds.fqme: first_ds,
second_ds.fqme: second_ds, second_ds.fqme: second_ds,
@ -642,14 +623,10 @@ def test_startup_and_focused_toggle_use_real_qt(
assert unrelated == [] assert unrelated == []
finally: finally:
gapman.remove_owner('chart-local') gapman.remove_owner('chart-local')
chart: _ChartStub
for chart in (*first_charts, *second_charts):
chart.close()
qapp.processEvents()
def test_ctrl_g_event_renders_real_history_overlay( def test_ctrl_g_event_renders_real_history_overlay(
qapp: QApplication, qtbot: QtBot,
) -> None: ) -> None:
''' '''
@ -665,7 +642,7 @@ def test_ctrl_g_event_renders_real_history_overlay(
''' '''
ds: SimpleNamespace ds: SimpleNamespace
charts: tuple[_ChartStub, _ChartStub] charts: tuple[_ChartStub, _ChartStub]
ds, charts = _display_state(FQME) ds, charts = _display_state(FQME, qtbot)
annots: dict[int, GapAnnotations] = {} annots: dict[int, GapAnnotations] = {}
gapman: GapOverlayMngr = GapOverlayMngr( gapman: GapOverlayMngr = GapOverlayMngr(
dss={FQME: ds}, dss={FQME: ds},
@ -686,6 +663,7 @@ def test_ctrl_g_event_renders_real_history_overlay(
active=False, active=False,
) )
source: QWidget = QWidget() source: QWidget = QWidget()
qtbot.addWidget(source)
source.order_mode = order_mode source.order_mode = order_mode
source._chart = ds.hist_chart source._chart = ds.hist_chart
source.linked = SimpleNamespace( source.linked = SimpleNamespace(
@ -727,12 +705,6 @@ def test_ctrl_g_event_renders_real_history_overlay(
assert annots[aid].scene() is charts[1].widget.scene() assert annots[aid].scene() is charts[1].widget.scene()
finally: finally:
gapman.remove_owner('chart-local') gapman.remove_owner('chart-local')
source.close()
source.deleteLater()
chart: _ChartStub
for chart in charts:
chart.close()
qapp.processEvents()
_actor_requests: list[dict] = [] _actor_requests: list[dict] = []

22
uv.lock
View File

@ -1132,6 +1132,7 @@ dev = [
{ name = "pyqt6" }, { name = "pyqt6" },
{ name = "pyqtgraph" }, { name = "pyqtgraph" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-qt" },
{ name = "qdarkstyle" }, { name = "qdarkstyle" },
{ name = "rapidfuzz" }, { name = "rapidfuzz" },
{ name = "xonsh" }, { name = "xonsh" },
@ -1149,6 +1150,7 @@ repl = [
] ]
testing = [ testing = [
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-qt" },
] ]
uis = [ uis = [
{ name = "pyqt6" }, { name = "pyqt6" },
@ -1206,6 +1208,7 @@ dev = [
{ name = "pyqt6", specifier = ">=6.7.0,<7.0.0" }, { name = "pyqt6", specifier = ">=6.7.0,<7.0.0" },
{ name = "pyqtgraph", specifier = ">=0.14.0" }, { name = "pyqtgraph", specifier = ">=0.14.0" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-qt" },
{ name = "qdarkstyle", specifier = ">=3.0.2,<4.0.0" }, { name = "qdarkstyle", specifier = ">=3.0.2,<4.0.0" },
{ name = "rapidfuzz", specifier = ">=3.2.0,<4.0.0" }, { name = "rapidfuzz", specifier = ">=3.2.0,<4.0.0" },
{ name = "xonsh", specifier = ">=0.23.0" }, { name = "xonsh", specifier = ">=0.23.0" },
@ -1219,7 +1222,10 @@ repl = [
{ name = "pyperclip", specifier = ">=1.9.0" }, { name = "pyperclip", specifier = ">=1.9.0" },
{ name = "xonsh", specifier = ">=0.23.0" }, { name = "xonsh", specifier = ">=0.23.0" },
] ]
testing = [{ name = "pytest" }] testing = [
{ name = "pytest" },
{ name = "pytest-qt" },
]
uis = [ uis = [
{ name = "pyqt6", specifier = ">=6.7.0,<7.0.0" }, { name = "pyqt6", specifier = ">=6.7.0,<7.0.0" },
{ name = "pyqtgraph", specifier = ">=0.14.0" }, { name = "pyqtgraph", specifier = ">=0.14.0" },
@ -1629,6 +1635,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
] ]
[[package]]
name = "pytest-qt"
version = "4.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pluggy" },
{ name = "pytest" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d3/61/8bdec02663c18bf5016709b909411dce04a868710477dc9b9844ffcf8dd2/pytest_qt-4.5.0.tar.gz", hash = "sha256:51620e01c488f065d2036425cbc1cbcf8a6972295105fd285321eb47e66a319f", size = 128702, upload-time = "2025-07-01T17:24:39.889Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/d0/8339b888ad64a3d4e508fed8245a402b503846e1972c10ad60955883dcbb/pytest_qt-4.5.0-py3-none-any.whl", hash = "sha256:ed21ea9b861247f7d18090a26bfbda8fb51d7a8a7b6f776157426ff2ccf26eff", size = 37214, upload-time = "2025-07-01T17:24:38.226Z" },
]
[[package]] [[package]]
name = "python-baseconv" name = "python-baseconv"
version = "1.2.2" version = "1.2.2"