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