cakefoot/src/test/test.cpp
Cocktail Frank 927de132be Remove calls to JSON constructor which use initializer list
There is a bug in the JSON library which causes the initializer list
constructor to inconsistently create either an array or a scalar value.

This bug causes inconsistent behavoir between the PC and WASM builds.

See https://json.nlohmann.me/home/faq/#known-bugs
2025-09-17 19:54:28 -04:00

1531 lines
56 KiB
C++

/*@~@~@ C A K E F O O T <presented by> 💫dank.game💫
|~)~)~)
|\~*~*| Licensed under the zlib and CC-BY licenses. Source is available at
|\\~*~|
,~|#\\~*|~, <https://open.shampoo.ooo/shampoo/cakefoot>
: \\@\\~| :
: \\#\\| : Created with the SPACE🪐BOX engine for cross-platform PC, WebGL and mobile games
: \\@\' :
: \\/ : <https://open.shampoo.ooo/shampoo/spacebox>
`~ ~ ~`~ */
#include "test/setup.hpp"
#include "filesystem.hpp"
#include "../Cakefoot.hpp"
#include "../Replay.hpp"
nlohmann::json mock_preferences_json = R"({
"config": {
"fullscreen": true,
"use display button": false
}})"_json;
nlohmann::json mock_stats_json = R"({
"achievements": [],
"config": {},
"stats": {
"STAT_CONSECUTIVE_DAYS_PLAYED": 7.0,
"STAT_WARPED_LEVELS_BEATEN": 21
}
})"_json;
nlohmann::json mock_progress_json = R"({
"progress": {
"all time bank": "--++-+----------------",
"arcade bank": 0,
"arcade checkpoint": 0.0,
"arcade difficulty": 0,
"arcade level": 1,
"arcade max distance": 0,
"arcade time": 0.0,
"current challenge": 0,
"current difficulty": 0,
"current level": 7,
"current view": 2,
"jackpot": 0,
"max difficulty": 0,
"max level": 7,
"max view": 2,
"quest bank": "--++-+----------------",
"quest best": 0.0,
"quest checkpoint": 0,
"quest deaths": 1,
"quest difficulty": 0,
"quest level": 7,
"quest time": 9999.934000015258789,
"total time": 9999.934000015258789,
"warped": "+++++++++++++++++++++-"
}
})"_json;
nlohmann::json mock_deprecated_progress_json = R"({
"progress": {
"arcade bank": "-++++++-+-----+---++-+",
"arcade checkpoint": 0,
"arcade coin": false,
"arcade difficulty": 0,
"arcade level": 8,
"arcade max distance": 14036,
"arcade time": 91.6259937286377,
"current challenge": 0,
"current difficulty": 2,
"current level": 11,
"current view": 0,
"deaths": 3583,
"jackpot": 0,
"max challenge": 5,
"max difficulty": 2,
"max level": 11,
"max view": 0,
"quest bank": "++++++++++------------",
"quest best": 4214.365234375,
"quest checkpoint": 0,
"quest coin": false,
"quest difficulty": 2,
"quest level": 11,
"quest time": 987.0749969482422,
"total time": 26409.425424814224
}})"_json;
nlohmann::json mock_zero_arcade_bank_json = R"({
"progress": {
"arcade bank": 0,
"quest bank": "++++++++++------------"
}})"_json;
nlohmann::json mock_zero_quest_bank_json = R"({
"progress": {
"arcade bank": "-++++++-+-----+---++-+",
"quest bank": 0
}})"_json;
nlohmann::json mock_scores_json = R"([
{
"date": "2024/10/16 18:57",
"distance": 14036,
"name": "ZZZ",
"time": 5.3380126953125
},
{
"date": "2024/10/16 18:57",
"distance": 14036,
"name": "ZZZ",
"time": 305.3380126953125
},
{
"date": "2024/10/16 18:57",
"distance": 1912,
"name": "UAA",
"time": 0.0
},
{
"date": "2024/10/16 18:57",
"distance": 1337,
"name": "UAA",
"time": 0.0
},
{
"date": "2024/10/16 18:57",
"distance": 777,
"name": "UAA",
"time": 0.0
},
{
"date": "2024/10/16 18:57",
"distance": 420,
"name": "UAA",
"time": 0.0
},
{
"date": "2024/10/16 18:57",
"distance": 49,
"name": "UWU",
"time": 0.0
},
{
"date": "2024/10/16 18:58",
"distance": 47,
"name": "BUB",
"time": 0.0
},
{
"date": "2024/10/16 18:57",
"distance": 35,
"name": "TOE",
"time": 0.0
},
{
"date": "2024/10/16 18:57",
"distance": 19,
"name": "AAA",
"time": 0.0
},
{
"date": "2024/10/16 18:57",
"distance": 19,
"name": "EGG",
"time": 0.0
},
{
"date": "2024/10/16 18:57",
"distance": 0,
"name": "AAA",
"time": 0.0
}
])"_json;
/* Use temporary directory to create mock paths for mock save-data JSON */
std::shared_ptr<fs::path> mock_preferences_path { sb::test::temp_path("cakefoot_mock_preferences.json") };
std::shared_ptr<fs::path> mock_stats_path { sb::test::temp_path("cakefoot_mock_stats.json") };
std::shared_ptr<fs::path> mock_progress_path { sb::test::temp_path("cakefoot_mock_progress.json") };
std::shared_ptr<fs::path> mock_deprecated_progress_path { sb::test::temp_path("cakefoot_mock_deprecated_progress.json") };
std::shared_ptr<fs::path> mock_scores_path { sb::test::temp_path("cakefoot_mock_scores.json") };
std::shared_ptr<fs::path> mock_replays_path { sb::test::temp_path("cakefoot_mock_replays") };
/* Mock paths for non-existing files */
std::shared_ptr<fs::path> mock_empty_stats_path { sb::test::temp_path("cakefoot_mock_empty_stats.json") };
std::shared_ptr<fs::path> mock_empty_progress_path { sb::test::temp_path("cakefoot_mock_empty_progress.json") };
std::shared_ptr<fs::path> mock_empty_scores_path { sb::test::temp_path("cakefoot_mock_empty_scores.json") };
std::shared_ptr<fs::path> mock_empty_preferences_path { sb::test::temp_path("cakefoot_mock_empty_preferences.json") };
/* Mock paths for testing bank parses */
std::shared_ptr<fs::path> mock_zero_arcade_bank_path { sb::test::temp_path("cakefoot_mock_zero_arcade_bank.json") };
std::shared_ptr<fs::path> mock_zero_quest_bank_path { sb::test::temp_path("cakefoot_mock_zero_quest_bank.json") };
int main(int argc, char** argv)
{
Catch::Session session;
/* Set up the CLI and reporters */
sb::test::setup(session, argc, argv);
/* Rig the stats so the last time stamp was recent and the day will not match today */
std::chrono::time_point<std::chrono::system_clock> now { std::chrono::system_clock::now() };
mock_stats_json["timestamp"] = sb::time::epoch_minutes(now - std::chrono::hours(23));
mock_stats_json["day"] = "ฅ^•ﻌ•^ฅ";
/* Write mock data to temporary files */
sb::test::write_json(*mock_preferences_path, mock_preferences_json);
sb::test::write_json(*mock_progress_path, mock_progress_json);
sb::test::write_json(*mock_deprecated_progress_path, mock_deprecated_progress_json);
sb::test::write_json(*mock_stats_path, mock_stats_json);
sb::test::write_json(*mock_scores_path, mock_scores_json);
sb::test::write_json(*mock_zero_arcade_bank_path, mock_zero_arcade_bank_json);
sb::test::write_json(*mock_zero_quest_bank_path, mock_zero_quest_bank_json);
return session.run();
}
class MockCakefoot
{
private:
class NoClipCakefoot : public Cakefoot
{
public:
NoClipCakefoot(bool noclip, std::vector<nlohmann::json> configs) : Cakefoot(configs)
{
this->noclip = noclip;
}
template<typename Clock>
void mock_validate_date(std::chrono::time_point<Clock> time)
{
validate_date(time);
}
const MultiBGM& mock_bgm()
{
return bgm;
}
const std::map<std::string, sb::audio::Chunk>& mock_audio()
{
return audio;
}
void mock_reconfig()
{
reconfig();
}
float mock_credits()
{
return credits;
}
bool mock_operator_menu_active()
{
return operator_menu_active;
}
std::vector<cakefoot::Replay> mock_replays(int level)
{
return replays(level);
}
void mock_store_replay_recording(cakefoot::Replay& replay, int level)
{
store_replay_recording(replay, level);
}
};
public:
NoClipCakefoot cakefoot;
sb::progress::Stats stats;
sb::progress::Achievements achievements;
sb::progress::Progress stat_progress;
sb::progress::Progress progress;
sb::progress::Progress preferences;
MockCakefoot(
fs::path progress, fs::path scores, fs::path preferences, fs::path stats, nlohmann::json merge = {},
bool noclip = true
) :
cakefoot(NoClipCakefoot { noclip, { merge, nlohmann::json({
{ "display", {{ "use play button", false }} },
/* Uncomment the next line to get log output on the console */
// { "log", {{ "stdout enabled", true }, {"debug to stdout", false }} },
{ "storage", {
{"progress file", progress},
{"scores file", scores},
{"preferences file", preferences},
{"stats file", stats},
{"stats write frequency", 2.0f}}},
{ "replay", {{ "directory", *mock_replays_path }} }
})}}),
stats(sb::progress::Stats { cakefoot.configuration() }),
achievements(sb::progress::Achievements { cakefoot.configuration() })
{
refresh_progress();
}
void refresh_progress()
{
stat_progress.load(cakefoot.configuration()("storage", "stats file").get<fs::path>());
if (fs::exists(cakefoot.configuration()("storage", "progress file").get<fs::path>()))
{
progress.load(cakefoot.configuration()("storage", "progress file").get<fs::path>());
}
if (fs::exists(cakefoot.configuration()("storage", "preferences file").get<fs::path>()))
{
preferences.load(cakefoot.configuration()("storage", "preferences file").get<fs::path>());
}
}
};
/*!
* Run a full quest followed by a full arcade run, until the end screen, by auto pressing buttons on the title screen.
* Check title screen menu values in the process. If count is 1 instead of the default of 2, only run the quest run.
*/
void mock_full_runs(MockCakefoot& mock, int count = 2)
{
bool checked_challenges = false;
bool checked_pause = false;
int resets = 0;
int downs = 0;
std::size_t toggles = 0;
std::size_t toggle_target = 0;
const nlohmann::json& challenge_list = mock.cakefoot.configuration()("challenge");
mock.cakefoot.run([&](float timestamp){
if (mock.cakefoot.configuration()("progress", "quest deaths") > 2)
{
mock.cakefoot.flag_to_end();
}
else
{
mock.refresh_progress();
std::size_t level = mock.cakefoot.configuration()("progress", "current level");
int progress_challenge = mock.cakefoot.configuration()("progress", "current challenge");
if (!checked_challenges)
{
if (downs++ == 0)
{
/* Move to challenge increment button */
sb::Delegate::post("down");
sb::Delegate::post("right");
/* Set toggle target to the amount of toggles to do a complete loop around the menu */
if (challenge_list.at(progress_challenge).at("name") == "RESUME QUEST")
{
toggle_target = challenge_list.size() - 1;
}
else if (challenge_list.at(progress_challenge).at("name") == "NEW QUEST")
{
toggle_target = challenge_list.size() - 3;
}
else
{
CAPTURE(challenge_list.at(progress_challenge).at("name"));
FAIL("Unsupported initial test conditions or menu code has failed");
}
/* Try posting the operator event to make sure it doesn't do anything. */
sb::Delegate::post("operator");
}
else if (toggles == 0)
{
/* Toggle to arcade mode, then start counting toggles */
if (challenge_list.at(progress_challenge).at("name") == "ARCADE")
{
toggles++;
}
sb::Delegate::post("any");
/* Make sure operator mode wasn't triggered */
CHECK_FALSE(mock.cakefoot.mock_operator_menu_active());
}
else if (toggles < toggle_target)
{
if (toggles == 2 || toggles == 3)
{
/* This is expected to not move the cursor to make sure the start button is disabled */
sb::Delegate::post("up");
}
/* Toggle until reaching arcade mode */
if (challenge_list.at(progress_challenge).at("name") != "ARCADE")
{
toggles++;
sb::Delegate::post("any");
}
}
else if (toggles == toggle_target)
{
/* One full loop exactly around the menu should have ended at arcade mode */
CHECK(challenge_list.at(progress_challenge).at("name") == "ARCADE");
CHECK(mock.cakefoot.mock_audio().at("menu").playing());
CHECK_FALSE(mock.cakefoot.mock_bgm().playing());
/* Do five toggles to wrap around to quest mode */
sb::Delegate::post("any");
sb::Delegate::post("any");
sb::Delegate::post("any");
sb::Delegate::post("any");
sb::Delegate::post("any");
toggles += 5;
}
else
{
/* Check if quest mode is active */
if (toggle_target == challenge_list.size() - 1)
{
CHECK(challenge_list.at(progress_challenge).at("name") == "RESUME QUEST");
}
else if (toggle_target == challenge_list.size() - 3)
{
CHECK(challenge_list.at(progress_challenge).at("name") == "NEW QUEST");
}
/* Start the quest */
sb::Delegate::post("up");
sb::Delegate::post("any");
checked_challenges = true;
toggles = 0;
downs = 0;
}
}
/* Check if a run is happening on cake difficulty */
else if (mock.cakefoot.configuration()("progress", "max difficulty") < 1)
{
/* Skip levels until the final level */
if (level < mock.cakefoot.configuration()("levels").size() - 2)
{
const nlohmann::json& world = mock.cakefoot.configuration()("world");
for (std::size_t ii = 0; ii < world.size(); ii++)
{
if (ii == world.size() - 1 || level < world[ii + 1].at("start"))
{
CHECK(mock.cakefoot.mock_bgm().playing());
CHECK(mock.cakefoot.mock_bgm().index() == ii);
break;
}
}
CHECK(challenge_list.at(progress_challenge).at("name") == "RESUME QUEST");
CHECK(mock.cakefoot.configuration()("progress", "quest level") == level);
sb::Delegate::post("skip forward");
}
/* Post a pause event to make sure it doesn't crash the game. The "any" event will close the pause menu
* if it is working correctly. */
else if (!checked_pause)
{
sb::Delegate::post("pause");
checked_pause = true;
}
/* Accelerate character */
else
{
sb::Delegate::post("any");
}
}
else if (resets < 1)
{
if (count < 2)
{
mock.cakefoot.flag_to_end();
}
else
{
sb::Delegate::post("reset");
resets++;
}
}
else if (downs++ == 0)
{
CHECK(challenge_list.at(progress_challenge).at("name") == "NEW QUEST");
sb::Delegate::post("down");
sb::Delegate::post("right");
}
else if (toggles++ < 2)
{
sb::Delegate::post("any");
if (toggles == 2)
{
sb::Delegate::post("up");
sb::Delegate::post("any");
}
}
else if (!mock.stat_progress.achievement_unlocked(mock.achievements["ACH_HOTCAKES"]))
{
if (mock.cakefoot.configuration()("progress", "arcade level") <
mock.cakefoot.configuration()("levels").size() - 2)
{
if (level > 0)
{
CHECK(challenge_list.at(progress_challenge).at("name") == "RESUME ARCADE");
CHECK(mock.cakefoot.configuration()("progress", "arcade level") == level);
}
sb::Delegate::post("skip forward");
}
else
{
sb::Delegate::post("any");
}
}
else
{
mock.cakefoot.flag_to_end();
}
}
mock.cakefoot.draw(timestamp);
});
}
TEST_CASE("Empty storage")
{
/* Create a game with no progress or existing stats. */
MockCakefoot mock {
*mock_empty_progress_path,
*mock_empty_scores_path,
*mock_empty_preferences_path,
*mock_empty_stats_path
};
/* Confirm that only one stat and achievement were written */
CHECK(mock.stat_progress.read("stats").size() == 1);
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_CONSECUTIVE_DAYS_PLAYED"]) == 1);
CHECK(mock.stat_progress.achievement_count() >= 1);
CHECK(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_EMPTY_STOMACH"]));
/* Force unlock date-based achievements */
std::tm birthday_tm;
std::istringstream ss("2024-05-10 00:00:00");
std::string format = "%Y-%m-%d %H:%M:%S";
ss >> std::get_time(&birthday_tm, format.c_str());
birthday_tm.tm_isdst = true;
std::chrono::time_point<std::chrono::system_clock> birthday {
std::chrono::system_clock::from_time_t(std::mktime(&birthday_tm))
};
std::tm cake_day_tm;
ss = std::istringstream("2024-11-26 00:00:00");
ss >> std::get_time(&cake_day_tm, format.c_str());
std::chrono::time_point<std::chrono::system_clock> cake_day {
std::chrono::system_clock::from_time_t(std::mktime(&cake_day_tm))
};
mock.cakefoot.mock_validate_date(birthday);
mock.cakefoot.mock_validate_date(cake_day);
#if !defined(__EMSCRIPTEN__)
/* Launch a game */
mock_full_runs(mock);
/* Stats are written when quit is called */
mock.cakefoot.quit();
mock.refresh_progress();
/* Check achievements */
CHECK(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_PIECE_OF_CAKE"]));
CHECK(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_EASY_AS_PIE"]));
CHECK(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_TAKES_THE_CAKE"]));
CHECK(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_CAKEWALK"]));
CHECK(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_MOIST"]));
CHECK(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_KNEAD_FOR_SPEED"]));
CHECK(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_CAKEFEAT"]));
CHECK(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_HOTCAKES"]));
CHECK(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_SLICE_OF_LIFE"]));
CHECK_FALSE(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_WHISKED"]));
CHECK(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_BIRTHDAY_CAKE"]));
CHECK(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_CAKE_MY_DAY"]));
/* Check stats */
CHECK(mock.stat_progress.stat_exists(mock.stats["STAT_FASTEST_QUEST_TIME"]));
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_FASTEST_QUEST_TIME"]) > 10.0f);
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_FASTEST_QUEST_TIME"]) < 30.0f);
CHECK(mock.stat_progress.stat_exists(mock.stats["STAT_BEST_ARCADE_CLOCK"]));
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_BEST_ARCADE_CLOCK"]) > 700.0f);
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_BEST_ARCADE_CLOCK"]) < 750.0f);
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_CHECKPOINTS_REACHED"]) == 1);
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_ARCADE_RUNS"]) == 1);
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_FARTHEST_ARCADE_DISTANCE"]) == 14'036);
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_FARTHEST_DISTANCE_REACHED"]) == 14'036);
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_QUESTS_COMPLETED"]) == 1);
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_LEVELS_UNLOCKED"]) == 22);
CHECK_FALSE(mock.stat_progress.stat_exists(mock.stats["STAT_WARPED_LEVELS_BEATEN"]));
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_ARCADE_RUNS_STARTED"]) == 1);
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_GAMES_STARTED"]) == 2);
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_POWER_ON_TIME"]) > 10.0f);
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_PLAY_TIME"]) > 15.0f);
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_POWER_ON_TIME"]) >
mock.stat_progress.stat_value(mock.stats["STAT_PLAY_TIME"]));
/* Check progress */
CHECK(mock.progress.read("progress", "quest deaths").get<int>() == 0);
CHECK(mock.progress.read("progress", "arcade deaths").get<int>() == 0);
/* Check replay data */
CHECK(fs::exists(*mock_replays_path));
CHECK_FALSE(fs::is_empty(*mock_replays_path));
std::size_t count = 0;
for (const fs::directory_entry& entry : fs::directory_iterator(*mock_replays_path))
{
/* Make sure only one file is in the directory */
CHECK(count++ < 1);
/* Open the replay as JSON and make sure the first key frame is located at the start of level 22 */
CHECK(entry.is_regular_file());
nlohmann::json json = sb::json_from_file(entry.path());
CHECK(json.contains("key frames"));
CHECK(json.at("key frames").at(0).at(2) == 5'637);
}
#endif
/* Delete progress files, so they won't affect the next test */
fs::remove(*mock_empty_stats_path);
fs::remove(*mock_empty_progress_path);
fs::remove(*mock_empty_preferences_path);
fs::remove(*mock_empty_scores_path);
fs::remove_all(*mock_replays_path);
}
#if !defined(__EMSCRIPTEN__)
TEST_CASE("D'ohnut")
{
/* Create a game with no progress or existing stats, and override the config. */
MockCakefoot mock {
*mock_empty_progress_path,
*mock_empty_scores_path,
*mock_empty_preferences_path,
*mock_empty_stats_path,
{},
false
};
/* Mock the D'ohnut achievement so it happens at any point after the beginning of the line */
mock.cakefoot.configuration()["achievements"][4]["goal"] = 0.01f;
mock.cakefoot.mock_reconfig();
/* Force another birthday unlock to check a date after May 10th as well */
std::tm birthday_tm;
std::istringstream ss("2024-05-31 00:00:00");
std::string format = "%Y-%m-%d %H:%M:%S";
ss >> std::get_time(&birthday_tm, format.c_str());
birthday_tm.tm_isdst = true;
std::chrono::time_point<std::chrono::system_clock> birthday {
std::chrono::system_clock::from_time_t(std::mktime(&birthday_tm))
};
mock.cakefoot.mock_validate_date(birthday);
/* Run until three deaths */
mock_full_runs(mock, 1);
/* Quit game and write save progress */
mock.cakefoot.quit();
/* Check results */
mock.refresh_progress();
CHECK(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_BIRTHDAY_CAKE"]));
CHECK(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_DOHNUT"]));
CHECK(mock.progress.read("progress", "quest deaths").get<int>() == 3);
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_TOTAL_DEATHS"], mock.stats) == 3);
/* Delete progress files, so they won't affect the next test */
fs::remove(*mock_empty_stats_path);
fs::remove(*mock_empty_progress_path);
}
#endif
TEST_CASE("In-progress")
{
/* Create a game with an in-progress quest. */
MockCakefoot mock {
*mock_progress_path,
*mock_empty_scores_path,
*mock_empty_preferences_path,
*mock_stats_path
};
/* Check initial stats after construction */
CHECK(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_EMPTY_STOMACH"]));
#if !defined(__EMSCRIPTEN__)
/* One full quest run with existing progress */
mock_full_runs(mock, 1);
/* Stats are written when quit is called */
mock.cakefoot.quit();
mock.refresh_progress();
/* Check achievements */
CHECK(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_PIECE_OF_CAKE"]));
CHECK(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_EASY_AS_PIE"]));
CHECK(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_TAKES_THE_CAKE"]));
CHECK(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_CAKEWALK"]));
CHECK(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_MOIST"]));
CHECK_FALSE(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_SLICE_OF_LIFE"]));
CHECK_FALSE(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_KNEAD_FOR_SPEED"]));
CHECK_FALSE(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_CAKEFEAT"]));
CHECK(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_WHISKED"]));
/* Check stats */
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_FASTEST_QUEST_TIME"]) > 9999.9f);
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_FARTHEST_DISTANCE_REACHED"]) == 14'036);
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_QUESTS_COMPLETED"]) == 1);
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_LEVELS_UNLOCKED"]) == 22);
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_WARPED_LEVELS_BEATEN"]) == 22);
/* Check progress */
CHECK(mock.progress.read("progress", "warped").get<std::string>() == "++++++++++++++++++++++");
#endif
/* Reset progress files, so they won't affect the next test */
fs::remove(*mock_stats_path);
sb::test::write_json(*mock_stats_path, mock_stats_json);
fs::remove(*mock_empty_progress_path);
}
TEST_CASE("Zero arcade bank")
{
/* Create a game with coins saved in the quest bank and an empty arcade bank. */
MockCakefoot mock {
*mock_zero_arcade_bank_path,
*mock_empty_scores_path,
*mock_empty_preferences_path,
*mock_empty_stats_path
};
/* Check the contents */
CHECK(mock.stat_progress.read("stats").size() == 3);
CHECK(mock.stat_progress.stat_exists(mock.stats["STAT_COINS_COLLECTED"]));
CHECK(mock.stat_progress.stat_exists(mock.stats["STAT_COINS_UNLOCKED"]));
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_COINS_COLLECTED"]) == 10);
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_COINS_UNLOCKED"]) == 10);
CHECK(mock.cakefoot.configuration()("progress", "all time bank") == "++++++++++------------");
/* Quit so another game can launch */
mock.cakefoot.quit();
/* Delete stats file, so it won't affect the next test */
fs::remove(*mock_empty_stats_path);
}
TEST_CASE("Zero quest bank")
{
/* Create a game with coins saved in the arcade bank and an empty quest bank. */
MockCakefoot mock {
*mock_zero_quest_bank_path,
*mock_empty_scores_path,
*mock_empty_preferences_path,
*mock_empty_stats_path
};
/* Check the contents */
CHECK(mock.stat_progress.read("stats").size() == 3);
CHECK(mock.stat_progress.stat_exists(mock.stats["STAT_COINS_COLLECTED"]));
CHECK(mock.stat_progress.stat_exists(mock.stats["STAT_COINS_UNLOCKED"]));
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_COINS_COLLECTED"]) == 11);
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_COINS_UNLOCKED"]) == 11);
CHECK(mock.cakefoot.configuration()("progress", "all time bank") == "-++++++-+-----+---++-+");
/* Quit so another game can launch */
mock.cakefoot.quit();
/* Delete stats file, so it won't affect the next test */
fs::remove(*mock_empty_stats_path);
}
TEST_CASE("Estimate stats")
{
/* Create a game with deprecated progress file and no existing stats file. */
MockCakefoot mock {
*mock_deprecated_progress_path,
*mock_scores_path,
*mock_preferences_path,
*mock_empty_stats_path
};
/* Check the contents */
REQUIRE(mock.stat_progress.stat_exists(mock.stats["STAT_SLICER_DEATHS"]));
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_SLICER_DEATHS"]) == 895);
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_TOTAL_DEATHS"], mock.stats) == 4 * 895);
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_DISTANCE_TRAVELED"]) == 2 * 20'000 + 10 * 800 + 32'687);
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_ARCADE_RUNS"]) == 12);
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_FARTHEST_ARCADE_DISTANCE"]) == 14'036);
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_FARTHEST_DISTANCE_REACHED"]) == 14'036);
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_QUESTS_COMPLETED"]) == 2);
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_CAKES_UNLOCKED"]) == 2);
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_FASTEST_QUEST_TIME"]) == 4'214.365234375);
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_BEST_ARCADE_CLOCK"]) == 305.3380126953125);
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_LEVELS_UNLOCKED"]) == 22);
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_COINS_COLLECTED"]) == 21);
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_COINS_UNLOCKED"]) == 14);
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_CONSECUTIVE_DAYS_PLAYED"]) == 1);
CHECK(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_EMPTY_STOMACH"]));
CHECK(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_LAYER_CAKE"]));
CHECK(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_FRESHLY_BAKED"]));
CHECK(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_FORKED_UP"]));
CHECK(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_OVERCOOKED"]));
CHECK(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_BAKERS_DOZEN"]));
CHECK(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_HOTCAKES"]));
CHECK(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_CAKEWALK"]));
CHECK(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_MOIST"]));
CHECK(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_SUPER_MOIST"]));
CHECK(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_PIECE_OF_CAKE"]));
CHECK(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_EASY_AS_PIE"]));
CHECK(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_TAKES_THE_CAKE"]));
CHECK(mock.cakefoot.configuration()("progress", "all time bank") == "++++++++++----+---++-+");
/* Quit so another game can launch */
mock.cakefoot.quit();
}
TEST_CASE("Existing stats")
{
/* Create a game with existing stats. */
MockCakefoot mock {
*mock_empty_progress_path,
*mock_scores_path,
*mock_preferences_path,
*mock_stats_path
};
/* Launch a game */
mock.cakefoot.run([&](float timestamp){
if (timestamp > 10.0f)
{
mock.cakefoot.flag_to_end();
}
else
{
mock.cakefoot.draw(timestamp);
}
});
/* Check that a consecutive day was added */
mock.refresh_progress();
CHECK(mock.stat_progress.stat_value(mock.stats["STAT_CONSECUTIVE_DAYS_PLAYED"]) == 8);
CHECK(mock.stat_progress.achievement_unlocked(mock.achievements["ACH_C8KE"]));
/* Quit so another game can launch */
mock.cakefoot.quit();
}
TEST_CASE("Idle timeout")
{
/* Mock demo and arcade-only modes */
for (const std::string mode : {"demo", "arcade"})
{
/* Fresh installation. */
MockCakefoot mock {
*mock_empty_progress_path,
*mock_empty_scores_path,
*mock_empty_preferences_path,
*mock_empty_stats_path
};
/* Mock a quick idle timeout. */
mock.cakefoot.configuration()[mode]["idle timeout"] = 2.0f;
mock.cakefoot.configuration()[mode]["countdown display timeout"] = 2.0f;
if (mode == "demo")
{
mock.cakefoot.configuration()["demo"]["active"] = true;
}
else
{
mock.cakefoot.configuration()["arcade"]["arcade only"] = true;
}
/* Launch */
bool started = false;
bool button_released = false;
mock.cakefoot.run([&](float timestamp){
/* Automatically fail if running for more than 15 seconds */
if (timestamp > 15.0f)
{
CAPTURE(mode);
FAIL("Idle timeout test failed");
}
else
{
if (!started)
{
/* Start game */
sb::Delegate::post("any");
started = true;
}
else if (!button_released)
{
/* Stop moving */
sb::Delegate::post("any", true);
button_released = true;
}
/* Use the currently playing audio to determine if the main menu has been returned to, confirming idle
* reset. */
else if (mock.cakefoot.mock_audio().at("menu").playing() && !mock.cakefoot.mock_bgm().playing())
{
mock.cakefoot.flag_to_end();
}
mock.cakefoot.draw(timestamp);
}
});
mock.cakefoot.quit();
}
}
TEST_CASE("Credits")
{
/* Test five different configurations for credits */
for (const nlohmann::json& params : nlohmann::json({
{ "four", true, 4.0f, 1.0f, 0.0f },
{ "one", true, 1.0f, 1.0f, 0.0f },
{ "double", true, 50.5f, 25.25f, 0.0f },
{ "max", true, 2.0f, 1.0f, 3.0f },
{ "none", false, 4.0f, 1.0f, 0.0f } }))
{
/* Mock arcade cabinet version requiring credits to play (other than the last configuration). */
MockCakefoot mock {
*mock_empty_progress_path,
*mock_empty_scores_path,
*mock_empty_preferences_path,
*mock_empty_stats_path,
{{ "arcade",
{
{ "arcade only", true },
{ "credits enabled", params[1] },
{ "idle timeout", 2.0f },
{ "countdown display timeout", 2.0f },
{ "credits required", params[2] },
{ "credit increase per event", params[3] },
{ "max credits", params[4] }
} } }
};
/* Launch */
bool running = false;
std::optional<float> run_start;
int try_count = 0;
mock.cakefoot.run([&](float timestamp){
/* Automatically fail if running for more than 10 seconds */
if (running && timestamp - *run_start > 10.0f)
{
CAPTURE(params);
FAIL("Credits test failed");
}
else if (!running)
{
running = true;
run_start = timestamp;
}
else
{
if (try_count == 0)
{
/* Always start by trying to start the game */
sb::Delegate::post("any");
sb::Delegate::post("any", true);
try_count++;
}
else if (try_count == 1)
{
if (params[0] == "none")
{
/* If there was no credit requirement, the game should have begun. */
CHECK(mock.cakefoot.mock_bgm().playing());
mock.cakefoot.flag_to_end();
}
else
{
/* Make sure the game didn't start */
CHECK(mock.cakefoot.mock_audio().at("menu").playing());
/* Try to start game again, then add a credit. */
sb::Delegate::post("any");
sb::Delegate::post("any", true);
sb::Delegate::post("add credit");
}
try_count++;
}
else if (try_count == 2)
{
/* Check the credit account */
if (params[0] != "double")
{
CHECK(mock.cakefoot.mock_credits() == 1.0f);
}
else
{
CHECK(mock.cakefoot.mock_credits() == 25.25f);
}
/* Make sure the game didn't start */
CHECK(mock.cakefoot.mock_audio().at("menu").playing());
/* Try to start game again, then add another credit. */
sb::Delegate::post("any");
sb::Delegate::post("any", true);
sb::Delegate::post("add credit");
try_count++;
}
else if (try_count == 3)
{
if (params[0] == "one")
{
/* Only one credit was required */
CHECK(mock.cakefoot.mock_bgm().playing());
mock.cakefoot.flag_to_end();
}
else
{
/* Check the credit account */
if (params[0] != "double")
{
CHECK(mock.cakefoot.mock_credits() == 2.0f);
}
else
{
CHECK(mock.cakefoot.mock_credits() == 50.5f);
}
/* Make sure the game didn't start */
CHECK(mock.cakefoot.mock_audio().at("menu").playing());
/* Try starting again */
sb::Delegate::post("any");
sb::Delegate::post("any", true);
}
try_count++;
}
else if (try_count == 4)
{
if (params[0] == "double" || params[0] == "max")
{
/* Credit requirement should be satisfied */
CHECK(mock.cakefoot.mock_bgm().playing());
if (params[0] == "max")
{
/* Test the max credits feature */
sb::Delegate::post("add credit");
sb::Delegate::post("add credit");
}
else
{
/* End test */
mock.cakefoot.flag_to_end();
}
}
/* Add two more credits for final test and try again */
sb::Delegate::post("add credit");
sb::Delegate::post("add credit");
sb::Delegate::post("any");
sb::Delegate::post("any", true);
try_count++;
}
else if (try_count == 5)
{
if (params[0] == "max")
{
/* Make sure credits didn't go over the max */
CHECK(mock.cakefoot.mock_credits() == 3.0f);
mock.cakefoot.flag_to_end();
}
else
{
/* Make sure credits were removed from account and game started. */
CHECK(mock.cakefoot.mock_credits() == 0.0f);
CHECK(mock.cakefoot.mock_bgm().playing());
}
try_count++;
}
else if (try_count == 6)
{
/* Wait for arcade run to reset, then try another run */
if (mock.cakefoot.mock_audio().at("menu").playing())
{
sb::Delegate::post("any");
sb::Delegate::post("any", true);
try_count++;
}
}
else if (try_count == 7)
{
/* Make sure the game didn't start */
CHECK_FALSE(mock.cakefoot.mock_bgm().playing());
mock.cakefoot.flag_to_end();
}
mock.cakefoot.draw(timestamp);
}
});
/* Make sure the amount of tries matches the parameters */
if (params[0] == "four")
{
CHECK(try_count == 7);
}
else if (params[0] == "one")
{
CHECK(try_count == 4);
}
else if (params[0] == "double")
{
CHECK(try_count == 5);
}
else if (params[0] == "max")
{
CHECK(try_count == 6);
}
else if (params[0] == "none")
{
CHECK(try_count == 2);
}
mock.cakefoot.quit();
}
}
TEST_CASE("Operator menu")
{
/* Mock arcade cabinet version */
MockCakefoot mock {
*mock_empty_progress_path,
*mock_empty_scores_path,
*mock_empty_preferences_path,
*mock_empty_stats_path,
{{ "operator",
{
{ "enabled", true }
} },
{ "arcade",
{
{ "arcade only", true },
{ "credits enabled", false },
{ "idle timeout", 2.0f },
{ "countdown display timeout", 2.0f },
{ "credits required", 1.0f },
{ "credit increase per event", 1.0f },
{ "max credits", 0.0f }
} } }
};
/* Launch */
bool running = false;
std::optional<float> run_start;
int toggles = 0;
int downs = 0;
int ups = 0;
mock.cakefoot.run([&](float timestamp){
/* Automatically fail if running for more than 10 seconds */
if (running && timestamp - *run_start > 10.0f)
{
FAIL("Operator menu test timed out");
}
else if (!running)
{
running = true;
run_start = timestamp;
}
else if (toggles == 0)
{
if (!mock.cakefoot.mock_operator_menu_active())
{
/* Open the operator menu */
sb::Delegate::post("operator");
}
else
{
/* Toggle credits enabled on */
sb::Delegate::post("any");
toggles++;
}
}
else if (downs == 0)
{
/* Setting should not have been applied */
CHECK_FALSE(mock.cakefoot.configuration()("arcade", "credits enabled"));
/* Move down to exit */
for (int count = 0; count < 7; count++) sb::Delegate::post("down");
downs = 7;
}
else if (ups == 0)
{
/* Try exiting which should require a confirmation press */
sb::Delegate::post("any");
/* Move up to apply & save */
sb::Delegate::post("up");
ups++;
}
else if (downs == 7)
{
/* Try moving to credit increase, deleting all characters, and changing value */
sb::Delegate::post("up");
sb::Delegate::post("up");
sb::Delegate::post("up");
sb::Delegate::post("up");
sb::Delegate::post("any");
sb::Delegate::post("up");
sb::Delegate::post("up");
sb::Delegate::post("any");
sb::Delegate::post("any");
sb::Delegate::post("any");
sb::Delegate::post("any");
sb::Delegate::post("up");
sb::Delegate::post("up");
sb::Delegate::post("any");
sb::Delegate::post("down");
sb::Delegate::post("down");
sb::Delegate::post("down");
sb::Delegate::post("any");
ups += 4;
/* Try the apply and save button */
sb::Delegate::post("down");
sb::Delegate::post("down");
sb::Delegate::post("down");
sb::Delegate::post("down");
sb::Delegate::post("any");
/* Move down to exit and press the button */
sb::Delegate::post("down");
sb::Delegate::post("any");
downs += 5;
}
else
{
mock.cakefoot.flag_to_end();
}
});
/* Check if the menu exited, all buttons were pressed, and the settings were applied */
CHECK_FALSE(mock.cakefoot.mock_operator_menu_active());
CHECK(toggles == 1);
CHECK(ups == 5);
CHECK(downs == 12);
CHECK(mock.cakefoot.configuration()("arcade", "credits enabled"));
CHECK(mock.cakefoot.configuration()("arcade", "credit increase per event") == 9.0f);
/* Quit the game and check if the setting was saved to file */
mock.cakefoot.quit();
mock.refresh_progress();
CHECK(mock.preferences.read("config", "arcade", "credits enabled"));
}
TEST_CASE("HTTP")
{
/* Only on Linux builds with cURL enabled, test the arcade build's remote logger call after auto-updates. */
#if defined(__LINUX__) && defined(HTTP_ENABLED)
/* Mock arcade cabinet version */
MockCakefoot mock {
*mock_empty_progress_path,
*mock_empty_scores_path,
*mock_empty_preferences_path,
*mock_empty_stats_path,
{{ "operator",
{
{ "enabled", true }
} },
{ "arcade",
{
{ "arcade only", true },
{ "credits enabled", false },
{ "idle timeout", 2.0f },
{ "countdown display timeout", 2.0f },
{ "credits required", 1.0f },
{ "credit increase per event", 1.0f },
{ "max credits", 0.0f }
} } }
};
/* Try a non-existent update script to make sure the game still runs. */
/* Try an update script that runs and exits with an error. */
/* Try an update script that runs and exits normally. */
/* Try a non-existent remote logger. */
/* Try a remote logger that returns an error code. */
/* Try a remote logger that returns a 200 status. */
#endif
}
float lower_precision(float higher_precision)
{
return float(int(cakefoot::Replay::time_precision * higher_precision)) / cakefoot::Replay::time_precision;
}
TEST_CASE("Replay")
{
using namespace cakefoot;
/* Mock replay is able to get public access to the JSON path. */
class MockReplay : public Replay
{
public:
const fs::path& mock_path()
{
return most_recent_path;
}
};
/* Create a 0.1s long replay with two frames */
MockReplay replay;
CHECK(replay.empty());
Replay::KeyFrame second_frame { Replay::KeyFrame{2.2f, glm::vec2 {0.1f, 0.0f}, true} };
replay.record(Replay::KeyFrame{2.1f, glm::vec2 {0.0f, 0.0f}});
CHECK_FALSE(replay.empty());
replay.record(second_frame);
/* The replay lengths are affected by the conversion to lower precision values */
CHECK(replay.length() == lower_precision(2.2f) - lower_precision(2.1f));
CHECK(replay.elapsed(second_frame) == 2.2f - lower_precision(2.1f));
/* Read the first frame */
std::vector<Replay::KeyFrame> unread { replay.unread(0.0f) };
CHECK(unread.size() == 1);
CHECK(unread.front().time == lower_precision(2.1f));
CHECK(replay.elapsed(unread.front()) == 0.0f);
CHECK(unread.front().translation == glm::vec2 {0.0f, 0.0f});
CHECK_FALSE(unread.front().mirrored);
/* Try reading a frame before a new frame is ready */
unread = replay.unread(0.05f);
CHECK(unread.size() == 0);
/* Read the next frame */
unread = replay.unread(0.11f);
CHECK(unread.size() == 1);
CHECK(unread.front().time == lower_precision(2.2f));
CHECK(replay.elapsed(unread.front()) == lower_precision(2.2f) - lower_precision(2.1f));
CHECK(unread.front().translation == glm::vec2 {0.1f, 0.0f});
CHECK(unread.front().mirrored);
/* Try reading another frame after all frames have been read */
unread = replay.unread(0.5f);
CHECK(unread.size() == 0);
/* Read all frames at once */
replay.reset();
unread = replay.unread(0.11f);
CHECK(unread.size() == 2);
CHECK_FALSE(replay.coin_taken);
CHECK_FALSE(replay.coin_collected);
/* Add coin events */
replay.record(Replay::KeyFrame{2.3f, glm::vec2 {0.2f, 0.0f}, true, Replay::coin});
replay.record(Replay::KeyFrame{2.4f, glm::vec2 {0.3f, 0.0f}, true, Replay::collision});
replay.record(Replay::KeyFrame{2.5f, glm::vec2 {0.1f, 0.0f}, true});
replay.record(Replay::KeyFrame{2.6f, glm::vec2 {0.2f, 0.0f}, true, Replay::coin});
replay.record(Replay::KeyFrame{2.7f, glm::vec2 {0.3f, 0.0f}, true});
replay.record(Replay::KeyFrame{2.8f, glm::vec2 {0.4f, 0.0f}, true, Replay::collect});
replay.record(Replay::KeyFrame{2.8f, glm::vec2 {0.4f, 0.0f}, true, Replay::checkpoint});
replay.record(Replay::KeyFrame{3.5f, glm::vec2 {0.8f, 0.0f}, true});
replay.record(Replay::KeyFrame{3.5f, glm::vec2 {0.8f, 0.0f}, true, Replay::end});
/* Check coin and checkpoint states */
replay.reset();
unread = replay.unread(0.25f);
CHECK(unread.size() == 3);
CHECK(replay.coin_taken);
CHECK_FALSE(replay.coin_collected);
CHECK(replay.checkpoints == 0);
unread = replay.unread(0.35f);
CHECK(unread.size() == 1);
CHECK_FALSE(replay.coin_taken);
CHECK_FALSE(replay.coin_collected);
CHECK(replay.checkpoints == 0);
unread = replay.unread(0.75f);
CHECK(unread.size() == 5);
CHECK(replay.coin_taken);
CHECK(replay.coin_collected);
CHECK(replay.checkpoints == 1);
CHECK_FALSE(replay.ended());
/* Read the end event */
CHECK(replay.unread(1.401f).size() == 2);
CHECK(replay.ended());
/* Write the replay and check the contents */
CHECK(replay.mock_path().empty());
replay.save(1, {}, fs::temp_directory_path());
std::chrono::time_point<std::chrono::system_clock> start_time {
std::chrono::system_clock::now() - std::chrono::seconds(static_cast<int>(replay.length()))
};
std::string start_timestamp { sb::time::minute_stamp(start_time) };
CHECK(fs::exists(replay.mock_path()));
std::ostringstream contents;
contents << "{\"_id\":\"" << replay.id() << "\",\"key frames\":[[209999,0,0],[220000,1000,0,1]," <<
"[230000,2000,0,1,3],[240000,3000,0,1,1],[250000,1000,0,1],[259999,2000,0,1,3],[270000,3000,0,1]," <<
"[280000,4000,0,1,5],[280000,4000,0,1,4],[350000,8000,0,1],[350000,8000,0,1,6]],\"metadata\":" <<
"{\"length\":\"1.4\",\"level\":1},\"start\":\"" << start_timestamp << "\"}\n";
CHECK(sb::file_to_string(replay.mock_path()) == contents.str());
fs::path path = replay.mock_path();
replay.remove();
CHECK_FALSE(fs::exists(path));
/* Clear data from a replay object */
replay.clear();
CHECK(replay.empty());
CHECK(replay.unread(1.0f).empty());
CHECK(replay.length() == 0.0f);
/* Check roundtrip of metadata */
replay.record(Replay::KeyFrame{3.14159f, glm::vec2 {2.71828f, 1.61803f}});
nlohmann::json metadata = nlohmann::json({{"Commander", "Waluigi"},{"Time Zone", "Andromeda"}});
replay.save(999'999, metadata, fs::temp_directory_path());
Replay roundtrip;
roundtrip.load(replay.mock_path());
metadata["level"] = 999'999;
metadata["length"] = "0";
CHECK(roundtrip.metadata() == metadata);
/* Mock three replays */
std::shared_ptr<fs::path> base_replay_path { sb::test::temp_path("cakefoot_base_replay.json") };
std::ofstream(*base_replay_path) << contents.str();
std::shared_ptr<fs::path> faster_replay_path { sb::test::temp_path("cakefoot_faster_replay.json") };
std::ofstream(*faster_replay_path) << "{\"key frames\":[[209999,0,0],[220000,1000,0,1],[230000,2000,0,1,3]," <<
"[240000,3000,0,1,1],[250000,1000,0,1],[259999,2000,0,1,3],[270000,3000,0,1]]}";
std::shared_ptr<fs::path> slower_replay_path { sb::test::temp_path("cakefoot_slower_replay.json") };
std::ofstream(*slower_replay_path) << "{\"key frames\":[[209999,0,0],[220000,1000,0,1],[230000,2000,0,1,3]," <<
"[240000,3000,0,1,1],[250000,1000,0,1],[259999,2000,0,1,3],[270000,3000,0,1],[290000,4000,0,1,5]]}";
Replay base_replay;
base_replay.load(*base_replay_path);
Replay faster_replay;
faster_replay.load(*faster_replay_path);
Replay slower_replay;
slower_replay.load(*slower_replay_path);
/* Test five different configurations for replays */
for (const nlohmann::json& params : nlohmann::json({
{ "default", 60.0f, 8, 1, "slowest" },
{ "oldest", 60.0f, 8, 1, "oldest" },
{ "impossible", 0.0f, 1, 1, "slowest" },
{ "level one", 60.0f, 1, 1, "slowest" },
{ "multiple", 60.0f, 8, 3, "slowest" } }))
{
/* Start with an empty replays directory */
fs::remove_all(*mock_replays_path);
/* Mock games with different replay configuration. */
MockCakefoot mock {
*mock_empty_progress_path,
*mock_empty_scores_path,
*mock_empty_preferences_path,
*mock_empty_stats_path,
{{ "replay",
{
{ "max length", params[1] },
{ "min level", params[2] },
{ "max files per level", params[3] },
{ "replace", params[4] }
} } }
};
for (int level : { 1, 10 })
{
mock.cakefoot.mock_store_replay_recording(base_replay, level);
}
if (params[0] == "impossible")
{
CHECK_FALSE(fs::exists(*mock_replays_path));
}
else
{
CHECK(mock.cakefoot.mock_replays(1).size() == (params[0] == "level one"));
CHECK(mock.cakefoot.mock_replays(10).size() == 1);
}
for (int level : { 1, 10 })
{
mock.cakefoot.mock_store_replay_recording(faster_replay, level);
}
if (params[0] == "impossible")
{
CHECK_FALSE(fs::exists(*mock_replays_path));
}
else if (params[0] == "multiple")
{
CHECK(mock.cakefoot.mock_replays(10).size() == 2);
}
else
{
CHECK(mock.cakefoot.mock_replays(1).size() == (params[0] == "level one"));
CHECK(mock.cakefoot.mock_replays(10).size() == 1);
CHECK(mock.cakefoot.mock_replays(10)[0].id() == faster_replay.id());
}
for (int level : { 1, 10 })
{
mock.cakefoot.mock_store_replay_recording(slower_replay, level);
}
if (params[0] == "impossible")
{
CHECK_FALSE(fs::exists(*mock_replays_path));
}
else if (params[0] == "multiple")
{
CHECK(mock.cakefoot.mock_replays(10).size() == 3);
}
else if (params[0] == "oldest")
{
CHECK(mock.cakefoot.mock_replays(10).size() == 1);
CHECK(mock.cakefoot.mock_replays(10)[0].id() == slower_replay.id());
}
else
{
CHECK(mock.cakefoot.mock_replays(1).size() == (params[0] == "level one"));
CHECK(mock.cakefoot.mock_replays(10).size() == 1);
CHECK(mock.cakefoot.mock_replays(10)[0].id() == faster_replay.id());
}
}
}
/* TODO: Test reading, sorting, and adding scores */
/* TODO: Test missing sound device */