Files
ghostgram/submodules/TextFormat/Tests/ChatInputContentConversionTests.swift
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

421 lines
25 KiB
Swift

import XCTest
import UIKit
import TelegramCore
import Postbox
@testable import TextFormat
final class ChatInputContentConversionTests: XCTestCase {
/// End-to-end guard for the LIVE legacy composer path: after `setInputContent` sets the field's
/// `attributedText = attributedString(from: content)`, the field runs `refreshChatTextInputAttributes`
/// (→ `refreshBlockQuotes`) as decoration. This verifies that a one-blockQuote multi-line quote's single
/// contiguous `.block` run SURVIVES that decoration (does not re-split at the interior newline), which is
/// what determines the number of on-screen quote boxes.
func test_liveDecoration_multiLineQuote_staysOneContiguousBlock() {
// What `attributedString(from: .blockQuote([para AAA, para BBB]))` produces: "AAA\nBBB" with ONE
// `.block` object over the whole range (interior "\n" included).
let content = ChatInputContent(blocks: [
.blockQuote(ChatInputBlockQuote(
content: ChatInputContent(blocks: [
.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "AAA")])),
.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "BBB")])),
]),
collapsed: false))
])
let textView = UITextView()
textView.attributedText = attributedString(from: content)
refreshChatTextInputAttributes(
context: NSObject(),
textView: textView,
primaryTextColor: .black,
accentTextColor: .blue,
baseFontSize: 17.0,
spoilersRevealed: false,
availableEmojis: Set(),
emojiViewProvider: nil,
makeCollapsedQuoteAttachment: nil)
XCTAssertEqual(textView.textStorage.string, "AAA\nBBB")
var ranges: [NSRange] = []
textView.textStorage.enumerateAttribute(ChatTextInputAttributes.block, in: NSRange(location: 0, length: textView.textStorage.length), options: []) { value, range, _ in
if let v = value as? ChatTextInputTextQuoteAttribute, case .quote = v.kind { ranges.append(range) }
}
XCTAssertEqual(ranges, [NSRange(location: 0, length: 7)], "decoration must keep ONE contiguous quote box, not two")
}
// MARK: - Task 4: attributedString(from:)
func test_attributedString_emitsSemanticAttributes() {
var a = ChatInputInlineAttributes(); a.bold = true
let content = ChatInputContent(blocks: [
.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "Hi", attributes: a)]))
])
let s = attributedString(from: content)
XCTAssertEqual(s.string, "Hi")
XCTAssertNotNil(s.attribute(ChatTextInputAttributes.bold, at: 0, effectiveRange: nil))
}
func test_attributedString_renderListMarkers_prependsBulletAndNumberText() {
let content = ChatInputContent(blocks: [
.paragraph(ChatInputParagraph(style: .body, list: ChatInputListMembership(marker: .bullet, level: 0), runs: [ChatInputRun(text: "First")])),
.paragraph(ChatInputParagraph(style: .body, list: ChatInputListMembership(marker: .bullet, level: 0), runs: [ChatInputRun(text: "Second")])),
.paragraph(ChatInputParagraph(style: .body, list: ChatInputListMembership(marker: .ordered, level: 0), runs: [ChatInputRun(text: "One")])),
.paragraph(ChatInputParagraph(style: .body, list: ChatInputListMembership(marker: .ordered, level: 0), runs: [ChatInputRun(text: "Two")])),
])
// Default (unchanged legacy behavior): list membership is ignored, markers not rendered.
XCTAssertEqual(attributedString(from: content).string, "First\nSecond\nOne\nTwo")
// Opt-in: markers rendered as literal text (bullet, then a fresh 1./2. ordered run).
XCTAssertEqual(attributedString(from: content, renderListMarkers: true).string, "• First\n• Second\n1. One\n2. Two")
}
func test_attributedString_renderListMarkers_orderedResetsAfterNonListParagraph() {
let content = ChatInputContent(blocks: [
.paragraph(ChatInputParagraph(style: .body, list: ChatInputListMembership(marker: .ordered, level: 0), runs: [ChatInputRun(text: "A")])),
.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "gap")])),
.paragraph(ChatInputParagraph(style: .body, list: ChatInputListMembership(marker: .ordered, level: 0), runs: [ChatInputRun(text: "B")])),
])
XCTAssertEqual(attributedString(from: content, renderListMarkers: true).string, "1. A\ngap\n1. B")
}
func test_attributedString_blocksJoinedByNewline_andCodeContiguous() {
let content = ChatInputContent(blocks: [
.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "a")])),
.code(ChatInputCode(language: "py", runs: [ChatInputRun(text: "b\nc")]))
])
let s = attributedString(from: content)
XCTAssertEqual(s.string, "a\nb\nc")
let q = s.attribute(ChatTextInputAttributes.block, at: 2, effectiveRange: nil) as? ChatTextInputTextQuoteAttribute
if case .code(let lang)? = q?.kind { XCTAssertEqual(lang, "py") } else { XCTFail("expected .code") }
}
// MARK: - Task 0 (1b-ii): multi-line quote contiguity
/// A `.blockQuote(collapsed: false)` with multiple inner paragraphs emits as ONE contiguous `.block` range
/// (interior "\n"s included) — the content.plainText joins inner paragraphs with "\n", and a single
/// `.block` attribute covers the whole range.
func test_attributedString_multiLineBlockQuote_emitsOneContiguousBlock() {
let content = ChatInputContent(blocks: [
.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "x")])),
.blockQuote(ChatInputBlockQuote(
content: ChatInputContent(blocks: [
.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "a")])),
.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "b")])),
]),
collapsed: false))
])
let s = attributedString(from: content)
XCTAssertEqual(s.string, "x\na\nb")
var ranges: [NSRange] = []
var objects = Set<ObjectIdentifier>()
s.enumerateAttribute(ChatTextInputAttributes.block, in: NSRange(location: 0, length: s.length), options: []) { value, range, _ in
if let v = value as? ChatTextInputTextQuoteAttribute, case .quote = v.kind {
ranges.append(range); objects.insert(ObjectIdentifier(v))
}
}
XCTAssertEqual(ranges, [NSRange(location: 2, length: 3)], "blockQuote must emit as one contiguous range incl. the interior newline")
XCTAssertEqual(objects.count, 1, "the whole blockQuote emits one attribute object")
// The inverse round-trip is lossless: the one contiguous `.block` range parses back to a single
// multi-paragraph `.blockQuote` (see `test_chatInputContent_contiguousMultiLineQuote_mapsToSingleBlockQuote`
// and `test_modelRoundTrip_identity_multiParagraphBlockQuote`).
}
/// The BUG regression guard: a single `.block`/.quote object spanning an interior "\n" (how the legacy
/// UITextView represents a multi-line quote) must parse to ONE multi-paragraph `.blockQuote`, not one
/// `.blockQuote` per line. Previously the per-"\n" split fragmented it, so a save/restore round-trip
/// (persisted `content`) turned one quote into several.
func test_chatInputContent_contiguousMultiLineQuote_mapsToSingleBlockQuote() {
let s = NSMutableAttributedString(string: "AAA\nBBB")
s.addAttribute(ChatTextInputAttributes.block,
value: ChatTextInputTextQuoteAttribute(kind: .quote, isCollapsed: false),
range: NSRange(location: 0, length: 7))
let parsed = chatInputContent(from: s)
XCTAssertEqual(parsed.blocks.count, 1, "one contiguous quote run → one blockQuote")
guard case let .blockQuote(bq) = parsed.blocks[0] else { XCTFail("expected a blockQuote"); return }
XCTAssertFalse(bq.collapsed)
XCTAssertEqual(bq.content.blocks.count, 2, "the interior newline becomes two inner paragraphs")
// And the projection back emits a SINGLE contiguous quote range (one box), not two.
let back = attributedString(from: parsed)
XCTAssertEqual(back.string, "AAA\nBBB")
var ranges: [NSRange] = []
back.enumerateAttribute(ChatTextInputAttributes.block, in: NSRange(location: 0, length: back.length), options: []) { value, range, _ in
if let v = value as? ChatTextInputTextQuoteAttribute, case .quote = v.kind { ranges.append(range) }
}
XCTAssertEqual(ranges, [NSRange(location: 0, length: 7)], "one quote box, not two")
}
/// A multi-paragraph `.blockQuote` now survives the NSAttributedString round-trip unchanged (was lossy).
func test_modelRoundTrip_identity_multiParagraphBlockQuote() {
let content = ChatInputContent(blocks: [
.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "x")])),
.blockQuote(ChatInputBlockQuote(
content: ChatInputContent(blocks: [
.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "a")])),
.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "b")])),
]),
collapsed: false)),
])
XCTAssertEqual(chatInputContent(from: attributedString(from: content)), content)
}
/// Per-line `.quote`-attributed spans map to separate `.blockQuote` blocks on parse (Task 16b). The resulting
/// content's attributed-string projection then emits each blockQuote as its own range. No coalescing.
func test_chatCurrencyRoundTrip_perLineQuoteAttrs_mapToSeparateBlockQuotes() {
let s = NSMutableAttributedString(string: "before\nq1\nq2\nafter")
// "before"=[0,6] \n=6 "q1"=[7,2] \n=9 "q2"=[10,2] \n=12 "after"=[13,5]
s.addAttribute(ChatTextInputAttributes.block,
value: ChatTextInputTextQuoteAttribute(kind: .quote, isCollapsed: false),
range: NSRange(location: 7, length: 2))
s.addAttribute(ChatTextInputAttributes.block,
value: ChatTextInputTextQuoteAttribute(kind: .quote, isCollapsed: false),
range: NSRange(location: 10, length: 2))
let parsed = chatInputContent(from: s)
// Each quote paragraph becomes its own `.blockQuote` block.
XCTAssertEqual(parsed.blocks.count, 4, "before + bq1 + bq2 + after")
guard case .paragraph = parsed.blocks[0],
case .blockQuote = parsed.blocks[1],
case .blockQuote = parsed.blocks[2],
case .paragraph = parsed.blocks[3] else {
XCTFail("expected paragraph/blockQuote/blockQuote/paragraph"); return
}
let back = attributedString(from: parsed)
XCTAssertEqual(back.string, "before\nq1\nq2\nafter")
var ranges: [NSRange] = []
back.enumerateAttribute(ChatTextInputAttributes.block, in: NSRange(location: 0, length: back.length), options: []) { value, range, _ in
if let v = value as? ChatTextInputTextQuoteAttribute, case .quote = v.kind {
ranges.append(range)
}
}
// Two separate ranges — one per blockQuote block.
XCTAssertEqual(ranges.count, 2, "two separate blockQuote blocks emit two separate ranges")
}
// MARK: - Task 5: chatInputContent(from:)
func test_chatInputContent_parsesInlineAndBlocks() {
let s = NSMutableAttributedString(string: "a\nq")
s.addAttribute(ChatTextInputAttributes.bold, value: true as NSNumber, range: NSRange(location: 0, length: 1))
s.addAttribute(ChatTextInputAttributes.block,
value: ChatTextInputTextQuoteAttribute(kind: .quote, isCollapsed: false),
range: NSRange(location: 2, length: 1))
let c = chatInputContent(from: s)
XCTAssertEqual(c.blocks.count, 2)
guard case let .paragraph(p0) = c.blocks[0],
case let .blockQuote(bq) = c.blocks[1] else { return XCTFail("expected paragraph + blockQuote") }
XCTAssertTrue(p0.runs.first?.attributes.bold == true)
XCTAssertFalse(bq.collapsed, "expanded quote maps to collapsed:false")
}
// MARK: - Task 6: round-trip identity
private func file(_ id: Int64) -> TelegramMediaFile {
TelegramMediaFile(
fileId: MediaId(namespace: 0, id: id),
partialReference: nil,
resource: LocalFileMediaResource(fileId: id),
previewRepresentations: [],
videoThumbnails: [],
immediateThumbnailData: nil,
mimeType: "image/webp",
size: nil,
attributes: [],
alternativeRepresentations: []
)
}
func test_modelRoundTrip_identity_overCoveredFeatures() {
var bold = ChatInputInlineAttributes(); bold.bold = true
var emoji = ChatInputInlineAttributes(); emoji.entity = .customEmoji(fileId: 42, file: file(42), enableAnimation: true)
var mention = ChatInputInlineAttributes(); mention.entity = .mention(EnginePeer.Id(9))
let content = ChatInputContent(blocks: [
.paragraph(ChatInputParagraph(style: .body, runs: [
ChatInputRun(text: "Hi "),
ChatInputRun(text: "bold", attributes: bold),
ChatInputRun(text: "\u{FFFC}", attributes: emoji),
ChatInputRun(text: "@x", attributes: mention),
])),
.blockQuote(ChatInputBlockQuote(
content: ChatInputContent(blocks: [
.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "q")])),
]),
collapsed: false)),
.code(ChatInputCode(language: "swift", runs: [ChatInputRun(text: "let x = 1\nlet y = 2")])),
])
XCTAssertEqual(chatInputContent(from: attributedString(from: content)), content)
}
// MARK: - Piece 4: model affordances must match the attributed string exactly
/// A rich fixture exercising every block kind + inline attribute, so `plainText`/`length` parity is
/// meaningfully tested (body run + bold + custom emoji + mention, an expanded blockQuote, a code block with an
/// interior newline, and a collapsed blockQuote).
private func richFixture() -> ChatInputContent {
var bold = ChatInputInlineAttributes(); bold.bold = true
var emoji = ChatInputInlineAttributes(); emoji.entity = .customEmoji(fileId: 7, file: nil, enableAnimation: true)
var mention = ChatInputInlineAttributes(); mention.entity = .mention(EnginePeer.Id(3))
let collapsedNested = ChatInputContent(blocks: [
.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "folded")]))
])
return ChatInputContent(blocks: [
.paragraph(ChatInputParagraph(style: .body, runs: [
ChatInputRun(text: "Hi "),
ChatInputRun(text: "bold", attributes: bold),
ChatInputRun(text: "\u{FFFC}", attributes: emoji),
ChatInputRun(text: "@x", attributes: mention),
])),
.blockQuote(ChatInputBlockQuote(
content: ChatInputContent(blocks: [
.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "q1")])),
.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "q2")])),
]),
collapsed: false)),
.code(ChatInputCode(language: "swift", runs: [ChatInputRun(text: "let a = 1\nlet b = 2")])),
.blockQuote(ChatInputBlockQuote(content: collapsedNested, collapsed: true)),
])
}
func test_plainText_matchesAttributedStringString() {
let content = richFixture()
XCTAssertEqual(content.plainText, attributedString(from: content).string)
}
func test_length_matchesAttributedStringLength() {
let content = richFixture()
XCTAssertEqual(content.length, attributedString(from: content).length)
}
func test_plainText_emptyAndSingleBlock() {
XCTAssertEqual(ChatInputContent().plainText, "")
XCTAssertEqual(ChatInputContent().length, 0)
let single = ChatInputContent(blocks: [.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "abc")]))])
XCTAssertEqual(single.plainText, "abc")
XCTAssertEqual(single.plainText, attributedString(from: single).string)
XCTAssertEqual(single.length, 3)
}
// MARK: - Piece 3: value-equality unlock (emoji object identity must NOT defeat equality)
/// The mechanism `ChatTextInputState.==` relies on in Piece 3: two composer strings that carry the SAME
/// custom emoji (same `fileId` + `enableAnimation`) but as DISTINCT attribute objects — and even differing
/// `interactivelySelectedFromPackId` / `file` resolution — must convert to EQUAL `ChatInputContent` models.
/// `ChatTextInputTextCustomEmojiAttribute.isEqual` is reference-based, so `NSAttributedString.isEqual(to:)`
/// would call these strings unequal; the value model is what makes the model-routed GET read-back compare
/// equal instead of churning change-detection.
func test_modelEquality_sameEmojiDistinctObjects_areEqual() {
let lhs = NSMutableAttributedString(string: "\u{FFFC}")
lhs.addAttribute(ChatTextInputAttributes.customEmoji,
value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: EngineItemCollectionId(namespace: 0, id: 111), fileId: 42, file: nil, enableAnimation: true),
range: NSRange(location: 0, length: 1))
let rhs = NSMutableAttributedString(string: "\u{FFFC}")
rhs.addAttribute(ChatTextInputAttributes.customEmoji,
value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: 42, file: file(42), enableAnimation: true),
range: NSRange(location: 0, length: 1))
XCTAssertFalse(lhs.isEqual(to: rhs), "precondition: reference-based isEqual treats distinct emoji objects as unequal")
XCTAssertEqual(chatInputContent(from: lhs), chatInputContent(from: rhs), "value model must ignore object identity / packId / file resolution")
}
/// Guard the other direction: a genuinely different emoji (different `fileId`) must convert to a DIFFERENT
/// model — coarser-or-equal equality must not collapse distinct content.
func test_modelEquality_differentFileId_areNotEqual() {
let lhs = NSMutableAttributedString(string: "\u{FFFC}")
lhs.addAttribute(ChatTextInputAttributes.customEmoji,
value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: 42, file: nil, enableAnimation: true),
range: NSRange(location: 0, length: 1))
let rhs = NSMutableAttributedString(string: "\u{FFFC}")
rhs.addAttribute(ChatTextInputAttributes.customEmoji,
value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: 99, file: nil, enableAnimation: true),
range: NSRange(location: 0, length: 1))
XCTAssertNotEqual(chatInputContent(from: lhs), chatInputContent(from: rhs))
}
func test_modelRoundTrip_customEmoji_withNilFile_preservesFileId() {
var e = ChatInputInlineAttributes(); e.entity = .customEmoji(fileId: 77, file: nil, enableAnimation: true)
let content = ChatInputContent(blocks: [.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "\u{FFFC}", attributes: e)]))])
XCTAssertEqual(chatInputContent(from: attributedString(from: content)), content)
}
// MARK: - Task 0 (1b-ii): customEmoji enableAnimation fidelity
func test_modelRoundTrip_customEmoji_preservesEnableAnimationFalse() {
// enableAnimation defaults to true in the chat attribute; dropping it would silently
// re-animate a non-animated emoji. The model must carry it (matching ChatTextInputStateText).
var e = ChatInputInlineAttributes(); e.entity = .customEmoji(fileId: 5, file: nil, enableAnimation: false)
let content = ChatInputContent(blocks: [.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "\u{FFFC}", attributes: e)]))])
XCTAssertEqual(chatInputContent(from: attributedString(from: content)), content)
}
// MARK: - Task 0 (1b-ii): collapsed-quote fidelity
func test_modelRoundTrip_collapsedBlockQuote_preservesNestedContent() {
var bold = ChatInputInlineAttributes(); bold.bold = true
let nested = ChatInputContent(blocks: [
.paragraph(ChatInputParagraph(style: .body, runs: [
ChatInputRun(text: "secret "),
ChatInputRun(text: "stuff", attributes: bold),
]))
])
let content = ChatInputContent(blocks: [
.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "before")])),
.blockQuote(ChatInputBlockQuote(content: nested, collapsed: true)),
.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "after")])),
])
XCTAssertEqual(chatInputContent(from: attributedString(from: content)), content)
}
func test_modelRoundTrip_collapsedBlockQuote_atStart() {
let nested = ChatInputContent(blocks: [.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "q")]))])
let content = ChatInputContent(blocks: [
.blockQuote(ChatInputBlockQuote(content: nested, collapsed: true)),
.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "after")])),
])
XCTAssertEqual(chatInputContent(from: attributedString(from: content)), content)
}
/// The chat-currency direction: a hand-built FOLDED `.collapsedBlock` string (exactly what
/// `textAttributedStringForStateText` consumes) must survive model round-trip with its nested
/// content + placement intact — proving the legacy composer's display path stays byte-identical.
func test_chatCurrencyRoundTrip_collapsedBlock_preserved() {
let nested = NSMutableAttributedString(string: "hi")
nested.addAttribute(ChatTextInputAttributes.bold, value: true as NSNumber, range: NSRange(location: 0, length: 2))
let s = NSMutableAttributedString(string: "before\n \nafter")
s.addAttribute(ChatTextInputAttributes.collapsedBlock, value: nested, range: NSRange(location: 7, length: 1))
let back = attributedString(from: chatInputContent(from: s))
XCTAssertEqual(back.string, "before\n \nafter")
var found = false
back.enumerateAttribute(ChatTextInputAttributes.collapsedBlock, in: NSRange(location: 0, length: back.length), options: []) { value, range, _ in
if let v = value as? NSAttributedString {
found = true
XCTAssertEqual(range, NSRange(location: 7, length: 1))
XCTAssertEqual(v.string, "hi")
XCTAssertNotNil(v.attribute(ChatTextInputAttributes.bold, at: 0, effectiveRange: nil))
}
}
XCTAssertTrue(found, "collapsedBlock attribute must survive the round-trip")
}
// Legacy composer render-only filter: the flat `NSAttributedString` projection drops `.media`/`.table` blocks and
// renders heading/list paragraphs as plain text (the legacy UITextView can't carry structural content).
func test_attributedString_filtersStructuralBlocksForLegacyComposer() {
let image = TelegramMediaImage(imageId: MediaId(namespace: 1, id: 1001), representations: [], immediateThumbnailData: nil, reference: nil, partialReference: nil, flags: [])
let media = ChatInputMedia(media: image, kind: .image, naturalSize: ChatInputSize(width: 0, height: 0), displayWidth: nil, alignment: .center, caption: [ChatInputRun(text: "CAPTION")])
let table = ChatInputTable(columns: [ChatInputColumnSpec(width: 0)], rows: [ChatInputTableRow(height: nil, isHeader: false, cells: [ChatInputTableCell(runs: [ChatInputRun(text: "CELL")], background: nil)])])
let content = ChatInputContent(blocks: [
.paragraph(ChatInputParagraph(style: .heading1, runs: [ChatInputRun(text: "Title")])),
.paragraph(ChatInputParagraph(style: .body, list: ChatInputListMembership(marker: .bullet, level: 0), runs: [ChatInputRun(text: "item")])),
.table(table),
.media(media),
.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "after")]))
])
let attr = attributedString(from: content)
// Heading/list render as plain text; media/table (and their cell/caption text) are dropped — no crash.
XCTAssertEqual(attr.string, "Title\nitem\nafter")
XCTAssertFalse(attr.string.contains("CELL"), "table cell text must be dropped")
XCTAssertFalse(attr.string.contains("CAPTION"), "media caption must be dropped")
}
}