i7t5--edmund
243 行
11 KiB
Swift
243 行
11 KiB
Swift
import Testing
|
|
import AppKit
|
|
@testable import EdmundCore
|
|
|
|
// Nested block styling: containers decide whether their nested *blocks* render.
|
|
// - Code block → literal (covered elsewhere; CodeBlock never descends).
|
|
// - Plain quote → nested blocks literal; inline still renders.
|
|
// - Callout → everything renders (recursive sub-render).
|
|
|
|
private func isMonospaced(_ f: NSFont?) -> Bool {
|
|
f?.fontDescriptor.symbolicTraits.contains(.monoSpace) ?? false
|
|
}
|
|
|
|
private func isBold(_ f: NSFont?) -> Bool {
|
|
guard let f else { return false }
|
|
return NSFontManager.shared.traits(of: f).contains(.boldFontMask)
|
|
}
|
|
|
|
private func decorationList(at offset: Int, in s: NSAttributedString) -> BlockDecorationList? {
|
|
guard offset < s.length else { return nil }
|
|
return s.attribute(.blockDecoration, at: offset, effectiveRange: nil) as? BlockDecorationList
|
|
}
|
|
|
|
@Suite("Nested — plain block quote keeps nested blocks literal")
|
|
@MainActor
|
|
struct PlainQuoteNestedTests {
|
|
|
|
@Test("Inline emphasis inside a quote still renders")
|
|
func inlineStillRenders() {
|
|
let s = makeEditor().styleBlock("> **important** text")
|
|
let loc = (s.string as NSString).range(of: "important").location
|
|
#expect(isBold(s.attribute(.font, at: loc, effectiveRange: nil) as? NSFont))
|
|
}
|
|
|
|
@Test("A fenced code block inside a quote stays literal (not monospaced)")
|
|
func nestedCodeLiteral() {
|
|
let s = makeEditor().styleBlock("> intro\n> ```swift\n> let x = 1\n> ```")
|
|
let loc = (s.string as NSString).range(of: "let x").location
|
|
#expect(!isMonospaced(s.attribute(.font, at: loc, effectiveRange: nil) as? NSFont))
|
|
}
|
|
|
|
@Test("A list item inside a quote stays literal (no bullet overlay, marker visible)")
|
|
func nestedListLiteral() {
|
|
let s = makeEditor().styleBlock("> intro\n> - item")
|
|
let ns = s.string as NSString
|
|
// No list bullet/overlay is emitted anywhere in the block.
|
|
var hasOverlay = false
|
|
s.enumerateAttribute(.fragmentOverlay, in: NSRange(location: 0, length: s.length),
|
|
options: []) { v, _, stop in if v != nil { hasOverlay = true; stop.pointee = true } }
|
|
#expect(!hasOverlay)
|
|
// The raw "-" marker is still visible text, not a hidden marker.
|
|
let dash = ns.range(of: "- item").location
|
|
#expect(!isHidden(at: dash, in: s))
|
|
}
|
|
|
|
@Test("A heading inside a quote stays literal (not enlarged/bold)")
|
|
func nestedHeadingLiteral() {
|
|
let s = makeEditor().styleBlock("> intro\n> # Heading")
|
|
let loc = (s.string as NSString).range(of: "Heading").location
|
|
let f = s.attribute(.font, at: loc, effectiveRange: nil) as? NSFont
|
|
#expect(!isBold(f))
|
|
}
|
|
}
|
|
|
|
@Suite("Nested — callout renders inner blocks")
|
|
@MainActor
|
|
struct CalloutNestedTests {
|
|
|
|
@Test("A code block inside a callout renders (closing fence detected, monospaced)")
|
|
func codeInCallout() {
|
|
let s = makeEditor().styleBlock(
|
|
"> [!note] Note\n> Body\n> ```python\n> def f():\n> pass\n> ```")
|
|
let ns = s.string as NSString
|
|
#expect(isMonospaced(s.attribute(.font, at: ns.range(of: "def f").location,
|
|
effectiveRange: nil) as? NSFont))
|
|
// The closing fence must be recognized: `pass` is inside the code, also mono.
|
|
#expect(isMonospaced(s.attribute(.font, at: ns.range(of: "pass").location,
|
|
effectiveRange: nil) as? NSFont))
|
|
}
|
|
|
|
@Test("A plain block quote inside a callout draws a left bar inside the box")
|
|
func quoteInCallout() {
|
|
let s = makeEditor().styleBlock("> [!note] Note\n> > quoted line\n> > more")
|
|
let loc = (s.string as NSString).range(of: "quoted line").location
|
|
let list = decorationList(at: loc, in: s)
|
|
#expect(list != nil)
|
|
// Outer callout box plus the inner quote's left bar.
|
|
#expect(list?.decorations.contains { if case .box = $0.kind { return true }; return false } == true)
|
|
#expect(list?.decorations.contains { if case .leftBar = $0.kind { return true }; return false } == true)
|
|
}
|
|
|
|
@Test("A callout inside a callout stacks two boxes, the inner one inset")
|
|
func calloutInCallout() {
|
|
let editor = makeEditor()
|
|
let s = editor.styleBlock("> [!note] Outer\n> > [!tip] Inner\n> > tip body")
|
|
let loc = (s.string as NSString).range(of: "tip body").location
|
|
let list = decorationList(at: loc, in: s)
|
|
#expect(list != nil)
|
|
let boxes = list?.decorations.filter { if case .box = $0.kind { return true }; return false } ?? []
|
|
#expect(boxes.count == 2)
|
|
// Outermost box at inset 0, inner box inset by one marker step.
|
|
#expect(boxes.first?.inset == 0)
|
|
#expect((boxes.last?.inset ?? 0) > 0)
|
|
}
|
|
|
|
@Test("A nested callout at the parent's last line keeps both boxes' bottom padding")
|
|
func nestedBottomPadding() {
|
|
// The last line carries the inner Tip box *and* the outer Note box, each
|
|
// with its own bottomPad, so the drawing can show the Tip's padding and
|
|
// then the Note's padding below it (summed frame growth).
|
|
let s = makeEditor().styleBlock("> [!note] Note\n> Hello\n> > [!tip] Tip\n> > nested")
|
|
let loc = (s.string as NSString).range(of: "nested").location
|
|
let list = s.attribute(.blockDecoration, at: loc, effectiveRange: nil) as? BlockDecorationList
|
|
#expect(list != nil)
|
|
let pads = (list?.decorations ?? []).compactMap { d -> CGFloat? in
|
|
if case .box(_, _, _, _, let bp) = d.kind { return bp }
|
|
return nil
|
|
}
|
|
#expect(pads.count == 2)
|
|
#expect(pads.allSatisfy { $0 > 0 })
|
|
}
|
|
|
|
@Test("A checklist inside a callout renders its checkbox as an overlay")
|
|
func checklistInCallout() {
|
|
let s = makeEditor().styleBlock("> [!note] Note\n> - [ ] task")
|
|
var hasOverlay = false
|
|
s.enumerateAttribute(.fragmentOverlay, in: NSRange(location: 0, length: s.length),
|
|
options: []) { v, _, stop in if v != nil { hasOverlay = true; stop.pointee = true } }
|
|
#expect(hasOverlay)
|
|
}
|
|
|
|
@Test("A table inside a callout renders table-row chrome")
|
|
func tableInCallout() {
|
|
let s = makeEditor().styleBlock(
|
|
"> [!note] Note\n> | a | b |\n> | --- | --- |\n> | 1 | 2 |")
|
|
let loc = (s.string as NSString).range(of: "1").location
|
|
let list = decorationList(at: loc, in: s)
|
|
#expect(list?.decorations.contains {
|
|
if case .tableRow = $0.kind { return true }; return false
|
|
} == true)
|
|
}
|
|
|
|
@Test("Display math inside a callout renders an overlay image")
|
|
func mathInCallout() {
|
|
let s = makeEditor().styleBlock("> [!note] Note\n> $$x^2$$")
|
|
var hasOverlay = false
|
|
s.enumerateAttribute(.fragmentOverlay, in: NSRange(location: 0, length: s.length),
|
|
options: []) { v, _, stop in if v != nil { hasOverlay = true; stop.pointee = true } }
|
|
#expect(hasOverlay)
|
|
}
|
|
|
|
@Test("An active callout shows raw '>' source for editing (no box, no overlay)")
|
|
func activeCalloutShowsRawSource() {
|
|
let editor = makeEditor()
|
|
// cursorPosition inside the callout → editing form: raw `>` visible
|
|
// (dimmed), no box decoration, no header overlay.
|
|
let s = editor.styleBlock("> [!note] Note\n> body\n> > [!tip] Tip\n> > tip body",
|
|
cursorPosition: 3)
|
|
// No callout box anywhere.
|
|
var hasBox = false
|
|
s.enumerateAttribute(.blockDecoration, in: NSRange(location: 0, length: s.length),
|
|
options: []) { v, _, stop in
|
|
let kinds = (v as? BlockDecorationList)?.decorations ?? [v as? BlockDecoration].compactMap { $0 }
|
|
if kinds.contains(where: { if case .box = $0.kind { return true }; return false }) {
|
|
hasBox = true; stop.pointee = true
|
|
}
|
|
}
|
|
#expect(!hasBox)
|
|
// No header overlay (icon/title image).
|
|
var hasOverlay = false
|
|
s.enumerateAttribute(.fragmentOverlay, in: NSRange(location: 0, length: s.length),
|
|
options: []) { v, _, stop in if v != nil { hasOverlay = true; stop.pointee = true } }
|
|
#expect(!hasOverlay)
|
|
// The leading `>` of the active callout is visible (dimmed), not hidden.
|
|
#expect(!isHidden(at: 0, in: s))
|
|
// The `[!note]` marker text is present and visible (editable).
|
|
let bracket = (s.string as NSString).range(of: "[!note]").location
|
|
#expect(!isHidden(at: bracket, in: s))
|
|
}
|
|
|
|
@Test("A heading inside a callout renders (bold/enlarged)")
|
|
func headingRendersInCallout() {
|
|
let editor = makeEditor()
|
|
let s = editor.styleBlock("> [!note] Note\n> # Inside\n> body")
|
|
let f = s.attribute(.font, at: (s.string as NSString).range(of: "Inside").location,
|
|
effectiveRange: nil) as? NSFont
|
|
#expect(isBold(f))
|
|
#expect((f?.pointSize ?? 0) > editor.bodyFont.pointSize)
|
|
}
|
|
|
|
@Test("Strict membership: a single-'>' line is not part of a '> >' nested callout")
|
|
func nestedMembershipStrict() {
|
|
// `> note body` carries one `>`, so it belongs to the Note, not the Tip
|
|
// (which needs `> >`). It must render with only the Note box.
|
|
let s = makeEditor().styleBlock("> [!note] Note\n> > [!tip] Tip\n> note body")
|
|
let loc = (s.string as NSString).range(of: "note body").location
|
|
let deco = s.attribute(.blockDecoration, at: loc, effectiveRange: nil)
|
|
// A single decoration (the Note box) — not a stack that includes the Tip box.
|
|
if let list = deco as? BlockDecorationList {
|
|
#expect(list.decorations.count == 1)
|
|
} else {
|
|
#expect(deco is BlockDecoration)
|
|
}
|
|
}
|
|
|
|
@Test("A heading inside a callout is NOT a document heading")
|
|
func headingInCalloutNotDocumentHeading() {
|
|
let editor = makeEditor()
|
|
editor.loadContent("intro\n\n> [!note] Note\n> # Inside\n> body\n\n## After")
|
|
editor.setSelectedRange(NSRange(location: 2, length: 0))
|
|
editor.scrollToHeading("Inside") // not a real heading block
|
|
#expect(editor.selectedRange().location == 2) // caret unmoved
|
|
// A genuine top-level heading is still found.
|
|
editor.scrollToHeading("After")
|
|
let after = (editor.rawSource as NSString).range(of: "## After").location
|
|
#expect(editor.selectedRange().location == after)
|
|
}
|
|
}
|
|
|
|
@Suite("Nested — decoration model")
|
|
struct DecorationModelTests {
|
|
|
|
private func box(inset: CGFloat) -> BlockDecoration {
|
|
BlockDecoration(.box(background: .clear, borderColor: nil,
|
|
borderEdges: [], borderWidth: 0, bottomPad: 0), inset: inset)
|
|
}
|
|
|
|
@Test("Box inset participates in equality")
|
|
func boxInsetEquality() {
|
|
#expect(box(inset: 0) == box(inset: 0))
|
|
#expect(box(inset: 0) != box(inset: 5))
|
|
}
|
|
|
|
@Test("BlockDecorationList compares its decorations by value")
|
|
func listEquality() {
|
|
let a = BlockDecorationList([box(inset: 0), box(inset: 5)])
|
|
let b = BlockDecorationList([box(inset: 0), box(inset: 5)])
|
|
let c = BlockDecorationList([box(inset: 0)])
|
|
#expect(a == b)
|
|
#expect(a != c)
|
|
}
|
|
}
|