gitbayTests/MathTests.swift

v1.13.0
gitbay-ios/gitbayTests/MathTests.swift history · blame · raw

223 lines · 9800 bytes

  1import Foundation
  2import Testing
  3@testable import gitbay
  4import OrgSwift
  5
  6/// The web's delimiter rules (internal/httpd/math.go): pandoc's `$…$` so
  7/// prices stay prose, and `$$…$$` inline or as a block.
  8struct MarkdownMathSplitTests {
  9
 10    private func math(_ tex: String, display: Bool = false) -> MathPiece {
 11        let delimiter = display ? "$$" : "$"
 12        return .math(MathExpression(tex: tex, display: display, source: delimiter + tex + delimiter))
 13    }
 14
 15    @Test func inlineMathBetweenText() {
 16        #expect(splitMarkdownMath("area $x^2$ here") == [.text("area "), math("x^2"), .text(" here")])
 17    }
 18
 19    @Test func pricesStayProse() {
 20        #expect(splitMarkdownMath("$5 and $10") == [.text("$5 and $10")])
 21    }
 22
 23    @Test func anOpenerFollowedBySpaceIsText() {
 24        #expect(splitMarkdownMath("a $ x$ b") == [.text("a $ x$ b")])
 25    }
 26
 27    @Test func aCloserNeedsNoSpaceBeforeAndNoDigitAfter() {
 28        #expect(splitMarkdownMath("$a $ and $b$1") == [.text("$a $ and $b$1")])
 29        #expect(splitMarkdownMath("$a $b$") == [math("a $b")])
 30    }
 31
 32    @Test func backslashEscapesADollar() {
 33        #expect(splitMarkdownMath(#"\$x$ and $y\$z$"#) == [.text(#"\$x$ and "#), math(#"y\$z"#)])
 34    }
 35
 36    @Test func codeSpansAreNotMath() {
 37        #expect(splitMarkdownMath("`$x$` and ``a$b$`` c") == [.text("`$x$` and ``a$b$`` c")])
 38    }
 39
 40    @Test func anUnclosedBacktickIsText() {
 41        #expect(splitMarkdownMath("` $x$") == [.text("` "), math("x")])
 42    }
 43
 44    @Test func linkDestinationsAreNotMath() {
 45        #expect(splitMarkdownMath("[a](https://x.test/$y$) b") == [.text("[a](https://x.test/$y$) b")])
 46    }
 47
 48    @Test func doubleDollarsAreDisplayMath() {
 49        #expect(splitMarkdownMath(#"so $$\frac12$$ ok"#) == [.text("so "), math(#"\frac12"#, display: true), .text(" ok")])
 50    }
 51
 52    @Test func emptyOrUnclosedDoubleDollarsAreText() {
 53        #expect(splitMarkdownMath("$$$$") == [.text("$$$$")])
 54        #expect(splitMarkdownMath("$$x and $y$") == [.text("$$x and "), math("y")])
 55    }
 56
 57    @Test func manyUnclosedOpenersSplitQuickly() {
 58        let text = String(repeating: "$a ", count: 50_000)
 59        #expect(splitMarkdownMath(text) == [.text(text)])
 60        #expect(splitMarkdownMath(text + "$b$").count == 1, "the first opener closes at the end, as on the web")
 61    }
 62
 63    @Test func theDocumentBudgetEscapesWhatIsLeft() {
 64        var budget = MathBudget(expressions: 1, bytes: 100)
 65        let text = budget.limit("$a$ and $b\\$c$")
 66        #expect(text == #"$a$ and \$b\\$c\$"#)
 67        #expect(splitMarkdownMath(text) == [math("a"), .text(#" and \$b\\$c\$"#)])
 68    }
 69}
 70
 71@MainActor
 72struct MarkdownMathBlockTests {
 73
 74    private func blocks(_ markdown: String) -> [MarkdownView.Block] {
 75        MarkdownView(markdown: markdown).blocks
 76    }
 77
 78    @Test func doubleDollarLinesFenceABlock() {
 79        #expect(blocks("$$\nx^2\n+ y\n$$") == [.math(MathExpression(
 80            tex: "x^2\n+ y\n", display: true, source: "$$\nx^2\n+ y\n$$"))])
 81    }
 82
 83    @Test func theClosingDollarsMayEndALine() {
 84        #expect(blocks("$$\nx^2 $$") == [.math(MathExpression(
 85            tex: "x^2 ", display: true, source: "$$\nx^2 $$"))])
 86    }
 87
 88    @Test func aWholeLineIsABlock() {
 89        #expect(blocks("$$x^2$$") == [.math(MathExpression(tex: "x^2", display: true, source: "$$x^2$$"))])
 90    }
 91
 92    @Test func aBlankLineBeforeTheCloserLeavesProse() {
 93        #expect(blocks("$$\nx^2\n\nmore") == [.paragraph("$$ x^2"), .paragraph("more")])
 94    }
 95
 96    @Test func aBlockInterruptsAParagraph() {
 97        #expect(blocks("text\n$$x$$\nafter") == [
 98            .paragraph("text"),
 99            .math(MathExpression(tex: "x", display: true, source: "$$x$$")),
100            .paragraph("after"),
101        ])
102    }
103
104    @Test func crlfBodiesFenceBlocksToo() {
105        #expect(blocks("$$\r\nx^2\r\n$$") == [.math(MathExpression(
106            tex: "x^2\n", display: true, source: "$$\nx^2\n$$"))])
107    }
108
109    @Test func blockMathTakesTheBudgetBeforeInlineMath() {
110        let budgeted = MarkdownView.budgeted(
111            [.paragraph("$a$"), .math(MathExpression(tex: "b", display: true, source: "$$b$$"))],
112            budget: MathBudget(expressions: 1, bytes: 100))
113        #expect(budgeted == [.paragraph(#"\$a\$"#), .math(MathExpression(tex: "b", display: true, source: "$$b$$"))])
114    }
115
116    @Test func aBlockPastTheBudgetIsShownAsSource() {
117        let many = Array(repeating: "$$x$$", count: MathBudget.document.expressions + 1).joined(separator: "\n")
118        #expect(blocks(many).last == .code("$$x$$"))
119    }
120}
121
122@MainActor
123struct MathRenderingTests {
124
125    @Test func rendersAnExpression() {
126        #expect(MathRenderer.render(#"\frac{a}{b} + \sqrt{x}"#, display: false, pointSize: 17) != nil)
127        #expect(MathRenderer.render(#"\begin{pmatrix} a & b \\ c & d \end{pmatrix}"#, display: true, pointSize: 17) != nil)
128    }
129
130    @Test func refusesWhatSwiftMathCannotParse() {
131        #expect(MathRenderer.render(#"\notacommand{x}"#, display: false, pointSize: 17) == nil)
132    }
133
134    @Test func refusesAnUnescapedDollarOrHashAsTheWebDoes() {
135        #expect(MathRenderer.render("5 and $10 nothing", display: false, pointSize: 17) == nil)
136        #expect(MathRenderer.render("x #1", display: false, pointSize: 17) == nil)
137        #expect(MathRenderer.render(##"\$5 + \#1"##, display: false, pointSize: 17) != nil)
138    }
139
140    @Test func refusesWhatSwiftMathWouldDropSilently() {
141        for tex in ["α + β", "x ≤ y", #"\text{日本語}"#, "x % note", "a\u{7}b"] {
142            #expect(MathRenderer.render(tex, display: false, pointSize: 17) == nil, "\(tex)")
143        }
144    }
145
146    @Test func translatesTheWebsSpellings() {
147        #expect(MathRenderer.swiftMath("f'(x) + g''") == #"f^{\prime}(x) + g^{\prime\prime}"#)
148        #expect(MathRenderer.swiftMath("a~b") == #"a\;b"#)
149        #expect(MathRenderer.swiftMath(#"\dfrac12 \tbinom{n}{k} \operatorname{rank} \boldsymbol{x}"#)
150            == #"\frac12 \binom{n}{k} \mathrm{rank}\, \mathbf{x}"#)
151        #expect(MathRenderer.swiftMath(#"\begin{align*} a &= b \end{align*}"#) == #"\begin{aligned} a &= b \end{aligned}"#)
152        #expect(MathRenderer.swiftMath(#"\begin{equation} x \end{equation}"#) == #" x "#)
153        #expect(MathRenderer.swiftMath(#"\dfracx"#) == #"\dfracx"#, "only whole command names")
154        for tex in [#"\dfrac{a}{b}"#, #"\operatorname{rank} A"#, #"\begin{align} a &= b \\ c &= d \end{align}"#,
155                    #"\begin{equation} e = mc^2 \end{equation}"#, #"\begin{gather*} a \\ b \end{gather*}"#, "f'(x)"] {
156            #expect(MathRenderer.render(tex, display: true, pointSize: 17) != nil, "\(tex)")
157        }
158    }
159
160    @Test func refusesTooManyCommandsOrTooLargeAnImage() {
161        let many = String(repeating: #"\alpha "#, count: MathRenderer.maxCommands)
162        #expect(MathRenderer.render(many, display: false, pointSize: 17) != nil)
163        #expect(MathRenderer.render(many + #"\beta"#, display: false, pointSize: 17) == nil)
164        let nested = String(repeating: #"\sqrt"#, count: MathRenderer.maxCommands) + "x"
165        _ = MathRenderer.render(nested, display: true, pointSize: 17)
166        #expect(MathRenderer.render(String(repeating: "x", count: 4000), display: false, pointSize: 17) == nil)
167    }
168
169    @Test func refusesTooLongOrTooDeepBeforeParsing() {
170        #expect(MathRenderer.render(String(repeating: "x", count: 8 << 10 + 1), display: false, pointSize: 17) == nil)
171        let deep = String(repeating: "{", count: 65) + "x" + String(repeating: "}", count: 65)
172        #expect(MathRenderer.render(deep, display: false, pointSize: 17) == nil)
173        let ok = String(repeating: "{", count: 64) + "x" + String(repeating: "}", count: 64)
174        #expect(MathRenderer.render(ok, display: false, pointSize: 17) != nil)
175    }
176
177    @Test func malformedInputDoesNotCrash() {
178        let inputs = [
179            "", "{", "}", "^", "_", "^^", "x^", "\\", "\\frac", "\\frac{a", "\\sqrt[", "\\sqrt[3",
180            "\\left(", "\\right)", "\\left( x", "\\begin{matrix}", "\\end{matrix}", "a & b", "\\\\",
181            "\\begin{pmatrix} a & & & \\\\ \\\\ \\end{pmatrix}", "\\color{red}{x}", "\\color{", "\\text{",
182            "\\operatorname{", "\\mathbb{", "\\overbrace{", "\\left\\", "x_{_{_{}}}", "\\limits",
183            "\\begin{cases} x \\end{matrix}", "\\begin{aligned} a &= b \\\\ &= c \\end{aligned}",
184        ]
185        for tex in inputs {
186            _ = MathRenderer.render(tex, display: true, pointSize: 17)
187            _ = MathRenderer.render(tex, display: false, pointSize: 17)
188        }
189    }
190}
191
192@MainActor
193struct OrgMathHookTests {
194
195    private func org(_ tex: String, display: Bool = false) -> OrgMath {
196        OrgMath(tex: tex, display: display, source: "$" + tex + "$")
197    }
198
199    @Test func drawsWhatRendersAndKeepsTheSourceOtherwise() {
200        let draw = MathRenderer.org(pointSize: 17, allowance: MathAllowance())
201        #expect(draw(org("x^2")) != nil)
202        #expect(draw(org(#"\notacommand"#)) == nil)
203    }
204
205    @Test func theAllowanceIsTheWebsDocumentBudget() {
206        let allowance = MathAllowance(budget: MathBudget(expressions: 2, bytes: 100))
207        #expect(allowance.admits(org("a")))
208        #expect(allowance.admits(org("b")))
209        #expect(allowance.admits(org("a")), "an expression drawn once is drawn again on the next pass")
210        #expect(!allowance.admits(org("c")))
211    }
212
213    @Test func theAllowanceStartsOverForNewText() {
214        let allowance = MathAllowance(budget: MathBudget(expressions: 1, bytes: 100))
215        allowance.reset(for: "one")
216        #expect(allowance.admits(org("a")))
217        #expect(!allowance.admits(org("b")))
218        allowance.reset(for: "one")
219        #expect(!allowance.admits(org("b")), "the same text keeps its allowance")
220        allowance.reset(for: "two")
221        #expect(allowance.admits(org("b")))
222    }
223}