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

157 lines
7.8 KiB
Swift

import XCTest
import Postbox
@testable import TelegramCore
@testable import TextFormat
/// The composer resolves custom-emoji `TelegramMediaFile`s asynchronously and writes them back into
/// the interface state. That write-back used to go through `ChatTextInputState(inputText:selectionRange:)`,
/// which re-derives the content from a FLATTENED `NSAttributedString` — so the moment a sticker
/// resolved, every heading / list / quote / table in the composer was retyped as a body paragraph.
/// Symptom: paste a rich message with custom emoji, see its headings, watch them vanish a beat later.
///
/// These pin the two properties the model-side resolver has to have: it fills the file in, and it
/// changes NOTHING else.
final class ChatInputContentCustomEmojiTests: XCTestCase {
private let fileId: Int64 = 12345
private func file(_ id: Int64) -> TelegramMediaFile {
return TelegramMediaFile(
fileId: MediaId(namespace: Namespaces.Media.CloudFile, id: id),
partialReference: nil,
resource: EmptyMediaResource(),
previewRepresentations: [],
videoThumbnails: [],
immediateThumbnailData: nil,
mimeType: "image/webp",
size: nil,
attributes: [],
alternativeRepresentations: []
)
}
private func emojiRun(_ id: Int64, file: TelegramMediaFile? = nil) -> ChatInputRun {
var attributes = ChatInputInlineAttributes()
attributes.entity = .customEmoji(fileId: id, file: file, enableAnimation: true)
return ChatInputRun(text: "\u{FFFC}", attributes: attributes)
}
private func plainRun(_ text: String) -> ChatInputRun {
return ChatInputRun(text: text, attributes: ChatInputInlineAttributes())
}
/// The shape a pasted rich message has: a heading, a quote, and a body paragraph with an emoji.
private func richContent() -> ChatInputContent {
return ChatInputContent(blocks: [
.paragraph(ChatInputParagraph(style: .heading1, runs: [self.plainRun("Title")])),
.blockQuote(ChatInputBlockQuote(content: ChatInputContent(blocks: [
.paragraph(ChatInputParagraph(style: .body, runs: [self.plainRun("quoted "), self.emojiRun(self.fileId)]))
]), collapsed: false, author: [])),
.paragraph(ChatInputParagraph(style: .body, list: ChatInputListMembership(marker: .bullet, level: 0, checked: nil),
runs: [self.plainRun("item "), self.emojiRun(self.fileId)]))
])
}
private func styles(_ content: ChatInputContent) -> [ChatInputParagraphStyle?] {
return content.blocks.map { block in
if case let .paragraph(p) = block { return p.style } else { return nil }
}
}
// MARK: Detection
/// The flat `inputText` projection drops whole blocks, so the old scan could not even SEE an emoji
/// inside a quote or a table cell. The model walk must.
func test_unresolvedIds_areFoundInsideNestedBlocks() {
let content = ChatInputContent(blocks: [
.blockQuote(ChatInputBlockQuote(content: ChatInputContent(blocks: [
.paragraph(ChatInputParagraph(style: .body, runs: [self.emojiRun(1)]))
]), collapsed: true, author: [])),
.table(ChatInputTable(columns: [ChatInputColumnSpec(width: 90)], rows: [
ChatInputTableRow(cells: [ChatInputTableCell(runs: [self.emojiRun(2)])])
])),
.media(ChatInputMedia(items: [], caption: [self.emojiRun(3)])),
.details(ChatInputDetails(content: ChatInputContent(blocks: [
.paragraph(ChatInputParagraph(style: .body, runs: [self.emojiRun(4)]))
]), title: [self.emojiRun(5)], expanded: true))
])
XCTAssertEqual(content.unresolvedCustomEmojiFileIds(), Set([1, 2, 3, 4, 5]))
}
/// An already-resolved run is not "unresolved" and must not be re-fetched every state update.
func test_unresolvedIds_excludeAlreadyResolvedRuns() {
let content = ChatInputContent(blocks: [
.paragraph(ChatInputParagraph(style: .body, runs: [
self.emojiRun(1, file: self.file(1)),
self.emojiRun(2)
]))
])
XCTAssertEqual(content.unresolvedCustomEmojiFileIds(), Set([2]))
}
// MARK: Resolution
/// THE regression: resolving a sticker must fill in the file and leave the block structure alone.
func test_resolving_fillsTheFileAndPreservesBlockStructure() throws {
let content = self.richContent()
XCTAssertEqual(self.styles(content), [.heading1, nil, .body], "precondition: a heading and a quote")
let resolved = content.resolvingCustomEmojiFiles([self.fileId: self.file(self.fileId)])
XCTAssertEqual(self.styles(resolved), [.heading1, nil, .body], "the heading must survive the resolution")
guard case .blockQuote = resolved.blocks[1] else {
return XCTFail("the quote must survive the resolution")
}
guard case let .paragraph(list) = resolved.blocks[2] else { return XCTFail() }
XCTAssertEqual(list.list?.marker, .bullet, "the list membership must survive too")
XCTAssertEqual(resolved.unresolvedCustomEmojiFileIds(), [], "every emoji got its file")
}
/// Nested emoji resolve, not just top-level ones.
func test_resolving_reachesNestedRuns() throws {
let resolved = self.richContent().resolvingCustomEmojiFiles([self.fileId: self.file(self.fileId)])
guard case let .blockQuote(quote) = resolved.blocks[1],
case let .paragraph(inner) = quote.content.blocks[0],
case let .customEmoji(_, file, _) = inner.runs[1].attributes.entity else {
return XCTFail("the quoted emoji run is still there")
}
XCTAssertNotNil(file, "the emoji inside the quote resolved")
}
/// Attribute-only: the flat text is byte-identical, which is what makes it safe for the caller to
/// keep its existing selection across the async resolution.
func test_resolving_doesNotChangeTheText() {
let content = self.richContent()
let resolved = content.resolvingCustomEmojiFiles([self.fileId: self.file(self.fileId)])
XCTAssertEqual(resolved.length, content.length, "the flat length is what a ChatInputSelection is taken against")
XCTAssertEqual(resolved.blocks.count, content.blocks.count)
}
/// A stale entry must never downgrade an already-resolved run, and an unrelated id must be a no-op.
func test_resolving_leavesResolvedAndUnrelatedRunsAlone() throws {
let content = ChatInputContent(blocks: [
.paragraph(ChatInputParagraph(style: .body, runs: [self.emojiRun(1, file: self.file(1))]))
])
let resolved = content.resolvingCustomEmojiFiles([2: self.file(2)])
XCTAssertEqual(resolved, content, "resolving an id that is not present changes nothing")
}
func test_resolving_withNoFiles_isANoOp() {
let content = self.richContent()
XCTAssertEqual(content.resolvingCustomEmojiFiles([:]), content)
}
/// The reason the resolver has to exist at all, stated as a test: the `NSAttributedString` round-trip
/// the old write-back went through is LOSSY for exactly this content. If someone ever "simplifies"
/// `serviceTasksForChatPresentationIntefaceState` back to `ChatTextInputState(inputText:…)`, this is
/// the assertion that explains why the headings disappear.
func test_theFlatRoundTrip_isLossy_whichIsWhyTheModelPathExists() {
let content = self.richContent()
let flattened = chatInputContent(from: attributedString(from: content))
XCTAssertEqual(self.styles(content).first, .heading1)
XCTAssertNotEqual(self.styles(flattened).first, .heading1,
"the flat round-trip drops the heading — resolving emoji through it is what broke paste")
}
}