The mock HTTP host at httpbin.org has been unstable recently, so replace it with a fork at mockhttp.org. Run requests through a CORS proxy for the Emscripten HTTP tests because mockhttp.org does not set CORS headers. Update instructions for building and running HTTP request tests with the Emscripten build. Add parameter for passing arbitrary headers along with analytics. Remove sleep statements from Windows test program builds. Set test program to refresh config less frequently to prevent a race condition when refreshing is lagging.
1459 lines
57 KiB
C++
1459 lines
57 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 <random>
|
|
|
|
#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"
|
|
#include "../math.hpp"
|
|
#include "../version.hpp"
|
|
#include "../cloud.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 = nlohmann::json({{
|
|
"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 = nlohmann::json({
|
|
{
|
|
"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, bool pretty = true)
|
|
{
|
|
std::string id { progress.id() };
|
|
progress.save(path, force, pretty);
|
|
REQUIRE_FALSE(progress.id().empty());
|
|
REQUIRE(progress.id() == id);
|
|
REQUIRE(progress.id().size() == 6);
|
|
REQUIRE(fs::exists(path));
|
|
REQUIRE(fs::file_size(path) > 0);
|
|
sb::progress::Progress check;
|
|
check.load(path);
|
|
CHECK(progress == check);
|
|
CHECK(progress.id() == check.id());
|
|
}
|
|
|
|
TEST_CASE("Progress")
|
|
{
|
|
sb::init();
|
|
|
|
/* 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_FALSE(progress.id().empty());
|
|
CHECK(progress.contains("profile"));
|
|
CHECK(progress.contains("rank"));
|
|
CHECK(progress.contains("achievements"));
|
|
CHECK(progress.contains("config"));
|
|
CHECK_FALSE(progress.contains("dankey kang"));
|
|
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 = nlohmann::json({
|
|
{"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 = nlohmann::json({
|
|
{"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);
|
|
#if defined(_WIN32)
|
|
/* Windows contains extra bytes in line breaks */
|
|
CHECK(fs::file_size(path) == 1'366);
|
|
#else
|
|
CHECK(fs::file_size(path) == 1'311);
|
|
#endif
|
|
test_progress_write(progress, path, true, false);
|
|
#if defined(_WIN32)
|
|
/* Windows contains extra bytes in line breaks */
|
|
CHECK(fs::file_size(path) == 721);
|
|
#else
|
|
CHECK(fs::file_size(path) == 720);
|
|
#endif
|
|
}
|
|
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");
|
|
|
|
/* Load stat and achievement progress from existing data. Check if existing data loads correctly. Update stat and
|
|
* achievement progress. Check if session data is tracked separately from cumulative data. */
|
|
using namespace sb::progress;
|
|
Progress gamer_progress;
|
|
CHECK(gamer_progress.session().contains("_id"));
|
|
gamer_progress.load(nlohmann::json({
|
|
{ "stats", { { "beans", 3.0f }, { "fish", 9.9f } } } ,
|
|
{ "achievements", { "slay", "grow" } }
|
|
}));
|
|
CHECK(gamer_progress.stat_value(Stat{"beans"}) == 3.0f);
|
|
CHECK(gamer_progress.stat_value(Stat{"fish"}) == 9.9f);
|
|
gamer_progress.increment_stat(Stat{"fish"}, 0.1f);
|
|
CHECK(gamer_progress.stat_value(Stat{"fish"}) == 10.0f);
|
|
CHECK(gamer_progress.session().contains("fish"));
|
|
CHECK_FALSE(gamer_progress.session().contains("beans"));
|
|
CHECK(gamer_progress.session().at("fish").get<float>() == 0.1f);
|
|
gamer_progress.unlock_achievement(Achievement{"champ"});
|
|
CHECK(gamer_progress.session().contains("achievements"));
|
|
CHECK(gamer_progress.session().at("achievements")[0] == "champ");
|
|
|
|
#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();
|
|
|
|
sb::quit();
|
|
}
|
|
|
|
TEST_CASE("JSON convenience accessor and merge")
|
|
{
|
|
/* For testing string key access */
|
|
nlohmann::json pokedex = nlohmann::json({
|
|
{"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 = nlohmann::json({
|
|
{"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")
|
|
{
|
|
sb::init();
|
|
|
|
/* 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": 5.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 30 seconds */
|
|
if (timestamp - first_draw.value() > 30.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();
|
|
|
|
sb::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 = nlohmann::json({
|
|
{"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 };
|
|
|
|
/* Host of mock HTTP server. The mock HTTP server is expected to be https://mockhttp.org, but a mirror can be used.
|
|
*/
|
|
std::string http_host = "https://mockhttp.org";
|
|
|
|
/* Create a request guaranteed to get a successful response */
|
|
std::string url_200 { http_host + "/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.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 { http_host + "/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);
|
|
/* 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. Note that some servers seem to force all headers to lowercase, so only lowercase headers are
|
|
* used. */
|
|
HTTP::Request request_get { http_host + "/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 { http_host + "/anything" };
|
|
nlohmann::json command = nlohmann::json({ { "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").dump() == command.dump());
|
|
pass_count++;
|
|
});
|
|
http2.post(request_post);
|
|
|
|
/* Create a request that tests authentication. Note that httpbin seems to require auth tests to use the GET method
|
|
* rather than POST. */
|
|
std::string user { "waluigi" };
|
|
std::string password { "1tsW4LU1G1t1m3" };
|
|
std::string authorization { "d2FsdWlnaToxdHNXNExVMUcxdDFtMw==" };
|
|
HTTP::Request request_auth { http_host + "/basic-auth/" + user + "/" + password };
|
|
request_auth.authorization(authorization);
|
|
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 { http_host + "/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);
|
|
|
|
/* Check session data transfer. */
|
|
sb::progress::Progress progress;
|
|
http1.post_analytics(
|
|
http_host + "/anything",
|
|
progress.session(),
|
|
"Waluigi's Motorcycle 🏍",
|
|
"4.2.0",
|
|
"Sony Playstation 9",
|
|
"",
|
|
authorization);
|
|
http1.queue(http1.count() - 1).callback([&](const HTTP::Request& request){
|
|
CHECK(request.status() == 200);
|
|
CHECK(request.error().empty());
|
|
CHECK(!request.bytes().empty());
|
|
CHECK(request.json().at("method") == "POST");
|
|
nlohmann::json data = request.json().at("data");
|
|
CHECK(data.at("_id").get<std::string>().size() == 6);
|
|
#if defined(STEAM_ENABLED)
|
|
if (sb::cloud::steam::initialized())
|
|
{
|
|
CHECK(data.at("steam user").get<std::string>().size() == 6);
|
|
}
|
|
#endif
|
|
CHECK(data.at("save data id") == progress.id());
|
|
CHECK(data.at("engine version") == sb::version);
|
|
CHECK(data.at("build") == sb::build);
|
|
CHECK(data.at("game title") == "Waluigi's Motorcycle 🏍");
|
|
CHECK(data.at("game version") == "4.2.0");
|
|
CHECK(data.at("platform") == "Sony Playstation 9");
|
|
pass_count++;
|
|
});
|
|
|
|
/* Check other analytics data transfer. */
|
|
nlohmann::json analytics = nlohmann::json({
|
|
{"Value Meal", 4},
|
|
{"Size", "Large"},
|
|
{"Drink", "Pepsi"},
|
|
{"platform", "MacDonald"},
|
|
{"game version", "0.69"}
|
|
});
|
|
http2.post_analytics(
|
|
http_host + "/anything",
|
|
analytics,
|
|
"Waluigi's Motorcycle 🏍",
|
|
"4.2.0",
|
|
"",
|
|
"F00F",
|
|
authorization);
|
|
http2.queue(http2.count() - 1).callback([&](const HTTP::Request& request){
|
|
CHECK(request.status() == 200);
|
|
CHECK(request.error().empty());
|
|
CHECK(!request.bytes().empty());
|
|
CHECK(request.json().at("method") == "POST");
|
|
nlohmann::json data = request.json().at("data");
|
|
#if defined(STEAM_ENABLED)
|
|
if (sb::cloud::steam::initialized())
|
|
{
|
|
CHECK(data.at("steam user").get<std::string>().size() == 6);
|
|
}
|
|
#endif
|
|
CHECK(data.at("save data id") == "F00F");
|
|
CHECK(data.at("engine version") == sb::version);
|
|
CHECK(data.at("build") == sb::build);
|
|
CHECK(data.at("game title") == "Waluigi's Motorcycle 🏍");
|
|
CHECK(data.at("game version") == "4.2.0");
|
|
CHECK(data.at("platform") == "MacDonald");
|
|
pass_count++;
|
|
});
|
|
|
|
/* Check counts for each request queue */
|
|
CHECK(http1.count() == 5);
|
|
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 == 8);
|
|
luigi_game.flag_to_end();
|
|
}
|
|
else if (timestamp > 30.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 = nlohmann::json({
|
|
{"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(sb::time::minute_stamp(now).size() == 12);
|
|
char minute[sizeof "202404201620"];
|
|
strftime(minute, sizeof minute, "%Y%m%d%H%M", localtime(&ctime));
|
|
CHECK(sb::time::minute_stamp(now) == std::string(minute));
|
|
}
|
|
|
|
/*!
|
|
* 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::init();
|
|
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});
|
|
|
|
sb::quit();
|
|
}
|
|
|
|
/*!
|
|
* Test the sb::math namespace, which includes trigonometric functions, curves, and random numbers.
|
|
*/
|
|
TEST_CASE("Math")
|
|
{
|
|
/* Expected random integers */
|
|
for (int generation = 0; generation < 3; generation++)
|
|
{
|
|
int result { sb::math::random::integer(0, 4) };
|
|
CHECK(result <= 4);
|
|
CHECK(result >= 0);
|
|
}
|
|
std::uint32_t seed { 1'234'567 };
|
|
std::mt19937 generator;
|
|
generator.seed(seed);
|
|
CHECK(sb::math::random::integer(0, 2'025, &generator) == 1'973);
|
|
CHECK(sb::math::random::integer(-9'999, -999, &generator) == -5'285);
|
|
CHECK_THROWS_AS(sb::math::random::integer(2, 1), std::invalid_argument);
|
|
|
|
/* Seeded random IDs of the same length are deterministic across platforms */
|
|
CHECK(sb::math::random::id(13, seed) == "b9imrmmfip4wz");
|
|
CHECK(sb::math::random::id(13, seed) == sb::math::random::id(13, seed));
|
|
|
|
/* ID is the correct length */
|
|
CHECK(sb::math::random::id(13).size() == 13);
|
|
|
|
/* Loop */
|
|
CHECK(sb::math::loop(0.0f, 0.0f) == 0.0f);
|
|
CHECK(sb::math::loop(1'800'777'777.0f, 0.0f) == 0.0f);
|
|
CHECK(sb::math::loop(1'800'777'777.0f, -1.0f) == 0.0f);
|
|
CHECK(sb::math::loop(0.0f, 1'800'777'777.0f) == 0.0f);
|
|
CHECK(sb::math::loop(0.0f, -1.0f) == 0.0f);
|
|
CHECK(sb::math::loop(glm::pi<float>(), glm::pi<float>()) == 0.0f);
|
|
CHECK(sb::math::loop(2.0f * glm::pi<float>(), glm::pi<float>()) == 0.0f);
|
|
CHECK(sb::math::loop(5.0f, 2.0f) == 0.5f);
|
|
float pos = sb::math::loop(35.0f, 10.0f);
|
|
CHECK(glm::lerp(glm::vec2(2.0f, -3.0f), glm::vec2(3.0f, 3.0f), pos) == glm::vec2(2.5f, 0.0f));
|
|
pos = sb::math::loop(250.0f, 200.0f);
|
|
CHECK(glm::lerp(0.0f, glm::pi<float>(), pos) == glm::quarter_pi<float>());
|
|
|
|
/* Ping pong */
|
|
CHECK(sb::math::ping_pong(0.0f, 0.0f) == 0.0f);
|
|
CHECK(sb::math::ping_pong(1'800'777'777.0f, 0.0f) == 0.0f);
|
|
CHECK(sb::math::ping_pong(1'800'777'777.0f, -1.0f) == 0.0f);
|
|
CHECK(sb::math::ping_pong(0.0f, 1'800'777'777.0f) == 0.0f);
|
|
CHECK(sb::math::ping_pong(0.0f, -1.0f) == 0.0f);
|
|
CHECK(sb::math::ping_pong(0.75f, 1.0f) == 0.75f);
|
|
CHECK(sb::math::ping_pong(1.25f, 1.0f) == 0.75f);
|
|
CHECK(sb::math::ping_pong(1.75f, 1.0f) == 0.25f);
|
|
CHECK(sb::math::ping_pong(3.75f, 1.0f) == 0.25f);
|
|
CHECK(sb::math::ping_pong(2.0f * 3.75f, 2.0f * 1.0f) == 0.25f);
|
|
CHECK(sb::math::ping_pong(glm::pi<float>(), glm::pi<float>()) == 1.0f);
|
|
CHECK(sb::math::ping_pong(5.5f, 2.5f) == 0.2f);
|
|
pos = sb::math::ping_pong(12.5f, 10.0f);
|
|
CHECK(glm::lerp(glm::vec2(2.0f, -3.0f), glm::vec2(3.0f, 3.0f), pos) == glm::vec2(2.75f, 1.5f));
|
|
pos = sb::math::ping_pong(250.0f, 200.0f);
|
|
CHECK(glm::lerp(0.0f, glm::pi<float>(), pos) == 3.0f * glm::quarter_pi<float>());
|
|
}
|
|
|
|
/*!
|
|
* Test the sb::Sprite class
|
|
*/
|
|
TEST_CASE("Sprite")
|
|
{
|
|
/* Default sprite with a default plane */
|
|
sb::Sprite sprite;
|
|
CHECK(sprite.texture_index() == 0);
|
|
CHECK(sprite.visible() == true);
|
|
CHECK(sprite.scale() == glm::vec2{1.0f, 1.0f});
|
|
CHECK(sprite.translation() == glm::vec3{0.0f, 0.0f, 0.0f});
|
|
|
|
/* Sprite with one frame */
|
|
sprite = sb::Sprite("cake/cake1.png");
|
|
CHECK(sprite.texture_index() == 0);
|
|
sprite.play();
|
|
sprite.update(0.01f);
|
|
CHECK(sprite.texture_index() == 0);
|
|
sprite.update(0.02f);
|
|
CHECK(sprite.texture_index() == 0);
|
|
sprite = sb::Sprite("cake/cake1.png", glm::vec2{2.0f});
|
|
CHECK(sprite.scale() == glm::vec2{2.0f, 2.0f});
|
|
|
|
/* Sprite with multiple frames, loaded from a folder of images */
|
|
sprite = sb::Sprite("cake/");
|
|
CHECK(sprite.texture_index() == 0);
|
|
sprite.frame_length(1.0f);
|
|
sprite.play();
|
|
sprite.update(0.0f);
|
|
sprite.update(1.01f);
|
|
CHECK(sprite.texture_index() == 1);
|
|
sprite.update(2.01f);
|
|
CHECK(sprite.texture_index() == 2);
|
|
sprite.update(3.01f);
|
|
CHECK(sprite.texture_index() == 3);
|
|
sprite.update(4.01f);
|
|
CHECK(sprite.texture_index() == 0);
|
|
sprite.move(glm::vec3{1.0f, 2.0f, 3.0f});
|
|
CHECK(sprite.translation() == glm::vec3{1.0f, 2.0f, 3.0f});
|
|
sprite.move(glm::vec3{-1.0f, 2.0f, -6.0f});
|
|
CHECK(sprite.translation() == glm::vec3{0.0f, 4.0f, -3.0f});
|
|
}
|
|
|
|
/*!
|
|
* Test the library functions for the test suite.
|
|
*/
|
|
TEST_CASE("Testing")
|
|
{
|
|
{
|
|
/* Get a scoped temp file path */
|
|
std::shared_ptr<fs::path> whats_up { sb::test::temp_path("Hello_World.txt") };
|
|
CHECK(*whats_up == fs::temp_directory_path() / "Hello_World.txt");
|
|
|
|
/* Write to the file to create it */
|
|
std::string contents { "What's up?!" };
|
|
std::ofstream(*whats_up) << contents;
|
|
CHECK(fs::exists(*whats_up));
|
|
CHECK(sb::file_to_string(*whats_up) == contents);
|
|
}
|
|
|
|
/* The temp file should be deleted after the scope exits */
|
|
CHECK_FALSE(fs::exists(fs::temp_directory_path() / "Hello_World.txt"));
|
|
|
|
{
|
|
/* Get a scoped temp directory path */
|
|
std::shared_ptr<fs::path> well { sb::test::temp_path("my_wishing_well") };
|
|
CHECK(*well == fs::temp_directory_path() / "my_wishing_well");
|
|
|
|
/* Create and add to the directory */
|
|
fs::create_directory(*well);
|
|
CHECK(fs::exists(*well));
|
|
std::string wish { "I want Vladimir Putin to take over America" };
|
|
std::ofstream(*well / "my_wish.txt") << wish;
|
|
fs::create_directory(*well / "coins");
|
|
std::ofstream(*well / "coins" / "nickel") << "5¢";
|
|
std::ofstream(*well / "coins" / "quarter") << "25¢";
|
|
CHECK(fs::exists(*well / "my_wish.txt"));
|
|
CHECK(fs::exists(*well / "coins" / "nickel"));
|
|
}
|
|
|
|
/* The directory (and its contents) should be deleted */
|
|
CHECK_FALSE(fs::exists(fs::temp_directory_path() / "my_wishing_well"));
|
|
}
|