import 'dart:async' as async; import 'dart:io'; import 'dart:typed_data'; import 'package:flame_lua_runtime/runtime/audio/runtime_audio_manager.dart'; import 'package:flame_lua_runtime/runtime/audio/runtime_audio_player.dart'; import 'package:flame_lua_runtime/runtime/diagnostics/runtime_diagnostics.dart'; import 'package:flame_lua_runtime/runtime/packages/game_package.dart'; import 'package:flame_lua_runtime/runtime/packages/game_package_manifest.dart'; import 'package:flame_lua_runtime/runtime/resources/game_resource_manager.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('RuntimeAudioManager', () { test('preloads required audio and exposes ready state', () async { final package = await _createPackage( 'required_audio', preload: GameResourcePreload.required, ); final audio = RuntimeAudioManager(); await audio.mount(package); expect(audio.audioState('dice'), GameResourceState.ready); audio.dispose(); }); test('lazy audio plays from package bytes', () async { final players = <_FakeRuntimeAudioPlayer>[]; final package = await _createPackage('lazy_audio'); final audio = RuntimeAudioManager( playerFactory: () { final player = _FakeRuntimeAudioPlayer(); players.add(player); return player; }, ); await audio.mount(package); final playback = await audio.play('dice', volume: 0.5); expect(playback, isNotNull); expect(audio.audioState('dice'), GameResourceState.ready); expect(players.single.startedBytes, _audioBytes); expect(players.single.volume, 0.5); players.single.complete(); await playback!.done; audio.dispose(); }); test('lazy audio failure records state, error and diagnostics', () async { final diagnostics = RuntimeDiagnostics(); final package = await _createPackage('missing_audio', writeAudio: false); final audio = RuntimeAudioManager(diagnostics: diagnostics); await audio.mount(package); final playback = await audio.play('dice'); expect(playback, isNull); expect(audio.audioState('dice'), GameResourceState.failed); expect(audio.audioError('dice'), isNotNull); expect( diagnostics.entries.single.type, RuntimeDiagnosticType.resourceLoadError, ); audio.dispose(); }); test( 'exports audio debug json, evicts and retries failed records', () async { final root = await Directory.systemTemp.createTemp('audio_retry_'); Directory('${root.path}/assets').createSync(recursive: true); addTearDown(() { if (root.existsSync()) { root.deleteSync(recursive: true); } }); final package = _createPackageAt( root, preload: GameResourcePreload.lazy, ); final audio = RuntimeAudioManager(); await audio.mount(package); expect(audio.audioDebugJson(), { 'generation': 1, 'hasPackage': true, 'count': 1, 'activeLoads': 0, 'pendingLoads': 0, 'activePlayers': 0, 'pooledPlayers': 0, 'channels': [], 'resources': [ { 'key': 'dice', 'path': endsWith('/assets/dice.wav'), 'type': GameResourceType.audio, 'declared': true, 'preload': GameResourcePreload.lazy, 'state': 'idle', 'loading': false, 'ready': false, }, ], }); expect(await audio.retryAudio('dice'), isFalse); expect(audio.audioState('dice'), GameResourceState.failed); expect(audio.audioDebugJson().toString(), contains('error')); File('${root.path}/assets/dice.wav').writeAsBytesSync(_audioBytes); expect(await audio.retryAudio('dice'), isTrue); expect(audio.audioState('dice'), GameResourceState.ready); expect(audio.evictAudio('dice'), isTrue); expect(audio.audioState('dice'), GameResourceState.idle); expect(audio.evictAudio('dice'), isFalse); audio.dispose(); }, ); test('preloads and evicts audio resource groups', () async { final package = await _createMultiAudioPackage('audio_group'); final audio = RuntimeAudioManager(); await audio.mount(package); await audio.preloadGroup('scene'); expect(audio.audioState('dice'), GameResourceState.ready); expect(audio.audioState('click'), GameResourceState.ready); expect(audio.audioState('bgm'), GameResourceState.idle); expect(audio.evictGroup('scene'), 2); expect(audio.audioState('dice'), GameResourceState.idle); expect(audio.audioState('click'), GameResourceState.idle); audio.dispose(); }); test('limits concurrent audio reads during group preload', () async { final package = _CountingAudioPackage( await _createMultiAudioPackage('audio_concurrency'), ); final audio = RuntimeAudioManager(maxConcurrentLoads: 1); await audio.mount(package); await audio.preloadGroup('scene'); expect(package.maxActiveReads, 1); expect(audio.audioState('dice'), GameResourceState.ready); expect(audio.audioState('click'), GameResourceState.ready); audio.dispose(); }); test( 'drops stale audio load result after dispose without diagnostics', () async { final diagnostics = RuntimeDiagnostics(); final package = _BlockingAudioPackage( await _createPackage('stale_audio'), ); final audio = RuntimeAudioManager(diagnostics: diagnostics); await audio.mount(package); final playback = audio.play('dice'); audio.dispose(); package.releaseReads(); expect(await playback, isNull); expect(diagnostics.entries, isEmpty); }, ); test('audio LRU evicts least recently used bytes', () async { final package = await _createMultiAudioPackage('audio_lru'); final audio = RuntimeAudioManager(maxCacheEntries: 1); await audio.mount(package); expect(await audio.retryAudio('dice'), isTrue); expect(audio.audioState('dice'), GameResourceState.ready); expect(await audio.retryAudio('bgm'), isTrue); expect(audio.audioState('dice'), GameResourceState.idle); expect(audio.audioState('bgm'), GameResourceState.ready); audio.dispose(); }); test('reuses pooled one-shot audio players', () async { final players = <_FakeRuntimeAudioPlayer>[]; final package = await _createPackage('sfx_pool'); final audio = RuntimeAudioManager( maxSfxPoolSize: 1, playerFactory: () { final player = _FakeRuntimeAudioPlayer(); players.add(player); return player; }, ); await audio.mount(package); final first = await audio.play('dice'); players.single.complete(); await first!.done; expect(audio.audioDebugJson()['pooledPlayers'], 1); final second = await audio.play('dice'); players.single.complete(); await second!.done; expect(players, hasLength(1)); expect(players.single.startCount, 2); audio.dispose(); }); test('plays, pauses, resumes and stops bgm channel', () async { final players = <_FakeRuntimeAudioPlayer>[]; final package = await _createPackage('bgm_audio'); final audio = RuntimeAudioManager( playerFactory: () { final player = _FakeRuntimeAudioPlayer(); players.add(player); return player; }, ); await audio.mount(package); final playback = await audio.playBgm( 'dice', channel: 'music', volume: 0.3, loop: true, ); expect(playback, isNotNull); expect(audio.hasBgm(channel: 'music'), isTrue); expect(players.single.startedBytes, _audioBytes); expect(players.single.volume, 0.3); expect(players.single.loop, isTrue); await audio.pauseBgm(channel: 'music'); await audio.resumeBgm(channel: 'music'); await audio.stopBgm(channel: 'music'); expect(players.single.paused, isTrue); expect(players.single.resumed, isTrue); expect(players.single.stopped, isTrue); expect(audio.hasBgm(channel: 'music'), isFalse); audio.dispose(); }); test('starting same bgm channel replaces previous playback', () async { final players = <_FakeRuntimeAudioPlayer>[]; final package = await _createPackage('replace_bgm_audio'); final audio = RuntimeAudioManager( playerFactory: () { final player = _FakeRuntimeAudioPlayer(); players.add(player); return player; }, ); await audio.mount(package); await audio.playBgm('dice', channel: 'music'); await audio.playBgm('dice', channel: 'music'); expect(players, hasLength(2)); expect(players.first.stopped, isTrue); expect(audio.hasBgm(channel: 'music'), isTrue); audio.dispose(); }); test('required audio preload failure fails mount', () async { final package = await _createPackage( 'required_missing_audio', preload: GameResourcePreload.required, writeAudio: false, ); final audio = RuntimeAudioManager(); await expectLater( audio.mount(package), throwsA(isA()), ); expect(audio.audioState('dice'), GameResourceState.failed); audio.dispose(); }); }); } const _audioBytes = [1, 2, 3, 4, 5]; Future _createPackage( String name, { String preload = GameResourcePreload.lazy, bool writeAudio = true, }) async { final root = await Directory.systemTemp.createTemp('audio_${name}_'); Directory('${root.path}/assets').createSync(recursive: true); if (writeAudio) { File('${root.path}/assets/dice.wav').writeAsBytesSync(_audioBytes); } addTearDown(() { if (root.existsSync()) { root.deleteSync(recursive: true); } }); return _createPackageAt(root, preload: preload); } Future _createMultiAudioPackage(String name) async { final root = await Directory.systemTemp.createTemp('audio_${name}_'); Directory('${root.path}/assets').createSync(recursive: true); File('${root.path}/assets/dice.wav').writeAsBytesSync(_audioBytes); File('${root.path}/assets/click.wav').writeAsBytesSync([6, 7, 8]); File('${root.path}/assets/bgm.wav').writeAsBytesSync([9, 10, 11, 12]); addTearDown(() { if (root.existsSync()) { root.deleteSync(recursive: true); } }); return GamePackage.file( rootPath: root.path, manifest: GamePackageManifest( gameId: 'test', name: 'Test', version: '0.1.0', runtimeApiVersion: 1, entry: 'scripts/main.lua', assetsBase: 'assets', resources: const { 'dice': GameResource( type: GameResourceType.audio, path: 'assets/dice.wav', preload: GameResourcePreload.lazy, group: 'scene', ), 'click': GameResource( type: GameResourceType.audio, path: 'assets/click.wav', preload: GameResourcePreload.lazy, group: 'scene', ), 'bgm': GameResource( type: GameResourceType.audio, path: 'assets/bgm.wav', preload: GameResourcePreload.lazy, group: 'music', ), }, ), ); } GamePackage _createPackageAt( Directory root, { String preload = GameResourcePreload.lazy, }) { return GamePackage.file( rootPath: root.path, manifest: GamePackageManifest( gameId: 'test', name: 'Test', version: '0.1.0', runtimeApiVersion: 1, entry: 'scripts/main.lua', assetsBase: 'assets', resources: { 'dice': GameResource( type: GameResourceType.audio, path: 'assets/dice.wav', preload: preload, ), }, ), ); } class _CountingAudioPackage extends GamePackage { _CountingAudioPackage(GamePackage package) : super.file(rootPath: package.rootPath, manifest: package.manifest); var activeReads = 0; var maxActiveReads = 0; @override Future readBytes(String relativeOrAbsolutePath) async { activeReads++; if (activeReads > maxActiveReads) { maxActiveReads = activeReads; } await Future.delayed(const Duration(milliseconds: 5)); try { return await super.readBytes(relativeOrAbsolutePath); } finally { activeReads--; } } } class _BlockingAudioPackage extends GamePackage { _BlockingAudioPackage(GamePackage package) : _releaseReads = async.Completer(), super.file(rootPath: package.rootPath, manifest: package.manifest); final async.Completer _releaseReads; void releaseReads() { if (!_releaseReads.isCompleted) { _releaseReads.complete(); } } @override Future readBytes(String relativeOrAbsolutePath) async { await _releaseReads.future; return super.readBytes(relativeOrAbsolutePath); } } class _FakeRuntimeAudioPlayer implements RuntimeAudioPlayer { async.Completer _done = async.Completer(); List? startedBytes; double? volume; var loop = false; var paused = false; var resumed = false; var stopped = false; var disposed = false; var startCount = 0; @override Future get done => _done.future; @override Future start( Uint8List bytes, { required double volume, bool loop = false, }) async { if (_done.isCompleted) { _done = async.Completer(); } startCount++; startedBytes = bytes.toList(growable: false); this.volume = volume; this.loop = loop; } void complete() { if (!_done.isCompleted) { _done.complete(); } } @override Future pause() async { paused = true; } @override Future resume() async { resumed = true; } @override Future stop() async { stopped = true; complete(); } @override Future dispose() async { disposed = true; complete(); } }