Initial flame_lua_runtime package
This commit is contained in:
484
test/runtime/audio/runtime_audio_manager_test.dart
Normal file
484
test/runtime/audio/runtime_audio_manager_test.dart
Normal file
@@ -0,0 +1,484 @@
|
||||
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': <String>[],
|
||||
'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<ResourceLoadException>()),
|
||||
);
|
||||
expect(audio.audioState('dice'), GameResourceState.failed);
|
||||
audio.dispose();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const _audioBytes = [1, 2, 3, 4, 5];
|
||||
|
||||
Future<GamePackage> _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<GamePackage> _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<ByteData> readBytes(String relativeOrAbsolutePath) async {
|
||||
activeReads++;
|
||||
if (activeReads > maxActiveReads) {
|
||||
maxActiveReads = activeReads;
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 5));
|
||||
try {
|
||||
return await super.readBytes(relativeOrAbsolutePath);
|
||||
} finally {
|
||||
activeReads--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _BlockingAudioPackage extends GamePackage {
|
||||
_BlockingAudioPackage(GamePackage package)
|
||||
: _releaseReads = async.Completer<void>(),
|
||||
super.file(rootPath: package.rootPath, manifest: package.manifest);
|
||||
|
||||
final async.Completer<void> _releaseReads;
|
||||
|
||||
void releaseReads() {
|
||||
if (!_releaseReads.isCompleted) {
|
||||
_releaseReads.complete();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ByteData> readBytes(String relativeOrAbsolutePath) async {
|
||||
await _releaseReads.future;
|
||||
return super.readBytes(relativeOrAbsolutePath);
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeRuntimeAudioPlayer implements RuntimeAudioPlayer {
|
||||
async.Completer<void> _done = async.Completer<void>();
|
||||
List<int>? startedBytes;
|
||||
double? volume;
|
||||
var loop = false;
|
||||
var paused = false;
|
||||
var resumed = false;
|
||||
var stopped = false;
|
||||
var disposed = false;
|
||||
var startCount = 0;
|
||||
|
||||
@override
|
||||
Future<void> get done => _done.future;
|
||||
|
||||
@override
|
||||
Future<void> start(
|
||||
Uint8List bytes, {
|
||||
required double volume,
|
||||
bool loop = false,
|
||||
}) async {
|
||||
if (_done.isCompleted) {
|
||||
_done = async.Completer<void>();
|
||||
}
|
||||
startCount++;
|
||||
startedBytes = bytes.toList(growable: false);
|
||||
this.volume = volume;
|
||||
this.loop = loop;
|
||||
}
|
||||
|
||||
void complete() {
|
||||
if (!_done.isCompleted) {
|
||||
_done.complete();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pause() async {
|
||||
paused = true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> resume() async {
|
||||
resumed = true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {
|
||||
stopped = true;
|
||||
complete();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
disposed = true;
|
||||
complete();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user