Compare commits
No commits in common. "335190a4e3ff5712626288ac5e81693b9646c140" and "f65df6a138cb8dc2119feb421c122a2c8ec2b89e" have entirely different histories.
335190a4e3
...
f65df6a138
|
|
@ -1,39 +0,0 @@
|
||||||
---
|
|
||||||
model: openai/gpt-5.6-sol
|
|
||||||
service: opencode
|
|
||||||
session: unavailable
|
|
||||||
timestamp: 2026-08-17T22:20:36Z
|
|
||||||
git_ref: f65df6a1
|
|
||||||
scope: code
|
|
||||||
substantive: true
|
|
||||||
raw_file: 20260817T222036Z_f65df6a1_prompt_io.raw.md
|
|
||||||
---
|
|
||||||
|
|
||||||
## Prompt
|
|
||||||
|
|
||||||
Add end-to-end coverage for chart-local gap annotations in the existing
|
|
||||||
gap-overlay worktree without disturbing concurrent manual backfiller testing
|
|
||||||
in the root checkout.
|
|
||||||
|
|
||||||
## Response summary
|
|
||||||
|
|
||||||
Adds real offscreen Qt and real Tractor actor integration coverage. The tests
|
|
||||||
drive startup registration, PyQtGraph insertion/removal, focused Ctrl-G input,
|
|
||||||
history-prepend repositioning, duplicate-FQME chart identity, typed endpoint
|
|
||||||
exchange and cancellation with stale-response recovery. Defects exposed by
|
|
||||||
those tests are fixed in manager cleanup, Qt geometry notification and PyQt6
|
|
||||||
event routing.
|
|
||||||
|
|
||||||
## Files changed
|
|
||||||
|
|
||||||
- `piker/ui/_gaps.py` - correct PyQtGraph ownership and bounds behavior.
|
|
||||||
- `piker/ui/_annotate.py` - notify Qt before reposition geometry changes.
|
|
||||||
- `piker/ui/_display.py` - expose deterministic startup registration.
|
|
||||||
- `piker/ui/_interaction.py` - focus toggles and PyQt6 event registration.
|
|
||||||
- `tests/test_gap_overlays.py` - real Qt and Tractor integration coverage.
|
|
||||||
- `plans/opencode/chart-local-gap-overlays.md` - update coverage boundaries.
|
|
||||||
- `plans/opencode/chart-local-gap-overlays.summary.md` - update test totals.
|
|
||||||
|
|
||||||
## Human edits
|
|
||||||
|
|
||||||
None - the changes remain uncommitted for human review.
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
---
|
|
||||||
model: openai/gpt-5.6-sol
|
|
||||||
service: opencode
|
|
||||||
timestamp: 2026-08-17T22:20:36Z
|
|
||||||
git_ref: f65df6a1
|
|
||||||
diff_cmd: git diff f65df6a1..HEAD
|
|
||||||
---
|
|
||||||
|
|
||||||
## Prompt
|
|
||||||
|
|
||||||
The user asked whether the chart-local gap annotation feature had complete
|
|
||||||
end-to-end coverage, then requested implementation in the existing gap-overlay
|
|
||||||
worktree while manual NativeDB/backfiller qualification continued in the root
|
|
||||||
checkout. The user initially declined a tests-only Prompt-IO entry. Real Qt
|
|
||||||
and Tractor integration tests then exposed production lifecycle and PyQt6
|
|
||||||
event-routing defects, making the resulting patch substantive code work and
|
|
||||||
requiring this full provenance entry.
|
|
||||||
|
|
||||||
## Generated code
|
|
||||||
|
|
||||||
> `git diff HEAD~1..HEAD -- piker/ui/_gaps.py`
|
|
||||||
|
|
||||||
Removes manager-owned graphics through their `PlotItem`, retains owning-plot
|
|
||||||
registrations, and excludes pixel-sized gap arrows from automatic data bounds.
|
|
||||||
|
|
||||||
> `git diff HEAD~1..HEAD -- piker/ui/_annotate.py`
|
|
||||||
|
|
||||||
Moves Qt geometry-change notification ahead of rectangle and arrow mutation so
|
|
||||||
scene spatial indexing follows history-prepend repositioning.
|
|
||||||
|
|
||||||
> `git diff HEAD~1..HEAD -- piker/ui/_display.py`
|
|
||||||
|
|
||||||
Extracts `_register_gap_overlays()` as the deterministic chart startup seam
|
|
||||||
which registers display states and renders default historical gap layers.
|
|
||||||
|
|
||||||
> `git diff HEAD~1..HEAD -- piker/ui/_interaction.py`
|
|
||||||
|
|
||||||
Adds focused-chart toggle routing, updates the gap keyboard path to PyQt6 event
|
|
||||||
enums, and keeps unrelated cached chart states untouched.
|
|
||||||
|
|
||||||
> `git diff HEAD~1..HEAD -- tests/test_gap_overlays.py`
|
|
||||||
|
|
||||||
Adds offscreen real-Qt manager, startup, keyboard, cached-chart and reposition
|
|
||||||
coverage plus a real Tractor actor endpoint/client dialog with deterministic
|
|
||||||
cancellation and stale-response recovery.
|
|
||||||
|
|
||||||
## Generated architecture output
|
|
||||||
|
|
||||||
> `git diff HEAD~1..HEAD -- plans/opencode/chart-local-gap-overlays.md plans/opencode/chart-local-gap-overlays.summary.md`
|
|
||||||
|
|
||||||
Records the automated integration boundary and remaining broker-backed manual
|
|
||||||
qualification requirements.
|
|
||||||
|
|
||||||
## Verification output
|
|
||||||
|
|
||||||
The source-isolated non-actor and related suites pass 24 tests with one actor
|
|
||||||
case deselected. The isolated actor case passes with one upstream Tractor/Trio
|
|
||||||
deprecation warning. Ruff, compileall, import resolution and whitespace checks
|
|
||||||
pass. Independent review reports no remaining P1/P2 findings.
|
|
||||||
|
|
@ -670,11 +670,6 @@ class GapAnnotations(GraphicsObject):
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
# Notify Qt before mutating anything used by
|
|
||||||
# `boundingRect()`.
|
|
||||||
self.prepareGeometryChange()
|
|
||||||
self._br = None
|
|
||||||
|
|
||||||
# rebuild rect array from gap specs with new indices
|
# rebuild rect array from gap specs with new indices
|
||||||
rect_memory = self._rectarray.ndarray()
|
rect_memory = self._rectarray.ndarray()
|
||||||
|
|
||||||
|
|
@ -782,4 +777,7 @@ class GapAnnotations(GraphicsObject):
|
||||||
self._arrow_path.addPolygon(arrow_poly)
|
self._arrow_path.addPolygon(arrow_poly)
|
||||||
self._arrow_path.closeSubpath()
|
self._arrow_path.closeSubpath()
|
||||||
|
|
||||||
|
# invalidate bounding rect cache
|
||||||
|
self._br = None
|
||||||
|
self.prepareGeometryChange()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
|
||||||
|
|
@ -320,41 +320,6 @@ async def increment_history_view(
|
||||||
profiler.finish()
|
profiler.finish()
|
||||||
|
|
||||||
|
|
||||||
def _register_gap_overlays(
|
|
||||||
dss: dict[str, DisplayState],
|
|
||||||
godwidget: GodWidget,
|
|
||||||
|
|
||||||
) -> GapOverlayMngr:
|
|
||||||
'''
|
|
||||||
Register display states and start local history gap overlays.
|
|
||||||
|
|
||||||
'''
|
|
||||||
from . import _remote_ctl
|
|
||||||
|
|
||||||
_remote_ctl._dss.update(dss)
|
|
||||||
gapman: GapOverlayMngr = _remote_ctl._gapman
|
|
||||||
godwidget.gapman = gapman
|
|
||||||
gap_timeframe: float = HIST_GAP_TIMEFRAME
|
|
||||||
|
|
||||||
# TODO: expose startup visibility and enabled timeframes through
|
|
||||||
# the UI config once chart periods are configurable.
|
|
||||||
fqme: str
|
|
||||||
ds: DisplayState
|
|
||||||
for fqme, ds in dss.items():
|
|
||||||
gap: GapOverlay = gapman.refresh(
|
|
||||||
ds=ds,
|
|
||||||
timeframe=gap_timeframe,
|
|
||||||
)
|
|
||||||
log.info(
|
|
||||||
'Chart-local gap overlay ready:\n'
|
|
||||||
f'fqme: {fqme}\n'
|
|
||||||
f'timeframe: {gap_timeframe}s\n'
|
|
||||||
f'gaps: {gap.gap_count}\n'
|
|
||||||
)
|
|
||||||
|
|
||||||
return gapman
|
|
||||||
|
|
||||||
|
|
||||||
async def graphics_update_loop(
|
async def graphics_update_loop(
|
||||||
dss: dict[str, DisplayState],
|
dss: dict[str, DisplayState],
|
||||||
nurse: trio.Nursery,
|
nurse: trio.Nursery,
|
||||||
|
|
@ -525,10 +490,27 @@ async def graphics_update_loop(
|
||||||
# XXX TODO: we need to do _dss UPDATE here so that when
|
# XXX TODO: we need to do _dss UPDATE here so that when
|
||||||
# a feed-view is switched you can still remote annotate the
|
# a feed-view is switched you can still remote annotate the
|
||||||
# prior view..
|
# prior view..
|
||||||
_register_gap_overlays(
|
from . import _remote_ctl
|
||||||
dss=dss,
|
_remote_ctl._dss.update(dss)
|
||||||
godwidget=godwidget,
|
gapman: GapOverlayMngr = _remote_ctl._gapman
|
||||||
)
|
godwidget.gapman = gapman
|
||||||
|
gap_timeframe: float = HIST_GAP_TIMEFRAME
|
||||||
|
|
||||||
|
# TODO: expose startup visibility and enabled timeframes through
|
||||||
|
# the UI config once chart periods are configurable.
|
||||||
|
fqme: str
|
||||||
|
ds: DisplayState
|
||||||
|
for fqme, ds in dss.items():
|
||||||
|
gap: GapOverlay = gapman.refresh(
|
||||||
|
ds=ds,
|
||||||
|
timeframe=gap_timeframe,
|
||||||
|
)
|
||||||
|
log.info(
|
||||||
|
'Chart-local gap overlay ready:\n'
|
||||||
|
f'fqme: {fqme}\n'
|
||||||
|
f'timeframe: {gap_timeframe}s\n'
|
||||||
|
f'gaps: {gap.gap_count}\n'
|
||||||
|
)
|
||||||
|
|
||||||
# main real-time quotes update loop
|
# main real-time quotes update loop
|
||||||
stream: tractor.MsgStream
|
stream: tractor.MsgStream
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,8 @@ from piker.types import Struct
|
||||||
from ._annotate import GapAnnotations
|
from ._annotate import GapAnnotations
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from PyQt6.QtWidgets import QGraphicsScene
|
|
||||||
from pyqtgraph import PlotItem
|
|
||||||
|
|
||||||
from piker.ui.qt import QGraphicsItem
|
from piker.ui.qt import QGraphicsItem
|
||||||
|
from PyQt6.QtWidgets import QGraphicsScene
|
||||||
from ._chart import ChartPlotWidget
|
from ._chart import ChartPlotWidget
|
||||||
from ._dataviz import Viz
|
from ._dataviz import Viz
|
||||||
from ._display import DisplayState
|
from ._display import DisplayState
|
||||||
|
|
@ -228,37 +226,6 @@ class GapOverlayMngr:
|
||||||
],
|
],
|
||||||
bool, # `SetGapOverlay.visible`
|
bool, # `SetGapOverlay.visible`
|
||||||
] = {}
|
] = {}
|
||||||
self._plots: dict[
|
|
||||||
int, # `id(GapAnnotations)`
|
|
||||||
PlotItem, # owning `Viz.plot`
|
|
||||||
] = {}
|
|
||||||
|
|
||||||
def _remove_annot(
|
|
||||||
self,
|
|
||||||
aid: int,
|
|
||||||
|
|
||||||
) -> None:
|
|
||||||
'''
|
|
||||||
Remove one gap item from all manager and plot registries.
|
|
||||||
|
|
||||||
'''
|
|
||||||
annot: QGraphicsItem|None = self.annots.pop(
|
|
||||||
aid,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
plot: PlotItem|None = self._plots.pop(aid, None)
|
|
||||||
if annot is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
if plot is not None:
|
|
||||||
plot.removeItem(annot)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Defensive fallback for manager state created before the
|
|
||||||
# owning `Viz.plot` registry was introduced.
|
|
||||||
scene: QGraphicsScene|None = annot.scene()
|
|
||||||
if scene:
|
|
||||||
scene.removeItem(annot)
|
|
||||||
|
|
||||||
def apply(
|
def apply(
|
||||||
self,
|
self,
|
||||||
|
|
@ -310,9 +277,16 @@ class GapOverlayMngr:
|
||||||
)
|
)
|
||||||
aid: int|None = self._layers.pop(layer_key, None)
|
aid: int|None = self._layers.pop(layer_key, None)
|
||||||
if aid is not None:
|
if aid is not None:
|
||||||
|
annot: QGraphicsItem|None = self.annots.pop(
|
||||||
|
aid,
|
||||||
|
None,
|
||||||
|
)
|
||||||
if aids is not None:
|
if aids is not None:
|
||||||
aids.discard(aid)
|
aids.discard(aid)
|
||||||
self._remove_annot(aid)
|
if annot is not None:
|
||||||
|
scene: QGraphicsScene|None = annot.scene()
|
||||||
|
if scene:
|
||||||
|
scene.removeItem(annot)
|
||||||
|
|
||||||
if owner == 'chart-local':
|
if owner == 'chart-local':
|
||||||
self._visible[
|
self._visible[
|
||||||
|
|
@ -344,15 +318,11 @@ class GapOverlayMngr:
|
||||||
fqme=req.fqme,
|
fqme=req.fqme,
|
||||||
timeframe=req.timeframe,
|
timeframe=req.timeframe,
|
||||||
)
|
)
|
||||||
viz.plot.addItem(
|
viz.plot.addItem(gaps_item)
|
||||||
gaps_item,
|
|
||||||
ignoreBounds=True,
|
|
||||||
)
|
|
||||||
gaps_item._chart = chart
|
gaps_item._chart = chart
|
||||||
|
|
||||||
new_aid: int = id(gaps_item)
|
new_aid: int = id(gaps_item)
|
||||||
self.annots[new_aid] = gaps_item
|
self.annots[new_aid] = gaps_item
|
||||||
self._plots[new_aid] = viz.plot
|
|
||||||
self._layers[layer_key] = new_aid
|
self._layers[layer_key] = new_aid
|
||||||
if aids is not None:
|
if aids is not None:
|
||||||
aids.add(new_aid)
|
aids.add(new_aid)
|
||||||
|
|
@ -433,4 +403,11 @@ class GapOverlayMngr:
|
||||||
layer_key: tuple[str, int, str, float]
|
layer_key: tuple[str, int, str, float]
|
||||||
for layer_key in owner_keys:
|
for layer_key in owner_keys:
|
||||||
aid: int = self._layers.pop(layer_key)
|
aid: int = self._layers.pop(layer_key)
|
||||||
self._remove_annot(aid)
|
annot: QGraphicsItem|None = self.annots.pop(
|
||||||
|
aid,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if annot is not None:
|
||||||
|
scene: QGraphicsScene|None = annot.scene()
|
||||||
|
if scene:
|
||||||
|
scene.removeItem(annot)
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ import trio
|
||||||
|
|
||||||
from piker.ui.qt import (
|
from piker.ui.qt import (
|
||||||
QWheelEvent,
|
QWheelEvent,
|
||||||
|
QGraphicsSceneMouseEvent as gs_mouse,
|
||||||
Qt,
|
Qt,
|
||||||
QEvent,
|
QEvent,
|
||||||
)
|
)
|
||||||
|
|
@ -107,43 +108,6 @@ ORDER_MODE = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _toggle_gap_overlays(
|
|
||||||
view: ChartView,
|
|
||||||
dss: dict[str, DisplayState],
|
|
||||||
gapman: GapOverlayMngr,
|
|
||||||
|
|
||||||
) -> list[GapOverlay]:
|
|
||||||
'''
|
|
||||||
Toggle gap layers sharing the focused chart and timeframe.
|
|
||||||
|
|
||||||
'''
|
|
||||||
gaps: list[GapOverlay] = []
|
|
||||||
fqme: str
|
|
||||||
ds: DisplayState
|
|
||||||
for fqme, ds in dss.items():
|
|
||||||
if view._chart is ds.hist_chart:
|
|
||||||
timeframe: float = HIST_GAP_TIMEFRAME
|
|
||||||
elif view._chart is ds.chart:
|
|
||||||
timeframe = RT_GAP_TIMEFRAME
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
|
|
||||||
gap: GapOverlay = gapman.toggle(
|
|
||||||
ds=ds,
|
|
||||||
timeframe=timeframe,
|
|
||||||
)
|
|
||||||
gaps.append(gap)
|
|
||||||
log.info(
|
|
||||||
'Toggled chart-local gap overlay:\n'
|
|
||||||
f'fqme: {fqme}\n'
|
|
||||||
f'timeframe: {timeframe}s\n'
|
|
||||||
f'visible: {gap.visible}\n'
|
|
||||||
f'gaps: {gap.gap_count}\n'
|
|
||||||
)
|
|
||||||
|
|
||||||
return gaps
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_viewmode_kb_inputs(
|
async def handle_viewmode_kb_inputs(
|
||||||
|
|
||||||
view: ChartView,
|
view: ChartView,
|
||||||
|
|
@ -190,7 +154,7 @@ async def handle_viewmode_kb_inputs(
|
||||||
shift: bool = False
|
shift: bool = False
|
||||||
|
|
||||||
# press branch
|
# press branch
|
||||||
if etype in {QEvent.Type.KeyPress}:
|
if etype in {QEvent.KeyPress}:
|
||||||
|
|
||||||
pressed.add(key)
|
pressed.add(key)
|
||||||
|
|
||||||
|
|
@ -220,10 +184,10 @@ async def handle_viewmode_kb_inputs(
|
||||||
log.debug(f'fast keys seqs {fast_key_seq}')
|
log.debug(f'fast keys seqs {fast_key_seq}')
|
||||||
|
|
||||||
# mods run through
|
# mods run through
|
||||||
if mods == Qt.KeyboardModifier.ShiftModifier:
|
if mods == Qt.ShiftModifier:
|
||||||
shift = True
|
shift = True
|
||||||
|
|
||||||
if mods == Qt.KeyboardModifier.ControlModifier:
|
if mods == Qt.ControlModifier:
|
||||||
ctrl = True
|
ctrl = True
|
||||||
|
|
||||||
# UI REPL-shell, with ctrl-p (for "pause")
|
# UI REPL-shell, with ctrl-p (for "pause")
|
||||||
|
|
@ -231,7 +195,7 @@ async def handle_viewmode_kb_inputs(
|
||||||
ctrl
|
ctrl
|
||||||
and
|
and
|
||||||
key in {
|
key in {
|
||||||
Qt.Key.Key_P,
|
Qt.Key_P,
|
||||||
}
|
}
|
||||||
):
|
):
|
||||||
feed = order_mode.feed # noqa
|
feed = order_mode.feed # noqa
|
||||||
|
|
@ -249,7 +213,7 @@ async def handle_viewmode_kb_inputs(
|
||||||
ctrl
|
ctrl
|
||||||
and
|
and
|
||||||
key in {
|
key in {
|
||||||
Qt.Key.Key_R,
|
Qt.Key_R,
|
||||||
}
|
}
|
||||||
):
|
):
|
||||||
fqme: str
|
fqme: str
|
||||||
|
|
@ -272,16 +236,29 @@ async def handle_viewmode_kb_inputs(
|
||||||
if (
|
if (
|
||||||
ctrl
|
ctrl
|
||||||
and
|
and
|
||||||
key == Qt.Key.Key_G
|
key == Qt.Key_G
|
||||||
):
|
):
|
||||||
gapman: GapOverlayMngr|None = godw.gapman
|
gapman: GapOverlayMngr|None = godw.gapman
|
||||||
if gapman is not None:
|
if gapman is not None:
|
||||||
_toggle_gap_overlays(
|
fqme: str
|
||||||
view=view,
|
ds: DisplayState
|
||||||
dss=dss,
|
for fqme, ds in dss.items():
|
||||||
gapman=gapman,
|
timeframe: float = (
|
||||||
)
|
HIST_GAP_TIMEFRAME
|
||||||
continue
|
if view._chart is ds.hist_chart
|
||||||
|
else RT_GAP_TIMEFRAME
|
||||||
|
)
|
||||||
|
gap: GapOverlay = gapman.toggle(
|
||||||
|
ds=ds,
|
||||||
|
timeframe=timeframe,
|
||||||
|
)
|
||||||
|
log.info(
|
||||||
|
'Toggled chart-local gap overlay:\n'
|
||||||
|
f'fqme: {fqme}\n'
|
||||||
|
f'timeframe: {timeframe}s\n'
|
||||||
|
f'visible: {gap.visible}\n'
|
||||||
|
f'gaps: {gap.gap_count}\n'
|
||||||
|
)
|
||||||
|
|
||||||
# ------ - ------
|
# ------ - ------
|
||||||
# SEARCH MODE
|
# SEARCH MODE
|
||||||
|
|
@ -386,7 +363,7 @@ async def handle_viewmode_kb_inputs(
|
||||||
fast_key_seq.clear()
|
fast_key_seq.clear()
|
||||||
|
|
||||||
# release branch
|
# release branch
|
||||||
elif etype in {QEvent.Type.KeyRelease}:
|
elif etype in {QEvent.KeyRelease}:
|
||||||
|
|
||||||
if on_next_release:
|
if on_next_release:
|
||||||
on_next_release()
|
on_next_release()
|
||||||
|
|
@ -698,8 +675,8 @@ class ChartView(ViewBox):
|
||||||
_event.open_handlers(
|
_event.open_handlers(
|
||||||
[self],
|
[self],
|
||||||
event_types={
|
event_types={
|
||||||
QEvent.Type.KeyPress,
|
QEvent.KeyPress,
|
||||||
QEvent.Type.KeyRelease,
|
QEvent.KeyRelease,
|
||||||
},
|
},
|
||||||
async_handler=partial(
|
async_handler=partial(
|
||||||
handle_viewmode_kb_inputs,
|
handle_viewmode_kb_inputs,
|
||||||
|
|
@ -709,7 +686,7 @@ class ChartView(ViewBox):
|
||||||
_event.open_handlers(
|
_event.open_handlers(
|
||||||
[self],
|
[self],
|
||||||
event_types={
|
event_types={
|
||||||
QEvent.Type.GraphicsSceneMousePress,
|
gs_mouse.GraphicsSceneMousePress,
|
||||||
},
|
},
|
||||||
async_handler=partial(
|
async_handler=partial(
|
||||||
handle_viewmode_mouse,
|
handle_viewmode_mouse,
|
||||||
|
|
|
||||||
|
|
@ -118,10 +118,3 @@ python -m ruff check piker/ui/_gaps.py \
|
||||||
Manual chart qualification must confirm startup rendering, focused-timeframe
|
Manual chart qualification must confirm startup rendering, focused-timeframe
|
||||||
`Ctrl+G`, remote typed replacement, remote disconnect cleanup, cached symbol
|
`Ctrl+G`, remote typed replacement, remote disconnect cleanup, cached symbol
|
||||||
switching and refresh after backfill repair.
|
switching and refresh after backfill repair.
|
||||||
|
|
||||||
Automated integration coverage uses real offscreen Qt plots and a real
|
|
||||||
Tractor child actor. It verifies manager insertion, replacement, hiding and
|
|
||||||
owner cleanup; prepend repositioning; duplicate-FQME chart identity; startup
|
|
||||||
registration; Qt Ctrl-G event routing; typed endpoint/client exchange; and
|
|
||||||
cancellation with stale-reply recovery. A broker-backed qtractor session and
|
|
||||||
history-revision-triggered refresh remain manual qualification boundaries.
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ Own gap overlays inside the chart actor
|
||||||
|
|
||||||
- 10 production/test files changed
|
- 10 production/test files changed
|
||||||
- 2 architecture plan files added
|
- 2 architecture plan files added
|
||||||
- 25 focused regressions passing
|
- 19 focused regressions passing
|
||||||
- 0 existing branches moved
|
- 0 existing branches moved
|
||||||
|
|
||||||
(this patch was generated in some part by
|
(this patch was generated in some part by
|
||||||
|
|
|
||||||
|
|
@ -2,29 +2,10 @@
|
||||||
Typed chart-local gap-overlay regressions.
|
Typed chart-local gap-overlay regressions.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
from collections.abc import (
|
|
||||||
Callable,
|
|
||||||
Iterator,
|
|
||||||
)
|
|
||||||
from contextlib import AsyncExitStack
|
|
||||||
import os
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
os.environ['QT_QPA_PLATFORM'] = 'offscreen'
|
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PyQt6.QtGui import QKeyEvent
|
|
||||||
from PyQt6.QtWidgets import QGraphicsScene
|
|
||||||
import pyqtgraph as pg
|
|
||||||
import pytest
|
import pytest
|
||||||
import tractor
|
|
||||||
from tractor._testing import tractor_test
|
|
||||||
import trio
|
|
||||||
from trio.testing import wait_all_tasks_blocked
|
|
||||||
|
|
||||||
from piker.ui._annotate import GapAnnotations
|
|
||||||
from piker.ui._display import _register_gap_overlays
|
|
||||||
from piker.ui._gaps import (
|
from piker.ui._gaps import (
|
||||||
GapOverlay,
|
GapOverlay,
|
||||||
GapOverlayMngr,
|
GapOverlayMngr,
|
||||||
|
|
@ -33,129 +14,6 @@ from piker.ui._gaps import (
|
||||||
SetGapOverlay,
|
SetGapOverlay,
|
||||||
gap_specs_from_ohlcv,
|
gap_specs_from_ohlcv,
|
||||||
)
|
)
|
||||||
from piker.ui._interaction import (
|
|
||||||
ChartView,
|
|
||||||
_toggle_gap_overlays,
|
|
||||||
)
|
|
||||||
from piker.ui.qt import (
|
|
||||||
QApplication,
|
|
||||||
QEvent,
|
|
||||||
QPointF,
|
|
||||||
QRectF,
|
|
||||||
Qt,
|
|
||||||
QWidget,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
def test_gap_specs_and_wire_roundtrip() -> None:
|
def test_gap_specs_and_wire_roundtrip() -> None:
|
||||||
|
|
@ -288,738 +146,3 @@ def test_gap_overlay_unknown_fqme_returns_typed_error() -> None:
|
||||||
|
|
||||||
assert resp.request_id == req.request_id
|
assert resp.request_id == req.request_id
|
||||||
assert resp.error == 'No display state for fqme=gone.test'
|
assert resp.error == 'No display state for fqme=gone.test'
|
||||||
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
_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()
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue