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>
172 lines
9.7 KiB
Swift
172 lines
9.7 KiB
Swift
#if canImport(UIKit)
|
|
import XCTest
|
|
import UIKit
|
|
@testable import RichTextEditorUIKit
|
|
import RichTextEditorCore
|
|
|
|
final class MapperTests: XCTestCase {
|
|
private let mapper = AttributedStringMapper()
|
|
|
|
private func formulaRenderResult(size: CGSize = CGSize(width: 28.0, height: 12.0)) -> RichTextFormulaRenderResult {
|
|
let image = UIGraphicsImageRenderer(size: size).image { _ in }
|
|
return RichTextFormulaRenderResult(image: image, size: size, ascent: 9.0, descent: 3.0)
|
|
}
|
|
|
|
func test_characterAttributes_roundTripThroughDict() {
|
|
// No link here: a link deliberately suppresses foreground/underline on read-back (covered by
|
|
// AttributedStringMapperLinkTests), so those fields are exercised here on an unlinked run.
|
|
// Use a NON-default foreground (red) so it round-trips as explicit — an explicit color equal to the
|
|
// theme's default would be stripped to nil (that behavior is covered by its own test below).
|
|
let ca = CharacterAttributes(bold: true, italic: true, underline: true, strikethrough: true,
|
|
fontSize: 18, foreground: RGBAColor(red: 1, green: 0, blue: 0),
|
|
highlight: RGBAColor(red: 1, green: 1, blue: 0),
|
|
baselineOffset: 4)
|
|
let dict = mapper.attributes(for: ca, style: .body)
|
|
let back = mapper.characterAttributes(from: dict)
|
|
XCTAssertTrue(back.bold); XCTAssertTrue(back.italic)
|
|
XCTAssertTrue(back.underline); XCTAssertTrue(back.strikethrough)
|
|
XCTAssertEqual(back.foreground, RGBAColor(red: 1, green: 0, blue: 0))
|
|
XCTAssertEqual(back.baselineOffset ?? 0, 4, accuracy: 0.01)
|
|
XCTAssertNotNil(back.highlight)
|
|
}
|
|
|
|
func test_uncoloredRun_staysNilThroughRoundTrip() {
|
|
// A run with no explicit color renders in the theme default but must read back as nil (unset),
|
|
// so re-theming recolors it and serialization stays clean.
|
|
let dict = mapper.attributes(for: CharacterAttributes(), style: .body)
|
|
XCTAssertEqual(dict[.foregroundColor] as? UIColor, RichTextEditorTheme.default.primaryText,
|
|
"un-colored body text renders in the primary text color")
|
|
let back = mapper.characterAttributes(from: dict, style: .body)
|
|
XCTAssertNil(back.foreground, "the injected default must be stripped back to nil")
|
|
}
|
|
|
|
func test_explicitColorEqualToDefault_isStripped() {
|
|
// An explicit foreground that happens to equal the theme default is indistinguishable from the
|
|
// injected default and is stripped to nil (visually identical, and re-themable). Documented behavior.
|
|
let ca = CharacterAttributes(foreground: RichTextEditorTheme.default.primaryText.rgba)
|
|
let dict = mapper.attributes(for: ca, style: .body)
|
|
let back = mapper.characterAttributes(from: dict, style: .body)
|
|
XCTAssertNil(back.foreground)
|
|
}
|
|
|
|
func test_captionUsesSecondaryDefault_andStripsIt() {
|
|
let theme = RichTextEditorTheme(primaryText: .red, secondaryText: .blue, placeholder: .placeholderText,
|
|
accent: .green, tableBorder: .gray, tableHeaderBackground: .gray, codeBackground: .placeholderText)
|
|
let m = AttributedStringMapper(theme: theme)
|
|
// Caption renders in secondary; body renders in primary.
|
|
XCTAssertEqual(m.attributes(for: CharacterAttributes(), style: .caption)[.foregroundColor] as? UIColor, UIColor.blue)
|
|
XCTAssertEqual(m.attributes(for: CharacterAttributes(), style: .body)[.foregroundColor] as? UIColor, UIColor.red)
|
|
// The caption default is stripped only when read back WITH the caption style.
|
|
let captionDict = m.attributes(for: CharacterAttributes(), style: .caption)
|
|
XCTAssertNil(m.characterAttributes(from: captionDict, style: .caption).foreground)
|
|
// Reading the caption dict back with the WRONG style (body) must NOT strip the secondary color —
|
|
// guards against a caller forgetting to pass .caption (e.g. MediaBlockBox captions).
|
|
XCTAssertNotNil(m.characterAttributes(from: captionDict, style: .body).foreground,
|
|
"caption secondary must not be stripped when read back as body style")
|
|
}
|
|
|
|
func test_linkUsesAccentColor() {
|
|
let theme = RichTextEditorTheme(primaryText: .black, secondaryText: .black, placeholder: .placeholderText,
|
|
accent: .green, tableBorder: .gray, tableHeaderBackground: .gray, codeBackground: .placeholderText)
|
|
let m = AttributedStringMapper(theme: theme)
|
|
let dict = m.attributes(for: CharacterAttributes(link: "https://example.com"), style: .body)
|
|
XCTAssertEqual(dict[.foregroundColor] as? UIColor, UIColor.green)
|
|
// Link foreground is render-only and never captured into the model.
|
|
XCTAssertNil(m.characterAttributes(from: dict, style: .body).foreground)
|
|
}
|
|
|
|
func test_paragraphBlock_roundTripsRuns() {
|
|
let block = ParagraphBlock(id: BlockID("p1"), style: .body, runs: [
|
|
TextRun(text: "Plain "),
|
|
TextRun(text: "bold", attributes: CharacterAttributes(bold: true)),
|
|
])
|
|
let attr = mapper.attributedString(for: block)
|
|
XCTAssertEqual(attr.string, "Plain bold")
|
|
let runs = mapper.runs(from: attr)
|
|
XCTAssertEqual(runs.count, 2)
|
|
XCTAssertEqual(runs[0].text, "Plain ")
|
|
XCTAssertFalse(runs[0].attributes.bold)
|
|
XCTAssertEqual(runs[1].text, "bold")
|
|
XCTAssertTrue(runs[1].attributes.bold)
|
|
}
|
|
|
|
func test_serifHeadingFont_doesNotReadBackAsFontFamily() {
|
|
let m = AttributedStringMapper()
|
|
// .heading1 resolves to the system serif (New York); a default CharacterAttributes has no fontFamily.
|
|
let attrs = m.attributes(for: CharacterAttributes(), style: .heading1)
|
|
let ca = m.characterAttributes(from: attrs)
|
|
XCTAssertNil(ca.fontFamily, "serif heading font is style-derived and must not leak into the model as a user fontFamily")
|
|
}
|
|
|
|
func test_substitutedFontFamily_doesNotLeakIntoModel() throws {
|
|
// TextKit's automatic font substitution (e.g. for Arabic/Hebrew/CJK glyphs the style font can't
|
|
// render) rewrites `.font` in the storage to a script-specific family. That is a display artifact,
|
|
// not user intent, and must NOT round-trip into the model — otherwise a rebuild (e.g. the paragraph
|
|
// merge on backspace) re-applies the substituted family and the font visibly changes.
|
|
let substituted = try XCTUnwrap(UIFont(name: "Helvetica Neue", size: 17),
|
|
"test needs a font with a non-default family")
|
|
XCTAssertNotEqual(substituted.familyName, UIFont.systemFont(ofSize: 17).familyName,
|
|
"precondition: the test font has a non-default family")
|
|
let ca = AttributedStringMapper().characterAttributes(from: [.font: substituted], style: .body)
|
|
XCTAssertNil(ca.fontFamily, "a display-substituted font family must not round-trip into the model")
|
|
}
|
|
|
|
func test_inlineCode_rendersMonospace_andRoundTripsClean() {
|
|
let ca = CharacterAttributes(inlineCode: true)
|
|
let dict = mapper.attributes(for: ca, style: .body)
|
|
let font = dict[.font] as? UIFont
|
|
XCTAssertNotNil(font)
|
|
XCTAssertNotEqual(font?.fontName, UIFont.systemFont(ofSize: 16).fontName,
|
|
"inline code must render with a non-system (monospace) font")
|
|
let back = mapper.characterAttributes(from: dict)
|
|
XCTAssertTrue(back.inlineCode)
|
|
XCTAssertNil(back.fontFamily, "the mono font name must not leak into fontFamily")
|
|
XCTAssertNil(back.highlight, "the code background must not be read back as a highlight")
|
|
XCTAssertNil(back.fontSize, "a plain inline-code run must not pin a font size")
|
|
XCTAssertFalse(back.bold, "monospaced .regular carries no bold trait")
|
|
XCTAssertFalse(back.italic, "monospaced .regular carries no italic trait")
|
|
XCTAssertEqual((dict[.font] as? UIFont)?.pointSize, 17, "body inline code renders at the body base size")
|
|
}
|
|
|
|
func test_formulaDisplaysRawLatex_andRoundTripsMetadata() {
|
|
let latex = "x_{1,2}=\\frac{-b\\pm\\sqrt{b^2-4ac}}{2a}"
|
|
let block = ParagraphBlock(id: BlockID("formula"), style: .body, runs: [
|
|
TextRun(text: latex, attributes: CharacterAttributes(formula: latex))
|
|
])
|
|
|
|
let attr = mapper.attributedString(for: block)
|
|
XCTAssertEqual(attr.string, latex)
|
|
|
|
let runs = mapper.runs(from: attr)
|
|
XCTAssertEqual(runs.count, 1)
|
|
XCTAssertEqual(runs[0].text, latex)
|
|
XCTAssertEqual(runs[0].attributes.formula, latex)
|
|
}
|
|
|
|
func test_formulaRendererDisplaysAttachmentAtom_andRoundTripsMetadata() throws {
|
|
let latex = "e^{I\\pi}=-1"
|
|
var capturedContext: RichTextFormulaRenderContext?
|
|
let mapper = AttributedStringMapper(formulaRenderer: { context in
|
|
capturedContext = context
|
|
return self.formulaRenderResult()
|
|
})
|
|
let block = ParagraphBlock(id: BlockID("formula"), style: .body, runs: [
|
|
TextRun(text: latex, attributes: CharacterAttributes(formula: latex))
|
|
])
|
|
|
|
let attr = mapper.attributedString(for: block)
|
|
XCTAssertEqual(attr.string, "\u{FFFC}")
|
|
XCTAssertEqual(capturedContext?.latex, latex)
|
|
XCTAssertEqual(capturedContext?.fontSize ?? 0.0, 17.0, accuracy: 0.01)
|
|
|
|
let attachment = try XCTUnwrap(attr.attribute(.attachment, at: 0, effectiveRange: nil) as? FormulaTextAttachment)
|
|
XCTAssertEqual(attachment.latex, latex)
|
|
|
|
let runs = mapper.runs(from: attr)
|
|
XCTAssertEqual(runs.count, 1)
|
|
XCTAssertEqual(runs[0].text, "\u{FFFC}")
|
|
XCTAssertEqual(runs[0].attributes.formula, latex)
|
|
}
|
|
}
|
|
#endif
|