File size: 13,242 Bytes
ec94fc1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
"""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"]