1269 lines
50 KiB
C++
1269 lines
50 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"
|
|
#include "../math.hpp"
|
|
#include "../version.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)
|
|
{
|
|
std::string id { progress.id() };
|
|
progress.save(path, force);
|
|
REQUIRE_FALSE(progress.id().empty());
|
|
if (!id.empty()) REQUIRE(progress.id() == id);
|
|
REQUIRE(progress.id().size() == 6);
|
|
REQUIRE(fs::exists(path));
|
|
sb::progress::Progress check;
|
|
check.load(path);
|
|
CHECK(progress == check);
|
|
CHECK(progress.id() == check.id());
|
|
}
|
|
|
|
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");
|
|
|
|
/* 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();
|
|
}
|
|
|
|
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 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();
|
|
}
|
|
|
|
/* 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. Note that httpbin.org seems to require auth tests to use the GET
|
|
* method rather than POST. */
|
|
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 session data transfer. */
|
|
sb::progress::Progress progress;
|
|
http1.post_session(
|
|
"https://httpbin.org/anything",
|
|
progress.session(),
|
|
"Waluigi's Motorcycle 🏍",
|
|
"4.2.0",
|
|
"Sony Playstation 9",
|
|
user,
|
|
password);
|
|
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 = nlohmann::json::parse(request.json().at("data").get<std::string>());
|
|
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("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 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 > 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(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::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});
|
|
}
|
|
|
|
/*!
|
|
* Test the sb::math namespace, which includes trigonometric functions, curves, and random numbers.
|
|
*/
|
|
TEST_CASE("Math")
|
|
{
|
|
/* Test that seeded random IDs of the same length are equal */
|
|
int seed { 1'800'777'777 };
|
|
CHECK(sb::math::random::id(13, seed) == sb::math::random::id(13, seed));
|
|
|
|
/* Test that the ID is the correct length */
|
|
CHECK(sb::math::random::id(13).size() == 13);
|
|
}
|