From d3aa58655a106d851cfbdd373067c53b3190de92 Mon Sep 17 00:00:00 2001 From: Peter Slattery Date: Fri, 10 Nov 2023 12:47:11 -0800 Subject: [PATCH] Initial implementation --- harness.jai | 294 +++++++++++++++++++++++++++++++++ module.jai | 243 +++++++++++++++++++++++++++ tests/basic_build.jai | 25 +++ tests/basic_usage.gs_test.jai | 12 ++ tests/before_after.gs_test.jai | 26 +++ tests/build_tests.sh | 22 +++ tests/example_math.jai | 8 + tests/test.sh | 21 +++ 8 files changed, 651 insertions(+) create mode 100644 harness.jai create mode 100644 module.jai create mode 100644 tests/basic_build.jai create mode 100644 tests/basic_usage.gs_test.jai create mode 100644 tests/before_after.gs_test.jai create mode 100755 tests/build_tests.sh create mode 100644 tests/example_math.jai create mode 100644 tests/test.sh diff --git a/harness.jai b/harness.jai new file mode 100644 index 0000000..9aa2b80 --- /dev/null +++ b/harness.jai @@ -0,0 +1,294 @@ +#import "Basic"; +#import "String"; + +/////////////////////////////////////////////////////////////////////// +// BEGIN INTERFACE + +// Register a function to run before each test +Before_Each :: (proc: Test_Proc, loc := #caller_location) #expand +{ + h := harness(); + if h.before_each + { + print_error("You cannot register more than one Before_Each handler", loc); + exit(1); + } + h.before_each = proc; +} + +// Register a function to run after each test +After_Each :: (proc: Test_Proc, loc := #caller_location) #expand +{ + h := harness(); + if h.after_each + { + print_error("You cannot register more than one After_Each handler", loc); + exit(1); + } + h.after_each = proc; +} + +// Wrapper for registering a test procedure you want to have run +Test :: (name: string, proc: Test_Proc, loc := #caller_location, only := false) #expand +{ + h := harness(); + test_index := h.tests.count; + array_add(*h.tests, .{ + name = name, + proc = proc + }); + + if only + { + if h.only_index < 0 + { + h.only_index = test_index; + } + else + { + print_error("You cannot register more than one test to be the only one to run.", loc); + exit(1); + } + } +} + +// Call this at the beginning of your main function +Init_Test_Harness :: (prefix: string = "", loc := #caller_location) #expand +{ + h := harness(); + << h = Test_Harness.{ + test_prefix = prefix, + filename = tprint("%", #file), + }; + init_string_builder(*h.message_builder); + + apply_command_line_arguments(h); + + print("%\n", loc.fully_pathed_filename); +} + +// Call this at the end of your main function +Run_Test_Harness :: (loc:= #caller_location) #expand +{ + h := harness(); + + if h.only_index >= 0 && h.only_index < h.tests.count + { + run_single_test(h, h.tests[h.only_index]); + } + else + { + for h.tests + { + run_single_test(h, it); + } + } + + return_code := finish_all_tests(); + exit(return_code); +} + +EXPECT_MACRO :: (input: $T, expected: T, loc: Source_Code_Location, info: string, condition: Code) #expand { + test_begin(); + passed := #insert condition; + if passed { + report_test_passed(); + } else { + report_test_failed(input, expected, loc, info); + } +} + +expect :: (input: $T, expected: T, loc := #caller_location, info := "") { + EXPECT_MACRO(input, expected, loc, info, #code input == expected); +} +expect_strings_equal :: (input: string, expected: string, loc:= #caller_location, info := "") { + EXPECT_MACRO(input, expected, loc, info, #code equal(input, expected)); +} +expect_true :: (input: bool, loc:= #caller_location, info := "") { + EXPECT_MACRO(input, true, loc, info, #code input == true); +} +expect_false :: (input: bool, loc:= #caller_location, info := "") { + EXPECT_MACRO(input, false, loc, info, #code input == false); +} + +// END INTERFACE +/////////////////////////////////////////////////////////////////////// + +test_begin :: () { + TESTS_ATTEMPTED += 1; +} + +report_test_passed :: () { + TESTS_PASSED += 1; +} + +report_test_failed :: (input: $T, expected: T, loc: Source_Code_Location, info: string) { + h := harness(); + ANY_TESTS_FAILED = true; + print_to_builder( + *h.message_builder, + " %Test Failed:%\n", + h.colors.red, + h.colors.normal + ); + print_to_builder(*h.message_builder, + " %:%:%\n", loc.fully_pathed_filename, loc.line_number, loc.character_number + ); + print_to_builder(*h.message_builder, + " Expected: %\n", expected + ); + print_to_builder(*h.message_builder, + " Received: %\n", input + ); + if info.count > 0 { + print_to_builder(*h.message_builder, " Info: %\n", info); + } +} + +#scope_file + +ANY_TESTS_FAILED := false; +TESTS_ATTEMPTED := 0; +TESTS_PASSED := 0; +TOTAL_TESTS_ATTEMPTED := 0; +TOTAL_TESTS_PASSED := 0; + +Terminal_Color_Codes :: struct { + normal: string; + red: string; + green: string; + blue: string; +}; + +colors :: Terminal_Color_Codes.{ + normal = "\e[m", + red = "\e[1;31m", + green = "\e[1;32m", + blue = "\e[1;34m", +}; +no_colors :: Terminal_Color_Codes.{}; + +Test_Proc :: #type () -> (); + +Test_Desc :: struct { + name: string; + proc: Test_Proc; +} + +Test_Harness :: struct { + filename: string; + tests: [..]Test_Desc; + + // if set, then tests[only_index] will be the only one to run. + // it is an error to set multiple tests to only. + only_index := -1; + + before_each: Test_Proc; + after_each: Test_Proc; + test_prefix: string = ""; + + verbose: bool = false; + colors := no_colors; + + message_builder: String_Builder; +} + +#add_context global_test_harness: Test_Harness; +harness :: () -> *Test_Harness { return *context.global_test_harness; } + +apply_command_line_arguments :: (harness: *Test_Harness) +{ + args := get_command_line_arguments(); + for arg: args { + if arg[0] != #char "-" continue; + if arg == { + case "-colors"; harness.colors = colors; + case "-verbose"; harness.verbose = true; + } + } +} + +test_suite_end :: (name: string) +{ + h := harness(); + print_test_name :: (h: Test_Harness, name: string, prefix: string) + { + print("%", h.colors.blue); + if (prefix.count) print("% :: %\n", prefix, name); + else print("%\n", name); + print("%", h.colors.normal); + } + + if TESTS_ATTEMPTED == TESTS_PASSED + { + if TESTS_ATTEMPTED > 0 + { + if (h.verbose) + { + print(" %PASS ", h.colors.green); + print_test_name(h, name, h.test_prefix); + } + } + else + { + print(" %NO TESTS RUN ", h.colors.red); + print_test_name(h, name, h.test_prefix); + } + } + else + { + print(" %FAIL ", h.colors.red); + print_test_name(h, name, h.test_prefix); + } + + messages := builder_to_string(*h.message_builder, do_reset = true); + print(messages); + + TOTAL_TESTS_ATTEMPTED += TESTS_ATTEMPTED; + TOTAL_TESTS_PASSED += TESTS_PASSED; + TESTS_ATTEMPTED = 0; + TESTS_PASSED = 0; +} + +run_single_test :: (h: *Test_Harness, test: Test_Desc) +{ + if h.before_each + { + h.before_each(); + } + + test.proc(); + test_suite_end(test.name); + + if h.after_each + { + h.after_each(); + } +} + +finish_all_tests :: () -> s32 +{ + h := harness(); + return_code: s32 = 0; + + if !ANY_TESTS_FAILED + { + print("Tests: % / %\n", TOTAL_TESTS_PASSED, TOTAL_TESTS_ATTEMPTED); + print(" SUITE STATUS: %PASS%\n\n", h.colors.green, h.colors.normal); + } + else + { + return_code = 1; + print("Tests: % / %\n", TOTAL_TESTS_PASSED, TOTAL_TESTS_ATTEMPTED); + print(" SUITE_STATUS: %FAIL%\n\n", h.colors.red, h.colors.normal); + } + return return_code; +} + +print_error :: (msg: string, loc: Source_Code_Location) +{ + h := harness(); + print("%", h.colors.red); + print("%:% - %\n", loc.fully_pathed_filename, loc.line_number, msg); + print("%", h.colors.normal); +} \ No newline at end of file diff --git a/module.jai b/module.jai new file mode 100644 index 0000000..f095793 --- /dev/null +++ b/module.jai @@ -0,0 +1,243 @@ +#import "Basic"; +#import "Compiler"; +#import "File"; +#import "File_Utilities"; +#import "String"; + +Test_Category :: struct +{ + file_extension: string = ".test.jai"; + // The extension all test files will end in. + // You can override this, but it is recommended that you never set it to .jai as + // that will match all your normal jai files as well. + + exe_extension: string = ""; + // The extension of the output test executable. + // If not set, this will be overridden + // with .test.exe on windows and .test everywhere else. + + category_name: string = "default"; + // If set to anything other than defualt, will put these tests inside an if block inside + // run_all_tests that checks for this name in the arguments to run_all_tests.sh + // ie. if category_name is "unit", then calling "run_all_tests.sh unit" will run just the + // tests in this category + + exclude_from_run_all_tests: bool = false; + // This is useful if you have a category of tests that need to be run manually, or don't + // make sense to be run in CI +} + +Config :: struct +{ + root_paths: []string; + // REQUIRED: The root directory in which tests are found + + test_categories: []Test_Category; + // REQUIRED: You must supply at least one test category. However, it is enough to simply + // do: + // config.test_categories = .[.{}]; + + tests_output_dir: string = ""; + // Where test executables will be output. + // If not set, this will be set to the value of {build_options.root_path}/tests_out +} + +build_all_tests :: (config: Config, build_options: Build_Options, loc := #caller_location) +{ + set_optimization(*build_options, .DEBUG); + configure(config, build_options, loc); + output(); +} + +configure :: (config: Config, build_options: Build_Options, loc := #caller_location) +{ + stored_config = config; + + if stored_config.test_categories.count == 0 + { + print("gs_test: Error - No test_categories provided, so no tests will be build. Aborting.\n"); + exit(1); + } + + for * stored_config.test_categories + { + if (it.file_extension == "") it.file_extension = ".test.jai"; + if (it.exe_extension == "") + { + it.exe_extension = ".test"; + } + } + + if (stored_config.root_paths.count == 0) + { + compiler_report(loc.fully_pathed_filename, loc.line_number, loc.character_number, "You must supply a root_path in gs_test's config object"); + exit(1); + } + + if (stored_config.tests_output_dir == "") + { + path := parse_path(stored_config.root_paths[0]); + array_add(*path.words, "tests_out"); + stored_config.tests_output_dir = path_to_string(path); + print("Fallback tests_output_dir: %\n", stored_config.tests_output_dir); + } + else + { + for i: 0..stored_config.tests_output_dir.count - 1 + { + if stored_config.tests_output_dir[i] == #char "\\" + { + stored_config.tests_output_dir[i] = #char "/"; + } + } + path := parse_path(stored_config.tests_output_dir); + stored_config.tests_output_dir = path_to_string(path); + } + + stored_build_options = build_options; + stored_build_options.output_path = stored_config.tests_output_dir; + + make_directory_if_it_does_not_exist(stored_build_options.output_path, true); + + for root_path: config.root_paths + { + visit_files(root_path, true, *test_files, record_test_file); + } +} + +output :: () +{ + run_all_tests_builder: String_Builder; + init_string_builder(*run_all_tests_builder); + append(*run_all_tests_builder, "#!/bin/bash\n"); + append(*run_all_tests_builder, "SUCCESS=0\n"); + append(*run_all_tests_builder, "ARGS=$@\n"); + + category_builders: [..]String_Builder; + for stored_config.test_categories + { + b := array_add(*category_builders); + init_string_builder(b); + } + + for test_files + { + category := stored_config.test_categories[it.category]; + exe_path := build_test_file(it.file_path, category); + if (exe_path != "" && !category.exclude_from_run_all_tests) + { + print_to_builder(*category_builders[it.category], "% $@\n%", exe_path, UPDATE_SUCCESS_CODE); + } + } + + for category, i: stored_config.test_categories + { + if category.exclude_from_run_all_tests continue; + + category_builder := *category_builders[i]; + print_to_builder(*run_all_tests_builder, "\n# Category: %\n", category.category_name); + if category.category_name != "default" + { + print_to_builder(*run_all_tests_builder, "if [[ \" ${ARGS[*]} \" =~ \" % \" ]]; then\n", category.category_name); + } + print_to_builder(*run_all_tests_builder, "%\n", builder_to_string(category_builder)); + if category.category_name != "default" + { + print_to_builder(*run_all_tests_builder, "fi\n"); + } + } + + append(*run_all_tests_builder, "exit $SUCCESS"); + run_all_tests_sh := builder_to_string(*run_all_tests_builder); + write_entire_file(string_append(stored_config.tests_output_dir, "/run_all_tests.sh"), run_all_tests_sh); +} + +#scope_file + +stored_config: Config; +stored_build_options: Build_Options; + +Test_File :: struct +{ + file_path: string; + category: int; +} + +test_files: [..]Test_File; + +UPDATE_SUCCESS_CODE :: #string DONE +if [ $? != 0 ]; +then + SUCCESS=1 +fi +DONE + +string_append :: (args: .. string) -> string +{ + sb: String_Builder; + init_string_builder(*sb); + for args { + append(*sb, it); + } + return builder_to_string(*sb); +} + +record_test_file :: (info: *File_Visit_Info, test_files: *[..]Test_File) +{ + if (info.is_directory) return; + + longest_match := 0; + longest_match_index := -1; + for category, category_i: stored_config.test_categories + { + if (ends_with_nocase(info.full_name, category.file_extension)) + { + if longest_match < category.file_extension.count + { + longest_match = category.file_extension.count; + longest_match_index = category_i; + } + } + } + + + if longest_match_index >= 0 + { + array_add(test_files, .{ + file_path = copy_string(info.full_name), + category = longest_match_index + }); + print(" Test File: % - Category: %\n", info.full_name, stored_config.test_categories[longest_match_index].category_name); + } +} + +build_test_file :: (path_string: string, category: Test_Category) -> string +{ + workspace := compiler_create_workspace(tprint("Test: %", path_string)); + if !workspace + { + compiler_report(path_string, 0, 0, "Unable to create workspace for test file"); + return ""; + } + + path := parse_path(path_string); + filename := path.words[path.words.count - 1]; + filename_without_ext := slice(filename, 0, filename.count - category.file_extension.count); + exe_output_path := string_append( + filename_without_ext, + category.exe_extension + ); + + build_options := stored_build_options; + build_options.output_executable_name = exe_output_path; + + set_build_options(build_options, workspace); + add_build_file(path_string, workspace); + add_build_string(tprint("#load \"%harness.jai\";\n", #filepath), workspace); + + output_path := parse_path(stored_build_options.output_path); + array_add(*output_path.words, exe_output_path); + output_path.trailing_slash = false; + + return path_to_string(output_path); +} \ No newline at end of file diff --git a/tests/basic_build.jai b/tests/basic_build.jai new file mode 100644 index 0000000..1cee36c --- /dev/null +++ b/tests/basic_build.jai @@ -0,0 +1,25 @@ +#import "Basic"; +#import "Compiler"; +#import "String"; + +gs_test :: #import "gs_test"; + +#run build(); +build :: () +{ + target_options := get_build_options(); + + target_options.output_executable_name = "basic_test"; + target_options.output_path = tprint("%/output", #filepath); + + gs_test.build_all_tests(.{ + root_paths = .[#filepath], + test_categories = .[ + .{ + file_extension = ".gs_test.jai", + } + ] + }, target_options); + + set_build_options_dc(.{ do_output = false }); // exclude this file from build +} diff --git a/tests/basic_usage.gs_test.jai b/tests/basic_usage.gs_test.jai new file mode 100644 index 0000000..f66d1cc --- /dev/null +++ b/tests/basic_usage.gs_test.jai @@ -0,0 +1,12 @@ +#load "./example_math.jai"; + +main :: () { + Init_Test_Harness(); + + Test("basic test", () { + expect(times2(1), 2); + expect(times2(2), 4); + }); + + Run_Test_Harness(); +} diff --git a/tests/before_after.gs_test.jai b/tests/before_after.gs_test.jai new file mode 100644 index 0000000..2846a20 --- /dev/null +++ b/tests/before_after.gs_test.jai @@ -0,0 +1,26 @@ +befores_called := 0; +afters_called := 0; + +main :: () { + Init_Test_Harness(); + + Before_Each(() { + befores_called += 1; + }); + + After_Each(() { + afters_called += 1; + }); + + Test("before / after hooks #1", () { + expect(befores_called, 1); + expect(afters_called, 0); + }); + + Test("before / after hooks #2", () { + expect(befores_called, 2); + expect(afters_called, 1); + }); + + Run_Test_Harness(); +} diff --git a/tests/build_tests.sh b/tests/build_tests.sh new file mode 100755 index 0000000..8b2520a --- /dev/null +++ b/tests/build_tests.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +ROOT_DIR=$(dirname BASH_SOURCE[0])/../ +IMPORT_DIR=$ROOT_DIR/../ + +if [[ "$OSTYPE" == "linux-gnu"* ]]; then + JAI=jai-linux +elif [[ "$OSTYPE" == "darwin"* ]]; then + JAI=jai-macos +else + JAI=jai +fi + +$JAI -import_dir "${IMPORT_DIR}" ./basic_build.jai +COMPILED=$? + +if [ $COMPILED -ne 0 ]; then + echo "Building Tests Failed" + exit 1 +fi + +chmod +x ./tests_out/run_all_tests.sh \ No newline at end of file diff --git a/tests/example_math.jai b/tests/example_math.jai new file mode 100644 index 0000000..a196d21 --- /dev/null +++ b/tests/example_math.jai @@ -0,0 +1,8 @@ + +times2 :: (a: int) -> int { + return a + a; +} + +times3 :: (a: int) -> int { + return a + a + a; +} diff --git a/tests/test.sh b/tests/test.sh new file mode 100644 index 0000000..74d17ab --- /dev/null +++ b/tests/test.sh @@ -0,0 +1,21 @@ +#!/bin/bash +SUCCESS=0 +ARGS=$@ + +TEST_DIR=$(dirname ${BASH_SOURCE[0]})/tests_out + +# Category: solo +if [[ " ${ARGS[*]} " =~ " solo " ]]; then + $TEST_DIR/basic_first.test $@ + if [ $? != 0 ]; + then + SUCCESS=1 + fi + $TEST_DIR/basic_first2.test $@ + if [ $? != 0 ]; + then + SUCCESS=1 + fi + +fi +exit $SUCCESS \ No newline at end of file