piker/tests/test_trend_graphics.py

930 lines
29 KiB
Python
Raw Permalink Normal View History

# piker: trading gear for hackers
# Copyright (C) 2018-present Tyler Goodlet (in stewardship of pikers)
# SPDX-License-Identifier: AGPL-3.0-or-later
# This program is free software: you can redistribute it and/or
# modify it under the terms of the GNU Affero General Public License
# as published by the Free Software Foundation, either version 3
# of the License, or (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public
# License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
'''
Trend overlay causality, coordinate and bounded Qt lifetime checks.
'''
from collections.abc import AsyncIterator, Iterator
from contextlib import asynccontextmanager
import os
from types import SimpleNamespace
from typing import Literal
os.environ['QT_QPA_PLATFORM'] = 'offscreen'
import numpy as np
import pyqtgraph as pg
import pytest
import trio
from trio.testing import wait_all_tasks_blocked
from piker.ui._trend import (
RegimeInterval,
ReversalPoint,
TrendOverlay,
classification_interval,
regime_rectangles,
reversal_points,
times_to_x,
trend_intervals,
)
from piker.ui.qt import QApplication, QtGui, QPointF
from piker.fsp._jev import JevClassification, RegimeAnswer
from piker.fsp import Fsp
from piker.ui._fsp import FspAdmin, update_trend_overlays
from piker.ui import _fsp as fsp_ui
def _chart(
times: tuple[float, ...],
first: int = 1000,
) -> np.ndarray:
'''
Make chart bars with absolute indices independent of timestamp.
'''
rows: np.ndarray = np.zeros(len(times), dtype=[
('index', 'i8'),
('time', 'f8'),
('low', 'f8'),
('high', 'f8'),
('close', 'f8'),
])
rows['index'] = np.arange(first, first + len(times))
rows['time'] = times
rows['low'] = 99
rows['high'] = 101
rows['close'] = 100
return rows
def _source(
times: tuple[float, ...],
trends: tuple[float, ...],
) -> np.ndarray:
'''
Make sparse FSP fields, including a deliberately mutable tail.
'''
fields: list[tuple[str, str]] = [
('index', 'i8'),
('time', 'f8'),
('ready', 'f8'),
]
order: int
for order in range(1, 5):
field: str = 'trend' if order == 1 else f'trend_{order}'
fields.append((field, 'f8'))
kind: str
for kind in ('top', 'bottom'):
fields.extend([
(f'{kind}_{order}', 'f8'),
(f'{kind}_time_{order}', 'f8'),
])
rows: np.ndarray = np.empty(len(times), dtype=fields)
name: str
for name in rows.dtype.names:
if name == 'index':
rows[name] = np.arange(5000, 5000 + len(rows))
else:
rows[name] = np.nan
rows['time'] = times
rows['ready'] = 4
rows['trend'] = trends
return rows
@pytest.fixture(scope='session')
def qapp() -> Iterator[QApplication]:
'''
Keep one offscreen application alive without launching a GUI.
'''
app: QApplication|None = QApplication.instance()
if app is None:
app = QApplication(['piker-trend-tests'])
app.setQuitOnLastWindowClosed(False)
yield app
app.processEvents()
@pytest.mark.parametrize('index_field', ['index', 'time'])
def test_timestamp_mapping_preserves_gaps(
index_field: Literal['index', 'time'],
) -> None:
'''
A timestamp inside a missing bucket must not snap to a future
bar or confuse slice offsets with absolute SHM coordinates.
Irregular 10-second bars and off-grid queries exercise both
chart axes; NaNs prove out-of-range observations stay hidden.
'''
rows: np.ndarray = _chart((100, 110, 140))
queries: np.ndarray = np.array((99, 100, 115, 125, 145, 150))
actual: np.ndarray = times_to_x(rows, queries, 10, index_field)
expected: list[float] = (
[np.nan, 1000, 1001.5, np.nan, 1002.5, np.nan]
if index_field == 'index'
else [np.nan, 100, 115, np.nan, 145, np.nan]
)
np.testing.assert_allclose(actual, expected, equal_nan=True)
def test_pivots_confirm_at_later_bar_close() -> None:
'''
Plotting a provisional reversal makes an intrabar repaint look
like a confirmed signal. A confirmed top and mutable-tail bottom
share an earlier pivot time here. Only the top may render, with
its 30-second source detection time and 10-second chart position
retained separately; ``source['index']`` is unrelated on purpose.
'''
source: np.ndarray = _source((100, 130, 160, 190), (0, 1, -1, 1))
source['top_1'][2] = 110
source['top_time_1'][2] = 130
source['bottom_1'][3] = 90
source['bottom_time_1'][3] = 160
chart: np.ndarray = _chart(tuple(range(100, 201, 10)))
points: list[ReversalPoint] = reversal_points(
source,
chart,
source_period_s=30,
chart_period_s=10,
)
assert len(points) == 1
assert points[0].x == 1003
assert points[0].pivot_time == 130
assert points[0].detected_at == 190
assert points[0].period_s == 30
assert points[0].kind == 'top'
def test_reversal_order_is_independent_of_timeframe() -> None:
'''
A second reversal order must use its own pivot fields without
implicitly changing the source sampling period. Distinct order
one and order two prices at the same source time identify which
columns were consumed and prevent order/timeframe conflation.
'''
source: np.ndarray = _source((10, 20, 30), (1, -1, 1))
source['top_1'][1] = 105
source['top_time_1'][1] = 10
source['bottom_2'][1] = 95
source['bottom_time_2'][1] = 10
points: list[ReversalPoint] = reversal_points(
source,
_chart((10, 20, 30)),
source_period_s=10,
chart_period_s=10,
order=2,
)
assert len(points) == 1
assert points[0].price == 95
assert points[0].order == 2
assert points[0].period_s == 10
def test_regime_runs_start_at_confirmation() -> None:
'''
Coloring from a pivot or detection-bar open falsely implies an
earlier available regime. A warm-up row, direction change, and
source gap arrange all run boundaries. Every interval starts at
finalized bar close and the mutable tail's opposite direction
cannot create a new regime.
'''
source: np.ndarray = _source(
(100, 110, 120, 130, 170, 180),
(1, 1, 1, -1, -1, 1),
)
source['ready'][0] = 0
spans: list[RegimeInterval] = trend_intervals(source, 10)
span: RegimeInterval # noqa: F842
assert [(span.start_time, span.end_time) for span in spans] == [
(120, 140),
(140, 150),
(180, 190),
]
assert [span.label for span in spans] == [
'uptrend', 'downtrend', 'downtrend',
]
def test_decimal_subsecond_epochs_do_not_invent_gaps() -> None:
'''
Float64 Unix timestamps cannot represent every 100 ms boundary
exactly; successive differences alternate around 0.1 seconds.
Comparing each difference strictly to the requested period splits
one regime into spurious holes. Realistic epochs prove rounding
preserves one run, while deleting one actual bucket still splits
both regime evidence and chart rectangle geometry.
'''
times: np.ndarray = 1_700_000_000 + np.arange(8) * 0.1
source: np.ndarray = _source(tuple(times), (1,) * 8)
spans: list[RegimeInterval] = trend_intervals(source, 0.1)
assert len(spans) == 1
rectangles: np.ndarray
_: list[RegimeInterval]
rectangles, _ = regime_rectangles(
spans, _chart(tuple(times)), 0.1,
)
assert len(rectangles) == 1
source['time'][4:] += 0.1
spans = trend_intervals(source, 0.1)
assert len(spans) == 2
whole: RegimeInterval = RegimeInterval(
float(times[0]), float(times[-1] + 0.3), 'uptrend',
)
rectangles, _ = regime_rectangles(
[whole], _chart(tuple(source['time'])), 0.1,
)
assert len(rectangles) == 2
@pytest.mark.parametrize('index_field', ['index', 'time'])
def test_delayed_regime_and_chart_gaps(
index_field: Literal['index', 'time'],
) -> None:
'''
A classifier may report an earlier onset after the chart has
advanced. Its rectangle must begin only at response arrival and
must not fill an absent chart bucket. Fractional arrival within
a bar, plus a 20-second hole, proves causal clipping and split
geometry on both time and absolute-index chart axes.
'''
chart: np.ndarray = _chart((100, 110, 140, 150))
span: RegimeInterval = RegimeInterval(
start_time=100,
end_time=155,
label='ranging',
probability=0.8,
source='jev',
observed_at=115,
)
rectangles: np.ndarray
metadata: list[RegimeInterval]
rectangles, metadata = regime_rectangles(
[span], chart, 10, index_field=index_field,
)
expected: list[list[float]] = (
[[1001.5, 99, 0.5, 2], [1002, 99, 1.5, 2]]
if index_field == 'index'
else [[115, 99, 5, 2], [140, 99, 15, 2]]
)
np.testing.assert_allclose(rectangles, expected)
assert metadata == [span, span]
assert metadata[0].probability == 0.8
assert metadata[0].source == 'jev'
def test_empty_and_unavailable_geometry() -> None:
'''
A layer can attach before history arrives, and late classifier
responses can lie beyond retained history. Empty chart arrays
and already-expired intervals must produce empty geometry rather
than indexing the final row or drawing a retrospective rectangle.
'''
span: RegimeInterval = RegimeInterval(10, 20, 'ranging')
empty: np.ndarray
metadata: list[RegimeInterval]
empty, metadata = regime_rectangles([span], _chart(()), 1)
assert empty.shape == (0, 4)
assert metadata == []
span = RegimeInterval(10, 20, 'ranging', observed_at=21)
empty, metadata = regime_rectangles([span], _chart((10, 20)), 1)
assert empty.shape == (0, 4)
assert metadata == []
@pytest.mark.parametrize('period', [0, -1, np.nan, np.inf])
def test_invalid_period_rejected(period: float) -> None:
'''
Invalid sampling periods break binary-search interval semantics
and can create infinite widths. All non-positive/non-finite
period classes are rejected before constructing chart geometry.
'''
with pytest.raises(ValueError, match='period'):
times_to_x(_chart((10, 20)), np.array([10]), period)
@pytest.mark.parametrize(
'market,revision,period,end_time,accepted',
[
('test.market', 3, 5, 130, True),
('other.market', 3, 5, 130, False),
('test.market', 4, 5, 130, False),
('test.market', 3, 15, 130, False),
('test.market', 3, 5, 119, False),
],
ids=['matching', 'market', 'revision', 'period', 'future'],
)
def test_jev_adapter_retains_causal_evidence(
market: str,
revision: int,
period: float,
end_time: float,
accepted: bool,
) -> None:
'''
Late or misrouted classifications must not color an unrelated
market, source revision, or timeframe. A typed Jev response
claims source evidence at 100 but arrives at 120; matching
evidence retains its original probability/model and starts at
receipt time. The rejected cases vary one ownership bound each.
'''
result: JevClassification = JevClassification(
market='test.market',
revision=3,
sequence=40,
segment=2,
source_as_of=100,
requested_at=118,
received_at=120,
latency=2,
model='jev-test',
prompt_version='test',
answers=(RegimeAnswer(
question='regime_5',
period=5,
label='range',
probability=0.82,
confidence=0.7,
probabilities=(('range', 0.82), ('unknown', 0.18)),
),),
input_tokens=100,
output_tokens=20,
)
interval: RegimeInterval|None = classification_interval(
result,
fqme=market,
revision=revision,
period_s=period,
end_time=end_time,
)
if not accepted:
assert interval is None
return
assert interval is not None
assert interval.start_time == 100
assert interval.observed_at == 120
assert interval.label == 'range'
assert interval.probability == 0.82
assert interval.source == 'typesafe.ai/jev-test'
def test_overlay_rebuild_and_owned_teardown(
qapp: QApplication,
) -> None:
'''
Equal-bound backfills rewrite earlier pivot fields without
changing the live tail. An ordinary paint gate intentionally
skips that change; a forced revision must replace stale markers
and rectangles, preserve a sibling layer, and release exactly
the owning layer's two items on repeated cancellation cleanup.
Real Qt items prove scene ownership rather than a mocked API.
'''
source: np.ndarray = _source(
(100, 110, 120, 130, 140), (1, 1, -1, -1, 1),
)
source['top_1'][1] = 105
source['top_time_1'][1] = 100
widget: pg.PlotWidget = pg.PlotWidget()
viz: SimpleNamespace = SimpleNamespace(
plot=widget.plotItem,
shm=SimpleNamespace(array=_chart((100, 110, 120, 130, 140))),
index_field='index',
)
first: TrendOverlay = TrendOverlay(
viz,
SimpleNamespace(array=source),
fqme='first.test',
source_period_s=10,
chart_period_s=10,
)
sibling: TrendOverlay = TrendOverlay(
viz,
SimpleNamespace(array=source),
fqme='second.test',
source_period_s=10,
chart_period_s=10,
)
try:
assert first.update()
assert sibling.update()
assert len(first.points) == 1
assert not first.update()
source['top_1'][1] = np.nan
source['bottom_1'][1] = 95
source['bottom_time_1'][1] = 100
assert not first.update()
assert first.update(force=True)
assert first.points[0].kind == 'bottom'
assert sibling.points[0].kind == 'top'
assert len(widget.plotItem.items) == 4
# A synthetic full-scene paint verifies Qt primitive types
# and brush batching without a GUI or screenshot dependency.
image: QtGui.QImage = QtGui.QImage(
160, 100, QtGui.QImage.Format.Format_ARGB32,
)
image.fill(0)
painter: QtGui.QPainter = QtGui.QPainter(image)
try:
widget.scene().render(painter)
finally:
painter.end()
first.remove()
first.remove()
assert len(widget.plotItem.items) == 2
assert sibling.markers in widget.plotItem.items
assert not first.update(force=True)
finally:
first.remove()
sibling.remove()
widget.close()
widget.deleteLater()
qapp.processEvents()
def test_provisional_updates_reuse_confirmed_markers(
qapp: QApplication,
) -> None:
'''
Quotes may repaint a provisional reversal repeatedly during one
bar. Rebuilding historical markers each time wastes UI time and
can present a temporary reversal as confirmed. Mutate only the
FSP tail, then the chart candle envelope: the first update is
skipped and the second updates rectangles while preserving the
same ``TrendOverlay.points`` list. Changing ``chart['index']``
requires remapping those retained points.
'''
source: np.ndarray = _source((10, 20, 30), (1, 1, -1))
source['top_1'][1] = 105
source['top_time_1'][1] = 10
widget: pg.PlotWidget = pg.PlotWidget()
chart: np.ndarray = _chart((10, 20, 30))
viz: SimpleNamespace = SimpleNamespace(
plot=widget.plotItem,
shm=SimpleNamespace(array=chart),
index_field='index',
)
overlay: TrendOverlay = TrendOverlay(
viz,
SimpleNamespace(array=source),
fqme='live.test',
source_period_s=10,
chart_period_s=10,
)
try:
assert overlay.update()
original: list[ReversalPoint] = overlay.points
source['trend'][-1] = 1
source['bottom_1'][-1] = 91
source['bottom_time_1'][-1] = 20
assert not overlay.update()
assert overlay.points is original
chart['high'][-1] = 109
assert overlay.update()
assert overlay.points is original
assert overlay.rectangles.rectangles[-1, 3] == 10
chart['high'][-1] = 100
assert overlay.update()
assert overlay.points is original
assert overlay.rectangles.rectangles[-1, 3] == 2
chart['index'] += 500
assert overlay.update()
assert overlay.points is not original
assert overlay.points[0].x == 1500
finally:
overlay.remove()
widget.close()
widget.deleteLater()
qapp.processEvents()
def test_overlay_limits_and_classifier_metadata(
qapp: QApplication,
) -> None:
'''
Unbounded event layers can allocate thousands of Qt objects and
delayed classifier results can overwrite deterministic meaning.
Many synthetic pivots exercise the configured retention limits;
replacing rectangles with a Jev result must retain probability,
source, and markers while keeping exactly two scene items.
'''
source: np.ndarray = _source(tuple(range(20)), (1, -1) * 10)
source['top_1'] = 105
source['top_time_1'] = source['time']
widget: pg.PlotWidget = pg.PlotWidget()
viz: SimpleNamespace = SimpleNamespace(
plot=widget.plotItem,
shm=SimpleNamespace(array=_chart(tuple(range(20)))),
index_field='index',
)
overlay: TrendOverlay = TrendOverlay(
viz,
SimpleNamespace(array=source),
fqme='bounded.test',
source_period_s=1,
chart_period_s=1,
max_points=3,
max_rectangles=2,
)
try:
overlay.update()
assert len(overlay.points) == 3
assert overlay.points[0].pivot_time == 16
assert len(overlay.rectangles.rectangles) == 2
assert len(widget.plotItem.items) == 2
classified: RegimeInterval = RegimeInterval(
start_time=15,
end_time=20,
label='ranging',
probability=0.73,
source='jev',
observed_at=18,
)
overlay.set_regimes([classified])
assert len(overlay.points) == 3
assert overlay.rectangles.intervals == [classified]
assert overlay.rectangles.rectangles[0, 0] == 1018
tip: str = overlay.rectangles.tooltip_at(QPointF(1019, 100))
assert 'ranging: 73.0%' in tip
assert '(1s)' in tip
assert 'jev' in tip
assert '00:00:18.000' in tip
overlay.set_regimes([])
assert len(overlay.rectangles.rectangles) == 0
overlay.set_regimes(None)
assert len(overlay.rectangles.rectangles) == 2
tip = overlay.rectangles.tooltip_at(QPointF(1019, 100))
assert 'trend-calculus' in tip
assert '%' not in tip
finally:
overlay.remove()
widget.close()
widget.deleteLater()
qapp.processEvents()
class _PriceChart:
'''
Expose the price-chart API while retaining actual Qt ownership.
'''
_trend_overlays: dict[tuple[str, str], list[TrendOverlay]]
def __init__(self) -> None:
'''
Build a chart whose SHM token identifies its base timeframe.
'''
self.widget: pg.PlotWidget = pg.PlotWidget()
self.viz: SimpleNamespace = SimpleNamespace(
plot=self.widget.plotItem,
shm=SimpleNamespace(
array=_chart((10, 20, 30, 40, 50)),
_token='ohlcv',
),
index_field='index',
)
def get_viz(self, fqme: str) -> SimpleNamespace|None:
'''
Resolve exactly the test market rather than any chart key.
'''
return self.viz if fqme == 'admin.test' else None
def close(self) -> None:
'''
Release the disposable offscreen widget after its layers.
'''
self.widget.close()
self.widget.deleteLater()
async def _admin_bootstrap_scenario(
qapp: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
'''
Attaching before FSP bootstrap exposes empty history, and opening
several reversal orders must not start multiple SHM writers.
The ``attach_first()`` task calls ``FspAdmin.open_trend_overlay``
while the mocked engine's ``started`` event stays unset.
``wait_all_tasks_blocked()`` reaches that wait deterministically;
one engine call and an empty Qt plot rule out early attachment.
Only setting ``started`` lets ``attached`` confirm publication.
Reopening another order retains one writer, and repeated
``FspAdmin.close_trend_overlays()`` preserves a sibling's items.
This exercises local lifecycle ordering, not actor IPC.
'''
chart: _PriceChart = _PriceChart()
source: np.ndarray = _source((10, 20, 30, 40, 50), (1,) * 5)
source['trend_2'] = 1
destination: SimpleNamespace = SimpleNamespace(
rt_shm=SimpleNamespace(array=source, _token='trend'),
)
flume: SimpleNamespace = SimpleNamespace(
mkt=SimpleNamespace(fqme='admin.test'),
rt_shm=chart.viz.shm,
rt_sample_period_s=10,
_hist_shm_token=None,
)
started: trio.Event = trio.Event()
attached: trio.Event = trio.Event()
calls: list[tuple[str, dict, str]] = []
attached_layers: list[TrendOverlay] = []
async def start_engine(
target: Fsp,
conf: dict,
loglevel: str,
) -> tuple[SimpleNamespace, trio.Event]:
'''
Return an allocated destination before publishing bootstrap.
'''
calls.append((target.name, conf, loglevel))
return destination, started
try:
nursery: trio.Nursery
async with trio.open_nursery() as nursery:
admin: FspAdmin = FspAdmin(
nursery, {}, SimpleNamespace(chart=chart), flume,
)
monkeypatch.setattr(
admin, 'start_engine_task', start_engine,
)
with pytest.raises(ValueError, match='positive'):
await admin.open_trend_overlay(chart_period_s=0)
assert calls == []
async def attach_first() -> None:
'''
Signal only after startup has produced visible data.
'''
layer: TrendOverlay = (
await admin.open_trend_overlay()
)
attached_layers.append(layer)
attached.set()
nursery.start_soon(attach_first)
await wait_all_tasks_blocked()
assert len(calls) == 1
assert not attached.is_set()
assert chart.widget.plotItem.items == []
started.set()
await attached.wait()
first: TrendOverlay = attached_layers[0]
second: TrendOverlay = await admin.open_trend_overlay(
order=2,
)
assert await admin.open_trend_overlay() is first
with pytest.raises(ValueError, match='period differs'):
await admin.open_trend_overlay(chart_period_s=60)
assert second is not first
assert len(calls) == 1
assert calls[0][0] == 'trend_calculus'
assert chart._trend_overlays == {
('admin.test', 'trend'): [first, second],
}
assert len(chart.widget.plotItem.items) == 4
sibling: TrendOverlay = TrendOverlay(
chart.viz,
SimpleNamespace(array=source, _token='other'),
fqme='other.test',
source_period_s=10,
chart_period_s=10,
)
foreign_key: tuple[str, str] = ('other.test', 'other')
chart._trend_overlays[foreign_key] = [sibling]
try:
admin.close_trend_overlays()
admin.close_trend_overlays()
assert len(chart.widget.plotItem.items) == 2
assert chart._trend_overlays == {
('other.test', 'other'): [sibling],
}
finally:
sibling.remove()
finally:
chart.close()
qapp.processEvents()
def test_admin_waits_for_bootstrap_and_reuses_writer(
qapp: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
'''
Exercise bootstrap publication and owner cleanup in local Trio.
``_admin_bootstrap_scenario()`` controls its ``started`` event
and waits for ``attach_first()`` to block in
``FspAdmin.open_trend_overlay()`` before inspecting the plot.
The unset ``attached`` event and empty plot prove attachment
waits for bootstrap rather than depending on timer luck. A
second order shares the writer; cleanup retains a sibling's
``_PriceChart._trend_overlays`` entry and its two Qt items.
'''
trio.run(_admin_bootstrap_scenario, qapp, monkeypatch)
def test_registry_filters_and_defers_hidden_revisions(
qapp: QApplication,
) -> None:
'''
A destination rewrite while hidden must survive an unchanged
source-tail paint signature when the chart becomes visible.
An equal-bound earlier pivot edit arranges that case. Wrong
market/token/timeframe invalidations leave the layer alone;
a matching hidden notification invalidates without touching Qt
data, and the next ordinary refresh consumes the corrected pivot.
'''
chart: _PriceChart = _PriceChart()
source: np.ndarray = _source((10, 20, 30, 40, 50), (1,) * 5)
source['top_1'][1] = 105
source['top_time_1'][1] = 10
overlay: TrendOverlay = TrendOverlay(
chart.viz,
SimpleNamespace(array=source, _token='trend'),
fqme='admin.test',
source_period_s=10,
chart_period_s=10,
)
chart._trend_overlays = {('admin.test', 'trend'): [overlay]}
try:
assert update_trend_overlays(chart) == 1
original: list[ReversalPoint] = overlay.points
source['top_1'][1] = 109
assert update_trend_overlays(
chart, fqme='other.test', force=True,
) == 0
assert update_trend_overlays(
chart, shm_token='other', force=True,
) == 0
assert update_trend_overlays(
chart, chart_period_s=60, force=True,
) == 0
assert update_trend_overlays(chart) == 0
assert overlay.points is original
admin: FspAdmin = FspAdmin(
None, {}, SimpleNamespace(chart=chart), None,
)
admin._trend_overlays.append((chart, overlay))
admin.refresh_trend_overlays('trend', render=False)
assert overlay.points is original
assert overlay.points[0].price == 105
assert update_trend_overlays(chart) == 1
assert overlay.points[0].price == 109
finally:
overlay.remove()
chart.close()
qapp.processEvents()
def test_admin_context_exit_releases_layers(
qapp: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
'''
A finished FSP-admin context must not leave its graphics attached
to a reused chart widget. Replace only actor-cluster startup and
engine allocation; ``open_fsp_admin()`` and its Trio nursery run
normal exit with actual Qt items. Empty
``FspAdmin._trend_overlays`` and ``_PriceChart._trend_overlays``
plus the signalled ``complete`` event from
``FspAdmin._registry`` prove graphics teardown and existing
engine shutdown notification both run. This local stub does not
prove remote actor shutdown or shared-memory visibility.
'''
chart: _PriceChart = _PriceChart()
@asynccontextmanager
async def cluster_context(
**kwargs: object,
) -> AsyncIterator[tuple[bool, dict]]:
'''
Supply a local cluster stub without spawning actor processes.
'''
yield False, {}
monkeypatch.setattr(
fsp_ui, 'maybe_open_context', cluster_context,
)
async def scenario() -> None:
'''
Observe cleanup after the actual admin context has exited.
'''
flume: SimpleNamespace = SimpleNamespace(
mkt=SimpleNamespace(fqme='admin.test'),
rt_shm=chart.viz.shm,
rt_sample_period_s=10,
_hist_shm_token=None,
)
source: np.ndarray = _source(
(10, 20, 30, 40, 50), (1,) * 5,
)
started: trio.Event = trio.Event()
started.set()
complete: trio.Event = trio.Event()
async def start_engine(
target: Fsp,
conf: dict,
loglevel: str,
) -> tuple[SimpleNamespace, trio.Event]:
'''
Return an already published, test-owned FSP destination.
'''
return SimpleNamespace(rt_shm=SimpleNamespace(
array=source,
_token='trend',
)), started
admin: FspAdmin
async with fsp_ui.open_fsp_admin(
SimpleNamespace(chart=chart), flume,
) as admin:
monkeypatch.setattr(
admin, 'start_engine_task', start_engine,
)
admin._registry[('test',)] = (None, None, complete)
await admin.open_trend_overlay()
assert len(chart.widget.plotItem.items) == 2
assert chart.widget.plotItem.items == []
assert chart._trend_overlays == {}
assert admin._trend_overlays == []
assert complete.is_set()
try:
trio.run(scenario)
finally:
chart.close()
qapp.processEvents()