6f9453592a
Previously extractMentions removed the `[[org-social:URL][nick]]` span
from the body text entirely, leaving the sentence fragmented. A post
composed as "Que tal @org-social" displayed as "Que tal" followed by a
detached chip, which read strangely and hid the name from its context.
Now the extractor replaces each link with `@nick` inline, so the sentence
stays intact ("Que tal @org-social"). The side-car mentions array is still
populated and the chip bar below the body remains as a tappable shortcut
to the mentioned user's profile.
304 lines
12 KiB
Swift
304 lines
12 KiB
Swift
import Foundation
|
||
import Testing
|
||
@testable import OrgSocialKit
|
||
|
||
/// Helper: find a substring in the AttributedString and assert the attribute
|
||
/// closure returns true for every run overlapping it.
|
||
private func hasAttribute(
|
||
_ attr: AttributedString,
|
||
over substring: String,
|
||
matches: (AttributeContainer) -> Bool
|
||
) -> Bool {
|
||
let full = String(attr.characters)
|
||
guard let range = full.range(of: substring) else { return false }
|
||
let startOffset = full.distance(from: full.startIndex, to: range.lowerBound)
|
||
let endOffset = full.distance(from: full.startIndex, to: range.upperBound)
|
||
let startIdx = attr.characters.index(attr.characters.startIndex, offsetBy: startOffset)
|
||
let endIdx = attr.characters.index(attr.characters.startIndex, offsetBy: endOffset)
|
||
for run in attr[startIdx..<endIdx].runs where !matches(run.attributes) {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
@Suite("OrgBodyRenderer – inline emphasis")
|
||
struct OrgBodyInlineTests {
|
||
|
||
@Test("bold renders as stronglyEmphasized")
|
||
func bold() {
|
||
let r = OrgBodyRenderer.render("A *bold* word")
|
||
#expect(String(r.inline.characters) == "A bold word")
|
||
#expect(hasAttribute(r.inline, over: "bold") { c in
|
||
c.inlinePresentationIntent?.contains(.stronglyEmphasized) == true
|
||
})
|
||
}
|
||
|
||
@Test("italic renders as emphasized")
|
||
func italic() {
|
||
let r = OrgBodyRenderer.render("An /italic/ word.")
|
||
#expect(String(r.inline.characters) == "An italic word.")
|
||
#expect(hasAttribute(r.inline, over: "italic") { c in
|
||
c.inlinePresentationIntent?.contains(.emphasized) == true
|
||
})
|
||
}
|
||
|
||
@Test("underline is kept as raw text (no attribute in v1)")
|
||
func underlineIsLiteral() {
|
||
let r = OrgBodyRenderer.render("Hello _under_ there.")
|
||
// The markers are preserved because InlinePresentationIntent has no
|
||
// underline case; we'd otherwise pull in UIKit/AppKit for one feature.
|
||
#expect(String(r.inline.characters) == "Hello _under_ there.")
|
||
}
|
||
|
||
@Test("strikethrough uses inlinePresentationIntent.strikethrough")
|
||
func strike() {
|
||
let r = OrgBodyRenderer.render("Oops +typo+!")
|
||
#expect(String(r.inline.characters) == "Oops typo!")
|
||
#expect(hasAttribute(r.inline, over: "typo") { c in
|
||
c.inlinePresentationIntent?.contains(.strikethrough) == true
|
||
})
|
||
}
|
||
|
||
@Test("code and verbatim both apply .code intent")
|
||
func codeVerbatim() {
|
||
let r = OrgBodyRenderer.render("Use ~let x~ and =NSString=.")
|
||
#expect(String(r.inline.characters) == "Use let x and NSString.")
|
||
#expect(hasAttribute(r.inline, over: "let x") { c in
|
||
c.inlinePresentationIntent?.contains(.code) == true
|
||
})
|
||
#expect(hasAttribute(r.inline, over: "NSString") { c in
|
||
c.inlinePresentationIntent?.contains(.code) == true
|
||
})
|
||
}
|
||
|
||
@Test("emphasis at start of string matches")
|
||
func emphasisAtStart() {
|
||
let r = OrgBodyRenderer.render("*first* word")
|
||
#expect(String(r.inline.characters) == "first word")
|
||
#expect(hasAttribute(r.inline, over: "first") { c in
|
||
c.inlinePresentationIntent?.contains(.stronglyEmphasized) == true
|
||
})
|
||
}
|
||
|
||
@Test("emphasis at end of string matches")
|
||
func emphasisAtEnd() {
|
||
let r = OrgBodyRenderer.render("ending with /italic/")
|
||
#expect(String(r.inline.characters) == "ending with italic")
|
||
#expect(hasAttribute(r.inline, over: "italic") { c in
|
||
c.inlinePresentationIntent?.contains(.emphasized) == true
|
||
})
|
||
}
|
||
|
||
@Test("marker with whitespace inside does not match")
|
||
func whitespaceInsideDoesNotMatch() {
|
||
let r = OrgBodyRenderer.render("not *open close* but * x * stays")
|
||
#expect(String(r.inline.characters) == "not open close but * x * stays")
|
||
// The second * ... * has whitespace adjacent to markers so stays literal.
|
||
#expect(hasAttribute(r.inline, over: "* x *") { c in
|
||
c.inlinePresentationIntent?.contains(.stronglyEmphasized) != true
|
||
})
|
||
}
|
||
|
||
@Test("emphasis inside word boundary does not match (no mid-word triggers)")
|
||
func noMidWord() {
|
||
let r = OrgBodyRenderer.render("foo*bar*baz")
|
||
// Preceded by `o` (word char) -> not a valid opening context per our rules.
|
||
#expect(String(r.inline.characters) == "foo*bar*baz")
|
||
}
|
||
|
||
@Test("code protects the asterisk inside")
|
||
func codeWrapsAsterisk() {
|
||
let r = OrgBodyRenderer.render("literal =*not bold*= here")
|
||
#expect(String(r.inline.characters) == "literal *not bold* here")
|
||
#expect(hasAttribute(r.inline, over: "*not bold*") { c in
|
||
c.inlinePresentationIntent?.contains(.code) == true
|
||
})
|
||
// And the inner asterisks did not produce bold on "not bold".
|
||
#expect(hasAttribute(r.inline, over: "not bold") { c in
|
||
c.inlinePresentationIntent?.contains(.stronglyEmphasized) != true
|
||
})
|
||
}
|
||
}
|
||
|
||
@Suite("OrgBodyRenderer – links and URLs")
|
||
struct OrgBodyLinksTests {
|
||
|
||
@Test("bracket link with label renders label as link")
|
||
func bracketLinkLabel() {
|
||
let r = OrgBodyRenderer.render("See [[https://example.com][the docs]] please")
|
||
#expect(String(r.inline.characters) == "See the docs please")
|
||
#expect(hasAttribute(r.inline, over: "the docs") {
|
||
$0.link == URL(string: "https://example.com")
|
||
})
|
||
}
|
||
|
||
@Test("bracket link without label uses the URL as text")
|
||
func bracketLinkBare() {
|
||
let r = OrgBodyRenderer.render("Docs: [[https://example.com/x]]")
|
||
#expect(String(r.inline.characters).contains("https://example.com/x"))
|
||
#expect(hasAttribute(r.inline, over: "https://example.com/x") {
|
||
$0.link == URL(string: "https://example.com/x")
|
||
})
|
||
}
|
||
|
||
@Test("bare URL in plain text becomes a link")
|
||
func bareURL() {
|
||
let r = OrgBodyRenderer.render("Visit https://example.com for more.")
|
||
#expect(hasAttribute(r.inline, over: "https://example.com") {
|
||
$0.link == URL(string: "https://example.com")
|
||
})
|
||
}
|
||
|
||
@Test("trailing punctuation stays outside the URL link")
|
||
func bareURLTrailingPunct() {
|
||
let r = OrgBodyRenderer.render("see https://example.com/foo.")
|
||
#expect(hasAttribute(r.inline, over: "https://example.com/foo") {
|
||
$0.link == URL(string: "https://example.com/foo")
|
||
})
|
||
// The trailing dot is NOT part of the link.
|
||
let full = String(r.inline.characters)
|
||
guard let dotRange = full.range(of: ".", options: .backwards) else { return }
|
||
let offset = full.distance(from: full.startIndex, to: dotRange.lowerBound)
|
||
let idx = r.inline.characters.index(r.inline.characters.startIndex, offsetBy: offset)
|
||
let afterIdx = r.inline.characters.index(after: idx)
|
||
let dotRun = r.inline[idx..<afterIdx]
|
||
for run in dotRun.runs {
|
||
#expect(run.attributes.link == nil)
|
||
}
|
||
}
|
||
|
||
@Test("image links are extracted, not inlined")
|
||
func imageExtracted() {
|
||
let r = OrgBodyRenderer.render("Look: [[https://cdn.test/cat.jpg][my cat]] end")
|
||
#expect(r.imageURLs.map(\.absoluteString) == ["https://cdn.test/cat.jpg"])
|
||
#expect(String(r.inline.characters).trimmingCharacters(in: .whitespaces).contains("my cat") == false)
|
||
}
|
||
|
||
@Test("bare image link is extracted")
|
||
func bareImage() {
|
||
let r = OrgBodyRenderer.render("[[https://cdn.test/a.png]]")
|
||
#expect(r.imageURLs.map(\.absoluteString) == ["https://cdn.test/a.png"])
|
||
}
|
||
}
|
||
|
||
@Suite("OrgBodyRenderer – mentions")
|
||
struct OrgBodyMentionsTests {
|
||
|
||
@Test("mention is extracted into side-car list and rendered inline as @nick")
|
||
func mention() {
|
||
let body = "Hola [[org-social:https://ex.com/social.org][alice]]!"
|
||
let r = OrgBodyRenderer.render(body)
|
||
#expect(r.mentions == [OrgMention(nick: "alice",
|
||
feedURL: URL(string: "https://ex.com/social.org")!)])
|
||
// Inline text keeps the readable @nick form so the sentence stays intact.
|
||
#expect(String(r.inline.characters).contains("@alice"))
|
||
// The raw Org link syntax is gone from the inline text.
|
||
#expect(String(r.inline.characters).contains("org-social:") == false)
|
||
}
|
||
|
||
@Test("duplicate mentions are deduplicated")
|
||
func mentionDedup() {
|
||
let url = "https://ex.com/social.org"
|
||
let body = "[[org-social:\(url)][a]] and [[org-social:\(url)][a]]"
|
||
let r = OrgBodyRenderer.render(body)
|
||
#expect(r.mentions.count == 1)
|
||
}
|
||
}
|
||
|
||
@Suite("OrgBodyRenderer – fenced blocks")
|
||
struct OrgBodyBlocksTests {
|
||
|
||
@Test("SRC block captured with language")
|
||
func srcWithLang() {
|
||
let body = """
|
||
before
|
||
#+BEGIN_SRC swift
|
||
let x = *not bold*
|
||
#+END_SRC
|
||
after
|
||
"""
|
||
let r = OrgBodyRenderer.render(body)
|
||
#expect(r.blocks.count == 1)
|
||
if case .src(let lang) = r.blocks[0].kind {
|
||
#expect(lang == "swift")
|
||
} else {
|
||
Issue.record("expected .src block, got \(r.blocks[0].kind)")
|
||
}
|
||
#expect(r.blocks[0].content == "let x = *not bold*")
|
||
#expect(String(r.inline.characters).contains("not bold") == false)
|
||
}
|
||
|
||
@Test("QUOTE block captured")
|
||
func quote() {
|
||
let body = """
|
||
#+BEGIN_QUOTE
|
||
something insightful
|
||
#+END_QUOTE
|
||
"""
|
||
let r = OrgBodyRenderer.render(body)
|
||
#expect(r.blocks.count == 1)
|
||
#expect(r.blocks[0].kind == .quote)
|
||
}
|
||
|
||
@Test("EXAMPLE block protects inner markers")
|
||
func example() {
|
||
let body = """
|
||
Preamble.
|
||
#+BEGIN_EXAMPLE
|
||
*foo* /bar/ ~baz~
|
||
#+END_EXAMPLE
|
||
And after.
|
||
"""
|
||
let r = OrgBodyRenderer.render(body)
|
||
#expect(r.blocks.count == 1)
|
||
#expect(r.blocks[0].kind == .example)
|
||
#expect(r.blocks[0].content == "*foo* /bar/ ~baz~")
|
||
// emphasis wasn't applied to the block contents
|
||
let text = String(r.inline.characters)
|
||
#expect(text.contains("*foo*") == false)
|
||
}
|
||
}
|
||
|
||
@Suite("OrgBodyRenderer – mixed and empty")
|
||
struct OrgBodyMixedTests {
|
||
|
||
@Test("empty body returns empty output")
|
||
func empty() {
|
||
let r = OrgBodyRenderer.render("")
|
||
#expect(String(r.inline.characters) == "")
|
||
#expect(r.blocks.isEmpty)
|
||
#expect(r.mentions.isEmpty)
|
||
#expect(r.imageURLs.isEmpty)
|
||
}
|
||
|
||
@Test("mixed content: emphasis + link + bare URL + mention")
|
||
func mixed() {
|
||
let body = "*Hi* [[org-social:https://a.com/s.org][a]] see https://b.com and [[https://c.com][docs]]."
|
||
let r = OrgBodyRenderer.render(body)
|
||
let text = String(r.inline.characters)
|
||
#expect(text.contains("Hi"))
|
||
#expect(text.contains("https://b.com"))
|
||
#expect(text.contains("docs"))
|
||
#expect(text.contains("[[") == false)
|
||
#expect(r.mentions.count == 1)
|
||
#expect(hasAttribute(r.inline, over: "Hi") { c in
|
||
c.inlinePresentationIntent?.contains(.stronglyEmphasized) == true
|
||
})
|
||
#expect(hasAttribute(r.inline, over: "https://b.com") {
|
||
$0.link == URL(string: "https://b.com")
|
||
})
|
||
#expect(hasAttribute(r.inline, over: "docs") {
|
||
$0.link == URL(string: "https://c.com")
|
||
})
|
||
}
|
||
|
||
@Test("punctuation immediately after emphasis still matches")
|
||
func punctAfter() {
|
||
let r = OrgBodyRenderer.render("This is *cool*!")
|
||
#expect(String(r.inline.characters) == "This is cool!")
|
||
#expect(hasAttribute(r.inline, over: "cool") { c in
|
||
c.inlinePresentationIntent?.contains(.stronglyEmphasized) == true
|
||
})
|
||
}
|
||
}
|