Initial commit
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/contracts/playback.dart';
|
||||
import 'package:smplayer/features/player/session/initial_subtitle_resolver.dart';
|
||||
import 'package:smplayer/providers/subtitle_settings_provider.dart';
|
||||
|
||||
const subtitles = <EmbySubtitleTrack>[
|
||||
EmbySubtitleTrack(index: 2, language: 'chi', title: '简体中文'),
|
||||
EmbySubtitleTrack(index: 3, language: 'eng', title: 'English'),
|
||||
];
|
||||
|
||||
RememberedSubtitleTrack rememberedSubtitle({
|
||||
int streamIndex = 2,
|
||||
String? language,
|
||||
String? title,
|
||||
}) {
|
||||
return RememberedSubtitleTrack(
|
||||
streamIndex: streamIndex,
|
||||
language: language,
|
||||
title: title,
|
||||
updatedAt: DateTime(2026),
|
||||
);
|
||||
}
|
||||
|
||||
InitialSubtitleSelection resolveSelection({
|
||||
List<EmbySubtitleTrack> availableSubtitles = subtitles,
|
||||
int? preferredSubtitleStreamIndex,
|
||||
int? defaultSubtitleStreamIndex,
|
||||
RememberedSubtitleTrack? remembered,
|
||||
}) {
|
||||
return resolveInitialSubtitleSelection(
|
||||
subtitles: availableSubtitles,
|
||||
preferredSubtitleStreamIndex: preferredSubtitleStreamIndex,
|
||||
defaultSubtitleStreamIndex: defaultSubtitleStreamIndex,
|
||||
rememberedSubtitle: remembered,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('显式 route 轨优先于记忆与服务器默认轨', () {
|
||||
final selection = resolveSelection(
|
||||
preferredSubtitleStreamIndex: 3,
|
||||
defaultSubtitleStreamIndex: 2,
|
||||
remembered: rememberedSubtitle(streamIndex: 2),
|
||||
);
|
||||
|
||||
expect(selection.type, InitialSubtitleSelectionType.track);
|
||||
expect(selection.track?.index, 3);
|
||||
});
|
||||
|
||||
test('route -1 且没有记忆时禁用字幕', () {
|
||||
final selection = resolveSelection(
|
||||
preferredSubtitleStreamIndex: -1,
|
||||
defaultSubtitleStreamIndex: 2,
|
||||
);
|
||||
|
||||
expect(selection.type, InitialSubtitleSelectionType.disabled);
|
||||
expect(selection.track, isNull);
|
||||
});
|
||||
|
||||
test('禁用字幕记忆优先于 route -1 与服务器默认轨', () {
|
||||
final selection = resolveSelection(
|
||||
preferredSubtitleStreamIndex: -1,
|
||||
defaultSubtitleStreamIndex: 2,
|
||||
remembered: RememberedSubtitleTrack(
|
||||
streamIndex: -1,
|
||||
updatedAt: DateTime(2026),
|
||||
),
|
||||
);
|
||||
|
||||
expect(selection.type, InitialSubtitleSelectionType.disabled);
|
||||
});
|
||||
|
||||
test('跨集记忆按语言与标题匹配可用轨', () {
|
||||
final selection = resolveSelection(
|
||||
defaultSubtitleStreamIndex: 3,
|
||||
remembered: rememberedSubtitle(
|
||||
streamIndex: 99,
|
||||
language: 'CHI',
|
||||
title: '简体中文',
|
||||
),
|
||||
);
|
||||
|
||||
expect(selection.type, InitialSubtitleSelectionType.track);
|
||||
expect(selection.track?.index, 2);
|
||||
});
|
||||
|
||||
test('记忆未命中时回落服务器默认轨', () {
|
||||
final selection = resolveSelection(
|
||||
defaultSubtitleStreamIndex: 3,
|
||||
remembered: rememberedSubtitle(
|
||||
streamIndex: 99,
|
||||
language: 'jpn',
|
||||
title: '日本語',
|
||||
),
|
||||
);
|
||||
|
||||
expect(selection.type, InitialSubtitleSelectionType.track);
|
||||
expect(selection.track?.index, 3);
|
||||
});
|
||||
|
||||
test('没有 route、记忆或服务器默认轨时保持引擎状态', () {
|
||||
final selection = resolveSelection(availableSubtitles: const []);
|
||||
|
||||
expect(selection.type, InitialSubtitleSelectionType.unchanged);
|
||||
expect(selection.track, isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/infra/player_engine/media3_player_engine.dart';
|
||||
|
||||
void main() {
|
||||
group('isMedia3NetworkError', () {
|
||||
test('命中所有 ERROR_CODE_IO_* 网络类错误', () {
|
||||
expect(
|
||||
isMedia3NetworkError('ERROR_CODE_IO_NETWORK_CONNECTION_FAILED'),
|
||||
isTrue,
|
||||
);
|
||||
expect(isMedia3NetworkError('ERROR_CODE_IO_BAD_HTTP_STATUS'), isTrue);
|
||||
expect(isMedia3NetworkError('ERROR_CODE_IO_FILE_NOT_FOUND'), isTrue);
|
||||
});
|
||||
|
||||
test('反直觉边界:TIMEOUT 不带 IO_ 前缀 → 不命中(走宽限等待)', () {
|
||||
expect(isMedia3NetworkError('ERROR_CODE_TIMEOUT'), isFalse);
|
||||
});
|
||||
|
||||
test('解码/解析类错误不命中', () {
|
||||
expect(isMedia3NetworkError('ERROR_CODE_DECODING_FAILED'), isFalse);
|
||||
expect(
|
||||
isMedia3NetworkError('ERROR_CODE_PARSING_CONTAINER_MALFORMED'),
|
||||
isFalse,
|
||||
);
|
||||
expect(isMedia3NetworkError('playback_error'), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/contracts/cancellation.dart';
|
||||
import 'package:smplayer/core/infra/player_engine/player_engine.dart';
|
||||
import 'package:smplayer/features/player/session/player_engine_holder.dart';
|
||||
import 'package:smplayer/features/player/session/player_engine_resolution.dart';
|
||||
|
||||
void main() {
|
||||
group('PlayerEngineHolder', () {
|
||||
test('同 kind 复用:第二次 acquire 不再调 create', () async {
|
||||
final holder = PlayerEngineHolder();
|
||||
var createCalls = 0;
|
||||
_FakeEngine factory(String tag) {
|
||||
createCalls++;
|
||||
return _FakeEngine(tag);
|
||||
}
|
||||
|
||||
final first = await holder.acquire(
|
||||
PlayerEngineKind.media3,
|
||||
() => factory('a'),
|
||||
);
|
||||
final second = await holder.acquire(
|
||||
PlayerEngineKind.media3,
|
||||
() => factory('b'),
|
||||
);
|
||||
|
||||
expect(createCalls, 1);
|
||||
expect(identical(first, second), isTrue);
|
||||
expect((second as _FakeEngine).tag, 'a');
|
||||
});
|
||||
|
||||
test('异 kind dispose+create:旧引擎先 dispose 再新建', () async {
|
||||
final holder = PlayerEngineHolder();
|
||||
final disposeOrder = <String>[];
|
||||
final acquireOrder = <String>[];
|
||||
|
||||
final fvp = _FakeEngine(
|
||||
'fvp',
|
||||
onDispose: () async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
disposeOrder.add('fvp');
|
||||
},
|
||||
);
|
||||
await holder.acquire(PlayerEngineKind.fvp, () => fvp);
|
||||
acquireOrder.add('fvp');
|
||||
|
||||
final media3 = _FakeEngine('media3');
|
||||
final second = await holder.acquire(PlayerEngineKind.media3, () {
|
||||
acquireOrder.add('media3-create');
|
||||
return media3;
|
||||
});
|
||||
|
||||
expect(disposeOrder, ['fvp']);
|
||||
expect(acquireOrder, ['fvp', 'media3-create']);
|
||||
expect(second, media3);
|
||||
expect(fvp.disposed, isTrue);
|
||||
expect(media3.disposed, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeEngine implements PlayerEngine {
|
||||
final String tag;
|
||||
final Future<void> Function()? onDispose;
|
||||
bool disposed = false;
|
||||
_FakeEngine(this.tag, {this.onDispose});
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
if (disposed) return;
|
||||
if (onDispose != null) await onDispose!();
|
||||
disposed = true;
|
||||
}
|
||||
|
||||
@override
|
||||
String get displayName => tag;
|
||||
|
||||
@override
|
||||
bool get usesPlatformSurface => false;
|
||||
|
||||
@override
|
||||
Future<int?> totalRxBytes() async => null;
|
||||
|
||||
@override
|
||||
bool isNetworkStreamError(String error) => false;
|
||||
|
||||
@override
|
||||
void configureHardwareDecoding(String mode) {}
|
||||
|
||||
@override
|
||||
Future<void> open(
|
||||
String url, {
|
||||
Duration start = Duration.zero,
|
||||
bool play = true,
|
||||
Map<String, String>? headers,
|
||||
CancellationToken? cancel,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
Future<void> play() async {}
|
||||
@override
|
||||
Future<void> pause() async {}
|
||||
@override
|
||||
Future<void> stop() async {}
|
||||
@override
|
||||
Future<void> seek(Duration position) async {}
|
||||
@override
|
||||
Future<void> setRate(double rate) async {}
|
||||
@override
|
||||
Future<void> setVolume(double volume) async {}
|
||||
@override
|
||||
Future<void> toggleMute() async {}
|
||||
@override
|
||||
Future<void> setAudioTrack(AudioTrack track) async {}
|
||||
@override
|
||||
Future<void> setSubtitleTrack(SubtitleTrack track) async {}
|
||||
|
||||
@override
|
||||
void setNativeSubtitleRendering(bool enabled) {}
|
||||
|
||||
@override
|
||||
void setSubtitleStyle({
|
||||
required double fontSize,
|
||||
required double bgOpacity,
|
||||
required int position,
|
||||
}) {}
|
||||
|
||||
@override
|
||||
Widget buildVideoView(BuildContext context, ValueListenable<BoxFit> fit) =>
|
||||
const SizedBox.shrink();
|
||||
|
||||
Future<String> getProperty(String name) async => '';
|
||||
|
||||
@override
|
||||
PlayerState get state => PlayerState();
|
||||
|
||||
@override
|
||||
PlayerStreams get stream => PlayerStreams();
|
||||
|
||||
@override
|
||||
ValueListenable<int?> get textureId => ValueNotifier<int?>(null);
|
||||
|
||||
@override
|
||||
ValueListenable<int> get textureVersion => ValueNotifier<int>(0);
|
||||
|
||||
@override
|
||||
ValueNotifier<ui.Size?> get videoSize => ValueNotifier<ui.Size?>(null);
|
||||
|
||||
@override
|
||||
ValueListenable<PlaybackStats?> get debugStats =>
|
||||
ValueNotifier<PlaybackStats?>(null);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/features/player/session/player_engine_resolution.dart';
|
||||
import 'package:smplayer/providers/player_engine_override_provider.dart';
|
||||
|
||||
void main() {
|
||||
group('resolvePlayerEngineKind - media3 override', () {
|
||||
test('Android + 非 DV/ProRes → media3', () {
|
||||
expect(
|
||||
resolvePlayerEngineKind(
|
||||
PlayerEngineOverride.media3,
|
||||
isDolbyVision: false,
|
||||
videoCodec: 'h264',
|
||||
isAndroid: true,
|
||||
),
|
||||
PlayerEngineKind.media3,
|
||||
);
|
||||
});
|
||||
|
||||
test('桌面 → mpv(不论 DV / codec)', () {
|
||||
expect(
|
||||
resolvePlayerEngineKind(
|
||||
PlayerEngineOverride.media3,
|
||||
isDolbyVision: false,
|
||||
videoCodec: 'h264',
|
||||
isAndroid: false,
|
||||
),
|
||||
PlayerEngineKind.mpv,
|
||||
);
|
||||
expect(
|
||||
resolvePlayerEngineKind(
|
||||
PlayerEngineOverride.media3,
|
||||
isDolbyVision: true,
|
||||
videoCodec: 'prores',
|
||||
isAndroid: false,
|
||||
),
|
||||
PlayerEngineKind.mpv,
|
||||
);
|
||||
});
|
||||
|
||||
test('Android + DV → media3(用户显式选 media3 时直通 DV)', () {
|
||||
expect(
|
||||
resolvePlayerEngineKind(
|
||||
PlayerEngineOverride.media3,
|
||||
isDolbyVision: true,
|
||||
videoCodec: 'hevc',
|
||||
isAndroid: true,
|
||||
),
|
||||
PlayerEngineKind.media3,
|
||||
);
|
||||
});
|
||||
|
||||
test('Android + ProRes → fvp(MediaCodec 不解 ProRes)', () {
|
||||
expect(
|
||||
resolvePlayerEngineKind(
|
||||
PlayerEngineOverride.media3,
|
||||
isDolbyVision: false,
|
||||
videoCodec: 'prores',
|
||||
isAndroid: true,
|
||||
),
|
||||
PlayerEngineKind.fvp,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('resolvePlayerEngineKind - auto behavior', () {
|
||||
test('auto + Android + 非 DV/ProRes → media3', () {
|
||||
expect(
|
||||
resolvePlayerEngineKind(
|
||||
PlayerEngineOverride.auto,
|
||||
isDolbyVision: false,
|
||||
videoCodec: 'h264',
|
||||
isAndroid: true,
|
||||
),
|
||||
PlayerEngineKind.media3,
|
||||
);
|
||||
});
|
||||
|
||||
test('auto + Android + DV → media3', () {
|
||||
expect(
|
||||
resolvePlayerEngineKind(
|
||||
PlayerEngineOverride.auto,
|
||||
isDolbyVision: true,
|
||||
videoCodec: 'hevc',
|
||||
isAndroid: true,
|
||||
),
|
||||
PlayerEngineKind.media3,
|
||||
);
|
||||
});
|
||||
|
||||
test('auto + Android + ProRes → fvp', () {
|
||||
expect(
|
||||
resolvePlayerEngineKind(
|
||||
PlayerEngineOverride.auto,
|
||||
isDolbyVision: false,
|
||||
videoCodec: 'prores',
|
||||
isAndroid: true,
|
||||
),
|
||||
PlayerEngineKind.fvp,
|
||||
);
|
||||
});
|
||||
|
||||
test('auto + 桌面 → mpv', () {
|
||||
expect(
|
||||
resolvePlayerEngineKind(
|
||||
PlayerEngineOverride.auto,
|
||||
isDolbyVision: false,
|
||||
videoCodec: 'h264',
|
||||
isAndroid: false,
|
||||
),
|
||||
PlayerEngineKind.mpv,
|
||||
);
|
||||
});
|
||||
|
||||
test('auto + 桌面 DV → fvp', () {
|
||||
expect(
|
||||
resolvePlayerEngineKind(
|
||||
PlayerEngineOverride.auto,
|
||||
isDolbyVision: true,
|
||||
videoCodec: 'hevc',
|
||||
isAndroid: false,
|
||||
),
|
||||
PlayerEngineKind.fvp,
|
||||
);
|
||||
});
|
||||
|
||||
test('fvp override → 总是 fvp', () {
|
||||
expect(
|
||||
resolvePlayerEngineKind(
|
||||
PlayerEngineOverride.fvp,
|
||||
isDolbyVision: false,
|
||||
videoCodec: 'h264',
|
||||
isAndroid: false,
|
||||
),
|
||||
PlayerEngineKind.fvp,
|
||||
);
|
||||
});
|
||||
|
||||
test('mpv override → 总是 mpv(即便 DV)', () {
|
||||
expect(
|
||||
resolvePlayerEngineKind(
|
||||
PlayerEngineOverride.mpv,
|
||||
isDolbyVision: true,
|
||||
videoCodec: 'hevc',
|
||||
isAndroid: true,
|
||||
),
|
||||
PlayerEngineKind.mpv,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('playerEngineKindFromDisplayName', () {
|
||||
test('media3 → PlayerEngineKind.media3', () {
|
||||
expect(
|
||||
playerEngineKindFromDisplayName('media3'),
|
||||
PlayerEngineKind.media3,
|
||||
);
|
||||
});
|
||||
|
||||
test('fvp / mpv 仍可反查', () {
|
||||
expect(playerEngineKindFromDisplayName('fvp'), PlayerEngineKind.fvp);
|
||||
expect(playerEngineKindFromDisplayName('mpv'), PlayerEngineKind.mpv);
|
||||
});
|
||||
|
||||
test('未知 displayName → null', () {
|
||||
expect(playerEngineKindFromDisplayName('unknown'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('nextAutoFallbackEngine', () {
|
||||
test('auto + media3 失败(非 DV)→ mpv', () {
|
||||
expect(
|
||||
nextAutoFallbackEngine(
|
||||
override: PlayerEngineOverride.auto,
|
||||
failedEngineKind: PlayerEngineKind.media3,
|
||||
isDolbyVision: false,
|
||||
),
|
||||
PlayerEngineKind.mpv,
|
||||
);
|
||||
});
|
||||
|
||||
test('auto + media3 失败(DV)→ 跳过 mpv 直达 fvp', () {
|
||||
expect(
|
||||
nextAutoFallbackEngine(
|
||||
override: PlayerEngineOverride.auto,
|
||||
failedEngineKind: PlayerEngineKind.media3,
|
||||
isDolbyVision: true,
|
||||
),
|
||||
PlayerEngineKind.fvp,
|
||||
);
|
||||
});
|
||||
|
||||
test('auto + mpv 失败 → fvp', () {
|
||||
expect(
|
||||
nextAutoFallbackEngine(
|
||||
override: PlayerEngineOverride.auto,
|
||||
failedEngineKind: PlayerEngineKind.mpv,
|
||||
isDolbyVision: false,
|
||||
),
|
||||
PlayerEngineKind.fvp,
|
||||
);
|
||||
});
|
||||
|
||||
test('auto + fvp 失败 → 无回退', () {
|
||||
expect(
|
||||
nextAutoFallbackEngine(
|
||||
override: PlayerEngineOverride.auto,
|
||||
failedEngineKind: PlayerEngineKind.fvp,
|
||||
isDolbyVision: true,
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('非 auto 不自动回退', () {
|
||||
expect(
|
||||
nextAutoFallbackEngine(
|
||||
override: PlayerEngineOverride.media3,
|
||||
failedEngineKind: PlayerEngineKind.media3,
|
||||
isDolbyVision: true,
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('未知失败引擎不自动回退', () {
|
||||
expect(
|
||||
nextAutoFallbackEngine(
|
||||
override: PlayerEngineOverride.auto,
|
||||
failedEngineKind: null,
|
||||
isDolbyVision: false,
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:smplayer/features/player/session/relink_scheduler.dart';
|
||||
|
||||
void main() {
|
||||
group('RelinkScheduler', () {
|
||||
test('schedule cancels pending sustain timer (attempts must accumulate)',
|
||||
() {
|
||||
fakeAsync((async) {
|
||||
final s = RelinkScheduler();
|
||||
var gaveUp = false;
|
||||
|
||||
void failOnce() {
|
||||
s.schedule(onRelink: (_) {}, onGiveUp: () => gaveUp = true);
|
||||
|
||||
async.flushTimers(flushPeriodicTimers: false);
|
||||
s.armSustain(sustain: const Duration(seconds: 10));
|
||||
|
||||
async.elapse(const Duration(seconds: 5));
|
||||
}
|
||||
|
||||
|
||||
failOnce();
|
||||
failOnce();
|
||||
failOnce();
|
||||
expect(gaveUp, isFalse);
|
||||
s.schedule(onRelink: (_) {}, onGiveUp: () => gaveUp = true);
|
||||
expect(gaveUp, isTrue);
|
||||
s.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('sustained playback resets attempts', () {
|
||||
fakeAsync((async) {
|
||||
final s = RelinkScheduler();
|
||||
s.schedule(onRelink: (_) {}, onGiveUp: () {});
|
||||
async.flushTimers(flushPeriodicTimers: false);
|
||||
expect(s.attempts, 1);
|
||||
s.armSustain(sustain: const Duration(seconds: 10));
|
||||
async.elapse(const Duration(seconds: 11));
|
||||
expect(s.attempts, 0);
|
||||
s.dispose();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/features/player/session/stall_detector.dart';
|
||||
|
||||
void main() {
|
||||
group('StallDetector', () {
|
||||
test('有字节统计时下载继续便不判定停滞', () {
|
||||
final detector = StallDetector(threshold: const Duration(seconds: 10));
|
||||
|
||||
expect(
|
||||
detector.update(
|
||||
position: const Duration(seconds: 5),
|
||||
bufferedPosition: const Duration(seconds: 5),
|
||||
bytesDownloaded: 100,
|
||||
isActivelyPlaying: true,
|
||||
now: Duration.zero,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
detector.update(
|
||||
position: const Duration(seconds: 5),
|
||||
bufferedPosition: const Duration(seconds: 5),
|
||||
bytesDownloaded: 200,
|
||||
isActivelyPlaying: true,
|
||||
now: const Duration(seconds: 12),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('无字节统计时缓冲终点继续前进便不判定停滞', () {
|
||||
final detector = StallDetector(threshold: const Duration(seconds: 10));
|
||||
|
||||
detector.update(
|
||||
position: const Duration(seconds: 5),
|
||||
bufferedPosition: const Duration(seconds: 5),
|
||||
bytesDownloaded: null,
|
||||
isActivelyPlaying: true,
|
||||
now: Duration.zero,
|
||||
);
|
||||
expect(
|
||||
detector.update(
|
||||
position: const Duration(seconds: 5),
|
||||
bufferedPosition: const Duration(seconds: 8),
|
||||
bytesDownloaded: null,
|
||||
isActivelyPlaying: true,
|
||||
now: const Duration(seconds: 12),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('累计字节首次变为可用时视为传输进展', () {
|
||||
final detector = StallDetector(threshold: const Duration(seconds: 10));
|
||||
|
||||
detector.update(
|
||||
position: const Duration(seconds: 5),
|
||||
bufferedPosition: const Duration(seconds: 5),
|
||||
bytesDownloaded: null,
|
||||
isActivelyPlaying: true,
|
||||
now: Duration.zero,
|
||||
);
|
||||
expect(
|
||||
detector.update(
|
||||
position: const Duration(seconds: 5),
|
||||
bufferedPosition: const Duration(seconds: 5),
|
||||
bytesDownloaded: 100,
|
||||
isActivelyPlaying: true,
|
||||
now: const Duration(seconds: 12),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('无字节统计且仍有明显缓冲余量时不重连', () {
|
||||
final detector = StallDetector(threshold: const Duration(seconds: 10));
|
||||
|
||||
detector.update(
|
||||
position: const Duration(seconds: 5),
|
||||
bufferedPosition: const Duration(seconds: 20),
|
||||
bytesDownloaded: null,
|
||||
isActivelyPlaying: true,
|
||||
now: Duration.zero,
|
||||
);
|
||||
expect(
|
||||
detector.update(
|
||||
position: const Duration(seconds: 5),
|
||||
bufferedPosition: const Duration(seconds: 20),
|
||||
bytesDownloaded: null,
|
||||
isActivelyPlaying: true,
|
||||
now: const Duration(seconds: 12),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('所有可用进展信号停止到阈值后只触发一次', () {
|
||||
final detector = StallDetector(threshold: const Duration(seconds: 10));
|
||||
|
||||
detector.update(
|
||||
position: const Duration(seconds: 5),
|
||||
bufferedPosition: const Duration(seconds: 5),
|
||||
bytesDownloaded: null,
|
||||
isActivelyPlaying: true,
|
||||
now: Duration.zero,
|
||||
);
|
||||
expect(
|
||||
detector.update(
|
||||
position: const Duration(seconds: 5),
|
||||
bufferedPosition: const Duration(seconds: 5),
|
||||
bytesDownloaded: null,
|
||||
isActivelyPlaying: true,
|
||||
now: const Duration(seconds: 10),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
detector.update(
|
||||
position: const Duration(seconds: 5),
|
||||
bufferedPosition: const Duration(seconds: 5),
|
||||
bytesDownloaded: null,
|
||||
isActivelyPlaying: true,
|
||||
now: const Duration(seconds: 20),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/contracts/playback.dart';
|
||||
import 'package:smplayer/features/player/session/initial_subtitle_resolver.dart';
|
||||
import 'package:smplayer/providers/subtitle_settings_provider.dart';
|
||||
|
||||
RememberedSubtitleTrack _remembered({
|
||||
int streamIndex = 3,
|
||||
String? language,
|
||||
String? title,
|
||||
}) {
|
||||
return RememberedSubtitleTrack(
|
||||
streamIndex: streamIndex,
|
||||
language: language,
|
||||
title: title,
|
||||
updatedAt: DateTime(2026),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
const embedded = [
|
||||
EmbySubtitleTrack(index: 2, language: 'chi', title: '简体中文'),
|
||||
EmbySubtitleTrack(index: 3, language: 'chi', title: '繁體中文'),
|
||||
EmbySubtitleTrack(index: 4, language: 'eng', title: 'English'),
|
||||
EmbySubtitleTrack(index: 5, title: 'Signs & Songs'),
|
||||
];
|
||||
|
||||
test('lang + title 精确命中(大小写不敏感)', () {
|
||||
final r = _remembered(language: 'CHI', title: '繁體中文');
|
||||
expect(matchRememberedSubtitle(r, embedded), 3);
|
||||
});
|
||||
|
||||
test('language 为 null 时按 title 命中', () {
|
||||
final r = _remembered(streamIndex: 99, title: 'signs & songs');
|
||||
expect(matchRememberedSubtitle(r, embedded), 5);
|
||||
});
|
||||
|
||||
test('title 不匹配时回落仅 lang 宽松匹配', () {
|
||||
final r = _remembered(language: 'chi', title: '不存在的标题');
|
||||
expect(matchRememberedSubtitle(r, embedded), 2);
|
||||
});
|
||||
|
||||
test('lang/title 全空时按 streamIndex 直配', () {
|
||||
final r = _remembered(streamIndex: 4);
|
||||
expect(matchRememberedSubtitle(r, embedded), 4);
|
||||
});
|
||||
|
||||
test('全部 miss 返回 null', () {
|
||||
final r = _remembered(streamIndex: 99, language: 'jpn', title: '日本語');
|
||||
expect(matchRememberedSubtitle(r, embedded), isNull);
|
||||
});
|
||||
|
||||
test('空轨道列表返回 null', () {
|
||||
expect(matchRememberedSubtitle(_remembered(), const []), isNull);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user