feat: Add tests for Wolf3dGuiApp and refactor game data picker management

- Introduced unit tests for the Wolf3dGuiApp to ensure proper directory configuration and audio management.
- Refactored the GameDataPickerManager to streamline directory and file selection processes.
- Removed obsolete GameDataPickerManager and Wolf3dAppManager classes to simplify the architecture.
- Enhanced the Wolf3dFlutterEngine to improve game version handling during save loading.
- Updated existing tests to reflect changes in the game data management structure.
- Removed unnecessary dependencies and cleaned up the codebase for better maintainability.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-24 14:35:20 +01:00
parent b980174905
commit ce4dd8d61d
18 changed files with 1919 additions and 810 deletions
@@ -1,144 +0,0 @@
library;
import 'dart:collection';
import 'package:file_selector/file_selector.dart';
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
/// Callback used to open a directory picker.
typedef PickDirectoryCallback =
Future<String?> Function({
String? confirmButtonText,
});
/// Callback used to open a multi-file picker.
typedef PickFilesCallback = Future<List<XFile>> Function();
/// Coordinates game-data selection flows for the no-data setup screen.
class GameDataPickerManager {
/// Creates a picker workflow manager bound to [engine].
///
/// [pickDirectory] and [pickFiles] are injectable test seams. Production
/// usage should rely on their defaults from `file_selector`.
GameDataPickerManager({
required this.engine,
PickDirectoryCallback? pickDirectory,
PickFilesCallback? pickFiles,
}) : _pickDirectory = pickDirectory ?? getDirectoryPath,
_pickFiles = pickFiles ?? openFiles;
/// Engine facade reloaded when users choose new data locations.
final Wolf3dFlutterEngine engine;
final PickDirectoryCallback _pickDirectory;
final PickFilesCallback _pickFiles;
bool _isLoadingGameData = false;
String? _pickerError;
/// Whether a picker/reload operation is currently in progress.
bool get isLoadingGameData => _isLoadingGameData;
/// Last picker/reload error, if any.
String? get pickerError => _pickerError;
/// Prompts the user for a game-data directory and reloads discovery.
///
/// Calls [notifyChanged] before and after the async workflow so UIs can
/// update loading/error state.
Future<void> pickGameDataDirectory({
required void Function() notifyChanged,
}) async {
_isLoadingGameData = true;
_pickerError = null;
notifyChanged();
try {
final String? directoryPath = await _pickDirectory(
confirmButtonText: 'Use this folder',
);
if (directoryPath == null || directoryPath.trim().isEmpty) {
return;
}
await engine.init(directory: directoryPath.trim());
} catch (error) {
_pickerError = 'Unable to load selected directory: $error';
} finally {
_isLoadingGameData = false;
notifyChanged();
}
}
/// Prompts the user for one or more game-data files and reloads discovery.
///
/// Selected file paths are collapsed into unique parent directories, then
/// passed to [Wolf3dFlutterEngine.init] as one primary directory plus
/// [Wolf3dFlutterEngine.init] `additionalDirectories`.
Future<void> pickGameDataFiles({
required void Function() notifyChanged,
}) async {
_isLoadingGameData = true;
_pickerError = null;
notifyChanged();
try {
final List<XFile> selectedFiles = await _pickFiles();
if (selectedFiles.isEmpty) {
return;
}
final LinkedHashSet<String> directories = LinkedHashSet<String>();
for (final XFile file in selectedFiles) {
final String directory = _directoryFromFilePath(file.path);
if (directory.isNotEmpty) {
directories.add(directory);
}
}
if (directories.isEmpty) {
_pickerError = 'Selected files do not expose local filesystem paths.';
return;
}
final List<String> orderedDirectories = directories.toList(
growable: false,
);
await engine.init(
directory: orderedDirectories.first,
additionalDirectories: orderedDirectories.skip(1),
);
} catch (error) {
_pickerError = 'Unable to load selected files: $error';
} finally {
_isLoadingGameData = false;
notifyChanged();
}
}
/// Returns the parent directory component from [path].
String _directoryFromFilePath(String path) {
final String trimmedPath = path.trim();
if (trimmedPath.isEmpty) {
return '';
}
final int slashIndex = trimmedPath.lastIndexOf('/');
final int backslashIndex = trimmedPath.lastIndexOf(r'\');
final int separatorIndex = slashIndex > backslashIndex
? slashIndex
: backslashIndex;
if (separatorIndex < 0) {
return '';
}
if (separatorIndex == 0) {
return trimmedPath[0];
}
return trimmedPath.substring(0, separatorIndex);
}
}
@@ -1,53 +0,0 @@
library;
import 'dart:async';
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
/// Coordinates app-shell setup actions and shutdown behavior.
class Wolf3dAppManager {
/// Creates an app-shell manager bound to [engine].
///
/// [pickerManager] can be provided in tests to control picker behavior.
Wolf3dAppManager({
required this.engine,
GameDataPickerManager? pickerManager,
}) : _pickerManager = pickerManager ?? GameDataPickerManager(engine: engine);
/// Engine facade managed by the app shell.
final Wolf3dFlutterEngine engine;
final GameDataPickerManager _pickerManager;
Future<void>? _shutdownFuture;
/// Forwarded picker loading flag for UI binding.
bool get isLoadingGameData => _pickerManager.isLoadingGameData;
/// Forwarded picker error text for UI binding.
String? get pickerError => _pickerManager.pickerError;
/// Shuts down engine audio exactly once and reuses in-flight shutdown calls.
Future<void> ensureAudioShutdown() {
final existing = _shutdownFuture;
if (existing != null) {
return existing;
}
final shutdown = engine.shutdownAudio();
_shutdownFuture = shutdown;
return shutdown;
}
/// Delegates directory picker + reload behavior.
Future<void> pickGameDataDirectory({
required void Function() notifyChanged,
}) async {
await _pickerManager.pickGameDataDirectory(notifyChanged: notifyChanged);
}
/// Delegates file picker + reload behavior.
Future<void> pickGameDataFiles({
required void Function() notifyChanged,
}) async {
await _pickerManager.pickGameDataFiles(notifyChanged: notifyChanged);
}
}
@@ -1,174 +0,0 @@
library;
import 'package:flutter/material.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_menu.dart';
/// Fallback screen shown when no Wolf3D game data files are discovered.
class NoGameDataScreen extends StatelessWidget {
const NoGameDataScreen({
super.key,
this.configuredDataDirectory,
this.onPickGameDataDirectory,
this.onPickGameDataFiles,
this.isLoadingGameData = false,
this.pickerError,
});
/// Previously configured external game-data directory, if any.
final String? configuredDataDirectory;
/// Invoked when the user requests selecting a game-data directory.
final Future<void> Function()? onPickGameDataDirectory;
/// Invoked when the user requests selecting one or more data files.
final Future<void> Function()? onPickGameDataFiles;
/// Whether the host is currently reloading after picker selection.
final bool isLoadingGameData;
/// Optional picker/reload error shown to the user.
final String? pickerError;
static Color _colorFromVgaIndex(int index) {
final int packed = ColorPalette.vga32Bit[index]; // 0xAABBGGRR
final int r = packed & 0xFF;
final int g = (packed >> 8) & 0xFF;
final int b = (packed >> 16) & 0xFF;
return Color((0xFF << 24) | (r << 16) | (g << 8) | b);
}
static final Color _backgroundColor = _colorFromVgaIndex(111);
static final Color _panelColor = _colorFromVgaIndex(103);
static final Color _borderColor = _colorFromVgaIndex(87);
static final Color _titleColor = _colorFromVgaIndex(
WolfMenuPalette.headerTextIndex,
);
static final Color _bodyColor = _colorFromVgaIndex(
WolfMenuPalette.unselectedTextIndex,
);
static final Color _emphasisColor = _colorFromVgaIndex(10);
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: _backgroundColor,
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 640),
child: DecoratedBox(
decoration: BoxDecoration(
color: _panelColor,
border: Border.all(color: _borderColor, width: 2),
),
child: Padding(
padding: EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'WOLF3D DATA NOT FOUND',
style: TextStyle(
color: _titleColor,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Text(
'No game files were discovered.\n\n'
'Select a game-data directory, or select one or more game-data files.',
style: TextStyle(
color: _bodyColor,
fontSize: 15,
height: 1.4,
),
),
const SizedBox(height: 12),
Text(
'Once all required files are present, the game starts automatically.',
style: TextStyle(
color: _emphasisColor,
fontSize: 14,
height: 1.35,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
ElevatedButton(
onPressed: isLoadingGameData
? null
: onPickGameDataDirectory,
child: Text(
isLoadingGameData
? 'Loading data...'
: 'Select data directory',
),
),
ElevatedButton(
onPressed: isLoadingGameData
? null
: onPickGameDataFiles,
child: Text(
isLoadingGameData
? 'Loading data...'
: 'Select data files',
),
),
],
),
if (isLoadingGameData)
Padding(
padding: const EdgeInsets.only(top: 10),
child: Text(
'Scanning selected locations...',
style: TextStyle(
color: _bodyColor,
fontSize: 13,
height: 1.3,
),
),
),
if (pickerError != null && pickerError!.trim().isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
pickerError!.trim(),
style: TextStyle(
color: _emphasisColor,
fontSize: 13,
height: 1.3,
fontWeight: FontWeight.w600,
),
),
),
if (configuredDataDirectory != null &&
configuredDataDirectory!.trim().isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
'Configured data directory: ${configuredDataDirectory!.trim()}',
style: TextStyle(
color: _bodyColor,
fontSize: 13,
height: 1.3,
),
),
),
],
),
),
),
),
),
),
);
}
}
@@ -1,79 +0,0 @@
library;
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
/// Minimal app shell that binds a prepared [Wolf3dFlutterEngine] instance to
/// host screens.
class Wolf3dApp extends StatefulWidget {
/// Shared initialized facade that owns game data, input, and audio services.
final Wolf3dFlutterEngine engine;
const Wolf3dApp({
super.key,
required this.engine,
});
@override
State<Wolf3dApp> createState() => _Wolf3dAppState();
}
class _Wolf3dAppState extends State<Wolf3dApp> with WidgetsBindingObserver {
late final Wolf3dAppManager _manager;
@override
void initState() {
super.initState();
_manager = Wolf3dAppManager(engine: widget.engine);
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
unawaited(_manager.ensureAudioShutdown());
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.detached) {
unawaited(_manager.ensureAudioShutdown());
}
}
Future<void> _pickGameDataDirectory() {
return _manager.pickGameDataDirectory(
notifyChanged: () {
if (mounted) {
setState(() {});
}
},
);
}
Future<void> _pickGameDataFiles() {
return _manager.pickGameDataFiles(
notifyChanged: () {
if (mounted) {
setState(() {});
}
},
);
}
@override
Widget build(BuildContext context) {
return widget.engine.availableGames.isEmpty
? NoGameDataScreen(
configuredDataDirectory: widget.engine.configuredDataDirectory,
onPickGameDataDirectory: _pickGameDataDirectory,
onPickGameDataFiles: _pickGameDataFiles,
isLoadingGameData: _manager.isLoadingGameData,
pickerError: _manager.pickerError,
)
: GameScreen(wolf3d: widget.engine);
}
}
@@ -2,7 +2,6 @@
library;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:wolf_3d_dart/wolf_3d_data.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
@@ -18,7 +17,6 @@ export 'audio/wolf3d_platform_audio.dart' show Wolf3dPlatformAudio;
export 'managers/game_app_lifecycle_manager.dart' show GameAppLifecycleManager;
export 'managers/game_data_directory_persistence.dart'
show DefaultGameDataDirectoryPersistence;
export 'managers/game_data_picker_manager.dart' show GameDataPickerManager;
export 'managers/game_display_manager.dart' show GameDisplayManager;
export 'managers/game_persistence_manager.dart' show GamePersistenceManager;
export 'managers/game_renderer_mode_manager.dart'
@@ -31,22 +29,19 @@ export 'managers/game_screen_input_manager.dart'
HostShortcutRegistry,
GameScreenInputManager,
isAltEnterShortcut;
export 'managers/wolf3d_app_manager.dart' show Wolf3dAppManager;
export 'screens/audio_gallery.dart' show AudioGallery;
export 'screens/debug_tools_screen.dart' show DebugToolsScreen;
export 'screens/game_screen.dart' show GameScreen;
export 'screens/no_game_data_screen.dart' show NoGameDataScreen;
export 'screens/sprite_gallery.dart' show SpriteGallery;
export 'screens/vga_gallery.dart' show VgaGallery;
export 'widgets/gallery_game_selector.dart'
show GalleryGameSelector, formatGalleryGameTitle;
export 'widgets/wolf3d_app.dart' show Wolf3dApp;
export 'widgets/wolf_menu_shell.dart' show WolfMenuShell;
/// Flutter-specific host facade built on top of [Wolf3dEngine].
///
/// This type keeps platform-neutral session/engine state in the Dart package
/// while owning Flutter-only concerns such as bundle loading and discovery.
/// while owning Flutter-only concerns such as platform discovery helpers.
class Wolf3dFlutterEngine extends Wolf3dEngine {
/// Creates an empty facade that must be initialized with [init].
Wolf3dFlutterEngine({
@@ -89,8 +84,9 @@ class Wolf3dFlutterEngine extends Wolf3dEngine {
/// used when available. [additionalDirectories] are scanned after the
/// primary directory and are not persisted.
///
/// This method merges bundled package data (when present) with discovered
/// external directories, deduplicating versions by [GameVersion].
/// This method scans only configured external directories, deduplicating
/// discovered versions by [GameVersion]. Shared package code does not bundle
/// or import game data on behalf of host applications.
Future<Wolf3dFlutterEngine> init({
String? directory,
Iterable<String>? additionalDirectories,
@@ -110,35 +106,6 @@ class Wolf3dFlutterEngine extends Wolf3dEngine {
await dataDirectoryPersistence.saveDataDirectory(requestedDirectory);
}
// Bundled assets let the GUI work out of the box on supported platforms.
final versionsToTry = [
(version: GameVersion.retail, path: 'retail'),
(version: GameVersion.shareware, path: 'shareware'),
];
for (final version in versionsToTry) {
try {
final ext = version.version.fileExtension;
final folder = 'packages/wolf_3d_assets/assets/${version.path}';
final data = WolfensteinLoader.loadFromBytes(
version: version.version,
vswap: await _tryLoad('$folder/VSWAP.$ext'),
mapHead: await _tryLoad('$folder/MAPHEAD.$ext'),
gameMaps: await _tryLoad('$folder/GAMEMAPS.$ext'),
vgaDict: await _tryLoad('$folder/VGADICT.$ext'),
vgaHead: await _tryLoad('$folder/VGAHEAD.$ext'),
vgaGraph: await _tryLoad('$folder/VGAGRAPH.$ext'),
audioHed: await _tryLoad('$folder/AUDIOHED.$ext'),
audioT: await _tryLoad('$folder/AUDIOT.$ext'),
);
availableGames.add(data);
} catch (e) {
debugPrint(e.toString());
}
}
// On non-web platforms, also scan local filesystem locations for
// user-supplied data folders so the host can pick up extra versions.
final Set<String> directoriesToScan = <String>{};
@@ -175,14 +142,4 @@ class Wolf3dFlutterEngine extends Wolf3dEngine {
return this;
}
/// Loads an asset from the Flutter bundle, returning `null` when absent.
Future<ByteData?> _tryLoad(String path) async {
try {
return await rootBundle.load(path);
} catch (e) {
debugPrint("Asset not found: $path");
return null;
}
}
}