From 5b139e6ab1d546dd06f4bec7c9d6e650f17c19cc Mon Sep 17 00:00:00 2001 From: Ed_ Date: Fri, 19 Jun 2026 21:32:24 -0400 Subject: [PATCH] feat(gui_2): add 3 drain-plane render functions (Phase 2, tasks 2.1-2.3) TIER-2 READ conductor/code_styleguides/error_handling.md end-to-end before Phase 2. Adds the drain plane that consumes the 8 controller error attributes (the data plane added by sub-track 3 Phase 6). Module-level functions in src/gui_2.py (lines 7293-7410): - _drain_normalize_errors (helper, lines 7295-7326): duck-typed normalizer for 3 error-container shapes (Optional[ErrorInfo], List[Tuple[str, ErrorInfo]], Dict[str, ErrorInfo]) - render_controller_error_modal (lines 7328-7368): FR-DP-1 Pattern 2 drain point; reads all 8 controller attrs, opens per-attr popups - _render_worker_error_indicator (lines 7370-7385): FR-DP-2 status-bar widget showing worker error count, clickable - _render_last_request_errors_modal (lines 7387-7409): FR-DP-3 per-request error modal opened after AI request completion App class delegation wrappers (lines 1138-1148): - App._render_controller_error_modal -> module-level - App._render_worker_error_indicator -> module-level - App._render_last_request_errors_modal -> module-level Per UI Delegation Pattern: App class has thin wrappers; logic at module level for hot-reload support. 1-space indentation, CRLF. Audit: no new violations introduced (gui_2.py still 25 V + 13 S + 2 RETHROW + 2 UNCLEAR + 12 COMPLIANT = 54). Tests: 4/4 pass. --- src/gui_2.py | 132 ++++++++++++++++++++++++++++++++++++- tests/test_gui_2_result.py | 86 +++++++++++++++++++++++- 2 files changed, 216 insertions(+), 2 deletions(-) diff --git a/src/gui_2.py b/src/gui_2.py index a96d5202..5681abf2 100644 --- a/src/gui_2.py +++ b/src/gui_2.py @@ -1135,6 +1135,18 @@ class App: self.show_windows[name] = bool(opened) if exp: render_func() + def _render_controller_error_modal(self) -> None: + """Thin delegation wrapper for render_controller_error_modal (drain plane). [SDM: src/gui_2.py:App._render_controller_error_modal]""" + render_controller_error_modal(self) + + def _render_worker_error_indicator(self) -> None: + """Thin delegation wrapper for _render_worker_error_indicator (drain plane). [SDM: src/gui_2.py:App._render_worker_error_indicator]""" + _render_worker_error_indicator(self) + + def _render_last_request_errors_modal(self) -> None: + """Thin delegation wrapper for _render_last_request_errors_modal (drain plane). [SDM: src/gui_2.py:App._render_last_request_errors_modal]""" + _render_last_request_errors_modal(self) + def _show_menus(self) -> None: global win32gui, win32con if win32gui is None: @@ -7276,7 +7288,125 @@ def render_mma_focus_selector(app: App) -> None: for tier in ["Tier 2", "Tier 3", "Tier 4"]: if imgui.selectable(tier, app.ui_focus_agent == tier)[0]: app.ui_focus_agent = tier imgui.end_combo() - imgui.same_line() if app.ui_focus_agent and imgui.button("x##clear_focus"): app.ui_focus_agent = None +#region: Drain Plane (result_migration_gui_2_20260619 Phase 2) + +def _drain_normalize_errors(value: Any) -> list: + """Normalize any of the 8 controller error attribute shapes to a flat list. + + The 8 attributes (added by sub-track 3 Phase 6) have three distinct shapes: + - Optional[ErrorInfo]: single error or None + (_signal_handler_error, _inject_preview_error, _mcp_config_parse_error, + _save_project_error) + - List[Tuple[str, ErrorInfo]]: append-only (op_name, error) tuples + (_last_request_errors, _worker_errors, _startup_timeline_errors) + - Dict[str, ErrorInfo]: provider -> error + (_model_fetch_errors) + + This helper extracts the ErrorInfo objects from each shape so the render + loop can iterate uniformly. Implemented via duck typing (no isinstance + against ErrorInfo, since gui_2.py intentionally does not import the + result_types module to keep the import graph lean). + + [SDM: src/gui_2.py:_drain_normalize_errors] + """ + if value is None: + return [] + if isinstance(value, dict): + return list(value.values()) + if isinstance(value, (list, tuple)): + result = [] + for item in value: + if isinstance(item, tuple) and len(item) >= 2: + result.append(item[1]) + else: + result.append(item) + return result + return [value] + +def render_controller_error_modal(app: "App") -> None: + """Drain plane: read the 8 controller error attributes; open imgui.open_popup for each non-empty. + + This is the canonical Pattern 2 drain point (per error_handling.md:396-407): + the caller (the render loop) calls this every frame; the function is non-blocking + and only triggers popups for non-empty error states. + + The 8 controller attributes drained here are the data plane added by + sub-track 3 (result_migration_app_controller_20260618). The render functions + consume them and surface them to the user via ImGui popups. + + [C: src/gui_2.py:_render_controller_error_modal delegation wrapper, + src/gui_2.py:_render_main_interface (call site TBD)] + """ + attrs = [ + ("_last_request_errors", "Last Request Errors"), + ("_worker_errors", "Worker Errors"), + ("_startup_timeline_errors", "Startup Timeline Errors"), + ("_signal_handler_error", "Signal Handler Error"), + ("_inject_preview_error", "Inject Preview Error"), + ("_mcp_config_parse_error", "MCP Config Parse Error"), + ("_save_project_error", "Save Project Error"), + ("_model_fetch_errors", "Model Fetch Errors"), + ] + for attr_name, popup_title in attrs: + raw = getattr(app.controller, attr_name, None) + errs = _drain_normalize_errors(raw) + if not errs: + continue + popup_id = f"{popup_title}##{attr_name}" + imgui.open_popup(popup_id) + opened, _ = imgui.begin_popup(popup_id) + if opened: + imgui.text(f"{popup_title}: {len(errs)} error{'s' if len(errs) != 1 else ''}") + imgui.separator() + for e in errs: + imgui.text_wrapped(getattr(e, "ui_message", lambda: str(e))()) + imgui.separator() + if imgui.button("OK"): + imgui.close_current_popup() + imgui.end_popup() + +def _render_worker_error_indicator(app: "App") -> None: + """Status-bar widget showing worker error count. Click opens the controller error modal. + + Visible only when app.controller._worker_errors is non-empty. Per the + UI delegation pattern (conductor/product-guidelines.md), the function lives + at module level and the App class wraps it as a thin delegation. + + [C: src/gui_2.py:App._render_worker_error_indicator delegation wrapper] + """ + errs = getattr(app.controller, "_worker_errors", None) + if not errs: + return + n = len(errs) + imgui.text(f"[!] {n} worker error{'s' if n != 1 else ''}") + if imgui.is_item_hovered() and imgui.is_mouse_clicked(0): + render_controller_error_modal(app) + +def _render_last_request_errors_modal(app: "App") -> None: + """Per-request error modal. Surfaced after each AI request via the request completion hook. + + Opens a modal only if errors accumulated during the request + (app.controller._last_request_errors is non-empty). + + [C: src/gui_2.py:App._render_last_request_errors_modal delegation wrapper] + """ + errs = getattr(app.controller, "_last_request_errors", None) + if not errs: + return + popup_id = "Request Errors##_last_request_errors" + opened, _ = imgui.begin_popup_modal(popup_id) + if opened: + imgui.text(f"{len(errs)} error{'s' if len(errs) != 1 else ''} during last request:") + imgui.separator() + for e in errs: + imgui.text_wrapped(getattr(e, "ui_message", lambda: str(e))()) + imgui.separator() + if imgui.button("OK"): + imgui.close_current_popup() + imgui.end_popup() + +#endregion: Drain Plane + #endregion: MMA diff --git a/tests/test_gui_2_result.py b/tests/test_gui_2_result.py index 4b489059..184e78ab 100644 --- a/tests/test_gui_2_result.py +++ b/tests/test_gui_2_result.py @@ -116,4 +116,88 @@ def test_phase_1_audit_has_42_migration_target_sites(): f"expected {EXPECTED_SITE_COUNT}. Categories seen: " f"{sorted({f.get('category') for f in migration_sites})}. " f"This must match the 42 sites declared in PHASE1_SITE_INVENTORY.md." - ) \ No newline at end of file + ) + + +def test_phase_2_invariant_drain_plane_render_functions_exist(): + """ + Verify the 3 new module-level render functions exist in src/gui_2.py: + - render_controller_error_modal + - _render_worker_error_indicator + - _render_last_request_errors_modal + + These are the drain-plane functions added in Phase 2 of the + result_migration_gui_2_20260619 track. They read the 8 controller + error attributes (added by sub-track 3 Phase 6) and surface them + to the user via ImGui popups and indicators. + + The test imports src.gui_2 and inspects the module for the function + names. A failure here means the drain-plane wiring is incomplete. + """ + import inspect + import src.gui_2 as gui2_mod + assert hasattr(gui2_mod, "render_controller_error_modal"), ( + "src/gui_2.py is missing the module-level function " + "'render_controller_error_modal'. This is the FR-DP-1 drain plane " + "function; it must be added per the result_migration_gui_2_20260619 " + "Phase 2 spec." + ) + assert hasattr(gui2_mod, "_render_worker_error_indicator"), ( + "src/gui_2.py is missing the module-level function " + "'_render_worker_error_indicator'. This is the FR-DP-2 drain plane " + "function; it must be added per the result_migration_gui_2_20260619 " + "Phase 2 spec." + ) + assert hasattr(gui2_mod, "_render_last_request_errors_modal"), ( + "src/gui_2.py is missing the module-level function " + "'_render_last_request_errors_modal'. This is the FR-DP-3 drain plane " + "function; it must be added per the result_migration_gui_2_20260619 " + "Phase 2 spec." + ) + assert callable(getattr(gui2_mod, "render_controller_error_modal")), ( + "render_controller_error_modal exists but is not callable." + ) + assert callable(getattr(gui2_mod, "_render_worker_error_indicator")), ( + "_render_worker_error_indicator exists but is not callable." + ) + assert callable(getattr(gui2_mod, "_render_last_request_errors_modal")), ( + "_render_last_request_errors_modal exists but is not callable." + ) + + +def test_phase_2_invariant_drain_plane_app_delegations_exist(): + """ + Verify the 3 new App class delegation methods exist in src/gui_2.py: + - App._render_controller_error_modal + - App._render_worker_error_indicator + - App._render_last_request_errors_modal + + Per conductor/product-guidelines.md ยง"UI Delegation for Hot-Reload", + the App class must contain only thin delegation wrappers; the actual + logic lives in module-level functions. This test locks the + delegation contract for Phase 2. + + The test imports src.gui_2, gets the App class via the module + (lazily - via the _LazyModule path or directly), and checks for + the methods. + """ + import src.gui_2 as gui2_mod + app_cls = getattr(gui2_mod, "App", None) + assert app_cls is not None, ( + "src.gui_2 has no 'App' class attribute. Cannot verify delegations." + ) + for method_name in ( + "_render_controller_error_modal", + "_render_worker_error_indicator", + "_render_last_request_errors_modal", + ): + assert hasattr(app_cls, method_name), ( + f"App class is missing delegation method '{method_name}'. " + f"The drain plane requires the App class to delegate to the " + f"module-level render functions so the UI delegation pattern " + f"supports hot-reload." + ) + method = getattr(app_cls, method_name) + assert callable(method), ( + f"App.{method_name} exists but is not callable." + ) \ No newline at end of file