import 'dart:io'; import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:starsync/src/platform/audio/sound_engine.dart'; import 'package:starsync/src/sfx/sfx.dart'; import 'package:starsync/src/sfx/sound_bank.dart'; import 'package:starsync/src/sfx/synth.dart'; import 'package:starsync/src/sfx/synth_sfx.dart'; class _FakeBackend implements AudioBackend { final bool failInit; _FakeBackend({this.failInit = false}); var inits = 0, deinits = 0; final loads = {}; final unloaded = []; final plays = <(String, double)>[]; @override Future init() async { inits++; if (failInit) throw StateError('no audio device'); } @override Future load(String name, Uint8List wav) async { loads[name] = wav; return name; } @override void play(Object sound, double volume) => plays.add((sound as String, volume)); @override Future unload(Object sound) async => unloaded.add(sound); @override Future deinit() async => deinits++; } Future _bytes() async => Uint8List.fromList([1, 2, 3]); void main() { test('loads a sound once per key and lets plays overlap', () async { final b = _FakeBackend(); final e = SoundEngine(backend: b); await Future.wait([ e.play('chat', 'abyss-chat', _bytes, volume: 0.5), e.play('chat', 'abyss-chat', _bytes, volume: 0.5), ]); await e.play('chat', 'abyss-chat', _bytes, volume: 0.25); expect(b.inits, 1); expect(b.loads.length, 1); expect(b.plays.map((p) => p.$2), [0.5, 0.5, 0.25]); }); test('a new key or fresh replaces and frees the slot', () async { final b = _FakeBackend(); final e = SoundEngine(backend: b); await e.play('chat', 'abyss-chat', _bytes, volume: 1); await e.play('chat', 'cosmic-chat', _bytes, volume: 1); await e.play('chat', 'cosmic-chat', _bytes, volume: 1, fresh: true); await Future.delayed(Duration.zero); expect(b.loads.length, 3); expect(b.loads.keys.toSet().length, 3, reason: 'load names are unique'); expect(b.unloaded.length, 2); expect(b.plays.map((p) => p.$1).toSet().length, 3); }); test('silence and zero volume load nothing', () async { final b = _FakeBackend(); final e = SoundEngine(backend: b); await e.play('chat', 'abyss-chat', _bytes, volume: 0); expect(b.inits, 0); expect(b.loads, isEmpty); }); test('a failed init means no sounds, no throw, one try', () async { final b = _FakeBackend(failInit: true); final e = SoundEngine(backend: b); expect(await e.warmUp(), isFalse); await e.play('chat', 'abyss-chat', _bytes, volume: 1); await e.play('join', 'abyss-join', _bytes, volume: 1); expect(b.inits, 1); expect(b.loads, isEmpty); expect(b.plays, isEmpty); await e.dispose(); expect(b.deinits, 0); }); test('a failed render is retried on the next play', () async { final b = _FakeBackend(); final e = SoundEngine(backend: b); await e.play('chat', 'abyss-chat', () async => throw StateError('render'), volume: 1); expect(b.plays, isEmpty); await e.play('chat', 'abyss-chat', _bytes, volume: 1); expect(b.plays.length, 1); }); test('dispose deinits once and drops later plays', () async { final b = _FakeBackend(); final e = SoundEngine(backend: b); await e.play('chat', 'abyss-chat', _bytes, volume: 1); await e.dispose(); await e.dispose(); await e.play('chat', 'abyss-chat', _bytes, volume: 1); expect(b.deinits, 1); expect(b.plays.length, 1); }); test('dispose without a sound ever played opens nothing', () async { final b = _FakeBackend(); await SoundEngine(backend: b).dispose(); expect(b.inits + b.deinits, 0); }); group('SynthSfx', () { test('renders a WAV per sound, volume as before (v^6), switches and preview', () async { final b = _FakeBackend(); var volume = 0.5; final off = {SfxKind.join}; final sfx = SynthSfx( volume: () => volume, enabled: (k) => !off.contains(k), presetFor: (_) => SoundPreset.woodAndGlass, engine: SoundEngine(backend: b), ); for (final k in SfxKind.values) { await sfx.play(SfxCue(k, readyIndex: 2)); } expect(b.plays.length, SfxKind.values.length - 1); expect(b.plays.every((p) => p.$2 == 0.015625), isTrue); // 0.5^6 for (final MapEntry(:key, :value) in b.loads.entries) { expect(String.fromCharCodes(value.sublist(0, 4)), 'RIFF', reason: key); expect(key, startsWith('woodAndGlass-')); } final chat = b.loads.entries.firstWhere((e) => e.key.startsWith('woodAndGlass-chat-2')); expect(chat.value, wav(render(soundFor(SfxKind.chat, readyIndex: 2, preset: SoundPreset.woodAndGlass)))); await sfx.preview(SfxKind.join, readyIndex: 2); expect(b.plays.last.$1, startsWith('woodAndGlass-join-2')); volume = 0; await sfx.play(const SfxCue(SfxKind.chat)); expect(b.plays.length, SfxKind.values.length); await sfx.dispose(); expect(b.deinits, 1); }); }); group('crash mark', () { late Directory dir; setUp(() => dir = Directory.systemTemp.createTempSync('sound_mark')); tearDown(() => dir.deleteSync(recursive: true)); test('exists only while the device opens', () async { final mark = '${dir.path}/audio-init.pending'; var seen = false; final b = _MarkBackend(() => seen = File(mark).existsSync()); final e = SoundEngine(backend: b)..crashMark = mark; expect(await e.warmUp(), isTrue); expect(seen, isTrue, reason: 'there while opening'); expect(File(mark).existsSync(), isFalse, reason: 'gone once open'); }); test('left by a crash: sounds stay off, a preview tries again', () async { final mark = '${dir.path}/audio-init.pending'; File(mark).writeAsStringSync('opening the audio device'); final b = _FakeBackend(); final e = SoundEngine(backend: b)..crashMark = mark; await e.play('chat', 'k', _bytes, volume: 1); expect(b.inits, 0, reason: 'never opened after a crash'); expect(b.plays, isEmpty); e.retry(); await e.play('chat', 'k', _bytes, volume: 1); expect(b.inits, 1); expect(b.plays, hasLength(1)); expect(File(mark).existsSync(), isFalse); }); }); } class _MarkBackend extends _FakeBackend { final void Function() onInit; _MarkBackend(this.onInit); @override Future init() async { onInit(); await super.init(); } }