Files
espresso_frame/server/tests/test_text_content.py
T
Thomas Faour 3735c5bfa7
Build and push server image / test (push) Successful in 27s
Build and push server image / build-and-push (push) Successful in 1m59s
Build and push server image / deploy (push) Successful in 1m9s
Add a text widget (rich text: bold/italic/underline, per-run color/highlight)
A new self-contained widget type showing user-authored rich text -- no
live upstream to poll, like the static image widget, just word-wrapped
styled text instead of an uploaded image.

The dialog's contenteditable HTML is never stored or replayed as HTML:
app/text_content.py parses it server-side (on save) into a plain
paragraphs-of-styled-runs structure -- the actual sanitization
boundary, since raw HTML never round-trips back into any browser DOM
(the dialog rebuilds its editor from that same JSON via
createElement/textContent). app/widgets/text.py renders it with a
custom word-wrap/shrink-to-fit layout, using real vendored font weights
(app/fonts/NotoSans-{Regular,Bold,Italic,BoldItalic}.ttf, OFL-licensed
like the emoji fonts already there) rather than every other widget's
single ImageFont.load_default() -- the one widget type where that
distinction matters.
2026-07-25 14:21:52 +00:00

150 lines
5.6 KiB
Python

"""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("<b>bold</b> <i>italic</i> <u>under</u>")
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("<strong>bold</strong><em>italic</em>")
runs = paragraphs[0]
assert runs[0]["bold"] is True
assert runs[1]["italic"] is True
def test_span_style_color_and_background():
html = '<span style="color: rgb(207, 0, 15); background-color: #ffdb00;">hi</span>'
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 = '<span style="font-weight: bold; font-style: italic; text-decoration: underline;">x</span>'
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('<font color="#00ff00">green</font>')[0][0]
assert run["color"] == "#00ff00"
def test_short_hex_color_expands():
run = parse_rich_text('<span style="color: #f00;">red</span>')[0][0]
assert run["color"] == "#ff0000"
def test_unparseable_color_is_dropped():
run = parse_rich_text('<span style="color: papayawhip;">x</span>')[0][0]
assert run["color"] is None
def test_nested_styles_combine():
run = parse_rich_text("<b><i>both</i></b>")[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("<b>bold</b>plain")
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("<div>line one</div><div>line two</div>")
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("<div>line one<br>line two</div>")
assert [p[0]["text"] for p in paragraphs] == ["line one", "line two"]
def test_blank_line_idiom_produces_one_empty_paragraph():
html = "<div>A</div><div><br></div><div>B</div>"
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 = "<div>A</div><div><br></div><div><br></div><div>B</div>"
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("<b>wor</b>ld")
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 <script> (or any tag outside the recognized set) never executes
or persists as a tag -- its text content just becomes an ordinary
unstyled run, exactly like any other stray text."""
paragraphs = parse_rich_text('<script>alert(1)</script>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 = '<span style="position: fixed; top: 0; color: #123456;">x</span>'
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("<b><i>unclosed tags <div>and a stray </b>")
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