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