Files
plezy/test/mpv/player_native_bridge_test.dart
T

401 lines
14 KiB
Dart

import 'dart:async' show Completer;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/mpv/models.dart';
import 'package:plezy/mpv/player/player_native.dart';
import 'package:plezy/services/settings_service.dart';
import '../test_helpers/mock_player_channels.dart';
import '../test_helpers/prefs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
await SettingsService.getInstance();
});
test('MPV coalesces concurrent Dart initialization requests', () async {
final initialize = Completer<bool>();
final calls = <MethodCall>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) {
calls.add(call);
if (call.method == 'initialize') return initialize.future;
return Future.value(null);
},
testBody: () async {
final player = PlayerNative();
try {
final logLevel = player.setLogLevel('warn');
final command = player.command(['stop']);
await Future<void>.delayed(Duration.zero);
expect(calls.where((call) => call.method == 'initialize'), hasLength(1));
initialize.complete(true);
await Future.wait([logLevel, command]);
expect(calls.where((call) => call.method == 'initialize'), hasLength(1));
expect(calls.where((call) => call.method == 'setLogLevel'), hasLength(1));
expect(calls.where((call) => call.method == 'command'), hasLength(1));
} finally {
if (!initialize.isCompleted) initialize.complete(true);
await player.dispose();
}
},
);
});
test('MPV accepts nested node observations and null unsupported values', () async {
final observations = <String, int>{};
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) async {
if (call.method == 'initialize') return true;
if (call.method == 'observeProperty') {
final arguments = call.arguments as Map;
observations[arguments['name'] as String] = arguments['id'] as int;
}
return null;
},
testBody: () async {
final player = PlayerNative();
try {
await player.setLogLevel('warn');
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
const codec = StandardMethodCodec();
Future<void> sendObservation(String name, Object? value) async {
final done = Completer<void>();
await messenger.handlePlatformMessage(
'com.plezy/mpv_player/events',
codec.encodeSuccessEnvelope([observations[name], value]),
(_) => done.complete(),
);
await done.future;
await Future<void>.delayed(Duration.zero);
}
await sendObservation('track-list', const [
{
'type': 'audio',
'id': 7,
'title': 'Main',
'selected': true,
'metadata': {
'nested': [true, 2, 3.5, null],
},
},
]);
await sendObservation('demuxer-cache-state', const {
'cache-end': 12.5,
'seekable-ranges': [
{'start': 1, 'end': 9.25},
],
});
await sendObservation('audio-device-list', const [
{'name': 'speakers', 'description': 'Main speakers'},
]);
expect(player.state.tracks.audio.single.id, '7');
expect(player.state.tracks.audio.single.title, 'Main');
expect(player.state.buffer, const Duration(milliseconds: 12500));
expect(player.state.bufferRanges.single.start, const Duration(seconds: 1));
expect(player.state.bufferRanges.single.end, const Duration(milliseconds: 9250));
expect(player.state.audioDevices.single.name, 'speakers');
// Unsupported mpv_node formats cross the native bridge as null and
// must not erase the last valid structured observation.
for (final invalid in [null, '{not-json', 42]) {
await sendObservation('track-list', invalid);
await sendObservation('demuxer-cache-state', invalid);
await sendObservation('audio-device-list', invalid);
expect(player.state.tracks.audio.single.id, '7');
expect(player.state.buffer, const Duration(milliseconds: 12500));
expect(player.state.bufferRanges.single.end, const Duration(milliseconds: 9250));
expect(player.state.audioDevices.single.name, 'speakers');
}
} finally {
await player.dispose();
}
},
);
});
test('Android command failure reaches seek recovery', () async {
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) async {
if (call.method == 'initialize') return true;
if (call.method == 'command') {
throw PlatformException(code: 'COMMAND_FAILED', message: 'mpv command failed');
}
return null;
},
testBody: () async {
final player = PlayerNative();
try {
await player.seek(const Duration(seconds: 12));
expect(player.state.position, Duration.zero);
} finally {
await player.dispose();
}
},
);
});
test('Android setLogLevel failure is exposed to Dart', () async {
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) async {
if (call.method == 'initialize') return true;
if (call.method == 'setLogLevel') {
throw PlatformException(code: 'UNSUPPORTED');
}
return null;
},
testBody: () async {
final player = PlayerNative();
try {
await expectLater(
player.setLogLevel('warn'),
throwsA(isA<PlatformException>().having((error) => error.code, 'code', 'UNSUPPORTED')),
);
} finally {
await player.dispose();
}
},
);
});
test('audio setLogLevel uses the dedicated native channel', () async {
MethodCall? logLevelCall;
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_audio_player',
eventChannelName: 'com.plezy/mpv_audio_player/events',
methodHandler: (call) async {
if (call.method == 'initialize') return true;
if (call.method == 'setLogLevel') logLevelCall = call;
return null;
},
testBody: () async {
final player = PlayerNative.audio();
try {
await player.setLogLevel('v');
expect(logLevelCall?.arguments, {'level': 'v'});
} finally {
await player.dispose();
}
},
);
});
test('Android mpv end-file error preserves native diagnostic message', () async {
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
testBody: () async {
final player = PlayerNative();
final error = player.streams.error.first;
try {
player.handlePlayerEvent('end-file', {'reason': 4, 'message': 'Invalid data found when processing input'});
await expectLater(
error,
completion(
isA<PlayerError>().having(
(value) => value.message,
'message',
'Invalid data found when processing input',
),
),
);
} finally {
await player.dispose();
}
},
);
});
test('Android mpv legacy end-file error keeps playback error fallback', () async {
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
testBody: () async {
final player = PlayerNative();
final error = player.streams.error.first;
try {
player.handlePlayerEvent('end-file', {'reason': 4});
await expectLater(
error,
completion(isA<PlayerError>().having((value) => value.message, 'message', 'Playback error')),
);
} finally {
await player.dispose();
}
},
);
});
test('overlapping playback-rate changes are serialized in call order', () async {
final releaseFirstSpeed = Completer<void>();
final firstSpeedStarted = Completer<void>();
final speedValues = <String>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) async {
if (call.method == 'initialize') return true;
if (call.method == 'setProperty') {
final arguments = call.arguments as Map;
if (arguments['name'] == 'speed') {
speedValues.add(arguments['value'] as String);
if (!firstSpeedStarted.isCompleted) firstSpeedStarted.complete();
if (speedValues.length == 1) await releaseFirstSpeed.future;
}
}
return null;
},
testBody: () async {
final player = PlayerNative();
try {
final first = player.setRate(1.25);
final second = player.setRate(1.5);
await firstSpeedStarted.future;
expect(speedValues, ['1.25']);
releaseFirstSpeed.complete();
await Future.wait([first, second]);
expect(speedValues, ['1.25', '1.5']);
} finally {
if (!releaseFirstSpeed.isCompleted) releaseFirstSpeed.complete();
await player.dispose();
}
},
);
});
for (final channel in [
(label: 'video', method: 'com.plezy/mpv_player', events: 'com.plezy/mpv_player/events', audio: false),
(label: 'audio', method: 'com.plezy/mpv_audio_player', events: 'com.plezy/mpv_audio_player/events', audio: true),
]) {
group('${channel.label} property bridge', () {
test('propagates SET_PROPERTY_FAILED', () async {
final calls = <MethodCall>[];
await withMockPlayerChannels(
methodChannelName: channel.method,
eventChannelName: channel.events,
methodHandler: (call) async {
calls.add(call);
if (call.method == 'initialize') return true;
if (call.method == 'setProperty') {
final arguments = call.arguments as Map;
if (arguments['name'] == 'unsupported-property') {
throw PlatformException(code: 'SET_PROPERTY_FAILED', message: 'Property write rejected');
}
}
return null;
},
testBody: () async {
final player = channel.audio ? PlayerNative.audio() : PlayerNative();
try {
await expectLater(
player.setProperty('unsupported-property', 'invalid'),
throwsA(isA<PlatformException>().having((error) => error.code, 'code', 'SET_PROPERTY_FAILED')),
);
final initializeIndex = calls.indexWhere((call) => call.method == 'initialize');
final propertyIndex = calls.indexWhere(
(call) => call.method == 'setProperty' && (call.arguments as Map)['name'] == 'unsupported-property',
);
expect(initializeIndex, isNonNegative);
expect(propertyIndex, greaterThan(initializeIndex));
} finally {
await player.dispose();
}
},
);
});
test('failed setVolume leaves published volume unchanged', () async {
await withMockPlayerChannels(
methodChannelName: channel.method,
eventChannelName: channel.events,
methodHandler: (call) async {
if (call.method == 'initialize') return true;
if (call.method == 'setProperty') {
final arguments = call.arguments as Map;
if (arguments['name'] == 'volume') {
throw PlatformException(code: 'SET_PROPERTY_FAILED', message: 'Property write rejected');
}
}
return null;
},
testBody: () async {
final player = channel.audio ? PlayerNative.audio() : PlayerNative();
try {
final initialVolume = player.state.volume;
await expectLater(
player.setVolume(37),
throwsA(isA<PlatformException>().having((error) => error.code, 'code', 'SET_PROPERTY_FAILED')),
);
expect(player.state.volume, initialVolume);
} finally {
await player.dispose();
}
},
);
});
test('successful setVolume publishes only after the accepted write', () async {
final volumeWriteStarted = Completer<void>();
final acceptVolumeWrite = Completer<void>();
await withMockPlayerChannels(
methodChannelName: channel.method,
eventChannelName: channel.events,
methodHandler: (call) async {
if (call.method == 'initialize') return true;
if (call.method == 'setProperty') {
final arguments = call.arguments as Map;
if (arguments['name'] == 'volume') {
volumeWriteStarted.complete();
await acceptVolumeWrite.future;
}
}
return null;
},
testBody: () async {
final player = channel.audio ? PlayerNative.audio() : PlayerNative();
try {
final initialVolume = player.state.volume;
final write = player.setVolume(42);
await volumeWriteStarted.future;
expect(player.state.volume, initialVolume);
acceptVolumeWrite.complete();
await write;
expect(player.state.volume, 42);
} finally {
if (!acceptVolumeWrite.isCompleted) acceptVolumeWrite.complete();
await player.dispose();
}
},
);
});
});
}
}