mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
test: isolate DisplayManager globals across modules, and name the failure (#563)
Follow-up to #562. That commit fixed the actual cause of the intermittent 15-test failure in test_display_dirty_tracking.py -- the emulator's fixed TCP port 8888, a machine-wide singleton that a concurrent pytest process takes away. This adds the two things that would have made it a five-minute diagnosis instead of a long one, and closes the other door into the same failure. Confirmed the module is order-independent as it stands, on this checkout: pytest test/ -q, three times 115 failed / 4464 passed / 63 skipped, byte-identical failure sets, the module 21/21 passed each time module forced last (197 files first) identical failure set module forced first identical failure set module after each of test_display_manager, test_display_controller, test_display_controller_vegas_tick, test_skin_system, test_sports_scroll, test_initial_update_budget, test_display_double_parity, test_initializing_screen all pass four concurrent processes on the file 21/21 each And reproduced the original, to be sure the diagnosis in #562 is the whole story. Holding 0.0.0.0:8888 from a separate process: HEAD's test/conftest.py 21 passed pre-#562 test/conftest.py 15 failed, 6 passed The 15/6 split is not arbitrary: the six survivors are the only tests in the file that never touch dm.matrix. conftest.py: DisplayManager is a process-wide singleton and the RGBMatrix / RGBMatrixOptions names it constructs through are module globals, bound once at import. All three are shared by every test module in the run, so a module that leaves an instance in _instance -- or leaves patch('src.display_manager. RGBMatrix') standing -- changes what the NEXT module builds, invisibly, and only in a full run. A module-scoped autouse fixture now resets the singleton and restores either binding if a patch outlived its module. Module-scoped rather than per-test so that files sharing one manager across their own tests keep doing so; only the leak across the module boundary is cut. Autouse fixtures are set up ahead of requested ones, so this is finalised after a module's own DisplayManager fixture. Verified with a throwaway pair of probe modules -- one leaks a patch and a singleton, the next asserts both are clean -- which passed and were then removed. test_display_dirty_tracking.py: _setup_matrix() swallows every construction failure and falls back to matrix=None, so a broken environment arrived as fifteen identical "'NoneType' object has no attribute 'SwapOnVSync'" errors naming neither the fixture nor the cause. The fixture now fails once, and says where to look; under a held port it reads DisplayManager fell back to matrix=None: RGBMatrix construction raised... Known causes: the emulator adapter losing a fixed TCP port to another process -- see pytest_configure in test/conftest.py -- or a patch('src.display_manager.RGBMatrix') leaked from an earlier test module. with WinError 10048 in the captured log directly above it. No regressions: full suite with both changes is 115 failed / 4464 passed / 63 skipped, failure set identical to the pre-change baseline. The 115 is the pre-existing Windows-environment baseline (os.geteuid, POSIX modes, fcntl); CI on Linux remains authoritative. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+56
-1
@@ -7,7 +7,7 @@ Provides common fixtures for mocking core components and test setup.
|
||||
import pytest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, MagicMock
|
||||
from unittest.mock import Mock, MagicMock, NonCallableMock
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
# Add project root to path
|
||||
@@ -70,6 +70,61 @@ def pytest_unconfigure(config):
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
# DisplayManager is a process-wide singleton, and the RGBMatrix /
|
||||
# RGBMatrixOptions names it builds through are module globals bound once at
|
||||
# import to either the emulator or the hardware library. All three are shared
|
||||
# by every test module in the run, so a module that leaves a live instance in
|
||||
# _instance -- or leaves patch('src.display_manager.RGBMatrix') standing --
|
||||
# changes what the NEXT module constructs. None of that is visible when the
|
||||
# affected file is run on its own; it surfaces as a full-suite failure that
|
||||
# does not reproduce. The full-run failure diff is how a change is confirmed
|
||||
# non-regressive, so it has to mean the same thing on every run.
|
||||
_PRISTINE_MATRIX_BINDINGS = {}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="module")
|
||||
def _reset_display_manager_globals():
|
||||
"""Undo any DisplayManager global state a test module leaves behind.
|
||||
|
||||
Autouse fixtures are set up ahead of the fixtures a test asks for, so this
|
||||
one is finalised after them -- including after a module-scoped fixture that
|
||||
owns a real DisplayManager (test_display_dirty_tracking.py's ``dm``).
|
||||
Module-scoped rather than per-test: files that deliberately share one
|
||||
manager across their own tests keep doing so; only the leak across the
|
||||
module boundary is cut.
|
||||
"""
|
||||
# Record the real bindings on the way in, while no patch of this module's
|
||||
# is active yet -- reading them on the way out would record the leak.
|
||||
_remember_matrix_bindings()
|
||||
yield
|
||||
|
||||
dm_mod = sys.modules.get("src.display_manager")
|
||||
if dm_mod is None:
|
||||
return # Module never imported it; nothing to reset.
|
||||
|
||||
# An instance left here is what the next module's DisplayManager() call
|
||||
# gets back -- potentially one built against a MagicMock matrix.
|
||||
dm_mod.DisplayManager._instance = None
|
||||
dm_mod.DisplayManager._initialized = False
|
||||
|
||||
# Put a binding back if a patch outlived the module that started it.
|
||||
# Restoring rather than failing: a leak reported against an innocent module
|
||||
# later in the run is the diagnosis problem, not the fix for it.
|
||||
for name, pristine in _PRISTINE_MATRIX_BINDINGS.items():
|
||||
if isinstance(getattr(dm_mod, name, None), NonCallableMock):
|
||||
setattr(dm_mod, name, pristine)
|
||||
|
||||
|
||||
def _remember_matrix_bindings():
|
||||
dm_mod = sys.modules.get("src.display_manager")
|
||||
if dm_mod is None:
|
||||
return
|
||||
for name in ("RGBMatrix", "RGBMatrixOptions"):
|
||||
current = getattr(dm_mod, name, None)
|
||||
if current is not None and not isinstance(current, NonCallableMock):
|
||||
_PRISTINE_MATRIX_BINDINGS.setdefault(name, current)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_display_manager():
|
||||
"""Create a mock DisplayManager for testing."""
|
||||
|
||||
@@ -44,6 +44,20 @@ def dm(tmp_path_factory):
|
||||
# this session; the individual tests that care still override it further.
|
||||
manager._snapshot_path = str(
|
||||
tmp_path_factory.mktemp("dirty_tracking") / "led_matrix_preview.png")
|
||||
# _setup_matrix() swallows every construction failure and falls back to
|
||||
# matrix=None, so a broken environment reaches the tests as fifteen
|
||||
# identical "'NoneType' object has no attribute 'SwapOnVSync'" errors that
|
||||
# name neither this fixture nor the real cause. Fail here instead, once,
|
||||
# and say where to look.
|
||||
if manager.matrix is None:
|
||||
pytest.fail(
|
||||
"DisplayManager fell back to matrix=None: RGBMatrix construction "
|
||||
"raised (the 'Failed to initialize RGB Matrix' log line above "
|
||||
"carries the reason). Known causes: the emulator adapter losing a "
|
||||
"fixed TCP port to another process -- see pytest_configure in "
|
||||
"test/conftest.py, which pins the port-free 'raw' adapter -- or a "
|
||||
"patch('src.display_manager.RGBMatrix') leaked from an earlier "
|
||||
"test module.")
|
||||
yield manager
|
||||
DisplayManager._instance = None
|
||||
DisplayManager._initialized = False
|
||||
|
||||
Reference in New Issue
Block a user