Own gap overlays in chart-local `GapOverlayMngr`
Detect positive timestamp gaps from chart OHLCV SHM with one
vectorized pass. Route local UI and remote callers through one
`GapOverlayMngr` renderer, and key each layer by owner, chart,
FQME and timeframe so remote ctx teardown cannot remove local
graphics.
Deats,
- add tagged `SetGapOverlay` requests and typed
`GapOverlay` responses;
- keep `remote_annotate()` intact and serve its typed dialog from
a dedicated `remote_gap_overlays()` Tractor ctx;
- admit `SetGapOverlay`, `GapOverlay`, `list[str]` and `None` in
the Tractor payload spec, then strictly narrow requests to
`SetGapOverlay` and replies to `GapOverlay`;
- serialize shared-stream dialogs and correlate request IDs so
stale replies cannot skew later exchanges;
- rename `AnnotCtl` / `open_annot_ctl()` to `AnnotClient` /
`open_annot_client()` across storage and annotation callers;
- enable 60s history overlays at startup and toggle the focused
1s or 60s chart directly with `Ctrl+G`;
- guard redraws by chart, FQME and timeframe; tolerate empty
arrays and out-of-range lookups; update rect and arrow geometry;
- cover detector geometry, msgpack roundtrips, schema rejection,
typed stale-FQME errors and renamed storage seams;
- document ownership, refresh, stack landing and live
qualification.
Prompt-IO: ai/prompt-io/opencode/20260814T032716Z_2aa66c2c_prompt_io.md
(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-08-14 23:03:11 +00:00
|
|
|
'''
|
|
|
|
|
Typed chart-local gap-overlay regressions.
|
|
|
|
|
|
|
|
|
|
'''
|
2026-08-17 23:11:03 +00:00
|
|
|
from collections.abc import (
|
|
|
|
|
Callable,
|
|
|
|
|
Iterator,
|
|
|
|
|
)
|
|
|
|
|
from contextlib import AsyncExitStack
|
2026-08-17 23:08:02 +00:00
|
|
|
import os
|
|
|
|
|
from types import SimpleNamespace
|
|
|
|
|
|
|
|
|
|
os.environ['QT_QPA_PLATFORM'] = 'offscreen'
|
|
|
|
|
|
Own gap overlays in chart-local `GapOverlayMngr`
Detect positive timestamp gaps from chart OHLCV SHM with one
vectorized pass. Route local UI and remote callers through one
`GapOverlayMngr` renderer, and key each layer by owner, chart,
FQME and timeframe so remote ctx teardown cannot remove local
graphics.
Deats,
- add tagged `SetGapOverlay` requests and typed
`GapOverlay` responses;
- keep `remote_annotate()` intact and serve its typed dialog from
a dedicated `remote_gap_overlays()` Tractor ctx;
- admit `SetGapOverlay`, `GapOverlay`, `list[str]` and `None` in
the Tractor payload spec, then strictly narrow requests to
`SetGapOverlay` and replies to `GapOverlay`;
- serialize shared-stream dialogs and correlate request IDs so
stale replies cannot skew later exchanges;
- rename `AnnotCtl` / `open_annot_ctl()` to `AnnotClient` /
`open_annot_client()` across storage and annotation callers;
- enable 60s history overlays at startup and toggle the focused
1s or 60s chart directly with `Ctrl+G`;
- guard redraws by chart, FQME and timeframe; tolerate empty
arrays and out-of-range lookups; update rect and arrow geometry;
- cover detector geometry, msgpack roundtrips, schema rejection,
typed stale-FQME errors and renamed storage seams;
- document ownership, refresh, stack landing and live
qualification.
Prompt-IO: ai/prompt-io/opencode/20260814T032716Z_2aa66c2c_prompt_io.md
(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-08-14 23:03:11 +00:00
|
|
|
import msgspec
|
|
|
|
|
import numpy as np
|
2026-08-17 23:09:46 +00:00
|
|
|
from PyQt6.QtGui import QKeyEvent
|
2026-08-17 23:08:28 +00:00
|
|
|
from PyQt6.QtWidgets import QGraphicsScene
|
2026-08-17 23:08:02 +00:00
|
|
|
import pyqtgraph as pg
|
Own gap overlays in chart-local `GapOverlayMngr`
Detect positive timestamp gaps from chart OHLCV SHM with one
vectorized pass. Route local UI and remote callers through one
`GapOverlayMngr` renderer, and key each layer by owner, chart,
FQME and timeframe so remote ctx teardown cannot remove local
graphics.
Deats,
- add tagged `SetGapOverlay` requests and typed
`GapOverlay` responses;
- keep `remote_annotate()` intact and serve its typed dialog from
a dedicated `remote_gap_overlays()` Tractor ctx;
- admit `SetGapOverlay`, `GapOverlay`, `list[str]` and `None` in
the Tractor payload spec, then strictly narrow requests to
`SetGapOverlay` and replies to `GapOverlay`;
- serialize shared-stream dialogs and correlate request IDs so
stale replies cannot skew later exchanges;
- rename `AnnotCtl` / `open_annot_ctl()` to `AnnotClient` /
`open_annot_client()` across storage and annotation callers;
- enable 60s history overlays at startup and toggle the focused
1s or 60s chart directly with `Ctrl+G`;
- guard redraws by chart, FQME and timeframe; tolerate empty
arrays and out-of-range lookups; update rect and arrow geometry;
- cover detector geometry, msgpack roundtrips, schema rejection,
typed stale-FQME errors and renamed storage seams;
- document ownership, refresh, stack landing and live
qualification.
Prompt-IO: ai/prompt-io/opencode/20260814T032716Z_2aa66c2c_prompt_io.md
(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-08-14 23:03:11 +00:00
|
|
|
import pytest
|
2026-08-17 23:11:03 +00:00
|
|
|
import tractor
|
|
|
|
|
from tractor._testing import tractor_test
|
2026-08-17 23:09:46 +00:00
|
|
|
import trio
|
|
|
|
|
from trio.testing import wait_all_tasks_blocked
|
Own gap overlays in chart-local `GapOverlayMngr`
Detect positive timestamp gaps from chart OHLCV SHM with one
vectorized pass. Route local UI and remote callers through one
`GapOverlayMngr` renderer, and key each layer by owner, chart,
FQME and timeframe so remote ctx teardown cannot remove local
graphics.
Deats,
- add tagged `SetGapOverlay` requests and typed
`GapOverlay` responses;
- keep `remote_annotate()` intact and serve its typed dialog from
a dedicated `remote_gap_overlays()` Tractor ctx;
- admit `SetGapOverlay`, `GapOverlay`, `list[str]` and `None` in
the Tractor payload spec, then strictly narrow requests to
`SetGapOverlay` and replies to `GapOverlay`;
- serialize shared-stream dialogs and correlate request IDs so
stale replies cannot skew later exchanges;
- rename `AnnotCtl` / `open_annot_ctl()` to `AnnotClient` /
`open_annot_client()` across storage and annotation callers;
- enable 60s history overlays at startup and toggle the focused
1s or 60s chart directly with `Ctrl+G`;
- guard redraws by chart, FQME and timeframe; tolerate empty
arrays and out-of-range lookups; update rect and arrow geometry;
- cover detector geometry, msgpack roundtrips, schema rejection,
typed stale-FQME errors and renamed storage seams;
- document ownership, refresh, stack landing and live
qualification.
Prompt-IO: ai/prompt-io/opencode/20260814T032716Z_2aa66c2c_prompt_io.md
(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-08-14 23:03:11 +00:00
|
|
|
|
2026-08-17 23:08:02 +00:00
|
|
|
from piker.ui._annotate import GapAnnotations
|
2026-08-17 23:09:46 +00:00
|
|
|
from piker.ui._display import _register_gap_overlays
|
Own gap overlays in chart-local `GapOverlayMngr`
Detect positive timestamp gaps from chart OHLCV SHM with one
vectorized pass. Route local UI and remote callers through one
`GapOverlayMngr` renderer, and key each layer by owner, chart,
FQME and timeframe so remote ctx teardown cannot remove local
graphics.
Deats,
- add tagged `SetGapOverlay` requests and typed
`GapOverlay` responses;
- keep `remote_annotate()` intact and serve its typed dialog from
a dedicated `remote_gap_overlays()` Tractor ctx;
- admit `SetGapOverlay`, `GapOverlay`, `list[str]` and `None` in
the Tractor payload spec, then strictly narrow requests to
`SetGapOverlay` and replies to `GapOverlay`;
- serialize shared-stream dialogs and correlate request IDs so
stale replies cannot skew later exchanges;
- rename `AnnotCtl` / `open_annot_ctl()` to `AnnotClient` /
`open_annot_client()` across storage and annotation callers;
- enable 60s history overlays at startup and toggle the focused
1s or 60s chart directly with `Ctrl+G`;
- guard redraws by chart, FQME and timeframe; tolerate empty
arrays and out-of-range lookups; update rect and arrow geometry;
- cover detector geometry, msgpack roundtrips, schema rejection,
typed stale-FQME errors and renamed storage seams;
- document ownership, refresh, stack landing and live
qualification.
Prompt-IO: ai/prompt-io/opencode/20260814T032716Z_2aa66c2c_prompt_io.md
(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-08-14 23:03:11 +00:00
|
|
|
from piker.ui._gaps import (
|
|
|
|
|
GapOverlay,
|
|
|
|
|
GapOverlayMngr,
|
|
|
|
|
GapOverlayPayload,
|
|
|
|
|
GapSpec,
|
|
|
|
|
SetGapOverlay,
|
|
|
|
|
gap_specs_from_ohlcv,
|
|
|
|
|
)
|
2026-08-17 23:09:46 +00:00
|
|
|
from piker.ui._interaction import (
|
|
|
|
|
ChartView,
|
|
|
|
|
_toggle_gap_overlays,
|
|
|
|
|
)
|
2026-08-17 23:08:28 +00:00
|
|
|
from piker.ui.qt import (
|
|
|
|
|
QApplication,
|
2026-08-17 23:09:46 +00:00
|
|
|
QEvent,
|
2026-08-17 23:08:28 +00:00
|
|
|
QPointF,
|
|
|
|
|
QRectF,
|
2026-08-17 23:09:46 +00:00
|
|
|
Qt,
|
|
|
|
|
QWidget,
|
2026-08-17 23:08:28 +00:00
|
|
|
)
|
2026-08-17 23:08:02 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
FQME: str = 'gap.test'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _ChartStub:
|
|
|
|
|
'''
|
|
|
|
|
Real Qt plot with the small chart API required by `gapman`.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
def __init__(
|
|
|
|
|
self,
|
|
|
|
|
fqme: str,
|
|
|
|
|
array: np.ndarray,
|
|
|
|
|
|
|
|
|
|
) -> None:
|
|
|
|
|
self.fqme: str = fqme
|
|
|
|
|
self.widget: pg.PlotWidget = pg.PlotWidget()
|
|
|
|
|
self.viz: SimpleNamespace = SimpleNamespace(
|
|
|
|
|
plot=self.widget.plotItem,
|
|
|
|
|
shm=SimpleNamespace(array=array),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def get_viz(
|
|
|
|
|
self,
|
|
|
|
|
fqme: str,
|
|
|
|
|
|
|
|
|
|
) -> SimpleNamespace:
|
|
|
|
|
'''
|
|
|
|
|
Return this chart's only viz.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
assert fqme == self.fqme
|
|
|
|
|
return self.viz
|
|
|
|
|
|
|
|
|
|
def close(self) -> None:
|
|
|
|
|
'''
|
|
|
|
|
Release this test-owned plot widget.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
self.widget.close()
|
|
|
|
|
self.widget.deleteLater()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ohlcv_array(
|
|
|
|
|
times: tuple[float, ...],
|
|
|
|
|
|
|
|
|
|
) -> np.ndarray:
|
|
|
|
|
'''
|
|
|
|
|
Build the minimal structured OHLCV used by gap rendering.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
dtype: np.dtype = np.dtype([
|
|
|
|
|
('index', 'i8'),
|
|
|
|
|
('time', 'f8'),
|
|
|
|
|
('open', 'f8'),
|
|
|
|
|
('close', 'f8'),
|
|
|
|
|
])
|
|
|
|
|
rows: list[tuple[int, float, float, float]] = []
|
|
|
|
|
i: int
|
|
|
|
|
time_s: float
|
|
|
|
|
for i, time_s in enumerate(times):
|
|
|
|
|
price: float = 100 + i
|
|
|
|
|
rows.append((i, time_s, price, price + 0.5))
|
|
|
|
|
|
|
|
|
|
return np.array(rows, dtype=dtype)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _display_state(
|
|
|
|
|
fqme: str,
|
|
|
|
|
|
|
|
|
|
) -> tuple[
|
|
|
|
|
SimpleNamespace,
|
|
|
|
|
tuple[_ChartStub, _ChartStub],
|
|
|
|
|
]:
|
|
|
|
|
'''
|
|
|
|
|
Build independent realtime and history chart stubs.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
rt_chart: _ChartStub = _ChartStub(
|
|
|
|
|
fqme,
|
|
|
|
|
_ohlcv_array((1, 2, 4, 5)),
|
|
|
|
|
)
|
|
|
|
|
hist_chart: _ChartStub = _ChartStub(
|
|
|
|
|
fqme,
|
|
|
|
|
_ohlcv_array((60, 120, 300, 360)),
|
|
|
|
|
)
|
|
|
|
|
ds: SimpleNamespace = SimpleNamespace(
|
|
|
|
|
fqme=fqme,
|
|
|
|
|
chart=rt_chart,
|
|
|
|
|
viz=rt_chart.viz,
|
|
|
|
|
hist_chart=hist_chart,
|
|
|
|
|
hist_viz=hist_chart.viz,
|
|
|
|
|
)
|
|
|
|
|
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()
|
Own gap overlays in chart-local `GapOverlayMngr`
Detect positive timestamp gaps from chart OHLCV SHM with one
vectorized pass. Route local UI and remote callers through one
`GapOverlayMngr` renderer, and key each layer by owner, chart,
FQME and timeframe so remote ctx teardown cannot remove local
graphics.
Deats,
- add tagged `SetGapOverlay` requests and typed
`GapOverlay` responses;
- keep `remote_annotate()` intact and serve its typed dialog from
a dedicated `remote_gap_overlays()` Tractor ctx;
- admit `SetGapOverlay`, `GapOverlay`, `list[str]` and `None` in
the Tractor payload spec, then strictly narrow requests to
`SetGapOverlay` and replies to `GapOverlay`;
- serialize shared-stream dialogs and correlate request IDs so
stale replies cannot skew later exchanges;
- rename `AnnotCtl` / `open_annot_ctl()` to `AnnotClient` /
`open_annot_client()` across storage and annotation callers;
- enable 60s history overlays at startup and toggle the focused
1s or 60s chart directly with `Ctrl+G`;
- guard redraws by chart, FQME and timeframe; tolerate empty
arrays and out-of-range lookups; update rect and arrow geometry;
- cover detector geometry, msgpack roundtrips, schema rejection,
typed stale-FQME errors and renamed storage seams;
- document ownership, refresh, stack landing and live
qualification.
Prompt-IO: ai/prompt-io/opencode/20260814T032716Z_2aa66c2c_prompt_io.md
(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-08-14 23:03:11 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_gap_specs_and_wire_roundtrip() -> None:
|
|
|
|
|
'''
|
|
|
|
|
Local detection and remote IPC share one typed request model.
|
|
|
|
|
|
|
|
|
|
Build a small sorted OHLCV array with one positive timestamp gap,
|
|
|
|
|
derive its geometry without a per-row dataframe scan, and
|
|
|
|
|
roundtrip the request/reply through msgpack. This proves callers
|
|
|
|
|
invoke one chart renderer with one concrete schema.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
dtype: np.dtype = np.dtype([
|
|
|
|
|
('index', 'i8'),
|
|
|
|
|
('time', 'f8'),
|
|
|
|
|
('open', 'f8'),
|
|
|
|
|
('close', 'f8'),
|
|
|
|
|
])
|
|
|
|
|
array: np.ndarray = np.array([
|
|
|
|
|
(10, 60, 100, 101),
|
|
|
|
|
(11, 120, 101, 102),
|
|
|
|
|
(12, 300, 95, 96),
|
|
|
|
|
(13, 360, 96, 97),
|
|
|
|
|
], dtype=dtype)
|
|
|
|
|
|
|
|
|
|
specs: list[GapSpec] = gap_specs_from_ohlcv(
|
|
|
|
|
array,
|
|
|
|
|
period_s=60,
|
|
|
|
|
)
|
|
|
|
|
assert len(specs) == 1
|
|
|
|
|
spec: GapSpec = specs[0]
|
|
|
|
|
assert spec.start_time == 120
|
|
|
|
|
assert spec.end_time == 300
|
|
|
|
|
assert spec.start_pos == (11.9, 102)
|
|
|
|
|
assert spec.end_pos == (12.1, 95)
|
|
|
|
|
assert spec.pointing == 'down'
|
|
|
|
|
|
|
|
|
|
req: SetGapOverlay = SetGapOverlay(
|
|
|
|
|
fqme='mnq.cme.ib',
|
|
|
|
|
timeframe=60,
|
|
|
|
|
specs=specs,
|
|
|
|
|
request_id='req-616',
|
|
|
|
|
)
|
|
|
|
|
req_wire: bytes = msgspec.msgpack.encode(req)
|
|
|
|
|
assert msgspec.msgpack.decode(
|
|
|
|
|
req_wire,
|
|
|
|
|
type=SetGapOverlay,
|
|
|
|
|
) == req
|
|
|
|
|
|
|
|
|
|
resp: GapOverlay = GapOverlay(
|
|
|
|
|
fqme=req.fqme,
|
|
|
|
|
timeframe=req.timeframe,
|
|
|
|
|
visible=True,
|
|
|
|
|
gap_count=1,
|
|
|
|
|
aid=616,
|
|
|
|
|
request_id=req.request_id,
|
|
|
|
|
)
|
|
|
|
|
resp_wire: bytes = msgspec.msgpack.encode(resp)
|
|
|
|
|
assert msgspec.msgpack.decode(
|
|
|
|
|
resp_wire,
|
|
|
|
|
type=GapOverlay,
|
|
|
|
|
) == resp
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_gap_overlay_payload_rejects_other_msgs() -> None:
|
|
|
|
|
'''
|
|
|
|
|
Reject payloads outside the strict gap-overlay dialog protocol.
|
|
|
|
|
|
|
|
|
|
The sibling Tractor context installs `GapOverlayPayload` so
|
|
|
|
|
legacy annotation dicts cannot enter its stream as partially
|
|
|
|
|
validated commands. Encode one such dict and prove the same
|
|
|
|
|
msgspec decoder rejects it before endpoint dispatch.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
invalid_wire: bytes = msgspec.msgpack.encode({
|
|
|
|
|
'cmd': 'SelectRect',
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
with pytest.raises(msgspec.ValidationError):
|
|
|
|
|
msgspec.msgpack.decode(
|
|
|
|
|
invalid_wire,
|
|
|
|
|
type=GapOverlayPayload,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
req: SetGapOverlay = SetGapOverlay(
|
|
|
|
|
fqme='mnq.cme.ib',
|
|
|
|
|
timeframe=60,
|
|
|
|
|
specs=[],
|
|
|
|
|
)
|
|
|
|
|
resp: GapOverlay = GapOverlay(
|
|
|
|
|
fqme=req.fqme,
|
|
|
|
|
timeframe=req.timeframe,
|
|
|
|
|
visible=False,
|
|
|
|
|
gap_count=0,
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises(msgspec.ValidationError):
|
|
|
|
|
msgspec.msgpack.decode(
|
|
|
|
|
msgspec.msgpack.encode(resp),
|
|
|
|
|
type=SetGapOverlay,
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises(msgspec.ValidationError):
|
|
|
|
|
msgspec.msgpack.decode(
|
|
|
|
|
msgspec.msgpack.encode(req),
|
|
|
|
|
type=GapOverlay,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_gap_overlay_unknown_fqme_returns_typed_error() -> None:
|
|
|
|
|
'''
|
|
|
|
|
Keep a stale remote FQME request inside the typed dialog.
|
|
|
|
|
|
|
|
|
|
A chart can unload or replace display state after a client learns
|
|
|
|
|
its FQME set. Submit a valid request to an empty controller and
|
|
|
|
|
prove it returns `GapOverlay.error` instead of leaking a
|
|
|
|
|
`KeyError` which would terminate the whole Tractor context.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
gapman: GapOverlayMngr = GapOverlayMngr(
|
|
|
|
|
dss={},
|
|
|
|
|
annots={},
|
|
|
|
|
)
|
|
|
|
|
req: SetGapOverlay = SetGapOverlay(
|
|
|
|
|
fqme='gone.test',
|
|
|
|
|
timeframe=60,
|
|
|
|
|
specs=[],
|
|
|
|
|
request_id='req-stale',
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
resp: GapOverlay = gapman.apply(req)
|
|
|
|
|
|
|
|
|
|
assert resp.request_id == req.request_id
|
|
|
|
|
assert resp.error == 'No display state for fqme=gone.test'
|
2026-08-17 23:08:02 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_gap_manager_real_qt_lifecycle(
|
|
|
|
|
qapp: QApplication,
|
|
|
|
|
|
|
|
|
|
) -> None:
|
|
|
|
|
'''
|
|
|
|
|
Remove overlays from every PyQtGraph registry.
|
|
|
|
|
|
|
|
|
|
Gap layers are inserted through `PlotItem.addItem()`. Removing
|
|
|
|
|
the item directly from its scene leaves stale `PlotItem.items`
|
|
|
|
|
and `ViewBox.addedItems` refs which can leak memory and affect
|
|
|
|
|
range calculations. This test uses real offscreen Qt plots,
|
|
|
|
|
replaces one remotely owned layer, hides it, and proves each
|
|
|
|
|
registry releases the old graphics object.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
ds: SimpleNamespace
|
|
|
|
|
charts: tuple[_ChartStub, _ChartStub]
|
|
|
|
|
ds, charts = _display_state(FQME)
|
|
|
|
|
annots: dict[int, GapAnnotations] = {}
|
|
|
|
|
gapman: GapOverlayMngr = GapOverlayMngr(
|
|
|
|
|
dss={FQME: ds},
|
|
|
|
|
annots=annots,
|
|
|
|
|
)
|
|
|
|
|
specs: list[GapSpec] = gap_specs_from_ohlcv(
|
|
|
|
|
ds.hist_viz.shm.array,
|
|
|
|
|
period_s=60,
|
|
|
|
|
)
|
|
|
|
|
aids: set[int] = set()
|
|
|
|
|
req: SetGapOverlay = SetGapOverlay(
|
|
|
|
|
fqme=FQME,
|
|
|
|
|
timeframe=60,
|
|
|
|
|
specs=specs,
|
|
|
|
|
request_id='first',
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
first: GapOverlay = gapman.apply(
|
|
|
|
|
req=req,
|
|
|
|
|
owner='remote-test',
|
|
|
|
|
aids=aids,
|
|
|
|
|
)
|
|
|
|
|
assert first.aid is not None
|
|
|
|
|
first_item: GapAnnotations = annots[first.aid]
|
|
|
|
|
assert first_item.scene() is charts[1].widget.scene()
|
|
|
|
|
assert first_item in ds.hist_viz.plot.items
|
|
|
|
|
assert first_item not in ds.hist_viz.plot.vb.addedItems
|
|
|
|
|
assert aids == {first.aid}
|
|
|
|
|
|
|
|
|
|
second: GapOverlay = gapman.apply(
|
|
|
|
|
req=SetGapOverlay(
|
|
|
|
|
fqme=FQME,
|
|
|
|
|
timeframe=60,
|
|
|
|
|
specs=specs,
|
|
|
|
|
request_id='second',
|
|
|
|
|
),
|
|
|
|
|
owner='remote-test',
|
|
|
|
|
aids=aids,
|
|
|
|
|
)
|
|
|
|
|
assert second.aid is not None
|
|
|
|
|
assert second.aid != first.aid
|
|
|
|
|
assert first.aid not in annots
|
|
|
|
|
assert first_item.scene() is None
|
|
|
|
|
assert first_item not in ds.hist_viz.plot.items
|
|
|
|
|
assert first_item not in ds.hist_viz.plot.vb.addedItems
|
|
|
|
|
assert aids == {second.aid}
|
|
|
|
|
|
|
|
|
|
hidden: GapOverlay = gapman.apply(
|
|
|
|
|
req=SetGapOverlay(
|
|
|
|
|
fqme=FQME,
|
|
|
|
|
timeframe=60,
|
|
|
|
|
specs=specs,
|
|
|
|
|
visible=False,
|
|
|
|
|
request_id='hidden',
|
|
|
|
|
),
|
|
|
|
|
owner='remote-test',
|
|
|
|
|
aids=aids,
|
|
|
|
|
)
|
|
|
|
|
assert hidden.visible is False
|
|
|
|
|
assert hidden.aid is None
|
|
|
|
|
assert aids == set()
|
|
|
|
|
assert annots == {}
|
|
|
|
|
assert gapman._layers == {}
|
|
|
|
|
assert gapman._plots == {}
|
|
|
|
|
item: object
|
|
|
|
|
for item in ds.hist_viz.plot.items:
|
|
|
|
|
assert not isinstance(item, GapAnnotations)
|
|
|
|
|
|
|
|
|
|
local: GapOverlay = gapman.apply(
|
|
|
|
|
req=SetGapOverlay(
|
|
|
|
|
fqme=FQME,
|
|
|
|
|
timeframe=60,
|
|
|
|
|
specs=specs,
|
|
|
|
|
request_id='local',
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
remote: GapOverlay = gapman.apply(
|
|
|
|
|
req=SetGapOverlay(
|
|
|
|
|
fqme=FQME,
|
|
|
|
|
timeframe=60,
|
|
|
|
|
specs=specs,
|
|
|
|
|
request_id='remote',
|
|
|
|
|
),
|
|
|
|
|
owner='remote-test',
|
|
|
|
|
)
|
|
|
|
|
assert local.aid is not None
|
|
|
|
|
assert remote.aid is not None
|
|
|
|
|
gapman.remove_owner('remote-test')
|
|
|
|
|
assert local.aid in annots
|
|
|
|
|
assert remote.aid not in annots
|
|
|
|
|
assert annots[local.aid].scene() is not None
|
|
|
|
|
gapman.remove_owner('chart-local')
|
|
|
|
|
assert annots == {}
|
|
|
|
|
finally:
|
|
|
|
|
chart: _ChartStub
|
|
|
|
|
for chart in charts:
|
|
|
|
|
chart.close()
|
|
|
|
|
qapp.processEvents()
|
|
|
|
|
|
|
|
|
|
|
2026-08-17 23:08:28 +00:00
|
|
|
def test_gap_annotations_reposition_after_prepend(
|
|
|
|
|
qapp: QApplication,
|
|
|
|
|
|
|
|
|
|
) -> None:
|
|
|
|
|
'''
|
|
|
|
|
Reposition live gap geometry after history index offsets change.
|
|
|
|
|
|
|
|
|
|
A history prepend preserves timestamps but shifts every absolute
|
|
|
|
|
chart index. Gap rectangles and arrows otherwise remain attached
|
|
|
|
|
to their pre-prepend x coordinates. Render one real gap item,
|
|
|
|
|
shift its source indexes by ten, call `reposition()`, and prove
|
|
|
|
|
its rectangle bounds and arrow spec move together while the item
|
|
|
|
|
stays attached to its real Qt scene.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
ds: SimpleNamespace
|
|
|
|
|
charts: tuple[_ChartStub, _ChartStub]
|
|
|
|
|
ds, charts = _display_state(FQME)
|
|
|
|
|
annots: dict[int, GapAnnotations] = {}
|
|
|
|
|
gapman: GapOverlayMngr = GapOverlayMngr(
|
|
|
|
|
dss={FQME: ds},
|
|
|
|
|
annots=annots,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
gap: GapOverlay = gapman.refresh(
|
|
|
|
|
ds=ds,
|
|
|
|
|
timeframe=60,
|
|
|
|
|
)
|
|
|
|
|
assert gap.aid is not None
|
|
|
|
|
item: GapAnnotations = annots[gap.aid]
|
|
|
|
|
old_br: QRectF = item.boundingRect()
|
|
|
|
|
scene: QGraphicsScene = charts[1].widget.scene()
|
|
|
|
|
old_scene_point: QPointF = item.mapToScene(
|
|
|
|
|
old_br.center()
|
|
|
|
|
)
|
|
|
|
|
qapp.processEvents()
|
|
|
|
|
assert item in scene.items(old_scene_point)
|
|
|
|
|
old_rects: np.ndarray = (
|
|
|
|
|
item._rectarray.ndarray().copy()
|
|
|
|
|
)
|
|
|
|
|
shifted: np.ndarray = ds.hist_viz.shm.array.copy()
|
|
|
|
|
shifted['index'] += 10
|
|
|
|
|
|
|
|
|
|
item.reposition(
|
|
|
|
|
array=shifted,
|
|
|
|
|
fqme=FQME,
|
|
|
|
|
timeframe=60,
|
|
|
|
|
)
|
|
|
|
|
qapp.processEvents()
|
|
|
|
|
|
|
|
|
|
new_rects: np.ndarray = item._rectarray.ndarray()
|
|
|
|
|
np.testing.assert_allclose(
|
|
|
|
|
new_rects[:, 0],
|
|
|
|
|
old_rects[:, 0] + 10,
|
|
|
|
|
)
|
|
|
|
|
assert item._gap_specs[0]['arrow_x'] == 12
|
|
|
|
|
new_br: QRectF = item.boundingRect()
|
|
|
|
|
new_scene_point: QPointF = item.mapToScene(
|
|
|
|
|
new_br.center()
|
|
|
|
|
)
|
|
|
|
|
assert new_br.left() == old_br.left() + 10
|
|
|
|
|
assert item in scene.items(new_scene_point)
|
|
|
|
|
assert item not in scene.items(old_scene_point)
|
|
|
|
|
assert item.scene() is charts[1].widget.scene()
|
|
|
|
|
finally:
|
|
|
|
|
gapman.remove_owner('chart-local')
|
|
|
|
|
chart: _ChartStub
|
|
|
|
|
for chart in charts:
|
|
|
|
|
chart.close()
|
|
|
|
|
qapp.processEvents()
|
|
|
|
|
|
|
|
|
|
|
2026-08-17 23:08:02 +00:00
|
|
|
def test_duplicate_fqme_layers_use_local_chart_identity(
|
|
|
|
|
qapp: QApplication,
|
|
|
|
|
|
|
|
|
|
) -> None:
|
|
|
|
|
'''
|
|
|
|
|
Keep cached displays for one FQME in separate local layers.
|
|
|
|
|
|
|
|
|
|
The actor-global display lookup is keyed by FQME and can
|
|
|
|
|
represent only its currently registered display. Local cached
|
|
|
|
|
charts still pass their exact `DisplayState` into
|
|
|
|
|
`GapOverlayMngr.refresh()`. Build two independent chart pairs
|
|
|
|
|
with the same FQME, refresh both, and prove their layers survive
|
|
|
|
|
independently.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
first_ds: SimpleNamespace
|
|
|
|
|
first_charts: tuple[_ChartStub, _ChartStub]
|
|
|
|
|
first_ds, first_charts = _display_state(FQME)
|
|
|
|
|
second_ds: SimpleNamespace
|
|
|
|
|
second_charts: tuple[_ChartStub, _ChartStub]
|
|
|
|
|
second_ds, second_charts = _display_state(FQME)
|
|
|
|
|
annots: dict[int, GapAnnotations] = {}
|
|
|
|
|
gapman: GapOverlayMngr = GapOverlayMngr(
|
|
|
|
|
dss={FQME: second_ds},
|
|
|
|
|
annots=annots,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
first: GapOverlay = gapman.refresh(
|
|
|
|
|
ds=first_ds,
|
|
|
|
|
timeframe=60,
|
|
|
|
|
)
|
|
|
|
|
second: GapOverlay = gapman.refresh(
|
|
|
|
|
ds=second_ds,
|
|
|
|
|
timeframe=60,
|
|
|
|
|
)
|
|
|
|
|
assert first.aid is not None
|
|
|
|
|
assert second.aid is not None
|
|
|
|
|
assert first.aid != second.aid
|
|
|
|
|
assert len(gapman._layers) == 2
|
|
|
|
|
chart_ids: set[int] = set()
|
|
|
|
|
key: tuple[str, int, str, float]
|
|
|
|
|
for key in gapman._layers:
|
|
|
|
|
chart_ids.add(key[1])
|
|
|
|
|
assert chart_ids == {
|
|
|
|
|
id(first_ds.hist_chart),
|
|
|
|
|
id(second_ds.hist_chart),
|
|
|
|
|
}
|
|
|
|
|
assert annots[first.aid].scene() is not None
|
|
|
|
|
assert annots[second.aid].scene() is not None
|
|
|
|
|
finally:
|
|
|
|
|
gapman.remove_owner('chart-local')
|
|
|
|
|
chart: _ChartStub
|
|
|
|
|
for chart in (*first_charts, *second_charts):
|
|
|
|
|
chart.close()
|
|
|
|
|
qapp.processEvents()
|
2026-08-17 23:09:46 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_startup_and_focused_toggle_use_real_qt(
|
|
|
|
|
qapp: QApplication,
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
|
|
|
|
|
) -> None:
|
|
|
|
|
'''
|
|
|
|
|
Register history gaps and toggle the focused chart only.
|
|
|
|
|
|
|
|
|
|
Startup previously lived inside the quote loop and keyboard
|
|
|
|
|
routing treated every state unrelated to the focused history
|
|
|
|
|
chart as a realtime target. Two independent chart pairs reproduce
|
|
|
|
|
that skew. The startup helper must create only 60-second layers;
|
|
|
|
|
then a focused history toggle must hide only its own layer while
|
|
|
|
|
leaving the other cached chart's overlay intact.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
from piker.ui import _remote_ctl
|
|
|
|
|
|
|
|
|
|
first_ds: SimpleNamespace
|
|
|
|
|
first_charts: tuple[_ChartStub, _ChartStub]
|
|
|
|
|
first_ds, first_charts = _display_state('first.test')
|
|
|
|
|
second_ds: SimpleNamespace
|
|
|
|
|
second_charts: tuple[_ChartStub, _ChartStub]
|
|
|
|
|
second_ds, second_charts = _display_state('second.test')
|
|
|
|
|
dss: dict[str, SimpleNamespace] = {
|
|
|
|
|
first_ds.fqme: first_ds,
|
|
|
|
|
second_ds.fqme: second_ds,
|
|
|
|
|
}
|
|
|
|
|
annots: dict[int, GapAnnotations] = {}
|
|
|
|
|
gapman: GapOverlayMngr = GapOverlayMngr(
|
|
|
|
|
dss={},
|
|
|
|
|
annots=annots,
|
|
|
|
|
)
|
|
|
|
|
remote_dss: dict[str, SimpleNamespace] = {}
|
|
|
|
|
monkeypatch.setattr(_remote_ctl, '_dss', remote_dss)
|
|
|
|
|
monkeypatch.setattr(_remote_ctl, '_annots', annots)
|
|
|
|
|
monkeypatch.setattr(_remote_ctl, '_gapman', gapman)
|
|
|
|
|
godwidget: SimpleNamespace = SimpleNamespace(gapman=None)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
started: GapOverlayMngr = _register_gap_overlays(
|
|
|
|
|
dss=dss,
|
|
|
|
|
godwidget=godwidget,
|
|
|
|
|
)
|
|
|
|
|
assert started is gapman
|
|
|
|
|
assert godwidget.gapman is gapman
|
|
|
|
|
assert remote_dss == dss
|
|
|
|
|
assert len(gapman._layers) == 2
|
|
|
|
|
key: tuple[str, int, str, float]
|
|
|
|
|
for key in gapman._layers:
|
|
|
|
|
assert key[3] == 60
|
|
|
|
|
|
|
|
|
|
view: SimpleNamespace = SimpleNamespace(
|
|
|
|
|
_chart=first_ds.hist_chart,
|
|
|
|
|
)
|
|
|
|
|
toggled: list[GapOverlay] = _toggle_gap_overlays(
|
|
|
|
|
view=view,
|
|
|
|
|
dss=dss,
|
|
|
|
|
gapman=gapman,
|
|
|
|
|
)
|
|
|
|
|
assert len(toggled) == 1
|
|
|
|
|
assert toggled[0].fqme == first_ds.fqme
|
|
|
|
|
assert toggled[0].timeframe == 60
|
|
|
|
|
assert toggled[0].visible is False
|
|
|
|
|
assert (
|
|
|
|
|
'chart-local',
|
|
|
|
|
id(first_ds.hist_chart),
|
|
|
|
|
first_ds.fqme,
|
|
|
|
|
60,
|
|
|
|
|
) not in gapman._layers
|
|
|
|
|
assert (
|
|
|
|
|
'chart-local',
|
|
|
|
|
id(second_ds.hist_chart),
|
|
|
|
|
second_ds.fqme,
|
|
|
|
|
60,
|
|
|
|
|
) in gapman._layers
|
|
|
|
|
|
|
|
|
|
view._chart = first_ds.chart
|
|
|
|
|
rt_toggled: list[GapOverlay] = _toggle_gap_overlays(
|
|
|
|
|
view=view,
|
|
|
|
|
dss=dss,
|
|
|
|
|
gapman=gapman,
|
|
|
|
|
)
|
|
|
|
|
assert len(rt_toggled) == 1
|
|
|
|
|
assert rt_toggled[0].fqme == first_ds.fqme
|
|
|
|
|
assert rt_toggled[0].timeframe == 1
|
|
|
|
|
assert rt_toggled[0].visible is True
|
|
|
|
|
assert (
|
|
|
|
|
'chart-local',
|
|
|
|
|
id(first_ds.chart),
|
|
|
|
|
first_ds.fqme,
|
|
|
|
|
1,
|
|
|
|
|
) in gapman._layers
|
|
|
|
|
|
|
|
|
|
unrelated: list[GapOverlay] = _toggle_gap_overlays(
|
|
|
|
|
view=SimpleNamespace(_chart=object()),
|
|
|
|
|
dss=dss,
|
|
|
|
|
gapman=gapman,
|
|
|
|
|
)
|
|
|
|
|
assert unrelated == []
|
|
|
|
|
finally:
|
|
|
|
|
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(
|
|
|
|
|
qapp: QApplication,
|
|
|
|
|
|
|
|
|
|
) -> None:
|
|
|
|
|
'''
|
|
|
|
|
Route a real Qt Ctrl-G event into a rendered history gap layer.
|
|
|
|
|
|
|
|
|
|
Calling the toggle helper directly does not prove that Qt event
|
|
|
|
|
filtering, `KeyboardMsg` conversion or the asynchronous view-mode
|
|
|
|
|
handler recognizes the configured binding. Install the production
|
|
|
|
|
`EventRelay` on a real widget, send one offscreen `QKeyEvent`,
|
|
|
|
|
and wait until the handler blocks again. The resulting manager
|
|
|
|
|
and scene state prove the complete local keyboard path ran.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
ds: SimpleNamespace
|
|
|
|
|
charts: tuple[_ChartStub, _ChartStub]
|
|
|
|
|
ds, charts = _display_state(FQME)
|
|
|
|
|
annots: dict[int, GapAnnotations] = {}
|
|
|
|
|
gapman: GapOverlayMngr = GapOverlayMngr(
|
|
|
|
|
dss={FQME: ds},
|
|
|
|
|
annots=annots,
|
|
|
|
|
)
|
|
|
|
|
nav: SimpleNamespace = SimpleNamespace(
|
|
|
|
|
hide_info=lambda: None,
|
|
|
|
|
)
|
|
|
|
|
lines: SimpleNamespace = SimpleNamespace(
|
|
|
|
|
unstage_line=lambda: None,
|
|
|
|
|
)
|
|
|
|
|
godwidget: SimpleNamespace = SimpleNamespace(gapman=gapman)
|
|
|
|
|
order_mode: SimpleNamespace = SimpleNamespace(
|
|
|
|
|
godw=godwidget,
|
|
|
|
|
cancel_all_orders=lambda: None,
|
|
|
|
|
current_pp=SimpleNamespace(nav=nav),
|
|
|
|
|
lines=lines,
|
|
|
|
|
active=False,
|
|
|
|
|
)
|
|
|
|
|
source: QWidget = QWidget()
|
|
|
|
|
source.order_mode = order_mode
|
|
|
|
|
source._chart = ds.hist_chart
|
|
|
|
|
source.linked = SimpleNamespace(
|
|
|
|
|
cursor=SimpleNamespace(in_query_mode=False),
|
|
|
|
|
)
|
|
|
|
|
source.setMouseMode = lambda mode: None
|
|
|
|
|
|
|
|
|
|
async def drive_key_event() -> None:
|
|
|
|
|
'''
|
|
|
|
|
Run the production input context around one key event.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
async with ChartView.open_async_input_handler(
|
|
|
|
|
source,
|
|
|
|
|
dss={FQME: ds},
|
|
|
|
|
):
|
|
|
|
|
key_event: QKeyEvent = QKeyEvent(
|
|
|
|
|
QEvent.Type.KeyPress,
|
|
|
|
|
Qt.Key.Key_G,
|
|
|
|
|
Qt.KeyboardModifier.ControlModifier,
|
|
|
|
|
'g',
|
|
|
|
|
)
|
|
|
|
|
assert QApplication.sendEvent(source, key_event)
|
|
|
|
|
await wait_all_tasks_blocked()
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
trio.run(drive_key_event)
|
|
|
|
|
assert len(gapman._layers) == 1
|
|
|
|
|
layer_key: tuple[str, int, str, float] = next(
|
|
|
|
|
iter(gapman._layers)
|
|
|
|
|
)
|
|
|
|
|
assert layer_key == (
|
|
|
|
|
'chart-local',
|
|
|
|
|
id(ds.hist_chart),
|
|
|
|
|
FQME,
|
|
|
|
|
60,
|
|
|
|
|
)
|
|
|
|
|
aid: int = gapman._layers[layer_key]
|
|
|
|
|
assert annots[aid].scene() is charts[1].widget.scene()
|
|
|
|
|
finally:
|
|
|
|
|
gapman.remove_owner('chart-local')
|
|
|
|
|
source.close()
|
|
|
|
|
source.deleteLater()
|
|
|
|
|
chart: _ChartStub
|
|
|
|
|
for chart in charts:
|
|
|
|
|
chart.close()
|
|
|
|
|
qapp.processEvents()
|
2026-08-17 23:11:03 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
_actor_requests: list[dict] = []
|
|
|
|
|
_actor_removed: list[str] = []
|
|
|
|
|
_delayed_request_ids: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _ActorGapMngr:
|
|
|
|
|
'''
|
|
|
|
|
Minimal child-actor manager used around the real endpoint.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
|
|
|
|
|
def apply(
|
|
|
|
|
self,
|
|
|
|
|
req: SetGapOverlay,
|
|
|
|
|
owner: str,
|
|
|
|
|
aids: set[int],
|
|
|
|
|
ds: object|None = None,
|
|
|
|
|
|
|
|
|
|
) -> GapOverlay:
|
|
|
|
|
'''
|
|
|
|
|
Record a typed request and return its correlated state.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
aid: int = 616
|
|
|
|
|
aids.add(aid)
|
|
|
|
|
_actor_requests.append({
|
|
|
|
|
'fqme': req.fqme,
|
|
|
|
|
'timeframe': req.timeframe,
|
|
|
|
|
'request_id': req.request_id,
|
|
|
|
|
'owner': owner,
|
|
|
|
|
})
|
|
|
|
|
return GapOverlay(
|
|
|
|
|
fqme=req.fqme,
|
|
|
|
|
timeframe=req.timeframe,
|
|
|
|
|
visible=req.visible,
|
|
|
|
|
gap_count=len(req.specs),
|
|
|
|
|
aid=aid,
|
|
|
|
|
request_id=req.request_id,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def remove_owner(
|
|
|
|
|
self,
|
|
|
|
|
owner: str,
|
|
|
|
|
|
|
|
|
|
) -> None:
|
|
|
|
|
'''
|
|
|
|
|
Record endpoint-finally cleanup in the child actor.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
_actor_removed.append(owner)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _init_gap_actor() -> None:
|
|
|
|
|
'''
|
|
|
|
|
Install isolated endpoint globals inside the child actor.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
from piker.ui import _remote_ctl
|
|
|
|
|
|
|
|
|
|
_actor_requests.clear()
|
|
|
|
|
_actor_removed.clear()
|
|
|
|
|
_delayed_request_ids.clear()
|
|
|
|
|
_remote_ctl._dss.clear()
|
|
|
|
|
_remote_ctl._dss[FQME] = object()
|
|
|
|
|
_remote_ctl._gapman = _ActorGapMngr()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _gap_actor_snapshot() -> dict:
|
|
|
|
|
'''
|
|
|
|
|
Return serializable endpoint evidence from the child actor.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
return {
|
|
|
|
|
'requests': list(_actor_requests),
|
|
|
|
|
'removed': list(_actor_removed),
|
|
|
|
|
'delayed_request_ids': list(_delayed_request_ids),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@tractor.context(pld_spec=GapOverlayPayload)
|
|
|
|
|
async def _delayed_gap_dialog(
|
|
|
|
|
ctx: tractor.Context,
|
|
|
|
|
|
|
|
|
|
) -> None:
|
|
|
|
|
'''
|
|
|
|
|
Delay the first reply so client cancellation leaves it queued.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
await ctx.started([FQME])
|
|
|
|
|
stream: tractor.MsgStream
|
|
|
|
|
async with ctx.open_stream() as stream:
|
|
|
|
|
with ctx.pld_rx.limit_plds(spec=SetGapOverlay):
|
|
|
|
|
req_count: int = 0
|
|
|
|
|
req: SetGapOverlay
|
|
|
|
|
async for req in stream:
|
|
|
|
|
req_count += 1
|
|
|
|
|
_delayed_request_ids.append(req.request_id)
|
|
|
|
|
if req_count == 1:
|
|
|
|
|
await stream.send(GapOverlay(
|
|
|
|
|
fqme=req.fqme,
|
|
|
|
|
timeframe=req.timeframe,
|
|
|
|
|
visible=req.visible,
|
|
|
|
|
gap_count=0,
|
|
|
|
|
request_id='actor-received',
|
|
|
|
|
))
|
|
|
|
|
await trio.sleep(0.05)
|
|
|
|
|
|
|
|
|
|
await stream.send(GapOverlay(
|
|
|
|
|
fqme=req.fqme,
|
|
|
|
|
timeframe=req.timeframe,
|
|
|
|
|
visible=req.visible,
|
|
|
|
|
gap_count=len(req.specs),
|
|
|
|
|
request_id=req.request_id,
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@tractor_test(timeout=20)
|
|
|
|
|
async def test_remote_gap_dialog_real_actor(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
|
|
|
|
|
) -> None:
|
|
|
|
|
'''
|
|
|
|
|
Exchange typed gap state through the production Tractor endpoint.
|
|
|
|
|
|
|
|
|
|
Schema-only tests can pass while endpoint discovery, directional
|
|
|
|
|
decoding, stream setup, request correlation or context-finally
|
|
|
|
|
cleanup is broken. Boot a real child actor, initialize only its
|
|
|
|
|
actor-local display and manager globals, call the production
|
|
|
|
|
`remote_gap_overlays()` context through `AnnotClient`, and verify
|
|
|
|
|
the child receives the generated request ID and removes its owner
|
|
|
|
|
after stream closure.
|
|
|
|
|
|
|
|
|
|
A second child context emits a receipt before delaying the first
|
|
|
|
|
reply. Cancel that exact client task after the receipt, submit a
|
|
|
|
|
second request on the shared stream, and prove the client
|
|
|
|
|
discards the queued first reply before returning the second. The
|
|
|
|
|
child-side request IDs prove cancellation happened after
|
|
|
|
|
publication instead of merely preventing the first send.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
from piker.ui._remote_ctl import (
|
|
|
|
|
AnnotClient,
|
|
|
|
|
remote_gap_overlays,
|
|
|
|
|
)
|
|
|
|
|
from piker.ui import _remote_ctl
|
|
|
|
|
|
|
|
|
|
receipt_seen: trio.Event = trio.Event()
|
|
|
|
|
original_warning: Callable[..., None] = (
|
|
|
|
|
_remote_ctl.log.warning
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def observe_warning(
|
|
|
|
|
msg: str,
|
|
|
|
|
*args: object,
|
|
|
|
|
**kwargs: object,
|
|
|
|
|
|
|
|
|
|
) -> None:
|
|
|
|
|
'''
|
|
|
|
|
Observe the stale receipt before cancelling its client task.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
if 'actor-received' in msg:
|
|
|
|
|
receipt_seen.set()
|
|
|
|
|
original_warning(msg, *args, **kwargs)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
_remote_ctl.log,
|
|
|
|
|
'warning',
|
|
|
|
|
observe_warning,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
actor_nursery: tractor.ActorNursery
|
|
|
|
|
async with tractor.open_nursery() as actor_nursery:
|
|
|
|
|
portal: tractor.Portal = await actor_nursery.start_actor(
|
|
|
|
|
'gap-overlay-test-server',
|
|
|
|
|
enable_modules=[
|
|
|
|
|
__name__,
|
|
|
|
|
'piker.ui._remote_ctl',
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
await portal.run(_init_gap_actor)
|
|
|
|
|
ctx: tractor.Context
|
|
|
|
|
fqmes: list[str]
|
|
|
|
|
async with portal.open_context(
|
|
|
|
|
remote_gap_overlays,
|
|
|
|
|
) as (ctx, fqmes):
|
|
|
|
|
assert fqmes == [FQME]
|
|
|
|
|
|
|
|
|
|
stream: tractor.MsgStream
|
|
|
|
|
async with ctx.open_stream() as stream:
|
|
|
|
|
client: AnnotClient = AnnotClient(
|
|
|
|
|
ctx2fqmes={ctx.cid: {FQME}},
|
|
|
|
|
fqme2ipc={},
|
|
|
|
|
fqme2gap_ipc={FQME: stream},
|
|
|
|
|
_gap_locks={FQME: trio.Lock()},
|
|
|
|
|
_annot_stack=AsyncExitStack(),
|
|
|
|
|
_ipcs={},
|
|
|
|
|
)
|
|
|
|
|
supplied_id: str = 'client-id-is-replaced'
|
|
|
|
|
gap: GapOverlay = await client.set_gap_overlay(
|
|
|
|
|
SetGapOverlay(
|
|
|
|
|
fqme=FQME,
|
|
|
|
|
timeframe=60,
|
|
|
|
|
specs=[],
|
|
|
|
|
request_id=supplied_id,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
assert gap.aid == 616
|
|
|
|
|
assert gap.request_id != supplied_id
|
|
|
|
|
|
|
|
|
|
snapshot: dict = await portal.run(
|
|
|
|
|
_gap_actor_snapshot,
|
|
|
|
|
)
|
|
|
|
|
requests: list[dict] = snapshot['requests']
|
|
|
|
|
assert len(requests) == 1
|
|
|
|
|
assert requests[0]['request_id'] == gap.request_id
|
|
|
|
|
assert snapshot['removed'] == [requests[0]['owner']]
|
|
|
|
|
|
|
|
|
|
delayed_ctx: tractor.Context
|
|
|
|
|
delayed_fqmes: list[str]
|
|
|
|
|
async with portal.open_context(
|
|
|
|
|
_delayed_gap_dialog,
|
|
|
|
|
) as (delayed_ctx, delayed_fqmes):
|
|
|
|
|
assert delayed_fqmes == [FQME]
|
|
|
|
|
|
|
|
|
|
delayed_stream: tractor.MsgStream
|
|
|
|
|
async with (
|
|
|
|
|
delayed_ctx.open_stream() as delayed_stream,
|
|
|
|
|
):
|
|
|
|
|
delayed_client: AnnotClient = AnnotClient(
|
|
|
|
|
ctx2fqmes={delayed_ctx.cid: {FQME}},
|
|
|
|
|
fqme2ipc={},
|
|
|
|
|
fqme2gap_ipc={FQME: delayed_stream},
|
|
|
|
|
_gap_locks={FQME: trio.Lock()},
|
|
|
|
|
_annot_stack=AsyncExitStack(),
|
|
|
|
|
_ipcs={},
|
|
|
|
|
)
|
|
|
|
|
first_scope: trio.CancelScope = (
|
|
|
|
|
trio.CancelScope()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def cancel_first_request() -> None:
|
|
|
|
|
'''
|
|
|
|
|
Wait for cancellation inside the first
|
|
|
|
|
dialog.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
with first_scope:
|
|
|
|
|
await delayed_client.set_gap_overlay(
|
|
|
|
|
SetGapOverlay(
|
|
|
|
|
fqme=FQME,
|
|
|
|
|
timeframe=60,
|
|
|
|
|
specs=[],
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
nursery: trio.Nursery
|
|
|
|
|
async with trio.open_nursery() as nursery:
|
|
|
|
|
nursery.start_soon(cancel_first_request)
|
|
|
|
|
await receipt_seen.wait()
|
|
|
|
|
first_scope.cancel()
|
|
|
|
|
await wait_all_tasks_blocked()
|
|
|
|
|
|
|
|
|
|
recovered: GapOverlay = (
|
|
|
|
|
await delayed_client.set_gap_overlay(
|
|
|
|
|
SetGapOverlay(
|
|
|
|
|
fqme=FQME,
|
|
|
|
|
timeframe=60,
|
|
|
|
|
specs=[],
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
assert recovered.fqme == FQME
|
|
|
|
|
assert recovered.request_id
|
|
|
|
|
nursery.cancel_scope.cancel()
|
|
|
|
|
|
|
|
|
|
delayed_snapshot: dict = await portal.run(
|
|
|
|
|
_gap_actor_snapshot,
|
|
|
|
|
)
|
|
|
|
|
delayed_ids: list[str] = (
|
|
|
|
|
delayed_snapshot['delayed_request_ids']
|
|
|
|
|
)
|
|
|
|
|
assert len(delayed_ids) == 2
|
|
|
|
|
assert delayed_ids[0] != delayed_ids[1]
|
|
|
|
|
assert delayed_ids[1] == recovered.request_id
|
|
|
|
|
finally:
|
|
|
|
|
await portal.cancel_actor()
|