|
| 1 | +"""Batch-10 Stage 0 guardrails: structural invariants that make any future |
| 2 | +``ExtrapolationWindow`` mixin split provably behavior-preserving. |
| 3 | +
|
| 4 | +These are pure characterization tests (no production change). Two external |
| 5 | +adversarial reviews (Codex + Antigravity/Gemini) of the decomposition plan |
| 6 | +identified the failure modes below; this file pins them so a split PR that would |
| 7 | +trip one fails loudly instead of silently changing behavior. |
| 8 | +
|
| 9 | +See docs/BATCH10_WINDOW_DECOMPOSITION_PLAN.md. |
| 10 | +""" |
| 11 | + |
| 12 | +from __future__ import annotations |
| 13 | + |
| 14 | +import os |
| 15 | +from collections import defaultdict |
| 16 | + |
| 17 | +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") |
| 18 | + |
| 19 | +import pytest |
| 20 | + |
| 21 | +pytest.importorskip("PySide6") |
| 22 | + |
| 23 | +from PySide6.QtWidgets import QMainWindow |
| 24 | + |
| 25 | +from app_desktop.window import ExtrapolationWindow |
| 26 | + |
| 27 | + |
| 28 | +def _window_mixins() -> list[type]: |
| 29 | + """The Window*Mixin classes composed into ExtrapolationWindow, in MRO order.""" |
| 30 | + return [ |
| 31 | + cls |
| 32 | + for cls in ExtrapolationWindow.__mro__ |
| 33 | + if cls.__name__.startswith("Window") and cls.__name__.endswith("Mixin") |
| 34 | + ] |
| 35 | + |
| 36 | + |
| 37 | +# --- Invariant 1: MRO snapshot (provider order is load-bearing) ----------------- |
| 38 | +# The Qt C++ bases MUST precede every mixin; a split must not re-order this. |
| 39 | +_EXPECTED_MRO = [ |
| 40 | + "ExtrapolationWindow", |
| 41 | + "QMainWindow", |
| 42 | + "QWidget", |
| 43 | + "QObject", |
| 44 | + "QPaintDevice", |
| 45 | + "Object", |
| 46 | + "WindowLatexPdfMixin", |
| 47 | + "WindowI18nMixin", |
| 48 | + "WindowImagesMixin", |
| 49 | + "WindowStatisticsMixin", |
| 50 | + "WindowDataMixin", |
| 51 | + "WindowFittingMixin", |
| 52 | + "WindowFittingResidualsMixin", |
| 53 | + "WindowFittingModelsMixin", |
| 54 | + "WindowFittingFormattersMixin", |
| 55 | + "WindowExtrapolationMixin", |
| 56 | + "object", |
| 57 | +] |
| 58 | + |
| 59 | + |
| 60 | +def test_extrapolation_window_mro_is_frozen(): |
| 61 | + """A split that re-orders bases (or forgets a shim's sub-mixins) changes |
| 62 | + method-resolution precedence — freeze the full MRO. Update this list |
| 63 | + *deliberately* when intentionally adding/splitting a mixin.""" |
| 64 | + actual = [c.__name__ for c in ExtrapolationWindow.__mro__] |
| 65 | + assert actual == _EXPECTED_MRO, ( |
| 66 | + "ExtrapolationWindow MRO changed. If this is an intentional mixin " |
| 67 | + "split/reorder, update _EXPECTED_MRO; otherwise the change silently " |
| 68 | + "alters method precedence.\n" |
| 69 | + f"expected: {_EXPECTED_MRO}\nactual: {actual}" |
| 70 | + ) |
| 71 | + |
| 72 | + |
| 73 | +# --- Invariant 2: Qt bases sit before the mixins (severance hazard) -------------- |
| 74 | +def test_qt_bases_precede_all_mixins_in_mro(): |
| 75 | + """QMainWindow (a C++ wrapper that does NOT cooperatively call super()) |
| 76 | + precedes every mixin, so a Qt event override placed in a mixin would be |
| 77 | + silently unreachable. This test documents that ordering so the next guard |
| 78 | + (no Qt-event overrides in mixins) is meaningful.""" |
| 79 | + names = [c.__name__ for c in ExtrapolationWindow.__mro__] |
| 80 | + qmain = names.index("QMainWindow") |
| 81 | + first_mixin = min( |
| 82 | + (i for i, n in enumerate(names) if n.startswith("Window") and n.endswith("Mixin")), |
| 83 | + default=len(names), |
| 84 | + ) |
| 85 | + assert qmain < first_mixin, "QMainWindow must precede the mixins in the MRO" |
| 86 | + |
| 87 | + |
| 88 | +# --- Invariant 3: no mixin overrides a Qt event handler ------------------------- |
| 89 | +_QT_EVENT_HANDLERS = frozenset(n for n in dir(QMainWindow) if n.endswith("Event")) |
| 90 | + |
| 91 | + |
| 92 | +def test_no_mixin_overrides_a_qt_event_handler(): |
| 93 | + """A mixin that defines e.g. closeEvent/resizeEvent/showEvent would be |
| 94 | + silently ignored (Qt C++ base severs the cooperative-super chain). Forbid it |
| 95 | + so a split can't accidentally move Qt event handling into a mixin (Gemini).""" |
| 96 | + offenders = [] |
| 97 | + for cls in _window_mixins(): |
| 98 | + for name in cls.__dict__: |
| 99 | + if name in _QT_EVENT_HANDLERS: |
| 100 | + offenders.append(f"{cls.__name__}.{name}") |
| 101 | + assert offenders == [], ( |
| 102 | + "Window mixins must not override Qt event handlers (unreachable through " |
| 103 | + f"the Qt C++ base). Offenders: {offenders}" |
| 104 | + ) |
| 105 | + |
| 106 | + |
| 107 | +# --- Invariant 4: no unexpected duplicate method names across sibling mixins ------ |
| 108 | +# Splitting a monolith bottom->top and composing Shim(A_top, B, C_bottom) reverses |
| 109 | +# shadowing precedence (A shadows C under left-to-right MRO). Any duplicate method |
| 110 | +# name across mixins is therefore a precedence hazard — allow only the KNOWN, |
| 111 | +# intentional shim override, fail on anything new (Codex + Gemini). |
| 112 | +_ALLOWED_DUP_METHODS = { |
| 113 | + # The fitting shim intentionally overrides this: it calls super() then adds |
| 114 | + # fallback-history UI (window_fitting_mixin.py). Both the shim and the |
| 115 | + # residuals sub-mixin define it, by design. |
| 116 | + "_on_fit_finished": {"WindowFittingMixin", "WindowFittingResidualsMixin"}, |
| 117 | +} |
| 118 | + |
| 119 | + |
| 120 | +def test_no_unexpected_duplicate_method_names_across_mixins(): |
| 121 | + owners: dict[str, set[str]] = defaultdict(set) |
| 122 | + for cls in _window_mixins(): |
| 123 | + for name, val in cls.__dict__.items(): |
| 124 | + if callable(val) and not name.startswith("__"): |
| 125 | + owners[name].add(cls.__name__) |
| 126 | + dups = {n: cs for n, cs in owners.items() if len(cs) > 1} |
| 127 | + unexpected = {n: cs for n, cs in dups.items() if _ALLOWED_DUP_METHODS.get(n) != cs} |
| 128 | + assert unexpected == {}, ( |
| 129 | + "Unexpected method-name collision across sibling window mixins — under " |
| 130 | + "left-to-right MRO the earlier mixin shadows the later one, which a " |
| 131 | + "split can silently reverse. Resolve the collision or, if intentional, " |
| 132 | + f"add it to _ALLOWED_DUP_METHODS. Collisions: {unexpected}" |
| 133 | + ) |
| 134 | + |
| 135 | + |
| 136 | +# --- Invariant 5: split mixins define no __init__ (construction order) ---------- |
| 137 | +def test_window_mixins_do_not_define_init(): |
| 138 | + """Mixins must not define __init__ — ExtrapolationWindow.__init__ drives |
| 139 | + construction, and a mixin __init__ would either be skipped or fight the Qt |
| 140 | + base's construction order. A split must keep pulling all setup from the |
| 141 | + window's own __init__ (Codex/Gemini Stage-0 check).""" |
| 142 | + offenders = [cls.__name__ for cls in _window_mixins() if "__init__" in cls.__dict__] |
| 143 | + assert offenders == [], f"Window mixins must not define __init__: {offenders}" |
| 144 | + |
| 145 | + |
| 146 | +# --- Invariant 6: internal helpers imported by tests stay importable ------------- |
| 147 | +# Splits must re-export moved internals from their original module, or these |
| 148 | +# direct imports (in the test suite) break. Enumerated so a split is forced to |
| 149 | +# preserve them (Codex). |
| 150 | +def test_directly_imported_mixin_internals_remain_importable(): |
| 151 | + from app_desktop.window_statistics_mixin import ( # noqa: F401 |
| 152 | + _statistics_raw_table_preserving_cells, |
| 153 | + ) |
0 commit comments