mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-15 01:36:07 +00:00
162 lines
6.0 KiB
Swift
162 lines
6.0 KiB
Swift
import CoreGraphics
|
||
import Foundation
|
||
import Testing
|
||
@testable import OpenClawChatUI
|
||
|
||
struct ChatInlineMathTests {
|
||
@Test func `basic span splits from prose`() {
|
||
#expect(ChatInlineMathScanner.pieces(in: #"before \(x + 1\) after"#) == [
|
||
.markdown("before "),
|
||
.math(latex: "x + 1", source: #"\(x + 1\)"#),
|
||
.markdown(" after"),
|
||
])
|
||
}
|
||
|
||
@Test func `multiple spans split independently`() {
|
||
let pieces = ChatInlineMathScanner.pieces(in: #"\(x\), then \(y\)"#)
|
||
#expect(pieces.compactMap(\.mathLatex) == ["x", "y"])
|
||
}
|
||
|
||
@Test func `span inside backticks stays markdown`() {
|
||
let source = #"use `\(not math\)` here"#
|
||
#expect(ChatInlineMathScanner.pieces(in: source) == [.markdown(source)])
|
||
}
|
||
|
||
@Test func `unmatched backtick does not hide later math`() {
|
||
#expect(ChatInlineMathScanner.pieces(in: #"stray ` before \(x\)"#) == [
|
||
.markdown("stray ` before "),
|
||
.math(latex: "x", source: #"\(x\)"#),
|
||
])
|
||
}
|
||
|
||
@Test func `escaped delimiters stay markdown`() {
|
||
let source = #"escaped \\(x\\)"#
|
||
#expect(ChatInlineMathScanner.pieces(in: source) == [.markdown(source)])
|
||
}
|
||
|
||
@Test func `unclosed opener stays literal`() {
|
||
#expect(ChatInlineMathScanner.pieces(in: #"before \(x"#) == [
|
||
.markdown("before "),
|
||
.literal(#"\(x"#),
|
||
])
|
||
}
|
||
|
||
@Test func `newline span stays literal`() {
|
||
let source = "before \\(x\ny\\) after"
|
||
#expect(ChatInlineMathScanner.pieces(in: source) == [
|
||
.markdown("before "),
|
||
.literal("\\(x\ny\\)"),
|
||
.markdown(" after"),
|
||
])
|
||
}
|
||
|
||
@Test func `only first sixteen spans are candidates`() {
|
||
let source = (0..<17).map { #"\(x\#($0)\)"# }.joined(separator: " ")
|
||
let pieces = ChatInlineMathScanner.pieces(in: source)
|
||
#expect(pieces.compactMap(\.mathLatex).count == ChatInlineMathScanner.maxSpanCount)
|
||
#expect(pieces.contains(.literal(#"\(x16\)"#)))
|
||
}
|
||
|
||
@Test @MainActor func `markdown after excess spans remains parsed`() throws {
|
||
let spans = (0..<17).map { #"\(x\#($0)\)"# }.joined(separator: " ")
|
||
let snapshot = ChatMarkdownRenderSnapshot(
|
||
text: spans + " [docs](https://example.com)",
|
||
isComplete: true)
|
||
let prose = try self.prose(in: snapshot)
|
||
let hasLink = prose.inlineContent?.contains { content in
|
||
guard case let .text(attributed) = content else { return false }
|
||
return attributed.runs.contains { $0.link != nil }
|
||
}
|
||
|
||
#expect(hasLink == true)
|
||
}
|
||
|
||
@Test @MainActor func `math link label stays literal and linked`() throws {
|
||
let snapshot = ChatMarkdownRenderSnapshot(
|
||
text: #"[\(x\)](https://example.com)"#,
|
||
isComplete: true)
|
||
let prose = try self.prose(in: snapshot)
|
||
let linkedText = prose.inlineContent?.compactMap { content -> AttributedString? in
|
||
guard case let .text(attributed) = content,
|
||
attributed.runs.contains(where: { $0.link != nil })
|
||
else { return nil }
|
||
return attributed
|
||
}.first
|
||
|
||
#expect(prose.inlineMathLatex.isEmpty)
|
||
#expect(try String(#require(linkedText).characters) == #"\(x\)"#)
|
||
}
|
||
|
||
@Test func `two hundred byte cap is inclusive`() {
|
||
let acceptedLatex = String(repeating: "x", count: ChatInlineMathScanner.maxSourceBytes - 4)
|
||
let rejectedLatex = acceptedLatex + "x"
|
||
#expect(ChatInlineMathScanner.pieces(in: "\\(\(acceptedLatex)\\)").compactMap(\.mathLatex) == [
|
||
acceptedLatex,
|
||
])
|
||
#expect(ChatInlineMathScanner.pieces(in: "\\(\(rejectedLatex)\\)") == [
|
||
.literal("\\(\(rejectedLatex)\\)"),
|
||
])
|
||
}
|
||
|
||
@Test @MainActor func `only completed prose prepares inline math`() throws {
|
||
let complete = ChatMarkdownRenderSnapshot(text: #"value \(x^2\)"#, isComplete: true)
|
||
let streaming = ChatMarkdownRenderSnapshot(
|
||
text: #"value \(x^2\)"#,
|
||
isComplete: false,
|
||
preparesReveal: true)
|
||
|
||
#expect(try self.prose(in: complete).inlineMathLatex == ["x^2"])
|
||
#expect(try self.prose(in: streaming).inlineMathLatex.isEmpty)
|
||
}
|
||
|
||
@Test @MainActor func `display math guards also reject inline math`() throws {
|
||
for source in [#"value \(α + β\)"#, #"value \(x\color{red}\)"#] {
|
||
let snapshot = ChatMarkdownRenderSnapshot(text: source, isComplete: true)
|
||
#expect(try self.prose(in: snapshot).inlineMathLatex.isEmpty)
|
||
}
|
||
}
|
||
|
||
@Test @MainActor func `markdown attributes can cross inline math`() throws {
|
||
let snapshot = ChatMarkdownRenderSnapshot(text: #"**value \(x\)**"#, isComplete: true)
|
||
let prose = try self.prose(in: snapshot)
|
||
|
||
#expect(prose.inlineMathLatex == ["x"])
|
||
#expect(prose.inlineAccessibilityText == "value x")
|
||
}
|
||
|
||
@Test @MainActor func `image sizing rejects unsafe bitmap bounds`() {
|
||
#expect(!ChatInlineMathImageCache.isSafeImageSize(.zero, scale: 2))
|
||
#expect(!ChatInlineMathImageCache.isSafeImageSize(
|
||
CGSize(width: 4097, height: 1),
|
||
scale: 1))
|
||
#expect(!ChatInlineMathImageCache.isSafeImageSize(
|
||
CGSize(width: 1024, height: 1024),
|
||
scale: 1))
|
||
#expect(ChatInlineMathImageCache.isSafeImageSize(
|
||
CGSize(width: 256, height: 256),
|
||
scale: 2))
|
||
}
|
||
|
||
@MainActor
|
||
private func prose(in snapshot: ChatMarkdownRenderSnapshot) throws -> ChatMarkdownProse {
|
||
guard case let .prose(prose) = try #require(snapshot.blocks.first) else {
|
||
Issue.record("expected prose block")
|
||
throw InlineMathTestError.expectedProse
|
||
}
|
||
return prose
|
||
}
|
||
}
|
||
|
||
private enum InlineMathTestError: Error {
|
||
case expectedProse
|
||
}
|
||
|
||
extension ChatInlineMathScanner.Piece {
|
||
fileprivate var mathLatex: String? {
|
||
if case let .math(latex, _) = self {
|
||
return latex
|
||
}
|
||
return nil
|
||
}
|
||
}
|