mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-09 19:29:41 +00:00
270 lines
11 KiB
Swift
270 lines
11 KiB
Swift
import Foundation
|
||
import HapiUI
|
||
import Testing
|
||
|
||
/// Ports of the web transform tests (`remark-repair-tables.test.ts`,
|
||
/// `remark-strip-cjk-autolink.test.ts`, plus the indented-code and autolink
|
||
/// behaviors). Inline expectations mirror the web semantics except where a
|
||
/// documented iOS delta applies.
|
||
@Suite("MarkdownTransforms")
|
||
struct MarkdownTransformsTests {
|
||
// MARK: - repairTables
|
||
|
||
@Test func padsTwoCellSeparatorForThreeColumnHeader() {
|
||
let input = "| A | B | C |\n|---|---|\n| 1 | 2 | 3 |"
|
||
let output = MarkdownTransforms.repairTables(input)
|
||
let lines = output.components(separatedBy: "\n")
|
||
#expect(lines[1] == "|---|---| --- |")
|
||
#expect(lines[0] == "| A | B | C |")
|
||
#expect(lines[2] == "| 1 | 2 | 3 |")
|
||
}
|
||
|
||
@Test func padsOneCellSeparatorForFourColumnHeader() {
|
||
let input = "| a | b | c | d |\n|-|\n| 1 | 2 | 3 | 4 |"
|
||
let output = MarkdownTransforms.repairTables(input)
|
||
let separator = output.components(separatedBy: "\n")[1]
|
||
let cellCount = separator.components(separatedBy: "|")
|
||
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
|
||
.count
|
||
#expect(cellCount == 4)
|
||
}
|
||
|
||
@Test func leavesMatchingSeparatorUnchanged() {
|
||
let input = "| A | B |\n|---|---|\n| 1 | 2 |"
|
||
#expect(MarkdownTransforms.repairTables(input) == input)
|
||
}
|
||
|
||
@Test func leavesSeparatorWithMoreCellsThanHeaderUnchanged() {
|
||
let input = "| a | b |\n|---|---|---|"
|
||
#expect(MarkdownTransforms.repairTables(input) == input)
|
||
}
|
||
|
||
@Test func skipsSeparatorNotFollowingPipeHeader() {
|
||
let input = "A | B | C\n---|---"
|
||
#expect(MarkdownTransforms.repairTables(input) == input)
|
||
}
|
||
|
||
@Test func ignoresTableLikeLinesInsideBacktickFence() {
|
||
let input = "```\n| A | B | C |\n|---|---|\n```"
|
||
#expect(MarkdownTransforms.repairTables(input) == input)
|
||
}
|
||
|
||
@Test func ignoresTableLikeLinesInsideTildeFence() {
|
||
let input = "~~~\n| A | B | C |\n|---|---|\n~~~"
|
||
#expect(MarkdownTransforms.repairTables(input) == input)
|
||
}
|
||
|
||
@Test func fourBacktickFenceIsNotClosedByThreeBackticks() {
|
||
let input = "````\n```\n| A | B | C |\n|---|---|\n````"
|
||
#expect(MarkdownTransforms.repairTables(input) == input)
|
||
}
|
||
|
||
@Test func oppositeMarkerInsideFenceDoesNotFlipState() {
|
||
let input = "~~~\n```\n| A | B | C |\n|---|---|\n~~~"
|
||
#expect(MarkdownTransforms.repairTables(input) == input)
|
||
}
|
||
|
||
@Test func closingFenceWithInfoTextDoesNotClose() {
|
||
let input = "```\ntext\n``` info\n| A | B | C |\n|---|---|"
|
||
#expect(MarkdownTransforms.repairTables(input) == input)
|
||
}
|
||
|
||
@Test func repairsTableAfterFenceCloses() {
|
||
let input = "```\ncode\n```\n| A | B | C |\n|---|---|"
|
||
let output = MarkdownTransforms.repairTables(input)
|
||
let lines = output.components(separatedBy: "\n")
|
||
#expect(lines[4] == "|---|---| --- |")
|
||
}
|
||
|
||
@Test func preservesAlignmentHints() {
|
||
let input = "| a | b | c |\n|:--|--:|"
|
||
let output = MarkdownTransforms.repairTables(input)
|
||
#expect(output.components(separatedBy: "\n")[1] == "|:--|--:| --- |")
|
||
}
|
||
|
||
@Test func escapedPipeInHeaderIsLiteral() {
|
||
// `\|` is one literal pipe char, so the header has 2 cells — the
|
||
// 2-cell separator already matches and must stay untouched.
|
||
let input = "| a \\| b | c |\n|---|---|"
|
||
#expect(MarkdownTransforms.repairTables(input) == input)
|
||
}
|
||
|
||
@Test func pipeInsideCodeSpanIsNotACellBoundary() {
|
||
let input = "| `a|b` | c |\n|---|---|"
|
||
#expect(MarkdownTransforms.repairTables(input) == input)
|
||
}
|
||
|
||
@Test func preservesIndentationOfRepairedSeparator() {
|
||
let input = " | a | b | c |\n |---|---|"
|
||
let output = MarkdownTransforms.repairTables(input)
|
||
#expect(output.components(separatedBy: "\n")[1] == " |---|---| --- |")
|
||
}
|
||
|
||
@Test func truncatedDataRowIsLeftAlone() {
|
||
// Mid-row streaming truncation: GFM tolerates short body rows, so no
|
||
// string-level repair is needed (web parity).
|
||
let input = "| a | b | c |\n|---|---|---|\n| 1 | 2"
|
||
#expect(MarkdownTransforms.repairTables(input) == input)
|
||
}
|
||
|
||
@Test func repairsBrokenTableAndLeavesValidOne() {
|
||
let input = "| A | B |\n|---|---|\n| 1 | 2 |\n\n| X | Y | Z |\n|---|---|\n| 7 | 8 | 9 |"
|
||
let output = MarkdownTransforms.repairTables(input)
|
||
let lines = output.components(separatedBy: "\n")
|
||
#expect(lines[1] == "|---|---|")
|
||
#expect(lines[5] == "|---|---| --- |")
|
||
}
|
||
|
||
// MARK: - disableIndentedCode
|
||
|
||
@Test func deindentsTopLevelIndentedText() {
|
||
let input = "para:\n\n quoted reply"
|
||
let output = MarkdownTransforms.disableIndentedCode(input)
|
||
#expect(output == "para:\n\n quoted reply")
|
||
}
|
||
|
||
@Test func deindentsDeeplyIndentedText() {
|
||
let input = "para:\n\n eight spaces"
|
||
let output = MarkdownTransforms.disableIndentedCode(input)
|
||
#expect(output == "para:\n\n eight spaces")
|
||
}
|
||
|
||
@Test func treatsTabAsIndentation() {
|
||
let input = "para:\n\n\ttabbed"
|
||
let output = MarkdownTransforms.disableIndentedCode(input)
|
||
#expect(output == "para:\n\n tabbed")
|
||
}
|
||
|
||
@Test func leavesFencedCodeAlone() {
|
||
let input = "```\n indented in fence\n```"
|
||
#expect(MarkdownTransforms.disableIndentedCode(input) == input)
|
||
}
|
||
|
||
@Test func leavesListContinuationAlone() {
|
||
let input = "1. item\n\n continuation text"
|
||
#expect(MarkdownTransforms.disableIndentedCode(input) == input)
|
||
}
|
||
|
||
@Test func leavesNestedListsAlone() {
|
||
let input = "- a\n - b\n - c\n - d"
|
||
#expect(MarkdownTransforms.disableIndentedCode(input) == input)
|
||
}
|
||
|
||
@Test func deindentsAfterListEnds() {
|
||
let input = "- item\n\ntext\n\n stray indent"
|
||
let output = MarkdownTransforms.disableIndentedCode(input)
|
||
#expect(output == "- item\n\ntext\n\n stray indent")
|
||
}
|
||
|
||
@Test func deindentsTopLevelIndentedListMarker() {
|
||
// iOS delta: becomes a real list (web renders paragraph text).
|
||
let input = "para\n\n - foo"
|
||
let output = MarkdownTransforms.disableIndentedCode(input)
|
||
#expect(output == "para\n\n - foo")
|
||
}
|
||
|
||
@Test func leavesIndentedFenceLookalikeBlockIntact() {
|
||
// A 4-space-indented ``` block stays one untouched unit.
|
||
let input = "text\n\n ```\n let x = 1\n ```"
|
||
#expect(MarkdownTransforms.disableIndentedCode(input) == input)
|
||
}
|
||
|
||
@Test func resumesDeindentingAfterIndentedFenceLookalikeCloses() {
|
||
let input = "text\n\n ```\n body\n ```\n\n tail"
|
||
let output = MarkdownTransforms.disableIndentedCode(input)
|
||
#expect(output == "text\n\n ```\n body\n ```\n\n tail")
|
||
}
|
||
|
||
// MARK: - splitTrailingCJKPunctuation
|
||
|
||
@Test func stripsTrailingFullwidthComma() {
|
||
let result = MarkdownTransforms.splitTrailingCJKPunctuation(fromAutolink: "https://example.com,")
|
||
#expect(result?.url == "https://example.com")
|
||
#expect(result?.trailing == ",")
|
||
}
|
||
|
||
@Test func stripsTrailingIdeographicFullStop() {
|
||
let result = MarkdownTransforms.splitTrailingCJKPunctuation(fromAutolink: "https://example.com/a。")
|
||
#expect(result?.url == "https://example.com/a")
|
||
#expect(result?.trailing == "。")
|
||
}
|
||
|
||
@Test func stripsMultipleTrailingCJKPunctuation() {
|
||
let result = MarkdownTransforms.splitTrailingCJKPunctuation(fromAutolink: "https://example.com。,")
|
||
#expect(result?.url == "https://example.com")
|
||
#expect(result?.trailing == "。,")
|
||
}
|
||
|
||
@Test func stripsSentenceEnderFollowedByClosingBracket() {
|
||
let result = MarkdownTransforms.splitTrailingCJKPunctuation(fromAutolink: "https://example.com/a。)")
|
||
#expect(result?.url == "https://example.com/a")
|
||
#expect(result?.trailing == "。)")
|
||
}
|
||
|
||
@Test func keepsCJKCharactersInsideThePath() {
|
||
#expect(MarkdownTransforms.splitTrailingCJKPunctuation(fromAutolink: "https://example.com/文档") == nil)
|
||
}
|
||
|
||
@Test func keepsLoneFullwidthClosingBracket() {
|
||
// Brackets alone (no sentence-ender) may be part of the URL.
|
||
#expect(MarkdownTransforms.splitTrailingCJKPunctuation(fromAutolink: "https://example.com/a)") == nil)
|
||
}
|
||
|
||
@Test func returnsNilForCleanURL() {
|
||
#expect(MarkdownTransforms.splitTrailingCJKPunctuation(fromAutolink: "https://example.com/a") == nil)
|
||
}
|
||
|
||
// MARK: - detectAutolinkRanges
|
||
|
||
@Test func detectsBareHTTPSURL() {
|
||
let text = "see https://example.com/x now"
|
||
let hits = MarkdownTransforms.detectAutolinkRanges(in: text)
|
||
#expect(hits.count == 1)
|
||
#expect(hits.first?.url == "https://example.com/x")
|
||
if let range = hits.first?.range {
|
||
#expect(String(text[range]) == "https://example.com/x")
|
||
}
|
||
}
|
||
|
||
@Test func detectsCustomSchemeURL() {
|
||
let hits = MarkdownTransforms.detectAutolinkRanges(in: "open obsidian://vault/Note here")
|
||
#expect(hits.first?.url == "obsidian://vault/Note")
|
||
}
|
||
|
||
@Test func stripsTrailingASCIIPunctuation() {
|
||
let hits = MarkdownTransforms.detectAutolinkRanges(in: "See obsidian://x.")
|
||
#expect(hits.first?.url == "obsidian://x")
|
||
}
|
||
|
||
@Test func keepsBalancedParensInURL() {
|
||
let hits = MarkdownTransforms.detectAutolinkRanges(in: "x obsidian://open?file=Note(1) y")
|
||
#expect(hits.first?.url == "obsidian://open?file=Note(1)")
|
||
}
|
||
|
||
@Test func stripsWikipediaStyleTrailingParen() {
|
||
let hits = MarkdownTransforms.detectAutolinkRanges(
|
||
in: "(https://en.wikipedia.org/wiki/Foo_(bar)) end"
|
||
)
|
||
#expect(hits.first?.url == "https://en.wikipedia.org/wiki/Foo_(bar)")
|
||
}
|
||
|
||
@Test func stripsTrailingCJKFromDetectedURL() {
|
||
// Sentence-ending punctuation right after the URL (before whitespace
|
||
// or end of text) must stay out of the link. Note: with NO whitespace
|
||
// after the punctuation, GFM swallows everything into the URL — that
|
||
// (web-matching) behavior is intentional and not stripped here.
|
||
let hits = MarkdownTransforms.detectAutolinkRanges(in: "见 https://example.com, 谢谢")
|
||
#expect(hits.count == 1)
|
||
#expect(hits.first?.url == "https://example.com")
|
||
|
||
let atEnd = MarkdownTransforms.detectAutolinkRanges(in: "见 https://example.com。")
|
||
#expect(atEnd.first?.url == "https://example.com")
|
||
}
|
||
|
||
@Test func ignoresSchemelessAndNonURIText() {
|
||
#expect(MarkdownTransforms.detectAutolinkRanges(in: "no links here").isEmpty)
|
||
#expect(MarkdownTransforms.detectAutolinkRanges(in: "javascript:alert(1)").isEmpty)
|
||
#expect(MarkdownTransforms.detectAutolinkRanges(in: "a ratio of 3://").isEmpty)
|
||
}
|
||
}
|