handbook_engine / tests /test_visual_regression.py
internationalscholarsprogram's picture
fix: ISP handbook styling overhaul - margins, typography, emphasis, benefits, CSS cascade
ec94fc1
"""Visual regression & block rendering tests.
Validates that:
1. The normalizer produces correct RenderBlock types for every layout
2. Block partials render with the correct CSS classes (no inline styles)
3. print.css contains all required hb-* classes and theme values
4. Enrollment steps section has page-break rules
"""
from __future__ import annotations
import re
from dataclasses import asdict
from pathlib import Path
import pytest
from app.services.normalizer import (
RenderBlock,
normalize_section,
normalize_university,
)
# ── Helpers ──
PRINT_CSS_PATH = Path(__file__).resolve().parent.parent / "app" / "static" / "css" / "print.css"
PARTIALS_DIR = Path(__file__).resolve().parent.parent / "app" / "templates" / "partials" / "blocks"
def _read_css() -> str:
return PRINT_CSS_PATH.read_text(encoding="utf-8")
# ══════════════════════════════════════════════════════════
# 1. Normalizer produces correct block types
# ══════════════════════════════════════════════════════════
class TestNormalizerHeadings:
def test_section_title_produces_heading_1(self):
blocks = normalize_section("overview", "Overview", {})
assert blocks[0].block_type == "heading_1"
assert blocks[0].data["text"] == "Overview"
def test_no_title_for_toc(self):
blocks = normalize_section("table_of_contents", "Table of Contents", {})
assert all(b.block_type != "heading_1" for b in blocks)
class TestNormalizerParagraph:
def test_text_field_produces_paragraph(self):
blocks = normalize_section("misc", "Misc", {"text": "Hello world"})
para = [b for b in blocks if b.block_type == "paragraph"]
assert len(para) == 1
assert para[0].data["text"] == "Hello world"
class TestNormalizerBullets:
def test_bullets_list(self):
blocks = normalize_section("misc", "Misc", {"bullets": ["A", "B", "C"]})
bullet = [b for b in blocks if b.block_type == "bullet_list"]
assert len(bullet) == 1
entries = [str(e) for e in bullet[0].data["entries"]]
assert entries == ["A", "B", "C"]
assert "hb-bullet-list" in bullet[0].css_class
def test_bullets_with_note(self):
blocks = normalize_section("misc", "Misc", {
"layout": "bullets_with_note",
"items": ["X", "Y"],
"note": "Important note",
})
note = [b for b in blocks if b.block_type == "note"]
assert len(note) == 1
assert "Important note" in note[0].data["text"]
class TestNormalizerSteps:
def test_enrollment_steps(self):
blocks = normalize_section("enrolment_steps", "Enrollment Steps", {
"steps": [
{"title": "Apply", "body": "Fill the form", "links": [], "qr_url": ""},
{"title": "Pay", "body": "Make payment"},
]
})
# heading + steps block
steps = [b for b in blocks if b.block_type == "enrollment_steps"]
assert len(steps) == 1
assert len(steps[0].data["steps"]) == 2
assert steps[0].data["steps"][0]["number"] == 1
assert steps[0].data["steps"][1]["title"] == "Pay"
class TestNormalizerTable:
def test_basic_table(self):
blocks = normalize_section("misc", "Misc", {
"columns": ["Name", "Value"],
"rows": [["A", "1"], ["B", "2"]],
})
tbl = [b for b in blocks if b.block_type == "table"]
assert len(tbl) == 1
assert tbl[0].data["variant"] == "standard"
assert tbl[0].data["columns"] == ["Name", "Value"]
assert len(tbl[0].data["rows"]) == 2
def test_table_v2(self):
blocks = normalize_section("misc", "Misc", {
"layout": "table_v2",
"base_columns": [{"key": "name", "label": "Name"}],
"header_groups": [],
"rows": [{"name": "Test"}],
})
tbl = [b for b in blocks if b.block_type == "table"]
assert len(tbl) == 1
assert tbl[0].data["variant"] == "comparison"
class TestNormalizerDocV1:
def test_doc_v1_blocks(self):
blocks = normalize_section("policy", "Policy", {
"layout": "doc_v1",
"blocks": [
{"type": "paragraph", "text": "Para text"},
{"type": "subheading", "text": "Sub heading"},
{"type": "bullets", "items": ["A", "B"]},
{"type": "note", "text": "Important note"},
{"type": "numbered_list", "items": ["First", "Second"]},
]
})
types = [b.block_type for b in blocks]
assert "heading_1" in types # section title
assert "paragraph" in types
assert "heading_2" in types
assert "bullet_list" in types
assert "note" in types
def test_note_inline_parts(self):
blocks = normalize_section("x", "X", {
"layout": "doc_v1",
"blocks": [
{
"type": "note_inline",
"parts": [
{"text": "NOTE: ", "style": "red_bold"},
{"text": "This is important", "style": ""},
],
}
]
})
notes = [b for b in blocks if b.block_type == "note"]
assert len(notes) == 1
assert notes[0].data["inline"] is True
assert notes[0].data["parts"][0]["style"] == "red_bold"
class TestNormalizerUniversitySummary:
def test_summary_with_universities(self):
blocks = normalize_section(
"summary_of_universities", "Summary of Universities",
{"intro": "Below are our partner universities."},
universities=[
{"university_name": "UniA", "sort_order": 2},
{"university_name": "UniB", "sort_order": 1},
],
)
summary = [b for b in blocks if b.block_type == "university_summary"]
assert len(summary) == 1
# Should be sorted by sort_order
assert summary[0].data["universities"] == ["UniB", "UniA"]
# ══════════════════════════════════════════════════════════
# 2. Block partials exist for every block type
# ══════════════════════════════════════════════════════════
class TestBlockPartials:
def test_render_block_dispatcher_exists(self):
assert (PARTIALS_DIR / "render_block.html").is_file()
@pytest.mark.parametrize("name", [
"heading.html",
"paragraph.html",
"bullet_list.html",
"note.html",
"table.html",
"enrollment_steps.html",
"university_summary.html",
"school_profile.html",
])
def test_block_partial_exists(self, name):
assert (PARTIALS_DIR / name).is_file(), f"Block partial {name} missing"
# ══════════════════════════════════════════════════════════
# 3. CSS contains all required hb-* classes and theme values
# ══════════════════════════════════════════════════════════
class TestPrintCSS:
def test_heading_colors(self):
css = _read_css()
assert "#1C75BC" in css, "Heading blue not in CSS"
assert "#1A9970" in css, "Heading green not in CSS"
def test_body_font(self):
css = _read_css()
assert "Century Gothic" in css
assert "10pt" in css
def test_justified_text(self):
css = _read_css()
assert "text-align: justify" in css
def test_bullet_character(self):
css = _read_css()
assert "\\27A2" in css, "➒ bullet character (\\27A2) not found"
def test_note_red_keyword(self):
css = _read_css()
assert "#C00000" in css, "Note red color not in CSS"
def test_benefit_item_bg(self):
css = _read_css()
assert "#00FCFC" in css, "Benefit item cyan background not in CSS"
def test_benefits_bar_green(self):
css = _read_css()
# benefits bar should use #1A9970
assert ".hb-benefits-bar" in css or ".benefits-bar" in css
def test_no_table_row_shading(self):
css = _read_css()
# The old CSS had .tbl tbody tr:nth-child(even) β€” should be removed
assert ".tbl tbody tr:nth-child(even)" not in css, "Table row shading still present"
assert ".hb-table tbody tr:nth-child(even)" not in css
def test_programs_no_row_shading(self):
css = _read_css()
assert "table.programs tbody tr:nth-child(even)" not in css
@pytest.mark.parametrize("cls", [
".hb-heading-1", ".hb-heading-2", ".hb-paragraph",
".hb-bullet-list", ".hb-note", ".hb-table",
".hb-programs", ".hb-benefits-bar", ".hb-benefit-item",
".hb-uni-name", ".hb-enrollment-steps",
".hb-university-list", ".hb-note-keyword",
])
def test_hb_class_in_css(self, cls):
css = _read_css()
assert cls in css, f"Expected class {cls} missing from print.css"
def test_enrollment_steps_page_break(self):
css = _read_css()
# sec-steps should have page-break-before
assert "sec-steps" in css
assert "page-break-before: always" in css or "break-before: page" in css
# ══════════════════════════════════════════════════════════
# 4. No inline styles in templates
# ══════════════════════════════════════════════════════════
class TestNoInlineStyles:
@pytest.mark.parametrize("template_name", [
"toc.html",
"university.html",
])
def test_no_style_attr_in_legacy_templates(self, template_name):
template_path = (
Path(__file__).resolve().parent.parent
/ "app" / "templates" / "partials" / template_name
)
content = template_path.read_text(encoding="utf-8")
style_attrs = re.findall(r'\bstyle\s*=\s*"[^"]*"', content, re.IGNORECASE)
assert len(style_attrs) == 0, (
f"Inline style(s) found in {template_name}: {style_attrs[:3]}"
)
@pytest.mark.parametrize("template_name", [
"heading.html",
"paragraph.html",
"bullet_list.html",
"note.html",
"table.html",
"enrollment_steps.html",
"university_summary.html",
"school_profile.html",
"render_block.html",
])
def test_no_style_attr_in_block_partials(self, template_name):
template_path = PARTIALS_DIR / template_name
content = template_path.read_text(encoding="utf-8")
style_attrs = re.findall(r'\bstyle\s*=\s*"[^"]*"', content, re.IGNORECASE)
assert len(style_attrs) == 0, (
f"Inline style(s) found in {template_name}: {style_attrs[:3]}"
)
# ══════════════════════════════════════════════════════════
# 5. University normalizer produces school_profile blocks
# ══════════════════════════════════════════════════════════
class TestUniversityNormalizer:
def test_basic_university(self):
uni_raw = {
"name": "Test University",
"anchor": "uni-test",
"sort_order": 1,
"sections": [
{
"section_key": "overview",
"section_json": {
"founded": "1900",
"total_students": "5000",
"location": "New York",
},
},
{
"section_key": "benefits",
"section_json": {
"benefits": ["Free tuition", "Scholarship"],
},
},
],
"website": "https://test.edu",
"_is_first": True,
}
stats: dict = {}
block = normalize_university(
uni_raw, allow_remote=False,
include_inactive_programs=False,
debug=False, stats=stats,
)
assert block.block_type == "school_profile"
assert block.data["name"] == "Test University"
assert block.data["website"] == "https://test.edu"
assert block.data["overview"]["founded"] == "1900"
assert block.data["benefits"] == ["Free tuition", "Scholarship"]