Files
caption-test/tests/conftest.py

158 lines
4.9 KiB
Python

"""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 "✅/❌ <docstring>" 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:
✅ <docstring summary>
Parametrized test (first occurrence of the group):
<docstring summary>
✅ <param>
Parametrized test (subsequent occurrences):
✅ <param>
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()