mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-10 19:42:21 +00:00
283 lines
12 KiB
Swift
283 lines
12 KiB
Swift
import Foundation
|
|
import HapiUI
|
|
import Testing
|
|
|
|
/// Markdown source → `MarkdownBlockNode` tree conversion (logic only — no
|
|
/// view rendering). This exercises the swift-markdown visitor, the pre-parse
|
|
/// transforms wiring, link policy application and file-path autolinking.
|
|
@Suite("MarkdownBlockTree")
|
|
struct MarkdownBlockTreeTests {
|
|
private typealias Run = (text: String, link: URL?, intent: InlinePresentationIntent?)
|
|
|
|
private func runs(_ attributed: AttributedString) -> [Run] {
|
|
attributed.runs.map { run in
|
|
(String(attributed.characters[run.range]), run.link, run.inlinePresentationIntent)
|
|
}
|
|
}
|
|
|
|
private func plain(_ attributed: AttributedString) -> String {
|
|
String(attributed.characters)
|
|
}
|
|
|
|
private func paragraphText(_ node: MarkdownBlockNode) -> AttributedString? {
|
|
if case .paragraph(let text) = node { return text }
|
|
return nil
|
|
}
|
|
|
|
// MARK: - Basic blocks
|
|
|
|
@Test func buildsPlainParagraph() throws {
|
|
let blocks = MarkdownBlockTree.build(from: "hello world")
|
|
#expect(blocks.count == 1)
|
|
let text = try #require(paragraphText(blocks[0]))
|
|
#expect(plain(text) == "hello world")
|
|
}
|
|
|
|
@Test func buildsInlineIntents() throws {
|
|
let blocks = MarkdownBlockTree.build(from: "**bold** and *it* and ~~gone~~ and `code`")
|
|
let text = try #require(paragraphText(blocks[0]))
|
|
let all = runs(text)
|
|
#expect(all.contains { $0.text == "bold" && $0.intent?.contains(.stronglyEmphasized) == true })
|
|
#expect(all.contains { $0.text == "it" && $0.intent?.contains(.emphasized) == true })
|
|
#expect(all.contains { $0.text == "gone" && $0.intent?.contains(.strikethrough) == true })
|
|
#expect(all.contains { $0.text == "code" && $0.intent?.contains(.code) == true })
|
|
}
|
|
|
|
@Test func buildsHeadingWithLevel() throws {
|
|
let blocks = MarkdownBlockTree.build(from: "## Title")
|
|
guard case .heading(let level, let text) = try #require(blocks.first) else {
|
|
Issue.record("expected heading")
|
|
return
|
|
}
|
|
#expect(level == 2)
|
|
#expect(plain(text) == "Title")
|
|
}
|
|
|
|
@Test func buildsFencedCodeBlock() throws {
|
|
let blocks = MarkdownBlockTree.build(from: "```swift\nlet x = 1\n```")
|
|
guard case .codeBlock(let language, let code) = try #require(blocks.first) else {
|
|
Issue.record("expected code block")
|
|
return
|
|
}
|
|
#expect(language == "swift")
|
|
#expect(code == "let x = 1")
|
|
}
|
|
|
|
@Test func mermaidFenceStaysACodeBlock() throws {
|
|
// v1 plan: mermaid/math degrade to plain code blocks.
|
|
let blocks = MarkdownBlockTree.build(from: "```mermaid\ngraph TD\n```")
|
|
guard case .codeBlock(let language, _) = try #require(blocks.first) else {
|
|
Issue.record("expected code block")
|
|
return
|
|
}
|
|
#expect(language == "mermaid")
|
|
}
|
|
|
|
@Test func buildsBlockquote() throws {
|
|
let blocks = MarkdownBlockTree.build(from: "> quoted")
|
|
guard case .blockquote(let children) = try #require(blocks.first) else {
|
|
Issue.record("expected blockquote")
|
|
return
|
|
}
|
|
let child = try #require(children.first)
|
|
let text = try #require(paragraphText(child))
|
|
#expect(plain(text) == "quoted")
|
|
}
|
|
|
|
@Test func buildsThematicBreak() {
|
|
let blocks = MarkdownBlockTree.build(from: "a\n\n---\n\nb")
|
|
#expect(blocks.count == 3)
|
|
#expect(blocks[1] == .thematicBreak)
|
|
}
|
|
|
|
@Test func buildsImagePlaceholder() throws {
|
|
let blocks = MarkdownBlockTree.build(from: "")
|
|
guard case .image(let alt, let destination) = try #require(blocks.first) else {
|
|
Issue.record("expected image")
|
|
return
|
|
}
|
|
#expect(alt == "shot")
|
|
#expect(destination == "https://x.com/y.png")
|
|
}
|
|
|
|
@Test func htmlBlockRendersAsLiteralParagraph() throws {
|
|
let blocks = MarkdownBlockTree.build(from: "<div>\nraw\n</div>")
|
|
let block = try #require(blocks.first)
|
|
let text = try #require(paragraphText(block))
|
|
#expect(plain(text).contains("<div>"))
|
|
}
|
|
|
|
// MARK: - Lists
|
|
|
|
@Test func buildsTaskList() throws {
|
|
let blocks = MarkdownBlockTree.build(from: "- [x] done\n- [ ] todo")
|
|
guard case .list(let model) = try #require(blocks.first) else {
|
|
Issue.record("expected list")
|
|
return
|
|
}
|
|
#expect(model.isOrdered == false)
|
|
#expect(model.items.count == 2)
|
|
#expect(model.items[0].checkbox == true)
|
|
#expect(model.items[1].checkbox == false)
|
|
}
|
|
|
|
@Test func buildsOrderedListWithStart() throws {
|
|
let blocks = MarkdownBlockTree.build(from: "3. three\n4. four")
|
|
guard case .list(let model) = try #require(blocks.first) else {
|
|
Issue.record("expected list")
|
|
return
|
|
}
|
|
#expect(model.isOrdered)
|
|
#expect(model.startIndex == 3)
|
|
#expect(model.items.count == 2)
|
|
#expect(model.items[0].checkbox == nil)
|
|
}
|
|
|
|
@Test func preservesThreeLevelListNesting() throws {
|
|
let blocks = MarkdownBlockTree.build(from: "- a\n - b\n - c")
|
|
guard case .list(let outer) = try #require(blocks.first) else {
|
|
Issue.record("expected list")
|
|
return
|
|
}
|
|
guard case .list(let middle)? = outer.items.first?.blocks.last else {
|
|
Issue.record("expected nested list at depth 2")
|
|
return
|
|
}
|
|
guard case .list(let inner)? = middle.items.first?.blocks.last else {
|
|
Issue.record("expected nested list at depth 3")
|
|
return
|
|
}
|
|
let block = try #require(inner.items.first?.blocks.first)
|
|
let text = try #require(paragraphText(block))
|
|
#expect(plain(text) == "c")
|
|
}
|
|
|
|
// MARK: - Tables
|
|
|
|
@Test func buildsTableFromValidSource() throws {
|
|
let blocks = MarkdownBlockTree.build(from: "| a | b |\n|:--|--:|\n| 1 | 2 |")
|
|
guard case .table(let model) = try #require(blocks.first) else {
|
|
Issue.record("expected table")
|
|
return
|
|
}
|
|
#expect(model.header.map { String($0.characters) } == ["a", "b"])
|
|
#expect(model.rows.map { row in row.map { String($0.characters) } } == [["1", "2"]])
|
|
#expect(model.columnAlignments == [.left, .right])
|
|
}
|
|
|
|
@Test func repairsTruncatedSeparatorIntoFullTable() throws {
|
|
// The broken separator would degrade to a paragraph without the
|
|
// repair transform; the tree must still contain a 3-column table.
|
|
let blocks = MarkdownBlockTree.build(from: "| A | B | C |\n|---|---|\n| 1 | 2 | 3 |")
|
|
guard case .table(let model) = try #require(blocks.first) else {
|
|
Issue.record("expected repaired table")
|
|
return
|
|
}
|
|
#expect(model.header.count == 3)
|
|
#expect(model.rows == [["1", "2", "3"].map { AttributedString($0) }])
|
|
}
|
|
|
|
@Test func indentedProseStaysProse() throws {
|
|
let blocks = MarkdownBlockTree.build(from: "intro:\n\n quoted line")
|
|
#expect(blocks.count == 2)
|
|
let text = try #require(paragraphText(blocks[1]))
|
|
#expect(plain(text) == "quoted line")
|
|
}
|
|
|
|
// MARK: - Links
|
|
|
|
@Test func explicitHTTPSLinkGetsLinkAttribute() throws {
|
|
let blocks = MarkdownBlockTree.build(from: "[site](https://example.com/a)")
|
|
let text = try #require(paragraphText(blocks[0]))
|
|
let linked = runs(text).compactMap { $0.link }
|
|
#expect(linked == [URL(string: "https://example.com/a")])
|
|
}
|
|
|
|
@Test func blockedSchemeRendersAsPlainText() throws {
|
|
let blocks = MarkdownBlockTree.build(from: "[x](javascript:alert(1))")
|
|
let text = try #require(paragraphText(blocks[0]))
|
|
#expect(runs(text).allSatisfy { $0.link == nil })
|
|
#expect(plain(text) == "x")
|
|
}
|
|
|
|
@Test func customSchemeKeepsLinkForConfirmFlow() throws {
|
|
let blocks = MarkdownBlockTree.build(from: "[open](vscode://file/a.swift)")
|
|
let text = try #require(paragraphText(blocks[0]))
|
|
#expect(runs(text).compactMap { $0.link } == [URL(string: "vscode://file/a.swift")])
|
|
}
|
|
|
|
@Test func relativeFileLinkRewritesToHapiFile() throws {
|
|
let blocks = MarkdownBlockTree.build(from: "[doc](docs/a.md)")
|
|
let text = try #require(paragraphText(blocks[0]))
|
|
let url = try #require(runs(text).compactMap { $0.link }.first)
|
|
#expect(url.scheme == "hapi-file")
|
|
#expect(FilePathLink(url: url) == FilePathLink(path: "docs/a.md", line: nil))
|
|
#expect(plain(text) == "doc")
|
|
}
|
|
|
|
@Test func schemelessNonFileLinkIsInert() throws {
|
|
let blocks = MarkdownBlockTree.build(from: "[settings](/settings)")
|
|
let text = try #require(paragraphText(blocks[0]))
|
|
#expect(runs(text).allSatisfy { $0.link == nil })
|
|
#expect(plain(text) == "settings")
|
|
}
|
|
|
|
@Test func bareURLIsAutolinked() throws {
|
|
let blocks = MarkdownBlockTree.build(from: "see https://example.com now")
|
|
let text = try #require(paragraphText(blocks[0]))
|
|
let all = runs(text)
|
|
#expect(all.contains { $0.text == "https://example.com" && $0.link == URL(string: "https://example.com") })
|
|
}
|
|
|
|
@Test func bareURLDropsTrailingCJKPunctuation() throws {
|
|
let boundary = MarkdownBlockTree.build(from: "见 https://example.com。")
|
|
let text = try #require(paragraphText(boundary[0]))
|
|
let all = runs(text)
|
|
#expect(all.contains { $0.text == "https://example.com" && $0.link == URL(string: "https://example.com") })
|
|
#expect(plain(text) == "见 https://example.com。")
|
|
// The punctuation itself is not part of any link run.
|
|
#expect(all.allSatisfy { !($0.text.contains("。") && $0.link != nil) })
|
|
}
|
|
|
|
@Test func filePathInProseGetsHapiFileLink() throws {
|
|
let blocks = MarkdownBlockTree.build(from: "see src/app.ts:12 ok")
|
|
let text = try #require(paragraphText(blocks[0]))
|
|
let hit = try #require(runs(text).first { $0.link != nil })
|
|
#expect(hit.text == "src/app.ts:12")
|
|
let url = try #require(hit.link)
|
|
#expect(FilePathLink(url: url) == FilePathLink(path: "src/app.ts", line: 12))
|
|
}
|
|
|
|
@Test func inlineCodePathGetsLinkAndCodeIntent() throws {
|
|
let blocks = MarkdownBlockTree.build(from: "`src/app.ts`")
|
|
let text = try #require(paragraphText(blocks[0]))
|
|
let hit = try #require(runs(text).first)
|
|
#expect(hit.intent?.contains(.code) == true)
|
|
let url = try #require(hit.link)
|
|
#expect(FilePathLink(url: url)?.path == "src/app.ts")
|
|
}
|
|
|
|
@Test func inlineCodeSnippetStaysUnlinked() throws {
|
|
let blocks = MarkdownBlockTree.build(from: "`npm run build`")
|
|
let text = try #require(paragraphText(blocks[0]))
|
|
let hit = try #require(runs(text).first)
|
|
#expect(hit.intent?.contains(.code) == true)
|
|
#expect(hit.link == nil)
|
|
}
|
|
|
|
@Test func linkLabelTextIsNotAutolinkedAgain() throws {
|
|
// Inside an explicit link, path-like label text must not sprout
|
|
// nested hapi-file links (web: transform skips link parents).
|
|
let blocks = MarkdownBlockTree.build(from: "[src/app.ts](https://example.com)")
|
|
let text = try #require(paragraphText(blocks[0]))
|
|
let linked = Set(runs(text).compactMap { $0.link?.absoluteString })
|
|
#expect(linked == ["https://example.com"])
|
|
}
|
|
|
|
@Test func smartPunctuationSubstitutionIsDisabled() throws {
|
|
let blocks = MarkdownBlockTree.build(from: "\"quoted\" -- and...")
|
|
let text = try #require(paragraphText(blocks[0]))
|
|
#expect(plain(text) == "\"quoted\" -- and...")
|
|
}
|
|
}
|