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

151 lines
6.9 KiB
Swift

import XCTest
import Foundation
import TelegramCore
@testable import TextFormat
/// Inline formatting INSIDE a quote, across the flat `NSAttributedString` projection
/// (`ChatTextInputState.inputText` → `attributedString(from:)`).
///
/// This projection is not only what the legacy composer field displays — the normal send path
/// serialises it (`expandedInputStateAttributedString(composeInputState.inputText)` → entities), and
/// so does the draft sync. So anything it drops is dropped from the sent message too: the projection
/// used to render an expanded quote as `content.plainText`, and a custom emoji inside one arrived at
/// the recipient as its `alt` text.
final class ChatInputQuoteInlineAttributeTests: XCTestCase {
private let emojiFileId: Int64 = 1234567890
private func customEmojiRun(_ text: String) -> ChatInputRun {
var attributes = ChatInputInlineAttributes()
attributes.entity = .customEmoji(fileId: self.emojiFileId, file: nil, enableAnimation: true)
return ChatInputRun(text: text, attributes: attributes)
}
private func boldRun(_ text: String) -> ChatInputRun {
var attributes = ChatInputInlineAttributes()
attributes.bold = true
return ChatInputRun(text: text, attributes: attributes)
}
private func quote(collapsed: Bool, runs: [ChatInputRun]) -> ChatInputContent {
return ChatInputContent(blocks: [
.blockQuote(ChatInputBlockQuote(
content: ChatInputContent(blocks: [.paragraph(ChatInputParagraph(runs: runs))]),
collapsed: collapsed))
])
}
/// What the send path actually serialises.
private func sendableString(_ content: ChatInputContent) -> NSAttributedString {
return expandedInputStateAttributedString(attributedString(from: content))
}
private func customEmojiIds(in string: NSAttributedString) -> [Int64] {
var ids: [Int64] = []
string.enumerateAttribute(ChatTextInputAttributes.customEmoji, in: NSRange(location: 0, length: string.length), options: []) { value, _, _ in
if let value = value as? ChatTextInputTextCustomEmojiAttribute {
ids.append(value.fileId)
}
}
return ids
}
// MARK: The regression
func test_customEmojiSurvivesAnExpandedQuote() {
let content = self.quote(collapsed: false, runs: [
ChatInputRun(text: "look "),
self.customEmojiRun("🌟")
])
XCTAssertEqual(self.customEmojiIds(in: self.sendableString(content)), [self.emojiFileId])
}
/// The collapsed form always worked — it stows the whole attributed string in `.collapsedBlock`,
/// which `expandedInputStateAttributedString` splices back in — so the bug only showed on expanded
/// quotes. Pinned so the two forms cannot drift apart again.
func test_customEmojiSurvivesACollapsedQuote() {
let content = self.quote(collapsed: true, runs: [
ChatInputRun(text: "look "),
self.customEmojiRun("🌟")
])
XCTAssertEqual(self.customEmojiIds(in: self.sendableString(content)), [self.emojiFileId])
}
/// Custom emoji was the reported symptom; the projection was dropping EVERY inline attribute in a
/// quote, so fixing only the emoji would have left bold, links and the rest still failing.
func test_boldSurvivesAnExpandedQuote() {
let string = self.sendableString(self.quote(collapsed: false, runs: [self.boldRun("shouted")]))
var foundBold = false
string.enumerateAttribute(ChatTextInputAttributes.bold, in: NSRange(location: 0, length: string.length), options: []) { value, _, _ in
if value != nil {
foundBold = true
}
}
XCTAssertTrue(foundBold)
}
// MARK: The things the fix must not disturb
/// The projection's characters are the flat axis every selection offset is measured against, so
/// recursing into the quote instead of taking `plainText` must not change a single one.
func test_flatTextIsUnchangedByTheRecursion() {
let content = self.quote(collapsed: false, runs: [
ChatInputRun(text: "look "),
self.customEmojiRun("🌟"),
ChatInputRun(text: " there")
])
XCTAssertEqual(attributedString(from: content).string, content.plainText)
}
/// A multi-paragraph quote still separates its paragraphs with exactly one newline, and still
/// matches `plainText`.
func test_flatTextIsUnchangedForAMultiParagraphQuote() {
let content = ChatInputContent(blocks: [
.blockQuote(ChatInputBlockQuote(
content: ChatInputContent(blocks: [
.paragraph(ChatInputParagraph(runs: [ChatInputRun(text: "first")])),
.paragraph(ChatInputParagraph(runs: [self.customEmojiRun("🌟")]))
]),
collapsed: false))
])
XCTAssertEqual(attributedString(from: content).string, content.plainText)
XCTAssertEqual(self.customEmojiIds(in: self.sendableString(content)), [self.emojiFileId])
}
/// The whole quote is still marked as a quote — that is what becomes `messageEntityBlockquote`.
func test_theQuoteAttributeStillCoversTheWholeQuote() {
let content = self.quote(collapsed: false, runs: [
ChatInputRun(text: "look "),
self.customEmojiRun("🌟")
])
let string = attributedString(from: content)
var covered = 0
string.enumerateAttribute(ChatTextInputAttributes.block, in: NSRange(location: 0, length: string.length), options: []) { value, range, _ in
if let value = value as? ChatTextInputTextQuoteAttribute, case .quote = value.kind {
covered += range.length
}
}
XCTAssertEqual(covered, string.length)
}
/// A code block nested in a quote keeps its own block kind: the quote attribute fills the gaps
/// around it rather than overwriting the more specific one.
func test_nestedCodeBlockKeepsItsOwnBlockKind() {
let content = ChatInputContent(blocks: [
.blockQuote(ChatInputBlockQuote(
content: ChatInputContent(blocks: [
.paragraph(ChatInputParagraph(runs: [ChatInputRun(text: "see")])),
.code(ChatInputCode(language: "swift", runs: [ChatInputRun(text: "let x = 1")]))
]),
collapsed: false))
])
let string = attributedString(from: content)
var sawCode = false
string.enumerateAttribute(ChatTextInputAttributes.block, in: NSRange(location: 0, length: string.length), options: []) { value, _, _ in
if let value = value as? ChatTextInputTextQuoteAttribute, case .code = value.kind {
sawCode = true
}
}
XCTAssertTrue(sawCode, "the inner code block must not be overwritten by the quote attribute")
}
}