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

181 lines
8.9 KiB
Swift

import XCTest
import Postbox
import TelegramCore
import RichTextEditorCore
@testable import RichTextEditorMessageConversion
final class ButtonActionCodecTests: XCTestCase {
private func assertRoundTrips(_ action: ReplyMarkupButtonAction, file: StaticString = #filePath, line: UInt = #line) {
let core = buttonAction(from: action)
XCTAssertEqual(replyMarkupButtonAction(from: core), action, file: file, line: line)
}
func testNamedActionsRoundTrip() {
assertRoundTrips(.url("https://telegram.org"))
assertRoundTrips(.copyText(payload: "hello"))
assertRoundTrips(.disabled)
}
func testNamedActionsUseNamedCases() {
XCTAssertEqual(buttonAction(from: .url("https://telegram.org")), .url("https://telegram.org"))
XCTAssertEqual(buttonAction(from: .copyText(payload: "hi")), .copyText("hi"))
XCTAssertEqual(buttonAction(from: .disabled), .disabled)
}
/// Every action Core cannot name must survive verbatim through the opaque blob.
func testUnsupportedActionsRoundTripVerbatim() {
assertRoundTrips(.callback(requiresPassword: true, data: MemoryBuffer(data: Data([0x01, 0x02, 0xff]))))
assertRoundTrips(.urlAuth(url: "https://telegram.org", buttonId: 7))
assertRoundTrips(.openWebView(url: "https://telegram.org/app", simple: true))
assertRoundTrips(.switchInline(samePeer: true, query: "q", peerTypes: [.users, .bots]))
assertRoundTrips(.openWebApp)
assertRoundTrips(.payment)
}
func testUnsupportedActionIsOpaqueToCore() {
guard case let .unsupported(kind, payload) = buttonAction(from: .payment) else {
return XCTFail("expected .unsupported")
}
XCTAssertFalse(kind.isEmpty)
XCTAssertFalse(payload.isEmpty)
}
/// A blob written by a future build with an unknown `kind`, or a corrupt payload, must degrade
/// rather than crash — a Document can arrive from a newer build via .rtdoc or the clipboard.
func testUnknownBlobDegradesToDisabled() {
XCTAssertEqual(replyMarkupButtonAction(from: .unsupported(kind: "notARealKind", payload: "%%%")), .disabled)
XCTAssertEqual(replyMarkupButtonAction(from: .unsupported(kind: "notARealKind", payload: "")), .disabled)
}
func testColorAndAlignmentMapBothWays() {
XCTAssertEqual(buttonColor(from: .danger), .danger)
XCTAssertNil(buttonColor(from: nil))
XCTAssertEqual(replyMarkupColor(from: .success), .success)
XCTAssertNil(replyMarkupColor(from: nil))
XCTAssertEqual(buttonRowAlignment(from: .center), .center)
XCTAssertEqual(instantPageRowAlignment(from: .justify), .justify)
}
/// The raw values are persisted on both sides and must stay in lockstep.
func testColorAndAlignmentRawValuesAgreeAcrossTheSeam() {
XCTAssertEqual(ButtonColor.primary.rawValue, ReplyMarkupButton.Style.Color.primary.rawValue)
XCTAssertEqual(ButtonColor.danger.rawValue, ReplyMarkupButton.Style.Color.danger.rawValue)
XCTAssertEqual(ButtonColor.success.rawValue, ReplyMarkupButton.Style.Color.success.rawValue)
XCTAssertEqual(ButtonRowAlignment.justify.rawValue, InstantPageButtonRowAlignment.justify.rawValue)
XCTAssertEqual(ButtonRowAlignment.left.rawValue, InstantPageButtonRowAlignment.left.rawValue)
XCTAssertEqual(ButtonRowAlignment.center.rawValue, InstantPageButtonRowAlignment.center.rawValue)
XCTAssertEqual(ButtonRowAlignment.right.rawValue, InstantPageButtonRowAlignment.right.rawValue)
}
func testChatInputButtonRoundTripsThroughButtonRef() {
let original = ChatInputButton(
label: [ChatInputRun(text: "Open")],
action: .callback(requiresPassword: false, data: MemoryBuffer(data: Data([9]))),
color: .danger,
isLink: true
)
XCTAssertEqual(chatInputButton(fromButtonRef: buttonRef(fromChatInputButton: original)), original)
}
/// Inline formatting on a label survives; a `.url` entity becomes the run's `link`.
func testLabelFormattingRoundTrips() {
let original = ChatInputButton(
label: [
ChatInputRun(text: "bold", attributes: ChatInputInlineAttributes(bold: true)),
ChatInputRun(text: "link", attributes: {
var a = ChatInputInlineAttributes()
a.entity = .url("https://telegram.org")
return a
}()),
],
action: .url("https://telegram.org")
)
XCTAssertEqual(chatInputButton(fromButtonRef: buttonRef(fromChatInputButton: original)), original)
}
}
/// The article-editor send path (`composeRichMessage`). A button has no message-entity form, so its
/// presence must force the rich InstantPage path — otherwise the message is sent as plain text +
/// entities and the buttons are destroyed on the way out.
final class ButtonSendPathTests: XCTestCase {
private func paragraph(_ runs: [TextRun]) -> Block {
.paragraph(ParagraphBlock(id: BlockID.generate(), style: .body, runs: runs))
}
private func inlineButtonRun(_ label: String) -> TextRun {
var attributes = CharacterAttributes.plain
attributes.button = ButtonRef(label: [TextRun(text: label)], action: .url("https://telegram.org"))
return TextRun(text: "\u{FFFC}", attributes: attributes)
}
func testButtonRowForcesTheRichPath() {
let row = ButtonRowBlock(id: BlockID.generate(),
buttons: [ButtonRef(label: [TextRun(text: "Go")], action: .url("https://telegram.org"))],
alignment: .justify)
let document = Document(blocks: [paragraph([TextRun(text: "hi")]), .buttonRow(row)])
guard case let .rich(page) = composeRichMessage(from: document) else {
return XCTFail("a button row must force the rich path")
}
XCTAssertTrue(page.blocks.contains { if case .buttonRow = $0 { return true } else { return false } })
}
func testInlineButtonForcesTheRichPath() {
let document = Document(blocks: [paragraph([TextRun(text: "tap "), inlineButtonRun("Go")])])
guard case .rich = composeRichMessage(from: document) else {
return XCTFail("an inline button must force the rich path")
}
}
/// The pill must reach the page as a `RichText.textButton`. It is an if-let chain rather than an
/// exhaustive switch, so a missing arm compiles and silently emits the bare U+FFFC placeholder.
func testInlineButtonEmitsATextButtonNotAPlaceholder() {
let document = Document(blocks: [paragraph([inlineButtonRun("Go")])])
guard case let .rich(page) = composeRichMessage(from: document),
case let .paragraph(text) = page.blocks.first else {
return XCTFail("expected one rich paragraph block")
}
guard case let .textButton(button) = text else {
return XCTFail("expected a RichText.textButton, got \(text)")
}
XCTAssertEqual(button.text.plainText, "Go")
XCTAssertEqual(button.action, .url("https://telegram.org"))
}
func testEmittedRowCarriesAlignmentActionAndStyle() {
let row = ButtonRowBlock(
id: BlockID.generate(),
buttons: [ButtonRef(label: [TextRun(text: "Open")], action: .url("https://telegram.org"), color: .success, isLink: true)],
alignment: .center
)
guard case let .rich(page) = composeRichMessage(from: Document(blocks: [.buttonRow(row)])),
case let .buttonRow(alignment, buttons) = page.blocks.first else {
return XCTFail("expected one buttonRow block")
}
XCTAssertEqual(alignment, .center)
XCTAssertEqual(buttons[0].action, .url("https://telegram.org"))
XCTAssertEqual(buttons[0].color, .success)
XCTAssertTrue(buttons[0].isLink)
}
/// A preserved non-URL action must reach the wire intact.
func testPreservedActionReachesThePage() {
let action = ReplyMarkupButtonAction.callback(requiresPassword: false, data: MemoryBuffer(data: Data([4, 2])))
let row = ButtonRowBlock(id: BlockID.generate(),
buttons: [ButtonRef(label: [TextRun(text: "Cb")], action: buttonAction(from: action))],
alignment: .justify)
guard case let .rich(page) = composeRichMessage(from: Document(blocks: [.buttonRow(row)])),
case let .buttonRow(_, buttons) = page.blocks.first else {
return XCTFail("expected one buttonRow block")
}
XCTAssertEqual(buttons[0].action, action)
}
/// Plain content must still take the entity path — the gate must not over-trigger.
func testPlainContentStillTakesTheEntityPath() {
guard case .plain = composeRichMessage(from: Document(blocks: [paragraph([TextRun(text: "just text")])])) else {
return XCTFail("plain text must not be promoted to the rich path")
}
}
}