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",
"- 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" +
"" +
"- book
- 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))
}
}