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,319 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:smplayer/core/contracts/library.dart';
import 'package:smplayer/features/aggregated/aggregated_grouping.dart';
GlobalSearchServerResult _server(
String id,
String name,
List<EmbyRawItem> items,
) => GlobalSearchServerResult(
serverId: id,
serverName: name,
serverUrl: 'http://$id',
token: 'tok-$id',
userId: 'user-$id',
items: items,
);
EmbyRawItem _item(
String id,
String name, {
String? type,
Map<String, dynamic>? providerIds,
String? lastPlayedDate,
int? playbackPositionTicks,
int? runtimeTicks,
bool? played,
}) => EmbyRawItem(
Id: id,
Name: name,
Type: type,
RunTimeTicks: runtimeTicks,
UserData: playbackPositionTicks != null || played != null
? EmbyRawUserData(
PlaybackPositionTicks: playbackPositionTicks,
Played: played,
)
: null,
extra: {
if (providerIds != null) 'ProviderIds': providerIds,
if (lastPlayedDate != null) 'UserData': {'LastPlayedDate': lastPlayedDate},
},
);
void main() {
group('flatten default behavior (dedup off)', () {
test('returns all items from all servers', () {
final results = [
_server('s1', 'S1', [_item('a', 'Movie A')]),
_server('s2', 'S2', [_item('b', 'Movie B')]),
];
final entries = flatten(results);
expect(entries.length, 2);
});
test('respects serverFilter', () {
final results = [
_server('s1', 'S1', [_item('a', 'A')]),
_server('s2', 'S2', [_item('b', 'B')]),
];
final entries = flatten(results, serverFilter: 's1');
expect(entries.length, 1);
expect(entries.first.source.serverId, 's1');
});
});
group('flatten dedup on, serverFilter null', () {
test('collapses same TMDB Movie to newest LastPlayedDate', () {
final results = [
_server('s1', 'S1', [
_item(
'a',
'Movie',
type: 'Movie',
providerIds: {'Tmdb': '100'},
lastPlayedDate: '2026-06-20T10:00:00Z',
),
]),
_server('s2', 'S2', [
_item(
'b',
'Movie',
type: 'Movie',
providerIds: {'Tmdb': '100'},
lastPlayedDate: '2026-06-24T15:00:00Z',
),
]),
];
final entries = flatten(results, deduplicateAcrossServers: true);
expect(entries.length, 1);
expect(entries.first.source.serverId, 's2');
expect(entries.first.sources.map((source) => source.source.serverId), [
's2',
's1',
]);
});
test('preserves each server item and its independent playback state', () {
final results = [
_server('s1', 'S1', [
_item(
'a',
'Movie',
type: 'Movie',
providerIds: {'Tmdb': '100'},
lastPlayedDate: '2026-06-20T10:00:00Z',
playbackPositionTicks: 100,
runtimeTicks: 1000,
),
]),
_server('s2', 'S2', [
_item(
'b',
'Movie',
type: 'Movie',
providerIds: {'Tmdb': '100'},
lastPlayedDate: '2026-06-24T15:00:00Z',
playbackPositionTicks: 800,
runtimeTicks: 1000,
),
]),
];
final entry = flatten(results, deduplicateAcrossServers: true).single;
expect(entry.sources, hasLength(2));
expect(entry.sources[0].item.UserData?.PlaybackPositionTicks, 800);
expect(entry.sources[1].item.UserData?.PlaybackPositionTicks, 100);
expect(entry.sources[0].item.RunTimeTicks, 1000);
expect(entry.sources[1].item.RunTimeTicks, 1000);
});
test('keeps input order when sources have equal LastPlayedDate', () {
final results = [
_server('s1', 'S1', [
_item(
'a',
'Movie',
type: 'Movie',
providerIds: {'Tmdb': '100'},
lastPlayedDate: '2026-06-24T15:00:00Z',
),
]),
_server('s2', 'S2', [
_item(
'b',
'Movie',
type: 'Movie',
providerIds: {'Tmdb': '100'},
lastPlayedDate: '2026-06-24T15:00:00Z',
),
]),
];
final entry = flatten(results, deduplicateAcrossServers: true).single;
expect(entry.sources.map((source) => source.source.serverId), [
's1',
's2',
]);
});
});
group('flatten dedup on + serverFilter non-null', () {
test('skips dedup when server filter is active', () {
final results = [
_server('s1', 'S1', [
_item('a', 'Movie', type: 'Movie', providerIds: {'Tmdb': '100'}),
]),
_server('s2', 'S2', [
_item('b', 'Movie', type: 'Movie', providerIds: {'Tmdb': '100'}),
]),
];
final entries = flatten(
results,
serverFilter: 's1',
deduplicateAcrossServers: true,
);
expect(entries.length, 1);
expect(entries.first.source.serverId, 's1');
expect(entries.first.sources, hasLength(1));
});
});
group('no ProviderIds never deduped', () {
test('items without ProviderIds stay separate', () {
final results = [
_server('s1', 'S1', [_item('a', 'Same Name', type: 'Movie')]),
_server('s2', 'S2', [_item('b', 'Same Name', type: 'Movie')]),
];
final entries = flatten(results, deduplicateAcrossServers: true);
expect(entries.length, 2);
});
});
group('Type namespace isolation', () {
test('Movie and Series with same tmdb id stay separate', () {
final results = [
_server('s1', 'S1', [
_item('a', 'Title', type: 'Movie', providerIds: {'Tmdb': '100'}),
]),
_server('s2', 'S2', [
_item('b', 'Title', type: 'Series', providerIds: {'Tmdb': '100'}),
]),
];
final entries = flatten(results, deduplicateAcrossServers: true);
expect(entries.length, 2);
});
});
group('ProviderIds key case insensitivity', () {
test('Tmdb vs tmdb vs TMDB all match', () {
final results = [
_server('s1', 'S1', [
_item(
'a',
'M',
type: 'Movie',
providerIds: {'Tmdb': '99'},
lastPlayedDate: '2026-06-20T00:00:00Z',
),
]),
_server('s2', 'S2', [
_item(
'b',
'M',
type: 'Movie',
providerIds: {'tmdb': '99'},
lastPlayedDate: '2026-06-21T00:00:00Z',
),
]),
_server('s3', 'S3', [
_item(
'c',
'M',
type: 'Movie',
providerIds: {'TMDB': '99'},
lastPlayedDate: '2026-06-22T00:00:00Z',
),
]),
];
final entries = flatten(results, deduplicateAcrossServers: true);
expect(entries.length, 1);
expect(entries.first.source.serverId, 's3');
});
});
group('null LastPlayedDate handling', () {
test('null loses to valid date', () {
final results = [
_server('s1', 'S1', [
_item('a', 'M', type: 'Movie', providerIds: {'Tmdb': '1'}),
]),
_server('s2', 'S2', [
_item(
'b',
'M',
type: 'Movie',
providerIds: {'Tmdb': '1'},
lastPlayedDate: '2026-06-01T00:00:00Z',
),
]),
];
final entries = flatten(results, deduplicateAcrossServers: true);
expect(entries.length, 1);
expect(entries.first.source.serverId, 's2');
});
test('both null keeps first encountered', () {
final results = [
_server('s1', 'S1', [
_item('a', 'M', type: 'Movie', providerIds: {'Tmdb': '1'}),
]),
_server('s2', 'S2', [
_item('b', 'M', type: 'Movie', providerIds: {'Tmdb': '1'}),
]),
];
final entries = flatten(results, deduplicateAcrossServers: true);
expect(entries.length, 1);
expect(entries.first.source.serverId, 's1');
});
});
group('transitive matching', () {
test('A(tmdb:1) B(tmdb:1,imdb:tt2) C(imdb:tt2) collapse to one', () {
final results = [
_server('s1', 'S1', [
_item(
'a',
'M',
type: 'Movie',
providerIds: {'Tmdb': '1'},
lastPlayedDate: '2026-06-20T00:00:00Z',
),
]),
_server('s2', 'S2', [
_item(
'b',
'M',
type: 'Movie',
providerIds: {'Tmdb': '1', 'Imdb': 'tt2'},
lastPlayedDate: '2026-06-22T00:00:00Z',
),
]),
_server('s3', 'S3', [
_item(
'c',
'M',
type: 'Movie',
providerIds: {'Imdb': 'tt2'},
lastPlayedDate: '2026-06-21T00:00:00Z',
),
]),
];
final entries = flatten(results, deduplicateAcrossServers: true);
expect(entries.length, 1);
expect(entries.first.source.serverId, 's2');
});
});
}
@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:smplayer/core/contracts/library.dart';
import 'package:smplayer/features/aggregated/widgets/aggregated_media_card.dart';
void main() {
testWidgets('multi-source badge does not trigger the whole card', (
tester,
) async {
var cardTapCount = 0;
var sourceBadgeTapCount = 0;
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
home: Scaffold(
body: Center(
child: SizedBox(
width: 420,
child: AggregatedMediaCard(
item: const EmbyRawItem(Id: 'movie-1', Name: 'Movie'),
imageUrl: null,
title: 'Movie',
serverName: 'Alpha',
serverSourceCount: 2,
onTap: () => cardTapCount++,
onServerSourcesTap: () => sourceBadgeTapCount++,
),
),
),
),
),
),
);
expect(find.text('Alpha 2服'), findsOneWidget);
expect(find.byIcon(Icons.keyboard_arrow_down_rounded), findsOneWidget);
await tester.tap(find.text('Alpha 2服'));
await tester.pump();
expect(sourceBadgeTapCount, 1);
expect(cardTapCount, 0);
await tester.tap(find.text('Movie'));
await tester.pump();
expect(cardTapCount, 1);
});
testWidgets('single-source badge keeps the existing passive appearance', (
tester,
) async {
var cardTapCount = 0;
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
home: Scaffold(
body: Center(
child: SizedBox(
width: 420,
child: AggregatedMediaCard(
item: const EmbyRawItem(Id: 'movie-1', Name: 'Movie'),
imageUrl: null,
title: 'Movie',
serverName: 'Alpha',
onTap: () => cardTapCount++,
),
),
),
),
),
),
);
expect(find.text('Alpha'), findsOneWidget);
expect(find.byIcon(Icons.keyboard_arrow_down_rounded), findsNothing);
await tester.tap(find.text('Alpha'));
await tester.pump();
expect(cardTapCount, 1);
});
}
@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:smplayer/features/detail/android/android_detail_geometry.dart';
import 'package:smplayer/features/detail/widgets/detail_scroll_progress.dart';
void main() {
testWidgets('landscape banner height respects the body height limit', (
tester,
) async {
late double bannerHeight;
late double collapseExtent;
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(
size: Size(800, 400),
padding: EdgeInsets.only(top: 24),
),
child: Builder(
builder: (context) {
bannerHeight = computeAndroidDetailBannerHeight(context);
collapseExtent = computeAndroidDetailCollapseExtent(context);
return const SizedBox.shrink();
},
),
),
);
expect(bannerHeight, closeTo(206.8, 0.01));
expect(collapseExtent, closeTo(138.8, 0.01));
});
testWidgets('scroll progress reaches one at the collapse extent', (
tester,
) async {
late double halfProgress;
late double completeProgress;
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(size: Size(400, 800)),
child: Builder(
builder: (context) {
halfProgress = computeDetailScrollProgress(
context,
120,
collapseExtent: 240,
);
completeProgress = computeDetailScrollProgress(
context,
240,
collapseExtent: 240,
);
return const SizedBox.shrink();
},
),
),
);
expect(halfProgress, 0.5);
expect(completeProgress, 1.0);
});
}
@@ -0,0 +1,35 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:smplayer/core/contracts/library.dart';
import 'package:smplayer/features/detail/emby_detail_helpers.dart';
void main() {
group('detailNavTitle', () {
test('uses series name for selected episode', () {
const episode = EmbyRawItem(
Id: 'ep1',
Name: '第 1 集',
Type: 'Episode',
SeriesName: '我的剧',
);
expect(detailNavTitle(episode, null), '我的剧');
});
test('uses loaded series item before episode name', () {
const episode = EmbyRawItem(
Id: 'ep1',
Name: '第 1 集',
Type: 'Episode',
);
const series = EmbyRawItem(Id: 'series1', Name: '正片剧名', Type: 'Series');
expect(detailNavTitle(episode, series), '正片剧名');
});
test('keeps movie title', () {
const movie = EmbyRawItem(Id: 'movie1', Name: '电影名', Type: 'Movie');
expect(detailNavTitle(movie, null), '电影名');
});
});
}
@@ -0,0 +1,27 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:smplayer/core/contracts/library.dart';
import 'package:smplayer/features/detail/model/selected_episode_merge.dart';
void main() {
group('applyPlaybackReturnProgress', () {
test('overrides stale playback position', () {
const item = EmbyRawItem(
Id: 'ep1',
Name: 'Episode 1',
UserData: EmbyRawUserData(PlaybackPositionTicks: 10),
);
final updated = applyPlaybackReturnProgress(item, 500);
expect(updated.UserData?.PlaybackPositionTicks, 500);
});
test('creates UserData when detail has none', () {
const item = EmbyRawItem(Id: 'movie1', Name: 'Movie 1');
final updated = applyPlaybackReturnProgress(item, 300);
expect(updated.UserData?.PlaybackPositionTicks, 300);
});
});
}
@@ -0,0 +1,76 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:smplayer/core/contracts/library.dart';
import 'package:smplayer/features/detail/widgets/stream_selector_actions.dart';
void main() {
const chineseSubtitle = EmbyRawMediaStream(
Type: 'Subtitle',
Language: 'chi',
DisplayTitle: 'Chinese Simplified (SRT)',
);
const untitledSubtitle = EmbyRawMediaStream(Type: 'Subtitle');
const dtsAudio = EmbyRawMediaStream(
Type: 'Audio',
Language: 'eng',
DisplayTitle: 'DTS 5.1',
);
const untitledAudio = EmbyRawMediaStream(Type: 'Audio');
group('subtitleSummaryLabel', () {
test('returns 关闭 when no subtitle selected', () {
expect(subtitleSummaryLabel(const [chineseSubtitle], null), '关闭');
});
test('returns 关闭 when index is out of range', () {
expect(subtitleSummaryLabel(const [chineseSubtitle], 5), '关闭');
});
test('prefers Language over DisplayTitle for the compact button', () {
expect(subtitleSummaryLabel(const [chineseSubtitle], 0), 'chi');
});
test('falls back to positional label when stream has no metadata', () {
expect(
subtitleSummaryLabel(const [chineseSubtitle, untitledSubtitle], 1),
'字幕 2',
);
});
});
group('audioSummaryLabel', () {
test('returns 默认 when index is out of range', () {
expect(audioSummaryLabel(const [dtsAudio], 3), '默认');
});
test('prefers Language over DisplayTitle for the compact button', () {
expect(audioSummaryLabel(const [dtsAudio], 0), 'eng');
});
test('falls back to positional label when stream has no metadata', () {
expect(audioSummaryLabel(const [dtsAudio, untitledAudio], 1), '音轨 2');
});
});
group('versionSummaryLabel', () {
const namedSource = EmbyRawMediaSource(Id: 'a', Name: '4K HDR 版本');
const unnamedSource = EmbyRawMediaSource(Id: 'b');
test('uses the source name when present', () {
expect(
versionSummaryLabel(const [namedSource, unnamedSource], 0),
'4K HDR 版本',
);
});
test('falls back to positional label when the source has no name', () {
expect(
versionSummaryLabel(const [namedSource, unnamedSource], 1),
'版本 2',
);
});
test('falls back to 版本 when index is out of range', () {
expect(versionSummaryLabel(const [namedSource], 9), '版本');
});
});
}
@@ -0,0 +1,171 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:smplayer/core/contracts/library.dart';
import 'package:smplayer/features/aggregated/aggregated_grouping.dart';
import 'package:smplayer/features/history/widgets/history_source_picker.dart';
void main() {
testWidgets('shows each server independent playback progress', (
tester,
) async {
final now = DateTime(2026, 7, 11, 12);
final partiallyWatchedSource = _entrySource(
serverId: 'alpha',
serverName: 'Alpha',
itemId: 'alpha-movie',
playbackPositionTicks: 600000000,
runtimeTicks: 1200000000,
lastPlayedDate: now.subtract(const Duration(hours: 1)),
);
final completedSource = _entrySource(
serverId: 'beta',
serverName: 'Beta',
itemId: 'beta-movie',
played: true,
runtimeTicks: 1200000000,
lastPlayedDate: now.subtract(const Duration(hours: 2)),
);
final unknownProgressSource = _entrySource(
serverId: 'gamma',
serverName: 'Gamma',
itemId: 'gamma-movie',
);
final entry = AggregatedEntry(
item: partiallyWatchedSource.item,
source: partiallyWatchedSource.source,
sources: [partiallyWatchedSource, completedSource, unknownProgressSource],
);
AggregatedEntrySource? selectedSource;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: HistorySourcePickerContent(
entry: entry,
now: now,
onSourceSelected: (source) => selectedSource = source,
),
),
),
);
expect(find.text('最近播放'), findsOneWidget);
expect(find.text('已看 50% · 剩余 1 分钟 · 1 小时前'), findsOneWidget);
expect(find.text('已看完 · 2 小时前'), findsOneWidget);
expect(find.text('进度未知 · 未记录播放时间'), findsOneWidget);
final progressIndicators = tester.widgetList<LinearProgressIndicator>(
find.byType(LinearProgressIndicator),
);
expect(progressIndicators.map((indicator) => indicator.value), [0.5, 1, 0]);
await tester.tap(find.text('Beta'));
await tester.pump();
expect(selectedSource, same(completedSource));
});
testWidgets('adaptive picker lets the user choose another server', (
tester,
) async {
tester.view.physicalSize = const Size(480, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final now = DateTime(2026, 7, 11, 12);
final alphaSource = _entrySource(
serverId: 'alpha',
serverName: 'Alpha',
itemId: 'alpha-movie',
playbackPositionTicks: 600000000,
runtimeTicks: 1200000000,
lastPlayedDate: now.subtract(const Duration(hours: 1)),
);
final betaSource = _entrySource(
serverId: 'beta',
serverName: 'Beta',
itemId: 'beta-movie',
playbackPositionTicks: 300000000,
runtimeTicks: 1200000000,
lastPlayedDate: now.subtract(const Duration(hours: 2)),
);
final entry = AggregatedEntry(
item: alphaSource.item,
source: alphaSource.source,
sources: [alphaSource, betaSource],
);
AggregatedEntrySource? selectedSource;
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) => TextButton(
onPressed: () async {
selectedSource = await showHistorySourcePicker(
context,
entry: entry,
now: now,
);
},
child: const Text('打开服务器选择'),
),
),
),
),
),
);
await tester.tap(find.text('打开服务器选择'));
await tester.pumpAndSettle();
expect(find.text('选择服务器'), findsOneWidget);
expect(find.text('Alpha'), findsOneWidget);
expect(find.text('Beta'), findsOneWidget);
await tester.tap(find.text('Beta'));
await tester.pumpAndSettle();
expect(selectedSource, same(betaSource));
expect(find.text('选择服务器'), findsNothing);
});
}
AggregatedEntrySource _entrySource({
required String serverId,
required String serverName,
required String itemId,
int? playbackPositionTicks,
int? runtimeTicks,
bool? played,
DateTime? lastPlayedDate,
}) {
final item = EmbyRawItem(
Id: itemId,
Name: 'Movie',
Type: 'Movie',
RunTimeTicks: runtimeTicks,
UserData: playbackPositionTicks != null || played != null
? EmbyRawUserData(
PlaybackPositionTicks: playbackPositionTicks,
Played: played,
)
: null,
extra: {
if (lastPlayedDate != null)
'UserData': {'LastPlayedDate': lastPlayedDate},
},
);
final serverResult = GlobalSearchServerResult(
serverId: serverId,
serverName: serverName,
serverUrl: 'https://$serverId.example',
token: 'token-$serverId',
userId: 'user-$serverId',
items: [item],
);
return AggregatedEntrySource(item: item, source: serverResult);
}
@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:forui/forui.dart';
import 'package:smplayer/core/contracts/auth.dart';
import 'package:smplayer/features/home/widgets/server_dropdown_button.dart';
import 'package:smplayer/providers/home_page_mode_provider.dart';
import 'package:smplayer/providers/session_provider.dart';
void main() {
testWidgets('server mode uses a capsule dropdown trigger', (tester) async {
await _pumpButton(tester, mode: HomePageMode.server);
expect(find.byType(BackdropFilter), findsOneWidget);
expect(find.byType(FPopoverMenu), findsOneWidget);
expect(find.text('Alpha'), findsOneWidget);
await tester.tap(find.text('Alpha'));
await tester.pumpAndSettle();
expect(find.text('推荐'), findsOneWidget);
expect(find.text('Beta'), findsOneWidget);
});
testWidgets('recommend mode uses the same capsule dropdown trigger', (
tester,
) async {
await _pumpButton(tester, mode: HomePageMode.recommend);
expect(find.byType(BackdropFilter), findsOneWidget);
expect(find.byType(FPopoverMenu), findsOneWidget);
expect(find.text('推荐'), findsOneWidget);
await tester.tap(find.text('推荐'));
await tester.pumpAndSettle();
expect(find.text('Alpha'), findsOneWidget);
expect(find.text('Beta'), findsOneWidget);
});
}
Future<void> _pumpButton(
WidgetTester tester, {
required HomePageMode mode,
}) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
homePageModeProvider.overrideWith((_) => mode),
sessionProvider.overrideWith(_FakeSessionNotifier.new),
],
child: const MaterialApp(
home: Scaffold(body: Center(child: ServerDropdownButton())),
),
),
);
await tester.pumpAndSettle();
}
class _FakeSessionNotifier extends SessionNotifier {
@override
Future<SessionListRes> build() async => const SessionListRes(
activeServerId: 'alpha',
sessions: [
AuthedSession(
serverId: 'alpha',
serverName: 'Alpha',
serverUrl: 'https://alpha.example',
token: 'token-a',
userId: 'user-a',
userName: 'User A',
isActive: true,
),
AuthedSession(
serverId: 'beta',
serverName: 'Beta',
serverUrl: 'https://beta.example',
token: 'token-b',
userId: 'user-b',
userName: 'User B',
isActive: false,
),
],
);
}
@@ -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 → fvpMediaCodec 不解 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);
});
}
@@ -0,0 +1,60 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:smplayer/features/settings/widgets/player_engine_row.dart';
import 'package:smplayer/providers/player_engine_override_provider.dart';
void main() {
group('PlayerEngineRow.visibleOptions', () {
test('Android 含 4 项', () {
final opts = PlayerEngineRow.visibleOptions(isAndroid: true);
expect(opts, [
PlayerEngineOverride.auto,
PlayerEngineOverride.fvp,
PlayerEngineOverride.mpv,
PlayerEngineOverride.media3,
]);
});
test('桌面含 3 项,不含 media3', () {
final opts = PlayerEngineRow.visibleOptions(isAndroid: false);
expect(opts, [
PlayerEngineOverride.auto,
PlayerEngineOverride.fvp,
PlayerEngineOverride.mpv,
]);
expect(opts.contains(PlayerEngineOverride.media3), isFalse);
});
});
group('PlayerEngineRow.coerceValue', () {
test('桌面 stale media3 → auto', () {
expect(
PlayerEngineRow.coerceValue(
PlayerEngineOverride.media3,
isAndroid: false,
),
PlayerEngineOverride.auto,
);
});
test('Android media3 保持 media3', () {
expect(
PlayerEngineRow.coerceValue(
PlayerEngineOverride.media3,
isAndroid: true,
),
PlayerEngineOverride.media3,
);
});
test('其它值在两个平台都原样保留', () {
for (final v in [
PlayerEngineOverride.auto,
PlayerEngineOverride.fvp,
PlayerEngineOverride.mpv,
]) {
expect(PlayerEngineRow.coerceValue(v, isAndroid: true), v);
expect(PlayerEngineRow.coerceValue(v, isAndroid: false), v);
}
});
});
}