central/tests/test_preview_hook.py
zvx 570b121276 fix(2-G.5): preview_for_settings contract in adapter docstring + distinguish [] from None
Fixup 1 — Contract section appended to SourceAdapter.preview_for_settings's
docstring. Override authors read adapter.py, not routes.py, so the contract
(pure function of settings; open your own short-lived aiohttp session; None
vs [] semantics) belongs on the base method, not on the GUI stub class.

Fixup 2 — _adapter_preview.html distinguishes [] from None. Previously the
elif test was truthiness (`elif preview_rows`) which collapsed both into
"render nothing". Now uses `elif preview_rows is not none` and special-cases
the empty-list case inside: legend "Preview (0 rows)" with no table; None
still renders nothing at all. Lets adapters signal "query ran, matched zero"
distinctly from "preview not meaningful".

Tests +1:
- test_partial_renders_empty_list — [] yields "Preview (0 rows)" legend,
  no table, no headers. Distinct from the existing None case.

Acceptance:
- 27/27 targeted (preview_hook +1 new, nwis, stream_registry).
- 458/458 full suite.
- (b) framework GUI dir still has zero adapter-name branches.
2026-05-19 17:55:39 +00:00

120 lines
3.8 KiB
Python

"""Tests for the SourceAdapter.preview_for_settings hook + framework rendering."""
from collections.abc import AsyncIterator
from pathlib import Path
import jinja2
import pytest
from pydantic import BaseModel
from central.adapter import SourceAdapter
from central.models import Event
TEMPLATES_DIR = Path(__file__).resolve().parents[1] / "src" / "central" / "gui" / "templates"
class _StubSettings(BaseModel):
pass
class _StubAdapter(SourceAdapter):
"""Minimal SourceAdapter subclass that does NOT override preview_for_settings."""
name = "stub"
display_name = "Stub Adapter"
description = "Test fixture"
settings_schema = _StubSettings
default_cadence_s = 60
def __init__(self) -> None:
pass
async def poll(self) -> AsyncIterator[Event]: # pragma: no cover - never invoked
if False:
yield # type: ignore[unreachable]
return
async def apply_config(self, new_config) -> None: # pragma: no cover
pass
def subject_for(self, event: Event) -> str: # pragma: no cover
return "stub"
@pytest.mark.asyncio
async def test_default_returns_none():
"""The base SourceAdapter's preview_for_settings default returns None.
Any adapter that does not override the hook gets a no-op preview, so the
framework renders the page without a preview block (no crash, no opt-out
boilerplate required in each adapter).
"""
adapter = _StubAdapter()
result = await adapter.preview_for_settings(_StubSettings())
assert result is None
def _render_partial(**context) -> str:
env = jinja2.Environment(
loader=jinja2.FileSystemLoader(str(TEMPLATES_DIR)),
autoescape=jinja2.select_autoescape(["html"]),
)
tmpl = env.get_template("_adapter_preview.html")
return tmpl.render(**context)
def test_partial_renders_list():
"""Given list[dict] with insertion-ordered keys, partial renders a table.
Framework is duck-typed: columns come from the first dict's keys. No
adapter-name branches, no per-adapter Jinja.
"""
rows = [
{"a": "x1", "b": "y1"},
{"a": "x2", "b": "y2"},
]
out = _render_partial(preview_rows=rows, preview_error=None)
# Header row uses first dict's keys in order.
assert "<th>a</th>" in out
assert "<th>b</th>" in out
# Body rows carry every value.
for cell in ("x1", "y1", "x2", "y2"):
assert cell in out
# Row count surfaces in the legend.
assert "Preview (2 rows)" in out
# No error banner when preview_error is None.
assert "Preview Unavailable" not in out
def test_partial_renders_error():
"""When preview_error is set, partial renders the error banner and no table."""
out = _render_partial(preview_rows=None, preview_error="Preview unavailable: boom")
assert "Preview unavailable: boom" in out
# No table when an error is set.
assert "<table" not in out
assert "<th>" not in out
def test_partial_renders_nothing_when_both_none():
"""No preview_rows and no preview_error -> partial renders no preview section.
Lets adapters that don't opt in (e.g. EONET, GDACS) render normally without
any preview-related markup on the page.
"""
out = _render_partial(preview_rows=None, preview_error=None).strip()
assert "<table" not in out
assert "Preview Unavailable" not in out
# Either empty or only whitespace/newlines from the template.
assert "Preview (" not in out
def test_partial_renders_empty_list():
"""Empty list -> legend with (0 rows), no table.
Distinct from None (which renders nothing at all). Lets adapters signal
'query ran, matched zero rows' separately from 'preview not meaningful'.
"""
out = _render_partial(preview_rows=[], preview_error=None)
assert "Preview (0 rows)" in out
assert "<table" not in out
assert "<th>" not in out