Fix Qt indexing in `GapAnnotations.reposition()`

`prepareGeometryChange()` ran after the gap rects and arrow specs
were mutated. Qt could retain the item's old scene index even
though `boundingRect()` returned its new prepended-history
position.

Invalidate cached bounds and notify Qt before changing any
geometry. Cover both scene lookup positions with a real offscreen
item.

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
chart_local_gapper
Gud Boi 2026-08-17 19:08:28 -04:00
parent 78982d8280
commit 711666f9c8
2 changed files with 84 additions and 4 deletions

View File

@ -670,6 +670,11 @@ 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
rect_memory = self._rectarray.ndarray()
@ -777,7 +782,4 @@ class GapAnnotations(GraphicsObject):
self._arrow_path.addPolygon(arrow_poly)
self._arrow_path.closeSubpath()
# invalidate bounding rect cache
self._br = None
self.prepareGeometryChange()
self.update()

View File

@ -10,6 +10,7 @@ os.environ['QT_QPA_PLATFORM'] = 'offscreen'
import msgspec
import numpy as np
from PyQt6.QtWidgets import QGraphicsScene
import pyqtgraph as pg
import pytest
@ -22,7 +23,11 @@ from piker.ui._gaps import (
SetGapOverlay,
gap_specs_from_ohlcv,
)
from piker.ui.qt import QApplication
from piker.ui.qt import (
QApplication,
QPointF,
QRectF,
)
FQME: str = 'gap.test'
@ -387,6 +392,79 @@ def test_gap_manager_real_qt_lifecycle(
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,