1 | N/A | -------------------------------------------------------------------------------- | >
2 | N/A | -- testkit.luau | >
3 | N/A | -- v0.7.3 | >
4 | N/A | -- MIT License | >
5 | N/A | -- Copyright (c) 2022 centau | >
6 | N/A | -------------------------------------------------------------------------------- | >
7 | N/A | | >
8 | 1 | local disable_ansi = false |
9 | N/A | | >
10 | 1 | local color = { |
11 | 1 | white_underline = function(s: string): string |
12 | 0 | return if disable_ansi then s else `\27[1;4m{s}\27[0m` |
13 | N/A | end, | >
14 | N/A | | >
15 | 1 | white = function(s: string): string |
16 | 24 | return if disable_ansi then s else `\27[37;1m{s}\27[0m` |
17 | N/A | end, | >
18 | N/A | | >
19 | 1 | green = function(s: string): string |
20 | 77 | return if disable_ansi then s else `\27[32;1m{s}\27[0m` |
21 | N/A | end, | >
22 | N/A | | >
23 | 1 | red = function(s: string): string |
24 | 146 | return if disable_ansi then s else `\27[31;1m{s}\27[0m` |
25 | N/A | end, | >
26 | N/A | | >
27 | 1 | yellow = function(s: string): string |
28 | 76 | return if disable_ansi then s else `\27[33;1m{s}\27[0m` |
29 | N/A | end, | >
30 | N/A | | >
31 | 1 | red_highlight = function(s: string): string |
32 | 0 | return if disable_ansi then s else `\27[41;1;30m{s}\27[0m` |
33 | N/A | end, | >
34 | N/A | | >
35 | 1 | green_highlight = function(s: string): string |
36 | 0 | return if disable_ansi then s else `\27[42;1;30m{s}\27[0m` |
37 | N/A | end, | >
38 | N/A | | >
39 | 1 | gray = function(s: string): string |
40 | 84 | return if disable_ansi then s else `\27[38;1m{s}\27[0m` |
41 | N/A | end, | >
42 | N/A | | >
43 | 1 | orange = function(s: string): string |
44 | 73 | return if disable_ansi then s else `\27[38;5;208m{s}\27[0m` |
45 | N/A | end, | >
46 | 0 | } |
47 | N/A | | >
48 | 1 | local function convert_units(unit: string, value: number): (number, string) |
49 | 6 | local sign = math.sign(value) |
50 | 6 | value = math.abs(value) |
51 | N/A | | >
52 | 6 | local prefix_colors = { |
53 | 6 | [4] = color.red, |
54 | 6 | [3] = color.red, |
55 | 6 | [2] = color.yellow, |
56 | 6 | [1] = color.yellow, |
57 | 6 | [0] = color.green, |
58 | 6 | [-1] = color.red, |
59 | 6 | [-2] = color.yellow, |
60 | 6 | [-3] = color.green, |
61 | 6 | [-4] = color.red, |
62 | 0 | } |
63 | N/A | | >
64 | 6 | local prefixes = { |
65 | 6 | [4] = "T", |
66 | 6 | [3] = "G", |
67 | 6 | [2] = "M", |
68 | 6 | [1] = "k", |
69 | 6 | [0] = " ", |
70 | 6 | [-1] = "m", |
71 | 6 | [-2] = "u", |
72 | 6 | [-3] = "n", |
73 | 6 | [-4] = "p", |
74 | 0 | } |
75 | N/A | | >
76 | 6 | local order = 0 |
77 | N/A | | >
78 | 7 | while value >= 1000 do |
79 | 1 | order += 1 |
80 | 1 | value /= 1000 |
81 | N/A | end | >
82 | N/A | | >
83 | 11 | while value ~= 0 and value < 1 do |
84 | 7 | order -= 1 |
85 | 7 | value *= 1000 |
86 | N/A | end | >
87 | N/A | | >
88 | 6 | if value >= 100 then |
89 | 1 | value = math.floor(value) |
90 | 5 | elseif value >= 10 then |
91 | 2 | value = math.floor(value * 1e1) / 1e1 |
92 | 3 | elseif value >= 1 then |
93 | 1 | value = math.floor(value * 1e2) / 1e2 |
94 | N/A | end | >
95 | N/A | | >
96 | 6 | return value * sign, prefix_colors[order](prefixes[order] .. unit) |
97 | N/A | end | >
98 | N/A | | >
99 | 1 | local WALL = color.gray("│") |
100 | N/A | | >
101 | N/A | -------------------------------------------------------------------------------- | >
102 | N/A | -- Testing | >
103 | N/A | -------------------------------------------------------------------------------- | >
104 | N/A | | >
105 | 0 | type Test = { |
106 | 0 | name: string, |
107 | 0 | case: Case?, |
108 | 0 | cases: { Case }, |
109 | 0 | duration: number, |
110 | 0 | error: { |
111 | 0 | message: string, |
112 | 0 | trace: string, |
113 | 0 | }?, |
114 | 0 | focus: boolean, |
115 | 0 | } |
116 | N/A | | >
117 | 0 | type Case = { |
118 | 0 | name: string, |
119 | 0 | result: number, |
120 | 0 | line: number?, |
121 | 0 | focus: boolean, |
122 | 0 | } |
123 | N/A | | >
124 | 1 | local PASS, FAIL, NONE, ERROR, SKIPPED = 1, 2, 3, 4, 5 |
125 | N/A | | >
126 | 1 | local check_for_focused = false |
127 | 1 | local skip = false |
128 | 1 | local test: Test? |
129 | 1 | local tests: { Test } = {} |
130 | N/A | | >
131 | 1 | local function output_test_result(test: Test) |
132 | 24 | if check_for_focused then |
133 | 0 | local any_focused = test.focus |
134 | 0 | for _, case in test.cases do |
135 | 0 | any_focused = any_focused or case.focus |
136 | N/A | end | >
137 | N/A | | >
138 | 0 | if not any_focused then |
139 | 0 | return |
140 | N/A | end | >
141 | N/A | end | >
142 | N/A | | >
143 | 24 | print(color.white(test.name)) |
144 | N/A | | >
145 | 24 | for _, case in test.cases do |
146 | 73 | local status = ({ |
147 | 73 | [PASS] = color.green("PASS"), |
148 | 73 | [FAIL] = color.red("FAIL"), |
149 | 73 | [NONE] = color.orange("NONE"), |
150 | 73 | [ERROR] = color.red("FAIL"), |
151 | 73 | [SKIPPED] = color.yellow("SKIP"), |
152 | 73 | })[case.result] |
153 | N/A | | >
154 | 73 | local line = case.result == FAIL and color.red(`{case.line}:`) or "" |
155 | 73 | if check_for_focused and case.focus == false and test.focus == false then |
156 | 0 | continue |
157 | N/A | end | >
158 | 73 | print(`{status}{WALL} {line}{color.gray(case.name)}`) |
159 | N/A | end | >
160 | N/A | | >
161 | 24 | if test.error then |
162 | 0 | print(color.gray("error: ") .. color.red(test.error.message)) |
163 | 0 | print(color.gray("trace: ") .. color.red(test.error.trace)) |
164 | 0 | else |
165 | 24 | print() |
166 | N/A | end | >
167 | N/A | end | >
168 | N/A | | >
169 | 1 | local function CASE(name: string) |
170 | 73 | skip = false |
171 | 73 | assert(test, "no active test") |
172 | N/A | | >
173 | 73 | local case = { |
174 | 73 | name = name, |
175 | 73 | result = NONE, |
176 | 73 | focus = false, |
177 | 0 | } |
178 | N/A | | >
179 | 73 | test.case = case |
180 | 73 | table.insert(test.cases, case) |
181 | N/A | end | >
182 | N/A | | >
183 | 1 | local function CHECK_EXPECT_ERR(fn, ...) |
184 | 9 | assert(test, "no active test") |
185 | 9 | local case = test.case |
186 | 9 | if not case then |
187 | 0 | CASE("") |
188 | 0 | case = test.case |
189 | N/A | end | >
190 | 9 | assert(case, "no active case") |
191 | 9 | if case.result ~= FAIL then |
192 | 9 | local ok, err = pcall(fn, ...) |
193 | 9 | case.result = if ok then FAIL else PASS |
194 | 9 | if skip then |
195 | 0 | case.result = SKIPPED |
196 | N/A | end | >
197 | 9 | case.line = debug.info(stack and stack + 1 or 2, "l") |
198 | N/A | end | >
199 | N/A | end | >
200 | N/A | | >
201 | 1 | local function CHECK(value: T, stack: number?): T? |
202 | 1195 | assert(test, "no active test") |
203 | N/A | | >
204 | 1195 | local case = test.case |
205 | N/A | | >
206 | 1195 | if not case then |
207 | 9 | CASE("") |
208 | 9 | case = test.case |
209 | N/A | end | >
210 | N/A | | >
211 | 1195 | assert(case, "no active case") |
212 | N/A | | >
213 | 1195 | if case.result ~= FAIL then |
214 | 1195 | case.result = value and PASS or FAIL |
215 | 1195 | if skip then |
216 | 1 | case.result = SKIPPED |
217 | N/A | end | >
218 | 1195 | case.line = debug.info(stack and stack + 1 or 2, "l") |
219 | N/A | end | >
220 | N/A | | >
221 | 1195 | return value |
222 | N/A | end | >
223 | N/A | | >
224 | 1 | local function TEST(name: string, fn: () -> ()) |
225 | N/A | | >
226 | 24 | test = { |
227 | 24 | name = name, |
228 | 24 | cases = {}, |
229 | 24 | duration = 0, |
230 | 24 | focus = false, |
231 | 24 | fn = fn |
232 | 0 | } |
233 | N/A | | >
234 | 24 | table.insert(tests, test) |
235 | N/A | end | >
236 | N/A | | >
237 | 1 | local function FOCUS() |
238 | 0 | assert(test, "no active test") |
239 | N/A | | >
240 | 0 | check_for_focused = true |
241 | 0 | if test.case then |
242 | 0 | test.case.focus = true |
243 | 0 | else |
244 | 0 | test.focus = true |
245 | N/A | end | >
246 | N/A | end | >
247 | N/A | | >
248 | 1 | local function FINISH(): boolean |
249 | 1 | local success = true |
250 | 1 | local total_cases = 0 |
251 | 1 | local passed_cases = 0 |
252 | 1 | local passed_focus_cases = 0 |
253 | 1 | local total_focus_cases = 0 |
254 | 1 | local duration = 0 |
255 | N/A | | >
256 | 1 | for _, t in tests do |
257 | 24 | if check_for_focused and not t.focus then |
258 | 0 | continue |
259 | N/A | end | >
260 | 24 | test = t |
261 | 24 | fn = t.fn |
262 | 24 | local start = os.clock() |
263 | 24 | local err |
264 | 24 | local success = xpcall(fn, function(m: string) |
265 | 0 | err = { message = m, trace = debug.traceback(nil, 2) } |
266 | N/A | end) | >
267 | 24 | test.duration = os.clock() - start |
268 | N/A | | >
269 | 24 | if not test.case then |
270 | 0 | CASE("") |
271 | N/A | end | >
272 | 24 | assert(test.case, "no active case") |
273 | N/A | | >
274 | 24 | if not success then |
275 | 0 | test.case.result = ERROR |
276 | 0 | test.error = err |
277 | N/A | end | >
278 | 24 | collectgarbage() |
279 | N/A | end | >
280 | N/A | | >
281 | 1 | for _, test in tests do |
282 | 24 | duration += test.duration |
283 | 24 | for _, case in test.cases do |
284 | 73 | total_cases += 1 |
285 | 73 | if case.focus or test.focus then |
286 | 0 | total_focus_cases += 1 |
287 | N/A | end | >
288 | 73 | if case.result == PASS or case.result == NONE or case.result == SKIPPED then |
289 | 73 | if case.focus or test.focus then |
290 | 0 | passed_focus_cases += 1 |
291 | N/A | end | >
292 | 73 | passed_cases += 1 |
293 | 0 | else |
294 | 0 | success = false |
295 | N/A | end | >
296 | N/A | end | >
297 | N/A | | >
298 | 24 | output_test_result(test) |
299 | N/A | end | >
300 | N/A | | >
301 | 1 | print(color.gray(string.format(`{passed_cases}/{total_cases} test cases passed in %.3f ms.`, duration * 1e3))) |
302 | 1 | if check_for_focused then |
303 | 0 | print(color.gray(`{passed_focus_cases}/{total_focus_cases} focused test cases passed`)) |
304 | N/A | end | >
305 | N/A | | >
306 | 1 | local fails = total_cases - passed_cases |
307 | N/A | | >
308 | 1 | print((fails > 0 and color.red or color.green)(`{fails} {fails == 1 and "fail" or "fails"}`)) |
309 | N/A | | >
310 | 1 | check_for_focused = false |
311 | 1 | return success, table.clear(tests) |
312 | N/A | end | >
313 | N/A | | >
314 | 1 | local function SKIP() |
315 | 1 | skip = true |
316 | N/A | end | >
317 | N/A | | >
318 | N/A | -------------------------------------------------------------------------------- | >
319 | N/A | -- Benchmarking | >
320 | N/A | -------------------------------------------------------------------------------- | >
321 | N/A | | >
322 | 0 | type Bench = { |
323 | 0 | time_start: number?, |
324 | 0 | memory_start: number?, |
325 | 0 | iterations: number?, |
326 | 0 | } |
327 | N/A | | >
328 | 1 | local bench: Bench? |
329 | N/A | | >
330 | 1 | function START(iter: number?): number |
331 | 1 | local n = iter or 1 |
332 | 1 | assert(n > 0, "iterations must be greater than 0") |
333 | 1 | assert(bench, "no active benchmark") |
334 | 1 | assert(not bench.time_start, "clock was already started") |
335 | N/A | | >
336 | 1 | bench.iterations = n |
337 | 1 | bench.memory_start = gcinfo() |
338 | 1 | bench.time_start = os.clock() |
339 | 1 | return n |
340 | N/A | end | >
341 | N/A | | >
342 | 1 | local function BENCH(name: string, fn: () -> ()) |
343 | 3 | local active = bench |
344 | 3 | assert(not active, "a benchmark is already in progress") |
345 | N/A | | >
346 | 3 | bench = {} |
347 | 3 | assert(bench); |
348 | 3 | (collectgarbage :: any)("collect") |
349 | N/A | | >
350 | 3 | local mem_start = gcinfo() |
351 | 3 | local time_start = os.clock() |
352 | 3 | local err_msg: string? |
353 | N/A | | >
354 | 3 | local success = xpcall(fn, function(m: string) |
355 | 0 | err_msg = m .. debug.traceback(nil, 2) |
356 | N/A | end) | >
357 | N/A | | >
358 | 3 | local time_stop = os.clock() |
359 | 3 | local mem_stop = gcinfo() |
360 | N/A | | >
361 | 3 | if not success then |
362 | 0 | print(`{WALL}{color.red("ERROR")}{WALL} {name}`) |
363 | 0 | print(color.gray(err_msg :: string)) |
364 | 0 | else |
365 | 3 | time_start = bench.time_start or time_start |
366 | 3 | mem_start = bench.memory_start or mem_start |
367 | N/A | | >
368 | 3 | local n = bench.iterations or 1 |
369 | 3 | local d, d_unit = convert_units("s", (time_stop - time_start) / n) |
370 | 3 | local a, a_unit = convert_units("B", math.round((mem_stop - mem_start) / n * 1e3)) |
371 | N/A | | >
372 | 3 | local function round(x: number): string |
373 | 6 | return x > 0 and x < 10 and (x - math.floor(x)) > 0 and string.format("%2.1f", x) |
374 | 6 | or string.format("%3.f", x) |
375 | N/A | end | >
376 | N/A | | >
377 | 3 | print( |
378 | 3 | string.format( |
379 | 3 | `%s %s %s %s{WALL} %s`, |
380 | 3 | color.gray(round(d)), |
381 | 3 | d_unit, |
382 | 3 | color.gray(round(a)), |
383 | 3 | a_unit, |
384 | 3 | color.gray(name) |
385 | 0 | ) |
386 | 0 | ) |
387 | N/A | end | >
388 | N/A | | >
389 | 3 | bench = nil |
390 | N/A | end | >
391 | N/A | | >
392 | N/A | -------------------------------------------------------------------------------- | >
393 | N/A | -- Printing | >
394 | N/A | -------------------------------------------------------------------------------- | >
395 | N/A | | >
396 | 1 | local function print2(v: unknown) |
397 | 0 | type Buffer = { n: number, [number]: string } |
398 | 0 | type Cyclic = { n: number, [{}]: number } |
399 | N/A | | >
400 | N/A | -- overkill concatenationless string buffer | >
401 | 0 | local function tos(value: any, stack: number, str: Buffer, cyclic: Cyclic) |
402 | 0 | local TAB = " " |
403 | 0 | local indent = table.concat(table.create(stack, TAB)) |
404 | N/A | | >
405 | 0 | if type(value) == "string" then |
406 | 0 | local n = str.n |
407 | 0 | str[n + 1] = "\"" |
408 | 0 | str[n + 2] = value |
409 | 0 | str[n + 3] = "\"" |
410 | 0 | str.n = n + 3 |
411 | 0 | elseif type(value) ~= "table" then |
412 | 0 | local n = str.n |
413 | 0 | str[n + 1] = value == nil and "nil" or tostring(value) |
414 | 0 | str.n = n + 1 |
415 | 0 | elseif next(value) == nil then |
416 | 0 | local n = str.n |
417 | 0 | str[n + 1] = "{}" |
418 | 0 | str.n = n + 1 |
419 | 0 | else -- is table |
420 | 0 | local tabbed_indent = indent .. TAB |
421 | N/A | | >
422 | 0 | if cyclic[value] then |
423 | 0 | str.n += 1 |
424 | 0 | str[str.n] = color.gray(`CYCLIC REF {cyclic[value]}`) |
425 | 0 | return |
426 | 0 | else |
427 | 0 | cyclic.n += 1 |
428 | 0 | cyclic[value] = cyclic.n |
429 | N/A | end | >
430 | N/A | | >
431 | 0 | str.n += 3 |
432 | 0 | str[str.n - 2] = "{ " |
433 | 0 | str[str.n - 1] = color.gray(tostring(cyclic[value])) |
434 | 0 | str[str.n - 0] = "\n" |
435 | N/A | | >
436 | 0 | local i, v = next(value, nil) |
437 | 0 | while v ~= nil do |
438 | 0 | local n = str.n |
439 | 0 | str[n + 1] = tabbed_indent |
440 | N/A | | >
441 | 0 | if type(i) ~= "string" then |
442 | 0 | str[n + 2] = "[" |
443 | 0 | str[n + 3] = tostring(i) |
444 | 0 | str[n + 4] = "]" |
445 | 0 | n += 4 |
446 | 0 | else |
447 | 0 | str[n + 2] = tostring(i) |
448 | 0 | n += 2 |
449 | N/A | end | >
450 | N/A | | >
451 | 0 | str[n + 1] = " = " |
452 | 0 | str.n = n + 1 |
453 | N/A | | >
454 | 0 | tos(v, stack + 1, str, cyclic) |
455 | N/A | | >
456 | 0 | i, v = next(value, i) |
457 | N/A | | >
458 | 0 | n = str.n |
459 | 0 | str[n + 1] = v ~= nil and ",\n" or "\n" |
460 | 0 | str.n = n + 1 |
461 | N/A | end | >
462 | N/A | | >
463 | 0 | local n = str.n |
464 | 0 | str[n + 1] = indent |
465 | 0 | str[n + 2] = "}" |
466 | 0 | str.n = n + 2 |
467 | N/A | end | >
468 | N/A | end | >
469 | N/A | | >
470 | 0 | local str = { n = 0 } |
471 | 0 | local cyclic = { n = 0 } |
472 | 0 | tos(v, 0, str, cyclic) |
473 | 0 | print(table.concat(str)) |
474 | N/A | end | >
475 | N/A | | >
476 | N/A | -------------------------------------------------------------------------------- | >
477 | N/A | -- Equality | >
478 | N/A | -------------------------------------------------------------------------------- | >
479 | N/A | | >
480 | 1 | local function shallow_eq(a: {}, b: {}): boolean |
481 | 0 | if #a ~= #b then |
482 | 0 | return false |
483 | N/A | end | >
484 | N/A | | >
485 | 0 | for i, v in next, a do |
486 | 0 | if b[i] ~= v then |
487 | 0 | return false |
488 | N/A | end | >
489 | N/A | end | >
490 | N/A | | >
491 | 0 | for i, v in next, b do |
492 | 0 | if a[i] ~= v then |
493 | 0 | return false |
494 | N/A | end | >
495 | N/A | end | >
496 | N/A | | >
497 | 0 | return true |
498 | N/A | end | >
499 | N/A | | >
500 | 1 | local function deep_eq(a: {}, b: {}): boolean |
501 | 0 | if #a ~= #b then |
502 | 0 | return false |
503 | N/A | end | >
504 | N/A | | >
505 | 0 | for i, v in next, a do |
506 | 0 | if type(b[i]) == "table" and type(v) == "table" then |
507 | 0 | if deep_eq(b[i], v) == false then |
508 | 0 | return false |
509 | N/A | end | >
510 | 0 | elseif b[i] ~= v then |
511 | 0 | return false |
512 | N/A | end | >
513 | N/A | end | >
514 | N/A | | >
515 | 0 | for i, v in next, b do |
516 | 0 | if type(a[i]) == "table" and type(v) == "table" then |
517 | 0 | if deep_eq(a[i], v) == false then |
518 | 0 | return false |
519 | N/A | end | >
520 | 0 | elseif a[i] ~= v then |
521 | 0 | return false |
522 | N/A | end | >
523 | N/A | end | >
524 | N/A | | >
525 | 0 | return true |
526 | N/A | end | >
527 | N/A | | >
528 | N/A | -------------------------------------------------------------------------------- | >
529 | N/A | -- Return | >
530 | N/A | -------------------------------------------------------------------------------- | >
531 | N/A | | >
532 | 1 | return { |
533 | 1 | test = function() |
534 | 1 | return { |
535 | 1 | TEST = TEST, |
536 | 1 | CASE = CASE, |
537 | 1 | CHECK = CHECK, |
538 | 1 | FINISH = FINISH, |
539 | 1 | SKIP = SKIP, |
540 | 1 | FOCUS = FOCUS, |
541 | 1 | CHECK_EXPECT_ERR = CHECK_EXPECT_ERR, |
542 | 0 | } |
543 | N/A | end, | >
544 | N/A | | >
545 | 1 | benchmark = function() |
546 | 1 | return BENCH, START |
547 | N/A | end, | >
548 | N/A | | >
549 | 1 | disable_formatting = function() |
550 | 0 | disable_ansi = true |
551 | N/A | end, | >
552 | N/A | | >
553 | 1 | print = print2, |
554 | N/A | | >
555 | 1 | seq = shallow_eq, |
556 | 1 | deq = deep_eq, |
557 | N/A | | >
558 | 1 | color = color, |
559 | 0 | } |