Files
Odin/core/testing/reporting.odin
gingerBill 842cfee0f3 Change Odin's LICENSE to zlib from BSD 3-clause
This change was made in order to allow things produced with Odin and using Odin's core library, to not require the LICENSE to also be distributed alongside the binary form.
2025-10-28 14:38:25 +00:00

338 lines
9.1 KiB
Odin

#+private
package testing
/*
(c) Copyright 2024 Feoramund <rune@swevencraft.org>.
Made available under Odin's license.
List of contributors:
Feoramund: Total rewrite.
*/
import "base:runtime"
import "core:fmt"
import "core:io"
import "core:mem"
import "core:path/filepath"
import "core:strings"
import "core:terminal/ansi"
// Definitions of colors for use in the test runner.
SGR_RESET :: ansi.CSI + ansi.RESET + ansi.SGR
SGR_READY :: ansi.CSI + ansi.FG_BRIGHT_BLACK + ansi.SGR
SGR_RUNNING :: ansi.CSI + ansi.FG_YELLOW + ansi.SGR
SGR_SUCCESS :: ansi.CSI + ansi.FG_GREEN + ansi.SGR
SGR_FAILED :: ansi.CSI + ansi.FG_RED + ansi.SGR
MAX_PROGRESS_WIDTH :: 100
// More than enough bytes to cover long package names, long test names, dozens
// of ANSI codes, et cetera.
LINE_BUFFER_SIZE :: (MAX_PROGRESS_WIDTH * 8 + 224) * runtime.Byte
PROGRESS_COLUMN_SPACING :: 2
Package_Run :: struct {
name: string,
header: string,
frame_ready: bool,
redraw_buffer: [LINE_BUFFER_SIZE]byte,
redraw_string: string,
last_change_state: Test_State,
last_change_name: string,
tests: []Internal_Test,
test_states: []Test_State,
}
Report :: struct {
packages: []Package_Run,
packages_by_name: map[string]^Package_Run,
pkg_column_len: int,
test_column_len: int,
progress_width: int,
all_tests: []Internal_Test,
all_test_states: []Test_State,
}
// Organize all tests by package and sort out test state data.
make_report :: proc(internal_tests: []Internal_Test) -> (report: Report, error: runtime.Allocator_Error) {
assert(len(internal_tests) > 0, "make_report called with no tests")
packages: [dynamic]Package_Run
report.all_tests = internal_tests
report.all_test_states = make([]Test_State, len(internal_tests)) or_return
// First, figure out what belongs where.
#no_bounds_check cur_pkg := internal_tests[0].pkg
pkg_start: int
// This loop assumes the tests are sorted by package already.
for it, index in internal_tests {
if cur_pkg != it.pkg {
#no_bounds_check {
append(&packages, Package_Run {
name = cur_pkg,
tests = report.all_tests[pkg_start:index],
test_states = report.all_test_states[pkg_start:index],
}) or_return
}
when PROGRESS_WIDTH == 0 {
report.progress_width = max(report.progress_width, index - pkg_start)
}
pkg_start = index
report.pkg_column_len = max(report.pkg_column_len, len(cur_pkg))
cur_pkg = it.pkg
}
report.test_column_len = max(report.test_column_len, len(it.name))
}
// Handle the last (or only) package.
#no_bounds_check {
append(&packages, Package_Run {
name = cur_pkg,
header = cur_pkg,
tests = report.all_tests[pkg_start:],
test_states = report.all_test_states[pkg_start:],
}) or_return
}
when PROGRESS_WIDTH == 0 {
report.progress_width = max(report.progress_width, len(internal_tests) - pkg_start)
} else {
report.progress_width = PROGRESS_WIDTH
}
report.progress_width = min(report.progress_width, MAX_PROGRESS_WIDTH)
report.pkg_column_len = PROGRESS_COLUMN_SPACING + max(report.pkg_column_len, len(cur_pkg))
shrink(&packages) or_return
for &pkg in packages {
pkg.header = fmt.aprintf("%- *[1]s[", pkg.name, report.pkg_column_len)
assert(len(pkg.header) > 0, "Error allocating package header string.")
// This is safe because the array is done resizing, and it has the same
// lifetime as the map.
report.packages_by_name[pkg.name] = &pkg
}
// It's okay to discard the dynamic array's allocator information here,
// because its capacity has been shrunk to its length, it was allocated by
// the caller's context allocator, and it will be deallocated by the same.
//
// `delete_slice` is equivalent to `delete_dynamic_array` in this case.
report.packages = packages[:]
return
}
destroy_report :: proc(report: ^Report) {
for pkg in report.packages {
delete(pkg.header)
}
delete(report.packages)
delete(report.packages_by_name)
delete(report.all_test_states)
}
redraw_package :: proc(w: io.Writer, report: Report, pkg: ^Package_Run) {
if pkg.frame_ready {
io.write_string(w, pkg.redraw_string)
return
}
// Write the output line here so we can cache it.
line_builder := strings.builder_from_bytes(pkg.redraw_buffer[:])
line_writer := strings.to_writer(&line_builder)
highest_run_index: int
failed_count: int
done_count: int
#no_bounds_check for i := 0; i < len(pkg.test_states); i += 1 {
switch pkg.test_states[i] {
case .Ready:
continue
case .Running:
highest_run_index = max(highest_run_index, i)
case .Successful:
done_count += 1
case .Failed:
failed_count += 1
done_count += 1
}
}
start := max(0, highest_run_index - (report.progress_width - 1))
end := min(start + report.progress_width, len(pkg.test_states))
// This variable is to keep track of the last ANSI code emitted, in
// order to avoid repeating the same code over in a sequence.
//
// This should help reduce screen flicker.
last_state := Test_State(-1)
io.write_string(line_writer, pkg.header)
#no_bounds_check for state in pkg.test_states[start:end] {
switch state {
case .Ready:
if last_state != state {
io.write_string(line_writer, SGR_READY)
last_state = state
}
case .Running:
if last_state != state {
io.write_string(line_writer, SGR_RUNNING)
last_state = state
}
case .Successful:
if last_state != state {
io.write_string(line_writer, SGR_SUCCESS)
last_state = state
}
case .Failed:
if last_state != state {
io.write_string(line_writer, SGR_FAILED)
last_state = state
}
}
io.write_byte(line_writer, '|')
}
for _ in 0 ..< report.progress_width - (end - start) {
io.write_byte(line_writer, ' ')
}
io.write_string(line_writer, SGR_RESET + "] ")
ticker: string
if done_count == len(pkg.test_states) {
ticker = "[package done]"
if failed_count > 0 {
ticker = fmt.tprintf("%s (" + SGR_FAILED + "%i" + SGR_RESET + " failed)", ticker, failed_count)
}
} else {
if len(pkg.last_change_name) == 0 {
#no_bounds_check pkg.last_change_name = pkg.tests[0].name
}
switch pkg.last_change_state {
case .Ready:
ticker = fmt.tprintf(SGR_READY + "%s" + SGR_RESET, pkg.last_change_name)
case .Running:
ticker = fmt.tprintf(SGR_RUNNING + "%s" + SGR_RESET, pkg.last_change_name)
case .Failed:
ticker = fmt.tprintf(SGR_FAILED + "%s" + SGR_RESET, pkg.last_change_name)
case .Successful:
ticker = fmt.tprintf(SGR_SUCCESS + "%s" + SGR_RESET, pkg.last_change_name)
}
}
if done_count == len(pkg.test_states) {
fmt.wprintfln(line_writer, " % 4i :: %s",
len(pkg.test_states),
ticker,
)
} else {
fmt.wprintfln(line_writer, "% 4i/% 4i :: %s",
done_count,
len(pkg.test_states),
ticker,
)
}
pkg.redraw_string = strings.to_string(line_builder)
pkg.frame_ready = true
io.write_string(w, pkg.redraw_string)
}
redraw_report :: proc(w: io.Writer, report: Report) {
// If we print a line longer than the user's terminal can handle, it may
// wrap around, shifting the progress report out of alignment.
//
// There are ways to get the current terminal width, and that would be the
// ideal way to handle this, but it would require system-specific code such
// as setting STDIN to be non-blocking in order to read the response from
// the ANSI DSR escape code, or reading environment variables.
//
// The DECAWM escape codes control whether or not the terminal will wrap
// long lines or overwrite the last visible character.
// This should be fine for now.
//
// Note that we only do this for the animated summary; log messages are
// still perfectly fine to wrap, as they're printed in their own batch,
// whereas the animation depends on each package being only on one line.
//
// Of course, if you resize your terminal while it's printing, things can
// still break...
fmt.wprint(w, ansi.CSI + ansi.DECAWM_OFF)
for &pkg in report.packages {
redraw_package(w, report, &pkg)
}
fmt.wprint(w, ansi.CSI + ansi.DECAWM_ON)
}
needs_to_redraw :: proc(report: Report) -> bool {
for pkg in report.packages {
if !pkg.frame_ready {
return true
}
}
return false
}
draw_status_bar :: proc(w: io.Writer, threads_string: string, total_done_count, total_test_count: int) {
if total_done_count == total_test_count {
// All tests are done; print a blank line to maintain the same height
// of the progress report.
fmt.wprintln(w)
} else {
fmt.wprintfln(w,
"%s % 4i/% 4i :: total",
threads_string,
total_done_count,
total_test_count)
}
}
write_memory_report :: proc(w: io.Writer, tracker: ^mem.Tracking_Allocator, pkg, name: string) {
fmt.wprintf(w,
"<% 10M/% 10M> <% 10M> (% 5i/% 5i) :: %s.%s",
tracker.current_memory_allocated,
tracker.total_memory_allocated,
tracker.peak_memory_allocated,
tracker.total_free_count,
tracker.total_allocation_count,
pkg,
name)
for ptr, entry in tracker.allocation_map {
fmt.wprintf(w,
"\n +++ leak % 10M @ %p [%s:%i:%s()]",
entry.size,
ptr,
filepath.base(entry.location.file_path),
entry.location.line,
entry.location.procedure)
}
for entry in tracker.bad_free_array {
fmt.wprintf(w,
"\n +++ bad free @ %p [%s:%i:%s()]",
entry.memory,
filepath.base(entry.location.file_path),
entry.location.line,
entry.location.procedure)
}
}