2026-05-31 22:21:03 +02:00
|
|
|
import XCTest
|
|
|
|
|
@testable import SaplingEditor
|
|
|
|
|
|
|
|
|
|
#if os(macOS)
|
|
|
|
|
import AppKit
|
|
|
|
|
|
|
|
|
|
final class MarkdownTextStylerRenderingTests: XCTestCase {
|
|
|
|
|
func testRenderedHeadingHidesMarkerWhileActiveHeadingKeepsSourceVisible() {
|
|
|
|
|
let source = "Intro\n# Heading"
|
|
|
|
|
let renderedStorage = styledStorage(source: source, activeLineIndex: 0)
|
|
|
|
|
let activeStorage = styledStorage(source: source, activeLineIndex: 1)
|
|
|
|
|
let headingMarker = (source as NSString).range(of: "#")
|
|
|
|
|
let headingText = (source as NSString).range(of: "Heading")
|
|
|
|
|
|
|
|
|
|
XCTAssertTrue(isHidden(renderedStorage, at: headingMarker.location))
|
|
|
|
|
XCTAssertGreaterThan(font(in: renderedStorage, at: headingText.location).pointSize, 20)
|
|
|
|
|
XCTAssertFalse(isHidden(activeStorage, at: headingMarker.location))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testRenderedInlineMarkdownHidesDelimitersOnly() {
|
|
|
|
|
let source = "Intro\nThis has **bold**, *italic*, and `code`."
|
|
|
|
|
let storage = styledStorage(source: source, activeLineIndex: 0)
|
|
|
|
|
let nsSource = source as NSString
|
|
|
|
|
|
|
|
|
|
XCTAssertTrue(isHidden(storage, at: nsSource.range(of: "**").location))
|
|
|
|
|
XCTAssertFalse(isHidden(storage, at: nsSource.range(of: "bold").location))
|
|
|
|
|
XCTAssertTrue(isHidden(storage, at: nsSource.range(of: "*italic*").location))
|
|
|
|
|
XCTAssertFalse(isHidden(storage, at: nsSource.range(of: "italic").location))
|
|
|
|
|
XCTAssertTrue(isHidden(storage, at: nsSource.range(of: "`").location))
|
|
|
|
|
XCTAssertNotNil(storage.attribute(.backgroundColor, at: nsSource.range(of: "code").location, effectiveRange: nil))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testRenderedTaskListHidesListMarkerAndShowsTaskState() {
|
|
|
|
|
let source = "Intro\n* [x] Done\n* [ ] Todo"
|
|
|
|
|
let storage = styledStorage(source: source, activeLineIndex: 0)
|
|
|
|
|
let nsSource = source as NSString
|
|
|
|
|
let checkedMarker = nsSource.range(of: "* [x]")
|
|
|
|
|
let uncheckedMarker = nsSource.range(of: "* [ ]")
|
|
|
|
|
let doneRange = nsSource.range(of: "Done")
|
|
|
|
|
|
|
|
|
|
XCTAssertTrue(isHidden(storage, at: checkedMarker.location))
|
|
|
|
|
XCTAssertFalse(isHidden(storage, at: checkedMarker.location + 2))
|
2026-05-31 22:24:57 +02:00
|
|
|
XCTAssertNotNil(storage.attribute(.backgroundColor, at: checkedMarker.location + 2, effectiveRange: nil))
|
2026-05-31 22:21:03 +02:00
|
|
|
XCTAssertTrue(isHidden(storage, at: uncheckedMarker.location))
|
|
|
|
|
XCTAssertFalse(isHidden(storage, at: uncheckedMarker.location + 2))
|
|
|
|
|
XCTAssertNotNil(storage.attribute(.strikethroughStyle, at: doneRange.location, effectiveRange: nil))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testRenderedCodeBlockHidesFencesAndStylesCodeContent() {
|
2026-06-01 10:17:17 +02:00
|
|
|
let source = "Intro\n```swift\nstruct Example {\n let value = 42\n}\n```"
|
2026-05-31 22:21:03 +02:00
|
|
|
let storage = styledStorage(source: source, activeLineIndex: 0)
|
|
|
|
|
let nsSource = source as NSString
|
|
|
|
|
let openingFence = nsSource.range(of: "```swift")
|
|
|
|
|
let language = nsSource.range(of: "swift")
|
|
|
|
|
let code = nsSource.range(of: "let value = 42")
|
2026-06-01 10:17:17 +02:00
|
|
|
let keyword = nsSource.range(of: "struct")
|
2026-05-31 22:21:03 +02:00
|
|
|
let closingFence = nsSource.range(of: "```", options: .backwards)
|
|
|
|
|
|
|
|
|
|
XCTAssertTrue(isHidden(storage, at: openingFence.location))
|
2026-06-01 14:22:27 +02:00
|
|
|
XCTAssertTrue(isHidden(storage, at: language.location))
|
|
|
|
|
XCTAssertNil(storage.attribute(.backgroundColor, at: code.location, effectiveRange: nil))
|
2026-05-31 22:21:03 +02:00
|
|
|
XCTAssertEqual(font(in: storage, at: code.location).fontDescriptor.symbolicTraits.contains(.monoSpace), true)
|
2026-06-01 10:17:17 +02:00
|
|
|
XCTAssertGreaterThan(paragraphStyle(in: storage, at: code.location).headIndent, 0)
|
|
|
|
|
XCTAssertEqual(font(in: storage, at: keyword.location).fontDescriptor.symbolicTraits.contains(.bold), true)
|
2026-05-31 22:21:03 +02:00
|
|
|
XCTAssertTrue(isHidden(storage, at: closingFence.location))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testDirtyCodeBlockRenderingResolvesBlockContext() {
|
|
|
|
|
let source = "Intro\n```swift\nlet value = 42\n```"
|
|
|
|
|
let storage = NSTextStorage(string: source)
|
|
|
|
|
let lineIndex = DocumentLineIndex(source: source)
|
|
|
|
|
let codeLine = 2
|
|
|
|
|
|
|
|
|
|
MarkdownTextStyler.apply(
|
|
|
|
|
to: storage,
|
|
|
|
|
lineIndex: lineIndex,
|
|
|
|
|
invalidationPlan: EditorDirtyLineInvalidationPlan(
|
|
|
|
|
reason: .activeLineChange,
|
|
|
|
|
isFullRender: false,
|
|
|
|
|
dirtyLineIndexes: [codeLine]
|
|
|
|
|
),
|
|
|
|
|
activeLineIndex: 0,
|
|
|
|
|
backgroundColor: .textBackgroundColor,
|
|
|
|
|
activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10),
|
|
|
|
|
textColor: .labelColor,
|
|
|
|
|
secondaryTextColor: .secondaryLabelColor,
|
|
|
|
|
accentColor: .controlAccentColor
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
let code = (source as NSString).range(of: "let value = 42")
|
2026-06-01 14:22:27 +02:00
|
|
|
XCTAssertNil(storage.attribute(.backgroundColor, at: code.location, effectiveRange: nil))
|
2026-05-31 22:21:03 +02:00
|
|
|
XCTAssertEqual(font(in: storage, at: code.location).fontDescriptor.symbolicTraits.contains(.monoSpace), true)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 10:17:17 +02:00
|
|
|
func testRenderedJsonYamlAndMarkdownCodeHighlighting() {
|
|
|
|
|
let json = styledStorage(source: "```json\n{\"name\": \"Sapling\", \"enabled\": true}\n```", activeLineIndex: -1)
|
|
|
|
|
let yaml = styledStorage(source: "```yaml\nname: Sapling\nactive: true\n```", activeLineIndex: -1)
|
|
|
|
|
let markdown = styledStorage(source: "```markdown\n# Heading\nSee **bold** text\n```", activeLineIndex: -1)
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(font(in: json, at: ("```json\n{" as NSString).length).fontDescriptor.symbolicTraits.contains(.bold), true)
|
|
|
|
|
XCTAssertEqual(font(in: yaml, at: ("```yaml\n" as NSString).length).fontDescriptor.symbolicTraits.contains(.bold), true)
|
|
|
|
|
XCTAssertEqual(font(in: markdown, at: ("```markdown\n" as NSString).length).fontDescriptor.symbolicTraits.contains(.bold), true)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 22:21:03 +02:00
|
|
|
private func styledStorage(source: String, activeLineIndex: Int) -> NSTextStorage {
|
|
|
|
|
let storage = NSTextStorage(string: source)
|
|
|
|
|
let lineIndex = DocumentLineIndex(source: source)
|
|
|
|
|
MarkdownTextStyler.apply(
|
|
|
|
|
to: storage,
|
|
|
|
|
lineIndex: lineIndex,
|
|
|
|
|
invalidationPlan: EditorDirtyLineInvalidator.plan(
|
|
|
|
|
previousText: nil,
|
|
|
|
|
currentLineIndex: lineIndex,
|
|
|
|
|
edit: nil,
|
|
|
|
|
previousActiveLineIndex: nil,
|
|
|
|
|
currentActiveLineIndex: activeLineIndex
|
|
|
|
|
),
|
|
|
|
|
activeLineIndex: activeLineIndex,
|
|
|
|
|
backgroundColor: .textBackgroundColor,
|
|
|
|
|
activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10),
|
|
|
|
|
textColor: .labelColor,
|
|
|
|
|
secondaryTextColor: .secondaryLabelColor,
|
|
|
|
|
accentColor: .controlAccentColor
|
|
|
|
|
)
|
|
|
|
|
return storage
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func isHidden(_ storage: NSTextStorage, at location: Int) -> Bool {
|
|
|
|
|
let color = storage.attribute(.foregroundColor, at: location, effectiveRange: nil) as? NSColor
|
|
|
|
|
return color?.alphaComponent == 0 && font(in: storage, at: location).pointSize < 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func font(in storage: NSTextStorage, at location: Int) -> NSFont {
|
|
|
|
|
guard let font = storage.attribute(.font, at: location, effectiveRange: nil) as? NSFont else {
|
|
|
|
|
return NSFont.systemFont(ofSize: 0)
|
|
|
|
|
}
|
|
|
|
|
return font
|
|
|
|
|
}
|
2026-06-01 10:17:17 +02:00
|
|
|
|
|
|
|
|
private func paragraphStyle(in storage: NSTextStorage, at location: Int) -> NSParagraphStyle {
|
|
|
|
|
storage.attribute(.paragraphStyle, at: location, effectiveRange: nil) as? NSParagraphStyle ?? NSParagraphStyle()
|
|
|
|
|
}
|
2026-05-31 22:21:03 +02:00
|
|
|
}
|
|
|
|
|
#endif
|