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>
211 lines
11 KiB
Swift
211 lines
11 KiB
Swift
#if canImport(UIKit)
|
|
import XCTest
|
|
@testable import RichTextEditorUIKit
|
|
@testable import RichTextEditorCore
|
|
|
|
@available(iOS 13.0, *)
|
|
final class CodeBlockBoxTests: XCTestCase {
|
|
private func makeBox(_ text: String, language: String? = "swift") -> CodeBlockBox {
|
|
CodeBlockBox(code: CodeBlock(id: BlockID("c1"), language: language, runs: [TextRun(text: text)]),
|
|
mapper: AttributedStringMapper(), width: 300)
|
|
}
|
|
|
|
/// A code block is a CONTAINER of [languagePara, codePara]: container(2) + (lang + 2) + (code + 2).
|
|
func test_codeBox_nodeSizeCountsBothChildren() {
|
|
let box = makeBox("a\nbb") // 4 UTF-16 units of code, "swift" = 5 of language
|
|
XCTAssertEqual(box.nodeSize, 4 + 5 + 6)
|
|
XCTAssertEqual(box.textLength, 4)
|
|
XCTAssertEqual(box.languageLength, 5)
|
|
}
|
|
|
|
func test_codeBox_textRefIsCode() {
|
|
XCTAssertEqual(makeBox("x").textRef, .code(BlockID("c1")))
|
|
}
|
|
|
|
func test_codeBox_usesFifteenPointFont_matchingQuote() {
|
|
XCTAssertEqual(CodeBlockBox.codeFont.pointSize, 15, accuracy: 0.5, "code block font is 15pt, matching the quote")
|
|
}
|
|
|
|
/// Code text must take the theme's primary text colour. An attributed string with no
|
|
/// `.foregroundColor` draws BLACK, so a dark theme rendered code invisible against its own band.
|
|
func test_codeBox_textTakesThePrimaryTextColour() {
|
|
let theme = RichTextEditorTheme(
|
|
primaryText: .magenta, secondaryText: .black, placeholder: .placeholderText,
|
|
accent: .link, tableBorder: .gray, tableHeaderBackground: .gray, codeBackground: .gray)
|
|
let mapper = AttributedStringMapper(styleSheet: .default, theme: theme)
|
|
let box = CodeBlockBox(code: CodeBlock(id: BlockID("c1"), runs: [TextRun(text: "let x = 1")]),
|
|
mapper: mapper, width: 300)
|
|
|
|
let colour = box.layout.attributedString.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? UIColor
|
|
XCTAssertEqual(colour, .magenta)
|
|
}
|
|
|
|
/// Newly TYPED characters take it too — the typing attributes are a separate site from the box's
|
|
/// own string, so fixing one without the other leaves fresh input black.
|
|
func test_codeBox_typingAttributesTakeThePrimaryTextColour() {
|
|
let theme = RichTextEditorTheme(
|
|
primaryText: .magenta, secondaryText: .black, placeholder: .placeholderText,
|
|
accent: .link, tableBorder: .gray, tableHeaderBackground: .gray, codeBackground: .gray)
|
|
let attrs = CodeBlockBox.codeAttributes(textColor: theme.primaryText)
|
|
XCTAssertEqual(attrs[.foregroundColor] as? UIColor, .magenta)
|
|
}
|
|
|
|
/// Render-only: the colour must not reach the model, or it would ride into a sent message.
|
|
func test_codeBox_textColourDoesNotEnterTheModel() {
|
|
let theme = RichTextEditorTheme(
|
|
primaryText: .magenta, secondaryText: .black, placeholder: .placeholderText,
|
|
accent: .link, tableBorder: .gray, tableHeaderBackground: .gray, codeBackground: .gray)
|
|
let mapper = AttributedStringMapper(styleSheet: .default, theme: theme)
|
|
let box = CodeBlockBox(code: CodeBlock(id: BlockID("c1"), runs: [TextRun(text: "let x = 1")]),
|
|
mapper: mapper, width: 300)
|
|
|
|
guard case let .code(cb) = box.currentBlock() else { return XCTFail("expected .code") }
|
|
XCTAssertEqual(cb.runs.count, 1)
|
|
XCTAssertNil(cb.runs[0].attributes.foreground, "code text colour is render-only")
|
|
}
|
|
|
|
func test_codeBox_currentBlockRoundTripsTextAndLanguage() {
|
|
guard case let .code(cb) = makeBox("a\nb", language: "ruby").currentBlock() else {
|
|
return XCTFail("expected .code")
|
|
}
|
|
XCTAssertEqual(cb.text, "a\nb")
|
|
XCTAssertEqual(cb.language, "ruby")
|
|
}
|
|
|
|
/// TWO regions, in DOCUMENT order: the language line above, then the code text.
|
|
func test_codeBox_leafRegionsAreLanguageThenText() {
|
|
let box = makeBox("a\nb"); box.globalStart = 5
|
|
let regions = box.leafRegions()
|
|
XCTAssertEqual(regions.count, 2)
|
|
XCTAssertEqual(regions[0].globalStart, 6) // nodeStart + 1
|
|
XCTAssertEqual(regions[0].length, 5) // "swift"
|
|
XCTAssertEqual(regions[0].ref, .codeLanguage(BlockID("c1")))
|
|
XCTAssertEqual(regions[1].globalStart, box.textStart)
|
|
XCTAssertEqual(regions[1].length, 3)
|
|
XCTAssertEqual(regions[1].ref, .code(BlockID("c1")))
|
|
}
|
|
|
|
/// The code text sits at the block's own leading edge — the paragraph column — not inset by the
|
|
/// quote's indent. The band reaches further out than the text; that is the bleed's job.
|
|
func test_codeBox_textOriginIsTheParagraphColumn() {
|
|
let box = makeBox("x")
|
|
box.frame = CGRect(x: 10, y: 0, width: 300, height: 40)
|
|
XCTAssertEqual(box.textOrigin.x, box.frame.minX, accuracy: 0.5)
|
|
}
|
|
|
|
/// `blockViewFrame` is the block's full DRAWN extent, and `BlockBackingView` clips to it — so the
|
|
/// band is invisible unless the bleed is reflected here.
|
|
func test_codeBox_blockViewFrameOutsetsByTheBleed() {
|
|
let box = makeBox("x")
|
|
box.horizontalBleed = (minXSide: 16, maxXSide: 16)
|
|
box.frame = CGRect(x: 16, y: 0, width: 288, height: 40)
|
|
|
|
XCTAssertEqual(box.blockViewFrame.minX, 0, accuracy: 0.5)
|
|
XCTAssertEqual(box.blockViewFrame.maxX, 320, accuracy: 0.5)
|
|
XCTAssertEqual(box.blockViewFrame.minY, box.frame.minY, accuracy: 0.5)
|
|
XCTAssertEqual(box.blockViewFrame.height, box.frame.height, accuracy: 0.5)
|
|
}
|
|
|
|
/// No bleed ⇒ the drawn extent is the frame, so an un-migrated container cannot make a code block
|
|
/// punch out of it.
|
|
func test_codeBox_blockViewFrameIsTheFrameWithoutBleed() {
|
|
let box = makeBox("x")
|
|
box.frame = CGRect(x: 16, y: 0, width: 288, height: 40)
|
|
XCTAssertEqual(box.blockViewFrame, box.frame)
|
|
}
|
|
|
|
/// The fill travels with the box, so a code block nested in a quote is filled too. It was not:
|
|
/// the quote underlay's feed walked top-level boxes only, and the code case lived there.
|
|
func test_codeBox_isNotFedToTheQuoteUnderlay() {
|
|
let canvas = DocumentCanvasView()
|
|
canvas.setBlocks([.code(CodeBlock(id: BlockID("c1"), runs: [TextRun(text: "x")]))], width: 300)
|
|
canvas.frame = CGRect(x: 0, y: 0, width: 300, height: 300)
|
|
canvas.layoutIfNeeded()
|
|
|
|
XCTAssertTrue(canvas.blockQuoteFillRects().isEmpty)
|
|
}
|
|
|
|
/// The language line is bold and takes the BODY size — the quote author's spec — rather than the old
|
|
/// absolute 11pt monospace. It is shown AS TYPED: the line became an editable field, so lowercasing it
|
|
/// here would fight the author's own keystrokes. The renderer still lowercases at display
|
|
/// (`instantPageV2CodeLanguageDisplayText`), which is where casing is a display concern.
|
|
func test_codeBox_languageLineIsBoldBodySizedAndAsTyped() {
|
|
let box = makeBox("x", language: "Swift")
|
|
let line = box.languageLayout.attributedString
|
|
|
|
XCTAssertEqual(line.string, "Swift")
|
|
let font = line.attribute(.font, at: 0, effectiveRange: nil) as? UIFont
|
|
XCTAssertEqual(font?.pointSize ?? 0, StyleSheet.default.metrics.body.size, accuracy: 0.5)
|
|
XCTAssertTrue(font?.fontDescriptor.symbolicTraits.contains(.traitBold) ?? false)
|
|
}
|
|
|
|
/// No language, and an empty language, are the same state — an EMPTY but present region. The field is
|
|
/// always visible (that is where the "Language" placeholder draws), so unlike the old display-only
|
|
/// label it is never absent and always reserves its line.
|
|
func test_codeBox_languageLineIsPresentButEmptyWhenAbsent() {
|
|
for box in [makeBox("x", language: nil), makeBox("x", language: "")] {
|
|
XCTAssertEqual(box.languageLength, 0)
|
|
XCTAssertEqual(box.languageLayout.attributedString.string, "")
|
|
XCTAssertGreaterThan(box.languageLineExtent, StyleSheet.default.metrics.code.languageSpacing)
|
|
XCTAssertNil(box.currentCode().language)
|
|
}
|
|
}
|
|
|
|
/// A labelled and an unlabelled block are the SAME height: both reserve the always-visible language
|
|
/// line. (Before the field was editable, the label appeared only when set and added its height.)
|
|
func test_codeBox_languageLineIsReservedWhetherOrNotItIsSet() {
|
|
let plain = makeBox("x", language: nil)
|
|
let labelled = makeBox("x", language: "swift")
|
|
|
|
XCTAssertEqual(labelled.measuredHeight(forWidth: 300), plain.measuredHeight(forWidth: 300), accuracy: 0.5)
|
|
XCTAssertGreaterThan(plain.measuredHeight(forWidth: 300), plain.topInset + plain.bottomInset)
|
|
}
|
|
|
|
func test_codeBox_factoryProducesCodeBlockBox() {
|
|
let canvas = DocumentCanvasView()
|
|
canvas.setBlocks([.code(CodeBlock(id: BlockID("c1"), runs: [TextRun(text: "x")]))], width: 300)
|
|
XCTAssertTrue(canvas.boxes.first is CodeBlockBox)
|
|
}
|
|
|
|
func test_theme_storesCodeBackground() {
|
|
let theme = RichTextEditorTheme(
|
|
primaryText: .black, secondaryText: .black, placeholder: .placeholderText,
|
|
accent: .link, tableBorder: .gray, tableHeaderBackground: .gray, codeBackground: .red)
|
|
XCTAssertEqual(theme.codeBackground, .red)
|
|
}
|
|
|
|
func test_emptyCodeBox_showsPlaceholder() {
|
|
let box = makeBox("", language: nil)
|
|
box.placeholders = .default
|
|
XCTAssertEqual(box.placeholderText, "Type code here")
|
|
}
|
|
func test_nonEmptyCodeBox_noPlaceholder() {
|
|
let box = makeBox("x")
|
|
box.placeholders = .default
|
|
XCTAssertNil(box.placeholderText)
|
|
}
|
|
func test_placeholders_containerDefaults() {
|
|
XCTAssertEqual(RichTextEditorPlaceholders.default.codeBlock, "Type code here")
|
|
XCTAssertEqual(RichTextEditorPlaceholders.default.blockQuote, "Type a quote here")
|
|
}
|
|
func test_theme_containerPlaceholder_settable() {
|
|
var theme = RichTextEditorTheme.default
|
|
theme.containerPlaceholder = .red
|
|
XCTAssertEqual(theme.containerPlaceholder, .red)
|
|
}
|
|
|
|
// Regression: a TextKit-2 text edit that does NOT change the container width must still re-flow the
|
|
// layout — otherwise the box height stays stale (the "code block doesn't grow on Enter; only rotation,
|
|
// a width change, fixes it" bug). setWidth(200) after building at 200 is a genuine no-op, so the edit's
|
|
// own invalidation is the only thing that can re-flow the height.
|
|
func test_codeBox_editAtSameWidth_reflowsHeight() {
|
|
let box = makeBox("a\nb", language: nil) // built at width 300
|
|
box.setWidth(300) // same width → genuine no-op (does not re-flow)
|
|
let before = box.height
|
|
box.textLayout.replace(start: 0, end: 0,
|
|
with: NSAttributedString(string: "\n", attributes: CodeBlockBox.codeAttributes(textColor: .black)))
|
|
XCTAssertGreaterThan(box.height, before, "height must grow after an insert even without a width change")
|
|
}
|
|
}
|
|
#endif
|