diff --git a/packages/wolf_3d_flutter/lib/managers/game_data_picker_manager.dart b/packages/wolf_3d_flutter/lib/managers/game_data_picker_manager.dart index 5625c1c..33ea0ac 100644 --- a/packages/wolf_3d_flutter/lib/managers/game_data_picker_manager.dart +++ b/packages/wolf_3d_flutter/lib/managers/game_data_picker_manager.dart @@ -5,18 +5,47 @@ 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 Function({ + String? confirmButtonText, + }); + +/// Callback used to open a multi-file picker. +typedef PickFilesCallback = Future> Function(); + /// Coordinates game-data selection flows for the no-data setup screen. class GameDataPickerManager { - GameDataPickerManager({required this.engine}); + /// 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 pickGameDataDirectory({ required void Function() notifyChanged, }) async { @@ -25,7 +54,7 @@ class GameDataPickerManager { notifyChanged(); try { - final String? directoryPath = await getDirectoryPath( + final String? directoryPath = await _pickDirectory( confirmButtonText: 'Use this folder', ); @@ -42,6 +71,11 @@ class GameDataPickerManager { } } + /// 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 pickGameDataFiles({ required void Function() notifyChanged, }) async { @@ -50,7 +84,7 @@ class GameDataPickerManager { notifyChanged(); try { - final List selectedFiles = await openFiles(); + final List selectedFiles = await _pickFiles(); if (selectedFiles.isEmpty) { return; @@ -84,6 +118,7 @@ class GameDataPickerManager { } } + /// Returns the parent directory component from [path]. String _directoryFromFilePath(String path) { final String trimmedPath = path.trim(); if (trimmedPath.isEmpty) { diff --git a/packages/wolf_3d_flutter/lib/managers/wolf3d_app_manager.dart b/packages/wolf_3d_flutter/lib/managers/wolf3d_app_manager.dart index 7fd1083..9099c90 100644 --- a/packages/wolf_3d_flutter/lib/managers/wolf3d_app_manager.dart +++ b/packages/wolf_3d_flutter/lib/managers/wolf3d_app_manager.dart @@ -6,17 +6,26 @@ import 'package:wolf_3d_flutter/wolf_3d_flutter.dart'; /// Coordinates app-shell setup actions and shutdown behavior. class Wolf3dAppManager { - Wolf3dAppManager({required this.engine}); + /// 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; - late final GameDataPickerManager _pickerManager = GameDataPickerManager( - engine: engine, - ); + final GameDataPickerManager _pickerManager; Future? _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 ensureAudioShutdown() { final existing = _shutdownFuture; if (existing != null) { @@ -28,12 +37,14 @@ class Wolf3dAppManager { return shutdown; } + /// Delegates directory picker + reload behavior. Future pickGameDataDirectory({ required void Function() notifyChanged, }) async { await _pickerManager.pickGameDataDirectory(notifyChanged: notifyChanged); } + /// Delegates file picker + reload behavior. Future pickGameDataFiles({ required void Function() notifyChanged, }) async { diff --git a/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart b/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart index 2e3eeb8..297542e 100644 --- a/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart +++ b/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart @@ -83,6 +83,14 @@ class Wolf3dFlutterEngine extends Wolf3dEngine { } /// Initializes the engine by loading available game data. + /// + /// If [directory] is provided, it is persisted and treated as the primary + /// external search root. If omitted, a previously persisted directory is + /// 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]. Future init({ String? directory, Iterable? additionalDirectories, diff --git a/packages/wolf_3d_flutter/test/app_managers_test.dart b/packages/wolf_3d_flutter/test/app_managers_test.dart new file mode 100644 index 0000000..45c4edc --- /dev/null +++ b/packages/wolf_3d_flutter/test/app_managers_test.dart @@ -0,0 +1,198 @@ +import 'package:file_selector/file_selector.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'; + +class _NoopAudio 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++; + } + + @override + void stopMusic() {} + + @override + void dispose() { + disposeCallCount++; + } +} + +class _RecordingEngine extends Wolf3dFlutterEngine { + _RecordingEngine({required this.audio}) : super(audioBackend: audio); + + @override + final _NoopAudio audio; + + int initCallCount = 0; + String? lastDirectory; + List lastAdditionalDirectories = []; + + @override + Future init({ + String? directory, + Iterable? additionalDirectories, + }) async { + initCallCount++; + lastDirectory = directory; + lastAdditionalDirectories = + additionalDirectories?.toList(growable: false) ?? []; + return this; + } +} + +void main() { + group('GameDataPickerManager', () { + test('pickGameDataDirectory reloads selected directory', () async { + final engine = _RecordingEngine(audio: _NoopAudio()); + int notifyCount = 0; + final manager = GameDataPickerManager( + engine: engine, + pickDirectory: ({String? confirmButtonText}) async => ' /tmp/wolf ', + ); + + await manager.pickGameDataDirectory( + notifyChanged: () => notifyCount++, + ); + + expect(notifyCount, 2); + expect(manager.isLoadingGameData, isFalse); + expect(manager.pickerError, isNull); + expect(engine.initCallCount, 1); + expect(engine.lastDirectory, '/tmp/wolf'); + expect(engine.lastAdditionalDirectories, isEmpty); + }); + + test('pickGameDataDirectory ignores empty selection', () async { + final engine = _RecordingEngine(audio: _NoopAudio()); + final manager = GameDataPickerManager( + engine: engine, + pickDirectory: ({String? confirmButtonText}) async => ' ', + ); + + await manager.pickGameDataDirectory(notifyChanged: () {}); + + expect(engine.initCallCount, 0); + expect(manager.pickerError, isNull); + }); + + test( + 'pickGameDataFiles deduplicates directories and forwards extras', + () async { + final engine = _RecordingEngine(audio: _NoopAudio()); + final manager = GameDataPickerManager( + engine: engine, + pickFiles: () async => [ + XFile('/a/one/VSWAP.WL6'), + XFile('/a/one/MAPHEAD.WL6'), + XFile('/a/two/VSWAP.WL1'), + ], + ); + + await manager.pickGameDataFiles(notifyChanged: () {}); + + expect(engine.initCallCount, 1); + expect(engine.lastDirectory, '/a/one'); + expect(engine.lastAdditionalDirectories, ['/a/two']); + expect(manager.pickerError, isNull); + }, + ); + + test('pickGameDataFiles reports missing directory paths', () async { + final engine = _RecordingEngine(audio: _NoopAudio()); + final manager = GameDataPickerManager( + engine: engine, + pickFiles: () async => [ + XFile('VSWAP.WL6'), + XFile('MAPHEAD.WL6'), + ], + ); + + await manager.pickGameDataFiles(notifyChanged: () {}); + + expect(engine.initCallCount, 0); + expect( + manager.pickerError, + 'Selected files do not expose local filesystem paths.', + ); + }); + + test('pickGameDataDirectory captures thrown picker error', () async { + final engine = _RecordingEngine(audio: _NoopAudio()); + final manager = GameDataPickerManager( + engine: engine, + pickDirectory: ({String? confirmButtonText}) async { + throw StateError('picker failed'); + }, + ); + + await manager.pickGameDataDirectory(notifyChanged: () {}); + + expect(engine.initCallCount, 0); + expect( + manager.pickerError, + contains('Unable to load selected directory'), + ); + expect(manager.isLoadingGameData, isFalse); + }); + }); + + group('Wolf3dAppManager', () { + test('ensureAudioShutdown only executes once', () async { + final audio = _NoopAudio(); + final engine = _RecordingEngine(audio: audio); + final manager = Wolf3dAppManager(engine: engine); + + await Future.wait(>[ + manager.ensureAudioShutdown(), + manager.ensureAudioShutdown(), + ]); + + expect(audio.stopAllAudioCallCount, 1); + expect(audio.disposeCallCount, 1); + }); + + test('forwards picker state from injected picker manager', () async { + final engine = _RecordingEngine(audio: _NoopAudio()); + final pickerManager = GameDataPickerManager( + engine: engine, + pickDirectory: ({String? confirmButtonText}) async => '/tmp/wolf', + ); + final manager = Wolf3dAppManager( + engine: engine, + pickerManager: pickerManager, + ); + + await manager.pickGameDataDirectory(notifyChanged: () {}); + + expect(manager.isLoadingGameData, isFalse); + expect(manager.pickerError, isNull); + expect(engine.lastDirectory, '/tmp/wolf'); + }); + }); +}