feat/testing #4
36
.gitea/workflows/ci.yml
Normal file
36
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,36 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
- push
|
||||
- pull_request
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository (with submodules)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Ensure submodules (like external/angelscript and external/raylib) are checked out.
|
||||
submodules: true
|
||||
# Fetch full history so submodules can be cloned properly.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
build-essential cmake git pkg-config \
|
||||
libx11-dev libxrandr-dev libxcursor-dev libxinerama-dev libxi-dev \
|
||||
libgl1-mesa-dev libasound2-dev libpulse-dev libudev-dev libpng-dev
|
||||
|
||||
- name: Configure (CMake)
|
||||
run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
|
||||
|
||||
- name: Build unit tests
|
||||
run: cmake --build build --target unit_tests -j $(nproc)
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
cd build
|
||||
ctest --output-on-failure -j $(nproc)
|
||||
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -4,3 +4,6 @@
|
||||
[submodule "external/angelscript"]
|
||||
path = external/angelscript
|
||||
url = https://github.com/anjo76/angelscript.git
|
||||
[submodule "external/acutest"]
|
||||
path = external/acutest
|
||||
url = https://github.com/mity/acutest
|
||||
|
||||
@@ -14,6 +14,12 @@ add_subdirectory(external/raylib)
|
||||
# -------------------------
|
||||
add_subdirectory(external/angelscript/sdk/angelscript/projects/cmake)
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Tests
|
||||
# -------------------------
|
||||
enable_testing()
|
||||
add_subdirectory(tests)
|
||||
# -------------------------
|
||||
# 3️⃣ scriptstdstring add-on
|
||||
# -------------------------
|
||||
|
||||
1
external/acutest
vendored
Submodule
1
external/acutest
vendored
Submodule
Submodule external/acutest added at 31751b4089
@@ -2,6 +2,7 @@
|
||||
#include "angelscript.h"
|
||||
#include "scriptbuilder.h"
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
class ScriptEngine {
|
||||
public:
|
||||
@@ -13,6 +14,14 @@ public:
|
||||
bool CompileScript(const std::string& filename);
|
||||
void CallScriptFunction(asIScriptFunction* func, float dt = 0.0f);
|
||||
void GarbageCollect();
|
||||
|
||||
// Add a path used to resolve #include directives in scripts
|
||||
void AddIncludePath(const std::string& path);
|
||||
|
||||
// Try to preserve script state across hot-reloads by calling optional
|
||||
// __SerializeState()/__DeserializeState() hooks in the script module.
|
||||
std::string CaptureScriptState();
|
||||
void RestoreScriptState(const std::string &state);
|
||||
|
||||
asIScriptEngine* GetEngine() const { return engine; }
|
||||
asIScriptFunction* GetUpdateFunction() const { return updateFunc; }
|
||||
@@ -24,6 +33,7 @@ private:
|
||||
asIScriptFunction* drawFunc;
|
||||
asIScriptModule* currentModule;
|
||||
bool hasValidScript;
|
||||
std::vector<std::string> includePaths;
|
||||
|
||||
static void MessageCallback(const asSMessageInfo* msg, void* param);
|
||||
static int IncludeCallback(const char* include, const char* from, CScriptBuilder* builder, void* userParam);
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
#include <assert.h>
|
||||
#include "log/log.h"
|
||||
|
||||
#include <vector>
|
||||
#include <mutex>
|
||||
|
||||
ScriptEngine::ScriptEngine() : engine(nullptr), updateFunc(nullptr), drawFunc(nullptr), currentModule(nullptr), hasValidScript(false) {
|
||||
}
|
||||
|
||||
@@ -38,6 +41,55 @@ bool ScriptEngine::Initialize() {
|
||||
return true;
|
||||
}
|
||||
|
||||
void ScriptEngine::AddIncludePath(const std::string& path) {
|
||||
// thread-safe append
|
||||
static std::mutex m;
|
||||
std::lock_guard<std::mutex> lock(m);
|
||||
includePaths.push_back(path);
|
||||
}
|
||||
|
||||
std::string ScriptEngine::CaptureScriptState() {
|
||||
if (!currentModule || !engine) return std::string();
|
||||
asIScriptFunction* ser = currentModule->GetFunctionByDecl("string __SerializeState()");
|
||||
if (!ser) return std::string();
|
||||
|
||||
asIScriptContext* ctx = engine->CreateContext();
|
||||
if (ctx->Prepare(ser) < 0) { ctx->Release(); return std::string(); }
|
||||
int r = ctx->Execute();
|
||||
std::string ret;
|
||||
if (r == asEXECUTION_FINISHED) {
|
||||
// Get return value (script std::string registered as value type)
|
||||
void* obj = ctx->GetReturnObject();
|
||||
if (obj) {
|
||||
try {
|
||||
std::string* s = reinterpret_cast<std::string*>(obj);
|
||||
ret = *s;
|
||||
} catch (...) {
|
||||
// fallback: leave ret empty
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx->Release();
|
||||
return ret;
|
||||
}
|
||||
|
||||
void ScriptEngine::RestoreScriptState(const std::string &state) {
|
||||
if (!currentModule || !engine || state.empty()) return;
|
||||
asIScriptFunction* des = currentModule->GetFunctionByDecl("void __DeserializeState(const string &in)");
|
||||
if (!des) return;
|
||||
|
||||
asIScriptContext* ctx = engine->CreateContext();
|
||||
if (ctx->Prepare(des) < 0) { ctx->Release(); return; }
|
||||
// SetArgObject expects pointer to the script string object; AngelScript's
|
||||
// std::string binding uses C++ std::string, so we pass the pointer.
|
||||
ctx->SetArgObject(0, (void*)&state);
|
||||
int r = ctx->Execute();
|
||||
if (r != asEXECUTION_FINISHED) {
|
||||
log_error("Failed to restore script state");
|
||||
}
|
||||
ctx->Release();
|
||||
}
|
||||
|
||||
void ScriptEngine::Shutdown() {
|
||||
ClearCachedFunctions();
|
||||
if (engine) {
|
||||
@@ -178,36 +230,36 @@ int ScriptEngine::IncludeCallback(const char* include, const char* from, CScript
|
||||
// Log the include request with clean filenames
|
||||
log_info("Including: %s (from: %s)", include, fromFile ? fromFile : "main");
|
||||
|
||||
// Build the full path relative to the including file's directory
|
||||
std::string fullPath;
|
||||
|
||||
// Build the full path candidates: relative to 'from' and any include paths
|
||||
std::vector<std::string> candidates;
|
||||
|
||||
if (from && strlen(from) > 0) {
|
||||
// Extract directory from the 'from' path
|
||||
std::string fromPath(from);
|
||||
size_t lastSlash = fromPath.find_last_of("/\\");
|
||||
|
||||
if (lastSlash != std::string::npos) {
|
||||
// Get the directory part
|
||||
std::string directory = fromPath.substr(0, lastSlash + 1);
|
||||
fullPath = directory + include;
|
||||
} else {
|
||||
// No directory in from path, use include as-is
|
||||
fullPath = include;
|
||||
candidates.push_back(directory + include);
|
||||
}
|
||||
} else {
|
||||
fullPath = include;
|
||||
}
|
||||
|
||||
// Try to add the included file
|
||||
int r = builder->AddSectionFromFile(fullPath.c_str());
|
||||
|
||||
if (r < 0) {
|
||||
log_error("Failed to include file: %s (tried path: %s)", include, fullPath.c_str());
|
||||
} else {
|
||||
log_info("Successfully included: %s", include);
|
||||
|
||||
// Add user-specified include search paths
|
||||
// Note: includePaths is a member of the ScriptEngine instance, but this
|
||||
// callback is static. For now we look for an environment variable or the
|
||||
// include as-is. If you want dynamic include paths, expose a global pointer
|
||||
// or userParam to pass the instance to the callback.
|
||||
candidates.push_back(include);
|
||||
|
||||
int result = -1;
|
||||
for (auto &cand : candidates) {
|
||||
result = builder->AddSectionFromFile(cand.c_str());
|
||||
if (result >= 0) {
|
||||
log_info("Successfully included: %s", cand.c_str());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return r;
|
||||
|
||||
log_error("Failed to include file: %s (tried %zu paths)", include, candidates.size());
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string ScriptEngine::ReadFile(const std::string& filename) {
|
||||
|
||||
30
tests/CMakeLists.txt
Normal file
30
tests/CMakeLists.txt
Normal file
@@ -0,0 +1,30 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
# Simple unit test executable that compiles acutest.c directly
|
||||
add_executable(unit_tests
|
||||
unit_tests.cpp
|
||||
${CMAKE_SOURCE_DIR}/src/HotReload.cpp
|
||||
${CMAKE_SOURCE_DIR}/src/ScriptEngine.cpp
|
||||
${CMAKE_SOURCE_DIR}/src/ScriptBindings.cpp
|
||||
${CMAKE_SOURCE_DIR}/src/log/log.c
|
||||
)
|
||||
|
||||
target_include_directories(unit_tests PRIVATE
|
||||
${CMAKE_SOURCE_DIR}/external/acutest/include
|
||||
${CMAKE_SOURCE_DIR}/include
|
||||
${CMAKE_SOURCE_DIR}/src/log
|
||||
${CMAKE_SOURCE_DIR}/external/angelscript/sdk/angelscript/include
|
||||
${CMAKE_SOURCE_DIR}/external/angelscript/sdk/add_on/scriptstdstring
|
||||
${CMAKE_SOURCE_DIR}/external/angelscript/sdk/add_on/scriptbuilder
|
||||
${CMAKE_SOURCE_DIR}/external/raylib/src
|
||||
)
|
||||
|
||||
# Link AngelScript and its add-ons needed by ScriptEngine
|
||||
target_link_libraries(unit_tests PRIVATE
|
||||
angelscript
|
||||
scriptstdstring
|
||||
scriptbuilder
|
||||
raylib
|
||||
)
|
||||
|
||||
add_test(NAME unit_tests COMMAND unit_tests)
|
||||
3
tests/acutest_main.c
Normal file
3
tests/acutest_main.c
Normal file
@@ -0,0 +1,3 @@
|
||||
#include "../external/acutest/include/acutest.h"
|
||||
|
||||
// This translation unit provides the single main() for Acutest.
|
||||
103
tests/unit_tests.cpp
Normal file
103
tests/unit_tests.cpp
Normal file
@@ -0,0 +1,103 @@
|
||||
#include "../external/acutest/include/acutest.h"
|
||||
#include <string.h>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <thread>
|
||||
#include <chrono>
|
||||
#include "../include/HotReload.h"
|
||||
#include "../include/ScriptEngine.h"
|
||||
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
/* simple sanity test */
|
||||
void test_one(void) {
|
||||
TEST_CHECK(1 + 1 == 2);
|
||||
}
|
||||
|
||||
void test_str(void) {
|
||||
TEST_CHECK(strcmp("foo","foo") == 0);
|
||||
}
|
||||
|
||||
void test_hotreload_directory_watch(void) {
|
||||
std::filesystem::path tmpDir = std::filesystem::current_path() / "tmp_watch";
|
||||
std::filesystem::create_directories(tmpDir);
|
||||
|
||||
std::filesystem::path f = tmpDir / "a.as";
|
||||
std::ofstream ofs(f);
|
||||
ofs << "// test" << std::endl;
|
||||
ofs.close();
|
||||
|
||||
HotReload hr(tmpDir.string());
|
||||
TEST_CHECK(hr.CheckForChanges() == false);
|
||||
|
||||
std::this_thread::sleep_for(1s);
|
||||
std::ofstream ofs2(f, std::ios::app);
|
||||
ofs2 << "// changed" << std::endl;
|
||||
ofs2.close();
|
||||
|
||||
TEST_CHECK(hr.CheckForChanges() == true);
|
||||
|
||||
std::filesystem::remove(f);
|
||||
std::filesystem::remove(tmpDir);
|
||||
}
|
||||
|
||||
void test_scriptengine_compile_and_call(void) {
|
||||
// Create a simple script file that defines Update(float) and a global
|
||||
std::filesystem::path tmpDir = std::filesystem::current_path() / "tmp_script";
|
||||
std::filesystem::create_directories(tmpDir);
|
||||
std::filesystem::path script = tmpDir / "simple.as";
|
||||
std::ofstream ofs(script);
|
||||
ofs << "float g_x = 0;\n";
|
||||
ofs << "void Update(float dt) { g_x += dt * 10.0f; }\n";
|
||||
ofs.close();
|
||||
|
||||
ScriptEngine se;
|
||||
TEST_CHECK(se.Initialize() == true);
|
||||
bool ok = se.CompileScript(script.string());
|
||||
TEST_CHECK(ok == true);
|
||||
asIScriptFunction* upd = se.GetUpdateFunction();
|
||||
TEST_CHECK(upd != nullptr);
|
||||
|
||||
// Call Update with dt=0.1
|
||||
se.CallScriptFunction(upd, 0.1f);
|
||||
|
||||
se.Shutdown();
|
||||
|
||||
std::filesystem::remove(script);
|
||||
std::filesystem::remove(tmpDir);
|
||||
}
|
||||
|
||||
void test_scriptengine_include(void) {
|
||||
std::filesystem::path tmpDir = std::filesystem::current_path() / "tmp_include";
|
||||
std::filesystem::create_directories(tmpDir);
|
||||
std::filesystem::path inc = tmpDir / "inc.as";
|
||||
std::ofstream ofsinc(inc);
|
||||
ofsinc << "void Included() { Print(\"Included called\"); }\n";
|
||||
ofsinc.close();
|
||||
|
||||
std::filesystem::path main = tmpDir / "main.as";
|
||||
std::ofstream ofsmain(main);
|
||||
ofsmain << "#include \"inc.as\"\n";
|
||||
ofsmain << "void Update(float dt) { Included(); }\n";
|
||||
ofsmain.close();
|
||||
|
||||
ScriptEngine se;
|
||||
TEST_CHECK(se.Initialize() == true);
|
||||
// Compile should succeed and include callback should resolve inc.as
|
||||
bool ok = se.CompileScript(main.string());
|
||||
TEST_CHECK(ok == true);
|
||||
se.Shutdown();
|
||||
|
||||
std::filesystem::remove(inc);
|
||||
std::filesystem::remove(main);
|
||||
std::filesystem::remove(tmpDir);
|
||||
}
|
||||
|
||||
TEST_LIST = {
|
||||
{ "one", test_one },
|
||||
{ "str", test_str },
|
||||
{ "hotreload_dir", test_hotreload_directory_watch },
|
||||
{ "script_compile", test_scriptengine_compile_and_call },
|
||||
{ "script_include", test_scriptengine_include },
|
||||
{ NULL, NULL }
|
||||
};
|
||||
Reference in New Issue
Block a user