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

264 lines
16 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#if canImport(UIKit)
import XCTest
import UIKit
@testable import RichTextEditorUIKit
import RichTextEditorCore
final class MediaBlockBoxTests: XCTestCase {
private func solid(_ size: CGSize) -> UIImage {
UIGraphicsImageRenderer(size: size).image { ctx in
UIColor.systemBlue.setFill(); ctx.fill(CGRect(origin: .zero, size: size))
}
}
private func mixedCanvas() -> DocumentCanvasView {
let v = DocumentCanvasView()
v.imageProvider = { _ in self.solid(CGSize(width: 100, height: 60)) }
v.setBlocks([
.paragraph(ParagraphBlock(id: BlockID("a"), runs: [TextRun(text: "Above")])),
.media(MediaBlock(id: BlockID("img"), mediaID: "x", naturalSize: Size2D(width: 100, height: 60),
caption: [TextRun(text: "Cap")])),
.paragraph(ParagraphBlock(id: BlockID("b"), runs: [TextRun(text: "Below")])),
], width: 300)
v.frame = CGRect(x: 0, y: 0, width: 300, height: 400); v.layoutIfNeeded()
return v
}
func test_imageBox_tokenMath() {
let block = MediaBlock(id: BlockID("img"), mediaID: "x", naturalSize: Size2D(width: 100, height: 60),
caption: [TextRun(text: "Cap")])
let box: CanvasBlock = MediaBlockBox(media: block, mapper: AttributedStringMapper(), width: 300)
box.nodeStart = 5
XCTAssertEqual(box.textLength, 3) // "Cap"
XCTAssertEqual(box.nodeSize, 8) // captionLen + 5
XCTAssertEqual(box.textStart, 7) // nodeStart + 2
XCTAssertEqual(box.textRef, .caption(BlockID("img")))
guard case .media(let out) = box.currentBlock() else { return XCTFail("expected media") }
XCTAssertEqual(out.caption.map(\.text).joined(), "Cap")
}
func test_mixedDoc_spansMatchCorePositionModel() {
let v = mixedCanvas()
let doc = Document(blocks: v.currentBlocks())
let tree = DocumentTree.build(from: doc)
XCTAssertEqual(v.documentSizeValue, DocumentTree.documentSize(doc))
// caption text start matches the Core global position of the caption text node
let imgBox = v.boxes[1]
let coreCaptionStart = PositionResolver.globalPosition(of: .caption(BlockID("img")), offset: 0, in: tree)
XCTAssertEqual(imgBox.textStart, coreCaptionStart)
}
func test_currentBlocks_roundTripsMixedDocument() {
let v = mixedCanvas()
let blocks = v.currentBlocks()
XCTAssertEqual(blocks.count, 3)
guard case .paragraph = blocks[0], case .media = blocks[1], case .paragraph = blocks[2]
else { return XCTFail("expected paragraph/media/paragraph") }
}
func test_imageRendersNonBlank() {
let v = mixedCanvas()
let image = UIGraphicsImageRenderer(bounds: v.bounds).image { _ in
v.drawHierarchy(in: v.bounds, afterScreenUpdates: true)
}
XCTAssertNotNil(image.cgImage)
}
func test_caption_isCentered() {
let block = MediaBlock(id: BlockID("img"), mediaID: "x", naturalSize: Size2D(width: 100, height: 60),
caption: [TextRun(text: "Cap")])
let box = MediaBlockBox(media: block, mapper: AttributedStringMapper(), width: 300)
let ps = box.caption.attributedString.attribute(.paragraphStyle, at: 0, effectiveRange: nil) as? NSParagraphStyle
XCTAssertEqual(ps?.alignment, .center)
}
func test_emptyCaption_reservesOneLine() {
let mapper = AttributedStringMapper()
let empty = MediaBlock(id: BlockID("img"), mediaID: "x",
naturalSize: Size2D(width: 100, height: 60), caption: [])
let box = MediaBlockBox(media: empty, mapper: mapper, width: 300)
// height minus the fixed image+inset+gap chrome must be ~one body line — the empty caption row
// does NOT collapse to 0.
let chrome = box.verticalInset * 2 + box.imageAreaHeight + box.captionGap
let reserved = box.height - chrome
// "One line" is V2's single-line item height for the CAPTION style (the caption renders at
// `.caption`, not `.body`) — ascender + |descender|, not a scaled font.lineHeight.
let captionLine = RichTextRenderMetrics.textHeight(
mapper.styleSheet.font(for: .caption, attributes: .plain),
factor: mapper.styleSheet.metrics.caption.lineSpacingFactor, lineCount: 1)
XCTAssertEqual(reserved, captionLine, accuracy: 0.5, "empty caption reserves exactly one V2 line")
}
func test_emptyCaption_showsAddCaptionPlaceholder() {
let mapper = AttributedStringMapper()
let empty = MediaBlock(id: BlockID("img"), mediaID: "x",
naturalSize: Size2D(width: 100, height: 60), caption: [])
let box = MediaBlockBox(media: empty, mapper: mapper, width: 300)
let ph = box.captionPlaceholder()
XCTAssertEqual(ph?.text, "Add caption")
XCTAssertEqual(ph?.rect.width ?? 0, 300, accuracy: 0.5) // spans the caption width (so it centers)
// The rect must cover the FULL reserved line (font.lineHeight × caption lineHeightMultiple), matching
// captionEmptyLineHeight — not the bare font.lineHeight — so the placeholder aligns with real text.
let ps = mapper.styleSheet.paragraphStyle(for: .caption, attributes: ParagraphAttributes(alignment: .center), list: nil)
let mult = ps.lineHeightMultiple > 0 ? ps.lineHeightMultiple : 1
let captionLine = mapper.styleSheet.font(for: .caption, attributes: .plain).lineHeight
XCTAssertEqual(ph?.rect.height ?? 0, captionLine * mult, accuracy: 0.5)
let filled = MediaBlock(id: BlockID("img"), mediaID: "x",
naturalSize: Size2D(width: 100, height: 60), caption: [TextRun(text: "Cap")])
let box2 = MediaBlockBox(media: filled, mapper: mapper, width: 300)
XCTAssertNil(box2.captionPlaceholder()) // no placeholder once text exists
}
func test_emptyCaption_caretAlignsToPlaceholderStart() {
let mapper = AttributedStringMapper()
let empty = MediaBlock(id: BlockID("img"), mediaID: "x",
naturalSize: Size2D(width: 100, height: 60), caption: [])
let box = MediaBlockBox(media: empty, mapper: mapper, width: 300)
// The caption's empty-line caret x offset (added by caretRect(for:)/updateCaretView) places the caret
// at the START (left edge) of the centered "Add caption" placeholder, not the line center: the
// placeholder is centered in a 300pt-wide rect, so its left edge is (300 - placeholderWidth)/2.
let font = mapper.styleSheet.font(for: .caption, attributes: .plain)
let phWidth = ("Add caption" as NSString).size(withAttributes: [.font: font]).width
let expected = (300 - phWidth) / 2
XCTAssertEqual(box.leafRegions()[0].emptyLineLeadingIndent, expected, accuracy: 0.5)
// Sanity: that offset is well left of the line center (150) — i.e. the caret no longer bisects "Add caption".
XCTAssertLessThan(box.leafRegions()[0].emptyLineLeadingIndent, 150 - 1.0)
let filled = MediaBlock(id: BlockID("img"), mediaID: "x",
naturalSize: Size2D(width: 100, height: 60), caption: [TextRun(text: "Cap")])
let box2 = MediaBlockBox(media: filled, mapper: mapper, width: 300)
XCTAssertEqual(box2.leafRegions()[0].emptyLineLeadingIndent, 0, accuracy: 0.5) // text exists → 0
}
func test_emptyCaption_caretRectSpansTheLineHeight() {
let mapper = AttributedStringMapper()
let v = DocumentCanvasView()
v.imageProvider = { _ in self.solid(CGSize(width: 100, height: 60)) }
v.setBlocks([.media(MediaBlock(id: BlockID("img"), mediaID: "x",
naturalSize: Size2D(width: 100, height: 60), caption: []))], width: 300)
v.frame = CGRect(x: 0, y: 0, width: 300, height: 400); v.layoutIfNeeded()
let imgBox = v.boxes[0] as! MediaBlockBox
let caret = v.caretRect(for: DocumentTextPosition(imgBox.textStart))
// The empty-caption caret spans the real line height (font.lineHeight × caption lineHeightMultiple),
// not the fixed 20pt fallback — so it matches a typed line and the centered placeholder on it.
let font = mapper.styleSheet.font(for: .caption, attributes: .plain)
let ps = mapper.styleSheet.paragraphStyle(for: .caption, attributes: ParagraphAttributes(alignment: .center), list: nil)
let mult = ps.lineHeightMultiple > 0 ? ps.lineHeightMultiple : 1
// Tight accuracy: the 15pt caption line (~19.7) sits just under the 20pt fallback, so a loose
// tolerance couldn't tell the real metric from the fallback.
XCTAssertEqual(caret.height, font.lineHeight * mult, accuracy: 0.1)
XCTAssertNotEqual(caret.height, 20.0, "the real caption line height, not the fixed-20pt empty-caret fallback")
}
func test_imageRect_respectsExplicitDisplayWidth() {
let block = MediaBlock(id: BlockID("img"), mediaID: "x", naturalSize: Size2D(width: 100, height: 50),
displayWidth: 60, caption: [])
let v = DocumentCanvasView()
v.setBlocks([.media(block)], width: 390)
v.frame = CGRect(x: 0, y: 0, width: 390, height: 400); v.layoutIfNeeded()
let box = v.boxes[0] as! MediaBlockBox
XCTAssertEqual(box.mediaRect().width, 60, accuracy: 0.5) // explicit width honored, not stretched full-bleed
}
func testAudioBlockIsFixedHeightAcrossWidths() {
// Build a .audio MediaBlockBox the same way the existing image tests build theirs.
let block = MediaBlock(id: BlockID("aud"), mediaID: "y", kind: .audio,
naturalSize: Size2D(width: 1, height: 1), caption: [])
let box = MediaBlockBox(media: block, mapper: AttributedStringMapper(), width: 300)
let hNarrow = box.measuredHeight(forWidth: 200)
let hWide = box.measuredHeight(forWidth: 600)
// The audio ROW area is fixed; only the (empty) caption row is shared. Heights must match across widths.
XCTAssertEqual(hNarrow, hWide, accuracy: 0.5)
// And the area must equal the fixed row height plus insets+caption (not an aspect-scaled image height).
XCTAssertLessThan(hNarrow, 120)
}
func testAudioMediaRectIsFixedHeightRow() {
// Build an .audio box exactly as testAudioBlockIsFixedHeightAcrossWidths does, then place a
// frame so that mediaRect() has a defined minX/minY (mirrors the image-rect test setup in
// test_imageRect_respectsExplicitDisplayWidth).
let block = MediaBlock(id: BlockID("aud"), mediaID: "y", kind: .audio,
naturalSize: Size2D(width: 1, height: 1), caption: [])
let box = MediaBlockBox(media: block, mapper: AttributedStringMapper(), width: 320)
// Assign frame + canvasWidth-matching layoutWidth so mediaRect() has stable geometry.
box.frame = CGRect(x: CanvasMetrics.pageMargin, y: 0, width: 320, height: 80)
let rect = box.mediaRect()
XCTAssertEqual(rect.height, MediaBlockBox.audioRowHeight, accuracy: 0.5) // 44pt, NOT a giant square
XCTAssertEqual(rect.width, 320 + CanvasMetrics.pageMargin * 2, accuracy: 0.5) // full canvas width (avail)
}
func testAudioBoxIsCaptionLess() {
let box = MediaBlockBox(media: MediaBlock(id: BlockID("a"), mediaID: "doc:1", kind: .audio,
naturalSize: Size2D(width: 1, height: 1), displayWidth: nil,
alignment: .center, caption: []),
mapper: AttributedStringMapper(), width: 300)
box.nodeStart = 0
XCTAssertEqual(box.textLength, 0)
XCTAssertEqual(box.nodeSize, 3)
XCTAssertEqual(box.textStart, box.nodeStart)
XCTAssertTrue(box.leafRegions().isEmpty)
XCTAssertEqual(box.height, 60, accuracy: 0.5) // verticalInset(8) + audioRowHeight(44) + verticalInset(8)
}
func testAudioCurrentBlockHasEmptyCaption() {
// Even if a caption is seeded (e.g. from an incoming message), audio drops it.
let box = MediaBlockBox(media: MediaBlock(id: BlockID("a"), mediaID: "doc:1", kind: .audio,
naturalSize: Size2D(width: 1, height: 1), displayWidth: nil,
alignment: .center, caption: [TextRun(text: "x")]),
mapper: AttributedStringMapper(), width: 300)
guard case .media(let m) = box.currentBlock() else { return XCTFail("expected .media") }
XCTAssertTrue(m.caption.isEmpty)
}
func testCaptionedImageBoxStillHasCaptionRegion() { // regression
let box = MediaBlockBox(media: MediaBlock(id: BlockID("img"), mediaID: "x", naturalSize: Size2D(width: 100, height: 50),
caption: []),
mapper: AttributedStringMapper(), width: 300)
box.nodeStart = 0
XCTAssertEqual(box.textStart, box.nodeStart + 2)
XCTAssertEqual(box.leafRegions().count, 1)
}
func test_mediaBlockBox_twoItems_usesMosaicHeight() {
let mapper = AttributedStringMapper() // match the file's existing test setup
let block = MediaBlock(id: BlockID("b1"), items: [
MediaItem(mediaID: "m1", kind: .image, naturalSize: Size2D(width: 100, height: 100)),
MediaItem(mediaID: "m2", kind: .image, naturalSize: Size2D(width: 100, height: 100)),
], caption: [])
let box = MediaBlockBox(media: block, mapper: mapper, width: 300, horizontalBleed: 0)
// Two square items in a 300-wide mosaic pack side-by-side → area height < single-full-width (300),
// and strictly positive.
XCTAssertGreaterThan(box.imageAreaHeight, 0)
XCTAssertLessThan(box.imageAreaHeight, 300)
}
func test_singleMedia_portrait_heightCappedToWidth() {
let mapper = AttributedStringMapper() // match the file's existing test setup
// A tall portrait: 100 wide x 400 tall. At width 300, uncapped height would be 1200.
let block = MediaBlock(id: BlockID("b1"), mediaID: "m1", kind: .image,
naturalSize: Size2D(width: 100, height: 400))
let box = MediaBlockBox(media: block, mapper: mapper, width: 300, horizontalBleed: 0)
// cap = min(1000, canvasWidth=300) = 300 → the slot is at most a 300x300 square.
XCTAssertEqual(box.imageAreaHeight, 300, accuracy: 0.5)
}
func test_singleMedia_landscape_uncapped() {
let mapper = AttributedStringMapper()
// Landscape 400x100 at width 300 → height 75, under the 300 cap.
let block = MediaBlock(id: BlockID("b1"), mediaID: "m1", kind: .image,
naturalSize: Size2D(width: 400, height: 100))
let box = MediaBlockBox(media: block, mapper: mapper, width: 300, horizontalBleed: 0)
XCTAssertEqual(box.imageAreaHeight, 75, accuracy: 0.5)
}
func test_mosaic_totalHeightCappedToWidth() {
let mapper = AttributedStringMapper()
// Several tall items → the packed mosaic exceeds a square; total height must be <= cap.
let items = (0..<5).map { MediaItem(mediaID: "m\($0)", kind: .image, naturalSize: Size2D(width: 100, height: 300)) }
let box = MediaBlockBox(media: MediaBlock(id: BlockID("b1"), items: items), mapper: mapper, width: 300, horizontalBleed: 0)
XCTAssertLessThanOrEqual(box.imageAreaHeight, 300.0 + 0.5) // cap = min(1000, 300)
XCTAssertGreaterThan(box.imageAreaHeight, 0)
}
}
#endif