Files
SpotiFLAC-Mobile/test/playback_automation_test.dart
T

282 lines
9.8 KiB
Dart

// Exercises the typed device API used by PlaybackAutomation.
// ignore_for_file: experimental_member_use
import 'dart:async';
import 'package:audio_session/audio_session.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/services/playback_automation.dart';
AudioDevice device(String id, AudioDeviceType type, {bool output = true}) =>
AudioDevice(
id: id,
name: id,
isInput: !output,
isOutput: output,
type: type,
);
class _Transport {
bool playing = true;
bool available = true;
int pauses = 0;
int plays = 0;
Completer<void>? pauseGate;
bool failPause = false;
final List<Object> errors = [];
late final PlaybackAutomation automation = PlaybackAutomation(
isPlaying: () => playing,
canResume: () => available,
pause: () async {
pauses++;
if (failPause) throw StateError('pause failed');
await pauseGate?.future;
playing = false;
},
play: () async {
plays++;
playing = true;
},
onError: errors.add,
);
Future<void> enable({bool mute = true, bool headphones = false}) => automation
.configure(pauseOnMute: mute, playOnHeadphonesConnected: headphones);
}
void main() {
test('mute automation defaults on and saved preferences survive restore', () {
final defaults = AppSettings.fromJson({});
expect(const AppSettings().pauseOnMute, isTrue);
expect(defaults.pauseOnMute, isTrue);
expect(defaults.playOnHeadphonesConnected, isFalse);
final settings = defaults.copyWith(
pauseOnMute: false,
playOnHeadphonesConnected: true,
);
final restored = AppSettings.fromJson(settings.toJson());
expect(restored.pauseOnMute, isFalse);
expect(restored.playOnHeadphonesConnected, isTrue);
expect(restored.copyWith(autoMix: true).pauseOnMute, isFalse);
});
test('mute pauses once; unmute resumes only a pause owned by mute', () async {
final transport = _Transport();
await transport.enable();
await transport.automation.volumeChanged(0.4);
expect(transport.pauses, 0);
await transport.automation.volumeChanged(0);
await transport.automation.volumeChanged(0);
expect(transport.pauses, 1);
await transport.automation.volumeChanged(0.01);
await transport.automation.volumeChanged(0.4);
expect(transport.plays, 1);
expect(transport.playing, isTrue);
await transport.automation.dispose();
});
test('an already paused song cannot start on unmute', () async {
final transport = _Transport()..playing = false;
await transport.enable();
await transport.automation.volumeChanged(0);
await transport.automation.volumeChanged(0.5);
expect(transport.pauses, 0);
expect(transport.plays, 0);
await transport.automation.dispose();
});
for (final reason in ['manual pause', 'sleep timer', 'focus loss', 'stop']) {
test('$reason cancels an unmute resume', () async {
final transport = _Transport();
await transport.enable();
await transport.automation.volumeChanged(0);
transport.automation.cancelPendingActions();
await transport.automation.volumeChanged(0.5);
expect(transport.plays, 0);
await transport.automation.dispose();
});
}
test('rapid mute and unmute do not leave a paused song behind', () async {
final transport = _Transport()..pauseGate = Completer<void>();
await transport.enable();
final muted = transport.automation.volumeChanged(0);
await Future<void>.delayed(Duration.zero);
expect(transport.pauses, 1);
final unmuted = transport.automation.volumeChanged(0.5);
transport.pauseGate!.complete();
await Future.wait([muted, unmuted]);
expect(transport.plays, 1);
expect(transport.playing, isTrue);
await transport.automation.dispose();
});
test('explicit pause during an automatic pause cancels resume', () async {
final transport = _Transport()..pauseGate = Completer<void>();
await transport.enable();
final muted = transport.automation.volumeChanged(0);
await Future<void>.delayed(Duration.zero);
transport.automation.cancelPendingActions();
final unmuted = transport.automation.volumeChanged(0.5);
transport.pauseGate!.complete();
await Future.wait([muted, unmuted]);
expect(transport.plays, 0);
await transport.automation.dispose();
});
test('disabled preferences and unavailable sessions do nothing', () async {
final transport = _Transport();
await transport.automation.volumeChanged(0);
expect(transport.pauses, 0);
transport.available = false;
await transport.enable();
expect(transport.pauses, 0);
await transport.automation.dispose();
});
test('turning mute automation off cancels its pending resume', () async {
final transport = _Transport();
await transport.enable();
await transport.automation.volumeChanged(0);
await transport.enable(mute: false);
await transport.automation.volumeChanged(0.5);
expect(transport.plays, 0);
await transport.automation.dispose();
});
test('starting a new song at zero volume pauses it', () async {
final transport = _Transport()..playing = false;
await transport.enable();
await transport.automation.volumeChanged(0);
transport.playing = true;
await transport.automation.playbackStarted();
expect(transport.pauses, 1);
await transport.automation.volumeChanged(0.5);
expect(transport.plays, 1);
await transport.automation.dispose();
});
test('failed pause is reported and cannot trigger unmute autoplay', () async {
final transport = _Transport()..failPause = true;
await transport.enable();
await transport.automation.volumeChanged(0);
await transport.automation.volumeChanged(0.5);
expect(transport.errors, hasLength(1));
expect(transport.plays, 0);
await transport.automation.dispose();
});
test(
'initial headphone inventory does not start a restored session',
() async {
final transport = _Transport()..playing = false;
await transport.enable(headphones: true);
await transport.automation.devicesChanged({
device('wired', AudioDeviceType.wiredHeadphones),
});
expect(transport.plays, 0);
await transport.automation.dispose();
},
);
for (final type in [
AudioDeviceType.wiredHeadset,
AudioDeviceType.wiredHeadphones,
AudioDeviceType.usbAudio,
AudioDeviceType.bluetoothA2dp,
AudioDeviceType.bluetoothLe,
]) {
test('$type connects once and disconnecting pauses', () async {
final transport = _Transport()..playing = false;
await transport.enable(headphones: true);
await transport.automation.volumeChanged(0.5);
await transport.automation.devicesChanged({});
final headphones = {device('headphones', type)};
await transport.automation.devicesChanged(headphones);
await transport.automation.devicesChanged(headphones);
expect(transport.plays, 1);
await transport.automation.devicesChanged({});
expect(transport.pauses, 1);
expect(transport.playing, isFalse);
await transport.automation.dispose();
});
}
test(
'speakers, microphones and Bluetooth call profiles do not autoplay',
() async {
final transport = _Transport()..playing = false;
await transport.enable(headphones: true);
await transport.automation.devicesChanged({});
await transport.automation.devicesChanged({
device('speaker', AudioDeviceType.builtInSpeaker),
device('call', AudioDeviceType.bluetoothSco),
device('mic', AudioDeviceType.wiredHeadset, output: false),
});
expect(transport.plays, 0);
await transport.automation.dispose();
},
);
test('headphone connections respect mute and audio interruptions', () async {
final transport = _Transport()..playing = false;
await transport.enable(headphones: true);
await transport.automation.devicesChanged({});
await transport.automation.volumeChanged(0);
await transport.automation.devicesChanged({
device('wired', AudioDeviceType.wiredHeadphones),
});
expect(transport.plays, 0);
await transport.automation.volumeChanged(0.5);
expect(transport.plays, 0);
transport.available = false;
await transport.automation.devicesChanged({
device('bluetooth', AudioDeviceType.bluetoothA2dp),
});
expect(transport.plays, 0);
await transport.automation.dispose();
});
test('unplugging cancels an unmute resume', () async {
final transport = _Transport();
await transport.enable(headphones: true);
await transport.automation.devicesChanged({
device('wired', AudioDeviceType.wiredHeadphones),
});
await transport.automation.volumeChanged(0);
await transport.automation.devicesChanged({});
await transport.automation.volumeChanged(0.5);
expect(transport.plays, 0);
await transport.automation.dispose();
});
test(
'pending connection playback is cancelled by an explicit pause',
() async {
final transport = _Transport()..playing = false;
await transport.enable(headphones: true);
await transport.automation.devicesChanged({});
final connected = transport.automation.devicesChanged({
device('wired', AudioDeviceType.wiredHeadphones),
});
transport.automation.cancelPendingActions();
await connected;
expect(transport.plays, 0);
await transport.automation.dispose();
},
);
test('disposal cancels queued work and stale notifications', () async {
final transport = _Transport();
await transport.enable(headphones: true);
final muted = transport.automation.volumeChanged(0);
await transport.automation.dispose();
await muted;
await transport.automation.volumeChanged(0.5);
expect(transport.pauses, 0);
expect(transport.plays, 0);
});
}