"""Shared pytest fixtures + reporter hooks for caption-test test suite. Provides: - proj_dir, path2cfg: real repository paths for integration-style tests that load actual gin presets from in/config_files/. - clear_gin: autouse fixture that wipes gin global state before every test (gin keeps bindings in module-level singleton; tests must not leak). Reporter hooks print "✅/❌ " next to each test's PASSED/FAILED line in -v mode. Preset name lists live in a separate module (`tests/_presets.py`) so test files can import them with a plain `import _presets` — no relative imports, no need for tests/ to be a package. """ from __future__ import annotations from pathlib import Path import gin import pytest DINOV3_PRESETS = ( "gtauav_balanced", "gtauav_balanced_asym", "gtauav_baseline", "gtauav_baseline_asym", "gtauav_image_heavy", "gtauav_text_heavy", ) STRIPNET_PRESETS = ( "gtauav_balanced_stripnet", "gtauav_balanced_stripnet_unfrozen", "gtauav_baseline_stripnet", "gtauav_baseline_stripnet_unfrozen", ) ALL_TRAINING_PRESETS = DINOV3_PRESETS + STRIPNET_PRESETS # --- gin hygiene ----------------------------------------------------------- @pytest.fixture(autouse=True) def clear_gin(): """Wipe gin's global binding state before AND after each test. Gin keeps bindings in module-level singletons; without this fixture, a test that loads a config (or even just calls gin.parse_config_file in a helper) leaks bindings into the next test, leading to flaky failures like 'Unknown configurable' or wrong field values. """ gin.clear_config() yield gin.clear_config() # --- real-repo paths ------------------------------------------------------- @pytest.fixture def proj_dir() -> Path: """Path to repository root (the directory that contains src/, tests/, in/).""" return Path(__file__).resolve().parent.parent @pytest.fixture def path2cfg(proj_dir: Path) -> str: """Trailing-slashed path to in/config_files/, matching `src/main.py`. Per REQUIREMENTS_GIN_STYLE.md §5, src/main.py builds this path as `f"{proj_dir}in/config_files/"`. Tests that exercise the real repo layout should use this fixture verbatim instead of constructing it independently. """ return f"{proj_dir}/in/config_files/" # --- reporter hooks -------------------------------------------------------- def _docstring_summary(item: pytest.Item) -> str | None: """Return the first non-empty line of a test's docstring, or None.""" func = getattr(item, "function", None) or getattr(item, "obj", None) if func is None or not getattr(func, "__doc__", None): return None for line in func.__doc__.strip().splitlines(): stripped = line.strip() if stripped: return stripped return None # Cache nodeid → docstring summary, populated at collection time so the # logreport hook can look them up without re-introspecting the test function. _LAST_SEEN_SUMMARY: dict[str, str] = {} def pytest_collection_modifyitems( config: pytest.Config, items: list[pytest.Item], ) -> None: """Cache each item's docstring summary for later use by the status hook.""" for item in items: summary = _docstring_summary(item) if summary: _LAST_SEEN_SUMMARY[item.nodeid] = summary def pytest_runtest_logreport(report: pytest.TestReport) -> None: """Print test results with parametrized tests grouped under one header. Non-parametrized test: ✅ Parametrized test (first occurrence of the group): Parametrized test (subsequent occurrences): ✅ Pytest emits 3 reports per test (setup → call → teardown). We hook the `call` phase — the one where pass/fail is actually decided. """ if report.when != "call": return summary = _LAST_SEEN_SUMMARY.get(report.nodeid, "(no docstring)") if report.passed: icon = "✅" elif report.failed: icon = "❌" else: icon = "⏭️" # Detect parametrization: pytest encodes params as `nodeid[param1-param2-...]`. if "[" in report.nodeid and report.nodeid.endswith("]"): base_id, _, param_part = report.nodeid.partition("[") param_label = param_part[:-1] or "empty" # strip trailing ']' # Print docstring header only on first encounter of this group. # Leading "\n" separates the header from pytest's progress dot. if base_id not in _PRINTED_GROUP_HEADERS: print(f"\n{summary}") _PRINTED_GROUP_HEADERS.add(base_id) print(f" {icon} {param_label}") else: print(f" {icon} {summary}") # Tracks which parametrized test groups have already had their docstring # header printed. Reset implicitly at the start of each pytest run because # Python re-imports conftest.py. _PRINTED_GROUP_HEADERS: set[str] = set()