| 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" |