mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-06 23:35:08 +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 pytest
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import Mock, MagicMock
|
from unittest.mock import Mock, MagicMock, NonCallableMock
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
# Add project root to path
|
# Add project root to path
|
||||||
@@ -70,6 +70,61 @@ def pytest_unconfigure(config):
|
|||||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
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
|
@pytest.fixture
|
||||||
def mock_display_manager():
|
def mock_display_manager():
|
||||||
"""Create a mock DisplayManager for testing."""
|
"""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.
|
# this session; the individual tests that care still override it further.
|
||||||
manager._snapshot_path = str(
|
manager._snapshot_path = str(
|
||||||
tmp_path_factory.mktemp("dirty_tracking") / "led_matrix_preview.png")
|
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
|
yield manager
|
||||||
DisplayManager._instance = None
|
DisplayManager._instance = None
|
||||||
DisplayManager._initialized = False
|
DisplayManager._initialized = False
|
||||||
|
|||||||
Reference in New Issue
Block a user