import Foundation import SwiftUI import Testing @testable import gitbay import OrgSwift /// The web's delimiter rules (internal/httpd/math.go): pandoc's `$…$` so /// prices stay prose, and `$$…$$` inline or as a block. struct MarkdownMathSplitTests { private func math(_ tex: String, display: Bool = false) -> MathPiece { let delimiter = display ? "$$" : "$" return .math(MathExpression(tex: tex, display: display, source: delimiter + tex + delimiter)) } @Test func inlineMathBetweenText() { #expect(splitMarkdownMath("area $x^2$ here") == [.text("area "), math("x^2"), .text(" here")]) } @Test func pricesStayProse() { #expect(splitMarkdownMath("$5 and $10") == [.text("$5 and $10")]) } @Test func anOpenerFollowedBySpaceIsText() { #expect(splitMarkdownMath("a $ x$ b") == [.text("a $ x$ b")]) } @Test func aCloserNeedsNoSpaceBeforeAndNoDigitAfter() { #expect(splitMarkdownMath("$a $ and $b$1") == [.text("$a $ and $b$1")]) #expect(splitMarkdownMath("$a $b$") == [math("a $b")]) } @Test func backslashEscapesADollar() { #expect(splitMarkdownMath(#"\$x$ and $y\$z$"#) == [.text(#"\$x$ and "#), math(#"y\$z"#)]) } @Test func codeSpansAreNotMath() { #expect(splitMarkdownMath("`$x$` and ``a$b$`` c") == [.text("`$x$` and ``a$b$`` c")]) } @Test func anUnclosedBacktickIsText() { #expect(splitMarkdownMath("` $x$") == [.text("` "), math("x")]) } @Test func linkDestinationsAreNotMath() { #expect(splitMarkdownMath("[a](https://x.test/$y$) b") == [.text("[a](https://x.test/$y$) b")]) } @Test func doubleDollarsAreDisplayMath() { #expect(splitMarkdownMath(#"so $$\frac12$$ ok"#) == [.text("so "), math(#"\frac12"#, display: true), .text(" ok")]) } @Test func emptyOrUnclosedDoubleDollarsAreText() { #expect(splitMarkdownMath("$$$$") == [.text("$$$$")]) #expect(splitMarkdownMath("$$x and $y$") == [.text("$$x and "), math("y")]) } @Test func manyUnclosedOpenersSplitQuickly() { let text = String(repeating: "$a ", count: 50_000) #expect(splitMarkdownMath(text) == [.text(text)]) #expect(splitMarkdownMath(text + "$b$").count == 1, "the first opener closes at the end, as on the web") } @Test func theDocumentBudgetEscapesWhatIsLeft() { var budget = MathBudget(expressions: 1, bytes: 100) let text = budget.limit("$a$ and $b\\$c$") #expect(text == #"$a$ and \$b\\$c\$"#) #expect(splitMarkdownMath(text) == [math("a"), .text(#" and \$b\\$c\$"#)]) } } @MainActor struct MarkdownMathBlockTests { private func blocks(_ markdown: String) -> [MarkdownView.Block] { MarkdownView(markdown: markdown).blocks } @Test func doubleDollarLinesFenceABlock() { #expect(blocks("$$\nx^2\n+ y\n$$") == [.math(MathExpression( tex: "x^2\n+ y\n", display: true, source: "$$\nx^2\n+ y\n$$"))]) } @Test func theClosingDollarsMayEndALine() { #expect(blocks("$$\nx^2 $$") == [.math(MathExpression( tex: "x^2 ", display: true, source: "$$\nx^2 $$"))]) } @Test func aWholeLineIsABlock() { #expect(blocks("$$x^2$$") == [.math(MathExpression(tex: "x^2", display: true, source: "$$x^2$$"))]) } @Test func aBlankLineBeforeTheCloserLeavesProse() { #expect(blocks("$$\nx^2\n\nmore") == [.paragraph("$$ x^2"), .paragraph("more")]) } @Test func aBlockInterruptsAParagraph() { #expect(blocks("text\n$$x$$\nafter") == [ .paragraph("text"), .math(MathExpression(tex: "x", display: true, source: "$$x$$")), .paragraph("after"), ]) } @Test func crlfBodiesFenceBlocksToo() { #expect(blocks("$$\r\nx^2\r\n$$") == [.math(MathExpression( tex: "x^2\n", display: true, source: "$$\nx^2\n$$"))]) } @Test func blockMathTakesTheBudgetBeforeInlineMath() { let budgeted = MarkdownView.budgeted( [.paragraph("$a$"), .math(MathExpression(tex: "b", display: true, source: "$$b$$"))], budget: MathBudget(expressions: 1, bytes: 100)) #expect(budgeted == [.paragraph(#"\$a\$"#), .math(MathExpression(tex: "b", display: true, source: "$$b$$"))]) } @Test func aBlockPastTheBudgetIsShownAsSource() { let many = Array(repeating: "$$x$$", count: MathBudget.document.expressions + 1).joined(separator: "\n") #expect(blocks(many).last == .code("$$x$$")) } } @MainActor struct MathRenderingTests { @Test func rendersAnExpression() { #expect(MathRenderer.render(#"\frac{a}{b} + \sqrt{x}"#, display: false, pointSize: 17) != nil) #expect(MathRenderer.render(#"\begin{pmatrix} a & b \\ c & d \end{pmatrix}"#, display: true, pointSize: 17) != nil) } @Test func refusesWhatSwiftMathCannotParse() { #expect(MathRenderer.render(#"\notacommand{x}"#, display: false, pointSize: 17) == nil) } @Test func refusesAnUnescapedDollarOrHashAsTheWebDoes() { #expect(MathRenderer.render("5 and $10 nothing", display: false, pointSize: 17) == nil) #expect(MathRenderer.render("x #1", display: false, pointSize: 17) == nil) #expect(MathRenderer.render(##"\$5 + \#1"##, display: false, pointSize: 17) != nil) } @Test func refusesWhatSwiftMathWouldDropSilently() { for tex in ["α + β", "x ≤ y", #"\text{日本語}"#, "x % note", "a\u{7}b"] { #expect(MathRenderer.render(tex, display: false, pointSize: 17) == nil, "\(tex)") } } @Test func translatesTheWebsSpellings() { #expect(MathRenderer.swiftMath("f'(x) + g''") == #"f^{\prime}(x) + g^{\prime\prime}"#) #expect(MathRenderer.swiftMath("a~b") == #"a\;b"#) #expect(MathRenderer.swiftMath(#"\dfrac12 \tbinom{n}{k} \operatorname{rank} \boldsymbol{x}"#) == #"\frac12 \binom{n}{k} \mathrm{rank}\, \mathbf{x}"#) #expect(MathRenderer.swiftMath(#"\begin{align*} a &= b \end{align*}"#) == #"\begin{aligned} a &= b \end{aligned}"#) #expect(MathRenderer.swiftMath(#"\begin{equation} x \end{equation}"#) == #" x "#) #expect(MathRenderer.swiftMath(#"\dfracx"#) == #"\dfracx"#, "only whole command names") for tex in [#"\dfrac{a}{b}"#, #"\operatorname{rank} A"#, #"\begin{align} a &= b \\ c &= d \end{align}"#, #"\begin{equation} e = mc^2 \end{equation}"#, #"\begin{gather*} a \\ b \end{gather*}"#, "f'(x)"] { #expect(MathRenderer.render(tex, display: true, pointSize: 17) != nil, "\(tex)") } } @Test func refusesTooManyCommandsOrTooLargeAnImage() { let many = String(repeating: #"\alpha "#, count: MathRenderer.maxCommands) #expect(MathRenderer.render(many, display: false, pointSize: 17) != nil) #expect(MathRenderer.render(many + #"\beta"#, display: false, pointSize: 17) == nil) let nested = String(repeating: #"\sqrt"#, count: MathRenderer.maxCommands) + "x" _ = MathRenderer.render(nested, display: true, pointSize: 17) #expect(MathRenderer.render(String(repeating: "x", count: 4000), display: false, pointSize: 17) == nil) } @Test func refusesTooLongOrTooDeepBeforeParsing() { #expect(MathRenderer.render(String(repeating: "x", count: 8 << 10 + 1), display: false, pointSize: 17) == nil) let deep = String(repeating: "{", count: 65) + "x" + String(repeating: "}", count: 65) #expect(MathRenderer.render(deep, display: false, pointSize: 17) == nil) let ok = String(repeating: "{", count: 64) + "x" + String(repeating: "}", count: 64) #expect(MathRenderer.render(ok, display: false, pointSize: 17) != nil) } @Test func malformedInputDoesNotCrash() { let inputs = [ "", "{", "}", "^", "_", "^^", "x^", "\\", "\\frac", "\\frac{a", "\\sqrt[", "\\sqrt[3", "\\left(", "\\right)", "\\left( x", "\\begin{matrix}", "\\end{matrix}", "a & b", "\\\\", "\\begin{pmatrix} a & & & \\\\ \\\\ \\end{pmatrix}", "\\color{red}{x}", "\\color{", "\\text{", "\\operatorname{", "\\mathbb{", "\\overbrace{", "\\left\\", "x_{_{_{}}}", "\\limits", "\\begin{cases} x \\end{matrix}", "\\begin{aligned} a &= b \\\\ &= c \\end{aligned}", ] for tex in inputs { _ = MathRenderer.render(tex, display: true, pointSize: 17) _ = MathRenderer.render(tex, display: false, pointSize: 17) } } } @MainActor struct OrgMathHookTests { private func org(_ tex: String, display: Bool = false) -> OrgMath { OrgMath(tex: tex, display: display, source: "$" + tex + "$") } @Test func drawsWhatRendersAndKeepsTheSourceOtherwise() { let draw = MathRenderer.org(dynamicTypeSize: .large, allowance: MathAllowance()) #expect(draw(org("x^2"), .body) != nil) #expect(draw(org(#"\notacommand"#), .body) == nil) } @Test func mathIsSizedToTheTextItSitsIn() { #expect(UIFont.pointSize(Font.TextStyle.title, .large) > UIFont.pointSize(Font.TextStyle.body, .large)) #expect(UIFont.pointSize(Font.TextStyle.body, .large) == UIFont.pointSize(UIFont.TextStyle.body, .large)) #expect(UIFont.pointSize(Font.TextStyle.body, .xxxLarge) > UIFont.pointSize(Font.TextStyle.body, .large)) } @Test func theAllowanceIsTheWebsDocumentBudget() { let allowance = MathAllowance(budget: MathBudget(expressions: 2, bytes: 100)) #expect(allowance.admits(org("a"))) #expect(allowance.admits(org("b"))) #expect(allowance.admits(org("a")), "an expression drawn once is drawn again on the next pass") #expect(!allowance.admits(org("c"))) } @Test func theAllowanceStartsOverForNewText() { let allowance = MathAllowance(budget: MathBudget(expressions: 1, bytes: 100)) allowance.reset(for: "one") #expect(allowance.admits(org("a"))) #expect(!allowance.admits(org("b"))) allowance.reset(for: "one") #expect(!allowance.admits(org("b")), "the same text keeps its allowance") allowance.reset(for: "two") #expect(allowance.admits(org("b"))) } }