mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 10:55:31 -06:00
68a3d28906
* fix(chat): close disclosures in coalesced HTML Keep model-authored details closers visible to the shared disclosure contract when CommonMark absorbs them into type-6 HTML blocks across the Control UI and native apps. * refactor(apps): isolate disclosure unfolding Keep the native disclosure segmenter below its lint complexity and type-size limits, and refresh the source-owned native i18n inventory after the move. * fix(apps): align disclosure helper visibility Keep the extracted same-file helper compatible with Swift access control while leaving its implementation types hidden outside the source file. * fix(apps): align source replacement visibility Keep the source replacement helper at the same file-private boundary as the extracted disclosure unfolding types.
1302 lines
47 KiB
Swift
1302 lines
47 KiB
Swift
import Foundation
|
||
import Testing
|
||
#if canImport(UIKit)
|
||
import SwiftUI
|
||
import UIKit
|
||
#endif
|
||
@testable import OpenClawChatUI
|
||
|
||
struct ChatMarkdownBlockSegmenterTests {
|
||
private func segments(_ markdown: String, isComplete: Bool = true) -> [ChatMarkdownBlock] {
|
||
ChatMarkdownBlockSegmenter.segments(markdown: markdown, isComplete: isComplete)
|
||
}
|
||
|
||
private func item(
|
||
_ content: [ChatMarkdownListItemContent],
|
||
checkbox: ChatMarkdownListItem.Checkbox? = nil) -> ChatMarkdownListItem
|
||
{
|
||
ChatMarkdownListItem(checkbox: checkbox, content: content)
|
||
}
|
||
|
||
// MARK: - Prose
|
||
|
||
@Test func `plain prose stays one block`() {
|
||
let blocks = self.segments("alpha\nbeta\n\ngamma")
|
||
#expect(blocks == [.prose("alpha\nbeta\n\ngamma")])
|
||
}
|
||
|
||
@Test func `whitespace only input yields no blocks`() {
|
||
#expect(self.segments(" \n\n ") == [])
|
||
}
|
||
|
||
@Test func `crlf input is normalized`() {
|
||
let blocks = self.segments("alpha\r\n```\r\ncode\r\n```")
|
||
#expect(blocks == [
|
||
.prose("alpha"),
|
||
.code(ChatCodeBlock(language: nil, code: "code", isComplete: true)),
|
||
])
|
||
}
|
||
|
||
// MARK: - Disclosures
|
||
|
||
@Test @MainActor func `details fold collapsed and expanded blocks`() throws {
|
||
let collapsed = try #require(self.segments("""
|
||
<details>
|
||
<summary>**Why**</summary>
|
||
|
||
Body
|
||
|
||
</details>
|
||
""").first)
|
||
let expanded = try #require(self.segments("""
|
||
<details open>
|
||
<summary>More</summary>
|
||
|
||
Body
|
||
|
||
</details>
|
||
""").first)
|
||
|
||
#expect(collapsed == .disclosure(ChatMarkdownDisclosure(
|
||
summary: "**Why**",
|
||
isExpanded: false,
|
||
blocks: [.prose("Body")])))
|
||
#expect(expanded == .disclosure(ChatMarkdownDisclosure(
|
||
summary: "More",
|
||
isExpanded: true,
|
||
blocks: [.prose("Body")])))
|
||
|
||
let snapshot = ChatMarkdownRenderSnapshot(
|
||
text: "<details>\n<summary>**Why**</summary>\n\nBody\n\n</details>",
|
||
isComplete: true)
|
||
guard case let .disclosure(rendered) = try #require(snapshot.blocks.first) else {
|
||
Issue.record("expected rendered disclosure")
|
||
return
|
||
}
|
||
#expect(String(rendered.summary.attributed.characters) == "Why")
|
||
#expect(rendered.summary.attributed.runs.contains {
|
||
$0.inlinePresentationIntent?.contains(.stronglyEmphasized) == true
|
||
})
|
||
}
|
||
|
||
@Test func `authored Details summary does not use localized fallback`() throws {
|
||
guard case let .disclosure(disclosure) = try #require(self.segments(
|
||
"<details>\n<summary>Details</summary>\n\nBody\n\n</details>").first)
|
||
else {
|
||
Issue.record("expected disclosure")
|
||
return
|
||
}
|
||
var fallbackEvaluated = false
|
||
let rendered = chatMarkdownDisclosureSummarySource(disclosure.summary) {
|
||
fallbackEvaluated = true
|
||
return "Localized details"
|
||
}
|
||
|
||
#expect(disclosure.summary == "Details")
|
||
#expect(rendered == "Details")
|
||
#expect(!fallbackEvaluated)
|
||
}
|
||
|
||
@Test func `details without summary use localized default label`() throws {
|
||
guard case let .disclosure(disclosure) = try #require(
|
||
self.segments("<details>\n\nBody\n\n</details>").first)
|
||
else {
|
||
Issue.record("expected disclosure")
|
||
return
|
||
}
|
||
|
||
#expect(disclosure.summary == nil)
|
||
#expect(chatMarkdownDisclosureSummarySource(disclosure.summary) { "Localized details" }
|
||
== "Localized details")
|
||
}
|
||
|
||
@Test @MainActor func `empty details summary after prose uses localized default`() throws {
|
||
let snapshot = ChatMarkdownRenderSnapshot(
|
||
text: """
|
||
Intro
|
||
|
||
<details>
|
||
<summary></summary>
|
||
|
||
Body
|
||
|
||
</details>
|
||
""",
|
||
isComplete: true)
|
||
guard case let .prose(intro) = try #require(snapshot.blocks.first),
|
||
case let .disclosure(disclosure) = try #require(snapshot.blocks.dropFirst().first)
|
||
else {
|
||
Issue.record("expected intro followed by disclosure")
|
||
return
|
||
}
|
||
|
||
#expect(String(intro.attributed.characters) == "Intro")
|
||
#expect(String(disclosure.summary.attributed.characters) == String(localized: "Details"))
|
||
}
|
||
|
||
@Test func `details body keeps native list and fence blocks`() throws {
|
||
let blocks = self.segments("""
|
||
<details open>
|
||
<summary>Why</summary>
|
||
|
||
- first
|
||
- second
|
||
|
||
```swift
|
||
let value = 1
|
||
```
|
||
|
||
</details>
|
||
""")
|
||
guard case let .disclosure(disclosure) = try #require(blocks.first) else {
|
||
Issue.record("expected disclosure block")
|
||
return
|
||
}
|
||
|
||
#expect(disclosure.blocks.count == 2)
|
||
guard case let .list(list) = disclosure.blocks[0] else {
|
||
Issue.record("expected native list in disclosure")
|
||
return
|
||
}
|
||
#expect(list.items.count == 2)
|
||
#expect(disclosure.blocks[1] == .code(ChatCodeBlock(
|
||
language: "swift",
|
||
code: "let value = 1",
|
||
isComplete: true)))
|
||
}
|
||
|
||
@Test func `type 6 HTML block cannot absorb the details closer`() {
|
||
#expect(self.segments("""
|
||
<details>
|
||
<summary>X</summary>
|
||
|
||
<div>body</div>
|
||
</details>
|
||
|
||
Following
|
||
""") == [
|
||
.disclosure(ChatMarkdownDisclosure(
|
||
summary: "X",
|
||
isExpanded: false,
|
||
blocks: [.prose("<div>body</div>")])),
|
||
.prose("Following"),
|
||
])
|
||
}
|
||
|
||
@Test @MainActor func `details preserve document scoped reference links`() throws {
|
||
let destination = try #require(URL(string: "https://example.com"))
|
||
let snapshot = ChatMarkdownRenderSnapshot(
|
||
text: """
|
||
[docs][id]
|
||
|
||
<details>
|
||
<summary>More</summary>
|
||
|
||
Body
|
||
|
||
</details>
|
||
|
||
[id]: https://example.com
|
||
""",
|
||
isComplete: true)
|
||
guard case let .prose(prose) = try #require(snapshot.blocks.first),
|
||
case .disclosure = try #require(snapshot.blocks.dropFirst().first)
|
||
else {
|
||
Issue.record("expected resolved prose followed by a disclosure")
|
||
return
|
||
}
|
||
#expect(prose.attributed.runs.contains { $0.link == destination })
|
||
|
||
let nestedSnapshot = ChatMarkdownRenderSnapshot(
|
||
text: """
|
||
<details>
|
||
<summary>More</summary>
|
||
|
||
[docs][id]
|
||
|
||
[id]: https://example.com
|
||
|
||
</details>
|
||
""",
|
||
isComplete: true)
|
||
guard case let .disclosure(disclosure) = try #require(nestedSnapshot.blocks.first),
|
||
case let .prose(nestedProse) = try #require(disclosure.blocks.first)
|
||
else {
|
||
Issue.record("expected resolved prose inside the disclosure")
|
||
return
|
||
}
|
||
#expect(nestedProse.attributed.runs.contains { $0.link == destination })
|
||
}
|
||
|
||
@Test func `unsupported nested details balance without closing outer disclosure`() throws {
|
||
let blocks = self.segments("""
|
||
<details>
|
||
<summary>Outer</summary>
|
||
|
||
<details class="legacy">
|
||
|
||
unsupported body
|
||
|
||
</details>
|
||
|
||
still outer
|
||
|
||
</details>
|
||
""")
|
||
guard case let .disclosure(outer) = try #require(blocks.first) else {
|
||
Issue.record("expected outer disclosure")
|
||
return
|
||
}
|
||
|
||
#expect(blocks.count == 1)
|
||
#expect(outer.blocks.contains(.prose(#"<details class="legacy">"#)))
|
||
#expect(outer.blocks.contains(.prose("</details>")))
|
||
#expect(outer.blocks.contains(.prose("still outer")))
|
||
}
|
||
|
||
@Test func `details tags in code stay literal`() {
|
||
let fenced = "```html\n<details>\n<summary>Code</summary>\n</details>\n```"
|
||
#expect(self.segments(fenced) == [
|
||
.code(ChatCodeBlock(
|
||
language: "html",
|
||
code: "<details>\n<summary>Code</summary>\n</details>",
|
||
isComplete: true)),
|
||
])
|
||
#expect(self.segments("`<details>`") == [.prose("`<details>`")])
|
||
}
|
||
|
||
@Test func `details in raw HTML contexts stay literal`() {
|
||
let commented = """
|
||
<!--
|
||
<details>
|
||
<summary>Example</summary>
|
||
</details>
|
||
-->
|
||
"""
|
||
let preformatted = """
|
||
<pre>
|
||
<details>
|
||
<summary>Example</summary>
|
||
</details>
|
||
</pre>
|
||
"""
|
||
|
||
#expect(self.segments(commented) == [.prose(commented)])
|
||
#expect(self.segments(preformatted) == [.prose(preformatted)])
|
||
}
|
||
|
||
@Test func `details after an HTML comment still fold`() {
|
||
let comment = """
|
||
<!--
|
||
<details>
|
||
</details>
|
||
-->
|
||
"""
|
||
#expect(self.segments("""
|
||
\(comment)
|
||
<details>
|
||
<summary>After</summary>
|
||
|
||
Body
|
||
|
||
</details>
|
||
""") == [
|
||
.prose(comment),
|
||
.disclosure(ChatMarkdownDisclosure(
|
||
summary: "After",
|
||
isExpanded: false,
|
||
blocks: [.prose("Body")])),
|
||
])
|
||
}
|
||
|
||
@Test func `raw HTML close markers inside details stay literal`() throws {
|
||
for (markdown, literal) in [
|
||
(
|
||
"<details>\n<summary>X</summary>\n<pre>\n</details>\n</pre>\n</details>",
|
||
"<pre>\n</details>\n</pre>"),
|
||
(
|
||
"<details>\n<summary>X</summary>\n<!--\n</details>\n-->\n</details>",
|
||
"<!--\n</details>\n-->"),
|
||
(
|
||
"<details>\n<summary>X</summary>\n<?pi\n<details>\n?>\n</details>",
|
||
"<?pi\n<details>\n?>"),
|
||
(
|
||
"<details>\n<summary>X</summary>\n<![CDATA[\n<details>\n]]>\n</details>",
|
||
"<![CDATA[\n<details>\n]]>"),
|
||
(
|
||
"<details>\n<summary>X</summary>\n<!DOCTYPE\n<details>\n</details>",
|
||
"<!DOCTYPE\n<details>"),
|
||
] {
|
||
let blocks = self.segments(markdown)
|
||
guard blocks.count == 1,
|
||
case let .disclosure(disclosure) = try #require(blocks.first)
|
||
else {
|
||
Issue.record("expected one outer disclosure")
|
||
continue
|
||
}
|
||
#expect(disclosure.blocks == [.prose(literal)])
|
||
}
|
||
}
|
||
|
||
@Test func `mid line and over indented details stay literal`() {
|
||
#expect(self.segments("before <details> after") == [.prose("before <details> after")])
|
||
#expect(self.segments(" <details>\n body\n </details>") == [
|
||
.prose(" <details>\n body\n </details>"),
|
||
])
|
||
}
|
||
|
||
@Test func `unclosed streaming details fold available body`() {
|
||
#expect(self.segments(
|
||
"<details open>\n<summary>Progress</summary>\n\n- first",
|
||
isComplete: false) == [
|
||
.disclosure(ChatMarkdownDisclosure(
|
||
summary: "Progress",
|
||
isExpanded: true,
|
||
blocks: [.prose("- first")])),
|
||
])
|
||
}
|
||
|
||
@Test func `details nesting stops at depth cap`() {
|
||
let depth = ChatMarkdownBlockSegmenter.maxDisclosureDepth + 1
|
||
let markdown = Array(repeating: "<details>", count: depth).joined(separator: "\n")
|
||
+ "\nbody\n"
|
||
+ Array(repeating: "</details>", count: depth).joined(separator: "\n")
|
||
var blocks = self.segments(markdown)
|
||
var structuralDepth = 0
|
||
while case let .disclosure(disclosure)? = blocks.first {
|
||
structuralDepth += 1
|
||
blocks = disclosure.blocks
|
||
}
|
||
|
||
#expect(structuralDepth == ChatMarkdownBlockSegmenter.maxDisclosureDepth)
|
||
#expect(blocks.contains(.prose("<details>")))
|
||
#expect(blocks.contains(.prose("</details>")))
|
||
}
|
||
|
||
// MARK: - Headings
|
||
|
||
@Test func `all ATX heading levels become native blocks`() {
|
||
for level in 1...6 {
|
||
let markdown = "\(String(repeating: "#", count: level)) Heading \(level)"
|
||
#expect(self.segments(markdown) == [
|
||
.heading(ChatMarkdownHeading(level: level, markdown: markdown)),
|
||
])
|
||
}
|
||
}
|
||
|
||
@Test func `Setext headings preserve their complete source`() {
|
||
#expect(self.segments("Primary\n=======") == [
|
||
.heading(ChatMarkdownHeading(level: 1, markdown: "Primary\n=======")),
|
||
])
|
||
#expect(self.segments("Secondary\n---------") == [
|
||
.heading(ChatMarkdownHeading(level: 2, markdown: "Secondary\n---------")),
|
||
])
|
||
}
|
||
|
||
@Test func `streaming ATX heading keeps the current source`() {
|
||
let markdown = "### Streamed heading"
|
||
#expect(self.segments(markdown, isComplete: false) == [
|
||
.heading(ChatMarkdownHeading(level: 3, markdown: markdown)),
|
||
])
|
||
}
|
||
|
||
@Test func `heading keeps inline markdown source and surrounding prose`() {
|
||
let heading = "## **Status** with `code` and [docs](https://example.com) ##"
|
||
#expect(self.segments("before\n\n\(heading)\n\nafter") == [
|
||
.prose("before"),
|
||
.heading(ChatMarkdownHeading(level: 2, markdown: heading)),
|
||
.prose("after"),
|
||
])
|
||
}
|
||
|
||
@Test func `heading nested in block quote stays prose`() {
|
||
let markdown = "> # Quoted"
|
||
#expect(self.segments(markdown) == [.prose(markdown)])
|
||
}
|
||
|
||
@Test func `heading nested in list stays attached to its item`() {
|
||
#expect(self.segments("- # Listed") == [
|
||
.list(ChatMarkdownList(
|
||
kind: .unordered,
|
||
items: [self.item([.markdown("# Listed")])])),
|
||
])
|
||
}
|
||
|
||
@Test func `reference heading keeps document scoped definition`() {
|
||
let markdown = "# [Docs][docs]\n\n[docs]: https://example.com"
|
||
#expect(self.segments(markdown) == [.prose(markdown)])
|
||
}
|
||
|
||
@Test func `heading composes with an unchanged native table`() {
|
||
let blocks = self.segments("# Results\n\n| Name | Count |\n| --- | ---: |\n| Claw | 2 |")
|
||
#expect(blocks == [
|
||
.heading(ChatMarkdownHeading(level: 1, markdown: "# Results")),
|
||
.table(ChatMarkdownTable(
|
||
header: ["Name", "Count"],
|
||
alignments: [.leading, .trailing],
|
||
rows: [["Claw", "2"]])),
|
||
])
|
||
}
|
||
|
||
@Test @MainActor func `render snapshot preserves heading inline attributes`() throws {
|
||
let snapshot = ChatMarkdownRenderSnapshot(
|
||
text: "## **Status** and [docs](https://example.com)",
|
||
isComplete: true)
|
||
guard case let .heading(level, prose) = try #require(snapshot.blocks.first) else {
|
||
Issue.record("expected heading block")
|
||
return
|
||
}
|
||
|
||
#expect(level == 2)
|
||
#expect(String(prose.attributed.characters) == "Status and docs")
|
||
#expect(prose.attributed.runs.contains { $0.link != nil })
|
||
#expect(prose.attributed.runs.contains {
|
||
$0.inlinePresentationIntent?.contains(.stronglyEmphasized) == true
|
||
})
|
||
}
|
||
|
||
// MARK: - Fenced code
|
||
|
||
@Test func `fence with language and surrounding prose`() {
|
||
let blocks = self.segments("""
|
||
before
|
||
```swift
|
||
let x = 1
|
||
```
|
||
after
|
||
""")
|
||
#expect(blocks == [
|
||
.prose("before"),
|
||
.code(ChatCodeBlock(language: "swift", code: "let x = 1", isComplete: true)),
|
||
.prose("after"),
|
||
])
|
||
}
|
||
|
||
@Test func `info string extras keep only the first word lowercased`() {
|
||
let blocks = self.segments("```Swift title=Example.swift\nlet x = 1\n```")
|
||
#expect(blocks == [
|
||
.code(ChatCodeBlock(language: "swift", code: "let x = 1", isComplete: true)),
|
||
])
|
||
}
|
||
|
||
@Test func `backtick info string containing backtick is not a fence`() {
|
||
// CommonMark: ``` foo`bar ``` is an inline code span, not a fence.
|
||
let blocks = self.segments("``` foo`bar ```")
|
||
#expect(blocks == [.prose("``` foo`bar ```")])
|
||
}
|
||
|
||
@Test func `tilde fence keeps nested backtick fences as content`() {
|
||
let blocks = self.segments("""
|
||
~~~markdown
|
||
```swift
|
||
let x = 1
|
||
```
|
||
~~~
|
||
""")
|
||
#expect(blocks == [
|
||
.code(ChatCodeBlock(
|
||
language: "markdown",
|
||
code: "```swift\nlet x = 1\n```",
|
||
isComplete: true)),
|
||
])
|
||
}
|
||
|
||
@Test func `shorter close run does not close the fence`() {
|
||
let blocks = self.segments("````\n```\ncode\n````")
|
||
#expect(blocks == [
|
||
.code(ChatCodeBlock(language: nil, code: "```\ncode", isComplete: true)),
|
||
])
|
||
}
|
||
|
||
@Test func `longer close run closes the fence`() {
|
||
let blocks = self.segments("```\ncode\n`````")
|
||
#expect(blocks == [
|
||
.code(ChatCodeBlock(language: nil, code: "code", isComplete: true)),
|
||
])
|
||
}
|
||
|
||
@Test func `close line with trailing text stays content`() {
|
||
let blocks = self.segments("```text\n``` not a close\nstill code\n```")
|
||
#expect(blocks == [
|
||
.code(ChatCodeBlock(
|
||
language: "text",
|
||
code: "``` not a close\nstill code",
|
||
isComplete: true)),
|
||
])
|
||
}
|
||
|
||
@Test func `top level indented fence remains native`() {
|
||
let blocks = self.segments(" ```\n code\n ```")
|
||
#expect(blocks == [
|
||
.code(ChatCodeBlock(language: nil, code: " code", isComplete: true)),
|
||
])
|
||
}
|
||
|
||
@Test func `four space indent is not a fence`() {
|
||
let markdown = " ```\n code"
|
||
#expect(self.segments(markdown) == [.prose(markdown)])
|
||
}
|
||
|
||
@Test func `unclosed fence in complete message renders as code`() {
|
||
let blocks = self.segments("```swift\nlet x = 1")
|
||
#expect(blocks == [
|
||
.code(ChatCodeBlock(language: "swift", code: "let x = 1", isComplete: true)),
|
||
])
|
||
}
|
||
|
||
// MARK: - Display math
|
||
|
||
@Test func `same line dollar delimiters extract display math`() {
|
||
let blocks = self.segments("before\n$$ x^2 + y^2 $$\nafter")
|
||
#expect(blocks == [
|
||
.prose("before"),
|
||
.math(ChatMathBlock(latex: "x^2 + y^2", isComplete: true)),
|
||
.prose("after"),
|
||
])
|
||
}
|
||
|
||
@Test func `own line dollar delimiters extract multiline display math`() {
|
||
let blocks = self.segments("""
|
||
$$
|
||
\\begin{aligned}
|
||
x &= 1 \\\\
|
||
y &= 2
|
||
\\end{aligned}
|
||
$$
|
||
""")
|
||
#expect(blocks == [
|
||
.math(ChatMathBlock(
|
||
latex: "\\begin{aligned}\nx &= 1 \\\\\ny &= 2\n\\end{aligned}",
|
||
isComplete: true)),
|
||
])
|
||
}
|
||
|
||
@Test func `bracket delimiters extract display math`() {
|
||
let blocks = self.segments(#"\[\frac{a}{b}\]"#)
|
||
#expect(blocks == [
|
||
.math(ChatMathBlock(latex: #"\frac{a}{b}"#, isComplete: true)),
|
||
])
|
||
}
|
||
|
||
@Test func `inline math delimiters stay prose`() {
|
||
let markdown = #"single $x$, parenthesized \(y\), and prose around $$z$$"#
|
||
#expect(self.segments(markdown) == [.prose(markdown)])
|
||
}
|
||
|
||
@Test func `unclosed math while streaming stays prose`() {
|
||
let markdown = "$$\nx + y"
|
||
#expect(self.segments(markdown, isComplete: false) == [.prose(markdown)])
|
||
}
|
||
|
||
@Test func `unclosed math in complete message renders as math`() {
|
||
let blocks = self.segments("$$\nx + y")
|
||
#expect(blocks == [
|
||
.math(ChatMathBlock(latex: "x + y", isComplete: true)),
|
||
])
|
||
}
|
||
|
||
@Test func `math composes with prose and fenced code blocks`() {
|
||
let blocks = self.segments("before\n$$E = mc^2$$\n```swift\nlet value = 1\n```\nafter")
|
||
#expect(blocks == [
|
||
.prose("before"),
|
||
.math(ChatMathBlock(latex: "E = mc^2", isComplete: true)),
|
||
.code(ChatCodeBlock(language: "swift", code: "let value = 1", isComplete: true)),
|
||
.prose("after"),
|
||
])
|
||
}
|
||
|
||
@Test func `oversized math stays raw prose`() {
|
||
let latex = String(repeating: "x", count: ChatMarkdownBlockSegmenter.maxMathBytes + 1)
|
||
let markdown = "$$\n\(latex)\n$$"
|
||
#expect(self.segments(markdown) == [.prose(markdown)])
|
||
}
|
||
|
||
@Test func `oversized math keeps nested code fence in prose`() {
|
||
let latex = String(repeating: "x", count: ChatMarkdownBlockSegmenter.maxMathBytes + 1)
|
||
let markdown = "$$\n\(latex)\n```swift\nlet value = 1\n```\n$$"
|
||
#expect(self.segments(markdown) == [.prose(markdown)])
|
||
}
|
||
|
||
@Test func `math delimiters inside code fences stay code`() {
|
||
let code = "$x$\n$$\nx + y\n$$\n\\[z\\]"
|
||
let blocks = self.segments("```tex\n\(code)\n```")
|
||
#expect(blocks == [
|
||
.code(ChatCodeBlock(language: "tex", code: code, isComplete: true)),
|
||
])
|
||
}
|
||
|
||
@Test func `math delimiters inside multiline code span stay prose`() {
|
||
let markdown = "`literal\n$$ x + y $$\nend`"
|
||
#expect(self.segments(markdown) == [.prose(markdown)])
|
||
}
|
||
|
||
@Test func `math delimiters inside list stay attached to their item`() {
|
||
#expect(self.segments("- item\n $$x + y$$") == [
|
||
.list(ChatMarkdownList(
|
||
kind: .unordered,
|
||
items: [self.item([.markdown("item\n$$x + y$$")])])),
|
||
])
|
||
}
|
||
|
||
// MARK: - Lists and thematic breaks
|
||
|
||
@Test func `issue reproduction renders ordered options as a native list`() {
|
||
let markdown = """
|
||
Here are the options:
|
||
|
||
**My read of each:**
|
||
|
||
1. **Option one heading** – a sentence describing it.
|
||
2. **Option two heading** – another sentence.
|
||
3. **Option three heading** – my pick, one more sentence.
|
||
"""
|
||
#expect(self.segments(markdown) == [
|
||
.prose("Here are the options:\n\n**My read of each:**"),
|
||
.list(ChatMarkdownList(
|
||
kind: .ordered(start: 1),
|
||
items: [
|
||
self.item([.markdown("**Option one heading** – a sentence describing it.")]),
|
||
self.item([.markdown("**Option two heading** – another sentence.")]),
|
||
self.item([.markdown("**Option three heading** – my pick, one more sentence.")]),
|
||
])),
|
||
])
|
||
}
|
||
|
||
@Test func `ordered list keeps its parsed start index`() {
|
||
#expect(self.segments("7. seven\n8. eight") == [
|
||
.list(ChatMarkdownList(
|
||
kind: .ordered(start: 7),
|
||
items: [
|
||
self.item([.markdown("seven")]),
|
||
self.item([.markdown("eight")]),
|
||
])),
|
||
])
|
||
}
|
||
|
||
@Test func `ordered task list keeps both number and checkbox markers`() throws {
|
||
guard case let .list(list) = try #require(self.segments("3. [ ] Pending\n4. [x] Done").first) else {
|
||
Issue.record("expected list block")
|
||
return
|
||
}
|
||
|
||
#expect(list.marker(for: list.items[0], at: 0) == ChatMarkdownListMarker(
|
||
text: "3.",
|
||
checkbox: .unchecked))
|
||
#expect(list.marker(for: list.items[1], at: 1) == ChatMarkdownListMarker(
|
||
text: "4.",
|
||
checkbox: .checked))
|
||
}
|
||
|
||
@Test func `nested and task lists preserve structure and state`() {
|
||
#expect(self.segments("- Parent\n 1. Child one\n 2. Child two\n- [x] Done\n- [ ] Pending") == [
|
||
.list(ChatMarkdownList(
|
||
kind: .unordered,
|
||
items: [
|
||
self.item([
|
||
.markdown("Parent"),
|
||
.list(ChatMarkdownList(
|
||
kind: .ordered(start: 1),
|
||
items: [
|
||
self.item([.markdown("Child one")]),
|
||
self.item([.markdown("Child two")]),
|
||
])),
|
||
]),
|
||
self.item([.markdown("Done")], checkbox: .checked),
|
||
self.item([.markdown("Pending")], checkbox: .unchecked),
|
||
])),
|
||
])
|
||
}
|
||
|
||
@Test func `list reference links preserve their document scoped definitions`() {
|
||
for markdown in [
|
||
"- [Docs][docs]\n\n[docs]: https://example.com",
|
||
"- [Docs][docs]\n\n [docs]: https://example.com",
|
||
] {
|
||
#expect(self.segments(markdown) == [.prose(markdown)])
|
||
}
|
||
}
|
||
|
||
@Test func `tab indented list fence keeps its structure`() {
|
||
#expect(self.segments("- item\n\t```swift\n\tlet value = 1\n\t```") == [
|
||
.list(ChatMarkdownList(
|
||
kind: .unordered,
|
||
items: [self.item([
|
||
.markdown("item"),
|
||
.code(ChatCodeBlock(language: "swift", code: " let value = 1", isComplete: true)),
|
||
])])),
|
||
])
|
||
}
|
||
|
||
@Test func `tab indentation keeps columns beyond the list prefix`() {
|
||
#expect(self.segments("- item\n ```\n\tindented\n ```") == [
|
||
.list(ChatMarkdownList(
|
||
kind: .unordered,
|
||
items: [self.item([
|
||
.markdown("item"),
|
||
.code(ChatCodeBlock(language: nil, code: " indented", isComplete: true)),
|
||
])])),
|
||
])
|
||
}
|
||
|
||
@Test func `indented code block in a list stays code`() {
|
||
#expect(self.segments("- item\n\n code") == [
|
||
.list(ChatMarkdownList(
|
||
kind: .unordered,
|
||
items: [self.item([
|
||
.markdown("item"),
|
||
.code(ChatCodeBlock(language: nil, code: "code", isComplete: true)),
|
||
])])),
|
||
])
|
||
}
|
||
|
||
@Test func `top level thematic break becomes a dedicated block`() {
|
||
#expect(self.segments("before\n\n---\n\nafter") == [
|
||
.prose("before"),
|
||
.thematicBreak,
|
||
.prose("after"),
|
||
])
|
||
}
|
||
|
||
@Test func `thematic break does not consume immediately following prose`() {
|
||
#expect(self.segments("---\nafter") == [
|
||
.thematicBreak,
|
||
.prose("after"),
|
||
])
|
||
}
|
||
|
||
@Test func `list does not consume an adjacent top level heading`() {
|
||
#expect(self.segments("- item\n# Heading") == [
|
||
.list(ChatMarkdownList(
|
||
kind: .unordered,
|
||
items: [self.item([.markdown("item")])])),
|
||
.heading(ChatMarkdownHeading(level: 1, markdown: "# Heading")),
|
||
])
|
||
}
|
||
|
||
@Test func `Setext underline is not mistaken for a thematic break`() {
|
||
#expect(self.segments("Heading\n---") == [
|
||
.heading(ChatMarkdownHeading(level: 2, markdown: "Heading\n---")),
|
||
])
|
||
}
|
||
|
||
@Test func `trailing list and thematic break stay prose while streaming`() {
|
||
for markdown in ["- streamed item", "---"] {
|
||
#expect(self.segments(markdown, isComplete: false) == [.prose(markdown)])
|
||
}
|
||
}
|
||
|
||
@Test func `lists stay prose while streaming and settled thematic breaks render`() {
|
||
#expect(self.segments("- settled\n\nafter", isComplete: false) == [
|
||
.prose("- settled\n\nafter"),
|
||
])
|
||
#expect(self.segments("---\n\nafter", isComplete: false) == [
|
||
.thematicBreak,
|
||
.prose("after"),
|
||
])
|
||
}
|
||
|
||
@Test @MainActor func `streaming list stays one revealable prose block`() {
|
||
let snapshot = ChatMarkdownRenderSnapshot(
|
||
text: "- first\n- second\n\nafter",
|
||
isComplete: false,
|
||
preparesReveal: true)
|
||
#expect(snapshot.blocks.count == 1)
|
||
#expect(snapshot.lastProseIndex == 0)
|
||
guard case .prose = snapshot.blocks[0] else {
|
||
Issue.record("expected streaming list to remain prose")
|
||
return
|
||
}
|
||
}
|
||
|
||
@Test func `oversized and overdeep lists stay raw prose`() {
|
||
let oversized = Array(
|
||
repeating: "- item",
|
||
count: ChatMarkdownBlockSegmenter.maxListItems + 1).joined(separator: "\n")
|
||
#expect(self.segments(oversized) == [.prose(oversized)])
|
||
|
||
let overdeep = (0...ChatMarkdownBlockSegmenter.maxListDepth)
|
||
.map { String(repeating: " ", count: $0) + "- level \($0)" }
|
||
.joined(separator: "\n")
|
||
#expect(self.segments(overdeep) == [.prose(overdeep)])
|
||
}
|
||
|
||
@Test @MainActor func `render snapshot exposes native list and thematic blocks`() {
|
||
let snapshot = ChatMarkdownRenderSnapshot(
|
||
text: "Intro\n\n- **First**\n- Second\n\n---",
|
||
isComplete: true)
|
||
#expect(snapshot.blocks.count == 3)
|
||
guard case let .list(list) = snapshot.blocks[1] else {
|
||
Issue.record("expected list block")
|
||
return
|
||
}
|
||
#expect(list.items.count == 2)
|
||
guard case .thematicBreak = snapshot.blocks[2] else {
|
||
Issue.record("expected thematic break block")
|
||
return
|
||
}
|
||
}
|
||
|
||
@Test @MainActor func `list recursion preserves inline math typography`() throws {
|
||
let snapshot = ChatMarkdownRenderSnapshot(
|
||
text: "- Parent \\(x\\)\n - Child \\(y\\)",
|
||
isComplete: true)
|
||
guard case let .list(list) = try #require(snapshot.blocks.first),
|
||
case let .list(nestedList) = try #require(list.items.first?.content.last)
|
||
else {
|
||
Issue.record("expected nested list block")
|
||
return
|
||
}
|
||
|
||
let renderer = ChatMarkdownRenderer(
|
||
snapshot: snapshot,
|
||
context: .assistant,
|
||
variant: .standard,
|
||
typography: .thinking,
|
||
textColor: OpenClawChatTheme.assistantText)
|
||
let listView = renderer.listView(list)
|
||
let nestedListView = listView.nestedListView(nestedList)
|
||
|
||
#expect(renderer.typography == .thinking)
|
||
#expect(listView.typography == .thinking)
|
||
#expect(listView.markdownRenderer("Parent \\(x\\)").typography == .thinking)
|
||
#expect(nestedListView.typography == .thinking)
|
||
#expect(nestedListView.markdownRenderer("Child \\(y\\)").typography == .thinking)
|
||
}
|
||
|
||
#if canImport(UIKit)
|
||
@MainActor private func fittedHeight(
|
||
_ view: some View,
|
||
width: CGFloat,
|
||
maximumHeight: CGFloat = 10000) -> CGFloat
|
||
{
|
||
let controller = UIHostingController(rootView: view)
|
||
return controller.sizeThatFits(in: CGSize(width: width, height: maximumHeight)).height
|
||
}
|
||
|
||
@Test @MainActor func `native list renderer builds on iOS across appearance and type size`() {
|
||
let markdown = """
|
||
9. **First** option
|
||
- Nested detail
|
||
- [x] Completed detail
|
||
10. Second option
|
||
|
||
---
|
||
"""
|
||
var windows: [UIWindow] = []
|
||
defer { windows.forEach { $0.isHidden = true } }
|
||
|
||
for scheme in [ColorScheme.light, .dark] {
|
||
let root = ChatMarkdownRenderer(
|
||
text: markdown,
|
||
context: .assistant,
|
||
variant: .standard,
|
||
textColor: OpenClawChatTheme.assistantText)
|
||
.environment(\.dynamicTypeSize, .accessibility2)
|
||
.preferredColorScheme(scheme)
|
||
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 700))
|
||
window.rootViewController = UIHostingController(rootView: root)
|
||
window.makeKeyAndVisible()
|
||
window.rootViewController?.view.layoutIfNeeded()
|
||
windows.append(window)
|
||
}
|
||
}
|
||
|
||
@Test @MainActor func `native list items reject compressed multiline heights`() {
|
||
let samples = [
|
||
"The issue is not posted by an official OpenClaw organization account or a separate assistant account, so the complete author identity guidance must remain visible to the reader.",
|
||
"공개 이슈의 작성자에는 선택한 계정 이름이 표시되므로 회사 계정보다 개인 GitHub 계정을 사용하고 비밀번호를 공유하지 않아도 된다는 전체 안내가 화면에 보여야 합니다.",
|
||
]
|
||
|
||
for width in [CGFloat(320), 768] {
|
||
for typeSize in [DynamicTypeSize.large, .accessibility2] {
|
||
for sample in samples {
|
||
let prose = ChatMarkdownRenderer(
|
||
text: sample,
|
||
context: .assistant,
|
||
variant: .standard,
|
||
textColor: OpenClawChatTheme.assistantText)
|
||
.environment(\.dynamicTypeSize, typeSize)
|
||
let list = ChatMarkdownRenderer(
|
||
text: "- \(sample)",
|
||
context: .assistant,
|
||
variant: .standard,
|
||
textColor: OpenClawChatTheme.assistantText)
|
||
.environment(\.dynamicTypeSize, typeSize)
|
||
|
||
let proseHeight = self.fittedHeight(prose, width: width)
|
||
let listHeight = self.fittedHeight(
|
||
list,
|
||
width: width,
|
||
maximumHeight: proseHeight / 2)
|
||
#expect(listHeight >= proseHeight)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
#endif
|
||
|
||
@Test func `unclosed streaming math leaves later opener lines as prose`() {
|
||
let markdown = "\\[\nfirst\n\\[\nsecond"
|
||
#expect(self.segments(markdown, isComplete: false) == [.prose(markdown)])
|
||
}
|
||
|
||
@Test func `unclosed streaming math keeps later code fence in prose`() {
|
||
let markdown = "before\n$$\nx + y\n```swift\nlet value = 1\n```"
|
||
#expect(self.segments(markdown, isComplete: false) == [.prose(markdown)])
|
||
}
|
||
|
||
// MARK: - Streaming fallbacks
|
||
|
||
@Test func `unclosed fence while streaming stays plain`() {
|
||
let blocks = self.segments("```swift\nlet x = 1", isComplete: false)
|
||
#expect(blocks == [
|
||
.code(ChatCodeBlock(language: "swift", code: "let x = 1", isComplete: false)),
|
||
])
|
||
}
|
||
|
||
@Test func `closed fence while streaming is complete`() {
|
||
let blocks = self.segments("```swift\nlet x = 1\n```\nmore", isComplete: false)
|
||
#expect(blocks == [
|
||
.code(ChatCodeBlock(language: "swift", code: "let x = 1", isComplete: true)),
|
||
.prose("more"),
|
||
])
|
||
}
|
||
|
||
@Test func `trailing table while streaming stays prose`() {
|
||
let markdown = "intro\n| a | b |\n| - | - |\n| 1 | 2 |"
|
||
#expect(self.segments(markdown, isComplete: false) == [.prose(markdown)])
|
||
}
|
||
|
||
@Test func `trailing table with only trailing newline while streaming stays prose`() {
|
||
// The trailing newline is not a committed blank line: the next delta
|
||
// may still append rows, so the table must not render rich yet.
|
||
let markdown = "| a | b |\n| - | - |\n| 1 | 2 |\n"
|
||
#expect(self.segments(markdown, isComplete: false) == [
|
||
.prose("| a | b |\n| - | - |\n| 1 | 2 |"),
|
||
])
|
||
}
|
||
|
||
@Test func `settled table while streaming renders as table`() {
|
||
let blocks = self.segments("| a | b |\n| - | - |\n| 1 | 2 |\n\nafter", isComplete: false)
|
||
#expect(blocks == [
|
||
.table(ChatMarkdownTable(
|
||
header: ["a", "b"],
|
||
alignments: [.leading, .leading],
|
||
rows: [["1", "2"]])),
|
||
.prose("after"),
|
||
])
|
||
}
|
||
|
||
// MARK: - Tables
|
||
|
||
@Test func `table with alignments and body rows`() {
|
||
let blocks = self.segments("""
|
||
| Name | Count | Price |
|
||
| :--- | :---: | ----: |
|
||
| a | 1 | 2.50 |
|
||
| b | 2 | 3.00 |
|
||
""")
|
||
#expect(blocks == [
|
||
.table(ChatMarkdownTable(
|
||
header: ["Name", "Count", "Price"],
|
||
alignments: [.leading, .center, .trailing],
|
||
rows: [["a", "1", "2.50"], ["b", "2", "3.00"]])),
|
||
])
|
||
}
|
||
|
||
@Test func `table without boundary pipes`() {
|
||
let blocks = self.segments("a | b\n--- | ---\n1 | 2")
|
||
#expect(blocks == [
|
||
.table(ChatMarkdownTable(
|
||
header: ["a", "b"],
|
||
alignments: [.leading, .leading],
|
||
rows: [["1", "2"]])),
|
||
])
|
||
}
|
||
|
||
@Test func `top level indented table remains native`() {
|
||
let blocks = self.segments(" | a | b |\n | - | - |\n | 1 | 2 |")
|
||
#expect(blocks == [
|
||
.table(ChatMarkdownTable(
|
||
header: ["a", "b"],
|
||
alignments: [.leading, .leading],
|
||
rows: [["1", "2"]])),
|
||
])
|
||
}
|
||
|
||
@Test func `list nested table stays attached to its item`() {
|
||
#expect(self.segments("- item\n | a | b |\n | - | - |\n | 1 | 2 |") == [
|
||
.list(ChatMarkdownList(
|
||
kind: .unordered,
|
||
items: [self.item([.markdown("item\n| a | b |\n| - | - |\n| 1 | 2 |")])])),
|
||
])
|
||
}
|
||
|
||
@Test func `escaped pipe stays a literal cell character`() {
|
||
let blocks = self.segments("| a\\|b | c |\n| - | - |\n| 1 | 2 |")
|
||
#expect(blocks == [
|
||
.table(ChatMarkdownTable(
|
||
header: ["a|b", "c"],
|
||
alignments: [.leading, .leading],
|
||
rows: [["1", "2"]])),
|
||
])
|
||
}
|
||
|
||
@Test func `short rows pad and long rows truncate to header width`() {
|
||
let blocks = self.segments("| a | b |\n| - | - |\n| 1 |\n| 1 | 2 | 3 |")
|
||
#expect(blocks == [
|
||
.table(ChatMarkdownTable(
|
||
header: ["a", "b"],
|
||
alignments: [.leading, .leading],
|
||
rows: [["1", ""], ["1", "2"]])),
|
||
])
|
||
}
|
||
|
||
@Test func `adjacent pipes remain an empty gfm cell`() {
|
||
let blocks = self.segments("| a | b | c |\n| - | - | - |\n| 1 || 3 |")
|
||
#expect(blocks == [
|
||
.table(ChatMarkdownTable(
|
||
header: ["a", "b", "c"],
|
||
alignments: [.leading, .leading, .leading],
|
||
rows: [["1", "", "3"]])),
|
||
])
|
||
}
|
||
|
||
@Test func `caret remains a literal gfm cell`() {
|
||
let blocks = self.segments("| a | b |\n| - | - |\n| ^ | value |")
|
||
#expect(blocks == [
|
||
.table(ChatMarkdownTable(
|
||
header: ["a", "b"],
|
||
alignments: [.leading, .leading],
|
||
rows: [["^", "value"]])),
|
||
])
|
||
}
|
||
|
||
@Test func `one cell body row without pipes is padded`() {
|
||
let blocks = self.segments("| a | b |\n| - | - |\nonly one cell")
|
||
#expect(blocks == [
|
||
.table(ChatMarkdownTable(
|
||
header: ["a", "b"],
|
||
alignments: [.leading, .leading],
|
||
rows: [["only one cell", ""]])),
|
||
])
|
||
}
|
||
|
||
@Test func `setext underline and link definition text remain body rows`() {
|
||
let blocks = self.segments("| a | b |\n| - | - |\n===\n[foo]: /url\n[foo]")
|
||
#expect(blocks == [
|
||
.table(ChatMarkdownTable(
|
||
header: ["a", "b"],
|
||
alignments: [.leading, .leading],
|
||
rows: [["===", ""], ["[foo]: /url", ""], ["[foo]", ""]])),
|
||
])
|
||
}
|
||
|
||
@Test func `table body stops at blank line`() {
|
||
let blocks = self.segments("| a |b|\n| - |-|\n| 1 |2|\n\nprose | not a row")
|
||
#expect(blocks == [
|
||
.table(ChatMarkdownTable(
|
||
header: ["a", "b"],
|
||
alignments: [.leading, .leading],
|
||
rows: [["1", "2"]])),
|
||
.prose("prose | not a row"),
|
||
])
|
||
}
|
||
|
||
@Test func `table body stops at another block`() {
|
||
let blocks = self.segments("| a | b |\n| - | - |\n| 1 | 2 |\n> quote")
|
||
#expect(blocks == [
|
||
.table(ChatMarkdownTable(
|
||
header: ["a", "b"],
|
||
alignments: [.leading, .leading],
|
||
rows: [["1", "2"]])),
|
||
.prose("> quote"),
|
||
])
|
||
}
|
||
|
||
@Test func `table body stops at empty list markers`() {
|
||
for (marker, kind) in [
|
||
("-", ChatMarkdownList.Kind.unordered),
|
||
("1.", ChatMarkdownList.Kind.ordered(start: 1)),
|
||
] {
|
||
let markdown = "| a | b |\n| - | - |\n| 1 | 2 |\n\(marker)"
|
||
#expect(self.segments(markdown) == [
|
||
.table(ChatMarkdownTable(
|
||
header: ["a", "b"],
|
||
alignments: [.leading, .leading],
|
||
rows: [["1", "2"]])),
|
||
.list(ChatMarkdownList(kind: kind, items: [self.item([])])),
|
||
])
|
||
}
|
||
}
|
||
|
||
@Test func `table body stops at fenced code`() {
|
||
let blocks = self.segments("| a | b |\n| - | - |\n| 1 | 2 |\n```swift\nlet x = 1\n```")
|
||
#expect(blocks == [
|
||
.table(ChatMarkdownTable(
|
||
header: ["a", "b"],
|
||
alignments: [.leading, .leading],
|
||
rows: [["1", "2"]])),
|
||
.code(ChatCodeBlock(language: "swift", code: "let x = 1", isComplete: true)),
|
||
])
|
||
}
|
||
|
||
@Test func `table body stops at html block`() {
|
||
let blocks = self.segments("| a | b |\n| - | - |\n| 1 | 2 |\n<x-status when=\"count > 0\">\nhtml\n</x-status>")
|
||
#expect(blocks == [
|
||
.table(ChatMarkdownTable(
|
||
header: ["a", "b"],
|
||
alignments: [.leading, .leading],
|
||
rows: [["1", "2"]])),
|
||
.prose("<x-status when=\"count > 0\">\nhtml\n</x-status>"),
|
||
])
|
||
}
|
||
|
||
@Test func `header delimiter count mismatch falls back to prose`() {
|
||
let markdown = "| a | b |\n| - | - | - |\n| 1 | 2 |"
|
||
#expect(self.segments(markdown) == [.prose(markdown)])
|
||
}
|
||
|
||
@Test func `delimiter row without header pipe falls back to prose`() {
|
||
let markdown = "heading\n| - | - |"
|
||
#expect(self.segments(markdown) == [.prose(markdown)])
|
||
}
|
||
|
||
@Test func `pipes without delimiter row stay prose`() {
|
||
let markdown = "use foo | bar\nthen continue"
|
||
#expect(self.segments(markdown) == [.prose(markdown)])
|
||
}
|
||
|
||
@Test func `table header inside paragraph is detected`() {
|
||
let blocks = self.segments("intro line\n| a | b |\n| - | - |\n| 1 | 2 |\n\ndone")
|
||
#expect(blocks == [
|
||
.prose("intro line"),
|
||
.table(ChatMarkdownTable(
|
||
header: ["a", "b"],
|
||
alignments: [.leading, .leading],
|
||
rows: [["1", "2"]])),
|
||
.prose("done"),
|
||
])
|
||
}
|
||
|
||
@Test func `table syntax inside fence stays code`() {
|
||
let blocks = self.segments("```\n| a | b |\n| - | - |\n```")
|
||
#expect(blocks == [
|
||
.code(ChatCodeBlock(language: nil, code: "| a | b |\n| - | - |", isComplete: true)),
|
||
])
|
||
}
|
||
|
||
@Test func `nested list fence stays attached to its item`() {
|
||
#expect(self.segments("- item\n ```swift\n let value = 1\n ```\n continuation") == [
|
||
.list(ChatMarkdownList(
|
||
kind: .unordered,
|
||
items: [self.item([
|
||
.markdown("item"),
|
||
.code(ChatCodeBlock(language: "swift", code: "let value = 1", isComplete: true)),
|
||
.markdown("continuation"),
|
||
])])),
|
||
])
|
||
}
|
||
|
||
@Test func `table syntax inside list fence stays fenced`() {
|
||
#expect(self.segments("- item\n ```\n | a | b |\n | - | - |\n ```") == [
|
||
.list(ChatMarkdownList(
|
||
kind: .unordered,
|
||
items: [self.item([
|
||
.markdown("item"),
|
||
.code(ChatCodeBlock(
|
||
language: nil,
|
||
code: "| a | b |\n| - | - |",
|
||
isComplete: true)),
|
||
])])),
|
||
])
|
||
}
|
||
|
||
@Test func `list marker inside top level fence stays code`() {
|
||
let blocks = self.segments("```markdown\n- item\n```")
|
||
#expect(blocks == [
|
||
.code(ChatCodeBlock(language: "markdown", code: "- item", isComplete: true)),
|
||
])
|
||
}
|
||
|
||
@Test func `html block containing fence stays one prose document`() {
|
||
let markdown = "<div>\n```swift\nlet value = 1\n```\n</div>"
|
||
#expect(self.segments(markdown) == [.prose(markdown)])
|
||
}
|
||
|
||
@Test func `blank line ends html block before top level fence`() {
|
||
let markdown = "<div>\nraw\n\n```swift\nlet value = 1\n```"
|
||
#expect(self.segments(markdown) == [
|
||
.prose("<div>\nraw"),
|
||
.code(ChatCodeBlock(language: "swift", code: "let value = 1", isComplete: true)),
|
||
])
|
||
}
|
||
|
||
@Test func `type seven html tag does not interrupt paragraph`() {
|
||
let markdown = "text\n<span>\n```swift\nlet value = 1\n```"
|
||
#expect(self.segments(markdown) == [
|
||
.prose("text\n<span>"),
|
||
.code(ChatCodeBlock(language: "swift", code: "let value = 1", isComplete: true)),
|
||
])
|
||
}
|
||
|
||
@Test func `type seven html tag does not interrupt indented paragraph continuation`() {
|
||
let markdown = "text\n continuation\n<span>\n```swift\nlet value = 1\n```"
|
||
#expect(self.segments(markdown) == [
|
||
.prose("text\n continuation\n<span>"),
|
||
.code(ChatCodeBlock(language: "swift", code: "let value = 1", isComplete: true)),
|
||
])
|
||
}
|
||
|
||
@Test func `reference definitions preserve document scope across blocks`() {
|
||
let markdown = "[docs][d]\n\n```swift\nlet value = 1\n```\n\n[d]: https://example.com"
|
||
#expect(self.segments(markdown) == [.prose(markdown)])
|
||
}
|
||
|
||
@Test func `multiline reference definitions preserve document scope`() {
|
||
let markdown = "[docs][d]\n\n```swift\nlet value = 1\n```\n\n[d]:\n https://example.com"
|
||
#expect(self.segments(markdown) == [.prose(markdown)])
|
||
}
|
||
|
||
@Test func `reference definitions inside containers preserve document scope`() {
|
||
let markdown = "[docs]\n\n```swift\nlet value = 1\n```\n\n> [docs]: https://example.com"
|
||
#expect(self.segments(markdown) == [.prose(markdown)])
|
||
}
|
||
|
||
@Test func `multiline reference labels preserve document scope`() {
|
||
let markdown = "[docs]\n\n```swift\nlet value = 1\n```\n\n[\ndocs\n]: https://example.com"
|
||
#expect(self.segments(markdown) == [.prose(markdown)])
|
||
}
|
||
|
||
@Test func `table after paragraph keeps inline header markdown`() {
|
||
let blocks = self.segments("intro\n| **a** | b\\|c |\n| - | - |\n| 1 | 2 |")
|
||
#expect(blocks == [
|
||
.prose("intro"),
|
||
.table(ChatMarkdownTable(
|
||
header: ["**a**", "b|c"],
|
||
alignments: [.leading, .leading],
|
||
rows: [["1", "2"]])),
|
||
])
|
||
}
|
||
|
||
@Test func `oversized table stays raw prose`() {
|
||
let rows = Array(
|
||
repeating: "| value | value |",
|
||
count: ChatMarkdownBlockSegmenter.maxTableRows)
|
||
let markdown = (["| a | b |", "| - | - |"] + rows).joined(separator: "\n")
|
||
#expect(self.segments(markdown) == [.prose(markdown)])
|
||
}
|
||
|
||
@Test func `table at the rendering bounds remains native`() {
|
||
let bodyRowCount = ChatMarkdownBlockSegmenter.maxTableRows - 1
|
||
let rows = Array(repeating: "| value | value |", count: bodyRowCount)
|
||
let markdown = (["| a | b |", "| - | - |"] + rows).joined(separator: "\n")
|
||
let blocks = self.segments(markdown)
|
||
guard case let .table(table) = blocks.first else {
|
||
Issue.record("expected bounded table")
|
||
return
|
||
}
|
||
#expect(table.rows.count == bodyRowCount)
|
||
}
|
||
}
|