Files
caption-test/tests/conftest.py

295 lines
9.1 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 '✅/❌/⏭️ <docstring>' 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 "✅/❌ <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()