Initial commit
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/contracts/playback.dart';
|
||||
import 'package:smplayer/core/domain/ports/emby_gateway.dart';
|
||||
import 'package:smplayer/features/player/services/emby_playback_reporter.dart';
|
||||
|
||||
class _CountingEmbyGateway implements EmbyGateway {
|
||||
int startedCount = 0;
|
||||
int progressCount = 0;
|
||||
int stoppedCount = 0;
|
||||
|
||||
@override
|
||||
Future<void> reportPlaybackProgress({
|
||||
required AuthedRequestContext ctx,
|
||||
required PlaybackReportPayload payload,
|
||||
}) async {
|
||||
progressCount++;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> reportPlaybackStarted({
|
||||
required AuthedRequestContext ctx,
|
||||
required PlaybackReportPayload payload,
|
||||
}) async {
|
||||
startedCount++;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> reportPlaybackStopped({
|
||||
required AuthedRequestContext ctx,
|
||||
required PlaybackReportPayload payload,
|
||||
}) async {
|
||||
stoppedCount++;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('禁用的上报器不会向 Emby 发送任何播放事件', () async {
|
||||
final gateway = _CountingEmbyGateway();
|
||||
final reporter = EmbyPlaybackReporter(
|
||||
gateway: gateway,
|
||||
context: const AuthedRequestContext(baseUrl: '', token: '', userId: ''),
|
||||
enabled: false,
|
||||
);
|
||||
addTearDown(reporter.dispose);
|
||||
const payload = PlaybackReportPayload(itemId: 'direct', positionTicks: 0);
|
||||
|
||||
await reporter.reportStarted(payload);
|
||||
await reporter.reportProgress(payload);
|
||||
await reporter.reportStopped(payload);
|
||||
reporter.startProgressTimer(
|
||||
({required bool isPaused, String? eventName}) => PlaybackReportPayload(
|
||||
itemId: 'direct',
|
||||
positionTicks: 0,
|
||||
isPaused: isPaused,
|
||||
eventName: eventName,
|
||||
),
|
||||
);
|
||||
|
||||
expect(gateway.startedCount, 0);
|
||||
expect(gateway.progressCount, 0);
|
||||
expect(gateway.stoppedCount, 0);
|
||||
expect(reporter.startedReported, isFalse);
|
||||
expect(reporter.stoppedReported, isFalse);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/features/player/widgets/player_clock_text.dart';
|
||||
|
||||
void main() {
|
||||
test('formatClock 补零到两位并用 24 小时制', () {
|
||||
expect(formatClock(DateTime(2026, 7, 2, 9, 5)), '09:05');
|
||||
expect(formatClock(DateTime(2026, 7, 2, 22, 47)), '22:47');
|
||||
expect(formatClock(DateTime(2026, 7, 2, 0, 0)), '00:00');
|
||||
expect(formatClock(DateTime(2026, 7, 2, 23, 59)), '23:59');
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/features/player/widgets/android/android_bottom_transport_controls.dart';
|
||||
|
||||
import '../../../../helpers/fake_player_engine.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('renders prev play next order', (tester) async {
|
||||
final player = FakePlayerEngine(playing: true);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Center(
|
||||
child: AndroidBottomTransportControls(
|
||||
player: player,
|
||||
hasPrev: true,
|
||||
hasNext: true,
|
||||
onPrevItem: () {},
|
||||
onNextItem: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byIcon(Icons.skip_previous), findsOneWidget);
|
||||
expect(find.byIcon(Icons.pause), findsOneWidget);
|
||||
expect(find.byIcon(Icons.skip_next), findsOneWidget);
|
||||
|
||||
final prevCenter = tester.getCenter(find.byIcon(Icons.skip_previous));
|
||||
final playCenter = tester.getCenter(find.byIcon(Icons.pause));
|
||||
final nextCenter = tester.getCenter(find.byIcon(Icons.skip_next));
|
||||
|
||||
expect(prevCenter.dx < playCenter.dx, isTrue);
|
||||
expect(playCenter.dx < nextCenter.dx, isTrue);
|
||||
|
||||
await player.dispose();
|
||||
});
|
||||
|
||||
testWidgets('invokes prev/next callbacks and toggles playback', (
|
||||
tester,
|
||||
) async {
|
||||
final player = FakePlayerEngine(playing: true);
|
||||
var prevTaps = 0;
|
||||
var nextTaps = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: AndroidBottomTransportControls(
|
||||
player: player,
|
||||
hasPrev: true,
|
||||
hasNext: true,
|
||||
onPrevItem: () => prevTaps++,
|
||||
onNextItem: () => nextTaps++,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.skip_previous));
|
||||
await tester.tap(find.byIcon(Icons.skip_next));
|
||||
await tester.tap(find.byIcon(Icons.pause));
|
||||
await tester.pump();
|
||||
|
||||
expect(prevTaps, 1);
|
||||
expect(nextTaps, 1);
|
||||
expect(player.pauseCalls, 1);
|
||||
expect(player.playCalls, 0);
|
||||
|
||||
await player.dispose();
|
||||
});
|
||||
|
||||
testWidgets('disables prev/next when unavailable', (tester) async {
|
||||
final player = FakePlayerEngine(playing: true);
|
||||
var prevTaps = 0;
|
||||
var nextTaps = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: AndroidBottomTransportControls(
|
||||
player: player,
|
||||
hasPrev: false,
|
||||
hasNext: false,
|
||||
onPrevItem: () => prevTaps++,
|
||||
onNextItem: () => nextTaps++,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final prevBtn = tester.widget<IconButton>(
|
||||
find.widgetWithIcon(IconButton, Icons.skip_previous),
|
||||
);
|
||||
final nextBtn = tester.widget<IconButton>(
|
||||
find.widgetWithIcon(IconButton, Icons.skip_next),
|
||||
);
|
||||
|
||||
expect(prevBtn.onPressed, isNull);
|
||||
expect(nextBtn.onPressed, isNull);
|
||||
|
||||
await player.dispose();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/features/player/widgets/android/android_gesture_feedback.dart';
|
||||
|
||||
void main() {
|
||||
Widget buildSubject({required bool seekIsForward}) {
|
||||
return MaterialApp(
|
||||
home: SizedBox(
|
||||
width: 360,
|
||||
height: 240,
|
||||
child: AndroidGestureFeedback(
|
||||
data: GestureFeedbackData(
|
||||
kind: GestureFeedbackKind.seek,
|
||||
seekTarget: const Duration(minutes: 1),
|
||||
totalDuration: const Duration(minutes: 10),
|
||||
seekIsForward: seekIsForward,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('seek preview shows rewind icon for backward seek', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(buildSubject(seekIsForward: false));
|
||||
|
||||
expect(find.byIcon(Icons.fast_rewind), findsOneWidget);
|
||||
expect(find.byIcon(Icons.fast_forward), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('seek preview shows forward icon for forward seek', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(buildSubject(seekIsForward: true));
|
||||
|
||||
expect(find.byIcon(Icons.fast_forward), findsOneWidget);
|
||||
expect(find.byIcon(Icons.fast_rewind), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('long press speed uses light background', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: SizedBox(
|
||||
width: 360,
|
||||
height: 240,
|
||||
child: AndroidGestureFeedback(
|
||||
data: GestureFeedbackData(
|
||||
kind: GestureFeedbackKind.longPressSpeed,
|
||||
speedRate: 3,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final container = tester.widget<Container>(
|
||||
find.descendant(
|
||||
of: find.byType(AndroidGestureFeedback),
|
||||
matching: find.byType(Container),
|
||||
),
|
||||
);
|
||||
final decoration = container.decoration as BoxDecoration;
|
||||
|
||||
expect(decoration.color, const Color(0x45181818));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/features/player/widgets/player_hold_speed_prompt.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('hold speed prompt uses light background', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Stack(
|
||||
children: [HoldSpeedPrompt(visible: true, isLeft: false, rate: 3)],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final container = tester.widget<Container>(
|
||||
find.descendant(
|
||||
of: find.byType(HoldSpeedPrompt),
|
||||
matching: find.byType(Container),
|
||||
),
|
||||
);
|
||||
final decoration = container.decoration as BoxDecoration;
|
||||
|
||||
expect(decoration.color, const Color(0x45181818));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/features/player/widgets/player_hold_speed_prompt.dart';
|
||||
import 'package:smplayer/features/player/widgets/player_video_widget.dart';
|
||||
|
||||
import '../../../helpers/fake_player_engine.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('keyboard hold speed prompt hides immediately on release', (
|
||||
tester,
|
||||
) async {
|
||||
final player = FakePlayerEngine(playing: true);
|
||||
final fit = ValueNotifier<BoxFit>(BoxFit.contain);
|
||||
final rates = <double>[];
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: SizedBox(
|
||||
width: 800,
|
||||
height: 450,
|
||||
child: PlayerVideoWidget(
|
||||
player: player,
|
||||
fit: fit,
|
||||
topBar: const [],
|
||||
bottomBar: const SizedBox.shrink(),
|
||||
overlays: const [],
|
||||
onControlsAreaTap: null,
|
||||
isFullscreen: false,
|
||||
onToggleFullscreen: () async {},
|
||||
isPip: false,
|
||||
keyboardEnabled: true,
|
||||
currentRate: 1,
|
||||
onSetRate: rates.add,
|
||||
suppressControlsChrome: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
expect(_promptOpacity(tester), 1);
|
||||
expect(rates.last, 3);
|
||||
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.pump();
|
||||
|
||||
expect(_promptOpacity(tester), 0);
|
||||
expect(rates.last, 1);
|
||||
|
||||
fit.dispose();
|
||||
await player.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
double _promptOpacity(WidgetTester tester) {
|
||||
return tester
|
||||
.widget<AnimatedOpacity>(
|
||||
find.descendant(
|
||||
of: find.byType(HoldSpeedPrompt),
|
||||
matching: find.byType(AnimatedOpacity),
|
||||
),
|
||||
)
|
||||
.opacity;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/infra/player_engine/player_engine.dart';
|
||||
import 'package:smplayer/features/player/widgets/track_selector_button.dart';
|
||||
|
||||
import '../../../helpers/fake_player_engine.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('音轨按钮由初始 tracks 决定显隐', (tester) async {
|
||||
final player = FakePlayerEngine(
|
||||
playing: true,
|
||||
tracks: const PlayerTracks(
|
||||
audio: [
|
||||
AudioTrack(id: '1'),
|
||||
AudioTrack(id: '2'),
|
||||
],
|
||||
),
|
||||
);
|
||||
addTearDown(player.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: AudioTracksButton(
|
||||
player: player,
|
||||
isPanelOpen: false,
|
||||
onTogglePanel: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byTooltip('音轨'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('字幕按钮响应 tracks stream 更新', (tester) async {
|
||||
final player = FakePlayerEngine(playing: true);
|
||||
addTearDown(player.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: SubtitleTracksButton(
|
||||
player: player,
|
||||
embySubtitles: const [],
|
||||
isPanelOpen: false,
|
||||
onTogglePanel: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.byTooltip('字幕'), findsNothing);
|
||||
|
||||
player.addTracks(
|
||||
const PlayerTracks(subtitle: [SubtitleTrack(id: 'embedded')]),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byTooltip('字幕'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user