diff --git a/.github/workflows/appimage.yml b/.github/workflows/appimage.yml index 9c26a805..bac80b19 100644 --- a/.github/workflows/appimage.yml +++ b/.github/workflows/appimage.yml @@ -41,6 +41,7 @@ jobs: - name: Run tests run: ctest --test-dir ${{github.workspace}}/build - name: Run engine (headless) + timeout-minutes: 1 run: | chmod +x ${{github.workspace}}/build/VoxelEngine ${{github.workspace}}/build/VoxelEngine --headless --dir ${{github.workspace}}/userdir diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 0d76f5f4..d3adc67f 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -43,6 +43,7 @@ jobs: run: | chmod +x build/VoxelEngine build/VoxelEngine --headless --dir userdir + timeout-minutes: 1 # - name: Create DMG # run: | # mkdir VoxelEngineDmgContent diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 57338d34..c11d0da4 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -38,6 +38,7 @@ jobs: run: ctest --output-on-failure --test-dir build - name: Run engine (headless) run: build/Release/VoxelEngine.exe --headless --dir userdir + timeout-minutes: 1 # - name: Package for Windows # run: | # mkdir packaged diff --git a/CMakeLists.txt b/CMakeLists.txt index 9e84d36d..57f19747 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -82,3 +82,5 @@ if (VOXELENGINE_BUILD_TESTS) enable_testing() add_subdirectory(test) endif() + +add_subdirectory(vctest) diff --git a/dev/tests/example.lua b/dev/tests/example.lua new file mode 100644 index 00000000..3b724d47 --- /dev/null +++ b/dev/tests/example.lua @@ -0,0 +1 @@ +print("Hello from the example test!") diff --git a/res/scripts/stdlib.lua b/res/scripts/stdlib.lua index 9cf23cc3..d5f8eed1 100644 --- a/res/scripts/stdlib.lua +++ b/res/scripts/stdlib.lua @@ -328,6 +328,43 @@ function __vc_on_world_quit() _rules.clear() end +local __vc_coroutines = {} +local __vc_next_coroutine = 1 +local __vc_coroutine_error = nil + +function __vc_start_coroutine(chunk) + local co = coroutine.create(function() + local status, err = pcall(chunk) + if not status then + __vc_coroutine_error = err + end + end) + local id = __vc_next_coroutine + __vc_next_coroutine = __vc_next_coroutine + 1 + __vc_coroutines[id] = co + return id +end + +function __vc_resume_coroutine(id) + local co = __vc_coroutines[id] + if co then + coroutine.resume(co) + if __vc_coroutine_error then + error(__vc_coroutine_error) + end + return coroutine.status(co) ~= "dead" + end + return false +end + +function __vc_stop_coroutine(id) + local co = __vc_coroutines[id] + if co then + coroutine.close(co) + __vc_coroutines[id] = nil + end +end + assets = {} assets.load_texture = core.__load_texture diff --git a/src/engine.cpp b/src/engine.cpp index 422637b8..49386b65 100644 --- a/src/engine.cpp +++ b/src/engine.cpp @@ -36,6 +36,7 @@ #include "window/Events.hpp" #include "window/input.hpp" #include "window/Window.hpp" +#include "interfaces/Process.hpp" #include #include @@ -178,12 +179,25 @@ void Engine::saveScreenshot() { void Engine::run() { if (params.headless) { - logger.info() << "nothing to do"; + runTest(); } else { mainloop(); } } +void Engine::runTest() { + if (params.testFile.empty()) { + logger.info() << "nothing to do"; + return; + } + logger.info() << "starting test " << params.testFile; + auto process = scripting::start_coroutine(params.testFile); + while (process->isActive()) { + process->update(); + } + logger.info() << "test finished"; +} + void Engine::mainloop() { logger.info() << "starting menu screen"; setScreen(std::make_shared(this)); diff --git a/src/engine.hpp b/src/engine.hpp index f05a4607..c3559fa8 100644 --- a/src/engine.hpp +++ b/src/engine.hpp @@ -49,6 +49,7 @@ struct CoreParameters { bool headless = false; std::filesystem::path resFolder {"res"}; std::filesystem::path userFolder {"."}; + std::filesystem::path testFile; }; class Engine : public util::ObjectsKeeper { @@ -93,6 +94,8 @@ public: /// Automatically sets MenuScreen void mainloop(); + void runTest(); + /// @brief Called after assets loading when all engine systems are initialized void onAssetsLoaded(); diff --git a/src/interfaces/Process.hpp b/src/interfaces/Process.hpp new file mode 100644 index 00000000..b62b1e0f --- /dev/null +++ b/src/interfaces/Process.hpp @@ -0,0 +1,12 @@ +#pragma once + +/// @brief Process interface. +class Process { +public: + virtual ~Process() {} + + virtual bool isActive() const = 0; + virtual void update() = 0; + virtual void waitForEnd() = 0; + virtual void terminate() = 0; +}; diff --git a/src/logic/scripting/scripting.cpp b/src/logic/scripting/scripting.cpp index ded9a970..9abe534c 100644 --- a/src/logic/scripting/scripting.cpp +++ b/src/logic/scripting/scripting.cpp @@ -25,6 +25,7 @@ #include "util/timeutil.hpp" #include "voxels/Block.hpp" #include "world/Level.hpp" +#include "interfaces/Process.hpp" using namespace scripting; @@ -71,6 +72,59 @@ void scripting::initialize(Engine* engine) { load_script(fs::path("classes.lua"), true); } +class LuaCoroutine : public Process { + lua::State* L; + int id; + bool alive = true; +public: + LuaCoroutine(lua::State* L, int id) : L(L), id(id) { + } + + bool isActive() const override { + return alive; + } + + void update() override { + if (lua::getglobal(L, "__vc_resume_coroutine")) { + lua::pushinteger(L, id); + if (lua::call(L, 1)) { + alive = lua::toboolean(L, -1); + lua::pop(L); + } + } + } + + void waitForEnd() override { + while (isActive()) { + update(); + } + } + + void terminate() override { + if (lua::getglobal(L, "__vc_stop_coroutine")) { + lua::pushinteger(L, id); + lua::pop(L, lua::call(L, 1)); + } + } +}; + +std::unique_ptr scripting::start_coroutine( + const std::filesystem::path& script +) { + auto L = lua::get_main_state(); + if (lua::getglobal(L, "__vc_start_coroutine")) { + auto source = files::read_string(script); + lua::loadbuffer(L, 0, source, script.filename().u8string()); + if (lua::call(L, 1)) { + int id = lua::tointeger(L, -1); + lua::pop(L, 2); + return std::make_unique(L, id); + } + lua::pop(L); + } + return nullptr; +} + [[nodiscard]] scriptenv scripting::get_root_environment() { return std::make_shared(0); } diff --git a/src/logic/scripting/scripting.hpp b/src/logic/scripting/scripting.hpp index 0ab3c035..4ccba2cb 100644 --- a/src/logic/scripting/scripting.hpp +++ b/src/logic/scripting/scripting.hpp @@ -11,8 +11,6 @@ #include "typedefs.hpp" #include "scripting_functional.hpp" -namespace fs = std::filesystem; - class Engine; class Content; struct ContentPack; @@ -34,6 +32,7 @@ class Entity; struct EntityDef; class GeneratorScript; struct GeneratorDef; +class Process; namespace scripting { extern Engine* engine; @@ -60,6 +59,10 @@ namespace scripting { void process_post_runnables(); + std::unique_ptr start_coroutine( + const std::filesystem::path& script + ); + void on_world_load(LevelController* controller); void on_world_tick(); void on_world_save(); @@ -136,7 +139,7 @@ namespace scripting { void load_content_script( const scriptenv& env, const std::string& prefix, - const fs::path& file, + const std::filesystem::path& file, const std::string& fileName, block_funcs_set& funcsset ); @@ -150,7 +153,7 @@ namespace scripting { void load_content_script( const scriptenv& env, const std::string& prefix, - const fs::path& file, + const std::filesystem::path& file, const std::string& fileName, item_funcs_set& funcsset ); @@ -161,13 +164,13 @@ namespace scripting { /// @param fileName script file path using the engine format void load_entity_component( const std::string& name, - const fs::path& file, + const std::filesystem::path& file, const std::string& fileName ); std::unique_ptr load_generator( const GeneratorDef& def, - const fs::path& file, + const std::filesystem::path& file, const std::string& dirPath ); @@ -179,7 +182,7 @@ namespace scripting { void load_world_script( const scriptenv& env, const std::string& packid, - const fs::path& file, + const std::filesystem::path& file, const std::string& fileName, world_funcs_set& funcsset ); @@ -193,7 +196,7 @@ namespace scripting { void load_layout_script( const scriptenv& env, const std::string& prefix, - const fs::path& file, + const std::filesystem::path& file, const std::string& fileName, uidocscript& script ); diff --git a/src/main.cpp b/src/main.cpp index a11020de..b0d03412 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -3,17 +3,23 @@ #include "util/command_line.hpp" #include "debug/Logger.hpp" +#include #include static debug::Logger logger("main"); int main(int argc, char** argv) { - debug::Logger::init("latest.log"); - CoreParameters coreParameters; - if (!parse_cmdline(argc, argv, coreParameters)) { - return EXIT_SUCCESS; + try { + if (!parse_cmdline(argc, argv, coreParameters)) { + return EXIT_SUCCESS; + } + } catch (const std::runtime_error& err) { + std::cerr << err.what() << std::endl; + return EXIT_FAILURE; } + + debug::Logger::init(coreParameters.userFolder.string()+"/latest.log"); platform::configure_encoding(); try { Engine(std::move(coreParameters)).run(); diff --git a/src/util/ArgsReader.hpp b/src/util/ArgsReader.hpp new file mode 100644 index 00000000..90d159f5 --- /dev/null +++ b/src/util/ArgsReader.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include + +namespace util { + class ArgsReader { + const char* last = ""; + char** argv; + int argc; + int pos = 0; + public: + ArgsReader(int argc, char** argv) : argv(argv), argc(argc) { + } + + void skip() { + pos++; + } + + bool hasNext() const { + return pos < argc && std::strlen(argv[pos]); + } + + bool isKeywordArg() const { + return last[0] == '-'; + } + + std::string next() { + if (pos >= argc) { + throw std::runtime_error("unexpected end"); + } + last = argv[pos]; + return argv[pos++]; + } + }; +} diff --git a/src/util/command_line.cpp b/src/util/command_line.cpp index e5a76197..768c6260 100644 --- a/src/util/command_line.cpp +++ b/src/util/command_line.cpp @@ -1,48 +1,16 @@ #include "command_line.hpp" -#include #include #include -#include -#include #include "files/engine_paths.hpp" +#include "util/ArgsReader.hpp" #include "engine.hpp" namespace fs = std::filesystem; -class ArgsReader { - const char* last = ""; - char** argv; - int argc; - int pos = 0; -public: - ArgsReader(int argc, char** argv) : argv(argv), argc(argc) { - } - - void skip() { - pos++; - } - - bool hasNext() const { - return pos < argc && strlen(argv[pos]); - } - - bool isKeywordArg() const { - return last[0] == '-'; - } - - std::string next() { - if (pos >= argc) { - throw std::runtime_error("unexpected end"); - } - last = argv[pos]; - return argv[pos++]; - } -}; - static bool perform_keyword( - ArgsReader& reader, const std::string& keyword, CoreParameters& params + util::ArgsReader& reader, const std::string& keyword, CoreParameters& params ) { if (keyword == "--res") { auto token = reader.next(); @@ -53,22 +21,25 @@ static bool perform_keyword( } else if (keyword == "--help" || keyword == "-h") { std::cout << "VoxelEngine command-line arguments:\n"; std::cout << " --help - show help\n"; - std::cout << " --res [path] - set resources directory\n"; - std::cout << " --dir [path] - set userfiles directory\n"; + std::cout << " --res - set resources directory\n"; + std::cout << " --dir - set userfiles directory\n"; std::cout << " --headless - run in headless mode\n"; + std::cout << " --test - test script file\n"; std::cout << std::endl; return false; } else if (keyword == "--headless") { params.headless = true; + } else if (keyword == "--test") { + auto token = reader.next(); + params.testFile = fs::u8path(token); } else { - std::cerr << "unknown argument " << keyword << std::endl; - return false; + throw std::runtime_error("unknown argument " + keyword); } return true; } bool parse_cmdline(int argc, char** argv, CoreParameters& params) { - ArgsReader reader(argc, argv); + util::ArgsReader reader(argc, argv); reader.skip(); while (reader.hasNext()) { std::string token = reader.next(); diff --git a/vctest/CMakeLists.txt b/vctest/CMakeLists.txt new file mode 100644 index 00000000..6c1a3843 --- /dev/null +++ b/vctest/CMakeLists.txt @@ -0,0 +1,31 @@ +project(vctest) + +set(CMAKE_CXX_STANDARD 17) + +file(GLOB_RECURSE SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp) + +add_executable(${PROJECT_NAME} ${SOURCES}) + +if(MSVC) + if(NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE) + endif() + if((CMAKE_BUILD_TYPE EQUAL "Release") OR (CMAKE_BUILD_TYPE EQUAL "RelWithDebInfo")) + set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Release>") + target_compile_options(${PROJECT_NAME} PRIVATE /W4 /MT /O2) + else() + target_compile_options(${PROJECT_NAME} PRIVATE /W4) + endif() + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS}") +else() + target_compile_options(${PROJECT_NAME} PRIVATE -Wall -Wextra + -Wformat-nonliteral -Wcast-align + -Wpointer-arith -Wundef + -Wwrite-strings -Wno-unused-parameter) +endif() + +if (CMAKE_CXX_COMPILER_ID STREQUAL "GNU") + set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -no-pie -lstdc++fs") +endif() + +target_include_directories(${PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../src ${CMAKE_DL_LIBS}) diff --git a/vctest/main.cpp b/vctest/main.cpp new file mode 100644 index 00000000..f6d3e773 --- /dev/null +++ b/vctest/main.cpp @@ -0,0 +1,213 @@ +#include +#include +#include +#include +#include +#include + +#include "util/ArgsReader.hpp" + +namespace fs = std::filesystem; + +inline fs::path TESTING_DIR = fs::u8path(".vctest"); + +struct Config { + fs::path executable; + fs::path directory; + fs::path resDir {"res"}; + fs::path workingDir {"."}; + bool outputAlways = false; +}; + +static bool perform_keyword( + util::ArgsReader& reader, const std::string& keyword, Config& config +) { + if (keyword == "--help" || keyword == "-h") { + std::cout << "Options\n\n"; + std::cout << " --help, -h = show help\n"; + std::cout << " --exe , -e = VoxelCore executable path\n"; + std::cout << " --tests , -d = tests directory path\n"; + std::cout << " --res , -r = 'res' directory path\n"; + std::cout << " --working-dir , -w = user directory path\n"; + std::cout << " --output-always = always show tests output\n"; + std::cout << std::endl; + return false; + } else if (keyword == "--exe" || keyword == "-e") { + config.executable = fs::path(reader.next()); + } else if (keyword == "--tests" || keyword == "-d") { + config.directory = fs::path(reader.next()); + } else if (keyword == "--res" || keyword == "-r") { + config.resDir = fs::path(reader.next()); + } else if (keyword == "--user" || keyword == "-u") { + config.workingDir = fs::path(reader.next()); + } else if (keyword == "--output-always") { + config.outputAlways = true; + } else { + std::cerr << "unknown argument " << keyword << std::endl; + return false; + } + return true; +} + +static bool parse_cmdline(int argc, char** argv, Config& config) { + util::ArgsReader reader(argc, argv); + while (reader.hasNext()) { + std::string token = reader.next(); + if (reader.isKeywordArg()) { + if (!perform_keyword(reader, token, config)) { + return false; + } + } + } + return true; +} + +static bool check_dir(const fs::path& dir) { + if (!fs::is_directory(dir)) { + std::cerr << dir << " is not a directory" << std::endl; + return false; + } + return true; +} + +static void print_separator(std::ostream& stream) { + for (int i = 0; i < 32; i++) { + stream << "="; + } + stream << "\n"; +} + +static bool check_config(const Config& config) { + if (!fs::exists(config.executable)) { + std::cerr << "file " << config.executable << " not found" << std::endl; + return true; + } + if (!check_dir(config.directory)) { + return true; + } + if (!check_dir(config.resDir)) { + return true; + } + if (!check_dir(config.workingDir)) { + return true; + } + return false; +} + +static void dump_config(const Config& config) { + std::cout << "paths:\n"; + std::cout << " VoxelCore executable = " << fs::canonical(config.executable).string() << "\n"; + std::cout << " Tests directory = " << fs::canonical(config.directory).string() << "\n"; + std::cout << " Resources directory = " << fs::canonical(config.resDir).string() << "\n"; + std::cout << " Working directory = " << fs::canonical(config.workingDir).string(); + std::cout << std::endl; +} + +static void cleanup(const fs::path& workingDir) { + auto dir = workingDir / TESTING_DIR; + std::cout << "cleaning up " << dir << std::endl; + fs::remove_all(dir); +} + +static void setup_working_dir(const fs::path& workingDir) { + auto dir = workingDir / TESTING_DIR; + std::cout << "setting up working directory " << dir << std::endl; + if (fs::is_directory(dir)) { + cleanup(workingDir); + } + fs::create_directories(dir); +} + +static void display_test_output(const fs::path& path, std::ostream& stream) { + stream << "[OUTPUT]" << std::endl; + if (fs::exists(path)) { + std::ifstream t(path); + stream << t.rdbuf(); + } +} + +static bool run_test(const Config& config, const fs::path& path) { + using std::chrono::duration_cast; + using std::chrono::high_resolution_clock; + using std::chrono::milliseconds; + + auto outputFile = config.workingDir / "output.txt"; + + auto name = path.stem(); + std::stringstream ss; + ss << config.executable << " --headless"; + ss << " --test " << path; + ss << " --res " << config.resDir; + ss << " --dir " << config.workingDir; + ss << " >" << (config.workingDir / "output.txt") << " 2>&1"; + auto command = ss.str(); + + print_separator(std::cout); + std::cout << "executing test " << name << "\ncommand: " << command << std::endl; + + auto start = high_resolution_clock::now(); + int code = system(command.c_str()); + auto testTime = + duration_cast(high_resolution_clock::now() - start) + .count(); + + if (code) { + display_test_output(outputFile, std::cerr); + std::cerr << "[FAILED] " << name << " in " << testTime << " ms" << std::endl; + fs::remove(outputFile); + return false; + } else { + if (config.outputAlways) { + display_test_output(outputFile, std::cout); + } + std::cout << "[PASSED] " << name << " in " << testTime << " ms" << std::endl; + fs::remove(outputFile); + return true; + } +} + +int main(int argc, char** argv) { + Config config; + try { + if (!parse_cmdline(argc, argv, config)) { + return 0; + } + } catch (const std::runtime_error& err) { + std::cerr << err.what() << std::endl; + throw; + } + if (check_config(config)) { + return 1; + } + dump_config(config); + + std::vector tests; + std::cout << "scanning for tests" << std::endl; + for (const auto& entry : fs::directory_iterator(config.directory)) { + auto path = entry.path(); + if (path.extension().string() != ".lua") { + std::cout << " " << entry.path() << " skipped" << std::endl; + continue; + } + std::cout << " " << entry.path() << " enqueued" << std::endl; + tests.push_back(path); + } + + setup_working_dir(config.workingDir); + config.workingDir /= TESTING_DIR; + + size_t passed = 0; + std::cout << "running " << tests.size() << " test(s)" << std::endl; + for (const auto& path : tests) { + passed += run_test(config, path); + } + print_separator(std::cout); + cleanup(config.workingDir); + std::cout << std::endl; + std::cout << passed << " test(s) passed, " << (tests.size() - passed) + << " test(s) failed" << std::endl; + if (passed < tests.size()) { + return 1; + } + return 0; +}