NoteHtmlTest.kt

7.2 KB · raw

1package com.peachpasswords.ui.vault23import androidx.compose.ui.text.font.FontStyle4import androidx.compose.ui.text.font.FontWeight5import androidx.compose.ui.text.style.TextDecoration6import com.peachpasswords.vault.VaultEntry7import org.junit.Assert.assertEquals8import org.junit.Assert.assertFalse9import org.junit.Assert.assertTrue10import org.junit.Test1112/**13 * The vocabulary under test is the browser sanitizer's allow-list14 * (NoteEditor.tsx ALLOWED_NOTE_TAGS): b, i, u, s, strong, em, h2, h3, ul, ol,15 * li, br, hr, p — plus the div wrappers contenteditable adds on its own.16 */17class NoteHtmlTest {1819    private fun note(title: String, body: String?) =20        VaultEntry(id = title, title = title, type = "note", notes = body, createdAt = 0, modifiedAt = 0)2122    // ── every allow-listed tag renders as text, never as markup ──────────────2324    @Test fun every_allowed_tag_renders_without_markup() {25        val cases = listOf(26            "<b>bold</b>" to "bold",27            "<strong>bold</strong>" to "bold",28            "<i>it</i>" to "it",29            "<em>it</em>" to "it",30            "<u>under</u>" to "under",31            "<s>struck</s>" to "struck",32            "<h2>Title</h2>" to "Title",33            "<h3>Sub</h3>" to "Sub",34            "<ul><li>one</li></ul>" to "•  one",35            "<ol><li>one</li></ol>" to "1.  one",36            "<p>para</p>" to "para",37            "<div>div text</div>" to "div text",38            "a<br>b" to "a\nb",39        )40        for ((html, expected) in cases) {41            assertEquals(html, expected, notePlainText(html))42            assertFalse(html, notePlainText(html).contains("<"))43        }44        // hr has no text of its own; it renders as a rule, not as "<hr>".45        assertFalse(notePlainText("a<hr>b").contains("hr"))46    }4748    @Test fun inline_tags_produce_the_matching_span_styles() {49        assertTrue(noteRendered("<b>x</b>").spanStyles.any { it.item.fontWeight == FontWeight.Bold })50        assertTrue(noteRendered("<strong>x</strong>").spanStyles.any { it.item.fontWeight == FontWeight.Bold })51        assertTrue(noteRendered("<i>x</i>").spanStyles.any { it.item.fontStyle == FontStyle.Italic })52        assertTrue(noteRendered("<em>x</em>").spanStyles.any { it.item.fontStyle == FontStyle.Italic })53        assertTrue(noteRendered("<u>x</u>").spanStyles.any { it.item.textDecoration == TextDecoration.Underline })54        assertTrue(noteRendered("<s>x</s>").spanStyles.any { it.item.textDecoration == TextDecoration.LineThrough })55        // Headings are the only block style that changes the type size.56        assertTrue(noteRendered("<h2>x</h2>").spanStyles.any { it.item.fontSize.value > 16f })57    }5859    @Test fun nested_inline_tags_keep_both_styles() {60        val styles = noteRendered("<b>a<i>b</i></b>").spanStyles61        assertTrue(styles.any { it.item.fontWeight == FontWeight.Bold && it.item.fontStyle == FontStyle.Italic })62    }6364    @Test fun entities_decode_and_re_encode() {65        assertEquals("a & b <tag>", notePlainText("<p>a &amp; b &lt;tag&gt;</p>"))66        // Round trip must not double-escape.67        val html = "<div>a &amp; b</div>"68        assertEquals("a &amp; b", noteStorageText(noteEditorText(html), isHtml = true))69    }7071    // ── plain-text notes are untouched ───────────────────────────────────────7273    @Test fun plain_text_note_is_passed_through_unchanged() {74        val plain = "buy milk\n- eggs\n1. bread\n**not bold**"75        assertFalse(isNoteHtml(plain))76        assertEquals(plain, noteEditorText(plain))77        assertEquals(plain, notePlainText(plain))78        assertEquals(plain, noteStorageText(plain, isHtml = false))79        assertTrue("plain text carries no styling", noteRendered(plain).spanStyles.isEmpty())80    }8182    @Test fun plain_text_note_survives_an_edit_without_becoming_html() {83        val stored = "line one\nline two"84        val edited = noteEditorText(stored) + "\nline three"85        assertEquals("line one\nline two\nline three", noteStorageText(edited, isNoteHtml(stored)))86    }8788    // ── the round trip that protects formatting on save ──────────────────────8990    @Test fun formatted_note_round_trips_through_the_editor() {91        val html = "<h2>Trip</h2><b>packing</b><br>list" +92            "<ul><li>socks</li><li>tent</li></ul>" +93            "<ol><li>book</li><li>map</li></ol>" +94            "<hr><i>later</i>"9596        val editorText = noteEditorText(html)97        assertEquals(98            "## Trip\n**packing**\nlist\n- socks\n- tent\n1. book\n2. map\n---\n*later*",99            editorText,100        )101        assertEquals(html, noteStorageText(editorText, isHtml = true))102    }103104    @Test fun round_trip_is_stable_over_repeated_edits() {105        val html = "<h2>Notes</h2><u>read</u> and <s>done</s><br><div>tail</div>"106        val once = noteStorageText(noteEditorText(html), isHtml = true)107        val twice = noteStorageText(noteEditorText(once), isHtml = true)108        assertEquals(once, twice)109        // The formatting survives — not just the text.110        assertTrue(once.contains("<h2>"))111        assertTrue(once.contains("<u>read</u>"))112        assertTrue(once.contains("<s>done</s>"))113    }114115    @Test fun appending_text_to_a_formatted_note_keeps_the_formatting() {116        val html = "<h2>Trip</h2><ul><li>socks</li></ul>"117        val edited = noteEditorText(html) + "\n- tent"118        val saved = noteStorageText(edited, isHtml = true)119        assertEquals("<h2>Trip</h2><ul><li>socks</li><li>tent</li></ul>", saved)120    }121122    // ── search and previews see text, never markup ───────────────────────────123124    @Test fun toc_search_does_not_match_markup() {125        val list = listOf(note("Trip", "<div><b>hotel</b> ideas</div>"))126        assertTrue("markup must not be searchable", filterNotesForToc(list, "div").isEmpty())127        assertTrue(filterNotesForToc(list, "strong").isEmpty())128        assertEquals(listOf("Trip"), filterNotesForToc(list, "hotel").map { it.title })129    }130131    @Test fun vault_search_text_for_a_note_carries_no_markup() {132        val text = vaultEntrySearchText(note("Trip", "<ul><li>socks</li></ul>"))133        assertFalse(text.contains("<"))134        assertTrue(text.contains("socks"))135    }136137    @Test fun preview_line_is_the_first_rendered_line() {138        assertEquals("Trip", notePreviewLine("<h2>Trip</h2><div>details</div>"))139        assertEquals("details", notePreviewLine("<div></div><div>details</div>"))140        assertEquals("plain first line", notePreviewLine("plain first line\nsecond"))141        assertEquals("", notePreviewLine(""))142        assertFalse(notePreviewLine("<div><b>hotel</b> ideas</div>").contains("<"))143    }144145    // ── the editor's formatting buttons write markup that survives a save ────146147    @Test fun toolbar_wrap_marks_the_selection_and_serialises_to_a_tag() {148        val value = androidx.compose.ui.text.input.TextFieldValue(149            "hello world",150            selection = androidx.compose.ui.text.TextRange(0, 5),151        )152        val bolded = wrapSelection(value, "**")153        assertEquals("**hello** world", bolded.text)154        assertEquals("<b>hello</b> world", noteStorageText(bolded.text, isHtml = true))155    }156}