fix(menu): main-menu Quit now closes the GUI instead of revealing the game

App.run ended its menu loop on Quit and just returned. The console exits then,
but in the GUI run() is on a daemon worker while the Swing JFrame stays
displayable on the EDT, keeping the JVM alive — and the dismissed menu overlay
just revealed the running game underneath ("back into the game"). EXIT_ON_CLOSE
only fires for the native window X, not a programmatic quit.

Add a GameIO.shutdown() teardown hook (default no-op) and call it after the menu
loop ends. SwingIO overrides it to stop music, dispose the window, and exit the
JVM, mirroring EXIT_ON_CLOSE; console keeps the no-op. Extract an injectable
App.run overload and add AppQuitTest asserting the Quit path invokes shutdown().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 09:39:05 +02:00
parent 1cbf606bd4
commit d0966c3ce4
4 changed files with 83 additions and 2 deletions

View File

@@ -64,8 +64,11 @@ public final class App {
/** Runs the menu shell on the given IO until the player quits. */ /** Runs the menu shell on the given IO until the player quits. */
public static void run(GameIO io) { public static void run(GameIO io) {
SaveService saves = new SaveService(); run(io, new SaveService(), new SettingsStore());
SettingsStore settingsStore = new SettingsStore(); }
/** Runs the menu shell with injectable persistence (for testing). */
static void run(GameIO io, SaveService saves, SettingsStore settingsStore) {
Settings settings = settingsStore.load(); Settings settings = settingsStore.load();
SettingsMenu.apply(settings, io); SettingsMenu.apply(settings, io);
@@ -88,6 +91,7 @@ public final class App {
} }
} }
io.write("Farewell."); io.write("Farewell.");
io.shutdown(); // GUI: close the window and exit; console: no-op
} }
/** Loads a fresh world and binds it to a newly named session. */ /** Loads a fresh world and binds it to a newly named session. */

View File

@@ -113,4 +113,14 @@ public interface GameIO {
write("Please enter a number between 1 and " + options.size() + "."); write("Please enter a number between 1 and " + options.size() + ".");
} }
} }
/**
* Tears the frontend down after the main-menu loop ends (the player chose
* Quit). Console mode has nothing to release and exits naturally when
* {@code main} returns; the GUI overrides this to close its window and
* terminate the JVM, which would otherwise stay alive on the Swing EDT.
*/
default void shutdown() {
// no-op in text mode
}
} }

View File

@@ -342,6 +342,21 @@ public class SwingIO implements GameIO {
music.level(level); music.level(level);
} }
/**
* Closes the GUI when the player quits from the main menu. Stops music,
* disposes the window, and terminates the JVM — mirroring the native
* window-close (EXIT_ON_CLOSE); without this the EDT keeps the process
* alive and the dismissed menu overlay just reveals the game underneath.
*/
@Override
public void shutdown() {
music.shutdown();
SwingUtilities.invokeLater(() -> {
frame.dispose();
System.exit(0);
});
}
@Override @Override
public void setMap(MapView view) { public void setMap(MapView view) {
map.show(view); map.show(view);

View File

@@ -0,0 +1,52 @@
package thb.jeanluc.adventure;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import thb.jeanluc.adventure.io.TestIO;
import thb.jeanluc.adventure.save.SaveService;
import thb.jeanluc.adventure.save.SettingsStore;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Regression test for the main-menu Quit: choosing Quit must end the menu loop
* AND tell the frontend to tear down. Previously {@code run} just returned,
* which exits the console but left the Swing window open (the menu overlay
* closed and the game reappeared underneath). We assert the frontend-agnostic
* contract: the Quit path invokes {@link thb.jeanluc.adventure.io.GameIO#shutdown()}.
*/
class AppQuitTest {
/** Fake IO that always picks the last menu option (Quit) and records shutdown. */
private static final class QuitIO extends TestIO {
boolean shutdownCalled;
int chooseCalls;
@Override
public int choose(String title, List<String> options) {
chooseCalls++;
return options.size() - 1; // Quit is the last main-menu option
}
@Override
public void shutdown() {
shutdownCalled = true;
}
}
@Test
void choosingQuit_tearsDownTheFrontend(@TempDir Path tmp) {
QuitIO io = new QuitIO();
SaveService saves = new SaveService(tmp.resolve("saves"));
SettingsStore settings = new SettingsStore(tmp.resolve("settings.json"));
App.run(io, saves, settings);
assertThat(io.shutdownCalled).isTrue();
assertThat(io.chooseCalls).isEqualTo(1); // straight to Quit, no game started
assertThat(io.allOutput()).contains("Farewell.");
}
}