NoteHtmlTest.kt

7.7 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, blockquote, 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            "<blockquote>quoted</blockquote>" to "quoted",35            "<ul><li>one</li></ul>" to "•  one",36            "<ol><li>one</li></ol>" to "1.  one",37            "<p>para</p>" to "para",38            "<div>div text</div>" to "div text",39            "a<br>b" to "a\nb",40        )41        for ((html, expected) in cases) {42            assertEquals(html, expected, notePlainText(html))43            assertFalse(html, notePlainText(html).contains("<"))44        }45        // hr has no text of its own; it renders as a rule, not as "<hr>".46        assertFalse(notePlainText("a<hr>b").contains("hr"))47    }4849    @Test fun inline_tags_produce_the_matching_span_styles() {50        assertTrue(noteRendered("<b>x</b>").spanStyles.any { it.item.fontWeight == FontWeight.Bold })51        assertTrue(noteRendered("<strong>x</strong>").spanStyles.any { it.item.fontWeight == FontWeight.Bold })52        assertTrue(noteRendered("<i>x</i>").spanStyles.any { it.item.fontStyle == FontStyle.Italic })53        assertTrue(noteRendered("<em>x</em>").spanStyles.any { it.item.fontStyle == FontStyle.Italic })54        assertTrue(noteRendered("<u>x</u>").spanStyles.any { it.item.textDecoration == TextDecoration.Underline })55        assertTrue(noteRendered("<s>x</s>").spanStyles.any { it.item.textDecoration == TextDecoration.LineThrough })56        // Headings are the only block style that changes the type size.57        assertTrue(noteRendered("<h2>x</h2>").spanStyles.any { it.item.fontSize.value > 16f })58    }5960    @Test fun nested_inline_tags_keep_both_styles() {61        val styles = noteRendered("<b>a<i>b</i></b>").spanStyles62        assertTrue(styles.any { it.item.fontWeight == FontWeight.Bold && it.item.fontStyle == FontStyle.Italic })63    }6465    @Test fun blockquote_round_trips_and_renders_italic() {66        val html = "<blockquote>quoted text</blockquote>"67        assertEquals("> quoted text", noteEditorText(html))68        assertEquals(html, noteStorageText(noteEditorText(html), isHtml = true))69        assertTrue(noteRendered(html).spanStyles.any { it.item.fontStyle == FontStyle.Italic })70    }7172    @Test fun entities_decode_and_re_encode() {73        assertEquals("a & b <tag>", notePlainText("<p>a &amp; b &lt;tag&gt;</p>"))74        // Round trip must not double-escape.75        val html = "<div>a &amp; b</div>"76        assertEquals("a &amp; b", noteStorageText(noteEditorText(html), isHtml = true))77    }7879    // ── plain-text notes are untouched ───────────────────────────────────────8081    @Test fun plain_text_note_is_passed_through_unchanged() {82        val plain = "buy milk\n- eggs\n1. bread\n**not bold**"83        assertFalse(isNoteHtml(plain))84        assertEquals(plain, noteEditorText(plain))85        assertEquals(plain, notePlainText(plain))86        assertEquals(plain, noteStorageText(plain, isHtml = false))87        assertTrue("plain text carries no styling", noteRendered(plain).spanStyles.isEmpty())88    }8990    @Test fun plain_text_note_survives_an_edit_without_becoming_html() {91        val stored = "line one\nline two"92        val edited = noteEditorText(stored) + "\nline three"93        assertEquals("line one\nline two\nline three", noteStorageText(edited, isNoteHtml(stored)))94    }9596    // ── the round trip that protects formatting on save ──────────────────────9798    @Test fun formatted_note_round_trips_through_the_editor() {99        val html = "<h2>Trip</h2><b>packing</b><br>list" +100            "<ul><li>socks</li><li>tent</li></ul>" +101            "<ol><li>book</li><li>map</li></ol>" +102            "<hr><i>later</i>"103104        val editorText = noteEditorText(html)105        assertEquals(106            "## Trip\n**packing**\nlist\n- socks\n- tent\n1. book\n2. map\n---\n*later*",107            editorText,108        )109        assertEquals(html, noteStorageText(editorText, isHtml = true))110    }111112    @Test fun round_trip_is_stable_over_repeated_edits() {113        val html = "<h2>Notes</h2><u>read</u> and <s>done</s><br><div>tail</div>"114        val once = noteStorageText(noteEditorText(html), isHtml = true)115        val twice = noteStorageText(noteEditorText(once), isHtml = true)116        assertEquals(once, twice)117        // The formatting survives — not just the text.118        assertTrue(once.contains("<h2>"))119        assertTrue(once.contains("<u>read</u>"))120        assertTrue(once.contains("<s>done</s>"))121    }122123    @Test fun appending_text_to_a_formatted_note_keeps_the_formatting() {124        val html = "<h2>Trip</h2><ul><li>socks</li></ul>"125        val edited = noteEditorText(html) + "\n- tent"126        val saved = noteStorageText(edited, isHtml = true)127        assertEquals("<h2>Trip</h2><ul><li>socks</li><li>tent</li></ul>", saved)128    }129130    // ── search and previews see text, never markup ───────────────────────────131132    @Test fun toc_search_does_not_match_markup() {133        val list = listOf(note("Trip", "<div><b>hotel</b> ideas</div>"))134        assertTrue("markup must not be searchable", filterNotesForToc(list, "div").isEmpty())135        assertTrue(filterNotesForToc(list, "strong").isEmpty())136        assertEquals(listOf("Trip"), filterNotesForToc(list, "hotel").map { it.title })137    }138139    @Test fun vault_search_text_for_a_note_carries_no_markup() {140        val text = vaultEntrySearchText(note("Trip", "<ul><li>socks</li></ul>"))141        assertFalse(text.contains("<"))142        assertTrue(text.contains("socks"))143    }144145    @Test fun preview_line_is_the_first_rendered_line() {146        assertEquals("Trip", notePreviewLine("<h2>Trip</h2><div>details</div>"))147        assertEquals("details", notePreviewLine("<div></div><div>details</div>"))148        assertEquals("plain first line", notePreviewLine("plain first line\nsecond"))149        assertEquals("", notePreviewLine(""))150        assertFalse(notePreviewLine("<div><b>hotel</b> ideas</div>").contains("<"))151    }152153    // ── the editor's formatting buttons write markup that survives a save ────154155    @Test fun toolbar_wrap_marks_the_selection_and_serialises_to_a_tag() {156        val value = androidx.compose.ui.text.input.TextFieldValue(157            "hello world",158            selection = androidx.compose.ui.text.TextRange(0, 5),159        )160        val bolded = wrapSelection(value, "**")161        assertEquals("**hello** world", bolded.text)162        assertEquals("<b>hello</b> world", noteStorageText(bolded.text, isHtml = true))163    }164}