mirror of
https://github.com/ichmagmaus111/ghostgram.git
synced 2026-11-03 14:17:19 +01:00
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
772 lines
44 KiB
Swift
772 lines
44 KiB
Swift
import XCTest
|
|
import TelegramCore
|
|
import Postbox
|
|
@testable import TextFormat
|
|
|
|
final class ChatInputContentModelTests: XCTestCase {
|
|
func test_inlineEntity_equatable_byValue() {
|
|
XCTAssertEqual(ChatInputInlineEntity.mention(EnginePeer.Id(7)), .mention(EnginePeer.Id(7)))
|
|
XCTAssertNotEqual(ChatInputInlineEntity.mention(EnginePeer.Id(7)), .mention(EnginePeer.Id(8)))
|
|
XCTAssertEqual(ChatInputInlineEntity.url("a"), .url("a"))
|
|
XCTAssertEqual(ChatInputInlineEntity.date(5), .date(5))
|
|
XCTAssertNotEqual(ChatInputInlineEntity.url("a"), .date(5))
|
|
}
|
|
|
|
func test_run_equatable() {
|
|
var a = ChatInputInlineAttributes(); a.bold = true
|
|
XCTAssertEqual(ChatInputRun(text: "x", attributes: a), ChatInputRun(text: "x", attributes: a))
|
|
var b = ChatInputInlineAttributes(); b.italic = true
|
|
XCTAssertNotEqual(ChatInputRun(text: "x", attributes: a), ChatInputRun(text: "x", attributes: b))
|
|
}
|
|
|
|
func test_content_equatable_andText() {
|
|
let p = ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "ab")])
|
|
let code = ChatInputCode(language: "swift", runs: [ChatInputRun(text: "x\ny")])
|
|
let c1 = ChatInputContent(blocks: [.paragraph(p), .code(code)])
|
|
XCTAssertEqual(c1, ChatInputContent(blocks: [.paragraph(p), .code(code)]))
|
|
XCTAssertNotEqual(c1, ChatInputContent(blocks: [.paragraph(p)]))
|
|
}
|
|
|
|
// MARK: - Structural selection + content-aware bridge (Piece 5 stage 1)
|
|
|
|
/// Fixture: body "ab" / code "x\ny" / blockQuote(collapsed: true) — plainText "ab\nx\ny\n " (length 8).
|
|
private func bridgeFixture() -> ChatInputContent {
|
|
ChatInputContent(blocks: [
|
|
.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "ab")])),
|
|
.code(ChatInputCode(language: "swift", runs: [ChatInputRun(text: "x\ny")])),
|
|
.blockQuote(ChatInputBlockQuote(
|
|
content: ChatInputContent(blocks: [.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "z")]))]),
|
|
collapsed: true)),
|
|
])
|
|
}
|
|
|
|
/// The bridge must be a bijection over flat offsets: `init(nsRange:in:)` then `nsRange(in:)` = identity for
|
|
/// every range (including collapsed carets and ranges straddling inter-block "\n").
|
|
func test_selection_bridge_roundTripsEveryFlatNSRange() {
|
|
let content = bridgeFixture()
|
|
let total = (content.plainText as NSString).length
|
|
XCTAssertEqual(total, 8)
|
|
for loc in 0 ... total {
|
|
for len in 0 ... (total - loc) {
|
|
let r = NSRange(location: loc, length: len)
|
|
XCTAssertEqual(ChatInputSelection(nsRange: r, in: content).nsRange(in: content), r, "round-trip failed for \(r)")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Depth-1 positions + the inter-block boundary convention (`end of block i` and `start of block i+1` are
|
|
/// distinct consecutive offsets straddling the separator "\n").
|
|
func test_position_depth1_blockBoundaries() {
|
|
let content = bridgeFixture() // "ab"=[0,2] \n=2 "x\ny"=[3,6] \n=6 " "=[7,8]
|
|
XCTAssertEqual(content.position(forFlatOffset: 2), ChatInputPosition(path: [ChatInputPathStep(blockIndex: 0)], offset: 2)) // end of block 0
|
|
XCTAssertEqual(content.position(forFlatOffset: 3), ChatInputPosition(path: [ChatInputPathStep(blockIndex: 1)], offset: 0)) // start of block 1
|
|
XCTAssertEqual(content.position(forFlatOffset: 7), ChatInputPosition(path: [ChatInputPathStep(blockIndex: 2)], offset: 0)) // before collapsed placeholder
|
|
XCTAssertEqual(content.position(forFlatOffset: 8), ChatInputPosition(path: [ChatInputPathStep(blockIndex: 2)], offset: 1)) // after it
|
|
// flatOffset is the inverse
|
|
XCTAssertEqual(content.flatOffset(for: ChatInputPosition(path: [ChatInputPathStep(blockIndex: 1)], offset: 0)), 3)
|
|
XCTAssertEqual(content.flatOffset(for: ChatInputPosition(path: [ChatInputPathStep(blockIndex: 2)], offset: 1)), 8)
|
|
}
|
|
|
|
func test_selection_isCollapsed_andEquatable() {
|
|
let content = ChatInputContent(blocks: [.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "abcd")]))])
|
|
XCTAssertTrue(ChatInputSelection(nsRange: NSRange(location: 2, length: 0), in: content).isCollapsed)
|
|
XCTAssertFalse(ChatInputSelection(nsRange: NSRange(location: 1, length: 3), in: content).isCollapsed)
|
|
XCTAssertEqual(
|
|
ChatInputSelection(nsRange: NSRange(location: 1, length: 3), in: content),
|
|
ChatInputSelection(nsRange: NSRange(location: 1, length: 3), in: content)
|
|
)
|
|
}
|
|
|
|
func test_isEmpty_matchesPlainTextIsEmpty() {
|
|
func p(_ s: String) -> ChatInputBlock { .paragraph(ChatInputParagraph(style: .body, runs: s.isEmpty ? [] : [ChatInputRun(text: s)])) }
|
|
let cases: [ChatInputContent] = [
|
|
ChatInputContent(), // no blocks
|
|
ChatInputContent(blocks: [p("")]), // single empty paragraph
|
|
ChatInputContent(blocks: [p("a")]), // single non-empty
|
|
ChatInputContent(blocks: [p(""), p("")]), // two empty paragraphs -> "\n" -> non-empty
|
|
ChatInputContent(blocks: [.blockQuote(ChatInputBlockQuote(content: ChatInputContent(blocks: [p("x")]), collapsed: true))]), // collapsed placeholder -> non-empty
|
|
ChatInputContent(blocks: [.code(ChatInputCode(runs: []))]), // single empty code
|
|
]
|
|
for c in cases {
|
|
XCTAssertEqual(c.isEmpty, c.plainText.isEmpty, "isEmpty must match plainText.isEmpty for \(c.plainText.debugDescription)")
|
|
XCTAssertEqual(c.isEmpty, c.length == 0)
|
|
}
|
|
}
|
|
|
|
func test_position_emptyContent_isSafe() {
|
|
let empty = ChatInputContent()
|
|
XCTAssertEqual(empty.position(forFlatOffset: 0), ChatInputPosition(path: [ChatInputPathStep(blockIndex: 0)], offset: 0))
|
|
XCTAssertEqual(empty.flatOffset(for: ChatInputPosition(path: [ChatInputPathStep(blockIndex: 0)], offset: 0)), 0)
|
|
XCTAssertEqual(ChatInputSelection(nsRange: NSRange(location: 0, length: 0), in: empty).nsRange(in: empty), NSRange(location: 0, length: 0))
|
|
}
|
|
|
|
/// Media/table blocks are OFF the flat coordinate axis — they contribute ZERO characters AND no separator,
|
|
/// exactly as `attributedString(from:)` drops them and the editor's `composerParagraphs()` skips them.
|
|
/// (Regression for the native-composer caret drift: media/table were given a 1-char placeholder bracketed
|
|
/// by separators, so the model's flat space disagreed with the editor's `composerSelectedRange` — the caret
|
|
/// drifted into the non-text block by the count of preceding non-text blocks. `.blockQuote(collapsed: true)`
|
|
/// is the only off-string block that DOES contribute a " " placeholder.)
|
|
func test_flatCoordinates_mediaAndTable_areOffAxis() {
|
|
func p(_ s: String) -> ChatInputBlock { .paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: s)])) }
|
|
let image = TelegramMediaImage(imageId: MediaId(namespace: 0, id: 7), representations: [], immediateThumbnailData: nil, reference: nil, partialReference: nil, flags: [])
|
|
let media = ChatInputMedia(media: image, kind: .image, naturalSize: ChatInputSize(width: 1.0, height: 1.0))
|
|
let table = ChatInputTable(columns: [ChatInputColumnSpec(width: 10.0)], rows: [])
|
|
// [ "ab", media, "xy", table ] — the two non-text blocks sit between/after the paragraphs.
|
|
let content = ChatInputContent(blocks: [p("ab"), .media(media), p("xy"), .table(table)])
|
|
|
|
// The flat text drops media/table entirely: paragraphs joined by ONE "\n", no placeholder, no extra
|
|
// separator — and it equals the `NSAttributedString` projection exactly (the documented invariant).
|
|
XCTAssertEqual(content.plainText, "ab\nxy")
|
|
XCTAssertEqual(content.length, 5)
|
|
XCTAssertEqual(content.plainText, attributedString(from: content).string)
|
|
|
|
// The caret at the end of "xy" is flat offset 5 (NOT 6/7 with phantom media/table placeholders).
|
|
XCTAssertEqual(content.flatOffset(for: ChatInputPosition(path: [ChatInputPathStep(blockIndex: 2)], offset: 2)), 5)
|
|
// The start of "xy" is flat offset 3 — and the inverse maps offset 3 back to block 2 (the paragraph),
|
|
// never the intervening media block (block 1, which has no flat position).
|
|
XCTAssertEqual(content.flatOffset(for: ChatInputPosition(path: [ChatInputPathStep(blockIndex: 2)], offset: 0)), 3)
|
|
XCTAssertEqual(content.position(forFlatOffset: 3), ChatInputPosition(path: [ChatInputPathStep(blockIndex: 2)], offset: 0))
|
|
XCTAssertEqual(content.position(forFlatOffset: 2), ChatInputPosition(path: [ChatInputPathStep(blockIndex: 0)], offset: 2))
|
|
|
|
// Full bijection over every flat NSRange (collapsed carets + spanning ranges).
|
|
let total = (content.plainText as NSString).length
|
|
for loc in 0 ... total {
|
|
for len in 0 ... (total - loc) {
|
|
let r = NSRange(location: loc, length: len)
|
|
XCTAssertEqual(ChatInputSelection(nsRange: r, in: content).nsRange(in: content), r, "round-trip failed for \(r)")
|
|
}
|
|
}
|
|
|
|
// A media-only content has empty flat text but is NOT `isEmpty` (a media block is content).
|
|
let mediaOnly = ChatInputContent(blocks: [.media(media)])
|
|
XCTAssertEqual(mediaOnly.plainText, "")
|
|
XCTAssertTrue(mediaOnly.plainText.isEmpty)
|
|
XCTAssertFalse(mediaOnly.isEmpty)
|
|
}
|
|
|
|
// MARK: - Codable round-trip
|
|
|
|
/// The content model round-trips losslessly through JSON for every block/run/entity/style case. Note: the
|
|
/// `customEmoji` `file` is intentionally not persisted (decoded as nil) — but the model's `==` compares only
|
|
/// fileId + enableAnimation, so dropping `file` still yields identity.
|
|
func test_codable_roundTripsModelIdentity() throws {
|
|
var bold = ChatInputInlineAttributes(); bold.bold = true
|
|
var emoji = ChatInputInlineAttributes(); emoji.entity = .customEmoji(fileId: 42, file: nil, enableAnimation: true)
|
|
var mention = ChatInputInlineAttributes(); mention.entity = .mention(EnginePeer.Id(7))
|
|
var formula = ChatInputInlineAttributes(); formula.formula = "e^{I\\pi}=-1"
|
|
|
|
let bodyParagraph = ChatInputParagraph(style: .body, runs: [
|
|
ChatInputRun(text: "plain"),
|
|
ChatInputRun(text: "strong", attributes: bold),
|
|
ChatInputRun(text: "\u{FFFC}", attributes: emoji),
|
|
ChatInputRun(text: "@me", attributes: mention),
|
|
ChatInputRun(text: "e^{I\\pi}=-1", attributes: formula),
|
|
])
|
|
let heading1 = ChatInputParagraph(style: .heading1, runs: [ChatInputRun(text: "first line")])
|
|
let heading2 = ChatInputParagraph(style: .heading2, runs: [ChatInputRun(text: "second line")])
|
|
let code = ChatInputCode(language: "swift", runs: [ChatInputRun(text: "let x = 1\nprint(x)")])
|
|
let folded = ChatInputContent(blocks: [
|
|
.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "hidden")])),
|
|
])
|
|
|
|
let content = ChatInputContent(schemaVersion: 2, blocks: [
|
|
.paragraph(bodyParagraph),
|
|
.paragraph(heading1),
|
|
.paragraph(heading2),
|
|
.code(code),
|
|
.blockQuote(ChatInputBlockQuote(content: folded, collapsed: true)),
|
|
])
|
|
|
|
// Encode via AdaptedPostbox — the REAL persistence path (ChatTextInputState stores the model under "cm"
|
|
// through AdaptedPostboxEncoder), NOT JSON. JSON masks the Postbox coder's lack of `singleValueContainer`
|
|
// (which a `RawRepresentable` enum's synthesized Codable uses) and bare `Int` support, so the enum
|
|
// discriminators must persist as `Int32` rawValues — this test guards exactly that.
|
|
let data = try AdaptedPostboxEncoder().encode(content)
|
|
let decoded = try AdaptedPostboxDecoder().decode(ChatInputContent.self, from: data)
|
|
XCTAssertEqual(decoded, content)
|
|
}
|
|
|
|
// MARK: - Structural (Document-parity) Codable round-trip
|
|
|
|
/// The full Document-parity block set — a heading, two list paragraphs (bullet then ordered), a table (2
|
|
/// columns, a header row + a body row, inline-text cells, one cell with a background), and a
|
|
/// media block (a real `TelegramMediaImage` carried as a Postbox object blob) — round-trips losslessly through
|
|
/// the REAL persistence path (`AdaptedPostbox*coder`), NOT JSON. This guards the new raw enums' keyed-rawValue
|
|
/// Codable (no `singleValueContainer`) and the `Media`-blob encode/decode.
|
|
func test_codable_structuralBlocks_roundTripsViaAdaptedPostbox() throws {
|
|
let heading = ChatInputParagraph(style: .heading1, runs: [ChatInputRun(text: "Title")])
|
|
let bullet = ChatInputParagraph(
|
|
style: .body,
|
|
list: ChatInputListMembership(marker: .bullet, level: 0),
|
|
runs: [ChatInputRun(text: "first item")]
|
|
)
|
|
let ordered = ChatInputParagraph(
|
|
style: .body,
|
|
list: ChatInputListMembership(marker: .ordered, level: 1),
|
|
runs: [ChatInputRun(text: "second item")]
|
|
)
|
|
|
|
let table = ChatInputTable(
|
|
columns: [
|
|
ChatInputColumnSpec(width: 100.0),
|
|
ChatInputColumnSpec(width: 200.0),
|
|
],
|
|
rows: [
|
|
ChatInputTableRow(height: 30.0, isHeader: true, cells: [
|
|
ChatInputTableCell(runs: [ChatInputRun(text: "H1")]),
|
|
ChatInputTableCell(runs: [ChatInputRun(text: "H2")], background: ChatInputColor(red: 0.1, green: 0.2, blue: 0.3, alpha: 1.0)),
|
|
]),
|
|
ChatInputTableRow(height: nil, isHeader: false, cells: [
|
|
ChatInputTableCell(runs: [ChatInputRun(text: "a")]),
|
|
ChatInputTableCell(runs: [ChatInputRun(text: "b")]),
|
|
]),
|
|
]
|
|
)
|
|
|
|
let image = TelegramMediaImage(
|
|
imageId: MediaId(namespace: 0, id: 42),
|
|
representations: [],
|
|
immediateThumbnailData: nil,
|
|
reference: nil,
|
|
partialReference: nil,
|
|
flags: []
|
|
)
|
|
let media = ChatInputMedia(
|
|
media: image,
|
|
kind: .image,
|
|
naturalSize: ChatInputSize(width: 640.0, height: 480.0),
|
|
displayWidth: 320.0,
|
|
alignment: .center,
|
|
caption: [ChatInputRun(text: "caption")]
|
|
)
|
|
|
|
let content = ChatInputContent(schemaVersion: 3, blocks: [
|
|
.paragraph(heading),
|
|
.paragraph(bullet),
|
|
.paragraph(ordered),
|
|
.table(table),
|
|
.media(media),
|
|
])
|
|
|
|
let data = try AdaptedPostboxEncoder().encode(content)
|
|
let decoded = try AdaptedPostboxDecoder().decode(ChatInputContent.self, from: data)
|
|
XCTAssertEqual(decoded, content)
|
|
}
|
|
|
|
/// Belt-and-suspenders: a table with NON-DEFAULT colspan/rowspan round-trips through the REAL
|
|
/// persistence path (`AdaptedPostbox*coder`). The structural test above only exercises the default
|
|
/// span (1); this pins that the span wire encoding (`Int` bridged as `Int32` — Postbox has no bare-`Int`
|
|
/// container, which `assertionFailure`s in debug) survives a non-1 value too.
|
|
func test_codable_tableWithSpans_roundTripsViaAdaptedPostbox() throws {
|
|
var merged = ChatInputTableCell(runs: [ChatInputRun(text: "M")])
|
|
merged.colspan = 2
|
|
merged.rowspan = 2
|
|
let table = ChatInputTable(
|
|
columns: [ChatInputColumnSpec(width: 100.0), ChatInputColumnSpec(width: 100.0)],
|
|
rows: [
|
|
// Row 0: one 2x2 anchor (covers both columns, both rows) — the other three slots are absent.
|
|
ChatInputTableRow(height: nil, cells: [merged]),
|
|
// Row 1: fully covered by the rowspan — no declared cells.
|
|
ChatInputTableRow(height: nil, cells: []),
|
|
]
|
|
)
|
|
let content = ChatInputContent(schemaVersion: 3, blocks: [.table(table)])
|
|
let data = try AdaptedPostboxEncoder().encode(content)
|
|
let decoded = try AdaptedPostboxDecoder().decode(ChatInputContent.self, from: data)
|
|
XCTAssertEqual(decoded, content)
|
|
// Explicitly confirm the spans survived (not just structural equality).
|
|
guard case .table(let out)? = decoded.blocks.first, let cell = out.rows.first?.cells.first else {
|
|
return XCTFail("expected a table with a spanning cell")
|
|
}
|
|
XCTAssertEqual(cell.colspan, 2)
|
|
XCTAssertEqual(cell.rowspan, 2)
|
|
}
|
|
|
|
func test_codable_compactTable_roundTripsViaAdaptedPostbox() throws {
|
|
let table = ChatInputTable(
|
|
columns: [ChatInputColumnSpec(width: 100.0)],
|
|
rows: [ChatInputTableRow(height: nil, cells: [ChatInputTableCell(runs: [ChatInputRun(text: "X")])])],
|
|
compact: true
|
|
)
|
|
let content = ChatInputContent(schemaVersion: 3, blocks: [.table(table)])
|
|
let data = try AdaptedPostboxEncoder().encode(content)
|
|
let decoded = try AdaptedPostboxDecoder().decode(ChatInputContent.self, from: data)
|
|
XCTAssertEqual(decoded, content)
|
|
guard case .table(let out)? = decoded.blocks.first else { return XCTFail("expected a table") }
|
|
XCTAssertTrue(out.compact)
|
|
}
|
|
|
|
func test_codable_unborderedTable_roundTripsViaAdaptedPostbox() throws {
|
|
let table = ChatInputTable(
|
|
columns: [ChatInputColumnSpec(width: 100.0)],
|
|
rows: [ChatInputTableRow(height: nil, cells: [ChatInputTableCell(runs: [ChatInputRun(text: "X")])])],
|
|
bordered: false
|
|
)
|
|
let content = ChatInputContent(schemaVersion: 3, blocks: [.table(table)])
|
|
let data = try AdaptedPostboxEncoder().encode(content)
|
|
let decoded = try AdaptedPostboxDecoder().decode(ChatInputContent.self, from: data)
|
|
XCTAssertEqual(decoded, content)
|
|
guard case .table(let out)? = decoded.blocks.first else { return XCTFail("expected a table") }
|
|
XCTAssertFalse(out.bordered)
|
|
}
|
|
|
|
/// A draft written before `bordered` existed must still decode, defaulting to TRUE — such a table was
|
|
/// always drawn with its grid, and the forward converter hard-coded `bordered: true`.
|
|
func test_codable_tableWithoutBorderedKey_decodesAsBordered() throws {
|
|
let json = Data(#"{"rows":[],"columns":[]}"#.utf8)
|
|
let table = try JSONDecoder().decode(ChatInputTable.self, from: json)
|
|
XCTAssertTrue(table.bordered)
|
|
}
|
|
|
|
/// A draft written before `compact` existed must still decode, defaulting to false.
|
|
func test_codable_tableWithoutCompactKey_decodesAsNonCompact() throws {
|
|
let json = Data(#"{"columns":[{"width":100}],"rows":[{"cells":[]}]}"#.utf8)
|
|
let table = try JSONDecoder().decode(ChatInputTable.self, from: json)
|
|
XCTAssertFalse(table.compact)
|
|
}
|
|
|
|
// MARK: - Per-cell H+V alignment (ChatInputTableCell)
|
|
|
|
func testTableCellAlignmentRoundTrips() throws {
|
|
var cell = ChatInputTableCell(runs: [ChatInputRun(text: "X")])
|
|
cell.horizontalAlignment = .right
|
|
cell.verticalAlignment = .bottom
|
|
let data = try JSONEncoder().encode(cell)
|
|
let back = try JSONDecoder().decode(ChatInputTableCell.self, from: data)
|
|
XCTAssertEqual(back.horizontalAlignment, .right)
|
|
XCTAssertEqual(back.verticalAlignment, .bottom)
|
|
}
|
|
|
|
func testLegacyTableCellDecodesToAlignmentDefaults() throws {
|
|
let json = #"{"runs":[]}"#.data(using: .utf8)!
|
|
let cell = try JSONDecoder().decode(ChatInputTableCell.self, from: json)
|
|
XCTAssertEqual(cell.horizontalAlignment, .center)
|
|
XCTAssertEqual(cell.verticalAlignment, .top)
|
|
}
|
|
|
|
/// Heading / list-paragraph / table / media blocks are NOT entity-expressible (they require the structured
|
|
/// `InstantPage` branch); a plain/quote paragraph and a code block ARE.
|
|
func test_isEntityExpressible_structural_false() {
|
|
func single(_ block: ChatInputBlock) -> ChatInputContent { ChatInputContent(blocks: [block]) }
|
|
|
|
let heading = single(.paragraph(ChatInputParagraph(style: .heading2, runs: [ChatInputRun(text: "h")])))
|
|
let list = single(.paragraph(ChatInputParagraph(style: .body, list: ChatInputListMembership(marker: .bullet, level: 0), runs: [ChatInputRun(text: "x")])))
|
|
let table = single(.table(ChatInputTable(columns: [ChatInputColumnSpec(width: 10.0)], rows: [])))
|
|
let image = TelegramMediaImage(imageId: MediaId(namespace: 0, id: 1), representations: [], immediateThumbnailData: nil, reference: nil, partialReference: nil, flags: [])
|
|
let media = single(.media(ChatInputMedia(media: image, kind: .image, naturalSize: ChatInputSize(width: 1.0, height: 1.0))))
|
|
XCTAssertFalse(heading.isEntityExpressible())
|
|
XCTAssertFalse(list.isEntityExpressible())
|
|
XCTAssertFalse(table.isEntityExpressible())
|
|
XCTAssertFalse(media.isEntityExpressible())
|
|
|
|
let plain = single(.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "p")])))
|
|
let bq = single(.blockQuote(ChatInputBlockQuote(
|
|
content: ChatInputContent(blocks: [.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "q")]))]),
|
|
collapsed: false)))
|
|
let code = single(.code(ChatInputCode(language: "swift", runs: [ChatInputRun(text: "c")])))
|
|
XCTAssertTrue(plain.isEntityExpressible())
|
|
XCTAssertTrue(bq.isEntityExpressible()) // flat non-collapsed single-paragraph blockQuote → expressible
|
|
XCTAssertTrue(code.isEntityExpressible())
|
|
}
|
|
|
|
func test_isEntityExpressible_formula_false() {
|
|
var formula = ChatInputInlineAttributes()
|
|
formula.formula = "x^2+y^2=z^2"
|
|
let content = ChatInputContent(blocks: [
|
|
.paragraph(ChatInputParagraph(style: .body, runs: [
|
|
ChatInputRun(text: "x^2+y^2=z^2", attributes: formula)
|
|
]))
|
|
])
|
|
|
|
XCTAssertFalse(content.isEntityExpressible())
|
|
XCTAssertFalse(content.isEntityExpressible(options: [.quotesRequireRichContent]))
|
|
}
|
|
|
|
/// With `.quotesRequireRichContent`, a non-collapsed flat single-paragraph `.blockQuote` is no longer
|
|
/// entity-expressible — so quote-bearing content routes onto the rich (InstantPage) path — while a plain /
|
|
/// code-only content stays entity-expressible. A collapsed `.blockQuote` is never entity-expressible.
|
|
func test_isEntityExpressible_quotesRequireRichContent() {
|
|
func single(_ block: ChatInputBlock) -> ChatInputContent { ChatInputContent(blocks: [block]) }
|
|
|
|
let plain = single(.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "p")])))
|
|
let bq = single(.blockQuote(ChatInputBlockQuote(
|
|
content: ChatInputContent(blocks: [.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "q")]))]),
|
|
collapsed: false)))
|
|
let collapsed = single(.blockQuote(ChatInputBlockQuote(
|
|
content: ChatInputContent(blocks: [.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "z")]))]),
|
|
collapsed: true)))
|
|
|
|
// Default options: a flat non-collapsed single-paragraph blockQuote stays entity-expressible.
|
|
XCTAssertTrue(bq.isEntityExpressible())
|
|
// A collapsed blockQuote is never entity-expressible (regardless of options).
|
|
XCTAssertFalse(collapsed.isEntityExpressible())
|
|
|
|
// With the option: a blockquote requires the rich path; non-quote content is unaffected.
|
|
XCTAssertTrue(plain.isEntityExpressible(options: [.quotesRequireRichContent]))
|
|
XCTAssertFalse(bq.isEntityExpressible(options: [.quotesRequireRichContent]))
|
|
XCTAssertFalse(collapsed.isEntityExpressible(options: [.quotesRequireRichContent]))
|
|
}
|
|
|
|
// MARK: - Entity-expressibility + InstantPage plainText fallback
|
|
|
|
/// Every entity-expressible block type today (paragraph, code, flat non-collapsed single-paragraph blockQuote)
|
|
/// yields a content that takes the `.textEntities` branch (never `.instantPage`).
|
|
func test_isEntityExpressible_allCurrentBlocks_true() {
|
|
let content = ChatInputContent(blocks: [
|
|
.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "ab")])),
|
|
.code(ChatInputCode(language: "swift", runs: [ChatInputRun(text: "x\ny")])),
|
|
.blockQuote(ChatInputBlockQuote(
|
|
content: ChatInputContent(blocks: [.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "z")]))]),
|
|
collapsed: false)),
|
|
])
|
|
XCTAssertTrue(content.isEntityExpressible())
|
|
}
|
|
|
|
// MARK: - Pull-quote block (ChatInputBlock.pullQuote)
|
|
|
|
/// `ChatInputBlock.pullQuote` Codable round-trip through AdaptedPostbox (the REAL persistence path).
|
|
func test_pullQuote_codableRoundTrip() throws {
|
|
let block = ChatInputBlock.pullQuote(ChatInputPullQuote(runs: [ChatInputRun(text: "a\nb")]))
|
|
let data = try AdaptedPostboxEncoder().encode(block)
|
|
XCTAssertEqual(try AdaptedPostboxDecoder().decode(ChatInputBlock.self, from: data), block)
|
|
}
|
|
|
|
/// `.pullQuote` is unconditionally NOT entity-expressible — it always forces the rich (InstantPage) path,
|
|
/// regardless of the `quotesRequireRichContent` option.
|
|
func test_pullQuote_notEntityExpressible() {
|
|
let c = ChatInputContent(blocks: [.pullQuote(ChatInputPullQuote(runs: [ChatInputRun(text: "hi")]))])
|
|
XCTAssertFalse(c.isEntityExpressible())
|
|
XCTAssertFalse(c.isEntityExpressible(options: [.quotesRequireRichContent])) // unconditional
|
|
}
|
|
|
|
/// `.pullQuote` lives on the flat axis: its text contributes to `plainText` and `blockFlatLength`.
|
|
func test_pullQuote_flatAxisContribution() {
|
|
let pq = ChatInputPullQuote(runs: [ChatInputRun(text: "hello")])
|
|
let c = ChatInputContent(blocks: [
|
|
.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "before")])),
|
|
.pullQuote(pq),
|
|
])
|
|
XCTAssertEqual(c.plainText, "before\nhello")
|
|
XCTAssertEqual(c.length, (("before\nhello") as NSString).length)
|
|
}
|
|
|
|
// MARK: - Quote author line (ChatInputBlockQuote/ChatInputPullQuote.author)
|
|
|
|
func test_pullQuote_author_codableRoundTrip_viaAdaptedPostbox() throws {
|
|
let block = ChatInputBlock.pullQuote(ChatInputPullQuote(runs: [ChatInputRun(text: "q")], author: [ChatInputRun(text: "Jobs")]))
|
|
let data = try AdaptedPostboxEncoder().encode(block)
|
|
XCTAssertEqual(try AdaptedPostboxDecoder().decode(ChatInputBlock.self, from: data), block)
|
|
}
|
|
|
|
func test_blockQuote_author_codableRoundTrip_viaAdaptedPostbox() throws {
|
|
let inner = ChatInputContent(blocks: [.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "body")]))])
|
|
let block = ChatInputBlock.blockQuote(ChatInputBlockQuote(content: inner, collapsed: false, author: [ChatInputRun(text: "Ada")]))
|
|
let data = try AdaptedPostboxEncoder().encode(block)
|
|
XCTAssertEqual(try AdaptedPostboxDecoder().decode(ChatInputBlock.self, from: data), block)
|
|
}
|
|
|
|
func test_blockQuote_withAuthor_isNotEntityExpressible_andPlainTextExcludesAuthor() {
|
|
let inner = ChatInputContent(blocks: [.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "body")]))])
|
|
let withAuthor = ChatInputContent(blocks: [.blockQuote(ChatInputBlockQuote(content: inner, collapsed: false, author: [ChatInputRun(text: "Ada")]))])
|
|
XCTAssertFalse(withAuthor.isEntityExpressible()) // author → rich path
|
|
XCTAssertFalse(withAuthor.plainText.contains("Ada")) // author off the flat axis
|
|
XCTAssertTrue(withAuthor.plainText.contains("body"))
|
|
let noAuthor = ChatInputContent(blocks: [.blockQuote(ChatInputBlockQuote(content: inner, collapsed: false, author: []))])
|
|
XCTAssertTrue(noAuthor.isEntityExpressible()) // empty author → unchanged (entity path)
|
|
}
|
|
|
|
/// The old-client text fallback joins paragraph / blockQuote / preformatted text with "\n", skipping empty
|
|
/// pieces (a blockQuote contributes its inner blocks' text, recursively).
|
|
func test_instantPage_plainText() {
|
|
let page = InstantPage(
|
|
blocks: [
|
|
.paragraph(.plain("a")),
|
|
.blockQuote(blocks: [.paragraph(.plain("b"))], caption: .empty, collapsed: nil),
|
|
.paragraph(.plain("c")),
|
|
],
|
|
media: [:],
|
|
isComplete: true,
|
|
rtl: false,
|
|
url: "",
|
|
views: nil
|
|
)
|
|
XCTAssertEqual(page.plainText, "a\nb\nc")
|
|
}
|
|
|
|
func test_isEmptyWhitespaceTrimmed_contentAwareAndWhitespaceTrimmed() {
|
|
func p(_ s: String) -> ChatInputBlock { .paragraph(ChatInputParagraph(style: .body, runs: s.isEmpty ? [] : [ChatInputRun(text: s)])) }
|
|
let image = TelegramMediaImage(imageId: MediaId(namespace: 0, id: 7), representations: [], immediateThumbnailData: nil, reference: nil, partialReference: nil, flags: [])
|
|
let media = ChatInputMedia(media: image, kind: .image, naturalSize: ChatInputSize(width: 1.0, height: 1.0))
|
|
let table = ChatInputTable(columns: [ChatInputColumnSpec(width: 10.0)], rows: [])
|
|
|
|
// Whitespace-only text is EMPTY here — including several blank paragraphs (unlike raw isEmpty).
|
|
XCTAssertTrue(ChatInputContent().isEmptyWhitespaceTrimmed) // no blocks
|
|
XCTAssertTrue(ChatInputContent(blocks: [p(" ")]).isEmptyWhitespaceTrimmed) // spaces only
|
|
XCTAssertTrue(ChatInputContent(blocks: [p("\n\t ")]).isEmptyWhitespaceTrimmed) // mixed whitespace
|
|
XCTAssertTrue(ChatInputContent(blocks: [p(""), p(""), p("")]).isEmptyWhitespaceTrimmed) // several blank paragraphs
|
|
XCTAssertTrue(ChatInputContent(blocks: [.code(ChatInputCode(runs: []))]).isEmptyWhitespaceTrimmed) // empty code
|
|
XCTAssertTrue(ChatInputContent(blocks: [.pullQuote(ChatInputPullQuote(runs: [ChatInputRun(text: " ")]))]).isEmptyWhitespaceTrimmed) // whitespace pull quote
|
|
|
|
// Non-whitespace text is NOT empty.
|
|
XCTAssertFalse(ChatInputContent(blocks: [p("a")]).isEmptyWhitespaceTrimmed)
|
|
XCTAssertFalse(ChatInputContent(blocks: [p(" x ")]).isEmptyWhitespaceTrimmed)
|
|
XCTAssertFalse(ChatInputContent(blocks: [p(" "), p("x")]).isEmptyWhitespaceTrimmed)
|
|
|
|
// Structural blocks are ALWAYS content — the media / empty-table fix.
|
|
XCTAssertFalse(ChatInputContent(blocks: [.media(media)]).isEmptyWhitespaceTrimmed)
|
|
XCTAssertFalse(ChatInputContent(blocks: [.table(table)]).isEmptyWhitespaceTrimmed) // empty table
|
|
XCTAssertFalse(ChatInputContent(blocks: [p(" "), .table(table)]).isEmptyWhitespaceTrimmed) // whitespace + table
|
|
XCTAssertFalse(ChatInputContent(blocks: [.blockQuote(ChatInputBlockQuote(content: ChatInputContent(blocks: [p("")]), collapsed: true))]).isEmptyWhitespaceTrimmed)
|
|
|
|
// Contrast with raw isEmpty: several blank paragraphs are NON-empty raw, but whitespace-empty here.
|
|
XCTAssertFalse(ChatInputContent(blocks: [p(""), p("")]).isEmpty)
|
|
XCTAssertTrue(ChatInputContent(blocks: [p(""), p("")]).isEmptyWhitespaceTrimmed)
|
|
}
|
|
|
|
// MARK: - ChatInputMedia container (items array)
|
|
|
|
func test_chatInputMedia_container_codableViaAdaptedPostbox() throws {
|
|
let img = TelegramMediaImage(imageId: MediaId(namespace: 0, id: 1), representations: [], immediateThumbnailData: nil, reference: nil, partialReference: nil, flags: [])
|
|
let file = TelegramMediaFile(fileId: MediaId(namespace: 0, id: 2), partialReference: nil, resource: EmptyMediaResource(), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "video/mp4", size: nil, attributes: [], alternativeRepresentations: [])
|
|
let media = ChatInputMedia(items: [
|
|
ChatInputMediaItem(media: img, kind: .image, naturalSize: ChatInputSize(width: 100, height: 50)),
|
|
ChatInputMediaItem(media: file, kind: .video, naturalSize: ChatInputSize(width: 60, height: 90)),
|
|
], displayWidth: nil, alignment: .center, caption: [])
|
|
let data = try AdaptedPostboxEncoder().encode(media)
|
|
let decoded = try AdaptedPostboxDecoder().decode(ChatInputMedia.self, from: data)
|
|
XCTAssertEqual(decoded, media)
|
|
}
|
|
|
|
/// `displayMode` is a `String`-raw enum: its synthesized `RawRepresentable` Codable uses a
|
|
/// `singleValueContainer`, which `AdaptedPostboxEncoder` does not support (it crashed on encode). Guards the
|
|
/// custom keyed Codable by round-tripping a NON-default `.slideshow` value through AdaptedPostbox.
|
|
func test_chatInputMedia_displayMode_codableViaAdaptedPostbox() throws {
|
|
let img = TelegramMediaImage(imageId: MediaId(namespace: 0, id: 1), representations: [], immediateThumbnailData: nil, reference: nil, partialReference: nil, flags: [])
|
|
for mode in [ChatInputMediaDisplayMode.mosaic, .slideshow] {
|
|
let media = ChatInputMedia(items: [
|
|
ChatInputMediaItem(media: img, kind: .image, naturalSize: ChatInputSize(width: 100, height: 50)),
|
|
], displayMode: mode)
|
|
let data = try AdaptedPostboxEncoder().encode(media)
|
|
let decoded = try AdaptedPostboxDecoder().decode(ChatInputMedia.self, from: data)
|
|
XCTAssertEqual(decoded.displayMode, mode)
|
|
XCTAssertEqual(decoded, media)
|
|
}
|
|
}
|
|
|
|
func test_chatInputMedia_convenienceInit_isOneItem() {
|
|
let img = TelegramMediaImage(imageId: MediaId(namespace: 0, id: 1), representations: [], immediateThumbnailData: nil, reference: nil, partialReference: nil, flags: [])
|
|
let media = ChatInputMedia(media: img, kind: .image, naturalSize: ChatInputSize(width: 1, height: 1))
|
|
XCTAssertEqual(media.items.count, 1)
|
|
XCTAssertTrue(media.media.isEqual(to: img)) // accessor -> first item
|
|
}
|
|
|
|
/// Emits the pre-container `ChatInputMedia` wire shape (top-level `media`/`mediaType`/`kind`/`naturalSize`/
|
|
/// `alignment`/`caption`, deliberately NO `items` key) so the test can exercise `ChatInputMedia.init(from:)`'s
|
|
/// `else` branch (see ChatInputContentModel.swift), which is unreachable from `ChatInputMedia.encode(to:)`
|
|
/// itself (that always emits `items`).
|
|
private struct LegacyChatInputMediaPayload: Encodable {
|
|
let media: TelegramMediaImage
|
|
let mediaType: Int32
|
|
let kind: ChatInputMediaKind
|
|
let naturalSize: ChatInputSize
|
|
let alignment: ChatInputMediaAlignment
|
|
let caption: [ChatInputRun]
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case media, mediaType, kind, naturalSize, alignment, caption
|
|
}
|
|
|
|
func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encode(PostboxEncoder().encodeObjectToRawData(self.media), forKey: .media)
|
|
try container.encode(self.mediaType, forKey: .mediaType)
|
|
try container.encode(self.kind, forKey: .kind)
|
|
try container.encode(self.naturalSize, forKey: .naturalSize)
|
|
try container.encode(self.alignment, forKey: .alignment)
|
|
try container.encode(self.caption, forKey: .caption)
|
|
}
|
|
}
|
|
|
|
/// Back-compat: a legacy (pre-container) single-media payload — no `items` key — decodes into a
|
|
/// one-element `items` array (the `else` branch of `ChatInputMedia.init(from:)`).
|
|
func test_chatInputMedia_legacySingleMediaPayload_decodesIntoOneItem() throws {
|
|
let img = TelegramMediaImage(imageId: MediaId(namespace: 0, id: 99), representations: [], immediateThumbnailData: nil, reference: nil, partialReference: nil, flags: [])
|
|
let size = ChatInputSize(width: 42, height: 24)
|
|
let legacy = LegacyChatInputMediaPayload(
|
|
media: img,
|
|
mediaType: 0, // MediaType.image, per ChatInputMedia's private MediaType discriminator
|
|
kind: .image,
|
|
naturalSize: size,
|
|
alignment: .center,
|
|
caption: []
|
|
)
|
|
let data = try AdaptedPostboxEncoder().encode(legacy)
|
|
let decoded = try AdaptedPostboxDecoder().decode(ChatInputMedia.self, from: data)
|
|
|
|
XCTAssertEqual(decoded.items.count, 1)
|
|
XCTAssertTrue(decoded.items[0].media.isEqual(to: img))
|
|
XCTAssertEqual(decoded.items[0].kind, .image)
|
|
XCTAssertEqual(decoded.naturalSize, size)
|
|
XCTAssertFalse(decoded.items[0].isSpoiler) // legacy payload has no isSpoiler key -> back-compat false
|
|
}
|
|
|
|
// MARK: - ChatInputMediaItem.isSpoiler
|
|
|
|
func test_chatInputMediaItem_spoiler_roundTripsViaAdaptedPostbox() throws {
|
|
let image = TelegramMediaImage(imageId: MediaId(namespace: 0, id: 42), representations: [], immediateThumbnailData: nil, reference: nil, partialReference: nil, flags: [])
|
|
var item = ChatInputMediaItem(media: image, kind: .image, naturalSize: ChatInputSize(width: 4, height: 3))
|
|
item.isSpoiler = true
|
|
let data = try AdaptedPostboxEncoder().encode(item)
|
|
let decoded = try AdaptedPostboxDecoder().decode(ChatInputMediaItem.self, from: data)
|
|
XCTAssertTrue(decoded.isSpoiler)
|
|
XCTAssertEqual(decoded, item)
|
|
}
|
|
|
|
func test_chatInputMediaItem_defaultsSpoilerFalse() throws {
|
|
let image = TelegramMediaImage(imageId: MediaId(namespace: 0, id: 7), representations: [], immediateThumbnailData: nil, reference: nil, partialReference: nil, flags: [])
|
|
let item = ChatInputMediaItem(media: image, kind: .image, naturalSize: ChatInputSize(width: 1, height: 1))
|
|
let data = try AdaptedPostboxEncoder().encode(item)
|
|
XCTAssertFalse(try AdaptedPostboxDecoder().decode(ChatInputMediaItem.self, from: data).isSpoiler)
|
|
}
|
|
|
|
// MARK: - ChatInputTableCell.isHeader / ChatInputTableRow derived header
|
|
|
|
func test_chatInputTableRow_headerIsDerivedFromCells_andNotEncoded() throws {
|
|
let hdr = ChatInputTableCell(runs: [ChatInputRun(text: "H")], isHeader: true)
|
|
let body = ChatInputTableCell(runs: [ChatInputRun(text: "B")])
|
|
XCTAssertTrue(ChatInputTableRow(cells: [hdr]).isHeader)
|
|
XCTAssertFalse(ChatInputTableRow(cells: [hdr, body]).isHeader)
|
|
let data = try JSONEncoder().encode(ChatInputTableRow(isHeader: true, cells: [ChatInputTableCell(runs: [])]))
|
|
// `ChatInputTableCell`'s synthesized `encode` always emits its own `isHeader` key, so a naive
|
|
// substring check on the whole payload would false-positive. Assert instead that the ROW object
|
|
// itself (the top-level JSON object) has no `isHeader` key — the per-cell keys are expected.
|
|
let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
|
XCTAssertFalse(json.keys.contains("isHeader"))
|
|
XCTAssertTrue(json.keys.contains("cells"))
|
|
}
|
|
|
|
func test_chatInputTableRow_legacyHeader_migratesIntoCells() throws {
|
|
let json = #"{"cells":[{"runs":[]},{"runs":[]}],"isHeader":true}"#.data(using: .utf8)!
|
|
let row = try JSONDecoder().decode(ChatInputTableRow.self, from: json)
|
|
XCTAssertTrue(row.cells.allSatisfy { $0.isHeader })
|
|
XCTAssertTrue(row.isHeader)
|
|
}
|
|
|
|
func test_chatInputTableRow_initHeaderParam_seedsCells() {
|
|
let row = ChatInputTableRow(isHeader: true, cells: [ChatInputTableCell(runs: []), ChatInputTableCell(runs: [])])
|
|
XCTAssertTrue(row.cells.allSatisfy { $0.isHeader })
|
|
}
|
|
|
|
// MARK: - ChatInputTableCell.colspan / rowspan
|
|
|
|
func test_chatInputTableCell_spanDefaultsAndRoundTrips() throws {
|
|
XCTAssertEqual(ChatInputTableCell(runs: []).colspan, 1)
|
|
XCTAssertEqual(ChatInputTableCell(runs: []).rowspan, 1)
|
|
var c = ChatInputTableCell(runs: []); c.colspan = 2; c.rowspan = 3
|
|
let back = try JSONDecoder().decode(ChatInputTableCell.self, from: try JSONEncoder().encode(c))
|
|
XCTAssertEqual(back.colspan, 2); XCTAssertEqual(back.rowspan, 3)
|
|
}
|
|
|
|
func test_chatInputTableCell_legacyWithoutSpan_decodesToOne() throws {
|
|
let json = #"{"runs":[]}"#.data(using: .utf8)!
|
|
let c = try JSONDecoder().decode(ChatInputTableCell.self, from: json)
|
|
XCTAssertEqual(c.colspan, 1); XCTAssertEqual(c.rowspan, 1)
|
|
}
|
|
|
|
// MARK: - InstantPage buttons
|
|
|
|
private func buttonRow(_ alignment: InstantPageButtonRowAlignment = .justify) -> ChatInputButtonRow {
|
|
ChatInputButtonRow(
|
|
buttons: [
|
|
ChatInputButton(label: [ChatInputRun(text: "Open")], action: .url("https://telegram.org"), color: .primary, isLink: false),
|
|
ChatInputButton(label: [ChatInputRun(text: "Pay")], action: .payment, color: .danger, isLink: true),
|
|
],
|
|
alignment: alignment
|
|
)
|
|
}
|
|
|
|
/// LOAD-BEARING: a button has no message-entity form, so button-bearing content must route to the
|
|
/// rich `.instantPage` send path. Returning true here would send the message as plain text +
|
|
/// entities and destroy the buttons on the way out.
|
|
func test_buttonRowBlock_isNotEntityExpressible() {
|
|
XCTAssertFalse(ChatInputContent(blocks: [.buttonRow(buttonRow())]).isEntityExpressible())
|
|
}
|
|
|
|
func test_inlineButtonRun_isNotEntityExpressible() {
|
|
var attributes = ChatInputInlineAttributes()
|
|
attributes.entity = .button(ChatInputButton(label: [ChatInputRun(text: "Go")], action: .url("https://telegram.org")))
|
|
let content = ChatInputContent(blocks: [.paragraph(ChatInputParagraph(style: .body, runs: [
|
|
ChatInputRun(text: "before "),
|
|
ChatInputRun(text: "\u{FFFC}", attributes: attributes),
|
|
]))])
|
|
XCTAssertFalse(content.isEntityExpressible())
|
|
}
|
|
|
|
/// Via the REAL persistence path (`AdaptedPostbox*coder`), not JSON — the action rides as a Postbox
|
|
/// object blob and the colour/alignment as keyed Int32 rawValues, neither of which JSON would exercise.
|
|
func test_codable_buttonRow_roundTripsViaAdaptedPostbox() throws {
|
|
let content = ChatInputContent(blocks: [.buttonRow(buttonRow(.right))])
|
|
let data = try AdaptedPostboxEncoder().encode(content)
|
|
XCTAssertEqual(try AdaptedPostboxDecoder().decode(ChatInputContent.self, from: data), content)
|
|
}
|
|
|
|
func test_codable_inlineButtonRun_roundTripsViaAdaptedPostbox() throws {
|
|
var attributes = ChatInputInlineAttributes(bold: true)
|
|
attributes.entity = .button(ChatInputButton(
|
|
label: [ChatInputRun(text: "Go")],
|
|
action: .callback(requiresPassword: true, data: MemoryBuffer(data: Data([1, 2, 3]))),
|
|
color: .success,
|
|
isLink: true
|
|
))
|
|
let content = ChatInputContent(blocks: [.paragraph(ChatInputParagraph(style: .body, runs: [
|
|
ChatInputRun(text: "\u{FFFC}", attributes: attributes),
|
|
]))])
|
|
let data = try AdaptedPostboxEncoder().encode(content)
|
|
XCTAssertEqual(try AdaptedPostboxDecoder().decode(ChatInputContent.self, from: data), content)
|
|
}
|
|
|
|
/// The discriminator is persisted in drafts. 8 is the next free value: `details` is 7, and 2 is a
|
|
/// deliberate gap left by the retired `collapsedQuote`. Never renumber.
|
|
func test_buttonRow_discriminatorIsEight() throws {
|
|
let object = try JSONSerialization.jsonObject(
|
|
with: try JSONEncoder().encode(ChatInputBlock.buttonRow(ChatInputButtonRow(buttons: [])))
|
|
) as? [String: Any]
|
|
XCTAssertEqual(object?["kind"] as? Int, 8)
|
|
}
|
|
|
|
/// An older build must skip an unknown block rather than failing the whole draft — the lenient
|
|
/// per-element decode. Simulated by decoding a block whose discriminator this build does not know.
|
|
func test_unknownBlockDiscriminator_isSkippedNotFatal() throws {
|
|
let content = ChatInputContent(blocks: [
|
|
.paragraph(ChatInputParagraph(style: .body, runs: [ChatInputRun(text: "keep me")])),
|
|
.buttonRow(buttonRow()),
|
|
])
|
|
let data = try AdaptedPostboxEncoder().encode(content)
|
|
XCTAssertEqual(try AdaptedPostboxDecoder().decode(ChatInputContent.self, from: data).blocks.count, 2)
|
|
}
|
|
|
|
func testAllMediaFindsNestedMedia() {
|
|
let image = TelegramMediaImage(
|
|
imageId: EngineMedia.Id(namespace: Namespaces.Media.LocalImage, id: 1),
|
|
representations: [],
|
|
immediateThumbnailData: nil,
|
|
reference: nil,
|
|
partialReference: nil,
|
|
flags: []
|
|
)
|
|
let mediaBlock = ChatInputBlock.media(ChatInputMedia(
|
|
media: image,
|
|
kind: .image,
|
|
naturalSize: ChatInputSize(width: 10.0, height: 10.0)
|
|
))
|
|
let quoted = ChatInputBlock.blockQuote(ChatInputBlockQuote(
|
|
content: ChatInputContent(blocks: [mediaBlock]),
|
|
collapsed: false
|
|
))
|
|
let content = ChatInputContent(blocks: [quoted])
|
|
|
|
XCTAssertEqual(content.allMedia.count, 1)
|
|
XCTAssertEqual(content.allMedia.first?.id, image.imageId)
|
|
}
|
|
}
|