From 335190a4e3ff5712626288ac5e81693b9646c140 Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 17 Aug 2026 19:11:03 -0400 Subject: [PATCH] Test gap dialogs through a real `tractor` actor Schema checks do not exercise ctx discovery, directional decoding, stream setup, request correlation or endpoint-finally cleanup. Boot an isolated child actor around `remote_gap_overlays()` and drive it through `AnnotClient`. Cancel one published request, leave its reply queued and prove the next request discards that stale msg. Also, - verify generated request IDs and remote-owner cleanup; - document automated coverage and broker-backed manual boundaries. Prompt-IO: ai/prompt-io/opencode/20260817T222036Z_f65df6a1_prompt_io.md (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`)) --- .../20260817T222036Z_f65df6a1_prompt_io.md | 39 +++ ...20260817T222036Z_f65df6a1_prompt_io.raw.md | 59 ++++ plans/opencode/chart-local-gap-overlays.md | 7 + .../chart-local-gap-overlays.summary.md | 2 +- tests/test_gap_overlays.py | 298 +++++++++++++++++- 5 files changed, 403 insertions(+), 2 deletions(-) create mode 100644 ai/prompt-io/opencode/20260817T222036Z_f65df6a1_prompt_io.md create mode 100644 ai/prompt-io/opencode/20260817T222036Z_f65df6a1_prompt_io.raw.md diff --git a/ai/prompt-io/opencode/20260817T222036Z_f65df6a1_prompt_io.md b/ai/prompt-io/opencode/20260817T222036Z_f65df6a1_prompt_io.md new file mode 100644 index 00000000..2b63e6dc --- /dev/null +++ b/ai/prompt-io/opencode/20260817T222036Z_f65df6a1_prompt_io.md @@ -0,0 +1,39 @@ +--- +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. diff --git a/ai/prompt-io/opencode/20260817T222036Z_f65df6a1_prompt_io.raw.md b/ai/prompt-io/opencode/20260817T222036Z_f65df6a1_prompt_io.raw.md new file mode 100644 index 00000000..991045d7 --- /dev/null +++ b/ai/prompt-io/opencode/20260817T222036Z_f65df6a1_prompt_io.raw.md @@ -0,0 +1,59 @@ +--- +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. diff --git a/plans/opencode/chart-local-gap-overlays.md b/plans/opencode/chart-local-gap-overlays.md index 1cd00771..0ab3f82d 100644 --- a/plans/opencode/chart-local-gap-overlays.md +++ b/plans/opencode/chart-local-gap-overlays.md @@ -118,3 +118,10 @@ python -m ruff check piker/ui/_gaps.py \ Manual chart qualification must confirm startup rendering, focused-timeframe `Ctrl+G`, remote typed replacement, remote disconnect cleanup, cached symbol 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. diff --git a/plans/opencode/chart-local-gap-overlays.summary.md b/plans/opencode/chart-local-gap-overlays.summary.md index 8f889cc6..57dea6a6 100644 --- a/plans/opencode/chart-local-gap-overlays.summary.md +++ b/plans/opencode/chart-local-gap-overlays.summary.md @@ -19,7 +19,7 @@ Own gap overlays inside the chart actor - 10 production/test files changed - 2 architecture plan files added -- 19 focused regressions passing +- 25 focused regressions passing - 0 existing branches moved (this patch was generated in some part by diff --git a/tests/test_gap_overlays.py b/tests/test_gap_overlays.py index 393cb247..5dbdee5f 100644 --- a/tests/test_gap_overlays.py +++ b/tests/test_gap_overlays.py @@ -2,7 +2,11 @@ Typed chart-local gap-overlay regressions. ''' -from collections.abc import Iterator +from collections.abc import ( + Callable, + Iterator, +) +from contextlib import AsyncExitStack import os from types import SimpleNamespace @@ -14,6 +18,8 @@ from PyQt6.QtGui import QKeyEvent from PyQt6.QtWidgets import QGraphicsScene import pyqtgraph as pg import pytest +import tractor +from tractor._testing import tractor_test import trio from trio.testing import wait_all_tasks_blocked @@ -727,3 +733,293 @@ def test_ctrl_g_event_renders_real_history_overlay( 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()