mirror of
https://github.com/ichmagmaus111/ghostgram.git
synced 2026-11-03 22:27:13 +01:00
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
151 lines
6.9 KiB
Swift
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")
|
|
}
|
|
}
|