# """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 '✅/❌/⏭️ ' after each test's `call` phase finishes. # Pytest emits 3 reports per test (setup → call → teardown). We hook the # `call` phase — the one where pass/fail is actually decided — and write # the icon + docstring summary to stdout, where it appears next to # pytest's own PASSED/FAILED line. # """ # if report.when != "call": # return # summary = _LAST_SEEN_SUMMARY.get(report.nodeid, "(no docstring)") # if report.passed: # icon = "✅" # elif report.failed: # icon = "❌" # else: # icon = "⏭️" # print(f" {icon} {summary}") """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()