Files
status-app/storybook/qmlTests/tests/tst_ChatTextView.qml
T

680 lines
30 KiB
QML

import QtQuick
import QtTest
import StatusQ
import StatusQ.Components
import StatusQ.Core.Utils
Item {
id: root
width: 600
height: 400
Component {
id: componentUnderTest
ChatTextView {
width: 400
font.pixelSize: 15
}
}
// An editable control that grabs keyboard focus before the ChatTextView selection —
// mirrors the real chat where the composer holds focus. Without the ChatTextView taking
// focus on selection, this field would preempt the Ctrl+C copy shortcut.
TextEdit {
id: otherEditor
objectName: "otherEditor"
anchors.bottom: parent.bottom
anchors.left: parent.left
width: 200
height: 30
text: "editable field"
}
SignalSpy {
id: mentionSpy
target: testCase.control
signalName: "mentionClicked"
}
SignalSpy {
id: linkSpy
target: testCase.control
signalName: "linkClicked"
}
TestCase {
id: testCase
name: "ChatTextView"
when: windowShown
property ChatTextView control: null
// The suite runs with `-platform offscreen` (storybook/CMakeLists.txt), whose clipboard is
// process-isolated (starts empty, never touches the real system clipboard), so the copy
// tests below don't clobber the user's clipboard and need no save/restore.
function init() {
control = createTemporaryObject(componentUnderTest, root)
verify(control)
}
// The `blocks` property drives the rendered content: a non-empty list produces
// visible content (implicitHeight > 0), an empty list collapses it.
function test_blocksDriveContent() {
compare(control.blocks.length, 0)
tryCompare(control, "implicitHeight", 0)
control.blocks = [
{ type: "text", html: "hello" },
{ type: "code", code: "x = 1" },
{ type: "quote", blocks: [{ type: "text", html: "quoted" }] }
]
tryVerify(() => control.implicitHeight > 0)
const withBlocks = control.implicitHeight
// Clearing the blocks collapses the content again.
control.blocks = []
tryCompare(control, "implicitHeight", 0)
verify(withBlocks > 0)
}
// Recursively true when some rendered Text/TextEdit under `item` contains `substr`.
function renders(item, substr) {
if (typeof item.text === "string" && item.text.indexOf(substr) >= 0)
return true
const kids = item.children
for (let i = 0; i < kids.length; ++i)
if (renders(kids[i], substr))
return true
return false
}
// Number of rendered Text/TextEdit items under `item` whose text contains `substr`.
function countRenders(item, substr) {
let n = (typeof item.text === "string" && item.text.indexOf(substr) >= 0) ? 1 : 0
const kids = item.children
for (let i = 0; i < kids.length; ++i)
n += countRenders(kids[i], substr)
return n
}
// The "(edited)" marker is rendered only when `edited` is set (appended after the text).
function test_editedMarkerRendered() {
control.blocks = [
{ type: "text", html: "first" },
{ type: "text", html: "hello" }
]
tryVerify(() => control.implicitHeight > 0)
verify(!renders(control, "(edited)"), "marker shown without edited")
control.edited = true
tryVerify(() => renders(control, "(edited)"), 1000, "marker not rendered when edited")
control.edited = false
tryVerify(() => !renders(control, "(edited)"), 1000, "marker not removed")
}
// The exposed lineHeight (px) is applied as a paragraph line-height style rule (mirrors the
// legacy renderer's `p { line-height: 22px }`); overriding it updates the rule.
function test_lineHeight_override() {
control.blocks = [{ type: "text", html: "a<br/>b" }]
tryVerify(() => renders(control, "line-height: 22px"))
control.lineHeight = 30
tryVerify(() => renders(control, "line-height: 30px"))
}
// When the last block isn't text (code/quote), the marker is still rendered (as its own
// trailing text block).
function test_editedMarkerAfterNonTextBlock() {
control.blocks = [
{ type: "text", html: "hello" },
{ type: "code", code: "x = 1" }
]
control.edited = true
tryVerify(() => control.implicitHeight > 0)
tryVerify(() => renders(control, "(edited)"), 1000, "marker not rendered after code block")
}
// A quote block carrying a nested code sub-block renders without errors.
function test_quoteWithNestedCode() {
control.blocks = [
{ type: "quote", blocks: [
{ type: "text", html: "intro" },
{ type: "code", code: "nested" }
] }
]
tryVerify(() => control.implicitHeight > 0)
}
// ── mouse selection (selectable mode) ────────────────────────────────────
// Without `selectable`, blocks are plain Labels — a drag selects nothing.
function test_notSelectable_noSelection() {
control.blocks = [
{ type: "text", html: "first line" },
{ type: "text", html: "second line" }
]
tryVerify(() => control.implicitHeight > 0)
mousePress(control, 4, 4)
mouseMove(control, control.width - 4, control.implicitHeight - 4)
mouseRelease(control, control.width - 4, control.implicitHeight - 4)
compare(control.selectedText, "")
}
// With `selectable`, dragging from the first block into the second selects across
// both — the combined selectedText contains text from each.
function test_crossBlockSelection() {
control.selectable = true
control.blocks = [
{ type: "text", html: "first line" },
{ type: "text", html: "second line" }
]
tryVerify(() => control.implicitHeight > 0)
// Press at x=0 so the anchor is the very start of the first block (positionAt(0)
// is reliably position 0, independent of glyph widths).
mousePress(control, 0, 3)
mouseMove(control, control.width - 4, control.implicitHeight - 4)
mouseRelease(control, control.width - 4, control.implicitHeight - 4)
verify(control.selectedText.indexOf("first") >= 0, "first block not selected")
verify(control.selectedText.indexOf("second") >= 0, "second block not selected")
}
// Double-click selects the word under the cursor (not the whole line).
function test_doubleClickSelectsWord() {
control.selectable = true
control.blocks = [{ type: "text", html: "hello world" }]
tryVerify(() => control.implicitHeight > 0)
mouseClick(control, 2, 3)
mouseClick(control, 2, 3)
compare(control.selectedText, "hello")
}
// Triple-click selects the whole (logical) line — here the first line of a two-line text
// block, not the entire block ("aaabbb").
function test_tripleClickSelectsLine() {
control.selectable = true
control.blocks = [{ type: "text", html: "aaa<br/>bbb" }]
tryVerify(() => control.implicitHeight > 0)
mouseClick(control, 2, 3)
mouseClick(control, 2, 3)
mouseClick(control, 2, 3)
compare(control.selectedText, "aaa")
}
// Triple-click on a single-line block selects the entire line.
function test_tripleClickSelectsFullSingleLine() {
control.selectable = true
control.blocks = [{ type: "text", html: "hello world foo" }]
tryVerify(() => control.implicitHeight > 0)
mouseClick(control, 2, 3)
mouseClick(control, 2, 3)
mouseClick(control, 2, 3)
compare(control.selectedText, "hello world foo")
}
// Clicking in place cycles: click, word, line, then a fourth click deselects.
function test_fourthClickDeselects() {
control.selectable = true
control.blocks = [{ type: "text", html: "hello world" }]
tryVerify(() => control.implicitHeight > 0)
mouseClick(control, 2, 3) // plain click
mouseClick(control, 2, 3) // word
verify(control.selectedText.length > 0, "second click should select a word")
mouseClick(control, 2, 3) // line
verify(control.selectedText.length > 0, "third click should select the line")
mouseClick(control, 2, 3) // deselect
compare(control.selectedText, "", "fourth click should deselect")
}
// Loosing focus clears the selection.
function test_clickingAnotherViewDeselectsPrevious() {
const blocks = [
{ type: "text", html: "first line" },
{ type: "text", html: "second line" }
]
const first = createTemporaryObject(componentUnderTest, root,
{ selectable: true, blocks: blocks, y: 0 })
const second = createTemporaryObject(componentUnderTest, root,
{ selectable: true, blocks: blocks, y: 200 })
verify(first && second)
tryVerify(() => first.implicitHeight > 0 && second.implicitHeight > 0)
// Select in the first view.
mousePress(first, 0, 3)
mouseMove(first, first.width - 4, first.implicitHeight - 4)
mouseRelease(first, first.width - 4, first.implicitHeight - 4)
verify(first.selectedText.length > 0, "first view not selected")
// A plain click (no drag) in the second view must clear the first's selection.
mousePress(second, 5, 3)
mouseRelease(second, 5, 3)
compare(second.selectedText, "", "click should not select the second view")
tryCompare(first, "selectedText", "")
}
// Selects across both blocks in `ctrl` (which must be selectable with content laid out)
// and asserts a non-empty selection results.
function selectAll(ctrl) {
mousePress(ctrl, 0, 3)
mouseMove(ctrl, ctrl.width - 4, ctrl.implicitHeight - 4)
mouseRelease(ctrl, ctrl.width - 4, ctrl.implicitHeight - 4)
verify(ctrl.selectedText.length > 0, "nothing selected")
}
readonly property var twoBlocks: [
{ type: "text", html: "first line" },
{ type: "text", html: "second line" }
]
// clearSelectionOnLostFocus (default true): losing active focus drops the selection.
function test_clearSelectionOnLostFocus_defaultClearsOnFocusLoss() {
control.selectable = true
control.blocks = twoBlocks
tryVerify(() => control.implicitHeight > 0)
selectAll(control)
verify(control.activeFocus, "selecting should have moved focus to the view")
// Focus goes elsewhere → selection cleared.
otherEditor.forceActiveFocus()
tryVerify(() => !control.activeFocus)
tryCompare(control, "selectedText", "")
}
// With clearSelectionOnLostFocus false, the selection survives focus loss (used while a
// context menu popup holds focus).
function test_clearSelectionOnLostFocus_falseKeepsSelectionOnFocusLoss() {
control.selectable = true
control.clearSelectionOnLostFocus = false
control.blocks = twoBlocks
tryVerify(() => control.implicitHeight > 0)
selectAll(control)
verify(control.activeFocus, "selecting should have moved focus to the view")
otherEditor.forceActiveFocus()
tryVerify(() => !control.activeFocus)
// Selection preserved despite having lost focus.
verify(control.selectedText.length > 0, "selection cleared despite the flag being false")
}
// Restoring the flag to true while the view already has a selection but no focus (e.g. the
// context menu just closed) doesn't clear the now-stale selection.
function test_clearSelectionOnLostFocus_setTrueWhileUnfocusedClears() {
control.selectable = true
control.clearSelectionOnLostFocus = false
control.blocks = twoBlocks
tryVerify(() => control.implicitHeight > 0)
selectAll(control)
otherEditor.forceActiveFocus()
tryVerify(() => !control.activeFocus)
verify(control.selectedText.length > 0, "precondition: selection kept while flag is false")
// Re-enabling the flag while unfocused doesn't clear the selection.
control.clearSelectionOnLostFocus = true
verify(control.selectedText.length > 0, "selection cleared by changing clearSelectionOnLostFocus")
}
// Toggling `selectable` after blocks are set rebuilds each block's renderer (Loader
// swap) and the coordinator re-collects the newly-created editors.
function test_selectableToggledAfterBlocks() {
control.selectable = false
control.blocks = [
{ type: "text", html: "first line" },
{ type: "text", html: "second line" }
]
tryVerify(() => control.implicitHeight > 0)
control.selectable = true
Qt.callLater(() => {}) // let the Loaders swap in
mousePress(control, 0, 3)
mouseMove(control, control.width - 4, control.implicitHeight - 4)
mouseRelease(control, control.width - 4, control.implicitHeight - 4)
verify(control.selectedText.indexOf("first") >= 0, "first block not selected")
verify(control.selectedText.indexOf("second") >= 0, "second block not selected")
}
// Hovering a link/mention rebuilds a selectable block's rich-text document (the hover
// background is baked into the HTML via richTextFor), which clears that editor's
// selection. The coordinator must restore it. Here we trigger the same document rebuild
// deterministically by changing a richTextFor input (linkColor) and assert the active
// cross-block selection survives.
function test_selectionSurvivesTextRebuild() {
control.selectable = true
control.blocks = [
{ type: "text", html: "first line" },
{ type: "text", html: "second line" }
]
tryVerify(() => control.implicitHeight > 0)
mousePress(control, 0, 3)
mouseMove(control, control.width - 4, control.implicitHeight - 4)
mouseRelease(control, control.width - 4, control.implicitHeight - 4)
verify(control.selectedText.indexOf("first") >= 0, "first block not selected")
verify(control.selectedText.indexOf("second") >= 0, "second block not selected")
const screenshot = grabImage(control)
// Rebuild the rich-text documents (as a hover would) and confirm
// the selection is restored rather than dropped.
control.linkColor = "#123456"
verify(control.selectedText.indexOf("first") >= 0,
"selection lost on text rebuild (first)")
verify(control.selectedText.indexOf("second") >= 0,
"selection lost on text rebuild (second)")
const screenshot2 = grabImage(control)
verify(screenshot.equals(screenshot2))
}
function test_selectionNotSurvivesTextReset() {
control.selectable = true
control.blocks = [
{ type: "text", html: "first line" },
{ type: "text", html: "second line" }
]
tryVerify(() => control.implicitHeight > 0)
mousePress(control, 0, 3)
mouseMove(control, control.width - 4, control.implicitHeight - 4)
mouseRelease(control, control.width - 4, control.implicitHeight - 4)
verify(control.selectedText.indexOf("first") >= 0, "first block not selected")
verify(control.selectedText.indexOf("second") >= 0, "second block not selected")
const screenshot = grabImage(control)
control.blocks = [
{ type: "text", html: "first line2" },
{ type: "text", html: "second line" }
]
control.blocks = [
{ type: "text", html: "first line" },
{ type: "text", html: "second line" }
]
verify(control.selectedText.indexOf("first") === -1,
"selection survived text reset (first)")
verify(control.selectedText.indexOf("second") === -1,
"selection survived text reset (second)")
const screenshot2 = grabImage(control)
verify(!screenshot.equals(screenshot2))
}
// copySelection() puts the combined selection on the clipboard.
function test_copySelection() {
control.selectable = true
control.blocks = [
{ type: "text", html: "alpha" },
{ type: "text", html: "beta" }
]
tryVerify(() => control.implicitHeight > 0)
mousePress(control, 0, 3)
mouseMove(control, control.width - 4, control.implicitHeight - 4)
mouseRelease(control, control.width - 4, control.implicitHeight - 4)
verify(control.selectedText.length > 0)
control.copySelection()
compare(ClipboardUtils.text, control.selectedText)
}
// Ctrl+C copies the selection even when another editable control held keyboard focus
// first. Selecting in the ChatTextView must move focus to it (onSelectedTextChanged:
// forceActiveFocus), so the previously-focused editor no longer preempts the window
// Copy shortcut.
function test_ctrlCCopiesWhenAnotherEditorHadFocus() {
control.selectable = true
control.blocks = [
{ type: "text", html: "alpha" },
{ type: "text", html: "beta" }
]
tryVerify(() => control.implicitHeight > 0)
// Known, different clipboard value so a successful copy is unambiguous.
ClipboardUtils.setText("sentinel-not-copied")
// Another editable control takes keyboard focus first.
otherEditor.forceActiveFocus()
verify(otherEditor.activeFocus, "other editor did not take focus")
// Select across both blocks.
mousePress(control, 0, 3)
mouseMove(control, control.width - 4, control.implicitHeight - 4)
mouseRelease(control, control.width - 4, control.implicitHeight - 4)
verify(control.selectedText.length > 0, "nothing selected")
// Selecting moved focus off the other editor onto the ChatTextView.
verify(control.activeFocus, "ChatTextView did not take focus on selection")
verify(!otherEditor.activeFocus, "focus not moved away from the other editor")
// Ctrl+C copies the ChatTextView selection (not the other editor's content).
keySequence(StandardKey.Copy)
tryCompare(ClipboardUtils, "text", control.selectedText)
}
// ── link / mention click signals ─────────────────────────────────────────
// Non-selectable (Label): clicking a mention <a> emits mentionClicked(pubKey).
function test_mentionClicked_nonSelectable() {
control.selectable = false
control.blocks = [{ type: "text", html: '<a href="0xabc">@alice</a>' }]
tryVerify(() => control.implicitHeight > 0)
mentionSpy.clear()
mouseClick(control, 10, 5)
compare(mentionSpy.count, 1)
compare(mentionSpy.signalArguments[0][0], "0xabc")
}
// Non-selectable: clicking a URL <a> emits linkClicked(url).
function test_linkClicked_nonSelectable() {
control.selectable = false
control.blocks = [{ type: "text", html: '<a href="https://status.im">https://status.im</a>' }]
tryVerify(() => control.implicitHeight > 0)
linkSpy.clear()
mouseClick(control, 10, 5)
compare(linkSpy.count, 1)
compare(linkSpy.signalArguments[0][0], "https://status.im")
}
// Selectable (TextEdit under the overlay): a click — not a drag — still routes the link.
function test_mentionClicked_selectable() {
control.selectable = true
control.blocks = [{ type: "text", html: '<a href="0xabc">@alice</a>' }]
tryVerify(() => control.implicitHeight > 0)
mentionSpy.clear()
mousePress(control, 10, 5)
mouseRelease(control, 10, 5)
compare(mentionSpy.count, 1)
compare(mentionSpy.signalArguments[0][0], "0xabc")
}
// A drag (selection) must NOT be treated as a link click.
function test_dragDoesNotClickLink() {
control.selectable = true
control.blocks = [{ type: "text", html: '<a href="0xabc">@alice</a>' }]
tryVerify(() => control.implicitHeight > 0)
mentionSpy.clear()
mousePress(control, 2, 5)
mouseMove(control, control.width - 4, 5)
mouseRelease(control, control.width - 4, 5)
compare(mentionSpy.count, 0)
}
// ── hoveredLink ──────────────────────────────────────────────────────────
// hoveredLink reports the url under the pointer, and clears when the pointer moves off it.
function test_hoveredLinkOverLink() {
control.blocks = [
{ type: "text", html: '<a href="https://status.im">https://status.im</a>' },
{ type: "text", html: "no link here at all" }
]
tryVerify(() => control.implicitHeight > 0)
mouseMove(control, 10, 5)
tryCompare(control, "hoveredLink", "https://status.im")
// Move onto the second (link-less) line: hoveredLink clears.
mouseMove(control, 10, control.implicitHeight - 3)
tryCompare(control, "hoveredLink", "")
}
// For a mention the exposed link is its pub key (the href).
function test_hoveredLinkOverMention() {
control.blocks = [{ type: "text", html: '<a href="0xabc" class="mention">@alice</a>' }]
tryVerify(() => control.implicitHeight > 0)
mouseMove(control, 10, 5)
tryCompare(control, "hoveredLink", "0xabc")
}
// Works in selectable mode too (the selection overlay doesn't block hover resolution).
function test_hoveredLinkSelectable() {
control.selectable = true
control.blocks = [{ type: "text", html: '<a href="https://status.im">https://status.im</a>' }]
tryVerify(() => control.implicitHeight > 0)
mouseMove(control, 10, 5)
tryCompare(control, "hoveredLink", "https://status.im")
}
// ── highlightedLink ──────────────────────────────────────────────────────
// Setting highlightedLink adds a background rule for that href; clearing removes it.
function test_highlightedLinkAddsRule() {
control.blocks = [{ type: "text", html: '<a href="https://status.im">https://status.im</a>' }]
tryVerify(() => control.implicitHeight > 0)
verify(!renders(control, 'a[href="https://status.im"]'), "rule present without highlight")
control.highlightedLink = "https://status.im"
tryVerify(() => renders(control, 'a[href="https://status.im"]'), 1000,
"highlight rule not applied")
control.highlightedLink = ""
tryVerify(() => !renders(control, 'a[href="https://status.im"]'), 1000,
"highlight rule not cleared")
}
// The highlight applies to every block containing the link (document-wide).
function test_highlightAppliesToAllBlocks() {
control.blocks = [
{ type: "text", html: '<a href="https://status.im">one</a>' },
{ type: "text", html: '<a href="https://status.im">two</a>' }
]
tryVerify(() => control.implicitHeight > 0)
control.highlightedLink = "https://status.im"
tryVerify(() => countRenders(control, 'a[href="https://status.im"]') === 2, 1000,
"highlight not applied to both blocks")
}
// A highlighted link and a different hovered link produce two independent style rules, so
// both are highlighted at once. Non-selectable so the source <style> is inspectable (a
// selectable TextEdit serializes its document, resolving the CSS to inline attributes);
// the shared styleFor is used by both renderers.
function test_highlightAndHoverCoexist() {
control.highlightedLink = "https://a.example"
control.blocks = [{ type: "text", html: '<a href="https://b.example">https://b.example</a>' }]
tryVerify(() => control.implicitHeight > 0)
mouseMove(control, 10, 5)
tryCompare(control, "hoveredLink", "https://b.example")
// Both rules present in the rendered style at the same time.
tryVerify(() => renders(control, 'a[href="https://a.example"]'), 1000,
"highlighted-link rule missing")
verify(renders(control, 'a[href="https://b.example"]'), "hovered-link rule missing")
}
// ── image emojis (Twemoji) via toBlocks(emojiBaseUrl) ─────────────────────
// 😎 is U+1F60E → twemoji file 1f60e.svg.
readonly property string emoji: "\u{1F60E}"
// With an emoji base url, toBlocks emits a Twemoji <img> and no raw emoji glyph.
function test_imageEmojis_rendersImage() {
control.blocks = MarkdownUtils.toBlocks(
"A" + emoji + "B", ({}), control.font, false, true, 0, Emoji.base)
tryVerify(() => control.implicitHeight > 0)
verify(renders(control, "<img"), "expected an <img> for the emoji")
verify(renders(control, "1f60e.svg"), "expected the twemoji svg source")
verify(!renders(control, emoji), "raw emoji glyph should be absent in image mode")
}
// With an empty base url, toBlocks keeps font-based emoji (raw glyph in a font-size span).
function test_imageEmojis_fontModeKeepsGlyph() {
control.blocks = MarkdownUtils.toBlocks(
"A" + emoji + "B", ({}), control.font, false, true, 0, "")
tryVerify(() => control.implicitHeight > 0)
verify(renders(control, emoji), "font mode keeps the raw emoji")
verify(!renders(control, "<img"), "font mode has no <img>")
}
// An emoji inside an inline code span is also rendered as a Twemoji <img> in image mode
// (the <code> content is routed through the same image wrapper), not left as a font glyph.
function test_imageEmojis_inCodeSpan() {
control.blocks = MarkdownUtils.toBlocks(
"`" + emoji + "`", ({}), control.font, false, true, 0, Emoji.base)
tryVerify(() => control.implicitHeight > 0)
verify(renders(control, "<code"), "inline code span present")
verify(renders(control, "<img"), "emoji rendered as image inside the code span")
verify(renders(control, "1f60e.svg"), "twemoji svg source")
verify(!renders(control, emoji), "raw emoji glyph should be absent in image mode")
}
// An emoji inside a fenced code block is rendered as a Twemoji <img> in image mode: the
// block carries a `codeHtml` (<pre>) variant and the code renderer switches to RichText.
function test_imageEmojis_inCodeBlock() {
control.blocks = MarkdownUtils.toBlocks(
"```\n" + emoji + "\n```", ({}), control.font, false, true, 0, Emoji.base)
tryVerify(() => control.implicitHeight > 0)
verify(renders(control, "<img"), "emoji rendered as image inside the code block")
verify(renders(control, "1f60e.svg"), "twemoji svg source")
verify(!renders(control, emoji), "raw emoji glyph should be absent in image mode")
}
// Copy parity: selecting an image emoji recovers its Unicode (not U+FFFC), matching font
// mode — so copy behaves the same in both modes.
function test_imageEmojis_copyRecoversUnicode() {
control.selectable = true
control.blocks = MarkdownUtils.toBlocks(
"A" + emoji + "B", ({}), control.font, false, true, 0, Emoji.base)
tryVerify(() => control.implicitHeight > 0)
mousePress(control, 0, 3)
mouseMove(control, control.width - 4, control.implicitHeight - 4)
mouseRelease(control, control.width - 4, control.implicitHeight - 4)
compare(control.selectedText, "A" + emoji + "B")
}
}
}