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

135 lines
6.4 KiB
Swift

import XCTest
import Postbox
import TelegramCore
import RichTextEditorCore
import RichTextEditorMessageConversion
@testable import ChatRichTextEditorComposer
/// The editor `Document` <-> `ChatInputContent` bridge, for buttons. The composer is the host that
/// must PRESERVE an incoming button (it has no authoring UI), so these are the round-trips that keep
/// a button-bearing message intact across an edit and a draft save.
final class DocumentButtonBridgeTests: XCTestCase {
private func toContent(_ document: Document) -> ChatInputContent {
chatInputContent(fromDocument: document, resolveEmoji: { _ in nil }, resolveMedia: { _ in nil })
}
private func toDocument(_ content: ChatInputContent) -> Document {
document(fromChatInputContent: content,
registerEmoji: { _, _ in EmojiRef(id: "0", instanceID: "0") },
registerMedia: { _ in "" })
}
private func row(_ alignment: ButtonRowAlignment = .center) -> ButtonRowBlock {
ButtonRowBlock(
id: BlockID.generate(),
buttons: [ButtonRef(label: [TextRun(text: "Open")], action: .url("https://telegram.org"), color: .primary),
ButtonRef(label: [TextRun(text: "Nope")], action: .disabled)],
alignment: alignment
)
}
func testButtonRowRoundTripsBothDirections() {
let source = row()
let content = toContent(Document(blocks: [.buttonRow(source)]))
guard case let .buttonRow(chatRow) = content.blocks.first else {
return XCTFail("expected a .buttonRow block, got \(String(describing: content.blocks.first))")
}
XCTAssertEqual(chatRow.alignment, .center)
XCTAssertEqual(chatRow.buttons.count, 2)
guard case let .buttonRow(restored) = toDocument(content).blocks.first else {
return XCTFail("expected a .buttonRow block back")
}
XCTAssertEqual(restored.alignment, source.alignment)
// The BlockID is regenerated by design; the buttons must survive untouched.
XCTAssertEqual(restored.buttons, source.buttons)
}
/// A preserved non-URL action must survive the composer round-trip verbatim — that is the whole
/// point of the opaque blob.
func testPreservedCallbackActionSurvivesTheRoundTrip() {
let source = ButtonRowBlock(
id: BlockID.generate(),
buttons: [ButtonRef(
label: [TextRun(text: "Tap")],
action: buttonAction(from: .callback(requiresPassword: true, data: MemoryBuffer(data: Data([7, 8, 9])))),
color: .danger,
isLink: true
)],
alignment: .right
)
guard case let .buttonRow(restored) = toDocument(toContent(Document(blocks: [.buttonRow(source)]))).blocks.first else {
return XCTFail("expected a .buttonRow block back")
}
XCTAssertEqual(restored.buttons, source.buttons)
}
func testInlineButtonRoundTripsAsSingleObjectReplacement() {
var attributes = CharacterAttributes.plain
attributes.button = ButtonRef(label: [TextRun(text: "Go")], action: .url("https://telegram.org"), isLink: true)
let document = Document(blocks: [.paragraph(ParagraphBlock(id: BlockID.generate(), style: .body, runs: [
TextRun(text: "before "),
TextRun(text: "\u{FFFC}", attributes: attributes),
]))])
let content = toContent(document)
guard case let .paragraph(p) = content.blocks.first, p.runs.count == 2 else {
return XCTFail("expected one paragraph with two runs")
}
XCTAssertEqual(p.runs[1].text, "\u{FFFC}")
guard case .button = p.runs[1].attributes.entity else {
return XCTFail("expected a .button entity")
}
guard case let .paragraph(bp) = toDocument(content).blocks.first, bp.runs.count == 2 else {
return XCTFail("expected one paragraph with two runs back")
}
XCTAssertEqual(bp.runs[1].text, "\u{FFFC}")
XCTAssertEqual(bp.runs[1].attributes.button, attributes.button)
}
/// A button row is not representable inside a table cell (a cell is a flat run list). It must
/// contribute nothing rather than leaking a placeholder or its label into the cell text.
func testButtonRowInsideTableCellContributesNoRuns() {
let cell = Cell(id: BlockID.generate(), blocks: [.buttonRow(row())])
let table = TableBlock(id: BlockID.generate(),
columns: [ColumnSpec(width: 0)],
rows: [Row(id: BlockID.generate(), height: nil, cells: [cell])],
compact: false)
guard case let .table(t) = toContent(Document(blocks: [.table(table)])).blocks.first else {
return XCTFail("expected a table")
}
XCTAssertTrue(t.rows[0].cells[0].runs.isEmpty)
}
/// A pasted button row cannot be rendered by the legacy input, so it must latch the composer to
/// the native editor — same rule as a table or a medium.
func testButtonRowRequiresNativeRichInput() {
// Exercised through the same predicate `rtfRequiresNativeRichInput` uses on its parsed document.
XCTAssertTrue(documentRequiresNativeRichInput(Document(blocks: [.buttonRow(row())])))
XCTAssertFalse(documentRequiresNativeRichInput(Document(blocks: [
.paragraph(ParagraphBlock(id: BlockID.generate(), style: .body, runs: [TextRun(text: "plain")])),
])))
}
/// A table's `bordered` flag survives both bridge directions, so a toggle reaches drafts and the wire.
func testTableBorderedRoundTripsBothDirections() {
let table = TableBlock(id: BlockID.generate(),
columns: [ColumnSpec(width: 0)],
rows: [Row(id: BlockID.generate(), height: nil,
cells: [Cell(id: BlockID.generate(), blocks: [])])],
bordered: false)
let content = toContent(Document(blocks: [.table(table)]))
guard case let .table(chatTable) = content.blocks.first else {
return XCTFail("expected a table in the currency")
}
XCTAssertFalse(chatTable.bordered)
guard case let .table(restored) = toDocument(content).blocks.first else {
return XCTFail("expected a table back")
}
XCTAssertFalse(restored.bordered)
}
}