gitbayTests/MathTests.swift

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

230 lines · 10234 bytes

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