mirror of
https://github.com/status-im/status-app.git
synced 2026-08-27 15:11:20 +00:00
680 lines
30 KiB
QML
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")
|
|
}
|
|
}
|
|
}
|