Initial commit

This commit is contained in:
admin1
2026-07-14 11:11:36 +08:00
commit 656499cf94
604 changed files with 119518 additions and 0 deletions
@@ -0,0 +1,97 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:smplayer/core/infra/player_engine/fvp_player_engine.dart';
void main() {
group('isFvpNetworkError', () {
test('命中 tcp:/tls: 前缀', () {
expect(isFvpNetworkError('tcp: connection lost'), isTrue);
expect(isFvpNetworkError('tls: certificate verify failed'), isTrue);
});
test('命中 TLS 层关键字(Windows 直连的主要场景)', () {
expect(isFvpNetworkError('TLS handshake failed'), isTrue);
expect(isFvpNetworkError('SSL_read: SSL_ERROR_SSL, WANT_WRITE'), isTrue);
expect(
isFvpNetworkError('reader: tls: handshake error (err=-1)'),
isTrue,
);
});
test('命中连接层关键字', () {
expect(isFvpNetworkError('Connection refused'), isTrue);
expect(isFvpNetworkError('connection reset by peer'), isTrue);
expect(isFvpNetworkError('connection timed out'), isTrue);
expect(isFvpNetworkError('Network is unreachable'), isTrue);
expect(isFvpNetworkError('Host not found'), isTrue);
expect(isFvpNetworkError('name resolution failed'), isTrue);
expect(isFvpNetworkError('No route to host'), isTrue);
});
test('命中 HTTP 状态码文本', () {
expect(isFvpNetworkError('HTTP error 403 Forbidden'), isTrue);
expect(isFvpNetworkError('HTTP error 502 Bad Gateway'), isTrue);
expect(isFvpNetworkError('Server returned 401 Unauthorized'), isTrue);
expect(isFvpNetworkError('Server returned 503'), isTrue);
});
test('命中 ffmpeg 协议缺失(TLS 后端被剥离时的典型 detail)', () {
expect(isFvpNetworkError('Protocol not found'), isTrue);
});
test('不命中解码器与非网络错误', () {
expect(isFvpNetworkError('Failed to allocate decoder'), isFalse);
expect(isFvpNetworkError('Error while decoding frame'), isFalse);
expect(isFvpNetworkError('unsupported pixel format'), isFalse);
expect(isFvpNetworkError('decoder timeout waiting for frame'), isFalse);
});
test('空串与无关文本不命中', () {
expect(isFvpNetworkError(''), isFalse);
expect(isFvpNetworkError('reader.buffering'), isFalse);
expect(isFvpNetworkError('render.video 1st_frame'), isFalse);
});
});
group('fvpNetworkErrorPayload', () {
test('只转换 IO 类别中的已识别网络错误', () {
expect(
fvpNetworkErrorPayload(
category: 'reader.error',
errorCode: -1,
detail: 'Connection reset by peer',
),
'reader: Connection reset by peer (err=-1)',
);
});
test('忽略 demuxer 解码错误和非负事件', () {
expect(
fvpNetworkErrorPayload(
category: 'demuxer.error',
errorCode: -1,
detail: 'Invalid data found when processing input',
),
isNull,
);
expect(
fvpNetworkErrorPayload(
category: 'reader.error',
errorCode: 0,
detail: 'Connection reset by peer',
),
isNull,
);
});
test('忽略非 IO 类别,即使 detail 含网络词', () {
expect(
fvpNetworkErrorPayload(
category: 'decoder.video',
errorCode: -1,
detail: 'TLS handshake failed',
),
isNull,
);
});
});
}
@@ -0,0 +1,416 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart'
show ValueNotifier, debugDefaultTargetPlatformOverride;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:smplayer/core/infra/player_engine/media3_player_engine.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const methodChannel = MethodChannel('smplayer/media3_engine');
const eventChannel = MethodChannel('smplayer/media3_engine/events');
setUp(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(methodChannel, (call) async {
switch (call.method) {
case 'create':
return <String, dynamic>{
'playerId': 7,
'ffmpegAvailable': true,
'ffmpegVersion': 'test-ffmpeg',
};
case 'open':
case 'setVolume':
case 'setResizeMode':
case 'replayKeyState':
case 'dispose':
return null;
}
fail('Unexpected media3 method call: ${call.method}');
});
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(eventChannel, (call) async => null);
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(methodChannel, null);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(eventChannel, null);
debugDefaultTargetPlatformOverride = null;
});
group('Media3PlayerEngine 平台守卫', () {
test('桌面(isAndroid=false)构造抛 UnsupportedError', () {
expect(
() => Media3PlayerEngine(isAndroid: false),
throwsA(isA<UnsupportedError>()),
);
});
test('AndroidisAndroid=true)构造不抛', () {
final engine = Media3PlayerEngine(isAndroid: true);
addTearDown(engine.dispose);
expect(engine.displayName, 'media3');
});
test('open 成功后请求 native 重放关键状态', () async {
final calls = <String>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(methodChannel, (call) async {
calls.add(call.method);
switch (call.method) {
case 'create':
return <String, dynamic>{
'playerId': 7,
'ffmpegAvailable': true,
'ffmpegVersion': 'test-ffmpeg',
};
case 'open':
case 'setVolume':
case 'replayKeyState':
case 'dispose':
return null;
}
fail('Unexpected media3 method call: ${call.method}');
});
final engine = Media3PlayerEngine(isAndroid: true);
addTearDown(engine.dispose);
await engine.open('http://127.0.0.1/video.mkv');
expect(calls, containsAllInOrder(<String>['open', 'replayKeyState']));
});
});
group('Media3PlayerEngine Android 视频视图', () {
test('源码固定使用 Hybrid Composition,避免旧设备 TLHC/HC fallback 改变 HDR 路径', () {
final source = File(
'lib/core/infra/player_engine/media3_player_engine.dart',
).readAsStringSync();
expect(source, contains('initExpensiveAndroidView'));
expect(source, isNot(contains('initSurfaceAndroidView')));
});
test('native SurfaceView 不主动改 z-order,避免 Flutter HC 下黑屏', () {
final source = File(
'plugins/media3_engine/android/src/main/kotlin/com/smplayer/media3_engine/Media3PlatformView.kt',
).readAsStringSync();
expect(source, isNot(contains('setZOrderMediaOverlay(true)')));
expect(source, isNot(contains('setZOrderOnTop(true)')));
});
testWidgets('playerId 就位后使用原生 SurfaceView PlatformView', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
try {
final engine = Media3PlayerEngine(isAndroid: true);
final fit = ValueNotifier<BoxFit>(BoxFit.contain);
addTearDown(engine.dispose);
addTearDown(fit.dispose);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Builder(
builder: (context) =>
SizedBox.expand(child: engine.buildVideoView(context, fit)),
),
),
);
expect(find.byType(PlatformViewLink), findsNothing);
await engine.open('http://127.0.0.1/video.mkv');
await tester.pump();
final view = tester.widget<PlatformViewLink>(
find.byType(PlatformViewLink),
);
expect(view.viewType, 'smplayer/media3_surface');
} finally {
debugDefaultTargetPlatformOverride = null;
}
});
testWidgets('open 等待 native ready 时仍先挂载视频 PlatformView', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
final openCompleter = Completer<Object?>();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(methodChannel, (call) async {
switch (call.method) {
case 'create':
return <String, dynamic>{
'playerId': 7,
'ffmpegAvailable': true,
'ffmpegVersion': 'test-ffmpeg',
};
case 'open':
return openCompleter.future;
case 'setVolume':
case 'setResizeMode':
case 'replayKeyState':
case 'dispose':
return null;
}
fail('Unexpected media3 method call: ${call.method}');
});
try {
final engine = Media3PlayerEngine(isAndroid: true);
final fit = ValueNotifier<BoxFit>(BoxFit.contain);
addTearDown(engine.dispose);
addTearDown(fit.dispose);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Builder(
builder: (context) =>
SizedBox.expand(child: engine.buildVideoView(context, fit)),
),
),
);
expect(find.byType(PlatformViewLink), findsNothing);
final openFuture = engine.open('http://127.0.0.1/video.mkv');
await tester.pump();
await tester.pump();
expect(openCompleter.isCompleted, isFalse);
expect(find.byType(PlatformViewLink), findsOneWidget);
openCompleter.complete();
await openFuture;
} finally {
debugDefaultTargetPlatformOverride = null;
}
});
testWidgets('playerId 就位与 fit 变化会同步 resize mode 到 native', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
final resizeModes = <String>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(methodChannel, (call) async {
switch (call.method) {
case 'create':
return <String, dynamic>{
'playerId': 7,
'ffmpegAvailable': true,
'ffmpegVersion': 'test-ffmpeg',
};
case 'open':
case 'setVolume':
case 'dispose':
case 'replayKeyState':
return null;
case 'setResizeMode':
expect(call.arguments, containsPair('playerId', 7));
resizeModes.add(
(call.arguments as Map<Object?, Object?>)['mode']! as String,
);
return null;
}
fail('Unexpected media3 method call: ${call.method}');
});
try {
final engine = Media3PlayerEngine(isAndroid: true);
final fit = ValueNotifier<BoxFit>(BoxFit.contain);
addTearDown(engine.dispose);
addTearDown(fit.dispose);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Builder(
builder: (context) =>
SizedBox.expand(child: engine.buildVideoView(context, fit)),
),
),
);
await engine.open('http://127.0.0.1/video.mkv');
await tester.pump();
expect(resizeModes, <String>['fit']);
fit.value = BoxFit.fill;
await tester.pump();
expect(resizeModes, <String>['fit', 'fill']);
fit.value = BoxFit.cover;
await tester.pump();
expect(resizeModes, <String>['fit', 'fill', 'zoom']);
fit.value = BoxFit.contain;
await tester.pump();
expect(resizeModes, <String>['fit', 'fill', 'zoom', 'fit']);
} finally {
debugDefaultTargetPlatformOverride = null;
}
});
});
group('Media3 native deferred prepare', () {
test('源码包含 surface 到达超时和 pending open 取消完成逻辑', () {
final source = File(
'plugins/media3_engine/android/src/main/kotlin/com/smplayer/media3_engine/Media3PlayerInstance.kt',
).readAsStringSync();
expect(source, contains('SURFACE_ATTACH_TIMEOUT_MS'));
expect(source, contains('surface not attached within timeout'));
expect(source, contains('cancelPendingOpen'));
expect(source, contains('open_cancelled'));
expect(source, contains('clearPendingSurfaceTimeout'));
});
test('MethodChannel 暴露 replayKeyState', () {
final source = File(
'plugins/media3_engine/android/src/main/kotlin/com/smplayer/media3_engine/Media3EnginePlugin.kt',
).readAsStringSync();
expect(source, contains('"replayKeyState"'));
expect(source, contains('player.replayKeyState()'));
});
});
group('Media3 single-flight _ensureCreated', () {
test('并发 prewarmNative + open 只执行一次 native create', () async {
int createCallCount = 0;
final createCompleter = Completer<Object?>();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(methodChannel, (call) async {
switch (call.method) {
case 'create':
createCallCount++;
return createCompleter.future;
case 'open':
expect(
(call.arguments as Map)['playerId'],
equals(42),
reason: 'open must use the playerId returned by create',
);
return null;
case 'setVolume':
case 'replayKeyState':
case 'dispose':
return null;
}
fail('Unexpected media3 method call: ${call.method}');
});
final engine = Media3PlayerEngine(isAndroid: true);
addTearDown(engine.dispose);
final prewarmFuture = engine.prewarmNative();
final openFuture = engine.open('http://127.0.0.1/video.mkv');
expect(
createCallCount,
equals(1),
reason: 'concurrent prewarm + open should share one create call',
);
createCompleter.complete(<String, dynamic>{
'playerId': 42,
'ffmpegAvailable': true,
'ffmpegVersion': 'test-ffmpeg',
});
await prewarmFuture;
await openFuture;
expect(
createCallCount,
equals(1),
reason: 'create must have been called exactly once',
);
});
test('dispose 期间 create 返回时释放孤儿 native player', () async {
final createCompleter = Completer<Object?>();
final disposedPlayerIds = <int>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(methodChannel, (call) async {
switch (call.method) {
case 'create':
return createCompleter.future;
case 'dispose':
final disposePlayerId =
(call.arguments as Map)['playerId'] as int;
disposedPlayerIds.add(disposePlayerId);
return null;
case 'setVolume':
return null;
}
fail('Unexpected media3 method call: ${call.method}');
});
final engine = Media3PlayerEngine(isAndroid: true);
final prewarmFuture = engine.prewarmNative();
await engine.dispose();
createCompleter.complete(<String, dynamic>{
'playerId': 99,
'ffmpegAvailable': true,
'ffmpegVersion': 'test-ffmpeg',
});
await prewarmFuture;
expect(
disposedPlayerIds,
contains(99),
reason: 'orphan native player created after dispose must be released',
);
});
test('create 失败后允许下次重试', () async {
int createAttempts = 0;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(methodChannel, (call) async {
switch (call.method) {
case 'create':
createAttempts++;
if (createAttempts == 1) {
throw PlatformException(
code: 'INIT_FAILED',
message: 'simulated first-create failure',
);
}
return <String, dynamic>{
'playerId': 7,
'ffmpegAvailable': true,
'ffmpegVersion': 'test-ffmpeg',
};
case 'open':
case 'setVolume':
case 'replayKeyState':
case 'dispose':
return null;
}
fail('Unexpected media3 method call: ${call.method}');
});
final engine = Media3PlayerEngine(isAndroid: true);
addTearDown(engine.dispose);
expect(engine.prewarmNative(), throwsA(isA<PlatformException>()));
await Future<void>.delayed(Duration.zero);
await engine.open('http://127.0.0.1/video.mkv');
expect(createAttempts, equals(2), reason: 'retry after first failure');
});
});
}
@@ -0,0 +1,56 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:smplayer/core/infra/player_engine/media_kit_player_engine.dart';
void main() {
group('isMpvNetworkError', () {
test('命中 tcp:/tls: 前缀', () {
expect(
isMpvNetworkError('tcp: ffurl_read returned 0xffffff99'),
isTrue,
);
expect(isMpvNetworkError('tls: error during handshake'), isTrue);
});
test('命中 HTTP open 失败', () {
expect(
isMpvNetworkError(
'Failed to open https://cdn.example/v.mkv.',
),
isTrue,
);
});
test('命中连接类关键词', () {
expect(isMpvNetworkError('connection reset by peer'), isTrue);
expect(isMpvNetworkError('Connection refused'), isTrue);
expect(isMpvNetworkError('connection timed out'), isTrue);
expect(isMpvNetworkError('Network is unreachable'), isTrue);
expect(isMpvNetworkError('Host not found'), isTrue);
expect(isMpvNetworkError('name resolution failed'), isTrue);
});
test('命中 TLS 与网络超时', () {
expect(isMpvNetworkError('TLS handshake failed'), isTrue);
expect(isMpvNetworkError('connection timed out'), isTrue);
});
test('不命中非网络 timeout 文本', () {
expect(isMpvNetworkError('decoder timeout waiting for frame'), isFalse);
expect(isMpvNetworkError('cplayer timeout while waiting'), isFalse);
});
test('命中 HTTP 错误码', () {
expect(isMpvNetworkError('http error 403'), isTrue);
expect(isMpvNetworkError('http error 502'), isTrue);
});
test('不命中解码器错误', () {
expect(isMpvNetworkError('Error while decoding frame'), isFalse);
expect(isMpvNetworkError('Failed to open decoder'), isFalse);
});
test('空串不命中', () {
expect(isMpvNetworkError(''), isFalse);
});
});
}
@@ -0,0 +1,41 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:smplayer/core/infra/player_engine/player_engine.dart';
void main() {
group('streamOpenErrorHttpStatus', () {
test('从回填的 error 串解析状态码', () {
expect(
streamOpenErrorHttpStatus('Failed to open http://h/v.mp4. (http 403)'),
403,
);
expect(streamOpenErrorHttpStatus('boom (http 502)'), 502);
});
test('无状态码返回 null', () {
expect(streamOpenErrorHttpStatus('Failed to open http://h/v.mp4.'), null);
expect(streamOpenErrorHttpStatus('connection reset by peer'), null);
expect(streamOpenErrorHttpStatus(''), null);
});
});
group('isTerminalStreamOpenError', () {
test('客户端 4xx 视为确定性失败(跳过重连)', () {
expect(isTerminalStreamOpenError('open failed (http 403)'), isTrue);
expect(isTerminalStreamOpenError('open failed (http 404)'), isTrue);
expect(isTerminalStreamOpenError('open failed (http 410)'), isTrue);
expect(isTerminalStreamOpenError('open failed (http 401)'), isTrue);
});
test('瞬时类 408/429 仍允许重连', () {
expect(isTerminalStreamOpenError('open failed (http 408)'), isFalse);
expect(isTerminalStreamOpenError('open failed (http 429)'), isFalse);
});
test('5xx 与无状态码不视为确定性失败', () {
expect(isTerminalStreamOpenError('open failed (http 502)'), isFalse);
expect(isTerminalStreamOpenError('open failed (http 503)'), isFalse);
expect(isTerminalStreamOpenError('Failed to open http://h/v.mp4.'), isFalse);
expect(isTerminalStreamOpenError('connection reset by peer'), isFalse);
});
});
}