"""app.text_content.parse_rich_text -- pure parsing logic, no HTTP, no DB. This is the sanitization boundary for the text widget's dialog (widget_dialog_text.js posts contenteditable innerHTML here, see routers/api_widgets.py's api_widget_config_save "text" branch); the main thing under test is that only recognized style flags survive and everything else (unknown tags/attributes, unparseable colors, excess length) is silently dropped rather than round-tripped.""" from __future__ import annotations from app.text_content import MAX_TOTAL_CHARS, has_text, parse_rich_text def test_plain_text_is_one_paragraph_one_run(): paragraphs = parse_rich_text("Hello world") assert paragraphs == [[{"text": "Hello world", "bold": False, "italic": False, "underline": False, "color": None, "bg": None}]] def test_bold_italic_underline_tags(): paragraphs = parse_rich_text("bold italic under") runs = paragraphs[0] assert runs[0]["bold"] is True and runs[0]["text"] == "bold" assert runs[2]["italic"] is True and runs[2]["text"] == "italic" assert runs[4]["underline"] is True and runs[4]["text"] == "under" def test_strong_and_em_are_treated_like_b_and_i(): paragraphs = parse_rich_text("bolditalic") runs = paragraphs[0] assert runs[0]["bold"] is True assert runs[1]["italic"] is True def test_span_style_color_and_background(): html = 'hi' run = parse_rich_text(html)[0][0] assert run["color"] == "#cf000f" assert run["bg"] == "#ffdb00" def test_span_style_font_weight_and_style_and_decoration(): html = 'x' run = parse_rich_text(html)[0][0] assert run["bold"] is True assert run["italic"] is True assert run["underline"] is True def test_font_tag_color_attribute(): run = parse_rich_text('green')[0][0] assert run["color"] == "#00ff00" def test_short_hex_color_expands(): run = parse_rich_text('red')[0][0] assert run["color"] == "#ff0000" def test_unparseable_color_is_dropped(): run = parse_rich_text('x')[0][0] assert run["color"] is None def test_nested_styles_combine(): run = parse_rich_text("both")[0][0] assert run["bold"] is True assert run["italic"] is True def test_style_does_not_leak_past_closing_tag(): paragraphs = parse_rich_text("boldplain") runs = paragraphs[0] assert runs[0]["bold"] is True assert runs[1]["bold"] is False def test_div_per_line_becomes_separate_paragraphs(): paragraphs = parse_rich_text("
line one
line two
") assert [p[0]["text"] for p in paragraphs] == ["line one", "line two"] def test_shift_enter_br_within_a_div_also_breaks_paragraphs(): paragraphs = parse_rich_text("
line one
line two
") assert [p[0]["text"] for p in paragraphs] == ["line one", "line two"] def test_blank_line_idiom_produces_one_empty_paragraph(): html = "
A

B
" paragraphs = parse_rich_text(html) assert [p[0]["text"] if p else None for p in paragraphs] == ["A", None, "B"] def test_double_blank_line_produces_two_empty_paragraphs(): html = "
A


B
" paragraphs = parse_rich_text(html) assert [p[0]["text"] if p else None for p in paragraphs] == ["A", None, None, "B"] def test_mid_word_style_change_does_not_insert_a_space(): """"wor" bolded, "ld" not -- must still read as one word "world" when rendered (see app/widgets/text.py's word-grouping), not "wor ld".""" paragraphs = parse_rich_text("world") runs = paragraphs[0] assert [r["text"] for r in runs] == ["wor", "ld"] def test_unrecognized_tags_are_dropped_but_their_text_survives_as_plain(): """A hello') runs = paragraphs[0] assert any(r["text"] == "alert(1)" and not r["bold"] for r in runs) assert any(r["text"] == "hello" for r in runs) def test_style_attribute_cannot_smuggle_unrecognized_css(): """Only color/background-color/font-weight/font-style/text-decoration are ever read from a style attribute -- anything else (a CSS injection attempt via e.g. a bogus property) is just ignored.""" html = 'x' run = parse_rich_text(html)[0][0] assert run["color"] == "#123456" # No other keys were introduced by the extra property. assert set(run.keys()) == {"text", "bold", "italic", "underline", "color", "bg"} def test_content_is_truncated_to_max_total_chars(): html = "a" * (MAX_TOTAL_CHARS + 500) paragraphs = parse_rich_text(html) total = sum(len(r["text"]) for p in paragraphs for r in p) assert total == MAX_TOTAL_CHARS def test_malformed_html_does_not_raise(): parse_rich_text("unclosed tags
and a stray ") def test_has_text_false_for_none_and_blank(): assert has_text(None) is False assert has_text([]) is False assert has_text([[]]) is False assert has_text([[{"text": " ", "bold": False, "italic": False, "underline": False, "color": None, "bg": None}]]) is False def test_has_text_true_for_real_content(): assert has_text(parse_rich_text("hi")) is True