Files
ichmagmaus111andClaude Opus 5.5 0622057a4f Ghostgram: open-source release
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:57:09 +01:00

655 lines
36 KiB
Swift

#if canImport(UIKit)
import XCTest
import UIKit
@testable import RichTextEditorCore
@testable import RichTextEditorUIKit
/// The button row is the editor's ONLY text-free `CanvasBlock`. Nothing in the canvas had exercised a
/// block with no editable text before, so these cover the structural contract: the position model, the
/// caret being able to reach and LEAVE the row, and the model round-trip through the canvas.
@available(iOS 13.0, *)
final class ButtonRowBoxTests: XCTestCase {
private func makeRow(_ labels: [String], alignment: ButtonRowAlignment = .justify) -> ButtonRowBlock {
ButtonRowBlock(id: BlockID.generate(),
buttons: labels.map { ButtonRef(label: [TextRun(text: $0)], action: .url("https://telegram.org")) },
alignment: alignment)
}
private func paragraph(_ text: String) -> Block {
.paragraph(ParagraphBlock(id: BlockID.generate(), style: .body, runs: [TextRun(text: text)]))
}
/// Re-runs layout so `stampListMarkers` re-reads the host capability flags. Production gets this for
/// free on its next layout pass; a test that assigns a hook after seeding must ask for it.
private func restamp(_ view: RichTextEditorView) {
_ = view.update(size: CGSize(width: 320, height: 480), insets: .zero)
}
private func makeView(with blocks: [Block]) -> RichTextEditorView {
let view = RichTextEditorView(frame: CGRect(x: 0, y: 0, width: 320, height: 480))
view.document = Document(blocks: blocks)
_ = view.update(size: CGSize(width: 320, height: 480), insets: .zero)
return view
}
func test_row_roundTripsThroughTheCanvas() {
let row = makeRow(["A", "B"], alignment: .center)
let view = makeView(with: [.buttonRow(row)])
guard case let .buttonRow(restored) = view.document.blocks.first else {
return XCTFail("expected a buttonRow block back from the canvas, got \(String(describing: view.document.blocks.first))")
}
XCTAssertEqual(restored.buttons, row.buttons)
XCTAssertEqual(restored.alignment, row.alignment)
XCTAssertEqual(restored.id, row.id)
}
func test_row_hasNonZeroHeight() {
let view = makeView(with: [.buttonRow(makeRow(["A", "B"]))])
XCTAssertGreaterThan(view.update(size: CGSize(width: 320, height: 480), insets: .zero), 0)
}
/// The box's `nodeSize` MUST agree with what `DocumentTree` produces, or every position after the
/// row is off and the caret lands in the wrong block.
func test_boxNodeSize_agreesWithTheDocumentTree() {
for labels in [[], ["A"], ["A", "B", "C"]] {
let row = makeRow(labels)
let document = Document(blocks: [.buttonRow(row)])
let box = ButtonRowBox(row: row, mapper: AttributedStringMapper(), width: 320)
// documentSize is the row's nodeSize at the top level (the doc root adds nothing).
XCTAssertEqual(box.nodeSize, DocumentTree.documentSize(document), "for \(labels.count) buttons")
}
}
/// A text-free block owns no leaf regions — that is what keeps the caret off a pill's label.
func test_row_ownsNoLeafRegions() {
let box = ButtonRowBox(row: makeRow(["A", "B"]), mapper: AttributedStringMapper(), width: 320)
XCTAssertTrue(box.leafRegions().isEmpty)
}
/// The regression `CanvasTableNavTests` exists for: a framed atom that swallows the inter-block gap
/// leaves unowned dead space and the caret cannot escape it. Every position in a document
/// containing a row must resolve.
func test_everyPositionResolves_withARowBetweenParagraphs() {
let view = makeView(with: [paragraph("above"), .buttonRow(makeRow(["A", "B"])), paragraph("below")])
let tree = DocumentTree.build(from: view.document)
let size = DocumentTree.documentSize(view.document)
XCTAssertGreaterThan(size, 0)
for position in 0 ... size {
XCTAssertNotNil(PositionResolver.resolve(position, in: tree), "position \(position) did not resolve")
}
}
/// A tap anywhere in the row's band lands on a pill rather than falling through to a neighbour.
func test_closestPosition_snapsToTheNearestPill() {
let row = makeRow(["A", "B", "C"])
let box = ButtonRowBox(row: row, mapper: AttributedStringMapper(), width: 320)
box.frame = CGRect(x: 0, y: 0, width: 320, height: box.height)
box.nodeStart = 10
// Far left → first pill; far right → last pill.
XCTAssertEqual(box.closestPosition(toCanvasPoint: CGPoint(x: 1, y: box.height / 2)), 11)
XCTAssertEqual(box.closestPosition(toCanvasPoint: CGPoint(x: 319, y: box.height / 2)), 13)
}
/// An EMPTY row still needs one caret target, or it can be neither selected nor deleted.
func test_emptyRow_stillResolvesToOnePosition() {
let box = ButtonRowBox(row: makeRow([]), mapper: AttributedStringMapper(), width: 320)
box.nodeStart = 4
XCTAssertEqual(box.nodeSize, 3)
XCTAssertEqual(box.closestPosition(toCanvasPoint: CGPoint(x: 10, y: 10)), 5)
}
/// Removing the last pill leaves an empty row that is still a valid, addressable block.
func test_replaceButton_removesAPillAndRepacks() {
let box = ButtonRowBox(row: makeRow(["A", "B"]), mapper: AttributedStringMapper(), width: 320)
let before = box.nodeSize
XCTAssertTrue(box.replaceButton(at: 0, with: nil))
XCTAssertEqual(box.nodeSize, before - 1)
XCTAssertEqual(box.currentRow().buttons.map(\.labelText), ["B"])
XCTAssertFalse(box.replaceButton(at: 5, with: nil))
}
func test_setAlignment_repacksAndRoundTrips() {
let box = ButtonRowBox(row: makeRow(["A", "B", "C"], alignment: .justify), mapper: AttributedStringMapper(), width: 320)
let justifiedFirst = box.pillFrames.first
box.setAlignment(.center)
XCTAssertEqual(box.currentRow().alignment, .center)
XCTAssertNotEqual(box.pillFrames.first, justifiedFirst)
}
/// The row is a framed atom, so it must NOT be given the inter-block gap — the neighbour below
/// owns it. Its own height is exactly the packed height.
func test_height_isThePackedHeightWithNoExternalInset() {
let box = ButtonRowBox(row: makeRow(["A"]), mapper: AttributedStringMapper(), width: 320)
XCTAssertEqual(box.height, RichTextButtonMetrics.default.blockRowHeight, accuracy: 0.01)
}
func test_spacingKind_isButtonRow() {
let box = ButtonRowBox(row: makeRow(["A"]), mapper: AttributedStringMapper(), width: 320)
XCTAssertEqual(box.spacingKind, .buttonRow)
}
/// Select All + Backspace must REMOVE a row. A text-free block's `textStart + textLength` collapses
/// to `nodeStart`, so without a `coverableContentEnd` arm every selection reads as fully covering
/// it — and a partial selection would wrongly delete the whole row.
func test_selectAllThenBackspace_removesTheRow() {
let view = makeView(with: [paragraph("above"), .buttonRow(makeRow(["A", "B"])), paragraph("below")])
view.selectAll()
view.deleteBackward()
XCTAssertFalse(view.document.blocks.contains { if case .buttonRow = $0 { return true } else { return false } },
"Select All + Backspace left the button row behind")
}
/// REGRESSION: a button row rendered NOTHING on screen. Every block in this canvas is view-backed
/// (`reconcileBlockViews` realizes a `BlockBackingView` only when `rendersAsBlockView` is true) and
/// `DocumentCanvasView` has no `draw(_:)` override, so a block that opts out is simply never drawn.
/// The protocol's `false` default is a stale artifact — all six pre-existing block types set it true.
///
/// Asserted through the realization seam rather than by pixel-diffing: it is the actual mechanism
/// that failed, and the geometry tests above all passed while the row was invisible.
func test_row_isRealizedAsABlockView() {
let canvas = DocumentCanvasView()
let row = makeRow(["A", "B"])
canvas.setBlocks([paragraph("above"), .buttonRow(row)], width: 300)
canvas.frame = CGRect(x: 0, y: 0, width: 300, height: canvas.intrinsicContentSize.height)
canvas.layoutIfNeeded()
canvas.reconcileBlockViews(visibleRect: CGRect(x: 0, y: 0, width: 300, height: 2000))
XCTAssertNotNil(canvas.blockViews[row.id],
"the button row was never realized as a block view, so it renders nothing")
}
/// The same contract, stated directly: opting out of view-backing means opting out of rendering.
func test_row_rendersAsABlockView() {
let box = ButtonRowBox(row: makeRow(["A"]), mapper: AttributedStringMapper(), width: 320)
XCTAssertTrue(box.rendersAsBlockView,
"a block that is not view-backed is never drawn — the canvas has no draw(_:) override")
}
// MARK: - Rendering (regression: rows were entirely invisible)
private func realizedCanvas(_ blocks: [Block], width: CGFloat = 300) -> DocumentCanvasView {
let canvas = DocumentCanvasView()
canvas.setBlocks(blocks, width: width)
canvas.frame = CGRect(x: 0, y: 0, width: width, height: canvas.intrinsicContentSize.height)
canvas.layoutIfNeeded()
canvas.reconcileBlockViews(visibleRect: CGRect(x: 0, y: 0, width: width, height: 4000))
return canvas
}
/// REGRESSION: button rows rendered NOTHING. Every block here is view-backed —
/// `reconcileBlockViews` realizes a `BlockBackingView` only when `rendersAsBlockView` is true, and
/// `DocumentCanvasView` has no `draw(_:)` override — so a block that opts out is never drawn at all.
/// The protocol's `false` default is a stale artifact; all six pre-existing block types set it true.
///
/// Asserted through the realization seam because that is the mechanism that actually failed: every
/// geometry test passed while the row was invisible on screen.
func test_row_isRealizedAsAButtonRowBackingView() {
let row = makeRow(["A", "B"])
let canvas = realizedCanvas([paragraph("above"), .buttonRow(row)])
let view = canvas.blockViews[row.id]
XCTAssertNotNil(view, "the button row was never realized, so it renders nothing")
XCTAssertTrue(view is ButtonRowBackingView,
"a row must get its pill-hosting backing view, not a plain one")
}
/// The pills must be real subviews — a rasterised bitmap can never host a custom emoji.
func test_row_hostsOnePillViewPerButton() {
let row = makeRow(["A", "B", "C"])
let canvas = realizedCanvas([.buttonRow(row)])
guard let view = canvas.blockViews[row.id] as? ButtonRowBackingView else {
return XCTFail("expected a ButtonRowBackingView")
}
view.layoutIfNeeded()
XCTAssertEqual(view.subviews.compactMap { $0 as? ButtonPillView }.count, 3)
}
/// A recycled row view must never be handed to a paragraph: it draws nothing of its own and would
/// render that paragraph blank. Same exclusion `TableBackingView` already has.
func test_rowBackingView_isNotRecycledIntoThePlainPool() {
// A row-ONLY document, so nothing else can contribute to the pool: any growth is the row's view.
let row = makeRow(["A"])
let canvas = realizedCanvas([.buttonRow(row)])
XCTAssertNotNil(canvas.blockViews[row.id], "precondition: the row was realized")
XCTAssertEqual(canvas.recycleQueueDepthForTesting, 0, "precondition: the pool starts empty")
canvas.reconcileBlockViews(visibleRect: CGRect(x: 0, y: 8000, width: 300, height: 300))
XCTAssertNil(canvas.blockViews[row.id], "precondition: the row was culled")
XCTAssertEqual(canvas.recycleQueueDepthForTesting, 0,
"a ButtonRowBackingView must not enter the plain-view recycle pool — a paragraph "
+ "later dequeuing it would render blank, since it draws nothing of its own")
}
/// An INLINE pill is hosted the same way an inline emoji is — as a view at its attachment's rect —
/// so that its label can carry a live custom emoji too.
func test_inlineButton_isHostedAsAPillView() {
var attributes = CharacterAttributes.plain
attributes.button = ButtonRef(label: [TextRun(text: "Go")], action: .url("https://telegram.org"))
let canvas = realizedCanvas([.paragraph(ParagraphBlock(id: BlockID.generate(), style: .body, runs: [
TextRun(text: "tap "),
TextRun(text: "\u{FFFC}", attributes: attributes),
]))])
canvas.syncButtonPillViews()
XCTAssertEqual(canvas.hostedButtonPillCountForTesting, 1,
"an inline pill must be hosted as a view, not baked into the attachment image")
}
// MARK: - Block pill geometry (regression: rows drawn at inline size)
/// REGRESSION: a block pill was framed at `attachment.size.height` — the label ink box plus padding
/// (~20pt), which is an INLINE pill's height — so rows read as inline buttons even though the
/// packing had allocated the correct 40pt slot. A block pill FILLS its slot, exactly as
/// `InstantPageV2ButtonRowView.layoutSubviews` does (`pill.frame = entry.frame`).
func test_blockPill_fillsItsFullSlotHeight() {
let row = makeRow(["A", "B"])
let canvas = realizedCanvas([.buttonRow(row)])
guard let view = canvas.blockViews[row.id] as? ButtonRowBackingView else {
return XCTFail("expected a ButtonRowBackingView")
}
view.layoutIfNeeded()
let pills = view.subviews.compactMap { $0 as? ButtonPillView }
XCTAssertEqual(pills.count, 2)
for pill in pills {
XCTAssertEqual(pill.bounds.height, RichTextButtonMetrics.default.blockRowHeight, accuracy: 0.01,
"a block pill must fill the fixed 40pt touch target, not the inline ink box")
XCTAssertEqual(pill.layer.cornerRadius, RichTextButtonMetrics.default.blockRowHeight / 2.0, accuracy: 0.01,
"the capsule radius follows the pill height")
}
}
/// A block pill's label is a point larger than an inline pill's, and semibold.
func test_blockPill_usesTheLargerLabelFont() {
let mapper = AttributedStringMapper()
let button = ButtonRef(label: [TextRun(text: "Go")], action: .disabled)
let block = mapper.buttonAttachment(button: button, isBlockPill: true, maxWidth: nil)
let font = block.labelString.attribute(.font, at: 0, effectiveRange: nil) as? UIFont
XCTAssertEqual(font?.pointSize, RichTextButtonMetrics.default.blockFontSize)
XCTAssertGreaterThan(RichTextButtonMetrics.default.blockFontSize, RichTextButtonMetrics.default.inlineFontSize)
}
// MARK: - Backspace (regression: a whole run of rows deleted at once)
private func rowCount(_ canvas: DocumentCanvasView) -> Int {
canvas.currentBlocks().filter { if case .buttonRow = $0 { return true } else { return false } }.count
}
/// REGRESSION: Backspace removed EVERY row in a run. A row is text-free, so `prevTextPosition` skips
/// back over all of them and iOS's object-replacement range spans the lot; the generic
/// selection-replace then dropped them together. Each press must remove exactly one.
func test_backspace_deletesOneRowAtATime_notTheWholeRun() {
let canvas = realizedCanvas([
paragraph("above"),
.buttonRow(makeRow(["A"])),
.buttonRow(makeRow(["B"])),
.buttonRow(makeRow(["C"])),
paragraph("below"),
])
XCTAssertEqual(rowCount(canvas), 3, "precondition")
// Simulates exactly what iOS delivers: Backspace at the trailing paragraph's start arrives as an
// object-replacement RANGE anchored at `prevTextPosition(before:)`, which — because a row carries
// no text — skips back over EVERY row in the run. Recomputed per press, as the OS would.
func backspaceAsDeliveredByIOS() {
guard let below = canvas.boxes.last else { return XCTFail("no trailing paragraph") }
let to = below.textStart
canvas.setSelectionForTesting(anchor: canvas.prevTextPosition(before: to), head: to)
canvas.deleteBackward()
}
backspaceAsDeliveredByIOS()
XCTAssertEqual(rowCount(canvas), 2, "the first Backspace must remove exactly one row")
backspaceAsDeliveredByIOS()
XCTAssertEqual(rowCount(canvas), 1, "the second Backspace must remove exactly one more")
backspaceAsDeliveredByIOS()
XCTAssertEqual(rowCount(canvas), 0, "the third removes the last")
}
/// Within a multi-pill row, Backspace removes one pill and keeps the row.
func test_backspace_onAPill_removesThatPillAndKeepsTheRow() {
let row = makeRow(["A", "B", "C"])
let canvas = realizedCanvas([paragraph("above"), .buttonRow(row)])
guard let box = canvas.boxes.compactMap({ $0 as? ButtonRowBox }).first else {
return XCTFail("expected a ButtonRowBox")
}
canvas.setSelectionForTesting(anchor: box.nodeStart + 3, head: box.nodeStart + 3) // the third pill
canvas.deleteBackward()
XCTAssertEqual(rowCount(canvas), 1, "the row must survive while it still has pills")
guard case let .buttonRow(restored) = canvas.currentBlocks().last else {
return XCTFail("expected the row back")
}
XCTAssertEqual(restored.buttons.map { $0.labelText }, ["A", "B"])
}
// MARK: - Authoring
func test_insertButtonRow_replacesAnEmptyParagraph() {
let view = makeView(with: [paragraph("above"), .paragraph(ParagraphBlock(id: BlockID.generate(), style: .body, runs: []))])
// Caret in the trailing EMPTY paragraph — the case that must be replaced rather than split.
let canvas = view.canvasForTesting
guard let empty = canvas.boxes.last else { return XCTFail("no trailing paragraph") }
canvas.setSelectionForTesting(anchor: empty.textStart, head: empty.textStart)
view.insertButtonRow()
let blocks = view.document.blocks
XCTAssertEqual(blocks.count, 2, "the empty paragraph is replaced, not left beside the row")
guard case .buttonRow = blocks[1] else {
return XCTFail("expected a button row, got \(blocks[1])")
}
}
/// A tap on a pill asks the host to edit it, and the completion applies.
func test_tappingAPill_requestsAnEditAndAppliesIt() {
let row = makeRow(["A", "B"])
let view = makeView(with: [.buttonRow(row)])
var received: ButtonRef?
view.onEditButtonRequested = { button, _, completion in
received = button
completion(ButtonRef(label: [TextRun(text: "Edited")], action: .url("https://example.com"), color: .success))
}
guard let box = view.canvasForTesting.boxes.compactMap({ $0 as? ButtonRowBox }).first,
let rect = box.pillCanvasRect(0) else {
return XCTFail("expected a row with a pill rect")
}
XCTAssertTrue(view.canvasForTesting.handleButtonTapIfNeeded(at: CGPoint(x: rect.midX, y: rect.midY)))
XCTAssertEqual(received?.labelText, "A")
guard case let .buttonRow(restored) = view.document.blocks.first else {
return XCTFail("expected the row back")
}
XCTAssertEqual(restored.buttons.map { $0.labelText }, ["Edited", "B"])
XCTAssertEqual(restored.buttons[0].color, .success)
}
/// The edit request says WHICH pill kind it is, so a host can offer different properties for each —
/// the article editor drops the link style for a block-row pill (a chrome-less row button is not
/// authorable) while keeping it for an inline one.
func test_editRequest_reportsThePillKind() {
// A block-row pill.
let rowView = makeView(with: [.buttonRow(makeRow(["A"]))])
var rowKind: Bool?
rowView.onEditButtonRequested = { _, isBlockPill, _ in rowKind = isBlockPill }
guard let box = rowView.canvasForTesting.boxes.compactMap({ $0 as? ButtonRowBox }).first,
let rect = box.pillCanvasRect(0) else {
return XCTFail("expected a row with a pill rect")
}
XCTAssertTrue(rowView.canvasForTesting.handleButtonTapIfNeeded(at: CGPoint(x: rect.midX, y: rect.midY)))
XCTAssertEqual(rowKind, true, "a row pill reports isBlockPill")
// An inline pill.
var attributes = CharacterAttributes.plain
attributes.button = ButtonRef(label: [TextRun(text: "Go")], action: .url("https://telegram.org"))
let inlineView = makeView(with: [.paragraph(ParagraphBlock(id: BlockID.generate(), style: .body,
runs: [TextRun(text: "\u{FFFC}", attributes: attributes)]))])
var inlineKind: Bool?
inlineView.onEditButtonRequested = { _, isBlockPill, _ in inlineKind = isBlockPill }
let canvas = inlineView.canvasForTesting
guard let region = canvas.allLeafRegions().first,
let attachmentBox = region.layout.attachmentBox(at: 0) else {
return XCTFail("expected an inline attachment box")
}
let point = CGPoint(x: attachmentBox.midX + region.canvasOrigin.x,
y: attachmentBox.midY + region.canvasOrigin.y)
XCTAssertTrue(canvas.handleButtonTapIfNeeded(at: point))
XCTAssertEqual(inlineKind, false, "an inline pill reports the inline kind")
}
/// Passing nil from the sheet deletes the pill; deleting the last one removes the row.
func test_editCompletionWithNil_deletesThePill() {
let view = makeView(with: [.buttonRow(makeRow(["Only"]))])
view.onEditButtonRequested = { _, _, completion in completion(nil) }
guard let box = view.canvasForTesting.boxes.compactMap({ $0 as? ButtonRowBox }).first,
let rect = box.pillCanvasRect(0) else {
return XCTFail("expected a row with a pill rect")
}
_ = view.canvasForTesting.handleButtonTapIfNeeded(at: CGPoint(x: rect.midX, y: rect.midY))
XCTAssertFalse(view.document.blocks.contains { if case .buttonRow = $0 { return true } else { return false } },
"deleting the last pill must remove the row")
}
/// With no host listening, a pill tap falls through to normal caret placement.
func test_pillTap_isInertWithoutAHost() {
let view = makeView(with: [.buttonRow(makeRow(["A"]))])
guard let box = view.canvasForTesting.boxes.compactMap({ $0 as? ButtonRowBox }).first,
let rect = box.pillCanvasRect(0) else {
return XCTFail("expected a row with a pill rect")
}
XCTAssertFalse(view.canvasForTesting.handleButtonTapIfNeeded(at: CGPoint(x: rect.midX, y: rect.midY)))
}
// MARK: - The row's "…" menu affordance
/// The "…" is EDITOR-ONLY chrome. It must be laid out AFTER the packing, never inside it — that
/// transcription is pinned to the V2 renderer, and a sent message has no such control.
func test_menuAffordance_sitsAfterTheLastPill() {
let box = ButtonRowBox(row: makeRow(["A", "B"]), mapper: AttributedStringMapper(), width: 320)
box.showsMenuAffordance = true
guard let last = box.pillFrames.last else { return XCTFail("expected pills") }
XCTAssertGreaterThan(box.menuButtonFrame.minX, last.minX)
XCTAssertEqual(box.menuButtonFrame.height, ButtonRowBox.menuButtonSide, accuracy: 0.01)
}
/// The row RESERVES width for the "…", so it always fits on the last line and never grows the row.
/// Reserving beats wrapping because the default alignment is `justify`, where pills stretch to fill
/// the width — an unreserved control would never fit and every row would be two lines tall.
func test_menuAffordance_alwaysFitsWithoutGrowingTheRow() {
for (labels, alignment) in [(["A"], ButtonRowAlignment.justify),
(["A", "B", "C"], .justify),
(Array(repeating: "A fairly long button label", count: 4), .left)] {
let box = ButtonRowBox(row: makeRow(labels, alignment: alignment), mapper: AttributedStringMapper(), width: 320)
box.showsMenuAffordance = true
XCTAssertLessThanOrEqual(box.menuButtonFrame.maxY, box.height + 0.01,
"the affordance must sit inside the row (\(labels.count), \(alignment))")
guard let last = box.pillFrames.last else { continue }
XCTAssertLessThanOrEqual(last.maxX, box.menuButtonFrame.minX + 0.01,
"pills must not run under the affordance (\(labels.count), \(alignment))")
}
}
/// `measuredHeight` is used for layout sizing and MUST agree with the laid-out `height`, or the row
/// is framed at one size and drawn at another.
func test_measuredHeight_agreesWithLaidOutHeight() {
for labels in [["A"], ["A", "B", "C"], Array(repeating: "A fairly long button label", count: 4)] {
let box = ButtonRowBox(row: makeRow(labels, alignment: .left), mapper: AttributedStringMapper(), width: 320)
XCTAssertEqual(box.measuredHeight(forWidth: 320), box.height, accuracy: 0.01,
"for \(labels.count) buttons")
}
}
/// Tapping the "…" asks the host for the row menu — the ONLY way to reach it, so a regression here
/// makes alignment and Add Button unreachable (as they were before this affordance existed).
func test_tappingTheMenuAffordance_requestsTheRowMenu() {
let view = makeView(with: [.buttonRow(makeRow(["A"]))])
var request: ButtonRowMenuRequest?
view.onRequestButtonRowMenu = { request = $0 }
restamp(view)
guard let box = view.canvasForTesting.boxes.compactMap({ $0 as? ButtonRowBox }).first else {
return XCTFail("expected a row")
}
let rect = box.menuButtonCanvasRect()
XCTAssertTrue(view.canvasForTesting.handleButtonTapIfNeeded(at: CGPoint(x: rect.midX, y: rect.midY)))
XCTAssertNotNil(request)
XCTAssertEqual(request?.alignment, .justify)
// The anchor contract: the host builds a transient anchor view at `sourceRect` INSIDE `view`, so
// both must be supplied and the rect must be the "…" in the CANVAS's coordinate space. Anchoring
// to the whole editor instead put the menu offscreen.
XCTAssertTrue(request?.view === view.canvasForTesting)
XCTAssertEqual(request?.sourceRect, rect)
XCTAssertFalse(rect.isEmpty)
}
/// Add Button appends a pill to the row.
func test_addButton_appendsAPill() {
let view = makeView(with: [.buttonRow(makeRow(["A"]))])
var request: ButtonRowMenuRequest?
view.onRequestButtonRowMenu = { request = $0 }
restamp(view)
view.onEditButtonRequested = { _, _, _ in } // the sheet opens for the new pill; leave it pending
guard let box = view.canvasForTesting.boxes.compactMap({ $0 as? ButtonRowBox }).first else {
return XCTFail("expected a row")
}
let rect = box.menuButtonCanvasRect()
_ = view.canvasForTesting.handleButtonTapIfNeeded(at: CGPoint(x: rect.midX, y: rect.midY))
request?.addButton()
guard case let .buttonRow(restored) = view.document.blocks.first else {
return XCTFail("expected the row back")
}
XCTAssertEqual(restored.buttons.count, 2)
}
/// Alignment applies through the menu request.
func test_menuRequest_setsAlignment() {
let view = makeView(with: [.buttonRow(makeRow(["A"], alignment: .justify))])
var request: ButtonRowMenuRequest?
view.onRequestButtonRowMenu = { request = $0 }
restamp(view)
guard let box = view.canvasForTesting.boxes.compactMap({ $0 as? ButtonRowBox }).first else {
return XCTFail("expected a row")
}
let rect = box.menuButtonCanvasRect()
_ = view.canvasForTesting.handleButtonTapIfNeeded(at: CGPoint(x: rect.midX, y: rect.midY))
request?.setAlignment(.center)
guard case let .buttonRow(restored) = view.document.blocks.first else {
return XCTFail("expected the row back")
}
XCTAssertEqual(restored.alignment, .center)
}
/// The affordance must not steal a tap meant for a pill.
func test_pillTap_stillReachesThePill_notTheMenu() {
let view = makeView(with: [.buttonRow(makeRow(["A", "B"]))])
var menuRequested = false
var editRequested = false
view.onRequestButtonRowMenu = { _ in menuRequested = true }
view.onEditButtonRequested = { _, _, _ in editRequested = true }
guard let box = view.canvasForTesting.boxes.compactMap({ $0 as? ButtonRowBox }).first,
let rect = box.pillCanvasRect(0) else {
return XCTFail("expected a pill rect")
}
_ = view.canvasForTesting.handleButtonTapIfNeeded(at: CGPoint(x: rect.midX, y: rect.midY))
XCTAssertTrue(editRequested)
XCTAssertFalse(menuRequested)
}
/// The chat composer is preservation-only — it wires no authoring hooks — so it must NOT show the
/// "…" control. The affordance is gated on a host actually listening, so no extra knob is needed.
func test_composerHost_showsNoMenuAffordance() {
let view = makeView(with: [.buttonRow(makeRow(["A"]))]) // no onRequestButtonRowMenu
guard let box = view.canvasForTesting.boxes.compactMap({ $0 as? ButtonRowBox }).first else {
return XCTFail("expected a row")
}
XCTAssertFalse(box.showsMenuAffordance)
XCTAssertEqual(box.menuButtonFrame, .zero)
XCTAssertNil(box.nearestPillIndex(toCanvasPoint: CGPoint(x: -100, y: -100)).flatMap { _ in
box.hitsMenuButton(atCanvasPoint: CGPoint(x: 0, y: 0)) ? 1 : nil
}, "a hidden affordance must not hit-test")
}
/// And with it hidden, the row reserves NO width — so composer pills are exactly as wide as they
/// will be in the sent message, which is the whole point of the composer being WYSIWYG.
func test_composerHost_reservesNoWidthForTheAffordance() {
let hidden = ButtonRowBox(row: makeRow(["A"]), mapper: AttributedStringMapper(), width: 320)
XCTAssertFalse(hidden.showsMenuAffordance)
let shown = ButtonRowBox(row: makeRow(["A"]), mapper: AttributedStringMapper(), width: 320)
shown.showsMenuAffordance = true
guard let hiddenPill = hidden.pillFrames.first, let shownPill = shown.pillFrames.first else {
return XCTFail("expected pills")
}
// Justify stretches the pill to the available width, so the reserve is directly visible.
XCTAssertEqual(hiddenPill.width, 320.0, accuracy: 0.01)
XCTAssertEqual(shownPill.width, 320.0 - ButtonRowBox.menuButtonReserve, accuracy: 0.01)
}
// MARK: - Inner padding gives way to the label
private func packedAttachments(_ buttons: [ButtonRef], alignment: ButtonRowAlignment = .justify,
width: CGFloat = 320.0) -> [ButtonTextAttachment] {
let mapper = AttributedStringMapper()
return richTextPackButtonRow(
buttons: buttons, alignment: alignment, availableWidth: width,
metrics: .default, isRTL: false,
hasIcon: { mapper.buttonHasIcon($0.action) },
measure: { mapper.buttonAttachment(button: $0, isBlockPill: true, maxWidth: $1, horizontalPadding: $2) }
).attachments
}
/// A pill's inner padding is a preference, not a constraint: a label that would be ellipsised at the
/// comfortable padding is re-measured at the minimum, and the room it wins goes to the text.
func test_aLabelThatDoesNotFit_fallsBackToTheMinimumPadding() {
// Four ~76pt columns. At 19pt padding that leaves ~38pt of ink — far less than these labels.
let labels = ["Subscribe now", "Open the website", "Read more", "Contact us"]
// `.disabled` carries no type badge, so the padding is the ONLY constraint and the full
// fallback is visible in the label.
let attachments = packedAttachments(labels.map { ButtonRef(label: [TextRun(text: $0)], action: .disabled) })
XCTAssertEqual(attachments.count, labels.count)
for attachment in attachments {
XCTAssertEqual(attachment.horizontalPadding, RichTextButtonMetrics.default.blockMinimumHorizontalPadding)
}
}
/// And the point of all of it: more of the label survives than before.
func test_theFallbackFitsMoreOfTheLabel() {
let label = "Subscribe now"
let button = ButtonRef(label: [TextRun(text: label)], action: .disabled)
let mapper = AttributedStringMapper()
let columnWidth = (320.0 - RichTextButtonMetrics.default.blockSpacing * 3.0) / 4.0
let comfortable = mapper.buttonAttachment(
button: button, isBlockPill: true, maxWidth: columnWidth,
horizontalPadding: RichTextButtonMetrics.default.blockHorizontalPadding)
guard let packed = packedAttachments(Array(repeating: button, count: 4)).first else {
return XCTFail("expected a pill")
}
XCTAssertTrue(comfortable.isTruncated, "precondition: this label does not fit at the comfortable padding")
XCTAssertGreaterThan(packed.labelString.length, comfortable.labelString.length)
}
/// The fallback only arms where it is needed — a short label keeps the comfortable padding, so a
/// row of short labels looks exactly as it did.
func test_aLabelThatFits_keepsTheComfortablePadding() {
guard let attachment = packedAttachments([ButtonRef(label: [TextRun(text: "Go")], action: .disabled)]).first else {
return XCTFail("expected a pill")
}
XCTAssertFalse(attachment.isTruncated)
XCTAssertEqual(attachment.horizontalPadding, RichTextButtonMetrics.default.blockHorizontalPadding)
}
/// Per BUTTON, not per row: one long label must not tighten its neighbours.
func test_theFallbackAppliesPerButton() {
let buttons = [
ButtonRef(label: [TextRun(text: "Go")], action: .disabled),
ButtonRef(label: [TextRun(text: "Subscribe to the newsletter")], action: .disabled),
]
let attachments = packedAttachments(buttons)
XCTAssertEqual(attachments.count, 2)
XCTAssertEqual(attachments.first?.horizontalPadding, RichTextButtonMetrics.default.blockHorizontalPadding)
XCTAssertEqual(attachments.last?.horizontalPadding, RichTextButtonMetrics.default.blockMinimumHorizontalPadding)
}
/// A link button is chrome-less at 0 padding already, so the fallback must never hand it MORE room
/// than it started with.
func test_theFallbackNeverWidensALinkButtonsPadding() {
let button = ButtonRef(label: [TextRun(text: String(repeating: "long ", count: 40))],
action: .disabled, isLink: true)
guard let attachment = packedAttachments([button]).first else {
return XCTFail("expected a pill")
}
XCTAssertTrue(attachment.isTruncated, "precondition: far too long to fit")
XCTAssertEqual(attachment.horizontalPadding, 0.0)
}
/// The justified path used to clear `padding + reserve` per side where only the LARGER of the two is
/// occupied, costing a badge-bearing label ~36pt of ink for nothing. The clearance is now the max.
func test_theBadgeReserveDoesNotStackOnTopOfThePadding() {
let button = ButtonRef(label: [TextRun(text: "Open")], action: .url("https://telegram.org"))
let metrics = RichTextButtonMetrics.default
// A column sized so the label fits with the badge cleared, but NOT if the two stacked.
let columnWidth = metrics.blockIconReserve * 2.0 + metrics.blockHorizontalPadding * 2.0 + 30.0
guard let attachment = packedAttachments([button], width: columnWidth).first else {
return XCTFail("expected a pill")
}
XCTAssertFalse(attachment.isTruncated)
XCTAssertEqual(attachment.horizontalPadding, metrics.blockHorizontalPadding)
}
}
#endif