thermograph/tests/web/test_content_loader.py

140 lines
4.2 KiB
Python
Raw Normal View History

Extract the glossary and static-page SEO copy into a schema-validated content/ tree (#241) The weather-terms glossary (9 entries) and four static pages' SEO title/ description were hardcoded directly in web/content.py - a 1019-line module that also owns all SSR rendering logic - mixed in with code that changes on a completely different cadence and for different reasons. Add content/glossary.yaml and content/pages.yaml (a new repo-root content/ tree, a sibling of backend/ and frontend/ paths.py resolves the same way - the seed of a future thermograph-copy repo per the architecture decision doc's own §4) plus web/content_loader.py: a small loader that validates each file's shape at load time (required fields present and non-empty, no duplicate glossary slugs) and fails loudly on a malformed edit rather than rendering a blank glossary card or an empty <title>. content.py's GLOSSARY dict and the about/privacy/hub/glossary_index page_title/description literals now come from the loader. Scope: only content that is genuinely pure static data with no embedded template logic. cities_flavor.json (Wikipedia extracts, already its own generated file) and the homepage's title/description (embedded in home.html.j2 as Jinja block overrides, a heavily-tested product-critical template) are deliberately left as they are - a future pass, not required for this one. UI microcopy bound to frontend logic stays in frontend/, per the doc's own line between content and frontend. Verified: content/glossary.yaml generated programmatically from the live GLOSSARY dict (not hand-transcribed) and round-tripped byte-for-byte identical against it; content/pages.yaml's four entries checked field-by- field against the original hardcoded strings. Full backend suite green (362 passed, 4 skipped) with zero existing test changes needed beyond one assertion made escaping-aware (a pre-existing Jinja double-escape quirk on the one title containing "&amp;", intentionally preserved not fixed). Built and booted the real Docker image: content/ present at /app/content, and curled /glossary, /glossary/percentine, /about, /privacy from inside the running container - all four render with the exact expected title text.
2026-07-21 01:21:11 +00:00
"""Tests for content_loader.py: schema validation (fail loud on a malformed
content/ file) and that the real glossary.yaml/pages.yaml load correctly."""
import pytest
from web import content_loader
def _write(tmp_path, name, text):
path = tmp_path / name
path.write_text(text, encoding="utf-8")
return str(path)
# --- load_glossary: schema validation --------------------------------------
def test_load_glossary_rejects_non_mapping_top_level(tmp_path):
path = _write(tmp_path, "g.yaml", "- just\n- a\n- list\n")
with pytest.raises(ValueError, match="top-level mapping"):
content_loader.load_glossary(path)
def test_load_glossary_rejects_missing_terms_key(tmp_path):
path = _write(tmp_path, "g.yaml", "not_terms: []\n")
with pytest.raises(ValueError, match="non-empty list"):
content_loader.load_glossary(path)
def test_load_glossary_rejects_empty_terms(tmp_path):
path = _write(tmp_path, "g.yaml", "terms: []\n")
with pytest.raises(ValueError, match="non-empty list"):
content_loader.load_glossary(path)
def test_load_glossary_rejects_missing_required_field(tmp_path):
path = _write(tmp_path, "g.yaml", """
terms:
- slug: foo
term: Foo
short: A short definition.
""")
with pytest.raises(ValueError, match="foo.*body"):
content_loader.load_glossary(path)
def test_load_glossary_rejects_blank_required_field(tmp_path):
path = _write(tmp_path, "g.yaml", """
terms:
- slug: foo
term: Foo
short: ""
body: Full body.
""")
with pytest.raises(ValueError, match="foo.*short"):
content_loader.load_glossary(path)
def test_load_glossary_rejects_duplicate_slug(tmp_path):
path = _write(tmp_path, "g.yaml", """
terms:
- slug: foo
term: Foo
short: s1
body: b1
- slug: foo
term: Foo Again
short: s2
body: b2
""")
with pytest.raises(ValueError, match="duplicate slug"):
content_loader.load_glossary(path)
def test_load_glossary_accepts_well_formed_content(tmp_path):
path = _write(tmp_path, "g.yaml", """
terms:
- slug: foo
term: Foo
short: A short definition.
body: A full body with <b>markup</b>.
- slug: bar
term: Bar
short: Another short definition.
body: Another full body.
""")
glossary = content_loader.load_glossary(path)
assert list(glossary) == ["foo", "bar"] # file order preserved
assert glossary["foo"] == {
"term": "Foo", "short": "A short definition.", "body": "A full body with <b>markup</b>.",
}
# --- load_pages: schema validation ------------------------------------------
def test_load_pages_rejects_missing_pages_key(tmp_path):
path = _write(tmp_path, "p.yaml", "not_pages: {}\n")
with pytest.raises(ValueError, match="non-empty mapping"):
content_loader.load_pages(path)
def test_load_pages_rejects_missing_required_field(tmp_path):
path = _write(tmp_path, "p.yaml", """
pages:
about:
title: About
""")
with pytest.raises(ValueError, match="about.*description"):
content_loader.load_pages(path)
def test_load_pages_accepts_well_formed_content(tmp_path):
path = _write(tmp_path, "p.yaml", """
pages:
about:
title: About Us
description: A description.
""")
pages = content_loader.load_pages(path)
assert pages == {"about": {"title": "About Us", "description": "A description."}}
# --- the real content/ files ------------------------------------------------
def test_real_glossary_loads_and_matches_the_rendered_site():
"""The module-level GLOSSARY content.py loads at import IS content_loader's
output this just re-asserts the real file is well-formed and non-trivial,
since a broken content/glossary.yaml would already have failed every other
test at collection time (content.py loads it at import)."""
glossary = content_loader.load_glossary()
assert len(glossary) >= 8
assert "percentile" in glossary
for slug, entry in glossary.items():
assert entry["term"] and entry["short"] and entry["body"], slug
def test_real_pages_loads_the_four_static_pages():
pages = content_loader.load_pages()
assert set(pages) == {"about", "privacy", "hub", "glossary_index"}
for key, entry in pages.items():
assert entry["title"] and entry["description"], key