| """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, |
| ) |
|
|
|
|
| |
|
|
| 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") |
|
|
|
|
| |
| |
| |
|
|
| 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"}, |
| ] |
| }) |
| |
| 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 |
| 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 |
| |
| assert summary[0].data["universities"] == ["UniB", "UniA"] |
|
|
|
|
| |
| |
| |
|
|
| 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" |
|
|
|
|
| |
| |
| |
|
|
| 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() |
| |
| assert ".hb-benefits-bar" in css or ".benefits-bar" in css |
|
|
| def test_no_table_row_shading(self): |
| css = _read_css() |
| |
| 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() |
| |
| assert "sec-steps" in css |
| assert "page-break-before: always" in css or "break-before: page" in css |
|
|
|
|
| |
| |
| |
|
|
| 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]}" |
| ) |
|
|
|
|
| |
| |
| |
|
|
| 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"] |
|
|