瀏覽代碼

Adding JS unit testing

main
Rocketsoup 1 年之前
父節點
當前提交
8afc776468
共有 2 個檔案被更改,包括 370 行新增28 行删除
  1. 31
    28
      js/markdown.js
  2. 339
    0
      testjs.html

+ 31
- 28
js/markdown.js 查看文件

@@ -105,6 +105,16 @@ class _MDToken {
105 105
 	}
106 106
 }
107 107
 
108
+class _MDUtils {
109
+	/**
110
+	 * @param {String} str
111
+	 * @returns {String}
112
+	 */
113
+	static escapeHTML(str) {
114
+		return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
115
+	}
116
+}
117
+
108 118
 
109 119
 // -- Spans -----------------------------------------------------------------
110 120
 
@@ -135,7 +145,7 @@ class _MDSpan {
135 145
 		}
136 146
 		for (const name in this.attributes) {
137 147
 			let value = this.attributes[name];
138
-			html += ` ${name}="${value.replace('"', '&quot;')}"`;
148
+			html += ` ${name}="${_MDUtils.escapeHTML(value)}"`;
139 149
 		}
140 150
 		return html;
141 151
 	}
@@ -185,7 +195,7 @@ class _MDTextSpan extends _MDSpan {
185 195
 		for (const abbrev in abbrevs) {
186 196
 			let def = abbrevs[abbrev];
187 197
 			let regex = regexes[abbrev];
188
-			let escapedDef = def.replace('"', '&quot;');
198
+			let escapedDef = _MDUtils.escapeHTML(def);
189 199
 			html = html.replace(regex, `<abbr title="${escapedDef}">$1</abbr>`);
190 200
 		}
191 201
 		return html;
@@ -231,14 +241,12 @@ class _MDLinkSpan extends _MDSpan {
231 241
 	}
232 242
 
233 243
 	toHTML(state) {
234
-		let escapedLink = this.link.replace('"', '&quot;');
235
-		var html = `<a href="${escapedLink}"`;
244
+		var html = `<a href="${_MDUtils.escapeHTML(this.link)}"`;
236 245
 		if (this.target) {
237
-			let escapedTarget = this.target.replace('"', '&quot;');
238
-			html += ` target="${escapedTarget}"`;
246
+			html += ` target="${_MDUtils.escapeHTML(this.target)}"`;
239 247
 		}
240 248
 		if (this.title) {
241
-			html += ` title="${this.title.replace('"', '&quot;')}"`;
249
+			html += ` title="${_MDUtils.escapeHTML(this.title)}"`;
242 250
 		}
243 251
 		html += this.htmlAttributes();
244 252
 		html += '>' + this.content.toHTML(state) + '</a>';
@@ -329,11 +337,11 @@ class _MDStrikethroughSpan extends _MDSpan {
329 337
 }
330 338
 
331 339
 class _MDCodeSpan extends _MDSpan {
332
-	/** @var {_MDSpan} content */
340
+	/** @var {String} content */
333 341
 	#content;
334 342
 
335 343
 	/**
336
-	 * @param {_MDSpan} content
344
+	 * @param {String} content
337 345
 	 */
338 346
 	constructor(content) {
339 347
 		super();
@@ -341,8 +349,7 @@ class _MDCodeSpan extends _MDSpan {
341 349
 	}
342 350
 
343 351
 	toHTML(state) {
344
-		let contentHTML = this.#content.toHTML(state);
345
-		return `<code${this.htmlAttributes()}>${contentHTML}</code>`;
352
+		return `<code${this.htmlAttributes()}>${_MDUtils.escapeHTML(this.#content)}</code>`;
346 353
 	}
347 354
 }
348 355
 
@@ -365,15 +372,12 @@ class _MDImageSpan extends _MDSpan {
365 372
 	}
366 373
 
367 374
 	toHTML(state) {
368
-		let escapedSource = this.source.replace('"', '&quot;');
369
-		let html = `<img src="${escapedSource}"`;
375
+		let html = `<img src="${_MDUtils.escapeHTML(this.source)}"`;
370 376
 		if (this.alt) {
371
-			let altEscaped = this.alt.replace('"', '&quot');
372
-			html += ` alt="${altEscaped}"`;
377
+			html += ` alt="${_MDUtils.escapeHTML(this.alt)}"`;
373 378
 		}
374 379
 		if (this.title) {
375
-			let titleEscaped = this.title.replace('"', '&quot;');
376
-			html += ` title="${titleEscaped}"`;
380
+			html += ` title="${_MDUtils.escapeHTML(this.title)}"`;
377 381
 		}
378 382
 		html += this.htmlAttributes();
379 383
 		html += '>';
@@ -403,9 +407,7 @@ class _MDReferencedImageSpan extends _MDImageSpan {
403 407
 		if (this.source) {
404 408
 			return super.toHTML(state);
405 409
 		} else {
406
-			let altEscaped = this.alt.replace('"', '&quot;');
407
-			let idEscaped = this.id.replace('"', '&quot;');
408
-			return `![${altEscaped}][${idEscaped}]`;
410
+			return `![${_MDUtils.escapeHTML(this.alt)}][${_MDUtils.escapeHTML(this.id)}]`;
409 411
 		}
410 412
 	}
411 413
 }
@@ -456,7 +458,7 @@ class _MDBlock {
456 458
 		}
457 459
 		for (const name in this.attributes) {
458 460
 			let value = this.attributes[name];
459
-			html += ` ${name}="${value.replace('"', '&quot;')}"`;
461
+			html += ` ${name}="${_MDUtils.escapeHTML(value)}"`;
460 462
 		}
461 463
 		return html;
462 464
 	}
@@ -1257,8 +1259,6 @@ class Markdown {
1257 1259
 
1258 1260
 	static #footnoteWithTitleRegex = /^\[\^(\d+?)\s+"(.*?)"\]/;  // 1=symbol, 2=title
1259 1261
 	static #footnoteRegex = /^\[\^(\d+?)\]/;  // 1=symbol
1260
-	// Note: label contents have to have matching pairs of [] and (). Handles images inside links.
1261
-	static #labelRegex = /^\[((?:[^\[\]]*?\[[^\[\]]*?\][^\[\]]*?|[^\(\)]*?\([^\(\)]*?\)[^\(\)]*?|[^\[\]\(\)]*?)*?)\]/;  // 1=content
1262 1262
 	static #urlWithTitleRegex = /^\((\S+?)\s+"(.*?)"\)/i;  // 1=URL, 2=title
1263 1263
 	static #urlRegex = /^\((\S+?)\)/i;  // 1=URL
1264 1264
 	static #emailWithTitleRegex = new RegExp("^\\(\\s*(" + this.#baseEmailRegex.source + ")\\s+\"(.*?)\"\\s*\\)", "i");  // 1=email, 2=title
@@ -1267,6 +1267,7 @@ class Markdown {
1267 1267
 	static #simpleEmailRegex = new RegExp("^<" + this.#baseEmailRegex.source + ">", "i");  // 1=email
1268 1268
 
1269 1269
 	/**
1270
+	 * Finds a `[label]` at the start of a string. Regex was too inefficient for this.
1270 1271
 	 * @param {String} line
1271 1272
 	 * @returns {String[]}
1272 1273
 	 */
@@ -1276,7 +1277,9 @@ class Markdown {
1276 1277
 		var bracketCount = 0;
1277 1278
 		for (var p = 1; p < line.length; p++) {
1278 1279
 			let ch = line.substring(p, p + 1);
1279
-			if (ch == '(') {
1280
+			if (ch == '\\') {
1281
+				p++;
1282
+			} else if (ch == '(') {
1280 1283
 				parenCount++;
1281 1284
 			} else if (ch == ')') {
1282 1285
 				parenCount--;
@@ -1544,6 +1547,7 @@ class Markdown {
1544 1547
 					startIndex: startIndex,
1545 1548
 					toDelete: endIndex - startIndex + delimiter.length + 1,
1546 1549
 					content: new _MDMultiSpan(contentSpans),
1550
+					rawContent: contentTokens.map((token) => token.original).join(''),
1547 1551
 				};
1548 1552
 			} while (hasNewStart);
1549 1553
 			return null;
@@ -1552,7 +1556,6 @@ class Markdown {
1552 1556
 
1553 1557
 		// Second pass - paired constructs. Prioritize pairs with no other paired tokens inside.
1554 1558
 		const delimiterTokens = new Set([
1555
-			_MDTokenType.Backtick,
1556 1559
 			_MDTokenType.Tilde,
1557 1560
 			_MDTokenType.Asterisk,
1558 1561
 			_MDTokenType.Underscore
@@ -1562,7 +1565,7 @@ class Markdown {
1562 1565
 				anyChanges = false;
1563 1566
 				// ``code``
1564 1567
 				if (spanMatch = matchPair([ _MDTokenType.Backtick, _MDTokenType.Backtick ], disallowed)) {
1565
-					spans.splice(spanMatch.startIndex, spanMatch.toDelete, new _MDCodeSpan(spanMatch.content));
1568
+					spans.splice(spanMatch.startIndex, spanMatch.toDelete, new _MDCodeSpan(spanMatch.rawContent));
1566 1569
 					anyChanges = true;
1567 1570
 				}
1568 1571
 
@@ -1580,8 +1583,8 @@ class Markdown {
1580 1583
 				}
1581 1584
 
1582 1585
 				// `code`
1583
-				if (spanMatch = matchPair([ _MDTokenType.Backtick ], disallowed)) {
1584
-					spans.splice(spanMatch.startIndex, spanMatch.toDelete, new _MDCodeSpan(spanMatch.content));
1586
+				else if (spanMatch = matchPair([ _MDTokenType.Backtick ], disallowed)) {
1587
+					spans.splice(spanMatch.startIndex, spanMatch.toDelete, new _MDCodeSpan(spanMatch.rawContent));
1585 1588
 					anyChanges = true;
1586 1589
 				}
1587 1590
 

+ 339
- 0
testjs.html 查看文件

@@ -0,0 +1,339 @@
1
+<!DOCTYPE html>
2
+<html>
3
+	<head>
4
+		<meta charset="utf-8">
5
+		<title>Markdown Unit Tests</title>
6
+		<link rel="icon" href="data:;base64,iVBORw0KGgo=">
7
+		<style type="text/css">
8
+			:root {
9
+				font-family: sans-serif;
10
+			}
11
+			.testcase {
12
+				border: 1px solid black;
13
+				padding: 0.5em 1em;
14
+				margin-bottom: 1em;
15
+				max-width: 70em;
16
+			}
17
+			.testcasename {
18
+				font-size: 115%;
19
+				font-weight: bold;
20
+			}
21
+			.testcasestatus {
22
+				font-weight: bold;
23
+			}
24
+			.result-untested {
25
+				color: #888;
26
+			}
27
+			.result-testing {
28
+				color: black;
29
+			}
30
+			.result-passed {
31
+				color: #090;
32
+			}
33
+			.result-failed {
34
+				color: #a00;
35
+			}
36
+			.result-errored {
37
+				color: #a80;
38
+			}
39
+		</style>
40
+		<script src="js/markdown.js"></script>
41
+		<script>
42
+			// TODO: Either run tests on a worker or at least on a setTimeout so
43
+			// UI can update and not block the thread for the whole duration.
44
+			function onLoad() {
45
+				let testClasses = [
46
+					MarkdownTest,
47
+				];
48
+				var tests = [];
49
+				for (const testClass of testClasses) {
50
+					const instance = new testClass();
51
+					if (!(instance instanceof BaseTest)) {
52
+						console.error(`${testClass.name} does not extend BaseTest`);
53
+						continue;
54
+					}
55
+					const testCases = TestCaseRun.findTestCases(instance);
56
+					tests.push(...testCases);
57
+					var classHTML = '<div class="testclass">';
58
+					classHTML += `<div class="testclassname">${testClass.name}</div>`;
59
+					for (const testCase of testCases) {
60
+						classHTML += testCase.toHTML();
61
+					}
62
+					classHTML += '</div>';
63
+					document.getElementById('results').innerHTML += classHTML;
64
+				}
65
+				var testInterval = setInterval(function() {
66
+					if (tests.length == 0) {
67
+						clearInterval(testInterval);
68
+						testInterval = null;
69
+						return;
70
+					}
71
+					let testCase = tests[0];
72
+					if (testCase.result == ResultType.testing) {
73
+						tests.splice(0, 1);
74
+						testCase.updateHTML();
75
+						testCase.run();
76
+						testCase.updateHTML();
77
+					} else {
78
+						testCase.result = ResultType.testing;
79
+						testCase.updateHTML();
80
+					}
81
+				}, 1);
82
+			}
83
+
84
+			/**
85
+			 * @param {String} text
86
+			 * @returns {String}
87
+			 */
88
+			function escapeHTML(text) {
89
+				return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
90
+			}
91
+
92
+			class ResultType {
93
+				static untested = new ResultType('untested');
94
+				static testing = new ResultType('testing');
95
+				static passed = new ResultType('passed');
96
+				static failed = new ResultType('failed');
97
+				static errored = new ResultType('errored');
98
+
99
+				#name;
100
+
101
+				constructor(name) {
102
+					this.#name = name;
103
+				}
104
+
105
+				toString() {
106
+					return `${this.constructor.name}.${this.#name}`;
107
+				}
108
+			}
109
+
110
+			class FailureError extends Error {
111
+				constructor(message=null) {
112
+					super(message);
113
+				}
114
+			}
115
+
116
+			class BaseTest {
117
+				setUp() {}
118
+				tearDown() {}
119
+				/** @var {TestCaseRun|null} */
120
+				currentRunner = null;
121
+				fail(failMessage=null) {
122
+					throw new FailureError(failMessage || 'failed');
123
+				}
124
+				assertTrue(test, failMessage=null) {
125
+					if (!test) this.fail(failMessage || `expected true, got ${test}`);
126
+				}
127
+				assertFalse(test, failMessage=null) {
128
+					if (test) this.fail(failMessage || `expected false, got ${test}`);
129
+				}
130
+				assertEqual(a, b, failMessage=null) {
131
+					if (a != b) this.fail(failMessage || `equality failed: ${a} != ${b}`);
132
+				}
133
+				expectError(e=true) {
134
+					if (this.currentRunner) this.currentRunner.expectedError = e;
135
+				}
136
+				/**
137
+				 * @param {number} maxTime maximum time in seconds
138
+				 * @param {function} timedCode code to run and time
139
+				 */
140
+				profile(maxTime, timedCode) {
141
+					const startTime = performance.now();
142
+					const result = timedCode();
143
+					const endTime = performance.now();
144
+					const seconds = (endTime - startTime) / 1000.0;
145
+					if (seconds > maxTime) {
146
+						this.fail(`Expected <= ${maxTime}s execution time, actual ${seconds}s.`);
147
+					}
148
+					return result;
149
+				}
150
+			}
151
+
152
+			class TestCaseRun {
153
+				static #nextUniqueId = 1;
154
+				/** @var {BaseTest} */
155
+				#objectUnderTest;
156
+				/** @var {method} */
157
+				#method;
158
+				uniqueId = 0;
159
+				/** @var {ResultType} */
160
+				result = ResultType.untested;
161
+				/** @var {String|null} */
162
+				message = null;
163
+				expectedError = null;
164
+				/** @var {String} */
165
+				get className() { return this.#objectUnderTest.constructor.name; }
166
+				/** @var {String} */
167
+				get methodName() { return this.#method.name; }
168
+				/**
169
+				 * @param {BaseTest} objectUnderTest
170
+				 * @param {method} method
171
+				 */
172
+				constructor(objectUnderTest, method) {
173
+					this.#objectUnderTest = objectUnderTest;
174
+					this.#method = method;
175
+					this.uniqueId = TestCaseRun.#nextUniqueId++;
176
+				}
177
+				run() {
178
+					try {
179
+						this.expectedError = null;
180
+						this.#objectUnderTest.currentRunner = this;
181
+						this.#objectUnderTest.setUp();
182
+						this.#method.bind(this.#objectUnderTest)();
183
+						this.result = ResultType.passed;
184
+						this.message = null;
185
+					} catch (e) {
186
+						if (e instanceof FailureError) {
187
+							this.result = ResultType.failed;
188
+							this.message = e.message;
189
+						} else if (this.#isExpectedError(e)) {
190
+							this.result = ResultType.passed;
191
+							this.message = null;
192
+						} else {
193
+							this.result = ResultType.errored;
194
+							this.message = e.message;
195
+						}
196
+					} finally {
197
+						this.expectedError = null;
198
+						try {
199
+							this.#objectUnderTest.tearDown();
200
+							this.#objectUnderTest.currentRunner = null;
201
+						} catch (e0) {
202
+							console.error(`Failed to run ${this.className}.tearDown() - ${e0.message}`);
203
+							this.result = ResultType.errored;
204
+							this.message = e0.message;
205
+						}
206
+					}
207
+				}
208
+				#isExpectedError(e) {
209
+					if (this.expectedError === null) return false;
210
+					if (this.expectedError === true) return true;
211
+					// TODO: Have a way to specify details about what kind of error is expected. Maybe a prototype instance and/or a testing lambda.
212
+					return false;
213
+				}
214
+				toHTML() {
215
+					var html = `<div class="testcase" id="testcase${this.uniqueId}">`;
216
+					html += `<div class="testcasename"><span class="testcaseclass">${this.className}</span>.<span class="testcasemethod">${this.methodName}</span></div>`;
217
+					switch (this.result) {
218
+						case ResultType.untested:
219
+							html += '<div class="testcasestatus result-untested">Waiting to test</div>';
220
+							break;
221
+						case ResultType.testing:
222
+							html += '<div class="testcasestatus result-tesitng">Testing...</div>';
223
+							break;
224
+						case ResultType.passed:
225
+							html += '<div class="testcasestatus result-passed">Passed</div>';
226
+							break;
227
+						case ResultType.failed:
228
+							html += '<div class="testcasestatus result-failed">Failed</div>';
229
+							html += `<div class="testcasemessage">${escapeHTML(this.message)}</div>`;
230
+							break;
231
+						case ResultType.errored:
232
+							html += '<div class="testcasestatus result-errored">Errored</div>';
233
+							html += `<div class="testcasemessage">${escapeHTML(this.message)}</div>`;
234
+							break;
235
+					}
236
+					html += `</div>`;
237
+					return html;
238
+				}
239
+				updateHTML() {
240
+					let existing = document.getElementById(`testcase${this.uniqueId}`);
241
+					if (existing) {
242
+						existing.outerHTML = this.toHTML();
243
+					} else {
244
+						document.getElementById('results').innerHTML += this.toHTML();
245
+					}
246
+				}
247
+
248
+				/**
249
+				 * @param {object} instance
250
+				 * @returns {TestCaseRun[]}
251
+				 */
252
+				static findTestCases(instance) {
253
+					if (!(instance instanceof BaseTest)) return [];
254
+					var members = [];
255
+					var obj = instance;
256
+					do {
257
+						members.push(...Object.getOwnPropertyNames(obj));
258
+					} while (obj = Object.getPrototypeOf(obj));
259
+					return members.sort().filter((e, i, arr) => {
260
+						if (e != arr[i + 1] && typeof instance[e] == 'function' && e.startsWith('test')) return true;
261
+					}).map((name) => {
262
+						return new TestCaseRun(instance, instance[name]);
263
+					});
264
+				}
265
+			}
266
+
267
+			class MarkdownTest extends BaseTest {
268
+				test_simpleSingleParagraph() {
269
+					let markdown = 'Lorem ipsum';
270
+					let expected = '<p>Lorem ipsum</p>';
271
+					let actual = Markdown.toHTML(markdown).trim();
272
+					this.assertEqual(actual, expected);
273
+				}
274
+
275
+				test_strong() {
276
+					let markdown = 'Lorem **ipsum** dolor **sit**';
277
+					let expected = '<p>Lorem <strong>ipsum</strong> dolor <strong>sit</strong></p>';
278
+					let actual = Markdown.toHTML(markdown).trim();
279
+					this.assertEqual(actual, expected);
280
+				}
281
+
282
+				test_emphasis() {
283
+					let markdown = 'Lorem _ipsum_ dolor _sit_';
284
+					let expected = '<p>Lorem <em>ipsum</em> dolor <em>sit</em></p>';
285
+					let actual = Markdown.toHTML(markdown).trim();
286
+					this.assertEqual(actual, expected);
287
+				}
288
+
289
+				test_strongEmphasis_easy() {
290
+					let markdown = 'Lorem **ipsum *dolor* sit** amet';
291
+					let expected = '<p>Lorem <strong>ipsum <em>dolor</em> sit</strong> amet</p>';
292
+					let actual = Markdown.toHTML(markdown).trim();
293
+					this.assertEqual(actual, expected);
294
+				}
295
+
296
+				test_strongEmphasis_medium() {
297
+					let markdown = 'Lorem ***ipsum*** dolor';
298
+					let expected1 = '<p>Lorem <strong><em>ipsum</em></strong> dolor</p>';
299
+					let expected2 = '<p>Lorem <em><strong>ipsum</strong></em> dolor</p>';
300
+					let actual = Markdown.toHTML(markdown).trim();
301
+					this.assertTrue(actual == expected1 || actual == expected2);
302
+				}
303
+
304
+				test_strongEmphasis_hard1() {
305
+					let markdown = 'Lorem ***ipsum* dolor** sit';
306
+					let expected = '<p>Lorem <strong><em>ipsum</em> dolor</strong> sit</p>';
307
+					let actual = Markdown.toHTML(markdown).trim();
308
+					this.assertEqual(actual, expected);
309
+				}
310
+
311
+				test_strongEmphasis_hard2() {
312
+					let markdown = 'Lorem ***ipsum** dolor* sit';
313
+					let expected = '<p>Lorem <em><strong>ipsum</strong> dolor</em> sit</p>';
314
+					let actual = Markdown.toHTML(markdown).trim();
315
+					this.assertEqual(actual, expected);
316
+				}
317
+
318
+				test_inlineCode() {
319
+					let markdown = 'Lorem `ipsum` dolor';
320
+					let expected = '<p>Lorem <code>ipsum</code> dolor</p>';
321
+					let actual = Markdown.toHTML(markdown).trim();
322
+					this.assertEqual(actual, expected);
323
+				}
324
+
325
+				test_inlineCode_withInnerBacktick() {
326
+					let markdown = 'Lorem ``ip`su`m`` dolor';
327
+					let expected = '<p>Lorem <code>ip`su`m</code> dolor</p>';
328
+					let actual = Markdown.toHTML(markdown).trim();
329
+					this.assertEqual(actual, expected);
330
+				}
331
+			}
332
+
333
+			document.addEventListener('DOMContentLoaded', onLoad);
334
+		</script>
335
+	</head>
336
+	<body>
337
+		<div id="results"></div>
338
+	</body>
339
+</html>

Loading…
取消
儲存