From ae3b0deb0431cc8fa93bade0731ebc68e5f74afc Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Mon, 23 Mar 2026 17:48:59 +0100 Subject: [PATCH] feat: Add app exit handler and corresponding tests for audio shutdown Signed-off-by: Hans Kokx --- apps/wolf_3d_gui/lib/screens/game_screen.dart | 106 ++++++++++++++---- .../test/game_screen_quit_test.dart | 99 ++++++++++++++++ 2 files changed, 182 insertions(+), 23 deletions(-) create mode 100644 apps/wolf_3d_gui/test/game_screen_quit_test.dart diff --git a/apps/wolf_3d_gui/lib/screens/game_screen.dart b/apps/wolf_3d_gui/lib/screens/game_screen.dart index 4896fbe..1835335 100644 --- a/apps/wolf_3d_gui/lib/screens/game_screen.dart +++ b/apps/wolf_3d_gui/lib/screens/game_screen.dart @@ -125,11 +125,23 @@ class GameScreen extends StatefulWidget { /// Declarative host shortcut registry used when [hostShortcutHandler] is null. final HostShortcutRegistry hostShortcutRegistry; + /// Optional host app-exit hook. + /// + /// Defaults to [SystemNavigator.pop] in production, but tests can inject a + /// fake callback to assert quit behavior. + final Future Function()? appExitHandler; + + /// Skips engine bootstrap for lightweight widget tests. + @visibleForTesting + final bool skipEngineBootstrapForTest; + /// Creates a gameplay screen driven by [wolf3d]. const GameScreen({ required this.wolf3d, this.hostShortcutHandler, this.hostShortcutRegistry = HostShortcutRegistry.defaults, + this.appExitHandler, + this.skipEngineBootstrapForTest = false, super.key, }); @@ -138,11 +150,12 @@ class GameScreen extends StatefulWidget { } class _GameScreenState extends State { - late final WolfEngine _engine; + WolfEngine? _engine; final DefaultRendererSettingsPersistence _persistence = DefaultRendererSettingsPersistence(); final DefaultSaveGamePersistence _savePersistence = DefaultSaveGamePersistence(); + Future? _quitFuture; /// Mirrors [WolfRendererSettings.mode] into the Flutter renderer enum. RendererMode _rendererMode = RendererMode.hardware; @@ -150,12 +163,17 @@ class _GameScreenState extends State { @override void initState() { super.initState(); + if (widget.skipEngineBootstrapForTest) { + return; + } + const Set supportedModes = { WolfRendererMode.hardware, WolfRendererMode.software, WolfRendererMode.ascii, }; - _engine = widget.wolf3d.launchEngine( + + final engine = widget.wolf3d.launchEngine( rendererCapabilities: const WolfRendererCapabilities( supportedModes: supportedModes, supportsAsciiThemes: true, @@ -175,7 +193,7 @@ class _GameScreenState extends State { } }, onGameWon: () { - _engine.difficulty = null; + _engine?.difficulty = null; widget.wolf3d.clearActiveDifficulty(); Navigator.of(context).pop(); }, @@ -184,14 +202,21 @@ class _GameScreenState extends State { }, saveGamePersistence: _savePersistence, ); - _syncRendererModeFrom(_engine.rendererSettings); + + _engine = engine; + _syncRendererModeFrom(engine.rendererSettings); _loadPersistedSettings(); } Future _loadPersistedSettings() async { + final engine = _engine; + if (engine == null) { + return; + } + final WolfRendererSettings? saved = await _persistence.load(); if (saved != null && mounted) { - _engine.updateRendererSettings(saved); + engine.updateRendererSettings(saved); } } @@ -217,21 +242,45 @@ class _GameScreenState extends State { } Future _quitApplication() async { - await widget.wolf3d.shutdownAudio(); - await SystemNavigator.pop(); + final existing = _quitFuture; + if (existing != null) { + await existing; + return; + } + + final quit = () async { + await widget.wolf3d.shutdownAudio(); + final handler = widget.appExitHandler; + if (handler != null) { + await handler(); + } else { + await SystemNavigator.pop(); + } + }(); + + _quitFuture = quit; + await quit; } + @visibleForTesting + Future debugQuitForTest() => _quitApplication(); + @override Widget build(BuildContext context) { + final engine = _engine; + if (engine == null) { + return const Scaffold(body: SizedBox.shrink()); + } + return PopScope( - canPop: _engine.difficulty != null, + canPop: engine.difficulty != null, onPopInvokedWithResult: (didPop, _) { - if (!didPop && _engine.difficulty == null) { + if (!didPop && engine.difficulty == null) { widget.wolf3d.input.queueBackAction(); } }, child: Scaffold( - floatingActionButton: kDebugMode && _engine.difficulty != null + floatingActionButton: kDebugMode && engine.difficulty != null ? FloatingActionButton( onPressed: _openDebugTools, tooltip: 'Open Debug Tools', @@ -251,7 +300,7 @@ class _GameScreenState extends State { children: [ _buildRenderer(), - if (!_engine.isInitialized) + if (!engine.isInitialized) Container( color: Colors.black, child: const Center( @@ -261,7 +310,7 @@ class _GameScreenState extends State { CircularProgressIndicator(color: Colors.teal), SizedBox(height: 20), Text( - "GET PSYCHED!", + 'GET PSYCHED!', style: TextStyle( color: Colors.teal, fontFamily: 'monospace', @@ -274,7 +323,7 @@ class _GameScreenState extends State { // A second full-screen overlay keeps the presentation simple while // the engine is still warming up or decoding the first frame. - if (!_engine.isInitialized) + if (!engine.isInitialized) Container( color: Colors.black, child: const Center( @@ -291,13 +340,18 @@ class _GameScreenState extends State { } Widget _buildRenderer() { + final engine = _engine; + if (engine == null) { + return const SizedBox.shrink(); + } + // Keep all renderers behind the same engine so mode switching does not // reset level state or audio playback. - final WolfRendererSettings settings = _engine.rendererSettings; + final WolfRendererSettings settings = engine.rendererSettings; switch (_rendererMode) { case RendererMode.software: return WolfFlutterRenderer( - engine: _engine, + engine: engine, onKeyEvent: _handleRendererKeyEvent, ); case RendererMode.ascii: @@ -306,13 +360,13 @@ class _GameScreenState extends State { ? AsciiThemes.quadrant : AsciiThemes.blocks; return WolfAsciiRenderer( - engine: _engine, + engine: engine, theme: theme, onKeyEvent: _handleRendererKeyEvent, ); case RendererMode.hardware: return WolfGlslRenderer( - engine: _engine, + engine: engine, effectsEnabled: settings.hardwareEffectsEnabled, bloomEnabled: settings.bloomEnabled, onKeyEvent: _handleRendererKeyEvent, @@ -333,20 +387,20 @@ class _GameScreenState extends State { } if (event.logicalKey == widget.wolf3d.input.rendererToggleKey) { - _engine.cycleRendererMode(); + _engine?.cycleRendererMode(); return; } if (event.logicalKey == widget.wolf3d.input.fpsToggleKey) { - setState(() => _engine.toggleFpsCounter()); + setState(() => _engine?.toggleFpsCounter()); return; } if (event.logicalKey == widget.wolf3d.input.asciiThemeCycleKey) { if (_rendererMode == RendererMode.ascii) { - _engine.cycleAsciiTheme(); + _engine?.cycleAsciiTheme(); } else if (_rendererMode == RendererMode.hardware) { - _engine.toggleHardwareEffects(); + _engine?.toggleHardwareEffects(); } } } @@ -355,8 +409,14 @@ class _GameScreenState extends State { if (!mounted || _rendererMode != RendererMode.hardware) { return; } - _engine.updateRendererSettings( - _engine.rendererSettings.copyWith(mode: WolfRendererMode.software), + + final engine = _engine; + if (engine == null) { + return; + } + + engine.updateRendererSettings( + engine.rendererSettings.copyWith(mode: WolfRendererMode.software), ); } diff --git a/apps/wolf_3d_gui/test/game_screen_quit_test.dart b/apps/wolf_3d_gui/test/game_screen_quit_test.dart new file mode 100644 index 0000000..24ce1f3 --- /dev/null +++ b/apps/wolf_3d_gui/test/game_screen_quit_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; +import 'package:wolf_3d_dart/wolf_3d_engine.dart'; +import 'package:wolf_3d_flutter/wolf_3d_flutter.dart'; +import 'package:wolf_3d_gui/screens/game_screen.dart'; + +class _CountingAudio implements EngineAudio { + @override + WolfensteinData? activeGame; + + int stopAllAudioCallCount = 0; + int disposeCallCount = 0; + + @override + Future debugSoundTest() async {} + + @override + Future init() async {} + + @override + void playLevelMusic(Music music) {} + + @override + void playMenuMusic() {} + + @override + void playSoundEffect(SoundEffect effect) {} + + @override + void playSoundEffectId(int sfxId) {} + + @override + Future stopAllAudio() async { + stopAllAudioCallCount++; + await Future.delayed(const Duration(milliseconds: 1)); + } + + @override + void stopMusic() {} + + @override + void dispose() { + disposeCallCount++; + } +} + +void main() { + testWidgets('quit path shuts down audio once and exits once', (tester) async { + final audio = _CountingAudio(); + final wolf3d = Wolf3d(audioBackend: audio); + int appExitCallCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: GameScreen( + wolf3d: wolf3d, + skipEngineBootstrapForTest: true, + appExitHandler: () async { + appExitCallCount++; + }, + ), + ), + ); + + final dynamic state = tester.state(find.byType(GameScreen)); + + await Future.wait([ + state.debugQuitForTest() as Future, + state.debugQuitForTest() as Future, + state.debugQuitForTest() as Future, + ]); + + expect(audio.stopAllAudioCallCount, 1); + expect(audio.disposeCallCount, 1); + expect(appExitCallCount, 1); + }); + + testWidgets('dispose path shuts down audio', (tester) async { + final audio = _CountingAudio(); + final wolf3d = Wolf3d(audioBackend: audio); + + await tester.pumpWidget( + MaterialApp( + home: GameScreen( + wolf3d: wolf3d, + skipEngineBootstrapForTest: true, + appExitHandler: () async {}, + ), + ), + ); + + await tester.pumpWidget(const MaterialApp(home: SizedBox.shrink())); + await tester.pump(const Duration(milliseconds: 10)); + + expect(audio.stopAllAudioCallCount, 1); + expect(audio.disposeCallCount, 1); + }); +}