package com.peachpasswords.ui.vault import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import com.peachpasswords.vault.VaultEntry import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test /** * The vocabulary under test is the browser sanitizer's allow-list * (NoteEditor.tsx ALLOWED_NOTE_TAGS): b, i, u, s, strong, em, h2, h3, ul, ol, * li, br, hr, blockquote, p — plus the div wrappers contenteditable adds on its own. */ class NoteHtmlTest { private fun note(title: String, body: String?) = VaultEntry(id = title, title = title, type = "note", notes = body, createdAt = 0, modifiedAt = 0) // ── every allow-listed tag renders as text, never as markup ────────────── @Test fun every_allowed_tag_renders_without_markup() { val cases = listOf( "bold" to "bold", "bold" to "bold", "it" to "it", "it" to "it", "under" to "under", "struck" to "struck", "

Title

" to "Title", "

Sub

" to "Sub", "
quoted
" to "quoted", "" to "• one", "
  1. one
" to "1. one", "

para

" to "para", "
div text
" to "div text", "a
b" to "a\nb", ) for ((html, expected) in cases) { assertEquals(html, expected, notePlainText(html)) assertFalse(html, notePlainText(html).contains("<")) } // hr has no text of its own; it renders as a rule, not as "
". assertFalse(notePlainText("a
b").contains("hr")) } @Test fun inline_tags_produce_the_matching_span_styles() { assertTrue(noteRendered("x").spanStyles.any { it.item.fontWeight == FontWeight.Bold }) assertTrue(noteRendered("x").spanStyles.any { it.item.fontWeight == FontWeight.Bold }) assertTrue(noteRendered("x").spanStyles.any { it.item.fontStyle == FontStyle.Italic }) assertTrue(noteRendered("x").spanStyles.any { it.item.fontStyle == FontStyle.Italic }) assertTrue(noteRendered("x").spanStyles.any { it.item.textDecoration == TextDecoration.Underline }) assertTrue(noteRendered("x").spanStyles.any { it.item.textDecoration == TextDecoration.LineThrough }) // Headings are the only block style that changes the type size. assertTrue(noteRendered("

x

").spanStyles.any { it.item.fontSize.value > 16f }) } @Test fun nested_inline_tags_keep_both_styles() { val styles = noteRendered("ab").spanStyles assertTrue(styles.any { it.item.fontWeight == FontWeight.Bold && it.item.fontStyle == FontStyle.Italic }) } @Test fun blockquote_round_trips_and_renders_italic() { val html = "
quoted text
" assertEquals("> quoted text", noteEditorText(html)) assertEquals(html, noteStorageText(noteEditorText(html), isHtml = true)) assertTrue(noteRendered(html).spanStyles.any { it.item.fontStyle == FontStyle.Italic }) } @Test fun entities_decode_and_re_encode() { assertEquals("a & b ", notePlainText("

a & b <tag>

")) // Round trip must not double-escape. val html = "
a & b
" assertEquals("a & b", noteStorageText(noteEditorText(html), isHtml = true)) } // ── plain-text notes are untouched ─────────────────────────────────────── @Test fun plain_text_note_is_passed_through_unchanged() { val plain = "buy milk\n- eggs\n1. bread\n**not bold**" assertFalse(isNoteHtml(plain)) assertEquals(plain, noteEditorText(plain)) assertEquals(plain, notePlainText(plain)) assertEquals(plain, noteStorageText(plain, isHtml = false)) assertTrue("plain text carries no styling", noteRendered(plain).spanStyles.isEmpty()) } @Test fun plain_text_note_survives_an_edit_without_becoming_html() { val stored = "line one\nline two" val edited = noteEditorText(stored) + "\nline three" assertEquals("line one\nline two\nline three", noteStorageText(edited, isNoteHtml(stored))) } // ── the round trip that protects formatting on save ────────────────────── @Test fun formatted_note_round_trips_through_the_editor() { val html = "

Trip

packing
list" + "" + "
  1. book
  2. map
" + "
later" val editorText = noteEditorText(html) assertEquals( "## Trip\n**packing**\nlist\n- socks\n- tent\n1. book\n2. map\n---\n*later*", editorText, ) assertEquals(html, noteStorageText(editorText, isHtml = true)) } @Test fun round_trip_is_stable_over_repeated_edits() { val html = "

Notes

read and done
tail
" val once = noteStorageText(noteEditorText(html), isHtml = true) val twice = noteStorageText(noteEditorText(once), isHtml = true) assertEquals(once, twice) // The formatting survives — not just the text. assertTrue(once.contains("

")) assertTrue(once.contains("read")) assertTrue(once.contains("done")) } @Test fun appending_text_to_a_formatted_note_keeps_the_formatting() { val html = "

Trip

" val edited = noteEditorText(html) + "\n- tent" val saved = noteStorageText(edited, isHtml = true) assertEquals("

Trip

", saved) } // ── search and previews see text, never markup ─────────────────────────── @Test fun toc_search_does_not_match_markup() { val list = listOf(note("Trip", "
hotel ideas
")) assertTrue("markup must not be searchable", filterNotesForToc(list, "div").isEmpty()) assertTrue(filterNotesForToc(list, "strong").isEmpty()) assertEquals(listOf("Trip"), filterNotesForToc(list, "hotel").map { it.title }) } @Test fun vault_search_text_for_a_note_carries_no_markup() { val text = vaultEntrySearchText(note("Trip", "")) assertFalse(text.contains("<")) assertTrue(text.contains("socks")) } @Test fun preview_line_is_the_first_rendered_line() { assertEquals("Trip", notePreviewLine("

Trip

details
")) assertEquals("details", notePreviewLine("
details
")) assertEquals("plain first line", notePreviewLine("plain first line\nsecond")) assertEquals("", notePreviewLine("")) assertFalse(notePreviewLine("
hotel ideas
").contains("<")) } // ── the editor's formatting buttons write markup that survives a save ──── @Test fun toolbar_wrap_marks_the_selection_and_serialises_to_a_tag() { val value = androidx.compose.ui.text.input.TextFieldValue( "hello world", selection = androidx.compose.ui.text.TextRange(0, 5), ) val bolded = wrapSelection(value, "**") assertEquals("**hello** world", bolded.text) assertEquals("hello world", noteStorageText(bolded.text, isHtml = true)) } }