feat(test): replace custom test header with Catch2 and migrated lua

tests to new system
This commit is contained in:
maelstrom 2025-12-13 01:37:12 +01:00
parent 3297f6e3ff
commit 47ad44bb83
18 changed files with 325 additions and 304 deletions

View file

@ -13,5 +13,16 @@
"program": "$ZED_WORKTREE_ROOT/build/bin/editor",
"request": "launch",
"adapter": "CodeLLDB"
},
{
"label": "Debug tests",
"build": {
"command": "cmake",
"args": ["--build", "build", "-j16"],
"cwd": "$ZED_WORKTREE_ROOT"
},
"program": "$ZED_WORKTREE_ROOT/build/bin/obtest",
"request": "launch",
"adapter": "CodeLLDB"
}
]

View file

@ -24,7 +24,4 @@ add_subdirectory(core)
add_subdirectory(client)
add_subdirectory(editor)
set( CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/tests )
enable_testing()
add_subdirectory(tests)

View file

@ -0,0 +1,3 @@
Start testing: Dec 11 22:10 CET
----------------------------------------------------------
End testing: Dec 11 22:10 CET

View file

@ -137,6 +137,7 @@ void ScriptContext::RunSleepingThreads() {
for (i = 0; i < sleepingThreads.size();) {
bool deleted = false;
// TODO: Remove threads that belong to non-existent scripts
SleepingThread sleep = sleepingThreads[i];
if (tu_clock_micros() >= sleep.targetTimeMicros) {
// Time args
@ -167,6 +168,12 @@ void ScriptContext::RunSleepingThreads() {
schedTime = tu_clock_micros() - startTime;
}
// Temporary stopgap until RunSleepingThreads can clear threads that belong to
// scripts no longer parented to the DataModel
void ScriptContext::DebugClearSleepingThreads() {
sleepingThreads.clear();
}
void ScriptContext::NewEnvironment(lua_State* L) {
lua_newtable(L); // Env table
lua_newtable(L); // Metatable

View file

@ -32,6 +32,8 @@ public:
lua_State* state;
void PushThreadSleep(lua_State* thread, float delay);
void RunSleepingThreads();
// TEMPORARY. USED ONLY FOR TESTING
void DebugClearSleepingThreads();
// Generates an environment with a metatable and pushes it both the env table and metatable in order onto the stack
void NewEnvironment(lua_State* state);

22
justfile Normal file
View file

@ -0,0 +1,22 @@
help:
just -l
configure:
cmake -Bbuild -DCMAKE_BUILD_TYPE=Debug .
# Commented out configure because it takes unnecessarily long
# Just run configure manually if you've made any changes
build: #configure
cmake --build build -j$(nproc)
editor: build
./build/bin/editor
test: build
ctest --test-dir=build
test-v: build
ctest --test-dir=build --rerun-failed --output-on-failure
test-dbg: build
gdb -q ./build/bin/obtest

View file

@ -1,14 +1,12 @@
function (create_test TEST_NAME)
set(TARGET_NAME test_${TEST_NAME})
add_executable(${TARGET_NAME} ${ARGN})
target_link_libraries(${TARGET_NAME} PRIVATE openblocks)
add_dependencies(${TARGET_NAME} openblocks)
add_test(NAME ${TARGET_NAME} COMMAND ${TARGET_NAME})
endfunction ()
create_test(lua src/luatest.cpp)
create_test(luasched src/luaschedtest.cpp)
create_test(luasignal src/luasignaltest.cpp)
include(${CMAKE_CURRENT_SOURCE_DIR}/deps.cmake)
# https://stackoverflow.com/a/36729074/16255372
add_custom_target(check ${CMAKE_CTEST_COMMAND} --output-on-failure WORKING_DIRECTORY ${CMAKE_BINARY_DIR})
add_executable(obtest
src/common.cpp
src/lua/luasched.cpp
src/lua/luasignal.cpp
src/lua/luageneric.cpp
)
target_link_libraries(obtest PRIVATE openblocks Catch2::Catch2WithMain)
target_include_directories(obtest PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)
catch_discover_tests(obtest)

8
tests/deps.cmake Normal file
View file

@ -0,0 +1,8 @@
include(CPM)
CPMAddPackage("gh:catchorg/Catch2@3.8.1")
list(APPEND CMAKE_MODULE_PATH ${Catch2_SOURCE_DIR}/extras)
include(CTest)
include(Catch)

54
tests/src/common.cpp Normal file
View file

@ -0,0 +1,54 @@
#include <catch2/reporters/catch_reporter_event_listener.hpp>
#include <catch2/reporters/catch_reporter_registrars.hpp>
#include "logger.h"
#include "objects/base/instance.h"
#include "objects/service/script/scriptcontext.h"
#include "objects/service/script/serverscriptservice.h"
#include "objects/service/workspace.h"
#include "testcommon.h"
std::shared_ptr<DataModel> gTestModel;
std::stringstream testLogOutput;
class commonTestListener : public Catch::EventListenerBase {
public:
using Catch::EventListenerBase::EventListenerBase;
void testRunStarting(Catch::TestRunInfo const&) override {
// TODO: Make physicsInit optional in headless environments
physicsInit();
gTestModel = DataModel::New();
gTestModel->Init(true);
Logger::initTest(&testLogOutput);
}
void testRunEnded(Catch::TestRunStats const&) override {
gTestModel = nullptr;
physicsDeinit();
Logger::initTest(nullptr);
}
void testCasePartialStarting(const Catch::TestCaseInfo &testInfo, uint64_t partNumber) override {
// Clear the log output prior to each test
testLogOutput.str("");
}
void testCasePartialEnded(const Catch::TestCaseStats &testCaseStats, uint64_t partNumber) override {
auto ctx = gTestModel->GetService<ScriptContext>();
ctx->DebugClearSleepingThreads();
// Clean up remaining scripts from ServerScriptService
for (auto& obj : gTestModel->GetService<ServerScriptService>()->GetChildren()) {
obj->Destroy();
}
// Also clear workspace
for (auto& obj : gTestModel->GetService<Workspace>()->GetChildren()) {
obj->Destroy();
}
}
};
CATCH_REGISTER_LISTENER(commonTestListener)

View file

@ -0,0 +1,20 @@
#include <catch2/catch_test_macros.hpp>
#include "testcommon.h"
#include "testutil.h"
TEST_CASE("Generic lua test", "[luageneric]") {
auto m = gTestModel;
SECTION("Script output") {
REQUIRE(luaEvalOut(m, "print('Hello, world!')") == "INFO: Hello, world!\n");
}
// SECTION("Script warning") {
// REQUIRE(luaEvalOut(m, "warn('Some warning here.')") == "WARN: Some warning here.\n");
// }
// SECTION("Script error") {
// REQUIRE(luaEvalOut(m, "error('An error!')") == "ERROR: An error!.\n");
// }
}

View file

@ -0,0 +1,81 @@
#include <catch2/catch_test_macros.hpp>
#include "objects/service/script/scriptcontext.h"
#include "testcommon.h"
#include "testutil.h"
#include "timeutil.h"
static auto& m = gTestModel;
static auto& out = testLogOutput;
TEST_CASE("Wait with delay") {
auto ctx = m->GetService<ScriptContext>();
tu_set_override(0);
luaEval(m, "wait(1) print('Wait')");
SECTION("Empty output at 0s") {
ctx->RunSleepingThreads();
REQUIRE(out.str() == "");
}
SECTION("Empty output at 0.5s") {
TT_ADVANCETIME(0.5);
ctx->RunSleepingThreads();
REQUIRE(out.str() == "");
}
SECTION("Print output at 1s") {
TT_ADVANCETIME(1);
ctx->RunSleepingThreads();
REQUIRE(out.str() == "INFO: Wait\n");
}
}
TEST_CASE("Wait with minimum delay") {
auto ctx = m->GetService<ScriptContext>();
tu_set_override(0);
luaEval(m, "wait(0) print('Wait')");
SECTION("Empty output at 0s") {
ctx->RunSleepingThreads();
REQUIRE(out.str() == "");
}
SECTION("Empty output at 0.02s") {
TT_ADVANCETIME(0.02);
ctx->RunSleepingThreads();
REQUIRE(out.str() == "");
}
SECTION("Print output at 0.03s") {
TT_ADVANCETIME(0.03);
ctx->RunSleepingThreads();
REQUIRE(out.str() == "INFO: Wait\n");
}
}
TEST_CASE("Run callback after delay") {
auto ctx = m->GetService<ScriptContext>();
tu_set_override(0);
luaEval(m, "delay(1, function() print('Delay') end)");
SECTION("Empty output at 0s") {
ctx->RunSleepingThreads();
REQUIRE(out.str() == "");
}
SECTION("Empty output at 0.5s") {
TT_ADVANCETIME(0.5);
ctx->RunSleepingThreads();
REQUIRE(out.str() == "");
}
SECTION("Print output at 1s") {
TT_ADVANCETIME(1);
ctx->RunSleepingThreads();
REQUIRE(out.str() == "INFO: Delay\n");
}
}

View file

@ -0,0 +1,79 @@
#include <catch2/catch_test_macros.hpp>
#include "objects/part/part.h"
#include "objects/service/script/scriptcontext.h"
#include "objects/service/workspace.h"
#include "testcommon.h"
#include "testutil.h"
#include "timeutil.h"
static auto& m = gTestModel;
static auto& out = testLogOutput;
TEST_CASE("Connect to event") {
auto ws = m->GetService<Workspace>();
auto part = Part::New();
ws->AddChild(part);
luaEval(m, "workspace.Part.Touched:Connect(function() print('Fired!') end)");
SECTION("Single fire") {
part->Touched->Fire();
REQUIRE(out.str() == "INFO: Fired!\n");
}
SECTION("Double fire") {
part->Touched->Fire();
part->Touched->Fire();
REQUIRE(out.str() == "INFO: Fired!\nINFO: Fired!\n");
}
}
TEST_CASE("Wait within event listener") {
auto ctx = m->GetService<ScriptContext>();
auto ws = m->GetService<Workspace>();
auto part = Part::New();
ws->AddChild(part);
tu_set_override(0);
luaEval(m, "workspace.Part.Touched:Connect(function() print('Fired!') wait(1) print('Waited') end)");
SECTION("Single fire") {
part->Touched->Fire();
REQUIRE(out.str() == "INFO: Fired!\n");
TT_ADVANCETIME(0.5);
ctx->RunSleepingThreads();
REQUIRE(out.str() == "INFO: Fired!\n");
TT_ADVANCETIME(0.5);
ctx->RunSleepingThreads();
REQUIRE(out.str() == "INFO: Fired!\nINFO: Waited\n");
}
SECTION("Nested double fire") {
part->Touched->Fire();
TT_ADVANCETIME(0.2);
part->Touched->Fire();
REQUIRE(out.str() == "INFO: Fired!\nINFO: Fired!\n");
TT_ADVANCETIME(1-0.2); // Small extra delay is necessary because floating point math
ctx->RunSleepingThreads();
REQUIRE(out.str() == "INFO: Fired!\nINFO: Fired!\nINFO: Waited\n");
TT_ADVANCETIME(0.2);
ctx->RunSleepingThreads();
REQUIRE(out.str() == "INFO: Fired!\nINFO: Fired!\nINFO: Waited\nINFO: Waited\n");
}
}
TEST_CASE("Wait for event") {
auto ctx = m->GetService<ScriptContext>();
auto ws = m->GetService<Workspace>();
auto part = Part::New();
ws->AddChild(part);
tu_set_override(0);
luaEval(m, "workspace.Part.Touched:Wait() print('Fired!')");
part->Touched->Fire();
REQUIRE(out.str() == "INFO: Fired!\n");
part->Touched->Fire(); // Firing again should not affect output
REQUIRE(out.str() == "INFO: Fired!\n");
}

View file

@ -1,77 +0,0 @@
#include "testutillua.h"
#include "timeutil.h"
void test_wait1(DATAMODEL_REF m) {
auto ctx = m->GetService<ScriptContext>();
std::stringstream out;
Logger::initTest(&out);
tu_set_override(0);
luaEval(m, "wait(1) print('Wait')");
ctx->RunSleepingThreads();
ASSERT_EQ("", out.str());
TT_ADVANCETIME(0.5);
ctx->RunSleepingThreads();
ASSERT_EQ("", out.str());
TT_ADVANCETIME(0.5);
ctx->RunSleepingThreads();
ASSERT_EQ("INFO: Wait\n", out.str());
Logger::initTest(nullptr);
}
void test_wait0(DATAMODEL_REF m) {
auto ctx = m->GetService<ScriptContext>();
std::stringstream out;
Logger::initTest(&out);
tu_set_override(0);
luaEval(m, "wait(0) print('Wait')");
ASSERT_EQ("", out.str());
ctx->RunSleepingThreads();
ASSERT_EQ("", out.str());
TT_ADVANCETIME(0.03);
ctx->RunSleepingThreads();
ASSERT_EQ("INFO: Wait\n", out.str());
Logger::initTest(nullptr);
}
void test_delay(DATAMODEL_REF m) {
auto ctx = m->GetService<ScriptContext>();
std::stringstream out;
Logger::initTest(&out);
tu_set_override(0);
luaEval(m, "delay(1, function() print('Delay') end)");
ctx->RunSleepingThreads();
ASSERT_EQ("", out.str());
TT_ADVANCETIME(0.5);
ctx->RunSleepingThreads();
ASSERT_EQ("", out.str());
TT_ADVANCETIME(0.5);
ctx->RunSleepingThreads();
ASSERT_EQ("INFO: Delay\n", out.str());
Logger::initTest(nullptr);
}
int main() {
auto m = DataModel::New();
m->Init(true);
test_wait1(m);
test_wait0(m);
test_delay(m);
return TEST_STATUS;
}

View file

@ -1,103 +0,0 @@
#include "testutil.h"
#include "testutillua.h"
#include "timeutil.h"
#include "objects/part/part.h"
#include "objects/service/workspace.h"
#include <memory>
#include <sstream>
void test_connect(DATAMODEL_REF m) {
auto ctx = m->GetService<ScriptContext>();
auto part = Part::New();
m->GetService<Workspace>()->AddChild(part);
std::stringstream out;
Logger::initTest(&out);
luaEval(m, "workspace.Part.Touched:Connect(function() print('Fired!') end)");
ASSERT_EQ("", out.str());
part->Touched->Fire();
ASSERT_EQ("INFO: Fired!\n", out.str());
part->Touched->Fire();
ASSERT_EQ("INFO: Fired!\nINFO: Fired!\n", out.str());
Logger::initTest(nullptr);
part->Destroy();
}
void test_waitwithin(DATAMODEL_REF m) {
auto ctx = m->GetService<ScriptContext>();
auto part = Part::New();
m->GetService<Workspace>()->AddChild(part);
std::stringstream out;
Logger::initTest(&out);
tu_set_override(0);
luaEval(m, "workspace.Part.Touched:Connect(function() print('Fired!') wait(1) print('Waited') end)");
ASSERT_EQ("", out.str());
// One shot
part->Touched->Fire();
ctx->RunSleepingThreads();
ASSERT_EQ("INFO: Fired!\n", out.str());
TT_ADVANCETIME(0.5);
ctx->RunSleepingThreads();
ASSERT_EQ("INFO: Fired!\n", out.str());
TT_ADVANCETIME(0.5);
ctx->RunSleepingThreads();
ASSERT_EQ("INFO: Fired!\nINFO: Waited\n", out.str());
// Clear
out = std::stringstream();
Logger::initTest(&out); // Shouldn't *theoretically* be necessary, but just in principle...
// Double fire
part->Touched->Fire();
TT_ADVANCETIME(0.2);
part->Touched->Fire();
ASSERT_EQ("INFO: Fired!\nINFO: Fired!\n", out.str());
TT_ADVANCETIME(1-0.2); // Small extra delay is necessary because floating point math
ctx->RunSleepingThreads();
ASSERT_EQ("INFO: Fired!\nINFO: Fired!\nINFO: Waited\n", out.str());
TT_ADVANCETIME(0.2);
ctx->RunSleepingThreads();
ASSERT_EQ("INFO: Fired!\nINFO: Fired!\nINFO: Waited\nINFO: Waited\n", out.str());
tu_set_override(-1UL);
Logger::initTest(nullptr);
part->Destroy();
}
void test_await(DATAMODEL_REF m) {
auto ctx = m->GetService<ScriptContext>();
auto part = Part::New();
m->GetService<Workspace>()->AddChild(part);
std::stringstream out;
Logger::initTest(&out);
tu_set_override(0);
luaEval(m, "workspace.Part.Touched:Wait() print('Fired!')");
ASSERT_EQ("", out.str());
part->Touched->Fire();
ASSERT_EQ("INFO: Fired!\n", out.str());
part->Touched->Fire(); // Firing again should not affect output
ASSERT_EQ("INFO: Fired!\n", out.str());
tu_set_override(-1UL);
Logger::initTest(nullptr);
part->Destroy();
}
int main() {
auto m = DataModel::New();
m->Init(true);
test_connect(m);
test_waitwithin(m);
test_await(m);
return TEST_STATUS;
}

View file

@ -1,20 +0,0 @@
#include "testutil.h"
#include "testutillua.h"
#include <memory>
void test_output(DATAMODEL_REF m) {
ASSERT_EQ("INFO: Hello, world!\n", luaEvalOut(m, "print('Hello, world!')"));
// ASSERT_EQ("WARN: Some warning here.\n", luaEvalOut(m, "warn('Some warning here.')"));
// ASSERT_EQ("ERROR: An error!.\n", luaEvalOut(m, "error('An error!')"));
}
int main() {
auto m = DataModel::New();
m->Init(true);
test_output(m);
return TEST_STATUS;
}

8
tests/src/testcommon.h Normal file
View file

@ -0,0 +1,8 @@
#pragma once
#include <sstream>
#include "objects/datamodel.h"
extern std::shared_ptr<DataModel> gTestModel;
extern std::stringstream testLogOutput;

View file

@ -1,70 +1,31 @@
#pragma once
// https://bastian.rieck.me/blog/2017/simple_unit_tests/
#include <algorithm>
#include <cstddef>
#include <iomanip>
#include <regex>
#include <sstream>
#include <string>
#ifdef __FUNCTION__
#define __FUNC_NAME __FUNCTION__
#else
#define __FUNC_NAME __func__
#endif
#define ASSERT(x, msg) __assert((x), __FILE__, __LINE__, __FUNC_NAME, msg)
#define ASSERT_EQ(x, y) __assert_eq((x) == (y), __FILE__, __LINE__, __FUNC_NAME, #x, (y))
// #define ASSERT_EQSTR(x, y) ASSERT(strcmp(x, y) == 0, #x " != " #y)
#define ASSERT_EQSTR(x, y) ASSERT_EQ(x, y)
#define DATAMODEL_REF std::shared_ptr<DataModel>
#include "objects/datamodel.h"
#include "objects/script.h"
#include "objects/service/script/serverscriptservice.h"
#include "testcommon.h"
#define TU_TIME_EXPOSE_TEST
#define TT_ADVANCETIME(secs) tu_set_override(tu_clock_micros() + (secs) * 1'000'000);
#include <cstdio>
#include <cstring>
inline std::string luaEvalOut(std::shared_ptr<DataModel> m, std::string source) {
testLogOutput.seekp(0, std::ios::end);
size_t offset = testLogOutput.tellp();
testLogOutput.seekp(0);
int TEST_STATUS = 0;
auto ss = m->GetService<ServerScriptService>();
auto s = Script::New();
m->AddChild(s);
s->source = source;
s->Run();
inline void __assert(bool cond, std::string file, int line, std::string func, std::string message) {
if (cond) return;
fprintf(stderr, "ASSERT FAILED : %s:%d : %s : '%s'\n", file.c_str(), line, func.c_str(), message.c_str());
TEST_STATUS = 1;
return testLogOutput.str().substr(offset);
}
template <typename T>
inline std::string quote(T value) {
return std::to_string(value);
inline void luaEval(std::shared_ptr<DataModel> m, std::string source) {
auto ss = m->GetService<ServerScriptService>();
auto s = Script::New();
ss->AddChild(s);
s->source = source;
s->Run();
}
template <>
std::string quote<std::string>(std::string value) {
std::stringstream ss;
ss << std::quoted(value);
std::string newstr = ss.str();
newstr = std::regex_replace(newstr, std::regex("\n"), "\\n");
return newstr;
}
template <>
inline std::string quote<const char*>(const char* value) {
return quote<std::string>(value);
}
template <>
inline std::string quote<char*>(char* value) {
return quote<std::string>(value);
}
template <typename T>
void __assert_eq(bool cond, std::string file, int line, std::string func, std::string model, T value) {
if (cond) return;
std::string message = model + " != " + quote(value);
fprintf(stderr, "ASSERT FAILED : %s:%d : %s : '%s'\n", file.c_str(), line, func.c_str(), message.c_str());
TEST_STATUS = 1;
}

View file

@ -1,30 +0,0 @@
#pragma once
#include "testutil.h"
#include <sstream>
#include "logger.h"
#include "objects/datamodel.h"
#include "objects/script.h"
#include "objects/service/script/scriptcontext.h"
std::string luaEvalOut(DATAMODEL_REF m, std::string source) {
std::stringstream out;
Logger::initTest(&out);
auto s = Script::New();
m->AddChild(s);
s->source = source;
s->Run();
Logger::initTest(nullptr);
return out.str();
}
void luaEval(DATAMODEL_REF m, std::string source) {
auto s = Script::New();
m->AddChild(s);
s->source = source;
s->Run();
}