mirror of
https://github.com/ichmagmaus111/ghostgram.git
synced 2026-11-03 22:27:13 +01:00
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
264 lines
16 KiB
Swift
264 lines
16 KiB
Swift
#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
|