mirror of
https://github.com/SpeedBreakerProject/speedbreaker.git
synced 2026-10-17 23:01:20 +02:00
Co-authored-by: love-gamer <340996855+love-gamer@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
380 lines
18 KiB
C++
380 lines
18 KiB
C++
// Tests for Settings > Quit Game (runtime/ui/quit_game.h): where it is
|
|
// offered, and its confirmation. From the repo root:
|
|
// clang++ -std=c++20 -O2 -Iruntime tests/quit_game_test.cpp -o build/quit_game_test
|
|
// build/quit_game_test runtime
|
|
// With the runtime folder it also reads the code around it: the button and
|
|
// its prompt compiled only where SB_QUIT_GAME is on (not iPhone or iPad), the
|
|
// prompt's text, Quit before Cancel with the focus on Cancel, B and Escape
|
|
// cancelling, the menu closing cancelling, the quit sent as SDL_EVENT_QUIT
|
|
// (no exit of its own), the presenter's RunFrame ending on that event, and
|
|
// main()'s one way out after it (settings saved, renderer and window shut
|
|
// down, exit code 0).
|
|
#include <ui/quit_game.h>
|
|
|
|
#include <cstdio>
|
|
#include <fstream>
|
|
#include <sstream>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
static int g_failures = 0;
|
|
#define CHECK(cond, ...) do { if (!(cond)) { g_failures++; printf(" FAIL %s:%d: %s: ", __FILE__, __LINE__, #cond); printf(__VA_ARGS__); printf("\n"); } } while (0)
|
|
|
|
namespace
|
|
{
|
|
using ui::quit::Button;
|
|
using ui::quit::Outcome;
|
|
using ui::quit::Prompt;
|
|
using ui::quit::System;
|
|
|
|
void Platforms()
|
|
{
|
|
CHECK(ui::quit::OfferedOn(System::Mac), "macOS offers Quit Game");
|
|
CHECK(ui::quit::OfferedOn(System::Linux), "Linux and SteamOS (x86-64, the Frame's arm64) offer it");
|
|
CHECK(ui::quit::OfferedOn(System::Windows), "Windows offers it");
|
|
CHECK(ui::quit::OfferedOn(System::Other), "another desktop system offers it");
|
|
CHECK(!ui::quit::OfferedOn(System::IPhoneIPad), "iPhone and iPad don't: the player closes apps there");
|
|
// The system this test is built for (a Mac, Linux or Windows: the
|
|
// ones the tests run on) is the one the header names.
|
|
#if defined(__APPLE__) && TARGET_OS_IPHONE
|
|
CHECK(ui::quit::kSystem == System::IPhoneIPad && !ui::quit::kOffered && SB_QUIT_GAME == 0, "an iOS build: no Quit Game");
|
|
#elif defined(__APPLE__)
|
|
CHECK(ui::quit::kSystem == System::Mac, "built for macOS: System::Mac");
|
|
#elif defined(_WIN32)
|
|
CHECK(ui::quit::kSystem == System::Windows, "built for Windows: System::Windows");
|
|
#elif defined(__linux__)
|
|
CHECK(ui::quit::kSystem == System::Linux, "built for Linux: System::Linux");
|
|
#endif
|
|
#if !(defined(__APPLE__) && TARGET_OS_IPHONE)
|
|
CHECK(ui::quit::kOffered && SB_QUIT_GAME == 1, "offered on this desktop system");
|
|
#endif
|
|
CHECK(ui::quit::kOffered == ui::quit::OfferedOn(ui::quit::kSystem), "the macro and the table agree");
|
|
}
|
|
|
|
void Steps()
|
|
{
|
|
// Nothing open: the prompt's buttons and B do nothing.
|
|
{
|
|
Prompt p;
|
|
CHECK(!p.Asking() && !p.Quitting(), "starts closed");
|
|
CHECK(p.Press(Button::Quit) == Outcome::None, "Quit without the prompt: nothing");
|
|
CHECK(p.Press(Button::Cancel) == Outcome::None && p.Back() == Outcome::None && p.Dismiss() == Outcome::None,
|
|
"Cancel, B, the menu closing without the prompt: nothing");
|
|
CHECK(!p.TakeFocus(), "no focus to put anywhere");
|
|
}
|
|
// Quit Game asks, with the focus on Cancel, once.
|
|
{
|
|
Prompt p;
|
|
CHECK(p.Choose() == Outcome::Asking && p.Asking(), "Quit Game opens the prompt");
|
|
CHECK(Prompt::kDefaultFocus == Button::Cancel, "the focus starts on Cancel");
|
|
p.Shown();
|
|
CHECK(p.TakeFocus(), "the first frame puts the focus on Cancel");
|
|
CHECK(!p.TakeFocus(), "...and only the first: the player's moves stay theirs");
|
|
CHECK(p.Choose() == Outcome::None, "Quit Game again while asking: nothing new");
|
|
}
|
|
// Quit takes a press only once the prompt has been up a frame.
|
|
{
|
|
Prompt p;
|
|
p.Choose();
|
|
p.Shown();
|
|
CHECK(p.Press(Button::Quit) == Outcome::None && p.Asking(), "Quit on the prompt's first frame: ignored, still asking");
|
|
p.Shown();
|
|
CHECK(p.Press(Button::Quit) == Outcome::Quit && p.Quitting(), "Quit a frame later: quits");
|
|
}
|
|
// Cancel, B / Escape, the menu closing: no quit, and asking again
|
|
// starts over (the focus on Cancel again).
|
|
for (int how = 0; how < 3; how++)
|
|
{
|
|
Prompt p;
|
|
p.Choose();
|
|
p.Shown();
|
|
p.TakeFocus();
|
|
p.Shown();
|
|
Outcome o = how == 0 ? p.Press(Button::Cancel) : how == 1 ? p.Back() : p.Dismiss();
|
|
CHECK(o == Outcome::Cancelled && !p.Asking() && !p.Quitting(), "cancel path %d: cancelled", how);
|
|
CHECK(p.Press(Button::Quit) == Outcome::None, "cancel path %d: Quit after it does nothing", how);
|
|
CHECK(p.Choose() == Outcome::Asking, "cancel path %d: Quit Game asks again", how);
|
|
p.Shown();
|
|
CHECK(p.TakeFocus(), "cancel path %d: the focus back on Cancel", how);
|
|
CHECK(p.Press(Button::Quit) == Outcome::None, "cancel path %d: the new prompt's first frame ignores Quit too", how);
|
|
}
|
|
// Cancelled on its first frame (B at once): fine, no focus left pending.
|
|
{
|
|
Prompt p;
|
|
p.Choose();
|
|
CHECK(p.Back() == Outcome::Cancelled && !p.TakeFocus(), "B before the first frame: cancelled, no focus to take");
|
|
}
|
|
// Quitting is final: one SDL_EVENT_QUIT, whatever comes after.
|
|
{
|
|
Prompt p;
|
|
p.Choose();
|
|
p.Shown();
|
|
p.Shown();
|
|
CHECK(p.Press(Button::Quit) == Outcome::Quit, "quits");
|
|
CHECK(p.Press(Button::Quit) == Outcome::None && p.Press(Button::Cancel) == Outcome::None, "no second quit, no cancel");
|
|
CHECK(p.Back() == Outcome::None && p.Dismiss() == Outcome::None, "B or the menu closing (main() shutting the UI down) don't cancel it");
|
|
CHECK(p.Choose() == Outcome::None && p.Quitting(), "Quit Game can't ask again while quitting");
|
|
// SDL refused the event: start over.
|
|
p.QuitFailed();
|
|
CHECK(!p.Quitting() && !p.Asking() && p.Choose() == Outcome::Asking, "a quit that couldn't be sent: Quit Game works again");
|
|
}
|
|
}
|
|
|
|
// The player, as ImGui gives the prompt their input: the footer's focus
|
|
// on Quit Game (the hardest case: one press from the prompt), one input
|
|
// a frame. A presses the focused button, B (or Escape) goes back, Left
|
|
// and Right move between Quit (left) and Cancel (right).
|
|
enum Input { kA, kB, kLeft, kRight, kInputs };
|
|
const char* const kInputNames[] = { "A", "B", "Left", "Right" };
|
|
|
|
// How many of the inputs it took to quit (0: they didn't).
|
|
size_t QuitAfter(const std::vector<int>& inputs)
|
|
{
|
|
Prompt p;
|
|
Button focus = Button::Cancel;
|
|
bool menuOpen = true;
|
|
for (size_t i = 0; i < inputs.size(); i++)
|
|
{
|
|
const int input = inputs[i];
|
|
if (!menuOpen)
|
|
return 0;
|
|
if (!p.Asking())
|
|
{
|
|
// The footer: A presses Quit Game (the prompt draws in the
|
|
// same frame); B closes the menu; Left and Right would move
|
|
// off Quit Game (kept on it here: never easier to quit).
|
|
if (input == kA && p.Choose() == Outcome::Asking)
|
|
{
|
|
p.Shown();
|
|
if (p.TakeFocus())
|
|
focus = Prompt::kDefaultFocus;
|
|
}
|
|
else if (input == kB)
|
|
menuOpen = false;
|
|
continue;
|
|
}
|
|
p.Shown();
|
|
Outcome o = Outcome::None;
|
|
if (input == kA)
|
|
o = p.Press(focus);
|
|
else if (input == kB)
|
|
o = p.Back();
|
|
else
|
|
focus = input == kLeft ? Button::Quit : Button::Cancel;
|
|
if (o == Outcome::Quit)
|
|
return i + 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
bool Quits(const std::vector<int>& inputs)
|
|
{
|
|
return QuitAfter(inputs) > 0;
|
|
}
|
|
|
|
void NoAccidentalQuit()
|
|
{
|
|
CHECK(Quits({ kA, kLeft, kA }), "A, Left, A quits (Quit Game, to Quit, press)");
|
|
CHECK(!Quits({ kA, kA }), "A, A: the second press is Cancel's");
|
|
CHECK(!Quits({ kA, kB }) && !Quits({ kA, kLeft, kB }), "B cancels, from either button");
|
|
CHECK(!Quits({ kA, kLeft, kRight, kA }), "back to Cancel, then A: cancelled");
|
|
// Every input sequence up to 7 long.
|
|
int sequences = 0, quitting = 0;
|
|
for (int length = 1; length <= 7; length++)
|
|
{
|
|
int total = 1;
|
|
for (int i = 0; i < length; i++)
|
|
total *= kInputs;
|
|
for (int code = 0; code < total; code++)
|
|
{
|
|
std::vector<int> seq;
|
|
for (int i = 0, c = code; i < length; i++, c /= kInputs)
|
|
seq.push_back(c % kInputs);
|
|
const size_t after = QuitAfter(seq);
|
|
sequences++;
|
|
quitting += after > 0;
|
|
if (!after)
|
|
continue;
|
|
seq.resize(after); // the inputs up to the quit
|
|
std::string how;
|
|
for (int input : seq)
|
|
how += std::string(kInputNames[input]) + " ";
|
|
bool oneButton = true;
|
|
for (int input : seq)
|
|
oneButton &= input == seq[0];
|
|
CHECK(!oneButton, "one button pressed over and over quit: %s", how.c_str());
|
|
CHECK(seq.size() >= 3, "fewer than 3 presses quit: %s", how.c_str());
|
|
// A quitting sequence ends with A, pressed with the focus moved
|
|
// to Quit by a Left after the prompt opened.
|
|
CHECK(seq.back() == kA, "a quit not on A: %s", how.c_str());
|
|
size_t open = 0;
|
|
while (open < seq.size() && seq[open] != kA)
|
|
open++;
|
|
bool left = false;
|
|
for (size_t i = open + 1; i + 1 < seq.size(); i++)
|
|
left |= seq[i] == kLeft;
|
|
CHECK(left, "a quit without a Left in the prompt: %s", how.c_str());
|
|
}
|
|
}
|
|
CHECK(quitting > 0 && quitting < sequences, "some sequences quit (%d of %d)", quitting, sequences);
|
|
}
|
|
|
|
// ---- The code around it (with the runtime folder's path) ----
|
|
|
|
std::vector<std::string> Lines(const std::string& path)
|
|
{
|
|
std::ifstream in(path);
|
|
std::vector<std::string> lines;
|
|
for (std::string line; std::getline(in, line);)
|
|
lines.push_back(line);
|
|
return lines;
|
|
}
|
|
|
|
std::string Trim(const std::string& s)
|
|
{
|
|
size_t a = s.find_first_not_of(" \t");
|
|
return a == std::string::npos ? std::string() : s.substr(a);
|
|
}
|
|
|
|
bool Has(const std::string& line, const char* text)
|
|
{
|
|
return line.find(text) != std::string::npos;
|
|
}
|
|
|
|
// For each line: inside the true branch of an `#if SB_QUIT_GAME`.
|
|
std::vector<bool> QuitGated(const std::vector<std::string>& lines)
|
|
{
|
|
struct Level { bool quit; bool inElse; };
|
|
std::vector<Level> stack;
|
|
std::vector<bool> gated;
|
|
for (const std::string& raw : lines)
|
|
{
|
|
std::string line = Trim(raw);
|
|
if (line.rfind("#if", 0) == 0)
|
|
stack.push_back({ Trim(line.substr(line.find_first_of(" \t") == std::string::npos ? line.size() : line.find_first_of(" \t"))) == "SB_QUIT_GAME", false });
|
|
else if (line.rfind("#elif", 0) == 0 && !stack.empty())
|
|
stack.back() = { false, true };
|
|
else if (line.rfind("#else", 0) == 0 && !stack.empty())
|
|
stack.back().inElse = true;
|
|
else if (line.rfind("#endif", 0) == 0 && !stack.empty())
|
|
stack.pop_back();
|
|
bool in = false;
|
|
for (const Level& l : stack)
|
|
in |= l.quit && !l.inElse;
|
|
gated.push_back(in);
|
|
}
|
|
return gated;
|
|
}
|
|
|
|
void SettingsMenu(const std::string& runtime)
|
|
{
|
|
const std::vector<std::string> lines = Lines(runtime + "/ui/settings_menu.cpp");
|
|
CHECK(lines.size() > 100, "read ui/settings_menu.cpp (%zu lines)", lines.size());
|
|
const std::vector<bool> gated = QuitGated(lines);
|
|
int button = -1, text = -1, quitButton = -1, cancelButton = -1, focus = -1, defaultFocus = -1, push = -1, back = -1, close = -1;
|
|
for (size_t i = 0; i < lines.size(); i++)
|
|
{
|
|
const std::string& l = lines[i];
|
|
// Everything of Quit Game's is compiled only where it's offered.
|
|
for (const char* name : { "\"Quit Game\"", "SDL_EVENT_QUIT", "SDL_PushEvent", "s_quit", "kQuitPopup", "Quit SpeedBreaker" })
|
|
if (Has(l, name))
|
|
CHECK(gated[i], "settings_menu.cpp:%zu: %s outside #if SB_QUIT_GAME: %s", i + 1, name, Trim(l).c_str());
|
|
// No way out of its own.
|
|
for (const char* name : { "exit(", "_Exit(", "abort(", "SDL_Quit(", "terminate(" })
|
|
CHECK(!Has(l, name), "settings_menu.cpp:%zu: %s: %s", i + 1, name, Trim(l).c_str());
|
|
if (Has(l, "ImGui::Button(\"Quit Game\")"))
|
|
button = int(i);
|
|
if (Has(l, "\"Quit SpeedBreaker? Progress since your last save will be lost.\""))
|
|
text = int(i);
|
|
if (Has(l, "ImGui::Button(\"Quit\")"))
|
|
quitButton = int(i);
|
|
if (Has(l, "ImGui::Button(\"Cancel\")") && quitButton >= 0 && cancelButton < 0 && int(i) > quitButton)
|
|
cancelButton = int(i);
|
|
if (Has(l, "s_quit.TakeFocus()"))
|
|
focus = int(i);
|
|
if (Has(l, "SetItemDefaultFocus") && cancelButton >= 0 && defaultFocus < 0)
|
|
defaultFocus = int(i);
|
|
if (Has(l, "SDL_PushEvent"))
|
|
push = int(i);
|
|
if (Has(l, "s_quit.Back()"))
|
|
back = int(i);
|
|
if (Has(l, "ImGui::Button(\"Close\")"))
|
|
close = int(i);
|
|
}
|
|
CHECK(button > 0, "the footer has a Quit Game button");
|
|
CHECK(close > 0 && button > close, "Quit Game comes after Close (line %d, Close %d)", button + 1, close + 1);
|
|
CHECK(button > 0 && Has(lines[button], "s_quit.Choose()"), "Quit Game only asks (s_quit.Choose())");
|
|
CHECK(text > 0, "the prompt says \"Quit SpeedBreaker? Progress since your last save will be lost.\"");
|
|
CHECK(quitButton > text && cancelButton > quitButton, "Quit, then Cancel to its right (lines %d, %d)", quitButton + 1, cancelButton + 1);
|
|
CHECK(focus > quitButton && focus < cancelButton && Has(lines[focus + 1], "SetKeyboardFocusHere"),
|
|
"the first frame's focus goes to the button after it: Cancel (line %d)", focus + 1);
|
|
CHECK(defaultFocus == cancelButton + 1, "Cancel is the prompt's default focus (line %d)", defaultFocus + 1);
|
|
CHECK(back > 0 && Has(lines[back - 1] + lines[back - 2], "ImGuiKey_GamepadFaceRight") && Has(lines[back - 1] + lines[back - 2], "ImGuiKey_Escape"),
|
|
"B and Escape cancel the prompt (line %d)", back + 1);
|
|
CHECK(push > 0 && (Has(lines[push - 1], "SDL_EVENT_QUIT") || Has(lines[push - 2], "SDL_EVENT_QUIT") || Has(lines[push - 3], "SDL_EVENT_QUIT")),
|
|
"the quit is an SDL_EVENT_QUIT pushed for the main loop (line %d)", push + 1);
|
|
// The menu closing under it cancels (ui.cpp's CloseSettings).
|
|
bool forget = false;
|
|
for (const std::string& l : Lines(runtime + "/ui/ui.cpp"))
|
|
forget |= Has(l, "ForgetQuitPrompt();");
|
|
CHECK(forget, "ui.cpp's CloseSettings forgets the prompt");
|
|
// The startup error page's Quit follows the same rule.
|
|
bool startup = false;
|
|
for (const std::string& l : Lines(runtime + "/ui/startup_error.cpp"))
|
|
startup |= Has(l, "kCanQuit = quit::kOffered");
|
|
CHECK(startup, "startup_error.cpp's Quit uses quit::kOffered");
|
|
}
|
|
|
|
// The way out: RunFrame ends on SDL_EVENT_QUIT, and main() then shuts
|
|
// down as for a window close.
|
|
void WayOut(const std::string& runtime)
|
|
{
|
|
const std::vector<std::string> presenter = Lines(runtime + "/video/presenter.cpp");
|
|
bool ends = false;
|
|
for (size_t i = 0; i + 1 < presenter.size(); i++)
|
|
if (Has(presenter[i], "e.type == SDL_EVENT_QUIT") && Has(presenter[i + 1], "return false;"))
|
|
ends = true;
|
|
CHECK(ends, "presenter.cpp's RunFrame returns false on SDL_EVENT_QUIT");
|
|
|
|
const std::vector<std::string> main = Lines(runtime + "/main.cpp");
|
|
int loop = -1;
|
|
for (size_t i = 0; i < main.size(); i++)
|
|
if (Has(main[i], "while (video::RunFrame())"))
|
|
loop = int(i);
|
|
CHECK(loop > 0, "main.cpp's game loop runs video::RunFrame");
|
|
bool save = false, renderer = false, video = false, flush = false, exit0 = false, otherExit = false;
|
|
for (size_t i = loop + 1; loop > 0 && i < main.size() && Trim(main[i]) != "}"; i++)
|
|
{
|
|
save |= Has(main[i], "settings::Save()");
|
|
renderer |= Has(main[i], "gpu::renderer::Shutdown()");
|
|
video |= Has(main[i], "video::Shutdown()");
|
|
flush |= Has(main[i], "report::FlushLog()");
|
|
exit0 |= Has(main[i], "std::_Exit(0)");
|
|
otherExit |= (Has(main[i], "_Exit(") || Has(main[i], "exit(")) && !Has(main[i], "std::_Exit(0)");
|
|
}
|
|
CHECK(save && renderer && video && flush, "after the loop: settings saved, renderer and video shut down, log flushed");
|
|
CHECK(exit0 && !otherExit, "exit code 0");
|
|
}
|
|
}
|
|
|
|
int main(int argc, char** argv)
|
|
{
|
|
Platforms();
|
|
Steps();
|
|
NoAccidentalQuit();
|
|
if (argc > 1)
|
|
{
|
|
SettingsMenu(argv[1]);
|
|
WayOut(argv[1]);
|
|
}
|
|
else
|
|
printf(" (no runtime folder given: the code around it not checked)\n");
|
|
if (g_failures)
|
|
{
|
|
printf("quit_game_test: %d FAILED\n", g_failures);
|
|
return 1;
|
|
}
|
|
printf("quit_game_test: all passed\n");
|
|
return 0;
|
|
}
|