Initial commit
This commit is contained in:
@@ -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 → 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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user