# 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 # . ''' 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()