main
py 199 lines 8.69 KB
Raw
1 from pathlib import Path
2
3 ROOT = Path(__file__).resolve().parents[1]
4 VIS = ROOT / "layouts/partials/visuals"
5 SC = ROOT / "layouts/shortcodes"
6
7
8 def _read(p: Path) -> str:
9 return p.read_text(encoding="utf-8")
10
11
12 def test_visual_module_partials_exist() -> None:
13 for name in (
14 "article-cover.html",
15 "cover-card.html",
16 "topic-constellation.html",
17 "signal-noise.html",
18 "repo-trend.html",
19 "fallback-card.html",
20 ):
21 assert (VIS / name).is_file(), f"missing visual partial {name}"
22
23
24 def test_reusable_shortcodes_exist() -> None:
25 for name in ("signal-noise.html", "topic-stars.html", "repo-trend.html"):
26 assert (SC / name).is_file(), f"missing shortcode {name}"
27
28
29 def test_weekly_single_wires_cover_and_signal_noise() -> None:
30 single = _read(ROOT / "layouts/weekly/single.html")
31 assert 'partial "visuals/article-cover.html"' in single
32 assert 'partial "visuals/signal-noise.html"' in single
33
34
35 def test_generated_visuals_have_accessible_names() -> None:
36 cover = _read(VIS / "cover-card.html")
37 # The figure must NOT use role="img": that collapses the whole container into a
38 # single image node and hides its real text (kicker, brand, topics) from AT.
39 assert 'role="img"' not in cover
40 # It still exposes a real-data accessible name, keeps the topic text in the DOM
41 # (not aria-hidden), and hides only the decorative motif.
42 assert "aria-label=" in cover
43 assert 'aria-hidden="true"' in cover
44 assert 'class="article-cover__topics"' in cover # real topic text stays exposed
45 trend = _read(VIS / "repo-trend.html")
46 assert "visually-hidden" in trend # text summary for the chart data
47 assert 'aria-hidden="true"' in trend
48
49
50 def test_visuals_are_locally_generated_not_hotlinked() -> None:
51 # No external/hotlinked image sources in any visual partial.
52 for p in VIS.glob("*.html"):
53 text = _read(p)
54 assert "http://" not in text, f"{p.name} must not hotlink http assets"
55 # GitHub repo deep-links are allowed as anchors, but never as <img src>.
56 for marker in ('img src="http', "src='http"):
57 assert marker not in text.replace(" ", ""), f"{p.name} hotlinks an image"
58
59
60 def test_safe_cover_only_accepts_local_resources() -> None:
61 orch = _read(VIS / "article-cover.html")
62 # Image path resolves Hugo resources (local hosting), matched exactly and
63 # guarded to image types, before rendering an <img>.
64 assert "Resources.GetMatch" in orch
65 assert "resources.Get" in orch
66 assert 'eq $candidate.ResourceType "image"' in orch
67 assert "#329" in orch # documented image-policy hook
68
69
70 def test_heading_levels_are_whitelisted() -> None:
71 # Untrusted `level` (shortcode/frontmatter) must be normalized + whitelisted
72 # before being used as a raw HTML tag name.
73 for name in ("topic-constellation.html", "signal-noise.html", "repo-trend.html"):
74 text = _read(VIS / name)
75 assert "| lower" in text
76 assert 'in (slice "h2" "h3" "h4" "h5" "h6")' in text
77 # The tag name is emitted only from the whitelisted value via safeHTML,
78 # never by interpolating the raw input as a tag name (`<{{ $level }}>`).
79 assert "<{{ $level }}>" not in text
80 assert "| safeHTML" in text
81
82
83 def test_topic_constellation_coerces_numeric_figures() -> None:
84 # `.repos`/`.stars` arrive as strings from the shortcode (`.Get`); only
85 # digit strings are passed to lang.FormatNumber, non-numeric input is dropped
86 # rather than erroring the build.
87 text = _read(VIS / "topic-constellation.html")
88 assert 'findRE "^[0-9]+$"' in text
89 assert "lang.FormatNumber 0 (int" in text
90
91
92 def test_topic_titles_are_non_interactive_labels() -> None:
93 # Operator ask (#328): topic titles must NOT look (or be) clickable. They are
94 # rendered as plain labels — no link, no button/pill affordance — while the
95 # chart itself stays intact.
96 cover = _read(VIS / "cover-card.html")
97 constellation = _read(VIS / "topic-constellation.html")
98 # No anchors around topic chips in either module.
99 assert "<a href=" not in cover, "cover topic chips must not be links"
100 assert "<a href=" not in constellation, "constellation chips must not be links"
101 # The topic list/text is still present (chart intact).
102 assert 'class="article-cover__topics"' in cover
103 assert 'class="article-cover__topic"' in cover
104 # CSS must not give the chips a button/pill affordance (background/border/hover).
105 css = _read(ROOT / "assets/css/extended/article-visuals.css")
106 for sel in (
107 ".article-cover__topic--link",
108 ".article-cover__topic--static",
109 ".topic-stars__chip--link",
110 ".topic-stars__chip--static",
111 ):
112 assert sel not in css, f"stale clickable-chip rule remains: {sel}"
113
114
115 def test_unsafe_markdown_remains_disabled() -> None:
116 cfg = _read(ROOT / "hugo.toml")
117 assert "unsafe = false" in cfg
118
119
120 def test_visuals_keep_claracle_brand() -> None:
121 cover = _read(VIS / "cover-card.html")
122 assert "Claracle" in cover
123 # Do not reintroduce the retired display brand in visible text.
124 for p in VIS.glob("*.html"):
125 assert "SquadScope" not in _read(p)
126
127
128 def test_signal_noise_module_preserves_caveat_slot() -> None:
129 sn = _read(VIS / "signal-noise.html")
130 assert "signal-noise__caveat" in sn # evidence-first: caveat language supported
131 assert "signal-noise__source" in sn
132
133
134 def test_article_visuals_css_is_responsive() -> None:
135 css = _read(ROOT / "assets/css/extended/article-visuals.css")
136 assert "@media (max-width: 768px)" in css
137 assert "aspect-ratio" in css # reserve space -> no CLS
138
139
140 # -----------------------------------------------------------------------
141 # Cover image pipeline validation (issue #358 acceptance criteria)
142 # -----------------------------------------------------------------------
143
144
145 def test_og_image_template_uses_1200x630() -> None:
146 """OG image must be resized to 1200x630 per issue #358 criterion 3."""
147 og = _read(ROOT / "layouts/partials/templates/opengraph.html")
148 assert "1200x630" in og, "OG template must resize to 1200x630"
149
150
151 def test_cover_image_orchestrator_uses_800x400_with_srcset() -> None:
152 """Cover image orchestrator must emit responsive 800x400/1600x800 local cover variants."""
153 orchestrator = _read(VIS / "article-cover.html")
154 assert "800x400" in orchestrator, "Cover image flow must produce 800x400 base size"
155 assert "1600x800" in orchestrator, "Cover image flow must produce 1600x800 for 2x srcset"
156 assert "srcset" in orchestrator, "Cover image flow must use responsive srcset"
157
158
159 def test_cover_only_renders_local_resources() -> None:
160 """Cover template must only render locally-hosted images (no hotlinking)."""
161 cover = _read(VIS / "article-cover.html")
162 assert "$page.Resources.GetMatch" in cover, "Cover lookup must resolve page-bundle resources"
163 assert "resources.Get" in cover, "Cover lookup must resolve global Hugo resources"
164 assert 'eq $candidate.ResourceType "image"' in cover, "Only Hugo image resources should render"
165 assert ".RelPermalink" in cover, "Rendered cover URLs must use local RelPermalink paths"
166 assert 'replaceRE `(?i)<img[^>]*>` ""' in cover, "Attribution must strip rendered img tags"
167
168
169 def test_image_registry_exists_with_required_fields() -> None:
170 """data/image-registry.json must exist with required schema fields."""
171 import json
172
173 registry_path = ROOT / "data" / "image-registry.json"
174 schema_path = ROOT / "data" / "image-registry.schema.json"
175
176 assert registry_path.exists(), "Image registry must exist at data/image-registry.json"
177 assert schema_path.exists(), (
178 "Image registry schema must exist at data/image-registry.schema.json"
179 )
180
181 schema = json.loads(schema_path.read_text(encoding="utf-8"))
182 assert schema.get("type") == "object", "Image registry schema must define an object"
183 assert "images" in schema.get("required", []), (
184 "Image registry schema must require an images array"
185 )
186 images_schema = schema.get("properties", {}).get("images", {})
187 assert images_schema.get("type") == "array", (
188 "Image registry schema must define images as an array"
189 )
190 item_properties = images_schema.get("items", {}).get("properties", {})
191 required_fields = {"filename", "source_url", "license", "attribution", "added_by"}
192 assert required_fields.issubset(item_properties), (
193 f"Image registry schema missing required fields: {sorted(required_fields - set(item_properties))}"
194 )
195
196 data = json.loads(registry_path.read_text(encoding="utf-8"))
197 assert isinstance(data, dict), "Image registry must be a JSON object"
198 assert data.get("$schema") == "./image-registry.schema.json"
199 assert isinstance(data.get("images"), list), "Image registry must declare an images array"