spacebox/src/test/test.cpp
Cocktail Frank 01d9fc61fc Add ability to make HTTP requests
Add an HTTP library to the `sb::cloud` namespace, mainly consisting of
an HTTP class and Request class. The HTTP class launches and manages a
queue of Request objects. Request objects are used to send HTTP GET and
POST requests. The library is implemented using lower level libraries -
Emscripten's Fetch API for WASM builds and cURL for all other builds.

Initialize both HTTP and Steam with a single function at
`sb:☁️:init`, and quit both HTTP and Steam with `sb:☁️:quit`.

Add an HTTP test program. For Emscripten HTTP tests, there is a
separate program built without Catch2 because Catch2 requires Asyncify,
which isn't compatible with Emscripten's Fetch API.

Add a cURL section to the test program's Makefile which handles
including cURL headers and linking to the cURL library on each
platform.

Pass version string into Ubuntu Docker builds of the test program, and
remove the connection between the container and the host's Git
directory because it is not necessary anymore.

Separate flags for building the test program for Emscripten with
Asyncify+Catch2 and Emscripten with the Fetch API.

Add documentation for the HTTP library and tests to the README.

Update the Ubuntu 18 Docker file to install libcurl with SSL.

Pack the Linux debug build into a ZIP archive like the regular build
after building, primarily so the Docker debug build can be pulled from
the container.

Add remote X connection to the Docker container build of the test
program, so the test program can access the display when run from
within the container.
2025-04-08 19:54:38 -04:00

1193 lines
47 KiB
C++

/*! /\ +=======================================================+
* ____/ \____ /: Open source game framework licensed to freely use, :
* \ / / : copy, and modify - created for dank.game :
* +==\ ^__^ /==+ : :
* : ~/ \~ : : Download at https://open.shampoo.ooo/shampoo/spacebox :
* : ~~~~~~~~~~~~ : +=======================================================+
* : SPACE ~~~~~ : /
* : ~~~~~~~ BOX :/
* +==============+
*
* Test the codebase using the Catch2 testing library. These tests should be run whenever changes are made to make sure
* the changes don't break existing code.
*
* The Makefile in this directory can be used to build an executable test program. The resulting program must be run
* from the current directory to read the test data.
*
* See also the Testing section in the main project README.
*/
#include <ctime>
#include <string>
#include <iostream>
#include <sstream>
#include <cstdio>
#include <thread>
#include <chrono>
#include "json/json.hpp"
#include "cli11/CLI11.hpp"
#include "SDL.h"
#if defined(STEAM_ENABLED)
#include "steam/steam_api_flat.h"
#endif
#include "setup.hpp"
#include "../sb.hpp"
#include "../Game.hpp"
#include "../Log.hpp"
#include "../progress.hpp"
#include "../extension.hpp"
#include "../filesystem.hpp"
#include "../Configuration.hpp"
#include "../Text.hpp"
int main(int argc, char** argv)
{
Catch::Session session;
/* Add default reporters and related options, temporarily set SDL to log to stdout, and change base directory on
* Mac builds. */
sb::test::setup(session, argc, argv);
/* Run tests */
return session.run();
}
class TestGame : public Game
{
public:
int call_count = 0;
using Game::Game;
void draw([[maybe_unused]] float timestamp)
{
if (++call_count == 10)
{
flag_to_end();
CHECK(done);
}
}
};
TEST_CASE("Run a game with command line parameters")
{
/* This modifies static data which remains modified for the rest of the test program's lifetime, so this is the only
* test of how init runs at the launch of a program. */
sb::init();
UNSCOPED_INFO("Passing zero and null to init");
CHECK_NOTHROW(sb::init(0, nullptr));
SECTION ("Passing a non-existent option")
{
CHECK_THROWS_AS(sb::init({"Hello,", " World!"}), CLI::ParseError);
}
SECTION ("Passing invalid JSON")
{
/* Try one test using the char** format */
const char* cli[] {"test_program", "--config", "{\"bad\": syntax}"};
CHECK_THROWS_AS(sb::init(3, const_cast<char**>(cli)), nlohmann::json::parse_error);
}
SECTION ("Passing non-existent file")
{
CHECK_THROWS_AS(sb::init({"--config-file", "non_existent.json"}), std::runtime_error);
}
SECTION ("Passing invalid JSON in a file")
{
CHECK_THROWS_AS(sb::init({"--config-file", "malformed.json"}), nlohmann::json::parse_error);
}
SECTION ("Adding a reserved keyword")
{
std::string storage;
CHECK_THROWS_AS(sb::cli::add("--config", storage), sb::ReservedError);
}
SECTION ("Passing valid JSON and custom option values, and checking if they are accessible in game")
{
sb::init(
{"--config", "{\"hello\": \"world\"}",
"--config-file", "Simulacra_and_Simulation.json",
"--config", "{\"display\": {\"title\": \"Luigi Adventure 🏍\"}}"
});
CHECK(sb::cli_configs()[1].contains("hello"));
CHECK(sb::cli_configs()[1].at("hello") == "world");
CHECK(sb::cli_configs()[0].contains("categories"));
CHECK(sb::cli_configs()[0].at("industryIdentifiers").at(0).at("identifier") == "0472065211");
SECTION ("Adding a custom option and passing a value for it, and overwriting a configuration default")
{
std::vector<std::string> camera_device_ids;
int seconds_until_exit;
sb::cli::add("--camera-device-id", camera_device_ids);
sb::cli::add("--exit-after", seconds_until_exit);
sb::cli::parse({"--camera-device-id", "video0", "video1", "--exit-after", "1234"});
CHECK(camera_device_ids[0] == "video0");
CHECK(camera_device_ids[1] == "video1");
CHECK(seconds_until_exit == 1234);
/* Overwrite a default defined in Configuration.cpp */
sb::cli::parse({});
SECTION ("Adding an existing option to the CLI")
{
CHECK_THROWS_AS(sb::cli::add("--camera-device-id", camera_device_ids), CLI::OptionAlreadyAdded);
}
}
/* Configuration from the command line gets merged into the Game object's Configuration object. This also
* checks to make sure command line JSON has priority over Game constructor JSON. */
nlohmann::json takeover {{
"display", {
{"title", "Waluigi's Wild Ride 🏍"}
}}};
/* Enable logging to stdout so log function output can be tested. Turn on log files for reference. */
nlohmann::json enable_log {
{
"log", {
{"stdout enabled", true},
{"file enabled", true},
{"debug to file", true},
{"debug to stdout", true}}}};
TestGame luigi_game {std::vector<nlohmann::json>{takeover, enable_log}};
/* Check for expected configuration entries */
CHECK(luigi_game.configuration()("log", "stdout enabled") == true);
CHECK(luigi_game.configuration()("log", "debug to stdout") == true);
CHECK(luigi_game.configuration()("hello") == "world");
CHECK(luigi_game.configuration()("authors").at(0) == "Jean Baudrillard");
CHECK(luigi_game.configuration()("industryIdentifiers").at(1).at("type") == "ISBN_13");
CHECK(luigi_game.configuration()("display", "title") == "Luigi Adventure 🏍");
/* Redirect std::cout to string buffer and save original buffer */
std::stringstream buffer;
std::streambuf* prevcoutbuf = std::cout.rdbuf(buffer.rdbuf());
/* Log messages and check if the messages are written to the log files. */
sb::Color green = sb::Color(0.0f, 0.6f, 0.0f);
sb::Log::Line() << "Hello, World!";
sb::Log::Multi() << "It's " << luigi_game.configuration()("display", "title") << sb::Log::end;
sb::Log::Multi() << "color: " << green << sb::Log::end;
sb::Log::Line(sb::Log::DEBUG) << luigi_game.configuration()("readingModes");
sb::Log::Multi(sb::Log::DEBUG) << luigi_game.configuration()("panelizationSummary") << std::endl << "type: " <<
luigi_game.configuration()("printType") << sb::Log::end;
/* Build a string of expected output and compare it to the standard output from the previous commands. */
std::ostringstream expected;
expected << "Hello, World!" << std::endl << "It's " << luigi_game.configuration()("display", "title") <<
std::endl << "color: " << green << std::endl << luigi_game.configuration()("readingModes") <<
std::endl << luigi_game.configuration()("panelizationSummary") << std::endl << "type: " <<
luigi_game.configuration()("printType") << std::endl;
CHECK(buffer.str() == expected.str());
/* Restore original buffer to stop capturing std::cout */
std::cout.rdbuf(prevcoutbuf);
UNSCOPED_INFO("Running the main loop");
std::optional<float> start_seconds;
bool checked = false;
bool started = false;
luigi_game.run([&](float timestamp)
{
if (!started)
{
luigi_game.suppress_input_temporarily(7.0f);
started = true;
}
else
{
if (!start_seconds.has_value())
{
start_seconds = float(SDL_GetTicks()) / 1000.0f;
}
float seconds_running = timestamp - start_seconds.value();
if (!checked && seconds_running > 0.3f && seconds_running < 7.0f)
{
CHECK_FALSE(SDL_HasEvents(SDL_FIRSTEVENT, SDL_LASTEVENT));
CHECK(luigi_game.input.is_suppressed());
CHECK_FALSE(luigi_game.done);
checked = true;
}
else if (seconds_running > 7.0f)
{
CHECK_FALSE(luigi_game.input.is_suppressed());
luigi_game.flag_to_end();
CHECK(luigi_game.done);
}
}
});
UNSCOPED_INFO("Running the main loop using a bound class member function");
CHECK_NOTHROW(luigi_game.run(std::bind(&TestGame::draw, &luigi_game, std::placeholders::_1)));
CHECK(luigi_game.call_count == 10);
#if defined(__EMSCRIPTEN__)
/* Sleeping between runs helps prevent Emscripten from locking. */
std::this_thread::sleep_for(std::chrono::seconds(5));
#endif
UNSCOPED_INFO("Runnning the main loop with separate draw and update functions");
start_seconds.reset();
int draw_count = 0;
int update_count = 0;
luigi_game.run(
[&]([[maybe_unused]] float timestamp)
{
draw_count++;
},
[&](float timestamp)
{
if (!start_seconds.has_value())
{
start_seconds = float(SDL_GetTicks()) / 1000.0f;
}
update_count++;
float seconds_running = timestamp - start_seconds.value();
if (seconds_running > 1.0f)
{
luigi_game.flag_to_end();
}
});
CHECK(draw_count > 0);
CHECK(update_count > 0);
#if defined(__EMSCRIPTEN__)
/* Sleeping between runs helps prevent Emscripten from locking. */
std::this_thread::sleep_for(std::chrono::seconds(3));
#endif
luigi_game.quit();
}
CHECK(sb::cli::reserved().size() == 3);
sb::quit();
/* Set the log to stdout, so the next test can start with the default. */
sb::test::reset_log_function();
}
/*!
* Test saving progress to a file path.
*/
void test_progress_write(sb::progress::Progress& progress, const fs::path& path, bool force = false)
{
progress.save(path, force);
REQUIRE(fs::exists(path));
sb::progress::Progress check;
check.load(path);
CHECK(progress == check);
}
TEST_CASE("Progress with stats and achievements")
{
/* Achievements and stats formatted for a config file */
nlohmann::json go_for_the_gold = R"({
"achievements": [
{
"id": "ACH_LAYER_CAKE",
"name": "Layer Cake",
"description": "Walk 10 thousand meters",
"stat":
{
"id": "STAT_DISTANCE_TRAVELED",
"unlock": 10000
}
},
{
"id": "ACH_FRESHLY_BAKED",
"name": "Freshly Baked",
"description": "Die once",
"stat":
{
"id": "STAT_TOTAL_DEATHS",
"unlock": 1
}
},
{
"id": "ACH_CAKEWALK",
"name": "Cakewalk",
"description": "Beat the game"
}
],
"stats": [
{
"id": "STAT_TOTAL_DEATHS",
"name": "Total deaths",
"sum": ["STAT_SLICER_DEATHS", "STAT_FISH_DEATHS", "STAT_DRONE_DEATHS", "STAT_FIRE_DEATHS"]
},
{
"id": "STAT_CAKES_UNLOCKED",
"name": "Cakes unlocked",
"max": 3
},
{
"id": "STAT_FARTHEST_DISTANCE_REACHED",
"name": "Farthest distance reached",
"aggregated": false
},
{
"id": "STAT_FASTEST_QUEST_TIME",
"name": "Fastest quest time",
"type": "FLOAT",
"aggregated": false,
"increment only": false
},
{
"id": "STAT_SLICER_DEATHS"
},
{
"id": "STAT_FISH_DEATHS"
},
{
"id": "STAT_DRONE_DEATHS"
},
{
"id": "STAT_FIRE_DEATHS"
}
]
})"_json;
/* Check stats were constructed */
sb::progress::Stats stats {go_for_the_gold};
CHECK(stats[0].id() == "STAT_TOTAL_DEATHS");
CHECK(stats[0].name() == "Total deaths");
CHECK(stats[0].sum().size() == 4);
CHECK(stats[0].sum()[2] == "STAT_DRONE_DEATHS");
CHECK(stats[0].type() == sb::progress::Stat::INT);
CHECK(stats["STAT_TOTAL_DEATHS"].increment_only() == true);
CHECK(stats[1].max() == 3);
CHECK(stats["STAT_CAKES_UNLOCKED"].sum().empty());
CHECK_THROWS_AS(stats[1234567890], std::out_of_range);
CHECK_THROWS_AS(stats["(T ⌒ T)"], std::out_of_range);
CHECK_FALSE(stats[2].aggregated());
CHECK(stats[3].type() == sb::progress::Stat::FLOAT);
CHECK_FALSE(stats["STAT_FASTEST_QUEST_TIME"].increment_only());
/* Construct achievement list from a config object */
sb::Configuration config;
config.merge(go_for_the_gold);
sb::progress::Achievements achievements {config};
/* Create progress that contains needed unlocker */
sb::progress::Progress progress;
CHECK_THROWS_AS(progress.load(nlohmann::json{}, {{"stats", "dankey kang"}}), sb::ReservedError);
progress.load(
{{"name", "My Face"}, {"profile", "🙂"}},
{{"name", "No Face"}, {"profile", "🫥"}, {"rank", "Gamer"}});
sb::progress::Stat distance {"STAT_DISTANCE_TRAVELED", "Distance traveled"};
progress.add_stat(distance);
/* Check achievement construction */
CHECK(achievements[0].id() == "ACH_LAYER_CAKE");
CHECK(achievements[0].name() == "Layer Cake");
CHECK(achievements["ACH_LAYER_CAKE"].description() == "Walk 10 thousand meters");
CHECK_FALSE(achievements["ACH_LAYER_CAKE"].unlocks(distance, 0'001));
CHECK(achievements[2].id() == "ACH_CAKEWALK");
CHECK(achievements["ACH_CAKEWALK"].name() == "Cakewalk");
CHECK(achievements["ACH_CAKEWALK"].description() == "Beat the game");
/* Check progress construction */
CHECK(progress.read("profile") == "🙂");
CHECK(progress.read("rank") == "Gamer");
CHECK(progress.read("achievements").is_array());
CHECK(progress.read("achievements").size() == 0);
CHECK(progress.read("config").is_object());
CHECK(progress.read("config").empty());
CHECK_THROWS_AS(progress.set(true, "achievements"), sb::ReservedError);
CHECK_THROWS_AS(progress.read("dankey", "kang"), std::runtime_error);
/* Set a stat */
progress.set_stat(distance, 12'345, achievements);
CHECK(progress.read("stats", "STAT_DISTANCE_TRAVELED") == 12'345);
CHECK(progress.stat_exists(distance));
CHECK(progress.stat_value(distance) == 12'345);
CHECK(achievements["ACH_LAYER_CAKE"].unlocks(distance, progress.stat_value(distance)));
CHECK(progress.achievement_unlocked(achievements["ACH_LAYER_CAKE"]));
CHECK(progress.read("achievements").size() == 1);
/* Check stat value defaulting */
CHECK_FALSE(progress.stat_exists(sb::progress::Stat{"BEANS"}));
CHECK_THROWS_AS(progress.stat_value(sb::progress::Stat{"BEANS"}, stats), std::runtime_error);
CHECK(progress.stat_default(sb::progress::Stat{"BEANS"}, 1'337, stats) == 1'337);
/* Add arbitrary progress fields */
progress.set(false, "waluigi", "defeated");
progress.set("your house", "waluigi", "location");
progress.set(1'000, "waluigi", "gallons of blood");
progress.set(1234.56789, "kilometers ran");
CHECK(progress.read("waluigi").is_object());
CHECK_FALSE(progress.read("waluigi", "defeated"));
CHECK(progress.read("waluigi", "location") == "your house");
CHECK(progress.read("waluigi", "gallons of blood") == 1'000);
CHECK(progress.read("waluigi").at("gallons of blood") == 1'000);
CHECK(progress.read("waluigi").contains("remorse") == false);
CHECK(progress.read("kilometers ran") == 1234.56789);
/* Check sum handling */
CHECK_THROWS_AS(progress.stat_value(stats["STAT_TOTAL_DEATHS"]), std::out_of_range);
CHECK_THROWS_AS(
progress.stat_value(stats["STAT_TOTAL_DEATHS"], {
sb::progress::Stat{"Hello, World!"},
sb::progress::Stat{"Dankey Kang XP"}
}), std::out_of_range);
progress.increment_stat(stats["STAT_SLICER_DEATHS"], 1, achievements, stats);
progress.increment_stat(stats["STAT_FISH_DEATHS"], 10, achievements, stats);
progress.increment_stat(stats["STAT_DRONE_DEATHS"], 100, achievements, stats);
CHECK(progress.stat_value(stats["STAT_TOTAL_DEATHS"], stats) == 111);
progress.increment_stat(stats["STAT_FIRE_DEATHS"], 1000, achievements, stats);
CHECK(progress.stat_value(stats["STAT_TOTAL_DEATHS"], stats) == 1111);
progress.set_stat(stats["STAT_TOTAL_DEATHS"], 2222);
CHECK(progress.stat_value(stats["STAT_TOTAL_DEATHS"], stats) == 1111);
progress.increment_stat(stats["STAT_TOTAL_DEATHS"]);
CHECK(progress.stat_value(stats["STAT_TOTAL_DEATHS"], stats) == 1111);
CHECK(achievements["ACH_FRESHLY_BAKED"].unlocks(
stats["STAT_TOTAL_DEATHS"], progress.stat_value(stats["STAT_TOTAL_DEATHS"], stats)));
CHECK(progress.achievement_unlocked(achievements["ACH_FRESHLY_BAKED"]));
CHECK(progress.read("achievements").size() == 2);
/* Store user config overrides */
nlohmann::json preferences {
{"display", {
{"fullscreen", true},
{"dimensions", {4096, 2160}}}},
{"big", "huge"},
{"balls", "to the wall"},
{"pedal", "to the metal"},
{"polygons", std::pow(glm::pi<double>(), 32.0)},
{"let's", "fucking go"}
};
nlohmann::json gamepad {
{"gamepad", {
{"1", {
{"map", {
{"A", 1},
{"B", 13}}}}},
{"2", {
{"map", {
{"A", 5},
{"B", 7}}}}}}}
};
progress.config(preferences);
progress.config(gamepad);
progress.config("", "agreements", "I agree to the official rules and regulations of this video game");
CHECK(progress.read("config", "display", "fullscreen"));
CHECK(progress.read("config", "display", "dimensions").is_array());
CHECK(progress.read("config", "display", "dimensions").size() == 2);
CHECK(progress.read("config", "display", "dimensions")[0] == 4096);
CHECK(progress.read("config", "balls") == "to the wall");
progress.merge(config);
CHECK(config("polygons") == std::pow(glm::pi<double>(), 32.0));
CHECK(config("let's") == "fucking go");
CHECK(config("gamepad", "1", "map", "B") == 13);
CHECK(config("gamepad", "2", "map", "A") == 5);
CHECK(config("gamepad").size() == 2);
CHECK(config("agreements", "I agree to the official rules and regulations of this video game") == "");
/* Write the progress to JSON files, testing different paths. Using shared_ptr guarantees the file will be deleted
* automatically. */
std::shared_ptr<fs::path> working_dir {
new fs::path { "spacebox_test_progress.json" },
[] (fs::path* ptr) { if (fs::exists(*ptr)) { fs::remove(*ptr); } delete ptr; }
};
std::shared_ptr<fs::path> tmp_dir {
new fs::path { fs::temp_directory_path() / "spacebox_test_progress.json" },
[] (fs::path* ptr) { if (fs::exists(*ptr)) { fs::remove(*ptr); } delete ptr; }
};
std::shared_ptr<fs::path> tmp_subdir {
new fs::path { fs::temp_directory_path() / "spacebox_test" / "storage" / "spacebox_test_progress.json" },
[] (fs::path* ptr) { fs::remove_all(fs::temp_directory_path() / "spacebox_test"); delete ptr; }
};
for (const fs::path& path : {*working_dir, *tmp_dir, *tmp_subdir})
{
if (path == *working_dir)
{
test_progress_write(progress, path);
}
else
{
test_progress_write(progress, path, true);
}
}
fs::file_time_type last_write_time = fs::last_write_time(*tmp_dir);
/* Check unsaved progress tracking */
for (const fs::path& path : {*working_dir, *tmp_dir, *tmp_subdir})
{
progress.save(path);
}
#if !defined(__EMSCRIPTEN__) && !defined(__APPLE__) && !defined(_WIN32)
/* Comparison of fs::last_write_time objects causes an error in the Emscripten compiler. */
CHECK(fs::last_write_time(*tmp_dir) == last_write_time);
#endif
std::this_thread::sleep_for(std::chrono::milliseconds(100));
for (const fs::path& path : {*working_dir, *tmp_dir, *tmp_subdir})
{
test_progress_write(progress, path, true);
}
#if !defined(__EMSCRIPTEN__) && !defined(__APPLE__) && !defined(_WIN32)
CHECK_FALSE(fs::last_write_time(*tmp_dir) == last_write_time);
#endif
last_write_time = fs::last_write_time(*tmp_dir);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
progress.increment_stat(stats["STAT_FISH_DEATHS"], 1);
for (const fs::path& path : {*working_dir, *tmp_dir, *tmp_subdir})
{
if (path == *working_dir)
{
test_progress_write(progress, path);
}
else
{
test_progress_write(progress, path, true);
}
}
#if !defined(__EMSCRIPTEN__) && !defined(__APPLE__) && !defined(_WIN32)
CHECK_FALSE(fs::last_write_time(*tmp_dir) == last_write_time);
#endif
/* Set the saved JSON progress as the preferences file to check if preferences are applied during construction */
sb::Game a_game { nlohmann::json { { "storage", {{ "preferences file", "spacebox_test_progress.json" }} } } };
CHECK(a_game.configuration()("polygons") == std::pow(glm::pi<double>(), 32.0));
CHECK(a_game.configuration()("let's") == "fucking go");
CHECK(a_game.configuration()("gamepad", "1", "map", "B") == 13);
CHECK(a_game.configuration()("gamepad", "2", "map", "A") == 5);
CHECK(a_game.configuration()(
"agreements", "I agree to the official rules and regulations of this video game") == "");
a_game.display.toggle_fullscreen();
a_game.quit();
/* Check config watch file feature */
std::shared_ptr<fs::path> achievements_path { sb::test::temp_path("spacebox_test/achievements_and_stats.json") };
sb::test::write_json(*achievements_path, go_for_the_gold);
sb::Configuration config_watch;
config_watch.merge(*achievements_path);
sb::progress::Achievements achievements_watch { config_watch };
config_watch["configuration"]["auto refresh interval"] = 0.0f;
config_watch.enable_auto_refresh(*achievements_path);
nlohmann::json waluigi_time = R"({
"achievements": [
{
"id": "ACH_WALUIGI_TIME",
"name": "Waluigi Time",
"description": "It's Waluigi time"
}
]
})"_json;
config_watch.update(0.0f);
std::this_thread::sleep_for(std::chrono::seconds(1));
sb::test::write_json(*achievements_path, waluigi_time);
config_watch.update(1.0f);
CHECK(config_watch("achievements", 0, "description") == "It's Waluigi time");
CHECK(achievements_watch["ACH_CAKEWALK"].name() == "Cakewalk");
achievements_watch = sb::progress::Achievements { config_watch };
CHECK(achievements_watch["ACH_WALUIGI_TIME"].name() == "Waluigi Time");
#if defined(__EMSCRIPTEN__) && !defined(__APPLE__)
/* Sleeping between runs helps prevent Emscripten from locking. */
std::this_thread::sleep_for(std::chrono::seconds(3));
#endif
/* Set the log to stdout, so the next test can start with the default. */
sb::test::reset_log_function();
}
TEST_CASE("JSON convenience accessor and merge")
{
/* For testing string key access */
nlohmann::json pokedex {
{"pokedex", {
{"gen 1", {
{"grass", {"bulbasaur", "oddish", "bellsprout"}},
{"water", {"squirtle", "magikarp", "BOZONGO"}}}},
{"gen 2", {
{"grass", {"chikorita", "bellossom"}},
{"water", {"totodile", "wooper"}}}}}}};
/* For testing integer index access */
nlohmann::json pokedex_array {
{"pokedex", {
{
{"grass", {"bulbasaur", "oddish", "bellsprout"}},
{"water", {"squirtle", "magikarp", "BOZONGO"}}},
{
{"grass", {"chikorita", "bellossom"}},
{"water", {"totodile", "wooper"}}}}}};
SECTION("Direct calls to convenience accessor")
{
std::ostringstream hierarchy;
CHECK_THROWS_AS(sb::json_access(hierarchy, pokedex, false, "pokedex", "gen 1", "steel"), std::runtime_error);
CHECK_THROWS_AS(sb::json_access(hierarchy, pokedex, false, "pokedex", "gen 3", "ground"), std::runtime_error);
hierarchy = std::ostringstream("");
CHECK(sb::json_access(hierarchy, pokedex, false, "pokedex").at("gen 1").at("grass")[1] == "oddish");
CHECK(hierarchy.str() == "\"pokedex\"");
hierarchy = std::ostringstream("");
CHECK(sb::json_access(hierarchy, pokedex, false, "pokedex", "gen 1", "water")[1] == "magikarp");
CHECK(hierarchy.str() == "\"pokedex\" > \"gen 1\" > \"water\"");
hierarchy = std::ostringstream("");
const nlohmann::json result = sb::json_access(hierarchy, pokedex, false, "pokedex", "gen 2", "water");
CHECK(hierarchy.str() == "\"pokedex\" > \"gen 2\" > \"water\"");
CHECK(result.is_array());
CHECK(result.size() == 2);
CHECK(result[0] == "totodile");
CHECK(result[1] == "wooper");
hierarchy = std::ostringstream("");
sb::json_access(hierarchy, pokedex, false, "pokedex", "gen 1", "water")[2] = "lapras";
CHECK(hierarchy.str() == "\"pokedex\" > \"gen 1\" > \"water\"");
CHECK(pokedex.at("pokedex").at("gen 1").at("water")[2] == "lapras");
hierarchy = std::ostringstream("");
sb::json_access(hierarchy, pokedex, true, "pokedex", "gen 3", "ground");
CHECK(pokedex.at("pokedex").at("gen 3").contains("ground"));
CHECK(pokedex.at("pokedex").at("gen 3").at("ground").is_null());
CHECK(hierarchy.str() == "\"pokedex\" > \"gen 3\" > \"ground\"");
hierarchy = std::ostringstream("");
sb::json_access(hierarchy, pokedex, true, "pokedex", "gen 4", "normal") = {"bidoof", "happiny"};
CHECK(pokedex.at("pokedex").at("gen 4").is_object());
CHECK(pokedex.at("pokedex").at("gen 4").contains("normal"));
CHECK(pokedex.at("pokedex").at("gen 4").at("normal").is_array());
CHECK(pokedex.at("pokedex").at("gen 4").at("normal").size() == 2);
CHECK(pokedex.at("pokedex").at("gen 4").at("normal")[0] == "bidoof");
CHECK(hierarchy.str() == "\"pokedex\" > \"gen 4\" > \"normal\"");
hierarchy = std::ostringstream("");
CHECK(sb::json_access(hierarchy, pokedex_array, false, "pokedex", 0, "grass").at(1) == "oddish");
CHECK(hierarchy.str() == "\"pokedex\" > 0 > \"grass\"");
hierarchy = std::ostringstream("");
sb::json_access(hierarchy, pokedex_array, true, "pokedex", 0, "fire") = {"charmander", "vulpix"};
CHECK(hierarchy.str() == "\"pokedex\" > 0 > \"fire\"");
CHECK(pokedex_array.at("pokedex").at(0).at("fire").at(1) == "vulpix");
hierarchy = std::ostringstream("");
sb::json_access(hierarchy, pokedex_array, true, "pokedex", 3, "normal") = {"bidoof", "happiny"};
CHECK(hierarchy.str() == "\"pokedex\" > 3 > \"normal\"");
CHECK(pokedex_array.at("pokedex").at(3).at("normal").is_array());
CHECK(pokedex_array.at("pokedex").at(3).at("normal").at(0) == "bidoof");
CHECK(pokedex_array.at("pokedex").at(2).is_null());
}
SECTION("Merge into a configuration object")
{
sb::Configuration config;
config.merge(pokedex);
CHECK(config().contains("display"));
CHECK(config("keys").at("fullscreen").size() == 2);
CHECK(config().contains("pokedex"));
CHECK(config("pokedex", "gen 2").contains("grass"));
CHECK(config("pokedex", "gen 2", "grass")[0] == "chikorita");
nlohmann::json waluigi_snatch = R"({
"pokedex": {
"gen 1": {
"water": ["Aqua Squirrel", "Aqua Chicken", "Aqua Rooster"],
"sour": ["Tangesquirt", "Lymeister"]
},
"gen 2": [],
"gen 3": "dankey kang"
},
"display": {
"dimensions": [4096, 2160]
},
"audio": null,
"input": {}
})"_json;
config.merge(waluigi_snatch);
CHECK(config("pokedex", "gen 1", "water")[0] == "Aqua Squirrel");
CHECK(config("pokedex", "gen 1", "sour")[1] == "Lymeister");
CHECK(config("pokedex", "gen 1", "grass")[1] == "oddish");
CHECK(config("pokedex", "gen 2").is_array());
CHECK(config("pokedex", "gen 2").size() == 0);
CHECK(config("pokedex", "gen 3") == "dankey kang");
CHECK(config("display", "dimensions").get<glm::vec2>() == glm::vec2 {4096, 2160});
CHECK(config("display", "max framerate") == -1);
CHECK_THROWS_AS(config("audio"), std::runtime_error);
CHECK(config("input", "default unsuppress delay") == 0.7f);
}
}
TEST_CASE("Configuration lookup")
{
/* Write a second config to the config folder. It will be overwritten during the test, then deleted
* automatically at the end. */
std::shared_ptr<fs::path> more_config {
new fs::path { "config/Read_me_2nd.json" },
[] (fs::path* ptr) { if (fs::exists(*ptr)) { fs::remove(*ptr); } delete ptr; }
};
sb::test::write_json(*more_config, R"({"Invader": "👾"})"_json);
/* Turn on auto refresh to check if updates are being read. */
sb::Game a_game {R"({
"configuration": {"auto refresh": true, "auto refresh interval": 1.0},
"log": {"stdout enabled": true}}
)"_json};
/* Test the read order of the test program's config files. */
CHECK(a_game.configuration()("configuration", "auto refresh"));
CHECK(a_game.configuration()("") == "");
CHECK(a_game.configuration()("Puddings").size() == 3);
CHECK(a_game.configuration()("Puddings")[0] == "Moist");
CHECK(a_game.configuration()("Invader") == "👾");
/* While the game is running, write new data to an existing config and check that it is loaded. */
bool edited = false;
std::optional<float> first_draw;
a_game.run(
[&](float timestamp)
{
if (!first_draw.has_value())
{
/* Use the timestamp of the first draw to determine how long the game has been running. */
first_draw = timestamp;
}
else
{
/* Timeout after 20 seconds */
if (timestamp - first_draw.value() > 20.0f)
{
a_game.flag_to_end();
FAIL("Config update not registered in game");
}
else
{
if (!edited)
{
sb::test::write_json("config/Read_me_2nd.json", R"({"Invader": "Waluigi"})"_json);
edited = true;
}
else
{
if (a_game.configuration()("Invader") == "Waluigi")
{
a_game.flag_to_end();
}
}
}
}
});
a_game.quit();
}
/* The HTTP test is not available on Emscripten because the Fetch API is not usable with Asyncify. There is a separate
* program for testing HTTP on WASM in wasm_http_test/ */
#if defined(HTTP_ENABLED) && !defined(__EMSCRIPTEN__)
TEST_CASE("HTTP")
{
using namespace sb::cloud;
sb::init();
nlohmann::json enable_log {
{"log", {
{"stdout enabled", true},
{"file enabled", true},
{"debug to file", true},
{"debug to stdout", true}
}}};
sb::Game luigi_game {enable_log};
REQUIRE(sb::cloud::HTTP::initialized());
/* Run again for logging now that logging is initialized */
sb::cloud::init();
/* Create two HTTP objects to test if request queues can run simultaneously */
HTTP http1;
HTTP http2;
/* Count the number of completed requests that passed all assertions */
std::size_t pass_count { 0 };
/* Create a request guaranteed to get a successful response */
std::string url_200 { "https://httpbin.org/status/200" };
HTTP::Request request_200 { url_200 };
CHECK(request_200.url() == url_200);
CHECK(request_200.timeout() == 10.0f);
CHECK(request_200.status() == 0);
CHECK(!request_200.complete());
/* Check for a successful request with no response or data */
request_200.callback([&](const HTTP::Request& request){
CHECK(request.status() == 200);
CHECK(request.url() == url_200);
CHECK(request.bytes().empty());
CHECK(request.json() == nlohmann::json {});
CHECK(request.error().empty());
pass_count++;
});
CHECK(request_200.callback() != nullptr);
http1.get(request_200);
/* Create a request guaranteed to get a failure response */
std::string url_404 { "https://httpbin.org/status/404" };
HTTP::Request request_404 { url_404 };
/* Check for a failed request with no response or data */
request_404.callback([&](const HTTP::Request& request){
CHECK(request.status() == 404);
CHECK(request.url() == url_404);
CHECK(request.bytes().empty());
CHECK(request.json() == nlohmann::json {});
/* Emscripten builds don't set the error, but they may in the future */
pass_count++;
});
http2.get(request_404);
/* Create a GET request with a query string and test headers, and the mock server will reply with details about the
* request in JSON. */
HTTP::Request request_get { "https://httpbin.org/anything?meatball=spicy" };
request_get.headers({"One-Plus-One", "2"});
request_get.callback([&](const HTTP::Request& request){
CHECK(request.status() == 200);
CHECK(request.error().empty());
CHECK(!request.bytes().empty());
CHECK(request.json().at("args").at("meatball") == "spicy");
CHECK(request.json().at("method") == "GET");
CHECK(request.json().at("headers").at("One-Plus-One") == "2");
pass_count++;
});
http1.get(request_get);
/* Create a POST request and pass data with the request */
HTTP::Request request_post { "https://httpbin.org/anything" };
nlohmann::json command { { "SADDLE UP", "SALLY FORTH" } };
request_post.data(command);
request_post.callback([&](const HTTP::Request& request){
CHECK(request.status() == 200);
CHECK(request.error().empty());
CHECK(!request.bytes().empty());
CHECK(request.json().at("method") == "POST");
CHECK(request.json().at("data") == command.dump());
pass_count++;
});
http2.post(request_post);
/* Create a request that tests authentication */
std::string user { "Waluigi" };
std::string password { "1tsW4LU1G1t1m3" };
HTTP::Request request_auth { "https://httpbin.org/basic-auth/" + user + "/" + password };
request_auth.user(user);
request_auth.password(password);
request_auth.callback([&](const HTTP::Request& request){
CHECK(request.status() == 200);
CHECK(request.error().empty());
CHECK(!request.bytes().empty());
CHECK(request.json().at("authenticated"));
CHECK(request.json().at("user") == user);
pass_count++;
});
http1.get(request_auth);
/* Create a request with no callback */
HTTP::Request request_no_callback { "https://httpbin.org/status/200" };
http2.get(request_no_callback);
/* Create a request for a bad URL */
HTTP::Request request_bad { "https://spicy.meatball/waluigi" };
request_bad.callback([&](const HTTP::Request& request){
CHECK(request.status() == 0);
CHECK(!request.error().empty());
CHECK(request.bytes().empty());
pass_count++;
});
http1.get(request_bad);
/* Create a request that should timeout */
HTTP::Request request_timeout { "https://httpbin.org/delay/5" };
request_timeout.timeout(1.0f);
CHECK(request_timeout.timeout() == 1.0f);
request_timeout.callback([&](const HTTP::Request& request){
CHECK(request.status() == 0);
CHECK(!request.error().empty());
CHECK(request.bytes().empty());
pass_count++;
});
http2.get(request_timeout);
/* Check counts for each request queue */
CHECK(http1.count() == 4);
CHECK(http2.count() == 4);
/* Run a game, updating the queues each frame */
luigi_game.run([&](float timestamp){
if (http1.count() == 0 && http2.count() == 0)
{
CHECK(pass_count == 7);
luigi_game.flag_to_end();
}
else if (timestamp > 10.0f)
{
FAIL("Timeout while running HTTP test");
}
http1.update(timestamp);
http2.update(timestamp);
});
sb::quit();
}
#endif
#if defined(STEAM_ENABLED)
TEST_CASE("Steam API connection")
{
sb::init();
nlohmann::json enable_log {
{"log", {
{"stdout enabled", true},
{"file enabled", true},
{"debug to file", true},
{"debug to stdout", true}
}}};
sb::Game luigi_game {enable_log};
if (!SteamAPI_IsSteamRunning())
{
FAIL("Steam client must be running to run this test.");
}
if (!fs::exists("steam_appid.txt"))
{
FAIL("There must be a file named \"steam_appid.txt\" in the working directory to run this test.");
}
UNSCOPED_INFO("Checking sb::cloud::steam interface was set to initialized");
REQUIRE(sb::cloud::steam::initialized());
/* Run again for logging now that logging is initialized */
sb::cloud::steam::init();
/* Set an int and a float stat */
sb::progress::Progress progress;
sb::progress::Stat bananas {"_BANANAS", "Bananas", sb::progress::Stat::INT, false};
sb::progress::Stat pies {"_PIES", "Pies", sb::progress::Stat::FLOAT, false};
int stash = GENERATE(take(1, random(0, 100'000)));
float radians = GENERATE(take(1, random(0, 100'000))) * glm::pi<float>();
sb::Animation set {[&](){
progress.set_stat(bananas, stash);
progress.set_stat(pies, radians);
}};
set.play_once(2.0f);
sb::Animation increment {[&](){
/* Add a bunch to the stash */
progress.increment_stat(bananas, 5);
sb::cloud::steam::store_stats();
/* Count players for a separate test */
sb::cloud::steam::count_players();
}};
increment.play_once(3.0f);
int remote_bananas;
float remote_pies;
/* Get the achievement at index 0 */
std::string achievement_id;
sb::Animation get_achievement {[&](){
achievement_id = SteamAPI_ISteamUserStats_GetAchievementName(SteamUserStats(), 0);
if (achievement_id.empty())
{
sb::Log::Multi(sb::Log::WARN) << "No achievement found for the app at index 0. There must be at least " <<
"one achievement defined to run this test." << sb::Log::end;
}
}};
get_achievement.play_once(2.0f);
/* Track progress through the steps required to complete the transaction with the Steam API */
bool achievement_cleared = false;
bool achievement_real_status;
bool achievement_successful;
/* Request stats so stat total can be tested. This requires a stat named _WALUIGI to have been previously set to 100
* on the test app. */
sb::cloud::steam::request_global_stats(60);
/* Run a game for at most 30 seconds simulating values being stored to the Steam API */
luigi_game.run([&](float timestamp){
/* Exit loop as soon as needed values are retrieved or timeout after 30 seconds */
if (timestamp > 30.0f)
{
CHECK(sb::cloud::steam::player_count().has_value());
CHECK(remote_bananas == stash + 5);
CHECK(remote_pies == radians);
CHECK(achievement_successful);
FAIL("Unable to sync data with Steam");
}
/* Get the stat's value on Steam */
SteamAPI_ISteamUserStats_GetStatInt32(SteamUserStats(), bananas.id().c_str(), &remote_bananas);
SteamAPI_ISteamUserStats_GetStatFloat(SteamUserStats(), pies.id().c_str(), &remote_pies);
/* Check if all tests have completed: player count, stat set, achievement set, global stat retrieved */
if (sb::cloud::steam::player_count().has_value() && remote_bananas == stash + 5 && remote_pies == radians &&
achievement_successful && sb::cloud::steam::global_stats_available())
{
/* Reset the achievement to unset if it was unset before the test */
if (!achievement_real_status)
{
if (!SteamAPI_ISteamUserStats_ClearAchievement(SteamUserStats(), achievement_id.c_str()))
{
sb::Log::Multi(sb::Log::ERR) << "Error restoring original unset status for " << achievement_id <<
sb::Log::end;
}
else
{
sb::Log::Multi(sb::Log::DEBUG) << "Restored unset status for " << achievement_id << sb::Log::end;
}
}
/* Make sure the global stat total is the expected value. */
sb::progress::Stat waluigi {"_WALUIGI"};
float total;
sb::cloud::steam::get_global_stat_total(waluigi, total);
CHECK(total == 100);
luigi_game.flag_to_end();
}
/* Check if the achievement has been registered (only if the achievement is already confirmed to be cleared) */
if (achievement_cleared)
{
SteamAPI_ISteamUserStats_GetAchievement(SteamUserStats(), achievement_id.c_str(), &achievement_successful);
}
/* Clear the achievement status */
if (!achievement_id.empty() && !achievement_cleared)
{
bool result = SteamAPI_ISteamUserStats_GetAchievement(
SteamUserStats(), achievement_id.c_str(), &achievement_real_status);
if (!result)
{
sb::Log::Multi(sb::Log::ERR) << "Unable to read achievement " << achievement_id << ". Ending test." <<
sb::Log::end;
luigi_game.flag_to_end();
}
else
{
sb::Log::Multi(sb::Log::DEBUG) << "Achievement " << achievement_id << " is currently " <<
std::boolalpha << achievement_real_status << sb::Log::end;
if (!SteamAPI_ISteamUserStats_ClearAchievement(SteamUserStats(), achievement_id.c_str()))
{
sb::Log::Multi(sb::Log::ERR) << "Unable to clear achievement status for " << achievement_id <<
". Ending test." << sb::Log::end;
luigi_game.flag_to_end();
}
else
{
sb::Log::Multi(sb::Log::DEBUG) << "Cleared achievement status for " << achievement_id <<
sb::Log::end;
achievement_cleared = true;
progress.unlock_achievement(sb::progress::Achievement {achievement_id});
CHECK(progress.achievement_unlocked(sb::progress::Achievement {achievement_id}));
sb::cloud::steam::store_stats();
}
}
}
/* Update animation timers */
set.update(timestamp);
increment.update(timestamp);
get_achievement.update(timestamp);
});
luigi_game.quit();
sb::quit();
/* Make sure Steam is indicated as unavailable if the framework is initialized with Steam initialization
* force-skipped. */
sb::initialize_steam = false;
sb::init();
CHECK_FALSE(sb::cloud::steam::initialized());
sb::quit();
/* Set the log to stdout, so the next test can start with the default. */
sb::test::reset_log_function();
}
#endif
TEST_CASE("Standard library time serialization")
{
/* Check that the minutes since epoch can be serialized and read back */
std::chrono::time_point<std::chrono::system_clock> now { std::chrono::system_clock::now() };
long megaminutes { std::chrono::duration_cast<std::chrono::minutes>(now.time_since_epoch()).count() };
nlohmann::json serialize(megaminutes);
std::chrono::minutes record { serialize.get<long>() };
long in_five_minutes { (record + std::chrono::minutes(5)).count() };
std::cout << "There are currently " << megaminutes << " minutes, and in five minutes there will be " <<
in_five_minutes << "." << std::endl;
CHECK(
in_five_minutes ==
std::chrono::duration_cast<std::chrono::minutes>((now + std::chrono::minutes(5)).time_since_epoch()).count()
);
/* Check that adding 24 hours changes the day string */
std::time_t ctime { std::chrono::system_clock::to_time_t(now) };
char day[sizeof "20240420"];
strftime(day, sizeof day, "%Y%m%d", localtime(&ctime));
std::time_t ctime_plus_1d { std::chrono::system_clock::to_time_t(now + std::chrono::hours(24)) };
char day_plus_1d[sizeof day];
strftime(day_plus_1d, sizeof day, "%Y%m%d", localtime(&ctime_plus_1d));
CHECK_FALSE(std::string(day) == std::string(day_plus_1d));
/* Check that 23 hours difference from hour zero doesn't change the day string */
std::chrono::time_point<std::chrono::system_clock> epoch;
std::time_t epoch_ctime { std::chrono::system_clock::to_time_t(epoch) };
char epoch_day[sizeof day];
strftime(epoch_day, sizeof day, "%Y%m%d", gmtime(&epoch_ctime));
std::time_t epoch_plus_23h { std::chrono::system_clock::to_time_t(epoch + std::chrono::hours(23)) };
char epoch_day_plus_23h[sizeof day];
strftime(epoch_day_plus_23h, sizeof day, "%Y%m%d", gmtime(&epoch_plus_23h));
CHECK(std::string(epoch_day) == std::string(epoch_day_plus_23h));
/* Check helper functions */
CHECK(sb::time::epoch_minutes(now) == megaminutes);
CHECK(sb::time::epoch_minutes(std::chrono::time_point<std::chrono::system_clock>()) == 0);
CHECK(sb::time::epoch_minutes(now + std::chrono::minutes(5)) == megaminutes + 5);
CHECK(sb::time::day_stamp(now).size() == 8);
CHECK(sb::time::day_stamp(now) == std::string(day));
}
/*!
* Check the calculated dimensions of text rendered with the BPmono.ttf font included in the test directory. The font
* font is defined as default in the configuration object.
*
* The font file is included with the test program because a font file is required by SPACE🪐BOX. BPmono.ttf is
* available in the engine source repository for all projects to use. The project can either copy BPmono.ttf or define a
* new default in the config.
*/
TEST_CASE("Text dimensions")
{
sb::Game game { nlohmann::json {{"log", {{"stdout enabled", true}}}} };
/* Empty string has a height of 18 because of the font's height. */
sb::Text hello {game.font()};
CHECK(hello.dimensions() == glm::ivec2 {0, 18});
/* Pre-specified dimensions */
std::string message {"Hello, World 🙂!"};
hello = sb::Text {game.font(), message, {}, {}, glm::ivec2 {1337, 420}};
CHECK(hello.dimensions() == glm::ivec2 {1337, 420});
/* Auto dimensions */
hello = sb::Text {game.font(), message};
CHECK(hello.dimensions() == glm::ivec2 {146, 18});
}