gitbayTests/MathTests.swift
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}