Initial commit
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/contracts/trakt/trakt_ids.dart';
|
||||
|
||||
void main() {
|
||||
test('fromJson 统一解析 Trakt 外部 ID', () {
|
||||
final ids = TraktIds.fromJson({
|
||||
'trakt': 1.0,
|
||||
'imdb': 'tt123',
|
||||
'tmdb': 2,
|
||||
'tvdb': 3.0,
|
||||
'slug': 'demo',
|
||||
});
|
||||
|
||||
expect(ids.toJson(), {
|
||||
'trakt': 1,
|
||||
'imdb': 'tt123',
|
||||
'tmdb': 2,
|
||||
'tvdb': 3,
|
||||
'slug': 'demo',
|
||||
});
|
||||
expect(TraktIds.fromJson(null).toJson(), isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/contracts/trakt/trakt_sync_job.dart';
|
||||
|
||||
void main() {
|
||||
test('旧队列 JSON 的冗余字段可忽略且调度字段保留', () {
|
||||
final job = TraktSyncJob.fromJson({
|
||||
'id': 'job-1',
|
||||
'type': 'scrobble_pause',
|
||||
'body': {
|
||||
'movie': {'title': 'Demo'},
|
||||
},
|
||||
'progress': 42.0,
|
||||
'createdAt': '2026-01-01T00:00:00Z',
|
||||
'updatedAt': '2026-01-02T00:00:00Z',
|
||||
'lastError': 'transient',
|
||||
'retryCount': 3,
|
||||
'nextRetryAt': '2026-01-03T00:00:00Z',
|
||||
'exhausted': false,
|
||||
});
|
||||
|
||||
expect(job.type, TraktSyncJobType.scrobblePause);
|
||||
expect(job.retryCount, 3);
|
||||
expect(job.nextRetryAt, DateTime.utc(2026, 1, 3));
|
||||
expect(job.toJson(), isNot(contains('progress')));
|
||||
expect(job.toJson(), isNot(contains('lastError')));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/contracts/danmaku.dart';
|
||||
import 'package:smplayer/core/contracts/script_widget.dart';
|
||||
import 'package:smplayer/core/contracts/server.dart';
|
||||
import 'package:smplayer/core/domain/ports/danmaku_sources_store.dart';
|
||||
import 'package:smplayer/core/domain/ports/script_widget_repository.dart';
|
||||
import 'package:smplayer/core/domain/ports/server_repository.dart';
|
||||
import 'package:smplayer/core/domain/ports/session_repository.dart';
|
||||
import 'package:smplayer/core/contracts/auth.dart';
|
||||
import 'package:smplayer/core/domain/usecases/backup_usecases.dart';
|
||||
|
||||
void main() {
|
||||
test('导出的备份包能被再次导入并保留脚本模块与订阅', () async {
|
||||
final timestamp = DateTime.utc(2026, 7, 13);
|
||||
final source = _MemorySetup.withScriptWidget(timestamp: timestamp);
|
||||
final export = ExportBackupUseCase(
|
||||
serverRepository: source.serverRepository,
|
||||
sessionRepository: source.sessionRepository,
|
||||
danmakuSourcesStore: source.danmakuSourcesStore,
|
||||
scriptWidgetRepository: source.scriptWidgetRepository,
|
||||
now: () => timestamp,
|
||||
);
|
||||
final backupResult = await export.execute();
|
||||
|
||||
final destination = _MemorySetup.empty();
|
||||
final import = ImportBackupUseCase(
|
||||
serverRepository: destination.serverRepository,
|
||||
sessionRepository: destination.sessionRepository,
|
||||
danmakuSourcesStore: destination.danmakuSourcesStore,
|
||||
scriptWidgetRepository: destination.scriptWidgetRepository,
|
||||
);
|
||||
final importResult = await import.execute(backupResult.json);
|
||||
|
||||
expect(backupResult.scriptWidgetCount, 1);
|
||||
expect(backupResult.scriptWidgetSubscriptionCount, 1);
|
||||
expect(importResult.scriptWidgetCount, 1);
|
||||
expect(importResult.scriptWidgetSubscriptionCount, 1);
|
||||
|
||||
final restoredWidgets = await destination.scriptWidgetRepository
|
||||
.listWidgets();
|
||||
expect(restoredWidgets.single.manifest.id, 'demo-widget');
|
||||
expect(restoredWidgets.single.globalParams, <String, Object?>{
|
||||
'apiKey': 'secret-token',
|
||||
});
|
||||
|
||||
final restoredSubscriptions = await destination.scriptWidgetRepository
|
||||
.listSubscriptions();
|
||||
expect(
|
||||
restoredSubscriptions.single.url,
|
||||
'https://example.com/catalog.fwd',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
class _MemorySetup {
|
||||
final ServerRepository serverRepository;
|
||||
final SessionRepository sessionRepository;
|
||||
final DanmakuSourcesStore danmakuSourcesStore;
|
||||
final ScriptWidgetRepository scriptWidgetRepository;
|
||||
|
||||
_MemorySetup({
|
||||
required this.serverRepository,
|
||||
required this.sessionRepository,
|
||||
required this.danmakuSourcesStore,
|
||||
required this.scriptWidgetRepository,
|
||||
});
|
||||
|
||||
factory _MemorySetup.empty() {
|
||||
return _MemorySetup(
|
||||
serverRepository: _MemoryServerRepository(),
|
||||
sessionRepository: _MemorySessionRepository(),
|
||||
danmakuSourcesStore: _MemoryDanmakuSourcesStore(),
|
||||
scriptWidgetRepository: _MemoryScriptWidgetRepository(),
|
||||
);
|
||||
}
|
||||
|
||||
factory _MemorySetup.withScriptWidget({required DateTime timestamp}) {
|
||||
final setup = _MemorySetup.empty();
|
||||
setup.scriptWidgetRepository.upsertWidget(
|
||||
InstalledScriptWidget(
|
||||
manifest: const ScriptWidgetManifest(
|
||||
id: 'demo-widget',
|
||||
title: 'Demo',
|
||||
),
|
||||
scriptSource: 'const WidgetMetadata = { id: "demo-widget" };',
|
||||
globalParams: const <String, Object?>{'apiKey': 'secret-token'},
|
||||
installedAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
),
|
||||
);
|
||||
setup.scriptWidgetRepository.upsertSubscription(
|
||||
ScriptWidgetSubscription(
|
||||
url: 'https://example.com/catalog.fwd',
|
||||
catalog: const ScriptWidgetCatalog(title: '示例订阅'),
|
||||
refreshedAt: timestamp,
|
||||
),
|
||||
);
|
||||
return setup;
|
||||
}
|
||||
}
|
||||
|
||||
class _MemoryServerRepository implements ServerRepository {
|
||||
final List<EmbyServer> _servers = <EmbyServer>[];
|
||||
|
||||
@override
|
||||
Future<List<EmbyServer>> list() async =>
|
||||
List<EmbyServer>.unmodifiable(_servers);
|
||||
|
||||
@override
|
||||
Future<EmbyServer?> findById(String id) async {
|
||||
for (final server in _servers) {
|
||||
if (server.id == id) return server;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<EmbyServer?> findByBaseUrl(String baseUrl) async {
|
||||
for (final server in _servers) {
|
||||
if (server.baseUrl == baseUrl) return server;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<EmbyServer> create(EmbyServer server) async {
|
||||
_servers.add(server);
|
||||
return server;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<EmbyServer> update(EmbyServer server) async {
|
||||
final existingIndex = _servers.indexWhere(
|
||||
(candidate) => candidate.id == server.id,
|
||||
);
|
||||
if (existingIndex < 0) {
|
||||
_servers.add(server);
|
||||
} else {
|
||||
_servers[existingIndex] = server;
|
||||
}
|
||||
return server;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteById(String id) async {
|
||||
_servers.removeWhere((server) => server.id == id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> touchConnectedAt(String id, String updatedAt) async {}
|
||||
|
||||
@override
|
||||
Future<void> replaceAll(List<EmbyServer> servers) async {
|
||||
_servers
|
||||
..clear()
|
||||
..addAll(servers);
|
||||
}
|
||||
}
|
||||
|
||||
class _MemorySessionRepository implements SessionRepository {
|
||||
final Map<String, SessionData> _sessionsByServerId = <String, SessionData>{};
|
||||
String? _activeServerId;
|
||||
|
||||
@override
|
||||
Future<SessionData> set(SessionData session) async {
|
||||
_sessionsByServerId[session.serverId] = session;
|
||||
return session;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setMany(List<SessionData> sessions) async {
|
||||
for (final session in sessions) {
|
||||
_sessionsByServerId[session.serverId] = session;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SessionData?> getByServerId(String serverId) async {
|
||||
return _sessionsByServerId[serverId];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SessionData?> getActive() async {
|
||||
final activeId = _activeServerId;
|
||||
if (activeId == null) return null;
|
||||
return _sessionsByServerId[activeId];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> getActiveServerId() async => _activeServerId;
|
||||
|
||||
@override
|
||||
Future<List<SessionData>> listAll() async =>
|
||||
List<SessionData>.unmodifiable(_sessionsByServerId.values);
|
||||
|
||||
@override
|
||||
Future<void> setActive(String serverId) async {
|
||||
_activeServerId = serverId;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clear([String? serverId]) async {
|
||||
if (serverId == null) {
|
||||
_sessionsByServerId.clear();
|
||||
_activeServerId = null;
|
||||
return;
|
||||
}
|
||||
_sessionsByServerId.remove(serverId);
|
||||
if (_activeServerId == serverId) _activeServerId = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> replaceAll(
|
||||
List<SessionData> sessions, {
|
||||
String? activeServerId,
|
||||
}) async {
|
||||
_sessionsByServerId
|
||||
..clear()
|
||||
..addEntries(
|
||||
sessions.map((session) => MapEntry(session.serverId, session)),
|
||||
);
|
||||
_activeServerId = activeServerId;
|
||||
}
|
||||
}
|
||||
|
||||
class _MemoryDanmakuSourcesStore implements DanmakuSourcesStore {
|
||||
List<DanmakuSource> _sources = <DanmakuSource>[];
|
||||
|
||||
@override
|
||||
Future<List<DanmakuSource>> list() async =>
|
||||
List<DanmakuSource>.from(_sources);
|
||||
|
||||
@override
|
||||
Future<void> replaceAll(List<DanmakuSource> sources) async {
|
||||
_sources = List<DanmakuSource>.from(sources);
|
||||
}
|
||||
}
|
||||
|
||||
class _MemoryScriptWidgetRepository implements ScriptWidgetRepository {
|
||||
final Map<String, InstalledScriptWidget> _widgets =
|
||||
<String, InstalledScriptWidget>{};
|
||||
final Map<String, ScriptWidgetSubscription> _subscriptions =
|
||||
<String, ScriptWidgetSubscription>{};
|
||||
|
||||
@override
|
||||
Future<List<InstalledScriptWidget>> listWidgets() async =>
|
||||
_widgets.values.toList(growable: false);
|
||||
|
||||
@override
|
||||
Future<InstalledScriptWidget?> findWidget(String widgetId) async {
|
||||
return _widgets[widgetId];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> upsertWidget(InstalledScriptWidget widget) async {
|
||||
_widgets[widget.manifest.id] = widget;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteWidget(String widgetId) async {
|
||||
_widgets.remove(widgetId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ScriptWidgetSubscription>> listSubscriptions() async =>
|
||||
_subscriptions.values.toList(growable: false);
|
||||
|
||||
@override
|
||||
Future<void> upsertSubscription(ScriptWidgetSubscription subscription) async {
|
||||
_subscriptions[subscription.url] = subscription;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> replaceAllWidgets(List<InstalledScriptWidget> widgets) async {
|
||||
_widgets
|
||||
..clear()
|
||||
..addEntries(
|
||||
widgets.map((widget) => MapEntry(widget.manifest.id, widget)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> replaceAllSubscriptions(
|
||||
List<ScriptWidgetSubscription> subscriptions,
|
||||
) async {
|
||||
_subscriptions
|
||||
..clear()
|
||||
..addEntries(
|
||||
subscriptions.map(
|
||||
(subscription) => MapEntry(subscription.url, subscription),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/contracts/auth.dart';
|
||||
import 'package:smplayer/core/contracts/cancellation.dart';
|
||||
import 'package:smplayer/core/contracts/library.dart';
|
||||
import 'package:smplayer/core/contracts/server.dart';
|
||||
import 'package:smplayer/core/domain/ports/emby_gateway.dart';
|
||||
import 'package:smplayer/core/domain/ports/server_repository.dart';
|
||||
import 'package:smplayer/core/domain/ports/session_repository.dart';
|
||||
import 'package:smplayer/core/domain/usecases/media_usecases.dart';
|
||||
|
||||
class _FakeSessionRepository implements SessionRepository {
|
||||
_FakeSessionRepository(this.sessions, {this.activeServerId});
|
||||
final List<SessionData> sessions;
|
||||
final String? activeServerId;
|
||||
|
||||
@override
|
||||
Future<List<SessionData>> listAll() async => sessions;
|
||||
|
||||
@override
|
||||
Future<String?> getActiveServerId() async => activeServerId;
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _FakeServerRepository implements ServerRepository {
|
||||
_FakeServerRepository(this.servers);
|
||||
final List<EmbyServer> servers;
|
||||
|
||||
@override
|
||||
Future<List<EmbyServer>> list() async => servers;
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _CountingEmbyGateway implements EmbyGateway {
|
||||
_CountingEmbyGateway({this.itemsToReturn = const []});
|
||||
List<EmbyRawItem> itemsToReturn;
|
||||
int libraryQueryCount = 0;
|
||||
|
||||
@override
|
||||
Future<List<EmbyRawItem>> getLibraryItems({
|
||||
required AuthedRequestContext ctx,
|
||||
String? parentId,
|
||||
String? includeItemTypes,
|
||||
List<String>? genreIds,
|
||||
String? searchTerm,
|
||||
String? anyProviderIdEquals,
|
||||
List<String>? personIds,
|
||||
String? fields,
|
||||
String? enableImageTypes,
|
||||
bool? groupProgramsBySeries,
|
||||
int? imageTypeLimit,
|
||||
int? startIndex,
|
||||
int? limit,
|
||||
bool? recursive,
|
||||
String? sortBy,
|
||||
SortOrder? sortOrder,
|
||||
String? filters,
|
||||
CancellationToken? cancelToken,
|
||||
}) async {
|
||||
libraryQueryCount++;
|
||||
return itemsToReturn;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<EmbyRawItem> getItemDetail(
|
||||
AuthedRequestContext ctx,
|
||||
String itemId,
|
||||
) async => itemsToReturn.firstWhere((it) => it.Id == itemId);
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
EmbyServer _server(String id) => EmbyServer(
|
||||
id: id,
|
||||
name: id,
|
||||
baseUrl: 'http://$id',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
);
|
||||
|
||||
SessionData _session(String serverId) => SessionData(
|
||||
serverId: serverId,
|
||||
token: 'token-$serverId',
|
||||
userId: 'user-$serverId',
|
||||
userName: serverId,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
);
|
||||
|
||||
|
||||
Future<List<CrossServerSourceCard>> _collectCards(
|
||||
CrossServerSearchUseCase useCase,
|
||||
CrossServerSearchReq request,
|
||||
) async {
|
||||
final events = await useCase.executeStream(request).toList();
|
||||
return events.isEmpty ? const [] : events.last;
|
||||
}
|
||||
|
||||
void main() {
|
||||
const movieReq = CrossServerSearchReq(
|
||||
anchorType: 'Movie',
|
||||
itemName: 'Movie One',
|
||||
providerIds: {'Tmdb': '42'},
|
||||
);
|
||||
|
||||
({CrossServerSearchUseCase uc, _CountingEmbyGateway gateway}) build({
|
||||
List<EmbyRawItem> remoteItems = const [],
|
||||
}) {
|
||||
final gateway = _CountingEmbyGateway(itemsToReturn: remoteItems);
|
||||
final uc = CrossServerSearchUseCase(
|
||||
sessionRepository: _FakeSessionRepository([
|
||||
_session('active'),
|
||||
_session('remote'),
|
||||
], activeServerId: 'active'),
|
||||
serverRepository: _FakeServerRepository([
|
||||
_server('active'),
|
||||
_server('remote'),
|
||||
]),
|
||||
embyGateway: gateway,
|
||||
);
|
||||
return (uc: uc, gateway: gateway);
|
||||
}
|
||||
|
||||
group('CrossServerSearchUseCase 空服务器跳过(streak+TTL)', () {
|
||||
test('连续 3 次空结果后,该服务器被跳过不再探测', () async {
|
||||
final (:uc, :gateway) = build();
|
||||
|
||||
for (var round = 1; round <= 3; round++) {
|
||||
final cards = await _collectCards(uc, movieReq);
|
||||
expect(cards, isEmpty, reason: 'round $round 应为空结果');
|
||||
}
|
||||
final queriesAfterThreeRounds = gateway.libraryQueryCount;
|
||||
expect(queriesAfterThreeRounds, greaterThan(0));
|
||||
|
||||
final cards = await _collectCards(uc, movieReq);
|
||||
expect(cards, isEmpty);
|
||||
expect(
|
||||
gateway.libraryQueryCount,
|
||||
queriesAfterThreeRounds,
|
||||
reason: '第 4 轮应命中 streak 跳过,网关不应再被探测',
|
||||
);
|
||||
});
|
||||
|
||||
test('未达阈值(2 次空)时仍继续探测', () async {
|
||||
final (:uc, :gateway) = build();
|
||||
|
||||
await _collectCards(uc, movieReq);
|
||||
await _collectCards(uc, movieReq);
|
||||
final queriesAfterTwoRounds = gateway.libraryQueryCount;
|
||||
|
||||
await _collectCards(uc, movieReq);
|
||||
expect(gateway.libraryQueryCount, greaterThan(queriesAfterTwoRounds));
|
||||
});
|
||||
|
||||
test('命中结果会清空 streak 并产出多版本 cards', () async {
|
||||
final movie = EmbyRawItem.fromJson(const {
|
||||
'Id': 'm1',
|
||||
'Name': 'Movie One',
|
||||
'Type': 'Movie',
|
||||
'MediaSources': [
|
||||
{'Id': 'ms1', 'Name': '1080p'},
|
||||
{'Id': 'ms2', 'Name': '4K'},
|
||||
],
|
||||
});
|
||||
final (:uc, :gateway) = build(remoteItems: [movie]);
|
||||
|
||||
final cards = await _collectCards(uc, movieReq);
|
||||
expect(cards, hasLength(2));
|
||||
expect(cards.map((c) => c.mediaSourceId), containsAll(['ms1', 'ms2']));
|
||||
expect(cards.every((c) => c.serverId == 'remote'), isTrue);
|
||||
|
||||
gateway.itemsToReturn = const [];
|
||||
final baseline = gateway.libraryQueryCount;
|
||||
await _collectCards(uc, movieReq);
|
||||
expect(gateway.libraryQueryCount, greaterThan(baseline));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/contracts/auth.dart';
|
||||
import 'package:smplayer/core/contracts/cancellation.dart';
|
||||
import 'package:smplayer/core/contracts/library.dart';
|
||||
import 'package:smplayer/core/contracts/server.dart';
|
||||
import 'package:smplayer/core/domain/ports/emby_gateway.dart';
|
||||
import 'package:smplayer/core/domain/ports/server_repository.dart';
|
||||
import 'package:smplayer/core/domain/ports/session_repository.dart';
|
||||
import 'package:smplayer/core/domain/usecases/media_usecases.dart';
|
||||
|
||||
class _FakeSessionRepository implements SessionRepository {
|
||||
@override
|
||||
Future<String?> getActiveServerId() async => 'active';
|
||||
|
||||
@override
|
||||
Future<List<SessionData>> listAll() async => [
|
||||
_buildSession('active'),
|
||||
_buildSession('remote'),
|
||||
];
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _FakeServerRepository implements ServerRepository {
|
||||
@override
|
||||
Future<List<EmbyServer>> list() async => [
|
||||
_buildServer('active'),
|
||||
_buildServer('remote'),
|
||||
];
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _ProviderFilteringEmbyGateway implements EmbyGateway {
|
||||
_ProviderFilteringEmbyGateway({
|
||||
this.providerMovieItems = const [],
|
||||
this.nameSearchMovieItems = const [],
|
||||
this.seriesItems = const [],
|
||||
});
|
||||
|
||||
final List<EmbyRawItem> providerMovieItems;
|
||||
final List<EmbyRawItem> nameSearchMovieItems;
|
||||
final List<EmbyRawItem> seriesItems;
|
||||
|
||||
int providerMovieQueryCount = 0;
|
||||
int nameSearchMovieQueryCount = 0;
|
||||
int seriesQueryCount = 0;
|
||||
int detailQueryCount = 0;
|
||||
int seasonsQueryCount = 0;
|
||||
|
||||
Iterable<EmbyRawItem> get _allItems => [
|
||||
...providerMovieItems,
|
||||
...nameSearchMovieItems,
|
||||
...seriesItems,
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<EmbyRawItem>> getLibraryItems({
|
||||
required AuthedRequestContext ctx,
|
||||
String? parentId,
|
||||
String? includeItemTypes,
|
||||
List<String>? genreIds,
|
||||
String? searchTerm,
|
||||
String? anyProviderIdEquals,
|
||||
List<String>? personIds,
|
||||
String? fields,
|
||||
String? enableImageTypes,
|
||||
bool? groupProgramsBySeries,
|
||||
int? imageTypeLimit,
|
||||
int? startIndex,
|
||||
int? limit,
|
||||
bool? recursive,
|
||||
String? sortBy,
|
||||
SortOrder? sortOrder,
|
||||
String? filters,
|
||||
CancellationToken? cancelToken,
|
||||
}) async {
|
||||
if (includeItemTypes == 'Movie' && anyProviderIdEquals != null) {
|
||||
providerMovieQueryCount++;
|
||||
return providerMovieItems;
|
||||
}
|
||||
if (includeItemTypes == 'Movie' && searchTerm != null) {
|
||||
nameSearchMovieQueryCount++;
|
||||
return nameSearchMovieItems;
|
||||
}
|
||||
if (includeItemTypes == 'Series') {
|
||||
seriesQueryCount++;
|
||||
return seriesItems;
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<EmbyRawItem> getItemDetail(
|
||||
AuthedRequestContext ctx,
|
||||
String itemId,
|
||||
) async {
|
||||
detailQueryCount++;
|
||||
return _allItems.firstWhere((item) => item.Id == itemId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<EmbyRawSeason>> getSeriesSeasons(
|
||||
AuthedRequestContext ctx,
|
||||
String seriesId, {
|
||||
CancellationToken? cancelToken,
|
||||
}) async {
|
||||
seasonsQueryCount++;
|
||||
return const [];
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
EmbyServer _buildServer(String serverId) => EmbyServer(
|
||||
id: serverId,
|
||||
name: serverId,
|
||||
baseUrl: 'http://$serverId',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
);
|
||||
|
||||
SessionData _buildSession(String serverId) => SessionData(
|
||||
serverId: serverId,
|
||||
token: 'token-$serverId',
|
||||
userId: 'user-$serverId',
|
||||
userName: serverId,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
);
|
||||
|
||||
EmbyRawItem _buildMovie({
|
||||
required String itemId,
|
||||
required String mediaSourceId,
|
||||
Map<String, String>? providerIds,
|
||||
}) => EmbyRawItem.fromJson({
|
||||
'Id': itemId,
|
||||
'Name': 'Movie One',
|
||||
'Type': 'Movie',
|
||||
if (providerIds != null) 'ProviderIds': providerIds,
|
||||
'MediaSources': [
|
||||
{'Id': mediaSourceId, 'Name': '4K'},
|
||||
],
|
||||
});
|
||||
|
||||
CrossServerSearchUseCase _buildUseCase(
|
||||
_ProviderFilteringEmbyGateway embyGateway,
|
||||
) => CrossServerSearchUseCase(
|
||||
sessionRepository: _FakeSessionRepository(),
|
||||
serverRepository: _FakeServerRepository(),
|
||||
embyGateway: embyGateway,
|
||||
);
|
||||
|
||||
Future<List<CrossServerSourceCard>> _collectCards(
|
||||
CrossServerSearchUseCase useCase,
|
||||
CrossServerSearchReq request,
|
||||
) async {
|
||||
final emittedCardLists = await useCase.executeStream(request).toList();
|
||||
return emittedCardLists.isEmpty ? const [] : emittedCardLists.last;
|
||||
}
|
||||
|
||||
void main() {
|
||||
const movieRequest = CrossServerSearchReq(
|
||||
anchorType: 'Movie',
|
||||
itemName: 'Movie One',
|
||||
providerIds: {'Tmdb': '42'},
|
||||
);
|
||||
|
||||
group('CrossServerSearchUseCase provider-id filtering', () {
|
||||
test('drops ignored provider-query results before detail fetch', () async {
|
||||
final mismatchedMovie = _buildMovie(
|
||||
itemId: 'unrelated-movie',
|
||||
mediaSourceId: 'unrelated-source',
|
||||
providerIds: const {'Tmdb': '999'},
|
||||
);
|
||||
final embyGateway = _ProviderFilteringEmbyGateway(
|
||||
providerMovieItems: [mismatchedMovie],
|
||||
nameSearchMovieItems: [mismatchedMovie],
|
||||
);
|
||||
|
||||
final cards = await _collectCards(
|
||||
_buildUseCase(embyGateway),
|
||||
movieRequest,
|
||||
);
|
||||
|
||||
expect(cards, isEmpty);
|
||||
expect(embyGateway.providerMovieQueryCount, 1);
|
||||
expect(embyGateway.nameSearchMovieQueryCount, 1);
|
||||
expect(embyGateway.detailQueryCount, 0);
|
||||
});
|
||||
|
||||
test(
|
||||
'keeps provider-query results with a normalized matching id',
|
||||
() async {
|
||||
final matchingMovie = _buildMovie(
|
||||
itemId: 'matching-movie',
|
||||
mediaSourceId: 'matching-source',
|
||||
providerIds: const {'tmdb': '42'},
|
||||
);
|
||||
final embyGateway = _ProviderFilteringEmbyGateway(
|
||||
providerMovieItems: [matchingMovie],
|
||||
);
|
||||
|
||||
final cards = await _collectCards(
|
||||
_buildUseCase(embyGateway),
|
||||
movieRequest,
|
||||
);
|
||||
|
||||
expect(cards, hasLength(1));
|
||||
expect(cards.single.mediaSourceId, 'matching-source');
|
||||
expect(embyGateway.nameSearchMovieQueryCount, 0);
|
||||
expect(embyGateway.detailQueryCount, 1);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'drops conflicting name results but keeps unscanned results',
|
||||
() async {
|
||||
final conflictingMovie = _buildMovie(
|
||||
itemId: 'conflicting-movie',
|
||||
mediaSourceId: 'conflicting-source',
|
||||
providerIds: const {'Tmdb': '999'},
|
||||
);
|
||||
final unscannedMovie = _buildMovie(
|
||||
itemId: 'unscanned-movie',
|
||||
mediaSourceId: 'unscanned-source',
|
||||
);
|
||||
final embyGateway = _ProviderFilteringEmbyGateway(
|
||||
nameSearchMovieItems: [conflictingMovie, unscannedMovie],
|
||||
);
|
||||
|
||||
final cards = await _collectCards(
|
||||
_buildUseCase(embyGateway),
|
||||
movieRequest,
|
||||
);
|
||||
|
||||
expect(cards, hasLength(1));
|
||||
expect(cards.single.landingItemId, 'unscanned-movie');
|
||||
expect(cards.single.mediaSourceId, 'unscanned-source');
|
||||
expect(embyGateway.detailQueryCount, 1);
|
||||
},
|
||||
);
|
||||
|
||||
test('rejects and caches a mismatched remote series identity', () async {
|
||||
final mismatchedSeries = EmbyRawItem.fromJson(const {
|
||||
'Id': 'unrelated-series',
|
||||
'Name': 'Unrelated Series',
|
||||
'Type': 'Series',
|
||||
'ProviderIds': {'Tmdb': '999'},
|
||||
});
|
||||
final embyGateway = _ProviderFilteringEmbyGateway(
|
||||
seriesItems: [mismatchedSeries],
|
||||
);
|
||||
final useCase = _buildUseCase(embyGateway);
|
||||
const episodeRequest = CrossServerSearchReq(
|
||||
anchorType: 'Episode',
|
||||
itemName: 'Episode One',
|
||||
providerIds: {},
|
||||
seriesProviderIds: {'Tmdb': '42'},
|
||||
parentIndexNumber: 1,
|
||||
indexNumber: 1,
|
||||
);
|
||||
|
||||
final firstCards = await _collectCards(useCase, episodeRequest);
|
||||
final secondCards = await _collectCards(useCase, episodeRequest);
|
||||
|
||||
expect(firstCards, isEmpty);
|
||||
expect(secondCards, isEmpty);
|
||||
expect(embyGateway.seriesQueryCount, 1);
|
||||
expect(embyGateway.seasonsQueryCount, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/contracts/danmaku.dart';
|
||||
import 'package:smplayer/core/contracts/script_widget.dart';
|
||||
import 'package:smplayer/core/domain/errors.dart';
|
||||
import 'package:smplayer/core/domain/ports/danmaku_sources_store.dart';
|
||||
import 'package:smplayer/core/domain/ports/script_widget_remote_gateway.dart';
|
||||
import 'package:smplayer/core/domain/ports/script_widget_repository.dart';
|
||||
import 'package:smplayer/core/domain/ports/script_widget_runtime.dart';
|
||||
import 'package:smplayer/core/domain/usecases/script_widget/script_widget_service.dart';
|
||||
|
||||
class _MemoryScriptWidgetRepository implements ScriptWidgetRepository {
|
||||
final Map<String, InstalledScriptWidget> widgets =
|
||||
<String, InstalledScriptWidget>{};
|
||||
final Map<String, ScriptWidgetSubscription> subscriptions =
|
||||
<String, ScriptWidgetSubscription>{};
|
||||
|
||||
@override
|
||||
Future<void> deleteWidget(String widgetId) async {
|
||||
widgets.remove(widgetId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<InstalledScriptWidget?> findWidget(String widgetId) async {
|
||||
return widgets[widgetId];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ScriptWidgetSubscription>> listSubscriptions() async {
|
||||
return subscriptions.values.toList(growable: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<InstalledScriptWidget>> listWidgets() async {
|
||||
return widgets.values.toList(growable: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> upsertSubscription(ScriptWidgetSubscription subscription) async {
|
||||
subscriptions[subscription.url] = subscription;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> upsertWidget(InstalledScriptWidget widget) async {
|
||||
widgets[widget.manifest.id] = widget;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> replaceAllWidgets(List<InstalledScriptWidget> widgets) async {
|
||||
this.widgets
|
||||
..clear()
|
||||
..addEntries(
|
||||
widgets.map((widget) => MapEntry(widget.manifest.id, widget)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> replaceAllSubscriptions(
|
||||
List<ScriptWidgetSubscription> subscriptions,
|
||||
) async {
|
||||
this.subscriptions
|
||||
..clear()
|
||||
..addEntries(
|
||||
subscriptions.map(
|
||||
(subscription) => MapEntry(subscription.url, subscription),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StaticScriptWidgetRuntime implements ScriptWidgetRuntime {
|
||||
_StaticScriptWidgetRuntime(this.manifest, {this.invocationResult});
|
||||
|
||||
ScriptWidgetManifest manifest;
|
||||
final Object? invocationResult;
|
||||
final Set<String> loadedWidgetIds = <String>{};
|
||||
Map<String, Object?>? lastInvocationParams;
|
||||
Completer<void>? loadGate;
|
||||
int loadCount = 0;
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
|
||||
@override
|
||||
Future<Object?> invokeModuleFunction(
|
||||
String widgetId,
|
||||
String functionName,
|
||||
Map<String, Object?> params,
|
||||
) async {
|
||||
lastInvocationParams = Map<String, Object?>.from(params);
|
||||
return invocationResult;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ScriptWidgetManifest> inspectWidget(String scriptSource) async {
|
||||
return manifest;
|
||||
}
|
||||
|
||||
@override
|
||||
bool isWidgetLoaded(String widgetId) => loadedWidgetIds.contains(widgetId);
|
||||
|
||||
@override
|
||||
Future<ScriptWidgetManifest> loadWidget(String scriptSource) async {
|
||||
loadCount++;
|
||||
await loadGate?.future;
|
||||
loadedWidgetIds.add(manifest.id);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> unloadWidget(String widgetId) async {
|
||||
loadedWidgetIds.remove(widgetId);
|
||||
}
|
||||
}
|
||||
|
||||
class _UnusedRemoteGateway implements ScriptWidgetRemoteGateway {
|
||||
@override
|
||||
Future<String> fetchText(String url) {
|
||||
throw UnsupportedError('This test does not perform remote requests.');
|
||||
}
|
||||
}
|
||||
|
||||
class _StaticRemoteGateway implements ScriptWidgetRemoteGateway {
|
||||
final String scriptSource;
|
||||
|
||||
const _StaticRemoteGateway(this.scriptSource);
|
||||
|
||||
@override
|
||||
Future<String> fetchText(String url) async => scriptSource;
|
||||
}
|
||||
|
||||
class _MemoryDanmakuSourcesStore implements DanmakuSourcesStore {
|
||||
List<DanmakuSource> sources = <DanmakuSource>[];
|
||||
|
||||
@override
|
||||
Future<List<DanmakuSource>> list() async {
|
||||
return List<DanmakuSource>.from(sources);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> replaceAll(List<DanmakuSource> sources) async {
|
||||
this.sources = List<DanmakuSource>.from(sources);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('视频契约兼容字段别名与宽松数值类型', () {
|
||||
final item = ScriptVideoItem.fromJson(<String, Object?>{
|
||||
'id': 42,
|
||||
'title': '示例影片',
|
||||
'poster_url': 'https://example.com/poster.jpg',
|
||||
'rating': '8.6',
|
||||
'duration': '3600',
|
||||
});
|
||||
final resource = ScriptVideoResource.fromJson(<String, Object?>{
|
||||
'url': 'https://example.com/video.m3u8',
|
||||
'headers': <String, Object?>{'Referer': 'https://example.com', 'X': 1},
|
||||
});
|
||||
|
||||
expect(item.id, '42');
|
||||
expect(item.posterPath, 'https://example.com/poster.jpg');
|
||||
expect(item.rating, 8.6);
|
||||
expect(item.duration, 3600);
|
||||
expect(resource.customHeaders, <String, String>{
|
||||
'Referer': 'https://example.com',
|
||||
'X': '1',
|
||||
});
|
||||
});
|
||||
|
||||
test('模块文本状态兼容 subTitle 并保留错误说明', () {
|
||||
final items = decodeVideoItems(<Object?>[
|
||||
<String, Object?>{
|
||||
'id': 'err_cf',
|
||||
'type': 'text',
|
||||
'title': '被 Cloudflare 拦截',
|
||||
'subTitle': '请稍后重试',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(items, hasLength(1));
|
||||
expect(items.single.type, 'text');
|
||||
expect(items.single.title, '被 Cloudflare 拦截');
|
||||
expect(items.single.description, '请稍后重试');
|
||||
});
|
||||
|
||||
test('模块列表兼容 JSON 字符串返回并从链接生成缺失 ID', () {
|
||||
final items = decodeVideoItems(
|
||||
jsonEncode(<Object?>[
|
||||
<String, Object?>{
|
||||
'type': 'link',
|
||||
'title': 91,
|
||||
'link': 'https://example.com/videos/91',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(items, hasLength(1));
|
||||
expect(items.single.id, 'https://example.com/videos/91');
|
||||
expect(items.single.title, '91');
|
||||
});
|
||||
|
||||
test('模块返回 null 或不可识别文本时抛出明确错误', () {
|
||||
expect(
|
||||
() => decodeVideoItems(null),
|
||||
throwsA(
|
||||
isA<DomainError>().having(
|
||||
(error) => error.message,
|
||||
'message',
|
||||
'模块返回了无法识别的内容格式',
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(() => decodeVideoItems('not-json'), throwsA(isA<DomainError>()));
|
||||
expect(decodeVideoItems(const <Object?>[]), isEmpty);
|
||||
});
|
||||
|
||||
test('sectionMode 保留有效分组并使用回退标题', () {
|
||||
final sections = decodeVideoSections(
|
||||
<Object?>[
|
||||
<String, Object?>{
|
||||
'title': '热门',
|
||||
'items': <Object?>[
|
||||
<String, Object?>{'id': 'movie-1', 'title': '影片一'},
|
||||
],
|
||||
},
|
||||
<String, Object?>{
|
||||
'data': <Object?>[
|
||||
<String, Object?>{'id': 'movie-2', 'title': '影片二'},
|
||||
],
|
||||
},
|
||||
<String, Object?>{'title': '空分组', 'items': <Object?>[]},
|
||||
],
|
||||
fallbackTitle: '模块内容',
|
||||
preserveSections: true,
|
||||
);
|
||||
|
||||
expect(sections, hasLength(2));
|
||||
expect(sections[0].title, '热门');
|
||||
expect(sections[0].items.single.id, 'movie-1');
|
||||
expect(sections[1].title, '模块内容');
|
||||
expect(sections[1].items.single.id, 'movie-2');
|
||||
});
|
||||
|
||||
test('模块调用补全选项默认值且调用参数优先', () async {
|
||||
const module = ScriptWidgetModule(
|
||||
title: '直播',
|
||||
functionName: 'getVideos',
|
||||
params: <ScriptWidgetParam>[
|
||||
ScriptWidgetParam(
|
||||
name: 'category',
|
||||
type: 'enumeration',
|
||||
enumOptions: <ScriptWidgetOption>[
|
||||
ScriptWidgetOption(value: 'jsonkawayi'),
|
||||
ScriptWidgetOption(value: 'jsonmihu'),
|
||||
],
|
||||
),
|
||||
ScriptWidgetParam(
|
||||
name: 'source',
|
||||
value: 'explicit',
|
||||
enumOptions: <ScriptWidgetOption>[
|
||||
ScriptWidgetOption(value: 'fallback'),
|
||||
],
|
||||
),
|
||||
ScriptWidgetParam(
|
||||
name: 'legacy',
|
||||
placeholders: <ScriptWidgetOption>[
|
||||
ScriptWidgetOption(value: 'legacy-first'),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
const manifest = ScriptWidgetManifest(
|
||||
id: 'live-widget',
|
||||
title: '直播模块',
|
||||
modules: <ScriptWidgetModule>[module],
|
||||
);
|
||||
final timestamp = DateTime.utc(2026, 7, 13);
|
||||
final repository = _MemoryScriptWidgetRepository();
|
||||
repository.widgets[manifest.id] = InstalledScriptWidget(
|
||||
manifest: manifest,
|
||||
scriptSource: 'widget source',
|
||||
installedAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
);
|
||||
final runtime = _StaticScriptWidgetRuntime(
|
||||
manifest,
|
||||
invocationResult: const <Object?>[],
|
||||
);
|
||||
final service = ScriptWidgetService(
|
||||
repository: repository,
|
||||
remoteGateway: _UnusedRemoteGateway(),
|
||||
runtime: runtime,
|
||||
);
|
||||
|
||||
await service.loadModuleSections(
|
||||
manifest.id,
|
||||
module,
|
||||
const <String, Object?>{},
|
||||
);
|
||||
expect(runtime.lastInvocationParams, <String, Object?>{
|
||||
'category': 'jsonkawayi',
|
||||
'source': 'explicit',
|
||||
'legacy': 'legacy-first',
|
||||
});
|
||||
|
||||
await service.loadModuleSections(
|
||||
manifest.id,
|
||||
module,
|
||||
const <String, Object?>{'category': 'jsonmihu'},
|
||||
);
|
||||
expect(runtime.lastInvocationParams, <String, Object?>{
|
||||
'category': 'jsonmihu',
|
||||
'source': 'explicit',
|
||||
'legacy': 'legacy-first',
|
||||
});
|
||||
});
|
||||
|
||||
test('远程订阅中的相对模块 URL 相对订阅地址解析', () async {
|
||||
final repository = _MemoryScriptWidgetRepository();
|
||||
final service = ScriptWidgetService(
|
||||
repository: repository,
|
||||
remoteGateway: _UnusedRemoteGateway(),
|
||||
runtime: _StaticScriptWidgetRuntime(
|
||||
const ScriptWidgetManifest(id: 'unused', title: 'Unused'),
|
||||
),
|
||||
);
|
||||
final rawCatalog = jsonEncode(<String, Object?>{
|
||||
'title': '示例订阅',
|
||||
'widgets': <Object?>[
|
||||
<String, Object?>{
|
||||
'id': 'relative',
|
||||
'title': '相对模块',
|
||||
'url': '../widgets/relative.js',
|
||||
},
|
||||
<String, Object?>{
|
||||
'id': 'absolute',
|
||||
'title': '绝对模块',
|
||||
'url': 'https://cdn.example.com/absolute.js',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
final subscription = await service.importSubscriptionSource(
|
||||
rawCatalog,
|
||||
sourceId: 'https://example.com/catalogs/main/catalog.fwd',
|
||||
);
|
||||
|
||||
expect(
|
||||
subscription.catalog.widgets[0].url,
|
||||
'https://example.com/catalogs/widgets/relative.js',
|
||||
);
|
||||
expect(
|
||||
subscription.catalog.widgets[1].url,
|
||||
'https://cdn.example.com/absolute.js',
|
||||
);
|
||||
});
|
||||
|
||||
test('弹幕模块安装与启停会同步来源并通知宿主刷新', () async {
|
||||
final repository = _MemoryScriptWidgetRepository();
|
||||
final sourcesStore = _MemoryDanmakuSourcesStore();
|
||||
var changeNotificationCount = 0;
|
||||
final service = ScriptWidgetService(
|
||||
repository: repository,
|
||||
remoteGateway: _UnusedRemoteGateway(),
|
||||
runtime: _StaticScriptWidgetRuntime(
|
||||
const ScriptWidgetManifest(
|
||||
id: 'danmaku-widget',
|
||||
title: '脚本弹幕',
|
||||
modules: <ScriptWidgetModule>[
|
||||
ScriptWidgetModule(
|
||||
id: 'danmu',
|
||||
title: '弹幕',
|
||||
functionName: 'searchDanmu',
|
||||
type: 'danmu',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
danmakuSourcesStore: sourcesStore,
|
||||
onDanmakuSourcesChanged: () async {
|
||||
changeNotificationCount++;
|
||||
},
|
||||
);
|
||||
|
||||
await service.installFromSource('widget source');
|
||||
expect(sourcesStore.sources.single.url, 'jsw://danmaku-widget');
|
||||
expect(sourcesStore.sources.single.enabled, isTrue);
|
||||
|
||||
await service.setWidgetEnabled('danmaku-widget', false);
|
||||
expect(sourcesStore.sources.single.enabled, isFalse);
|
||||
expect(changeNotificationCount, 2);
|
||||
});
|
||||
|
||||
test('并发首次调用只加载一次模块运行环境', () async {
|
||||
final timestamp = DateTime.utc(2026, 7, 12);
|
||||
final repository = _MemoryScriptWidgetRepository();
|
||||
repository.widgets['concurrent-widget'] = InstalledScriptWidget(
|
||||
manifest: const ScriptWidgetManifest(
|
||||
id: 'concurrent-widget',
|
||||
title: '并发模块',
|
||||
),
|
||||
scriptSource: 'concurrent source',
|
||||
installedAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
);
|
||||
final runtime = _StaticScriptWidgetRuntime(
|
||||
const ScriptWidgetManifest(id: 'concurrent-widget', title: '并发模块'),
|
||||
)..loadGate = Completer<void>();
|
||||
final service = ScriptWidgetService(
|
||||
repository: repository,
|
||||
remoteGateway: _UnusedRemoteGateway(),
|
||||
runtime: runtime,
|
||||
);
|
||||
|
||||
final firstInvocation = service.invoke(
|
||||
'concurrent-widget',
|
||||
'loadContent',
|
||||
const <String, Object?>{},
|
||||
);
|
||||
final secondInvocation = service.invoke(
|
||||
'concurrent-widget',
|
||||
'loadContent',
|
||||
const <String, Object?>{},
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(runtime.loadCount, 1);
|
||||
runtime.loadGate!.complete();
|
||||
await Future.wait<Object?>(<Future<Object?>>[
|
||||
firstInvocation,
|
||||
secondInvocation,
|
||||
]);
|
||||
expect(runtime.loadCount, 1);
|
||||
});
|
||||
|
||||
test('运行环境失效后下一次调用会重新加载', () async {
|
||||
final timestamp = DateTime.utc(2026, 7, 12);
|
||||
final repository = _MemoryScriptWidgetRepository();
|
||||
repository.widgets['reload-widget'] = InstalledScriptWidget(
|
||||
manifest: const ScriptWidgetManifest(id: 'reload-widget', title: '重载模块'),
|
||||
scriptSource: 'reload source',
|
||||
installedAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
);
|
||||
final runtime = _StaticScriptWidgetRuntime(
|
||||
const ScriptWidgetManifest(id: 'reload-widget', title: '重载模块'),
|
||||
);
|
||||
final service = ScriptWidgetService(
|
||||
repository: repository,
|
||||
remoteGateway: _UnusedRemoteGateway(),
|
||||
runtime: runtime,
|
||||
);
|
||||
|
||||
await service.invoke(
|
||||
'reload-widget',
|
||||
'loadContent',
|
||||
const <String, Object?>{},
|
||||
);
|
||||
runtime.loadedWidgetIds.clear();
|
||||
await service.invoke(
|
||||
'reload-widget',
|
||||
'loadContent',
|
||||
const <String, Object?>{},
|
||||
);
|
||||
|
||||
expect(runtime.loadCount, 2);
|
||||
});
|
||||
|
||||
test('远程更新返回不同模块 ID 时拒绝覆盖已安装模块', () async {
|
||||
final timestamp = DateTime.utc(2026, 7, 12);
|
||||
final repository = _MemoryScriptWidgetRepository();
|
||||
final installedWidget = InstalledScriptWidget(
|
||||
manifest: const ScriptWidgetManifest(id: 'original-widget', title: '原模块'),
|
||||
scriptSource: 'original source',
|
||||
sourceUrl: 'https://example.com/original.js',
|
||||
installedAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
);
|
||||
repository.widgets['original-widget'] = installedWidget;
|
||||
final runtime = _StaticScriptWidgetRuntime(
|
||||
const ScriptWidgetManifest(id: 'other-widget', title: '其他模块'),
|
||||
);
|
||||
final service = ScriptWidgetService(
|
||||
repository: repository,
|
||||
remoteGateway: const _StaticRemoteGateway('replacement source'),
|
||||
runtime: runtime,
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
service.refreshWidget('original-widget'),
|
||||
throwsA(isA<DomainError>()),
|
||||
);
|
||||
|
||||
expect(repository.widgets['original-widget'], installedWidget);
|
||||
expect(repository.widgets.containsKey('other-widget'), isFalse);
|
||||
expect(runtime.loadCount, 0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/contracts/server.dart';
|
||||
import 'package:smplayer/core/domain/ports/server_repository.dart';
|
||||
import 'package:smplayer/core/domain/usecases/server_usecases.dart';
|
||||
|
||||
class _CapturingServerRepository implements ServerRepository {
|
||||
List<EmbyServer>? replacedServers;
|
||||
|
||||
@override
|
||||
Future<void> replaceAll(List<EmbyServer> servers) async {
|
||||
replacedServers = List<EmbyServer>.of(servers);
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('ReorderServersUseCase', () {
|
||||
test('persists the provided server order using replaceAll', () async {
|
||||
final repository = _CapturingServerRepository();
|
||||
final usecase = ReorderServersUseCase(serverRepository: repository);
|
||||
final originalServers = <EmbyServer>[
|
||||
_server(id: 'first'),
|
||||
_server(id: 'second'),
|
||||
_server(id: 'third'),
|
||||
];
|
||||
final reorderedServers = <EmbyServer>[
|
||||
originalServers[1],
|
||||
originalServers[2],
|
||||
originalServers[0],
|
||||
];
|
||||
|
||||
await usecase.execute(reorderedServers);
|
||||
|
||||
expect(repository.replacedServers?.map((server) => server.id), [
|
||||
'second',
|
||||
'third',
|
||||
'first',
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
EmbyServer _server({required String id}) {
|
||||
return EmbyServer(
|
||||
id: id,
|
||||
name: 'Server $id',
|
||||
baseUrl: 'https://$id.example.com/emby',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/contracts/auth.dart';
|
||||
import 'package:smplayer/core/contracts/cancellation.dart';
|
||||
import 'package:smplayer/core/contracts/library.dart';
|
||||
import 'package:smplayer/core/contracts/server.dart';
|
||||
import 'package:smplayer/core/domain/ports/emby_gateway.dart';
|
||||
import 'package:smplayer/core/domain/ports/server_repository.dart';
|
||||
import 'package:smplayer/core/domain/ports/session_repository.dart';
|
||||
import 'package:smplayer/core/domain/usecases/media_usecases.dart';
|
||||
|
||||
class _FakeSessionRepository implements SessionRepository {
|
||||
_FakeSessionRepository(this.sessions);
|
||||
final List<SessionData> sessions;
|
||||
|
||||
@override
|
||||
Future<List<SessionData>> listAll() async => sessions;
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _FakeServerRepository implements ServerRepository {
|
||||
_FakeServerRepository(this.servers);
|
||||
final List<EmbyServer> servers;
|
||||
|
||||
@override
|
||||
Future<List<EmbyServer>> list() async => servers;
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _FakeEmbyGateway implements EmbyGateway {
|
||||
_FakeEmbyGateway(this.onGetLibraryItems);
|
||||
final Future<List<EmbyRawItem>> Function(AuthedRequestContext ctx)
|
||||
onGetLibraryItems;
|
||||
|
||||
@override
|
||||
Future<List<EmbyRawItem>> getLibraryItems({
|
||||
required AuthedRequestContext ctx,
|
||||
String? parentId,
|
||||
String? includeItemTypes,
|
||||
List<String>? genreIds,
|
||||
String? searchTerm,
|
||||
String? anyProviderIdEquals,
|
||||
List<String>? personIds,
|
||||
String? fields,
|
||||
String? enableImageTypes,
|
||||
bool? groupProgramsBySeries,
|
||||
int? imageTypeLimit,
|
||||
int? startIndex,
|
||||
int? limit,
|
||||
bool? recursive,
|
||||
String? sortBy,
|
||||
SortOrder? sortOrder,
|
||||
String? filters,
|
||||
CancellationToken? cancelToken,
|
||||
}) => onGetLibraryItems(ctx);
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
EmbyServer _server(String id) => EmbyServer(
|
||||
id: id,
|
||||
name: id,
|
||||
baseUrl: 'http://$id',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
);
|
||||
|
||||
SessionData _session(String serverId) => SessionData(
|
||||
serverId: serverId,
|
||||
token: 'token-$serverId',
|
||||
userId: 'user-$serverId',
|
||||
userName: serverId,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
);
|
||||
|
||||
EmbyRawItem _item(String id) => EmbyRawItem(Id: id, Name: id, Type: 'Movie');
|
||||
|
||||
TmdbAvailabilityUseCase _uc({
|
||||
required List<String> serverIds,
|
||||
required Future<List<EmbyRawItem>> Function(AuthedRequestContext ctx)
|
||||
onGetLibraryItems,
|
||||
Duration settleTimeout = const Duration(milliseconds: 100),
|
||||
}) => TmdbAvailabilityUseCase(
|
||||
sessionRepository: _FakeSessionRepository(
|
||||
serverIds.map(_session).toList(growable: false),
|
||||
),
|
||||
serverRepository: _FakeServerRepository(
|
||||
serverIds.map(_server).toList(growable: false),
|
||||
),
|
||||
embyGateway: _FakeEmbyGateway(onGetLibraryItems),
|
||||
settleTimeout: settleTimeout,
|
||||
);
|
||||
|
||||
void main() {
|
||||
const req = TmdbAvailabilityReq(tmdbId: '42', mediaType: 'movie');
|
||||
|
||||
test('慢服务器挂起:settleTimeout 先发已完成快照,迟到命中追加补全', () async {
|
||||
final hang = Completer<List<EmbyRawItem>>();
|
||||
final uc = _uc(
|
||||
serverIds: ['fast', 'slow'],
|
||||
onGetLibraryItems: (ctx) => ctx.baseUrl == 'http://slow'
|
||||
? hang.future
|
||||
: Future.value([_item('item-fast')]),
|
||||
);
|
||||
|
||||
final emissions = <List<String>>[];
|
||||
final done = Completer<void>();
|
||||
uc
|
||||
.executeStream(req)
|
||||
.listen(
|
||||
(hits) => emissions.add(
|
||||
hits.map((h) => h.serverId).toList(growable: false),
|
||||
),
|
||||
onDone: done.complete,
|
||||
);
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
expect(emissions, [
|
||||
['fast'],
|
||||
]);
|
||||
|
||||
hang.complete([_item('item-slow')]);
|
||||
await done.future;
|
||||
expect(emissions, hasLength(2));
|
||||
expect(emissions.last, containsAll(['fast', 'slow']));
|
||||
});
|
||||
|
||||
test('全部服务器先于 settleTimeout 返回:只发一次快照并立即关流', () async {
|
||||
final uc = _uc(
|
||||
serverIds: ['a', 'b'],
|
||||
onGetLibraryItems: (ctx) => Future.value([_item('item-${ctx.baseUrl}')]),
|
||||
settleTimeout: const Duration(seconds: 10),
|
||||
);
|
||||
|
||||
final sw = Stopwatch()..start();
|
||||
final emissions = await uc.executeStream(req).toList();
|
||||
sw.stop();
|
||||
|
||||
expect(sw.elapsed, lessThan(const Duration(seconds: 2)));
|
||||
expect(emissions, hasLength(1));
|
||||
expect(emissions.single.map((h) => h.serverId), containsAll(['a', 'b']));
|
||||
});
|
||||
|
||||
test('迟到探测未命中(null)不重复发相同快照', () async {
|
||||
final hang = Completer<List<EmbyRawItem>>();
|
||||
final uc = _uc(
|
||||
serverIds: ['fast', 'slow'],
|
||||
onGetLibraryItems: (ctx) => ctx.baseUrl == 'http://slow'
|
||||
? hang.future
|
||||
: Future.value([_item('item-fast')]),
|
||||
);
|
||||
|
||||
final emissionsFuture = uc.executeStream(req).toList();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
hang.complete(const []);
|
||||
final emissions = await emissionsFuture;
|
||||
|
||||
expect(emissions, hasLength(1));
|
||||
expect(emissions.single.map((h) => h.serverId), ['fast']);
|
||||
});
|
||||
|
||||
test('settleTimeout 默认 3 秒', () {
|
||||
final uc = TmdbAvailabilityUseCase(
|
||||
sessionRepository: _FakeSessionRepository(const []),
|
||||
serverRepository: _FakeServerRepository(const []),
|
||||
embyGateway: _FakeEmbyGateway((_) async => const []),
|
||||
);
|
||||
expect(uc.settleTimeout, const Duration(seconds: 3));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/infra/emby_api/emby_headers.dart';
|
||||
|
||||
void main() {
|
||||
group('EmbyRequestHeaders.media', () {
|
||||
test('媒体流 UA 使用基础 UA', () {
|
||||
final headers = EmbyRequestHeaders.media(
|
||||
deviceId: '6f43c257-2d5e-407e-9827-0a59d37ceb97',
|
||||
deviceName: 'TestDevice',
|
||||
token: 't',
|
||||
userId: 'u',
|
||||
);
|
||||
|
||||
expect(headers['User-Agent'], EmbyRequestHeaders.userAgent);
|
||||
expect(headers['Accept'], '*/*');
|
||||
expect(headers['Accept-Language'], EmbyRequestHeaders.acceptLanguage);
|
||||
});
|
||||
});
|
||||
|
||||
group('EmbyRequestHeaders.directMedia', () {
|
||||
test('播放器直连头不包含凭据或设备标识', () {
|
||||
final headers = EmbyRequestHeaders.directMedia();
|
||||
final normalizedNames = headers.keys
|
||||
.map((name) => name.toLowerCase())
|
||||
.toSet();
|
||||
|
||||
expect(headers['User-Agent'], EmbyRequestHeaders.userAgent);
|
||||
expect(headers['Accept'], '*/*');
|
||||
expect(normalizedNames, isNot(contains('authorization')));
|
||||
expect(normalizedNames, isNot(contains('cookie')));
|
||||
expect(normalizedNames, isNot(contains('x-emby-token')));
|
||||
expect(normalizedNames, isNot(contains('x-mediabrowser-token')));
|
||||
expect(normalizedNames, isNot(contains('x-emby-authorization')));
|
||||
expect(normalizedNames, isNot(contains('x-emby-device-id')));
|
||||
});
|
||||
});
|
||||
|
||||
group('SenPlayer 身份对齐', () {
|
||||
test('UA 为 SenPlayer 固定值', () {
|
||||
expect(EmbyRequestHeaders.userAgent, 'SenPlayer/6.2.0');
|
||||
expect(EmbyRequestHeaders.clientName, 'SenPlayer');
|
||||
expect(EmbyRequestHeaders.version, '6.1.0');
|
||||
});
|
||||
|
||||
test('auth 头字面格式与 SenPlayer 身份一致', () {
|
||||
final auth = EmbyAuthHeader.build(
|
||||
deviceId: 'd',
|
||||
deviceName: 'Mac',
|
||||
token: 't',
|
||||
userId: 'u',
|
||||
);
|
||||
expect(
|
||||
auth,
|
||||
'MediaBrowser Token="t", Emby UserId="u", Client="SenPlayer", '
|
||||
'Device="Mac", DeviceId="d", Version="6.1.0"',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/contracts/library.dart';
|
||||
import 'package:smplayer/core/contracts/playback.dart';
|
||||
import 'package:smplayer/core/domain/ports/emby_gateway.dart';
|
||||
import 'package:smplayer/core/infra/emby_api/playback_resolver.dart';
|
||||
|
||||
|
||||
class _UnusedEmbyGateway implements EmbyGateway {
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
void main() {
|
||||
const ctx = AuthedRequestContext(
|
||||
baseUrl: 'https://emby.example.com',
|
||||
token: 'tok123',
|
||||
userId: 'user1',
|
||||
);
|
||||
|
||||
final resolver = PlaybackResolver(
|
||||
gateway: _UnusedEmbyGateway(),
|
||||
deviceId: 'device-1',
|
||||
deviceName: 'TestBox',
|
||||
);
|
||||
|
||||
EmbyRawItem item({int? resumeTicks}) => EmbyRawItem(
|
||||
Id: 'item1',
|
||||
Name: 'Movie One',
|
||||
Type: 'Movie',
|
||||
RunTimeTicks: 600 * 10000000,
|
||||
UserData: resumeTicks == null
|
||||
? null
|
||||
: EmbyRawUserData(PlaybackPositionTicks: resumeTicks),
|
||||
);
|
||||
|
||||
Map<String, dynamic> playbackInfo(Map<String, dynamic> source) => {
|
||||
'PlaySessionId': 'ps1',
|
||||
'MediaSources': [source],
|
||||
};
|
||||
|
||||
group('PlaybackResolver.prepare 流地址优先级', () {
|
||||
test('DirectStreamUrl 优先,playMethod=DirectStream', () async {
|
||||
final result = await resolver.prepare(
|
||||
ctx: ctx,
|
||||
payload: const PlaybackRequestPayload(itemId: 'item1'),
|
||||
preloadedItem: item(),
|
||||
preloadedPlaybackInfo: playbackInfo({
|
||||
'Id': 's1',
|
||||
'Container': 'mkv',
|
||||
'DirectStreamUrl': '/Videos/item1/stream.mkv?x=1',
|
||||
'TranscodingUrl': '/Videos/item1/main.m3u8',
|
||||
'SupportsDirectStream': true,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
result.streamUrl,
|
||||
'https://emby.example.com/Videos/item1/stream.mkv?x=1&api_key=tok123',
|
||||
);
|
||||
expect(result.playMethod, 'DirectStream');
|
||||
expect(result.directStream, isTrue);
|
||||
});
|
||||
|
||||
test('无 DirectStreamUrl 时用 TranscodingUrl,playMethod=Transcode', () async {
|
||||
final result = await resolver.prepare(
|
||||
ctx: ctx,
|
||||
payload: const PlaybackRequestPayload(itemId: 'item1'),
|
||||
preloadedItem: item(),
|
||||
preloadedPlaybackInfo: playbackInfo({
|
||||
'Id': 's1',
|
||||
'Container': 'mkv',
|
||||
'TranscodingUrl': '/Videos/item1/main.m3u8',
|
||||
}),
|
||||
);
|
||||
expect(result.streamUrl, contains('/Videos/item1/main.m3u8'));
|
||||
expect(result.streamUrl, contains('api_key=tok123'));
|
||||
expect(result.playMethod, 'Transcode');
|
||||
});
|
||||
|
||||
test('跨域绝对 CDN 地址不追加 Emby token', () async {
|
||||
final result = await resolver.prepare(
|
||||
ctx: ctx,
|
||||
payload: const PlaybackRequestPayload(itemId: 'item1'),
|
||||
preloadedItem: item(),
|
||||
preloadedPlaybackInfo: playbackInfo({
|
||||
'Id': 's1',
|
||||
'Container': 'mkv',
|
||||
'DirectStreamUrl':
|
||||
'https://cdn.example.net/media/movie.mkv?signature=signed',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
result.streamUrl,
|
||||
'https://cdn.example.net/media/movie.mkv?signature=signed',
|
||||
);
|
||||
expect(result.streamUrl, isNot(contains('tok123')));
|
||||
expect(result.streamUrl, isNot(contains('api_key')));
|
||||
});
|
||||
|
||||
test('部署在子路径下时保留 Emby base path', () async {
|
||||
const subpathContext = AuthedRequestContext(
|
||||
baseUrl: 'https://emby.example.com/emby',
|
||||
token: 'tok123',
|
||||
userId: 'user1',
|
||||
);
|
||||
final result = await resolver.prepare(
|
||||
ctx: subpathContext,
|
||||
payload: const PlaybackRequestPayload(itemId: 'item1'),
|
||||
preloadedItem: item(),
|
||||
preloadedPlaybackInfo: playbackInfo({
|
||||
'Id': 's1',
|
||||
'Container': 'mkv',
|
||||
'DirectStreamUrl': '/Videos/item1/stream.mkv',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
result.streamUrl,
|
||||
'https://emby.example.com/emby/Videos/item1/stream.mkv?api_key=tok123',
|
||||
);
|
||||
});
|
||||
|
||||
test('两者皆无时回退静态流(含容器扩展名与鉴权参数)', () async {
|
||||
final result = await resolver.prepare(
|
||||
ctx: ctx,
|
||||
payload: const PlaybackRequestPayload(itemId: 'item1'),
|
||||
preloadedItem: item(),
|
||||
preloadedPlaybackInfo: playbackInfo({'Id': 's1', 'Container': 'mp4'}),
|
||||
);
|
||||
expect(
|
||||
result.streamUrl,
|
||||
startsWith('https://emby.example.com/Videos/item1/stream.mp4?'),
|
||||
);
|
||||
expect(result.streamUrl, contains('api_key=tok123'));
|
||||
expect(result.streamUrl, contains('MediaSourceId=s1'));
|
||||
expect(result.streamUrl, contains('PlaySessionId=ps1'));
|
||||
expect(result.playMethod, 'DirectStream');
|
||||
});
|
||||
|
||||
test('按 mediaSourceId 选源,未命中回退首源', () async {
|
||||
final info = {
|
||||
'MediaSources': [
|
||||
{'Id': 's1', 'DirectStreamUrl': '/Videos/item1/a.mkv'},
|
||||
{'Id': 's2', 'DirectStreamUrl': '/Videos/item1/b.mkv'},
|
||||
],
|
||||
};
|
||||
final picked = await resolver.prepare(
|
||||
ctx: ctx,
|
||||
payload: const PlaybackRequestPayload(
|
||||
itemId: 'item1',
|
||||
mediaSourceId: 's2',
|
||||
),
|
||||
preloadedItem: item(),
|
||||
preloadedPlaybackInfo: info,
|
||||
);
|
||||
expect(picked.streamUrl, contains('/Videos/item1/b.mkv'));
|
||||
|
||||
final fallback = await resolver.prepare(
|
||||
ctx: ctx,
|
||||
payload: const PlaybackRequestPayload(
|
||||
itemId: 'item1',
|
||||
mediaSourceId: 'missing',
|
||||
),
|
||||
preloadedItem: item(),
|
||||
preloadedPlaybackInfo: info,
|
||||
);
|
||||
expect(fallback.streamUrl, contains('/Videos/item1/a.mkv'));
|
||||
});
|
||||
});
|
||||
|
||||
group('PlaybackResolver.prepare 续播位置', () {
|
||||
Future<double> startSecondsFor(PlaybackRequestPayload payload) async {
|
||||
final result = await resolver.prepare(
|
||||
ctx: ctx,
|
||||
payload: payload,
|
||||
preloadedItem: item(resumeTicks: 120 * 10000000),
|
||||
preloadedPlaybackInfo: playbackInfo({
|
||||
'Id': 's1',
|
||||
'DirectStreamUrl': '/Videos/item1/a.mkv',
|
||||
}),
|
||||
);
|
||||
return result.startPositionSeconds;
|
||||
}
|
||||
|
||||
test('playFromStart 强制从头', () async {
|
||||
expect(
|
||||
await startSecondsFor(
|
||||
const PlaybackRequestPayload(itemId: 'item1', playFromStart: true),
|
||||
),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('显式 startPositionTicks 优先于 UserData', () async {
|
||||
expect(
|
||||
await startSecondsFor(
|
||||
const PlaybackRequestPayload(
|
||||
itemId: 'item1',
|
||||
startPositionTicks: 60 * 10000000,
|
||||
),
|
||||
),
|
||||
60,
|
||||
);
|
||||
});
|
||||
|
||||
test('默认取 UserData.PlaybackPositionTicks', () async {
|
||||
expect(
|
||||
await startSecondsFor(const PlaybackRequestPayload(itemId: 'item1')),
|
||||
120,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('PlaybackResolver.resolveSubtitles', () {
|
||||
test('未提供 baseUrl/token 时外挂轨全部过滤,仅留内封(详尽用例见 '
|
||||
'playback_resolver_subtitles_test.dart)', () {
|
||||
final subtitles = PlaybackResolver.resolveSubtitles(const [
|
||||
EmbyRawMediaStream(Index: 1, Type: 'Subtitle', Title: 'embedded'),
|
||||
EmbyRawMediaStream(
|
||||
Index: 2,
|
||||
Type: 'Subtitle',
|
||||
Codec: 'srt',
|
||||
IsExternal: true,
|
||||
),
|
||||
EmbyRawMediaStream(
|
||||
Index: 3,
|
||||
Type: 'Subtitle',
|
||||
Codec: 'srt',
|
||||
DeliveryMethod: 'External',
|
||||
),
|
||||
EmbyRawMediaStream(
|
||||
Index: 4,
|
||||
Type: 'Subtitle',
|
||||
Codec: 'srt',
|
||||
DeliveryUrl: '/subs/4.srt',
|
||||
),
|
||||
EmbyRawMediaStream(Index: 5, Type: 'Audio'),
|
||||
]);
|
||||
expect(subtitles, hasLength(1));
|
||||
expect(subtitles.single.index, 1);
|
||||
expect(subtitles.single.title, 'embedded');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/contracts/library.dart';
|
||||
import 'package:smplayer/core/infra/emby_api/playback_resolver.dart';
|
||||
|
||||
void main() {
|
||||
const baseUrl = 'https://emby.example.com';
|
||||
const token = 'tok123';
|
||||
const itemId = 'item1';
|
||||
const mediaSourceId = 'src9';
|
||||
|
||||
EmbyRawMediaStream embeddedSubtitle({
|
||||
int index = 2,
|
||||
String codec = 'subrip',
|
||||
String? title,
|
||||
String? language,
|
||||
bool isDefault = false,
|
||||
bool isForced = false,
|
||||
}) => EmbyRawMediaStream(
|
||||
Index: index,
|
||||
Type: 'Subtitle',
|
||||
Codec: codec,
|
||||
Title: title,
|
||||
Language: language,
|
||||
IsDefault: isDefault,
|
||||
IsForced: isForced,
|
||||
);
|
||||
|
||||
EmbyRawMediaStream externalSubtitle({
|
||||
int index = 10,
|
||||
String codec = 'srt',
|
||||
String? deliveryUrl = '/Videos/item1/10/Subtitles/stream.srt',
|
||||
String? deliveryMethod = 'External',
|
||||
bool isExternal = true,
|
||||
String? title,
|
||||
String? language,
|
||||
}) => EmbyRawMediaStream(
|
||||
Index: index,
|
||||
Type: 'Subtitle',
|
||||
Codec: codec,
|
||||
IsExternal: isExternal,
|
||||
DeliveryMethod: deliveryMethod,
|
||||
DeliveryUrl: deliveryUrl,
|
||||
Title: title,
|
||||
Language: language,
|
||||
);
|
||||
|
||||
group('PlaybackResolver.resolveSubtitles 外挂字幕', () {
|
||||
test('内封轨保留且 isExternal=false(未传 itemId 时无抽取 URL)', () {
|
||||
final subtitles = PlaybackResolver.resolveSubtitles(
|
||||
[embeddedSubtitle(index: 2, title: '简中', language: 'chi')],
|
||||
baseUrl: baseUrl,
|
||||
token: token,
|
||||
);
|
||||
expect(subtitles, hasLength(1));
|
||||
expect(subtitles.single.index, 2);
|
||||
expect(subtitles.single.isExternal, isFalse);
|
||||
expect(subtitles.single.deliveryUrl, isNull);
|
||||
expect(subtitles.single.codec, 'subrip');
|
||||
});
|
||||
|
||||
test('外挂文本轨保留,deliveryUrl 补全 base 与 api_key', () {
|
||||
final subtitles = PlaybackResolver.resolveSubtitles(
|
||||
[externalSubtitle(index: 10, codec: 'srt', language: 'chi')],
|
||||
baseUrl: baseUrl,
|
||||
token: token,
|
||||
);
|
||||
expect(subtitles, hasLength(1));
|
||||
final track = subtitles.single;
|
||||
expect(track.isExternal, isTrue);
|
||||
expect(
|
||||
track.deliveryUrl,
|
||||
'$baseUrl/Videos/item1/10/Subtitles/stream.srt?api_key=$token',
|
||||
);
|
||||
});
|
||||
|
||||
test('deliveryUrl 已是绝对地址时不重复补 base,只补 api_key', () {
|
||||
final subtitles = PlaybackResolver.resolveSubtitles(
|
||||
[
|
||||
externalSubtitle(
|
||||
deliveryUrl: 'https://cdn.example.com/subs/a.ass',
|
||||
codec: 'ass',
|
||||
),
|
||||
],
|
||||
baseUrl: baseUrl,
|
||||
token: token,
|
||||
);
|
||||
expect(
|
||||
subtitles.single.deliveryUrl,
|
||||
'https://cdn.example.com/subs/a.ass?api_key=$token',
|
||||
);
|
||||
});
|
||||
|
||||
test('外挂位图轨(pgs/vobsub/dvdsub)被过滤', () {
|
||||
for (final bitmapCodec in ['pgssub', 'pgs', 'dvdsub', 'sup', 'vobsub']) {
|
||||
final subtitles = PlaybackResolver.resolveSubtitles(
|
||||
[externalSubtitle(codec: bitmapCodec)],
|
||||
baseUrl: baseUrl,
|
||||
token: token,
|
||||
);
|
||||
expect(subtitles, isEmpty, reason: 'codec=$bitmapCodec 应被过滤');
|
||||
}
|
||||
});
|
||||
|
||||
test('内封位图轨保留(由引擎渲染,行为不变)', () {
|
||||
final subtitles = PlaybackResolver.resolveSubtitles(
|
||||
[embeddedSubtitle(codec: 'pgssub')],
|
||||
baseUrl: baseUrl,
|
||||
token: token,
|
||||
);
|
||||
expect(subtitles, hasLength(1));
|
||||
expect(subtitles.single.isExternal, isFalse);
|
||||
});
|
||||
|
||||
test('外挂轨缺 DeliveryUrl 时被过滤', () {
|
||||
final subtitles = PlaybackResolver.resolveSubtitles(
|
||||
[externalSubtitle(deliveryUrl: null)],
|
||||
baseUrl: baseUrl,
|
||||
token: token,
|
||||
);
|
||||
expect(subtitles, isEmpty);
|
||||
});
|
||||
|
||||
test('未提供 baseUrl/token 时外挂轨被过滤(内封不受影响)', () {
|
||||
final subtitles = PlaybackResolver.resolveSubtitles([
|
||||
embeddedSubtitle(index: 2),
|
||||
externalSubtitle(index: 10),
|
||||
]);
|
||||
expect(subtitles, hasLength(1));
|
||||
expect(subtitles.single.index, 2);
|
||||
});
|
||||
|
||||
test('仅 DeliveryMethod==External(无 IsExternal 标记)也按外挂处理', () {
|
||||
final subtitles = PlaybackResolver.resolveSubtitles(
|
||||
[externalSubtitle(isExternal: false, deliveryMethod: 'External')],
|
||||
baseUrl: baseUrl,
|
||||
token: token,
|
||||
);
|
||||
expect(subtitles.single.isExternal, isTrue);
|
||||
});
|
||||
|
||||
test('混合列表保持原顺序,各归其类', () {
|
||||
final subtitles = PlaybackResolver.resolveSubtitles(
|
||||
[
|
||||
embeddedSubtitle(index: 2, codec: 'ass'),
|
||||
externalSubtitle(index: 10, codec: 'srt'),
|
||||
externalSubtitle(index: 11, codec: 'pgssub'),
|
||||
embeddedSubtitle(index: 3, codec: 'subrip'),
|
||||
],
|
||||
baseUrl: baseUrl,
|
||||
token: token,
|
||||
);
|
||||
expect(subtitles.map((t) => t.index).toList(), [2, 10, 3]);
|
||||
expect(subtitles.map((t) => t.isExternal).toList(), [false, true, false]);
|
||||
});
|
||||
});
|
||||
|
||||
group('PlaybackResolver.resolveSubtitles 内封文本抽取地址', () {
|
||||
test('内封文本轨生成服务端抽取 deliveryUrl(isExternal 仍为 false)', () {
|
||||
final subtitles = PlaybackResolver.resolveSubtitles(
|
||||
[embeddedSubtitle(index: 2, codec: 'subrip', language: 'chi')],
|
||||
baseUrl: baseUrl,
|
||||
token: token,
|
||||
itemId: itemId,
|
||||
mediaSourceId: mediaSourceId,
|
||||
);
|
||||
expect(subtitles, hasLength(1));
|
||||
final track = subtitles.single;
|
||||
expect(track.isExternal, isFalse);
|
||||
|
||||
|
||||
expect(
|
||||
track.deliveryUrl,
|
||||
'$baseUrl/Videos/$itemId/$mediaSourceId/Subtitles/2/0/Stream.subrip'
|
||||
'?api_key=$token',
|
||||
);
|
||||
});
|
||||
|
||||
test('扩展名用 codec 原名 passthrough(webvtt 归一化为 vtt)', () {
|
||||
final cases = {'subrip': 'subrip', 'ssa': 'ssa', 'webvtt': 'vtt'};
|
||||
cases.forEach((codec, ext) {
|
||||
final subtitles = PlaybackResolver.resolveSubtitles(
|
||||
[embeddedSubtitle(index: 4, codec: codec)],
|
||||
baseUrl: baseUrl,
|
||||
token: token,
|
||||
itemId: itemId,
|
||||
mediaSourceId: mediaSourceId,
|
||||
);
|
||||
expect(
|
||||
subtitles.single.deliveryUrl,
|
||||
'$baseUrl/Videos/$itemId/$mediaSourceId/Subtitles/4/0/Stream.$ext'
|
||||
'?api_key=$token',
|
||||
reason: 'codec=$codec 应映射为 .$ext',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('srt/ass/vtt codec 直接用作扩展名,含 /0/ 段', () {
|
||||
final cases = {'srt': 'srt', 'ass': 'ass', 'vtt': 'vtt'};
|
||||
cases.forEach((codec, ext) {
|
||||
final subtitles = PlaybackResolver.resolveSubtitles(
|
||||
[embeddedSubtitle(index: 5, codec: codec)],
|
||||
baseUrl: baseUrl,
|
||||
token: token,
|
||||
itemId: itemId,
|
||||
mediaSourceId: mediaSourceId,
|
||||
);
|
||||
expect(
|
||||
subtitles.single.deliveryUrl,
|
||||
endsWith('/Subtitles/5/0/Stream.$ext?api_key=$token'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('内封位图轨不生成抽取 URL(deliveryUrl 为 null)', () {
|
||||
for (final bitmapCodec in ['pgssub', 'pgs', 'dvdsub', 'vobsub', 'sup']) {
|
||||
final subtitles = PlaybackResolver.resolveSubtitles(
|
||||
[embeddedSubtitle(index: 6, codec: bitmapCodec)],
|
||||
baseUrl: baseUrl,
|
||||
token: token,
|
||||
itemId: itemId,
|
||||
mediaSourceId: mediaSourceId,
|
||||
);
|
||||
expect(subtitles, hasLength(1));
|
||||
expect(
|
||||
subtitles.single.deliveryUrl,
|
||||
isNull,
|
||||
reason: 'codec=$bitmapCodec 不应生成抽取 URL',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('缺 mediaSourceId 时内封文本轨不生成抽取 URL', () {
|
||||
final subtitles = PlaybackResolver.resolveSubtitles(
|
||||
[embeddedSubtitle(index: 2, codec: 'subrip')],
|
||||
baseUrl: baseUrl,
|
||||
token: token,
|
||||
itemId: itemId,
|
||||
);
|
||||
expect(subtitles.single.deliveryUrl, isNull);
|
||||
});
|
||||
|
||||
test('外挂轨不受内封抽取逻辑影响,仍用自身 DeliveryUrl', () {
|
||||
final subtitles = PlaybackResolver.resolveSubtitles(
|
||||
[
|
||||
embeddedSubtitle(index: 2, codec: 'subrip'),
|
||||
externalSubtitle(index: 10, codec: 'srt'),
|
||||
],
|
||||
baseUrl: baseUrl,
|
||||
token: token,
|
||||
itemId: itemId,
|
||||
mediaSourceId: mediaSourceId,
|
||||
);
|
||||
final embedded = subtitles.firstWhere((t) => !t.isExternal);
|
||||
final external = subtitles.firstWhere((t) => t.isExternal);
|
||||
expect(
|
||||
embedded.deliveryUrl,
|
||||
'$baseUrl/Videos/$itemId/$mediaSourceId/Subtitles/2/0/Stream.subrip'
|
||||
'?api_key=$token',
|
||||
);
|
||||
expect(
|
||||
external.deliveryUrl,
|
||||
'$baseUrl/Videos/item1/10/Subtitles/stream.srt?api_key=$token',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('PlaybackResolver.isTextSubtitleCodec', () {
|
||||
test('文本 codec 判定大小写不敏感', () {
|
||||
for (final textCodec in [
|
||||
'srt',
|
||||
'SUBRIP',
|
||||
'vtt',
|
||||
'WebVTT',
|
||||
'ass',
|
||||
'ssa',
|
||||
]) {
|
||||
expect(
|
||||
PlaybackResolver.isTextSubtitleCodec(textCodec),
|
||||
isTrue,
|
||||
reason: 'codec=$textCodec',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('位图/未知/null codec 返回 false', () {
|
||||
for (final nonText in ['pgssub', 'dvdsub', 'sup', 'unknown', null]) {
|
||||
expect(
|
||||
PlaybackResolver.isTextSubtitleCodec(nonText),
|
||||
isFalse,
|
||||
reason: 'codec=$nonText',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/infra/player_engine/fvp_player_engine.dart';
|
||||
|
||||
void main() {
|
||||
group('isFvpNetworkError', () {
|
||||
test('命中 tcp:/tls: 前缀', () {
|
||||
expect(isFvpNetworkError('tcp: connection lost'), isTrue);
|
||||
expect(isFvpNetworkError('tls: certificate verify failed'), isTrue);
|
||||
});
|
||||
|
||||
test('命中 TLS 层关键字(Windows 直连的主要场景)', () {
|
||||
expect(isFvpNetworkError('TLS handshake failed'), isTrue);
|
||||
expect(isFvpNetworkError('SSL_read: SSL_ERROR_SSL, WANT_WRITE'), isTrue);
|
||||
expect(
|
||||
isFvpNetworkError('reader: tls: handshake error (err=-1)'),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('命中连接层关键字', () {
|
||||
expect(isFvpNetworkError('Connection refused'), isTrue);
|
||||
expect(isFvpNetworkError('connection reset by peer'), isTrue);
|
||||
expect(isFvpNetworkError('connection timed out'), isTrue);
|
||||
expect(isFvpNetworkError('Network is unreachable'), isTrue);
|
||||
expect(isFvpNetworkError('Host not found'), isTrue);
|
||||
expect(isFvpNetworkError('name resolution failed'), isTrue);
|
||||
expect(isFvpNetworkError('No route to host'), isTrue);
|
||||
});
|
||||
|
||||
test('命中 HTTP 状态码文本', () {
|
||||
expect(isFvpNetworkError('HTTP error 403 Forbidden'), isTrue);
|
||||
expect(isFvpNetworkError('HTTP error 502 Bad Gateway'), isTrue);
|
||||
expect(isFvpNetworkError('Server returned 401 Unauthorized'), isTrue);
|
||||
expect(isFvpNetworkError('Server returned 503'), isTrue);
|
||||
});
|
||||
|
||||
test('命中 ffmpeg 协议缺失(TLS 后端被剥离时的典型 detail)', () {
|
||||
expect(isFvpNetworkError('Protocol not found'), isTrue);
|
||||
});
|
||||
|
||||
test('不命中解码器与非网络错误', () {
|
||||
expect(isFvpNetworkError('Failed to allocate decoder'), isFalse);
|
||||
expect(isFvpNetworkError('Error while decoding frame'), isFalse);
|
||||
expect(isFvpNetworkError('unsupported pixel format'), isFalse);
|
||||
expect(isFvpNetworkError('decoder timeout waiting for frame'), isFalse);
|
||||
});
|
||||
|
||||
test('空串与无关文本不命中', () {
|
||||
expect(isFvpNetworkError(''), isFalse);
|
||||
expect(isFvpNetworkError('reader.buffering'), isFalse);
|
||||
expect(isFvpNetworkError('render.video 1st_frame'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('fvpNetworkErrorPayload', () {
|
||||
test('只转换 IO 类别中的已识别网络错误', () {
|
||||
expect(
|
||||
fvpNetworkErrorPayload(
|
||||
category: 'reader.error',
|
||||
errorCode: -1,
|
||||
detail: 'Connection reset by peer',
|
||||
),
|
||||
'reader: Connection reset by peer (err=-1)',
|
||||
);
|
||||
});
|
||||
|
||||
test('忽略 demuxer 解码错误和非负事件', () {
|
||||
expect(
|
||||
fvpNetworkErrorPayload(
|
||||
category: 'demuxer.error',
|
||||
errorCode: -1,
|
||||
detail: 'Invalid data found when processing input',
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
fvpNetworkErrorPayload(
|
||||
category: 'reader.error',
|
||||
errorCode: 0,
|
||||
detail: 'Connection reset by peer',
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('忽略非 IO 类别,即使 detail 含网络词', () {
|
||||
expect(
|
||||
fvpNetworkErrorPayload(
|
||||
category: 'decoder.video',
|
||||
errorCode: -1,
|
||||
detail: 'TLS handshake failed',
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart'
|
||||
show ValueNotifier, debugDefaultTargetPlatformOverride;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/infra/player_engine/media3_player_engine.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
const methodChannel = MethodChannel('smplayer/media3_engine');
|
||||
const eventChannel = MethodChannel('smplayer/media3_engine/events');
|
||||
|
||||
setUp(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(methodChannel, (call) async {
|
||||
switch (call.method) {
|
||||
case 'create':
|
||||
return <String, dynamic>{
|
||||
'playerId': 7,
|
||||
'ffmpegAvailable': true,
|
||||
'ffmpegVersion': 'test-ffmpeg',
|
||||
};
|
||||
case 'open':
|
||||
case 'setVolume':
|
||||
case 'setResizeMode':
|
||||
case 'replayKeyState':
|
||||
case 'dispose':
|
||||
return null;
|
||||
}
|
||||
fail('Unexpected media3 method call: ${call.method}');
|
||||
});
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(eventChannel, (call) async => null);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(methodChannel, null);
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(eventChannel, null);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
group('Media3PlayerEngine 平台守卫', () {
|
||||
test('桌面(isAndroid=false)构造抛 UnsupportedError', () {
|
||||
expect(
|
||||
() => Media3PlayerEngine(isAndroid: false),
|
||||
throwsA(isA<UnsupportedError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('Android(isAndroid=true)构造不抛', () {
|
||||
final engine = Media3PlayerEngine(isAndroid: true);
|
||||
addTearDown(engine.dispose);
|
||||
expect(engine.displayName, 'media3');
|
||||
});
|
||||
|
||||
test('open 成功后请求 native 重放关键状态', () async {
|
||||
final calls = <String>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(methodChannel, (call) async {
|
||||
calls.add(call.method);
|
||||
switch (call.method) {
|
||||
case 'create':
|
||||
return <String, dynamic>{
|
||||
'playerId': 7,
|
||||
'ffmpegAvailable': true,
|
||||
'ffmpegVersion': 'test-ffmpeg',
|
||||
};
|
||||
case 'open':
|
||||
case 'setVolume':
|
||||
case 'replayKeyState':
|
||||
case 'dispose':
|
||||
return null;
|
||||
}
|
||||
fail('Unexpected media3 method call: ${call.method}');
|
||||
});
|
||||
|
||||
final engine = Media3PlayerEngine(isAndroid: true);
|
||||
addTearDown(engine.dispose);
|
||||
|
||||
await engine.open('http://127.0.0.1/video.mkv');
|
||||
|
||||
expect(calls, containsAllInOrder(<String>['open', 'replayKeyState']));
|
||||
});
|
||||
});
|
||||
|
||||
group('Media3PlayerEngine Android 视频视图', () {
|
||||
test('源码固定使用 Hybrid Composition,避免旧设备 TLHC/HC fallback 改变 HDR 路径', () {
|
||||
final source = File(
|
||||
'lib/core/infra/player_engine/media3_player_engine.dart',
|
||||
).readAsStringSync();
|
||||
|
||||
expect(source, contains('initExpensiveAndroidView'));
|
||||
expect(source, isNot(contains('initSurfaceAndroidView')));
|
||||
});
|
||||
|
||||
test('native SurfaceView 不主动改 z-order,避免 Flutter HC 下黑屏', () {
|
||||
final source = File(
|
||||
'plugins/media3_engine/android/src/main/kotlin/com/smplayer/media3_engine/Media3PlatformView.kt',
|
||||
).readAsStringSync();
|
||||
|
||||
expect(source, isNot(contains('setZOrderMediaOverlay(true)')));
|
||||
expect(source, isNot(contains('setZOrderOnTop(true)')));
|
||||
});
|
||||
|
||||
testWidgets('playerId 就位后使用原生 SurfaceView PlatformView', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
try {
|
||||
final engine = Media3PlayerEngine(isAndroid: true);
|
||||
final fit = ValueNotifier<BoxFit>(BoxFit.contain);
|
||||
addTearDown(engine.dispose);
|
||||
addTearDown(fit.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: Builder(
|
||||
builder: (context) =>
|
||||
SizedBox.expand(child: engine.buildVideoView(context, fit)),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.byType(PlatformViewLink), findsNothing);
|
||||
|
||||
await engine.open('http://127.0.0.1/video.mkv');
|
||||
await tester.pump();
|
||||
|
||||
final view = tester.widget<PlatformViewLink>(
|
||||
find.byType(PlatformViewLink),
|
||||
);
|
||||
expect(view.viewType, 'smplayer/media3_surface');
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('open 等待 native ready 时仍先挂载视频 PlatformView', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
final openCompleter = Completer<Object?>();
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(methodChannel, (call) async {
|
||||
switch (call.method) {
|
||||
case 'create':
|
||||
return <String, dynamic>{
|
||||
'playerId': 7,
|
||||
'ffmpegAvailable': true,
|
||||
'ffmpegVersion': 'test-ffmpeg',
|
||||
};
|
||||
case 'open':
|
||||
return openCompleter.future;
|
||||
case 'setVolume':
|
||||
case 'setResizeMode':
|
||||
case 'replayKeyState':
|
||||
case 'dispose':
|
||||
return null;
|
||||
}
|
||||
fail('Unexpected media3 method call: ${call.method}');
|
||||
});
|
||||
|
||||
try {
|
||||
final engine = Media3PlayerEngine(isAndroid: true);
|
||||
final fit = ValueNotifier<BoxFit>(BoxFit.contain);
|
||||
addTearDown(engine.dispose);
|
||||
addTearDown(fit.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: Builder(
|
||||
builder: (context) =>
|
||||
SizedBox.expand(child: engine.buildVideoView(context, fit)),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.byType(PlatformViewLink), findsNothing);
|
||||
|
||||
final openFuture = engine.open('http://127.0.0.1/video.mkv');
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(openCompleter.isCompleted, isFalse);
|
||||
expect(find.byType(PlatformViewLink), findsOneWidget);
|
||||
|
||||
openCompleter.complete();
|
||||
await openFuture;
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('playerId 就位与 fit 变化会同步 resize mode 到 native', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
final resizeModes = <String>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(methodChannel, (call) async {
|
||||
switch (call.method) {
|
||||
case 'create':
|
||||
return <String, dynamic>{
|
||||
'playerId': 7,
|
||||
'ffmpegAvailable': true,
|
||||
'ffmpegVersion': 'test-ffmpeg',
|
||||
};
|
||||
case 'open':
|
||||
case 'setVolume':
|
||||
case 'dispose':
|
||||
case 'replayKeyState':
|
||||
return null;
|
||||
case 'setResizeMode':
|
||||
expect(call.arguments, containsPair('playerId', 7));
|
||||
resizeModes.add(
|
||||
(call.arguments as Map<Object?, Object?>)['mode']! as String,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
fail('Unexpected media3 method call: ${call.method}');
|
||||
});
|
||||
|
||||
try {
|
||||
final engine = Media3PlayerEngine(isAndroid: true);
|
||||
final fit = ValueNotifier<BoxFit>(BoxFit.contain);
|
||||
addTearDown(engine.dispose);
|
||||
addTearDown(fit.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: Builder(
|
||||
builder: (context) =>
|
||||
SizedBox.expand(child: engine.buildVideoView(context, fit)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await engine.open('http://127.0.0.1/video.mkv');
|
||||
await tester.pump();
|
||||
expect(resizeModes, <String>['fit']);
|
||||
|
||||
fit.value = BoxFit.fill;
|
||||
await tester.pump();
|
||||
expect(resizeModes, <String>['fit', 'fill']);
|
||||
|
||||
fit.value = BoxFit.cover;
|
||||
await tester.pump();
|
||||
expect(resizeModes, <String>['fit', 'fill', 'zoom']);
|
||||
|
||||
fit.value = BoxFit.contain;
|
||||
await tester.pump();
|
||||
expect(resizeModes, <String>['fit', 'fill', 'zoom', 'fit']);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('Media3 native deferred prepare', () {
|
||||
test('源码包含 surface 到达超时和 pending open 取消完成逻辑', () {
|
||||
final source = File(
|
||||
'plugins/media3_engine/android/src/main/kotlin/com/smplayer/media3_engine/Media3PlayerInstance.kt',
|
||||
).readAsStringSync();
|
||||
|
||||
expect(source, contains('SURFACE_ATTACH_TIMEOUT_MS'));
|
||||
expect(source, contains('surface not attached within timeout'));
|
||||
expect(source, contains('cancelPendingOpen'));
|
||||
expect(source, contains('open_cancelled'));
|
||||
expect(source, contains('clearPendingSurfaceTimeout'));
|
||||
});
|
||||
|
||||
test('MethodChannel 暴露 replayKeyState', () {
|
||||
final source = File(
|
||||
'plugins/media3_engine/android/src/main/kotlin/com/smplayer/media3_engine/Media3EnginePlugin.kt',
|
||||
).readAsStringSync();
|
||||
|
||||
expect(source, contains('"replayKeyState"'));
|
||||
expect(source, contains('player.replayKeyState()'));
|
||||
});
|
||||
});
|
||||
|
||||
group('Media3 single-flight _ensureCreated', () {
|
||||
test('并发 prewarmNative + open 只执行一次 native create', () async {
|
||||
int createCallCount = 0;
|
||||
final createCompleter = Completer<Object?>();
|
||||
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(methodChannel, (call) async {
|
||||
switch (call.method) {
|
||||
case 'create':
|
||||
createCallCount++;
|
||||
return createCompleter.future;
|
||||
case 'open':
|
||||
expect(
|
||||
(call.arguments as Map)['playerId'],
|
||||
equals(42),
|
||||
reason: 'open must use the playerId returned by create',
|
||||
);
|
||||
return null;
|
||||
case 'setVolume':
|
||||
case 'replayKeyState':
|
||||
case 'dispose':
|
||||
return null;
|
||||
}
|
||||
fail('Unexpected media3 method call: ${call.method}');
|
||||
});
|
||||
|
||||
final engine = Media3PlayerEngine(isAndroid: true);
|
||||
addTearDown(engine.dispose);
|
||||
|
||||
final prewarmFuture = engine.prewarmNative();
|
||||
final openFuture = engine.open('http://127.0.0.1/video.mkv');
|
||||
|
||||
expect(
|
||||
createCallCount,
|
||||
equals(1),
|
||||
reason: 'concurrent prewarm + open should share one create call',
|
||||
);
|
||||
|
||||
createCompleter.complete(<String, dynamic>{
|
||||
'playerId': 42,
|
||||
'ffmpegAvailable': true,
|
||||
'ffmpegVersion': 'test-ffmpeg',
|
||||
});
|
||||
|
||||
await prewarmFuture;
|
||||
await openFuture;
|
||||
|
||||
expect(
|
||||
createCallCount,
|
||||
equals(1),
|
||||
reason: 'create must have been called exactly once',
|
||||
);
|
||||
});
|
||||
|
||||
test('dispose 期间 create 返回时释放孤儿 native player', () async {
|
||||
final createCompleter = Completer<Object?>();
|
||||
final disposedPlayerIds = <int>[];
|
||||
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(methodChannel, (call) async {
|
||||
switch (call.method) {
|
||||
case 'create':
|
||||
return createCompleter.future;
|
||||
case 'dispose':
|
||||
final disposePlayerId =
|
||||
(call.arguments as Map)['playerId'] as int;
|
||||
disposedPlayerIds.add(disposePlayerId);
|
||||
return null;
|
||||
case 'setVolume':
|
||||
return null;
|
||||
}
|
||||
fail('Unexpected media3 method call: ${call.method}');
|
||||
});
|
||||
|
||||
final engine = Media3PlayerEngine(isAndroid: true);
|
||||
|
||||
final prewarmFuture = engine.prewarmNative();
|
||||
|
||||
await engine.dispose();
|
||||
|
||||
createCompleter.complete(<String, dynamic>{
|
||||
'playerId': 99,
|
||||
'ffmpegAvailable': true,
|
||||
'ffmpegVersion': 'test-ffmpeg',
|
||||
});
|
||||
|
||||
await prewarmFuture;
|
||||
|
||||
expect(
|
||||
disposedPlayerIds,
|
||||
contains(99),
|
||||
reason: 'orphan native player created after dispose must be released',
|
||||
);
|
||||
});
|
||||
|
||||
test('create 失败后允许下次重试', () async {
|
||||
int createAttempts = 0;
|
||||
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(methodChannel, (call) async {
|
||||
switch (call.method) {
|
||||
case 'create':
|
||||
createAttempts++;
|
||||
if (createAttempts == 1) {
|
||||
throw PlatformException(
|
||||
code: 'INIT_FAILED',
|
||||
message: 'simulated first-create failure',
|
||||
);
|
||||
}
|
||||
return <String, dynamic>{
|
||||
'playerId': 7,
|
||||
'ffmpegAvailable': true,
|
||||
'ffmpegVersion': 'test-ffmpeg',
|
||||
};
|
||||
case 'open':
|
||||
case 'setVolume':
|
||||
case 'replayKeyState':
|
||||
case 'dispose':
|
||||
return null;
|
||||
}
|
||||
fail('Unexpected media3 method call: ${call.method}');
|
||||
});
|
||||
|
||||
final engine = Media3PlayerEngine(isAndroid: true);
|
||||
addTearDown(engine.dispose);
|
||||
|
||||
expect(engine.prewarmNative(), throwsA(isA<PlatformException>()));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
await engine.open('http://127.0.0.1/video.mkv');
|
||||
expect(createAttempts, equals(2), reason: 'retry after first failure');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/infra/player_engine/media_kit_player_engine.dart';
|
||||
|
||||
void main() {
|
||||
group('isMpvNetworkError', () {
|
||||
test('命中 tcp:/tls: 前缀', () {
|
||||
expect(
|
||||
isMpvNetworkError('tcp: ffurl_read returned 0xffffff99'),
|
||||
isTrue,
|
||||
);
|
||||
expect(isMpvNetworkError('tls: error during handshake'), isTrue);
|
||||
});
|
||||
|
||||
test('命中 HTTP open 失败', () {
|
||||
expect(
|
||||
isMpvNetworkError(
|
||||
'Failed to open https://cdn.example/v.mkv.',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('命中连接类关键词', () {
|
||||
expect(isMpvNetworkError('connection reset by peer'), isTrue);
|
||||
expect(isMpvNetworkError('Connection refused'), isTrue);
|
||||
expect(isMpvNetworkError('connection timed out'), isTrue);
|
||||
expect(isMpvNetworkError('Network is unreachable'), isTrue);
|
||||
expect(isMpvNetworkError('Host not found'), isTrue);
|
||||
expect(isMpvNetworkError('name resolution failed'), isTrue);
|
||||
});
|
||||
|
||||
test('命中 TLS 与网络超时', () {
|
||||
expect(isMpvNetworkError('TLS handshake failed'), isTrue);
|
||||
expect(isMpvNetworkError('connection timed out'), isTrue);
|
||||
});
|
||||
|
||||
test('不命中非网络 timeout 文本', () {
|
||||
expect(isMpvNetworkError('decoder timeout waiting for frame'), isFalse);
|
||||
expect(isMpvNetworkError('cplayer timeout while waiting'), isFalse);
|
||||
});
|
||||
|
||||
test('命中 HTTP 错误码', () {
|
||||
expect(isMpvNetworkError('http error 403'), isTrue);
|
||||
expect(isMpvNetworkError('http error 502'), isTrue);
|
||||
});
|
||||
|
||||
test('不命中解码器错误', () {
|
||||
expect(isMpvNetworkError('Error while decoding frame'), isFalse);
|
||||
expect(isMpvNetworkError('Failed to open decoder'), isFalse);
|
||||
});
|
||||
|
||||
test('空串不命中', () {
|
||||
expect(isMpvNetworkError(''), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/infra/player_engine/player_engine.dart';
|
||||
|
||||
void main() {
|
||||
group('streamOpenErrorHttpStatus', () {
|
||||
test('从回填的 error 串解析状态码', () {
|
||||
expect(
|
||||
streamOpenErrorHttpStatus('Failed to open http://h/v.mp4. (http 403)'),
|
||||
403,
|
||||
);
|
||||
expect(streamOpenErrorHttpStatus('boom (http 502)'), 502);
|
||||
});
|
||||
|
||||
test('无状态码返回 null', () {
|
||||
expect(streamOpenErrorHttpStatus('Failed to open http://h/v.mp4.'), null);
|
||||
expect(streamOpenErrorHttpStatus('connection reset by peer'), null);
|
||||
expect(streamOpenErrorHttpStatus(''), null);
|
||||
});
|
||||
});
|
||||
|
||||
group('isTerminalStreamOpenError', () {
|
||||
test('客户端 4xx 视为确定性失败(跳过重连)', () {
|
||||
expect(isTerminalStreamOpenError('open failed (http 403)'), isTrue);
|
||||
expect(isTerminalStreamOpenError('open failed (http 404)'), isTrue);
|
||||
expect(isTerminalStreamOpenError('open failed (http 410)'), isTrue);
|
||||
expect(isTerminalStreamOpenError('open failed (http 401)'), isTrue);
|
||||
});
|
||||
|
||||
test('瞬时类 408/429 仍允许重连', () {
|
||||
expect(isTerminalStreamOpenError('open failed (http 408)'), isFalse);
|
||||
expect(isTerminalStreamOpenError('open failed (http 429)'), isFalse);
|
||||
});
|
||||
|
||||
test('5xx 与无状态码不视为确定性失败', () {
|
||||
expect(isTerminalStreamOpenError('open failed (http 502)'), isFalse);
|
||||
expect(isTerminalStreamOpenError('open failed (http 503)'), isFalse);
|
||||
expect(isTerminalStreamOpenError('Failed to open http://h/v.mp4.'), isFalse);
|
||||
expect(isTerminalStreamOpenError('connection reset by peer'), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,662 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fjs/fjs.dart';
|
||||
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'
|
||||
show ExternalLibrary;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:smplayer/core/contracts/error.dart';
|
||||
import 'package:smplayer/core/domain/errors.dart';
|
||||
import 'package:smplayer/core/infra/script_widget/quickjs_widget_runtime.dart';
|
||||
import 'package:smplayer/core/infra/script_widget/safe_script_http_client.dart';
|
||||
import 'package:smplayer/core/infra/script_widget/widget_host_bridge.dart';
|
||||
|
||||
const String _widgetScript = r'''
|
||||
const WidgetMetadata = {
|
||||
id: 'runtime-test',
|
||||
title: 'Runtime Test',
|
||||
version: '1.0.0',
|
||||
requiredVersion: '0.0.1',
|
||||
modules: []
|
||||
};
|
||||
|
||||
async function inspectHost(params) {
|
||||
const document = Widget.html.load(
|
||||
'<main><a class="item" href="/detail"> 示例内容 </a></main>'
|
||||
);
|
||||
const item = document('.item').first();
|
||||
return {
|
||||
value: params.value,
|
||||
storedValue: Widget.storage.get('seed'),
|
||||
text: item.text().trim(),
|
||||
href: item.attr('href')
|
||||
};
|
||||
}
|
||||
|
||||
async function loadDetail(link) {
|
||||
return { id: 'detail', title: link };
|
||||
}
|
||||
''';
|
||||
|
||||
|
||||
Future<bool> _tryInitFjs() async {
|
||||
final environmentPath = Platform.environment['FJS_LIBRARY_PATH'];
|
||||
final candidatePaths = <String>[
|
||||
if (environmentPath != null && environmentPath.isNotEmpty) environmentPath,
|
||||
'build/fjs_native/release/libfjs.dylib',
|
||||
'build/fjs_native/release/libfjs.so',
|
||||
'build/fjs_native/release/fjs.dll',
|
||||
];
|
||||
for (final path in candidatePaths) {
|
||||
if (!File(path).existsSync()) continue;
|
||||
try {
|
||||
await LibFjs.init(externalLibrary: ExternalLibrary.open(path));
|
||||
return true;
|
||||
} on StateError {
|
||||
return true;
|
||||
} catch (_) {}
|
||||
}
|
||||
try {
|
||||
await LibFjs.init();
|
||||
return true;
|
||||
} on StateError {
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> main() async {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final fjsReady = await _tryInitFjs();
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{
|
||||
'smplayer.script_widget.storage.runtime-test': jsonEncode(
|
||||
<String, Object?>{'seed': 'persisted'},
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
group(
|
||||
'QuickJsWidgetRuntime',
|
||||
() {
|
||||
test('加载元数据并调用宿主 storage 与 HTML API', () async {
|
||||
final runtime = QuickJsWidgetRuntime();
|
||||
addTearDown(runtime.dispose);
|
||||
|
||||
final manifest = await runtime.loadWidget(_widgetScript);
|
||||
final result = await runtime.invokeModuleFunction(
|
||||
manifest.id,
|
||||
'inspectHost',
|
||||
<String, Object?>{'value': 7},
|
||||
);
|
||||
|
||||
expect(manifest.title, 'Runtime Test');
|
||||
expect(result, <String, Object?>{
|
||||
'value': 7,
|
||||
'storedValue': 'persisted',
|
||||
'text': '示例内容',
|
||||
'href': '/detail',
|
||||
});
|
||||
});
|
||||
|
||||
test('ForwardWidgets 原始参数调用保持字符串参数', () async {
|
||||
final runtime = QuickJsWidgetRuntime();
|
||||
addTearDown(runtime.dispose);
|
||||
await runtime.loadWidget(_widgetScript);
|
||||
|
||||
final result = await runtime.invokeModuleFunction(
|
||||
'runtime-test',
|
||||
'loadDetail',
|
||||
<String, Object?>{
|
||||
'__forwardRawArgument': 'https://example.com/detail/42',
|
||||
'link': 'ignored wrapper value',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result, <String, Object?>{
|
||||
'id': 'detail',
|
||||
'title': 'https://example.com/detail/42',
|
||||
});
|
||||
});
|
||||
|
||||
test('卸载模块后拒绝继续调用', () async {
|
||||
final runtime = QuickJsWidgetRuntime();
|
||||
addTearDown(runtime.dispose);
|
||||
await runtime.loadWidget(_widgetScript);
|
||||
await runtime.unloadWidget('runtime-test');
|
||||
|
||||
await expectLater(
|
||||
runtime.invokeModuleFunction(
|
||||
'runtime-test',
|
||||
'inspectHost',
|
||||
<String, Object?>{},
|
||||
),
|
||||
throwsA(isA<DomainError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('DOM 助手覆盖 closest、顶层 text 与 console.debug', () async {
|
||||
final runtime = QuickJsWidgetRuntime();
|
||||
addTearDown(runtime.dispose);
|
||||
await runtime.loadWidget(_domHelpersWidgetScript);
|
||||
|
||||
final result = await runtime.invokeModuleFunction(
|
||||
'runtime-dom-test',
|
||||
'inspectDomHelpers',
|
||||
<String, Object?>{},
|
||||
);
|
||||
|
||||
expect(result, <String, Object?>{
|
||||
'honeypotAncestorClass': 'col-lg-8',
|
||||
'noAncestorForNormal': true,
|
||||
'topLevelText': '你好世界',
|
||||
'consoleDebugCallable': true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
test('Widget.dom 句柄 API 支持 parse/select/text/attr/html', () async {
|
||||
final runtime = QuickJsWidgetRuntime();
|
||||
addTearDown(runtime.dispose);
|
||||
await runtime.loadWidget(_domHandleWidgetScript);
|
||||
|
||||
final result = await runtime.invokeModuleFunction(
|
||||
'runtime-dom-handle-test',
|
||||
'inspectDomHandles',
|
||||
<String, Object?>{},
|
||||
);
|
||||
|
||||
expect(result, <String, Object?>{
|
||||
'itemText': '示例',
|
||||
'itemHref': '/detail',
|
||||
'scopedCount': 1,
|
||||
'outerScopedCount': 2,
|
||||
'docHtmlContainsItem': true,
|
||||
'missingAttr': null,
|
||||
});
|
||||
});
|
||||
|
||||
test(
|
||||
'模块使用 statusCode 校验 http 响应时可正常拿到抓取的 HTML',
|
||||
() async {
|
||||
final httpClient = _StubbedScriptHttpClient(
|
||||
responseHtmlByUrl: <String, String>{
|
||||
'https://sample.local/list': _sampleFakeListHtml,
|
||||
},
|
||||
);
|
||||
final runtime = QuickJsWidgetRuntime(
|
||||
hostBridge: WidgetHostBridge(httpClient: httpClient),
|
||||
);
|
||||
addTearDown(runtime.dispose);
|
||||
await runtime.loadWidget(_httpStatusCodeWidgetScript);
|
||||
|
||||
final rawItems = await runtime.invokeModuleFunction(
|
||||
'runtime-http-test',
|
||||
'fetchItems',
|
||||
<String, Object?>{},
|
||||
);
|
||||
|
||||
expect(rawItems, <Map<String, Object?>>[
|
||||
<String, Object?>{
|
||||
'title': '正常条目',
|
||||
'link': 'https://sample.local/normal',
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
test('模块超时后运行环境被卸载,宿主 Future 迟到完成不会出错', () async {
|
||||
final httpClient = _StubbedScriptHttpClient(
|
||||
responseHtmlByUrl: <String, String>{
|
||||
'https://sample.local/list': _sampleFakeListHtml,
|
||||
},
|
||||
responseDelay: const Duration(milliseconds: 150),
|
||||
);
|
||||
final runtime = QuickJsWidgetRuntime(
|
||||
hostBridge: WidgetHostBridge(httpClient: httpClient),
|
||||
invocationTimeout: const Duration(milliseconds: 50),
|
||||
);
|
||||
addTearDown(runtime.dispose);
|
||||
await runtime.loadWidget(_httpStatusCodeWidgetScript);
|
||||
|
||||
await expectLater(
|
||||
runtime.invokeModuleFunction(
|
||||
'runtime-http-test',
|
||||
'fetchItems',
|
||||
<String, Object?>{},
|
||||
),
|
||||
throwsA(
|
||||
isA<DomainError>().having(
|
||||
(error) => error.code,
|
||||
'code',
|
||||
ErrorCode.serverTimeout,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 250));
|
||||
expect(runtime.isWidgetLoaded('runtime-http-test'), isFalse);
|
||||
});
|
||||
|
||||
test('async 函数返回 List<Map> 时得到规范化后的 Dart 集合', () async {
|
||||
final runtime = QuickJsWidgetRuntime();
|
||||
addTearDown(runtime.dispose);
|
||||
await runtime.loadWidget(_asyncReturnShapesWidgetScript);
|
||||
|
||||
final result = await runtime.invokeModuleFunction(
|
||||
'runtime-async-shapes-test',
|
||||
'returnList',
|
||||
<String, Object?>{},
|
||||
);
|
||||
|
||||
expect(result, isA<List<Object?>>());
|
||||
expect(result, <Map<String, Object?>>[
|
||||
<String, Object?>{'title': '正常', 'link': '/a'},
|
||||
<String, Object?>{'title': '也是正常', 'link': '/b'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('async 函数显式返回 null 时上层拿到 Dart null', () async {
|
||||
final runtime = QuickJsWidgetRuntime();
|
||||
addTearDown(runtime.dispose);
|
||||
await runtime.loadWidget(_asyncReturnShapesWidgetScript);
|
||||
|
||||
final result = await runtime.invokeModuleFunction(
|
||||
'runtime-async-shapes-test',
|
||||
'returnNull',
|
||||
<String, Object?>{},
|
||||
);
|
||||
|
||||
expect(result, isNull);
|
||||
});
|
||||
|
||||
test('async 函数抛异常时映射为 DomainError.internalError', () async {
|
||||
final runtime = QuickJsWidgetRuntime();
|
||||
addTearDown(runtime.dispose);
|
||||
await runtime.loadWidget(_asyncReturnShapesWidgetScript);
|
||||
|
||||
await expectLater(
|
||||
runtime.invokeModuleFunction(
|
||||
'runtime-async-shapes-test',
|
||||
'throwError',
|
||||
<String, Object?>{},
|
||||
),
|
||||
throwsA(
|
||||
isA<DomainError>().having(
|
||||
(error) => error.code,
|
||||
'code',
|
||||
ErrorCode.internalError,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
test('脚本尾部使用 CommonJS 导出时仍能安装并调用', () async {
|
||||
final runtime = QuickJsWidgetRuntime();
|
||||
addTearDown(runtime.dispose);
|
||||
|
||||
final manifest = await runtime.loadWidget(_commonJsExportsWidgetScript);
|
||||
expect(manifest.id, 'runtime-commonjs-test');
|
||||
expect(manifest.modules, hasLength(1));
|
||||
|
||||
final result = await runtime.invokeModuleFunction(
|
||||
manifest.id,
|
||||
'reportEnvironment',
|
||||
<String, Object?>{},
|
||||
);
|
||||
expect(result, <String, Object?>{
|
||||
'hasModule': true,
|
||||
'hasExports': true,
|
||||
'hasGlobal': true,
|
||||
'hasSelf': true,
|
||||
'exportedTitle': 'CommonJS Test',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
test('Widget.storage 支持 Web Storage 风格的 getItem/setItem 别名', () async {
|
||||
final runtime = QuickJsWidgetRuntime();
|
||||
addTearDown(runtime.dispose);
|
||||
await runtime.loadWidget(_storageAliasWidgetScript);
|
||||
|
||||
final result = await runtime.invokeModuleFunction(
|
||||
'runtime-storage-alias-test',
|
||||
'roundTrip',
|
||||
<String, Object?>{},
|
||||
);
|
||||
expect(result, <String, Object?>{
|
||||
'writtenValue': 'session-token-42',
|
||||
'readBackValue': 'session-token-42',
|
||||
'valueAfterRemoval': null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
test(
|
||||
'死循环脚本在 invocationTimeout 后被终止并卸载',
|
||||
() async {
|
||||
final runtime = QuickJsWidgetRuntime(
|
||||
invocationTimeout: const Duration(seconds: 2),
|
||||
);
|
||||
addTearDown(runtime.dispose);
|
||||
await runtime.loadWidget(_infiniteLoopWidgetScript);
|
||||
|
||||
await expectLater(
|
||||
runtime.invokeModuleFunction(
|
||||
'runtime-watchdog-test',
|
||||
'spinForever',
|
||||
<String, Object?>{},
|
||||
),
|
||||
throwsA(
|
||||
isA<DomainError>().having(
|
||||
(error) => error.code,
|
||||
'code',
|
||||
ErrorCode.serverTimeout,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(runtime.isWidgetLoaded('runtime-watchdog-test'), isFalse);
|
||||
},
|
||||
timeout: const Timeout(Duration(seconds: 15)),
|
||||
);
|
||||
},
|
||||
skip: fjsReady
|
||||
? false
|
||||
: '未找到 fjs 原生库(libfjs)。构建方式见本文件 _tryInitFjs 注释。',
|
||||
);
|
||||
|
||||
group('normalizeQuickJsValue', () {
|
||||
test(
|
||||
'Map<dynamic, dynamic> 与 List<dynamic> 递归规范化为 Map<String, dynamic>',
|
||||
() {
|
||||
final normalized = normalizeQuickJsValue(<dynamic, dynamic>{
|
||||
'items': <dynamic>[
|
||||
<dynamic, dynamic>{'id': 'foo', 'value': 1},
|
||||
<dynamic, dynamic>{'id': 'bar', 'value': 2},
|
||||
],
|
||||
'total': 2,
|
||||
});
|
||||
|
||||
expect(normalized, isA<Map<String, dynamic>>());
|
||||
final map = normalized! as Map<String, dynamic>;
|
||||
expect(map['total'], 2);
|
||||
final items = (map['items']! as List<Object?>)
|
||||
.cast<Map<String, dynamic>>();
|
||||
expect(items, hasLength(2));
|
||||
expect(items.first, <String, dynamic>{'id': 'foo', 'value': 1});
|
||||
},
|
||||
);
|
||||
|
||||
test('原始类型与 null 保持不变', () {
|
||||
expect(normalizeQuickJsValue(null), isNull);
|
||||
expect(normalizeQuickJsValue('hello'), 'hello');
|
||||
expect(normalizeQuickJsValue(42), 42);
|
||||
expect(normalizeQuickJsValue(true), true);
|
||||
});
|
||||
|
||||
test('非字符串键会被转为字符串键,避免下游 Map 类型转换失败', () {
|
||||
final normalized = normalizeQuickJsValue(<dynamic, dynamic>{
|
||||
1: 'one',
|
||||
2: 'two',
|
||||
});
|
||||
|
||||
expect(normalized, <String, dynamic>{'1': 'one', '2': 'two'});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const String _domHelpersWidgetScript = r'''
|
||||
const WidgetMetadata = {
|
||||
id: 'runtime-dom-test',
|
||||
title: 'DOM Helpers Test',
|
||||
version: '1.0.0',
|
||||
requiredVersion: '0.0.1',
|
||||
modules: []
|
||||
};
|
||||
|
||||
async function inspectDomHelpers() {
|
||||
const document = Widget.html.load(
|
||||
'<section>'
|
||||
+ '<article class="videos-text-align" data-id="normal">'
|
||||
+ ' <span class="title">正常</span>'
|
||||
+ '</article>'
|
||||
+ '<div class="col-lg-8">'
|
||||
+ ' <article class="videos-text-align" data-id="honeypot">'
|
||||
+ ' <span class="title">蜜罐</span>'
|
||||
+ ' </article>'
|
||||
+ '</div>'
|
||||
+ '</section>'
|
||||
);
|
||||
const normalTitle = document('.videos-text-align').eq(0).find('.title');
|
||||
const honeypotTitle = document('.videos-text-align').eq(1).find('.title');
|
||||
|
||||
const honeypotAncestor = honeypotTitle.closest('.col-lg-8');
|
||||
const normalAncestor = normalTitle.closest('.col-lg-8');
|
||||
|
||||
const paragraphDocument = Widget.html.load('<p>你好世界</p>');
|
||||
const topLevelText = paragraphDocument.text().trim();
|
||||
|
||||
var consoleDebugCallable = false;
|
||||
try {
|
||||
console.debug('探针');
|
||||
consoleDebugCallable = true;
|
||||
} catch (error) {}
|
||||
|
||||
return {
|
||||
honeypotAncestorClass: honeypotAncestor.attr('class'),
|
||||
noAncestorForNormal: normalAncestor.length === 0,
|
||||
topLevelText: topLevelText,
|
||||
consoleDebugCallable: consoleDebugCallable
|
||||
};
|
||||
}
|
||||
''';
|
||||
|
||||
const String _domHandleWidgetScript = r'''
|
||||
const WidgetMetadata = {
|
||||
id: 'runtime-dom-handle-test',
|
||||
title: 'DOM Handle Test',
|
||||
version: '1.0.0',
|
||||
requiredVersion: '0.0.1',
|
||||
modules: []
|
||||
};
|
||||
|
||||
async function inspectDomHandles() {
|
||||
const docHandle = Widget.dom.parse(
|
||||
'<main>'
|
||||
+ '<div class="wrap"><a class="item" href="/detail">示例</a></div>'
|
||||
+ '<a class="item" href="/other">其他</a>'
|
||||
+ '</main>'
|
||||
);
|
||||
const wrapHandles = Widget.dom.select(docHandle, '.wrap');
|
||||
const scopedItems = Widget.dom.select(wrapHandles[0], '.item');
|
||||
const allItems = Widget.dom.select(docHandle, '.item');
|
||||
return {
|
||||
itemText: Widget.dom.text(scopedItems[0]),
|
||||
itemHref: Widget.dom.attr(scopedItems[0], 'href'),
|
||||
scopedCount: scopedItems.length,
|
||||
outerScopedCount: allItems.length,
|
||||
docHtmlContainsItem: Widget.dom.html(docHandle).indexOf('class="item"') >= 0,
|
||||
missingAttr: Widget.dom.attr(scopedItems[0], 'data-missing')
|
||||
};
|
||||
}
|
||||
''';
|
||||
|
||||
const String _asyncReturnShapesWidgetScript = r'''
|
||||
const WidgetMetadata = {
|
||||
id: 'runtime-async-shapes-test',
|
||||
title: 'Async Return Shapes Test',
|
||||
version: '1.0.0',
|
||||
requiredVersion: '0.0.1',
|
||||
modules: []
|
||||
};
|
||||
|
||||
async function returnList() {
|
||||
return [
|
||||
{ title: '正常', link: '/a' },
|
||||
{ title: '也是正常', link: '/b' }
|
||||
];
|
||||
}
|
||||
|
||||
async function returnNull() {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function throwError() {
|
||||
throw new Error('模块脚本内的显式错误');
|
||||
}
|
||||
''';
|
||||
|
||||
const String _commonJsExportsWidgetScript = r'''
|
||||
const WidgetMetadata = {
|
||||
id: 'runtime-commonjs-test',
|
||||
title: 'CommonJS Test',
|
||||
version: '1.0.0',
|
||||
requiredVersion: '0.0.1',
|
||||
modules: [
|
||||
{
|
||||
title: '环境探测',
|
||||
functionName: 'reportEnvironment',
|
||||
params: []
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
async function reportEnvironment() {
|
||||
return {
|
||||
hasModule: typeof module === 'object' && module !== null,
|
||||
hasExports: typeof exports === 'object' && exports !== null,
|
||||
hasGlobal: typeof global === 'object' && global !== null,
|
||||
hasSelf: typeof self === 'object' && self !== null,
|
||||
exportedTitle: module.exports.metadata ? module.exports.metadata.title : ''
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
metadata: WidgetMetadata,
|
||||
reportEnvironment: reportEnvironment
|
||||
};
|
||||
''';
|
||||
|
||||
const String _storageAliasWidgetScript = r'''
|
||||
const WidgetMetadata = {
|
||||
id: 'runtime-storage-alias-test',
|
||||
title: 'Storage Alias Test',
|
||||
version: '1.0.0',
|
||||
requiredVersion: '0.0.1',
|
||||
modules: []
|
||||
};
|
||||
|
||||
async function roundTrip() {
|
||||
Widget.storage.setItem('token', 'session-token-42');
|
||||
const written = Widget.storage.getItem('token');
|
||||
Widget.storage.removeItem('token');
|
||||
return {
|
||||
writtenValue: 'session-token-42',
|
||||
readBackValue: written,
|
||||
valueAfterRemoval: Widget.storage.getItem('token') || null
|
||||
};
|
||||
}
|
||||
''';
|
||||
|
||||
const String _infiniteLoopWidgetScript = r'''
|
||||
const WidgetMetadata = {
|
||||
id: 'runtime-watchdog-test',
|
||||
title: 'Watchdog Test',
|
||||
version: '1.0.0',
|
||||
requiredVersion: '0.0.1',
|
||||
modules: []
|
||||
};
|
||||
|
||||
async function spinForever() {
|
||||
var counter = 0;
|
||||
while (true) {
|
||||
counter = (counter + 1) % 1000003;
|
||||
if (counter < 0) break;
|
||||
}
|
||||
return counter;
|
||||
}
|
||||
''';
|
||||
|
||||
const String _httpStatusCodeWidgetScript = r'''
|
||||
const WidgetMetadata = {
|
||||
id: 'runtime-http-test',
|
||||
title: 'HTTP StatusCode Test',
|
||||
version: '1.0.0',
|
||||
requiredVersion: '0.0.1',
|
||||
modules: []
|
||||
};
|
||||
|
||||
async function fetchItems() {
|
||||
const response = await Widget.http.get('https://sample.local/list');
|
||||
if (!response || response.statusCode !== 200) {
|
||||
throw new Error('意料之外的响应:' + (response && response.statusCode));
|
||||
}
|
||||
const document = Widget.html.load(response.data);
|
||||
return Array.from(document('.videos-text-align')).map(function(element) {
|
||||
const wrapper = document(element);
|
||||
const link = wrapper.find('a').attr('href');
|
||||
if (!link) return null;
|
||||
if (wrapper.closest('.col-lg-8').length > 0) return null;
|
||||
return {
|
||||
title: wrapper.find('.title').text().trim(),
|
||||
link: link
|
||||
};
|
||||
}).filter(function(item) { return item != null; });
|
||||
}
|
||||
''';
|
||||
|
||||
const String _sampleFakeListHtml =
|
||||
'<div>'
|
||||
'<div class="videos-text-align">'
|
||||
'<a href="https://sample.local/normal"></a>'
|
||||
'<span class="title">正常条目</span>'
|
||||
'</div>'
|
||||
'<div class="col-lg-8">'
|
||||
'<div class="videos-text-align">'
|
||||
'<a href="https://sample.local/honeypot"></a>'
|
||||
'<span class="title">蜜罐</span>'
|
||||
'</div>'
|
||||
'</div>'
|
||||
'</div>';
|
||||
|
||||
class _StubbedScriptHttpClient extends SafeScriptHttpClient {
|
||||
final Map<String, String> responseHtmlByUrl;
|
||||
final Duration responseDelay;
|
||||
|
||||
_StubbedScriptHttpClient({
|
||||
required this.responseHtmlByUrl,
|
||||
this.responseDelay = Duration.zero,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<SafeScriptHttpResponse> request({
|
||||
required Uri uri,
|
||||
String method = 'GET',
|
||||
Map<String, String> headers = const <String, String>{},
|
||||
Map<String, Object?> queryParameters = const <String, Object?>{},
|
||||
Object? body,
|
||||
bool followRedirects = true,
|
||||
}) async {
|
||||
if (responseDelay > Duration.zero) {
|
||||
await Future<void>.delayed(responseDelay);
|
||||
}
|
||||
final resolvedUrl = uri.toString();
|
||||
final responseHtml = responseHtmlByUrl[resolvedUrl];
|
||||
if (responseHtml == null) {
|
||||
throw StateError('未注册 HTTP mock:$resolvedUrl');
|
||||
}
|
||||
return SafeScriptHttpResponse(
|
||||
data: responseHtml,
|
||||
text: responseHtml,
|
||||
statusCode: 200,
|
||||
reasonPhrase: 'OK',
|
||||
headers: const <String, String>{'content-type': 'text/html'},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/infra/script_widget/safe_script_http_client.dart';
|
||||
|
||||
void main() {
|
||||
group('脚本 HTTP 地址校验', () {
|
||||
test('允许公网 IPv4 与 IPv6 地址', () {
|
||||
expect(isPublicScriptAddress(InternetAddress('8.8.8.8')), isTrue);
|
||||
expect(
|
||||
isPublicScriptAddress(InternetAddress('2606:4700:4700::1111')),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('拒绝真实威胁的 IPv4 地址(本机、LAN、云元数据、多播)', () {
|
||||
const blockedAddresses = <String>[
|
||||
'0.0.0.0',
|
||||
'10.0.0.1',
|
||||
'127.0.0.1',
|
||||
'169.254.169.254',
|
||||
'172.16.0.1',
|
||||
'192.0.0.1',
|
||||
'192.168.1.1',
|
||||
'224.0.0.1',
|
||||
'255.255.255.255',
|
||||
];
|
||||
|
||||
for (final address in blockedAddresses) {
|
||||
expect(
|
||||
isPublicScriptAddress(InternetAddress(address)),
|
||||
isFalse,
|
||||
reason: address,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
'允许 CGNAT、文档、benchmark 段(代理 fake-ip 池会用到)',
|
||||
() {
|
||||
const allowedAddresses = <String>[
|
||||
'100.64.0.1',
|
||||
'192.0.2.1',
|
||||
'198.18.0.31',
|
||||
'198.51.100.1',
|
||||
'203.0.113.1',
|
||||
];
|
||||
|
||||
for (final address in allowedAddresses) {
|
||||
expect(
|
||||
isPublicScriptAddress(InternetAddress(address)),
|
||||
isTrue,
|
||||
reason: address,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('拒绝 IPv6 回环、ULA、链路本地和 IPv4 映射回环', () {
|
||||
const blockedAddresses = <String>[
|
||||
'::',
|
||||
'::1',
|
||||
'::ffff:127.0.0.1',
|
||||
'fc00::1',
|
||||
'fd12:3456::1',
|
||||
'fe80::1',
|
||||
];
|
||||
|
||||
for (final address in blockedAddresses) {
|
||||
expect(
|
||||
isPublicScriptAddress(InternetAddress(address)),
|
||||
isFalse,
|
||||
reason: address,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('允许 IPv6 文档段与 6to4 段(同样常见于 fake-ip 池)', () {
|
||||
const allowedAddresses = <String>[
|
||||
'2001:db8::1',
|
||||
'2002::1',
|
||||
'3fff::1',
|
||||
];
|
||||
|
||||
for (final address in allowedAddresses) {
|
||||
expect(
|
||||
isPublicScriptAddress(InternetAddress(address)),
|
||||
isTrue,
|
||||
reason: address,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('过滤可用地址', () {
|
||||
test('保留 DNS 里能用的公网地址,丢弃真正的保留段地址', () {
|
||||
final filtered = filterPublicScriptAddresses(
|
||||
host: 'example.com',
|
||||
addresses: <InternetAddress>[
|
||||
InternetAddress('fe80::1'),
|
||||
InternetAddress('8.8.8.8'),
|
||||
InternetAddress('10.0.0.5'),
|
||||
],
|
||||
);
|
||||
|
||||
expect(
|
||||
filtered.map((address) => address.address).toList(),
|
||||
<String>['8.8.8.8'],
|
||||
);
|
||||
});
|
||||
|
||||
test('全部为保留段地址时抛错并在消息里列出被拒地址', () {
|
||||
expect(
|
||||
() => filterPublicScriptAddresses(
|
||||
host: 'example.internal',
|
||||
addresses: <InternetAddress>[
|
||||
InternetAddress('10.0.0.1'),
|
||||
InternetAddress('192.168.1.1'),
|
||||
],
|
||||
),
|
||||
throwsA(
|
||||
isA<SafeScriptHttpException>().having(
|
||||
(exception) => exception.message,
|
||||
'message',
|
||||
allOf(
|
||||
contains('example.internal'),
|
||||
contains('10.0.0.1'),
|
||||
contains('192.168.1.1'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('地址列表为空时抛出 host 未解析错误', () {
|
||||
expect(
|
||||
() => filterPublicScriptAddresses(
|
||||
host: 'unresolved.example',
|
||||
addresses: const <InternetAddress>[],
|
||||
),
|
||||
throwsA(
|
||||
isA<SafeScriptHttpException>().having(
|
||||
(exception) => exception.message,
|
||||
'message',
|
||||
contains('unresolved.example'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('脚本 HTTP URI 校验', () {
|
||||
test('仅允许无用户信息的绝对 HTTP 与 HTTPS URI', () {
|
||||
expect(
|
||||
isValidScriptHttpUri(Uri.parse('https://example.com/video')),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
isValidScriptHttpUri(Uri.parse('http://example.com/video')),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
isValidScriptHttpUri(Uri.parse('https://user@example.com/video')),
|
||||
isFalse,
|
||||
);
|
||||
expect(isValidScriptHttpUri(Uri.parse('file:///private/video')), isFalse);
|
||||
expect(isValidScriptHttpUri(Uri.parse('/relative/video')), isFalse);
|
||||
});
|
||||
|
||||
test('拒绝非白名单 HTTP 方法且不会发起网络解析', () async {
|
||||
final client = SafeScriptHttpClient();
|
||||
|
||||
await expectLater(
|
||||
client.request(uri: Uri.parse('https://example.com'), method: 'TRACE'),
|
||||
throwsA(isA<SafeScriptHttpException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('脚本 HTTP 响应上限', () {
|
||||
test('接受不超过上限的分块响应', () async {
|
||||
final responseBytes = await collectScriptResponseBytes(
|
||||
Stream<List<int>>.fromIterable(<List<int>>[
|
||||
<int>[1, 2],
|
||||
<int>[3, 4],
|
||||
]),
|
||||
declaredLength: 4,
|
||||
maximumBytes: 4,
|
||||
);
|
||||
|
||||
expect(responseBytes, <int>[1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
test('在声明长度超过上限时立即拒绝', () async {
|
||||
await expectLater(
|
||||
collectScriptResponseBytes(
|
||||
const Stream<List<int>>.empty(),
|
||||
declaredLength: 5,
|
||||
maximumBytes: 4,
|
||||
),
|
||||
throwsA(isA<SafeScriptHttpException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('在实际流量超过上限时中止读取', () async {
|
||||
await expectLater(
|
||||
collectScriptResponseBytes(
|
||||
Stream<List<int>>.fromIterable(<List<int>>[
|
||||
<int>[1, 2, 3],
|
||||
<int>[4, 5],
|
||||
]),
|
||||
maximumBytes: 4,
|
||||
),
|
||||
throwsA(isA<SafeScriptHttpException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/contracts/script_widget.dart';
|
||||
import 'package:smplayer/core/domain/ports/script_widget_remote_gateway.dart';
|
||||
import 'package:smplayer/core/domain/ports/script_widget_repository.dart';
|
||||
import 'package:smplayer/core/domain/ports/script_widget_runtime.dart';
|
||||
import 'package:smplayer/core/domain/usecases/script_widget/script_widget_service.dart';
|
||||
import 'package:smplayer/core/infra/danmaku_api/script_widget_danmaku_gateway.dart';
|
||||
|
||||
class _SingleWidgetRepository implements ScriptWidgetRepository {
|
||||
_SingleWidgetRepository(this.widget);
|
||||
|
||||
final InstalledScriptWidget widget;
|
||||
|
||||
@override
|
||||
Future<InstalledScriptWidget?> findWidget(String widgetId) async {
|
||||
return widget.manifest.id == widgetId ? widget : null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<InstalledScriptWidget>> listWidgets() async {
|
||||
return <InstalledScriptWidget>[widget];
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _DanmakuScriptRuntime implements ScriptWidgetRuntime {
|
||||
String? lastFunctionName;
|
||||
Map<String, Object?>? lastParams;
|
||||
bool loaded = false;
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
|
||||
@override
|
||||
Future<Object?> invokeModuleFunction(
|
||||
String widgetId,
|
||||
String functionName,
|
||||
Map<String, Object?> params,
|
||||
) async {
|
||||
lastFunctionName = functionName;
|
||||
lastParams = Map<String, Object?>.from(params);
|
||||
return switch (functionName) {
|
||||
'searchDanmu' => <String, Object?>{
|
||||
'animes': <Object?>[
|
||||
<String, Object?>{'animeId': 42, 'animeTitle': '示例剧集'},
|
||||
],
|
||||
},
|
||||
'getDetail' => <String, Object?>{
|
||||
'episodes': <Object?>[
|
||||
<String, Object?>{
|
||||
'episodeId': 101,
|
||||
'episodeTitle': '第 1 集',
|
||||
'episodeNumber': '1',
|
||||
},
|
||||
<String, Object?>{
|
||||
'episodeId': 102,
|
||||
'episodeTitle': '第 2 集',
|
||||
'episodeNumber': '2',
|
||||
},
|
||||
],
|
||||
},
|
||||
'getComments' => <String, Object?>{
|
||||
'comments': <Object?>[
|
||||
<String, Object?>{'cid': 9, 'p': '1.5,1,16711680', 'm': '测试弹幕'},
|
||||
],
|
||||
},
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ScriptWidgetManifest> inspectWidget(String scriptSource) async {
|
||||
return const ScriptWidgetManifest(id: 'danmaku-test', title: 'Danmaku');
|
||||
}
|
||||
|
||||
@override
|
||||
bool isWidgetLoaded(String widgetId) => loaded && widgetId == 'danmaku-test';
|
||||
|
||||
@override
|
||||
Future<ScriptWidgetManifest> loadWidget(String scriptSource) async {
|
||||
loaded = true;
|
||||
return const ScriptWidgetManifest(id: 'danmaku-test', title: 'Danmaku');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> unloadWidget(String widgetId) async {
|
||||
if (widgetId == 'danmaku-test') loaded = false;
|
||||
}
|
||||
}
|
||||
|
||||
class _UnusedRemoteGateway implements ScriptWidgetRemoteGateway {
|
||||
@override
|
||||
Future<String> fetchText(String url) {
|
||||
throw UnsupportedError('This test does not perform remote requests.');
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late _DanmakuScriptRuntime runtime;
|
||||
late ScriptWidgetDanmakuGateway gateway;
|
||||
|
||||
setUp(() {
|
||||
runtime = _DanmakuScriptRuntime();
|
||||
final timestamp = DateTime.utc(2026, 7, 12);
|
||||
final service = ScriptWidgetService(
|
||||
repository: _SingleWidgetRepository(
|
||||
InstalledScriptWidget(
|
||||
manifest: const ScriptWidgetManifest(
|
||||
id: 'danmaku-test',
|
||||
title: 'Danmaku',
|
||||
),
|
||||
scriptSource: 'widget source',
|
||||
installedAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
),
|
||||
),
|
||||
remoteGateway: _UnusedRemoteGateway(),
|
||||
runtime: runtime,
|
||||
);
|
||||
gateway = ScriptWidgetDanmakuGateway(loadService: () async => service);
|
||||
});
|
||||
|
||||
test('文件名中的季集信息映射到 searchDanmu 并筛选目标分集', () async {
|
||||
final matches = await gateway.match(
|
||||
'jsw://danmaku-test',
|
||||
'示例剧集 S02E02',
|
||||
1800,
|
||||
);
|
||||
|
||||
expect(matches, hasLength(1));
|
||||
expect(matches.single.episodeId, 102);
|
||||
expect(matches.single.animeId, 42);
|
||||
expect(matches.single.animeTitle, '示例剧集');
|
||||
});
|
||||
|
||||
test('getComments 响应映射为标准弹幕评论', () async {
|
||||
final comments = await gateway.fetchComments(
|
||||
'jsw://danmaku-test',
|
||||
102,
|
||||
chConvert: 1,
|
||||
);
|
||||
|
||||
expect(runtime.lastFunctionName, 'getComments');
|
||||
expect(runtime.lastParams, <String, Object?>{
|
||||
'commentId': 102,
|
||||
'chConvert': 1,
|
||||
});
|
||||
expect(comments.single.cid, 9);
|
||||
expect(comments.single.time, 1.5);
|
||||
expect(comments.single.text, '测试弹幕');
|
||||
expect(comments.single.color.toARGB32(), 0xFFFF0000);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/contracts/script_widget.dart';
|
||||
import 'package:smplayer/core/infra/storage/json_script_widget_repository.dart';
|
||||
|
||||
void main() {
|
||||
test('模块与订阅可以跨仓储实例持久化并按标题排序', () async {
|
||||
final temporaryDirectory = await Directory.systemTemp.createTemp(
|
||||
'smplayer-script-widget-repository-',
|
||||
);
|
||||
addTearDown(() => temporaryDirectory.delete(recursive: true));
|
||||
final repositoryFile = File(
|
||||
'${temporaryDirectory.path}${Platform.pathSeparator}widgets.json',
|
||||
);
|
||||
final repository = JsonScriptWidgetRepository(file: repositoryFile);
|
||||
final timestamp = DateTime.utc(2026, 7, 12);
|
||||
|
||||
await repository.upsertWidget(
|
||||
_installedWidget(id: 'zeta', title: '最后', timestamp: timestamp),
|
||||
);
|
||||
await repository.upsertWidget(
|
||||
_installedWidget(id: 'alpha', title: '开头', timestamp: timestamp),
|
||||
);
|
||||
await repository.upsertSubscription(
|
||||
ScriptWidgetSubscription(
|
||||
url: 'https://example.com/catalog.fwd',
|
||||
catalog: const ScriptWidgetCatalog(
|
||||
title: '示例订阅',
|
||||
widgets: <ScriptWidgetCatalogEntry>[
|
||||
ScriptWidgetCatalogEntry(
|
||||
id: 'alpha',
|
||||
title: '开头',
|
||||
url: 'https://example.com/alpha.js',
|
||||
),
|
||||
],
|
||||
),
|
||||
refreshedAt: timestamp,
|
||||
),
|
||||
);
|
||||
|
||||
final reloadedRepository = JsonScriptWidgetRepository(file: repositoryFile);
|
||||
final widgets = await reloadedRepository.listWidgets();
|
||||
final subscriptions = await reloadedRepository.listSubscriptions();
|
||||
|
||||
expect(widgets.map((widget) => widget.manifest.id), <String>[
|
||||
'alpha',
|
||||
'zeta',
|
||||
]);
|
||||
expect(subscriptions.single.catalog.title, '示例订阅');
|
||||
expect(
|
||||
subscriptions.single.catalog.widgets.single.url,
|
||||
'https://example.com/alpha.js',
|
||||
);
|
||||
});
|
||||
|
||||
test('损坏的仓储文件安全降级为空状态', () async {
|
||||
final temporaryDirectory = await Directory.systemTemp.createTemp(
|
||||
'smplayer-script-widget-corrupt-',
|
||||
);
|
||||
addTearDown(() => temporaryDirectory.delete(recursive: true));
|
||||
final repositoryFile = File(
|
||||
'${temporaryDirectory.path}${Platform.pathSeparator}widgets.json',
|
||||
);
|
||||
await repositoryFile.writeAsString('{not valid json');
|
||||
|
||||
final repository = JsonScriptWidgetRepository(file: repositoryFile);
|
||||
|
||||
expect(await repository.listWidgets(), isEmpty);
|
||||
expect(await repository.listSubscriptions(), isEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
InstalledScriptWidget _installedWidget({
|
||||
required String id,
|
||||
required String title,
|
||||
required DateTime timestamp,
|
||||
}) {
|
||||
return InstalledScriptWidget(
|
||||
manifest: ScriptWidgetManifest(id: id, title: title),
|
||||
scriptSource: 'const WidgetMetadata = { id: "$id", title: "$title" };',
|
||||
installedAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:smplayer/core/infra/storage/search_history_store.dart';
|
||||
|
||||
void main() {
|
||||
test('旧 JSON 搜索历史读取后迁移为字符串列表', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'smplayer.search_history': jsonEncode(['电影', '剧集']),
|
||||
});
|
||||
|
||||
expect(await SearchHistoryStore().list(), ['电影', '剧集']);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
expect(prefs.getStringList('smplayer.search_history'), ['电影', '剧集']);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:smplayer/core/infra/script_widget/widget_host_bridge.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('sharedCache 按模块隔离且不读取旧全局缓存', () async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{
|
||||
'smplayer.script_widget.shared_cache.widget-a': jsonEncode(
|
||||
<String, Object?>{'owner': 'widget-a', 'value': 1},
|
||||
),
|
||||
'smplayer.script_widget.shared_cache.widget-b': jsonEncode(
|
||||
<String, Object?>{'owner': 'widget-b', 'value': 2},
|
||||
),
|
||||
'smplayer.script_widget.shared_cache': jsonEncode(<String, Object?>{
|
||||
'owner': 'legacy-global',
|
||||
}),
|
||||
});
|
||||
final bridge = WidgetHostBridge();
|
||||
|
||||
final widgetAData = await bridge.loadBootstrapData('widget-a');
|
||||
final widgetBData = await bridge.loadBootstrapData('widget-b');
|
||||
final widgetCData = await bridge.loadBootstrapData('widget-c');
|
||||
|
||||
expect(widgetAData.sharedCache, <String, Object?>{
|
||||
'owner': 'widget-a',
|
||||
'value': 1,
|
||||
});
|
||||
expect(widgetBData.sharedCache, <String, Object?>{
|
||||
'owner': 'widget-b',
|
||||
'value': 2,
|
||||
});
|
||||
expect(widgetCData.sharedCache, isEmpty);
|
||||
});
|
||||
|
||||
test('损坏的模块缓存不会污染其他模块的启动数据', () async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{
|
||||
'smplayer.script_widget.shared_cache.invalid-widget': '{invalid',
|
||||
'smplayer.script_widget.shared_cache.valid-widget': jsonEncode(
|
||||
<String, Object?>{'ready': true},
|
||||
),
|
||||
});
|
||||
final bridge = WidgetHostBridge();
|
||||
|
||||
final invalidWidgetData = await bridge.loadBootstrapData('invalid-widget');
|
||||
final validWidgetData = await bridge.loadBootstrapData('valid-widget');
|
||||
|
||||
expect(invalidWidgetData.sharedCache, isEmpty);
|
||||
expect(validWidgetData.sharedCache, <String, Object?>{'ready': true});
|
||||
});
|
||||
|
||||
group('formatConsoleValue', () {
|
||||
test('展开 JS Error marker 为 name/message/stack 拼接', () {
|
||||
final formatted = formatConsoleValue(<String, Object?>{
|
||||
'__consoleErrorMarker': true,
|
||||
'name': 'TypeError',
|
||||
'message': '视频列表加载失败:网络超时',
|
||||
'stack': 'at getList (91porn_int.js:848)',
|
||||
});
|
||||
|
||||
expect(
|
||||
formatted,
|
||||
'TypeError: 视频列表加载失败:网络超时\nat getList (91porn_int.js:848)',
|
||||
);
|
||||
});
|
||||
|
||||
test('普通 Map 走 JSON 编码而不是 toString()', () {
|
||||
final formatted = formatConsoleValue(<String, Object?>{
|
||||
'status': 403,
|
||||
'reason': 'blocked',
|
||||
});
|
||||
|
||||
expect(formatted, '{"status":403,"reason":"blocked"}');
|
||||
});
|
||||
|
||||
test('字符串和基本类型保持原样', () {
|
||||
expect(formatConsoleValue('hello'), 'hello');
|
||||
expect(formatConsoleValue(42), '42');
|
||||
expect(formatConsoleValue(true), 'true');
|
||||
expect(formatConsoleValue(null), 'null');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/media/embedded_subtitle_cues.dart';
|
||||
import 'package:smplayer/core/media/external_subtitle_loader.dart';
|
||||
|
||||
void main() {
|
||||
group('ExternalSubtitleLoader.parseSubtitleContent — SRT', () {
|
||||
const srt = '''
|
||||
1
|
||||
00:00:01,000 --> 00:00:02,500
|
||||
第一句
|
||||
第二行
|
||||
|
||||
2
|
||||
00:00:04,000 --> 00:00:06,000
|
||||
<i>Second cue</i>
|
||||
''';
|
||||
|
||||
test('解析编号块、时间轴与多行文本,剥 HTML 标签', () {
|
||||
final cues = ExternalSubtitleLoader.parseSubtitleContent(srt);
|
||||
expect(cues, hasLength(2));
|
||||
expect(cues[0].start, const Duration(seconds: 1));
|
||||
expect(cues[0].end, const Duration(milliseconds: 2500));
|
||||
expect(cues[0].text, '第一句\n第二行');
|
||||
expect(cues[1].text, 'Second cue');
|
||||
});
|
||||
|
||||
test('容忍 CRLF 换行与点号毫秒分隔', () {
|
||||
const crlfSrt = '1\r\n00:00:01.000 --> 00:00:02.000\r\nhello\r\n\r\n';
|
||||
final cues = ExternalSubtitleLoader.parseSubtitleContent(crlfSrt);
|
||||
expect(cues, hasLength(1));
|
||||
expect(cues.single.text, 'hello');
|
||||
});
|
||||
|
||||
test('无时间轴的垃圾块被跳过', () {
|
||||
const noisy = '''
|
||||
garbage block
|
||||
|
||||
1
|
||||
00:00:01,000 --> 00:00:02,000
|
||||
ok
|
||||
''';
|
||||
final cues = ExternalSubtitleLoader.parseSubtitleContent(noisy);
|
||||
expect(cues, hasLength(1));
|
||||
expect(cues.single.text, 'ok');
|
||||
});
|
||||
|
||||
test('end < start 的非法 cue 被丢弃', () {
|
||||
const invalid = '''
|
||||
1
|
||||
00:00:05,000 --> 00:00:02,000
|
||||
bad
|
||||
|
||||
2
|
||||
00:00:06,000 --> 00:00:07,000
|
||||
good
|
||||
''';
|
||||
final cues = ExternalSubtitleLoader.parseSubtitleContent(invalid);
|
||||
expect(cues, hasLength(1));
|
||||
expect(cues.single.text, 'good');
|
||||
});
|
||||
});
|
||||
|
||||
group('ExternalSubtitleLoader.parseSubtitleContent — VTT', () {
|
||||
const vtt = '''
|
||||
WEBVTT
|
||||
|
||||
NOTE this is a comment
|
||||
|
||||
cue-1
|
||||
00:01.000 --> 00:02.000
|
||||
Hello <b>world</b>
|
||||
|
||||
00:00:03.000 --> 00:00:04.500
|
||||
第二条
|
||||
''';
|
||||
|
||||
test('识别 WEBVTT 头,支持省略小时位与 cue 标识行', () {
|
||||
final cues = ExternalSubtitleLoader.parseSubtitleContent(vtt);
|
||||
expect(cues, hasLength(2));
|
||||
expect(cues[0].start, const Duration(seconds: 1));
|
||||
expect(cues[0].text, 'Hello world');
|
||||
expect(cues[1].start, const Duration(seconds: 3));
|
||||
expect(cues[1].end, const Duration(milliseconds: 4500));
|
||||
expect(cues[1].text, '第二条');
|
||||
});
|
||||
});
|
||||
|
||||
group('ExternalSubtitleLoader.parseSubtitleContent — ASS 降级纯文本', () {
|
||||
const ass = '''
|
||||
[Script Info]
|
||||
Title: Test
|
||||
|
||||
[Events]
|
||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||
Dialogue: 0,0:00:01.00,0:00:02.50,Default,,0,0,0,,{\\an8\\fs20}你好,世界\\N第二行
|
||||
Dialogue: 0,0:00:04.00,0:00:05.00,Sign,,0,0,0,,{\\pos(1,2)}m 0 0 l 100 0
|
||||
Dialogue: 0,0:00:06.00,0:00:07.00,Default,,0,0,0,,plain text
|
||||
''';
|
||||
|
||||
test('剥样式覆盖标签、\\N 转行、Text 内逗号保留', () {
|
||||
final cues = ExternalSubtitleLoader.parseSubtitleContent(ass);
|
||||
final first = cues.first;
|
||||
expect(first.start, const Duration(seconds: 1));
|
||||
expect(first.end, const Duration(milliseconds: 2500));
|
||||
expect(first.text, '你好,世界\n第二行');
|
||||
expect(first.text, isNot(contains('an8')));
|
||||
expect(cues.last.text, 'plain text');
|
||||
});
|
||||
|
||||
test('自定义 Format 字段顺序按声明解析', () {
|
||||
const customFormat = '''
|
||||
[Events]
|
||||
Format: Start, End, Text
|
||||
Dialogue: 0:00:01.00,0:00:02.00,custom order
|
||||
''';
|
||||
final cues = ExternalSubtitleLoader.parseSubtitleContent(customFormat);
|
||||
expect(cues, hasLength(1));
|
||||
expect(cues.single.start, const Duration(seconds: 1));
|
||||
expect(cues.single.text, 'custom order');
|
||||
});
|
||||
|
||||
test('非法时间戳的 Dialogue 行被跳过', () {
|
||||
const invalidTime = '''
|
||||
[Events]
|
||||
Dialogue: 0,bad,0:00:02.00,Default,,0,0,0,,skip me
|
||||
Dialogue: 0,0:00:03.00,0:00:04.00,Default,,0,0,0,,keep me
|
||||
''';
|
||||
final cues = ExternalSubtitleLoader.parseSubtitleContent(invalidTime);
|
||||
expect(cues, hasLength(1));
|
||||
expect(cues.single.text, 'keep me');
|
||||
});
|
||||
});
|
||||
|
||||
group('parseSubtitleContent — 容错', () {
|
||||
test('空内容/纯垃圾返回空列表而非抛异常', () {
|
||||
expect(ExternalSubtitleLoader.parseSubtitleContent(''), isEmpty);
|
||||
expect(
|
||||
ExternalSubtitleLoader.parseSubtitleContent('random garbage\nfoo bar'),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('SubtitleCueSource.textAt', () {
|
||||
SubtitleCueSource sourceFromSrt() => SubtitleCueSource(
|
||||
ExternalSubtitleLoader.parseSubtitleContent('''
|
||||
1
|
||||
00:00:01,000 --> 00:00:03,000
|
||||
first
|
||||
|
||||
2
|
||||
00:00:02,000 --> 00:00:04,000
|
||||
overlap
|
||||
|
||||
3
|
||||
00:00:10,000 --> 00:00:12,000
|
||||
later
|
||||
'''),
|
||||
);
|
||||
|
||||
test('位置命中单条 cue', () {
|
||||
final source = sourceFromSrt();
|
||||
expect(source.textAt(const Duration(milliseconds: 1500)), ['first']);
|
||||
expect(source.textAt(const Duration(seconds: 11)), ['later']);
|
||||
});
|
||||
|
||||
test('重叠 cue 全部返回、按 start 顺序', () {
|
||||
final source = sourceFromSrt();
|
||||
expect(source.textAt(const Duration(milliseconds: 2500)), [
|
||||
'first',
|
||||
'overlap',
|
||||
]);
|
||||
});
|
||||
|
||||
test('二分跳过历史 cue 时仍保留跨越多个短 cue 的长字幕', () {
|
||||
final source = SubtitleCueSource(const [
|
||||
TimedSubtitle(
|
||||
start: Duration(seconds: 1),
|
||||
end: Duration(seconds: 20),
|
||||
text: 'long',
|
||||
),
|
||||
TimedSubtitle(
|
||||
start: Duration(seconds: 2),
|
||||
end: Duration(seconds: 3),
|
||||
text: 'expired',
|
||||
),
|
||||
TimedSubtitle(
|
||||
start: Duration(seconds: 10),
|
||||
end: Duration(seconds: 12),
|
||||
text: 'current',
|
||||
),
|
||||
]);
|
||||
|
||||
expect(source.textAt(const Duration(seconds: 11)), ['long', 'current']);
|
||||
});
|
||||
|
||||
test('无命中返回空列表', () {
|
||||
final source = sourceFromSrt();
|
||||
expect(source.textAt(Duration.zero), isEmpty);
|
||||
expect(source.textAt(const Duration(seconds: 5)), isEmpty);
|
||||
expect(source.textAt(const Duration(seconds: 20)), isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'package:smplayer/core/contracts/cancellation.dart';
|
||||
import 'package:smplayer/core/infra/player_engine/player_engine.dart';
|
||||
|
||||
class FakePlayerEngine implements PlayerEngine {
|
||||
FakePlayerEngine({
|
||||
required bool playing,
|
||||
PlayerTracks tracks = const PlayerTracks(),
|
||||
}) {
|
||||
state
|
||||
..playing = playing
|
||||
..tracks = tracks;
|
||||
}
|
||||
|
||||
final PlayerStreams _streams = PlayerStreams();
|
||||
final ValueNotifier<int?> _textureId = ValueNotifier<int?>(null);
|
||||
final ValueNotifier<int> _textureVersion = ValueNotifier<int>(0);
|
||||
final ValueNotifier<ui.Size?> _videoSize = ValueNotifier<ui.Size?>(null);
|
||||
final ValueNotifier<PlaybackStats?> _debugStats =
|
||||
ValueNotifier<PlaybackStats?>(null);
|
||||
|
||||
@override
|
||||
final PlayerState state = PlayerState();
|
||||
|
||||
int playCalls = 0;
|
||||
int pauseCalls = 0;
|
||||
|
||||
@override
|
||||
PlayerStreams get stream => _streams;
|
||||
|
||||
@override
|
||||
ValueListenable<int?> get textureId => _textureId;
|
||||
|
||||
@override
|
||||
ValueListenable<int> get textureVersion => _textureVersion;
|
||||
|
||||
@override
|
||||
ValueNotifier<ui.Size?> get videoSize => _videoSize;
|
||||
|
||||
@override
|
||||
String get displayName => 'fake';
|
||||
|
||||
@override
|
||||
bool get usesPlatformSurface => false;
|
||||
|
||||
@override
|
||||
Future<int?> totalRxBytes() async => null;
|
||||
|
||||
@override
|
||||
bool isNetworkStreamError(String error) => false;
|
||||
|
||||
@override
|
||||
ValueListenable<PlaybackStats?> get debugStats => _debugStats;
|
||||
|
||||
@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 {
|
||||
playCalls++;
|
||||
state.playing = true;
|
||||
stream.addPlaying(true);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pause() async {
|
||||
pauseCalls++;
|
||||
state.playing = false;
|
||||
stream.addPlaying(false);
|
||||
}
|
||||
|
||||
@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 {}
|
||||
|
||||
void addTracks(PlayerTracks tracks) {
|
||||
state.tracks = tracks;
|
||||
stream.addTracks(tracks);
|
||||
}
|
||||
|
||||
@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) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
Future<String> getProperty(String name) async => '';
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
_streams.dispose();
|
||||
_textureId.dispose();
|
||||
_textureVersion.dispose();
|
||||
_videoSize.dispose();
|
||||
_debugStats.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:smplayer/providers/player_engine_override_provider.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
const key = 'smplayer.player_engine_override';
|
||||
|
||||
ProviderContainer makeContainer() {
|
||||
final c = ProviderContainer();
|
||||
addTearDown(c.dispose);
|
||||
return c;
|
||||
}
|
||||
|
||||
test('默认(无持久化值)build 返回 auto', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final c = makeContainer();
|
||||
final value = await c.read(playerEngineOverrideProvider.future);
|
||||
expect(value, PlayerEngineOverride.auto);
|
||||
});
|
||||
|
||||
test('setOverride(media3) 后 build 返回 media3 且持久化为 name', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final c = makeContainer();
|
||||
await c.read(playerEngineOverrideProvider.future);
|
||||
|
||||
await c
|
||||
.read(playerEngineOverrideProvider.notifier)
|
||||
.setOverride(PlayerEngineOverride.media3);
|
||||
|
||||
expect(
|
||||
c.read(playerEngineOverrideProvider).value,
|
||||
PlayerEngineOverride.media3,
|
||||
);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
expect(prefs.getString(key), 'media3');
|
||||
});
|
||||
|
||||
test('持久化值为 media3 时 build 读回 media3', () async {
|
||||
SharedPreferences.setMockInitialValues({key: 'media3'});
|
||||
final c = makeContainer();
|
||||
final value = await c.read(playerEngineOverrideProvider.future);
|
||||
expect(value, PlayerEngineOverride.media3);
|
||||
});
|
||||
|
||||
test('损坏字符串容错返回 auto', () async {
|
||||
SharedPreferences.setMockInitialValues({key: 'garbage-value'});
|
||||
final c = makeContainer();
|
||||
final value = await c.read(playerEngineOverrideProvider.future);
|
||||
expect(value, PlayerEngineOverride.auto);
|
||||
});
|
||||
|
||||
test('byName(media3) 不抛', () {
|
||||
expect(() => PlayerEngineOverride.values.byName('media3'), returnsNormally);
|
||||
expect(
|
||||
PlayerEngineOverride.values.byName('media3'),
|
||||
PlayerEngineOverride.media3,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:smplayer/core/domain/ports/sm_account_gateway.dart';
|
||||
import 'package:smplayer/providers/sm_account_provider.dart';
|
||||
import 'package:smplayer/providers/tmdb_settings_provider.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
const tmdbEnabledKey = 'smplayer.tmdb_enabled';
|
||||
const tmdbAccessModeKey = 'smplayer.tmdb_access_mode';
|
||||
const tmdbApiKeyKey = 'smplayer.tmdb_api_key';
|
||||
const tmdbLanguageKey = 'smplayer.tmdb_language';
|
||||
|
||||
ProviderContainer makeContainer(SmLoginResult loginResult) {
|
||||
final c = ProviderContainer(
|
||||
overrides: [
|
||||
smAccountGatewayProvider.overrideWithValue(
|
||||
_FakeSmAccountGateway(loginResult),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(c.dispose);
|
||||
return c;
|
||||
}
|
||||
|
||||
test('VIP login enables TMDB through sm-server proxy', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
tmdbEnabledKey: false,
|
||||
tmdbAccessModeKey: TmdbAccessMode.official.name,
|
||||
tmdbApiKeyKey: 'keep-me',
|
||||
tmdbLanguageKey: 'en-US',
|
||||
});
|
||||
final c = makeContainer(
|
||||
const SmLoginResult(
|
||||
ok: true,
|
||||
email: 'vip@example.com',
|
||||
accessToken: 'access',
|
||||
refreshToken: 'refresh',
|
||||
expiresIn: 900,
|
||||
isVip: true,
|
||||
),
|
||||
);
|
||||
|
||||
await c.read(smAccountProvider.future);
|
||||
final result = await c
|
||||
.read(smAccountProvider.notifier)
|
||||
.login('vip@example.com', 'password');
|
||||
|
||||
expect(result.ok, isTrue);
|
||||
final settings = await c.read(tmdbSettingsProvider.future);
|
||||
expect(settings.enabled, isTrue);
|
||||
expect(settings.accessMode, TmdbAccessMode.smAccount);
|
||||
expect(settings.apiKey, 'keep-me');
|
||||
expect(settings.language, 'en-US');
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
expect(prefs.getBool(tmdbEnabledKey), isTrue);
|
||||
expect(prefs.getString(tmdbAccessModeKey), TmdbAccessMode.smAccount.name);
|
||||
});
|
||||
|
||||
test('non-VIP login leaves TMDB settings unchanged', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
tmdbEnabledKey: false,
|
||||
tmdbAccessModeKey: TmdbAccessMode.official.name,
|
||||
});
|
||||
final c = makeContainer(
|
||||
const SmLoginResult(
|
||||
ok: true,
|
||||
email: 'user@example.com',
|
||||
accessToken: 'access',
|
||||
refreshToken: 'refresh',
|
||||
expiresIn: 900,
|
||||
),
|
||||
);
|
||||
|
||||
await c.read(smAccountProvider.future);
|
||||
final result = await c
|
||||
.read(smAccountProvider.notifier)
|
||||
.login('user@example.com', 'password');
|
||||
|
||||
expect(result.ok, isTrue);
|
||||
final settings = await c.read(tmdbSettingsProvider.future);
|
||||
expect(settings.enabled, isFalse);
|
||||
expect(settings.accessMode, TmdbAccessMode.official);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
expect(prefs.getBool(tmdbEnabledKey), isFalse);
|
||||
expect(prefs.getString(tmdbAccessModeKey), TmdbAccessMode.official.name);
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeSmAccountGateway implements SmAccountGateway {
|
||||
final SmLoginResult loginResult;
|
||||
|
||||
_FakeSmAccountGateway(this.loginResult);
|
||||
|
||||
@override
|
||||
Future<SmLoginResult> login({
|
||||
required String baseUrl,
|
||||
required String email,
|
||||
required String password,
|
||||
}) async => loginResult;
|
||||
|
||||
@override
|
||||
Future<void> logout({
|
||||
required String baseUrl,
|
||||
required String refreshToken,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
Future<SmTokenRefreshResult> refreshAccessToken({
|
||||
required String baseUrl,
|
||||
required String refreshToken,
|
||||
}) async => const SmTokenRefreshResult(ok: false);
|
||||
|
||||
@override
|
||||
Future<SmActionResult> registerStart({
|
||||
required String baseUrl,
|
||||
required String email,
|
||||
}) async => SmActionResult.failure('unused');
|
||||
|
||||
@override
|
||||
Future<SmLoginResult> registerVerify({
|
||||
required String baseUrl,
|
||||
required String email,
|
||||
required String password,
|
||||
required String code,
|
||||
}) async => loginResult;
|
||||
|
||||
@override
|
||||
Future<SmActionResult> resendCode({
|
||||
required String baseUrl,
|
||||
required String email,
|
||||
}) async => SmActionResult.failure('unused');
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:smplayer/providers/appearance_provider.dart';
|
||||
import 'package:smplayer/router/app_router.dart';
|
||||
import 'package:smplayer/router/page_transitions.dart';
|
||||
|
||||
void main() {
|
||||
group('player route transition regression', () {
|
||||
late GoRouter router;
|
||||
|
||||
setUp(() {
|
||||
final container = ProviderContainer(
|
||||
overrides: [initialLocationProvider.overrideWithValue('/')],
|
||||
);
|
||||
router = container.read(appRouterProvider);
|
||||
});
|
||||
|
||||
test('player route uses platform-aware player transition', () {
|
||||
GoRoute? playerRoute;
|
||||
for (final route in router.configuration.routes) {
|
||||
if (route is GoRoute && route.path == '/player/:itemId') {
|
||||
playerRoute = route;
|
||||
break;
|
||||
}
|
||||
for (final sub in route.routes) {
|
||||
if (sub is GoRoute && sub.path == '/player/:itemId') {
|
||||
playerRoute = sub;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (playerRoute != null) break;
|
||||
}
|
||||
|
||||
expect(
|
||||
playerRoute,
|
||||
isNotNull,
|
||||
reason: '/player/:itemId route must exist',
|
||||
);
|
||||
|
||||
final page = playerRoute!.pageBuilder!(
|
||||
_FakeBuildContext(),
|
||||
GoRouterState(
|
||||
router.configuration,
|
||||
uri: Uri.parse('/player/test123'),
|
||||
matchedLocation: '/player/test123',
|
||||
pathParameters: const {'itemId': 'test123'},
|
||||
fullPath: '/player/:itemId',
|
||||
pageKey: const ValueKey('/player/test123'),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
page,
|
||||
isA<CustomTransitionPage>(),
|
||||
reason: 'player route must use a custom player transition',
|
||||
);
|
||||
final transitionPage = page as CustomTransitionPage;
|
||||
expect(
|
||||
transitionPage.transitionsBuilder,
|
||||
same(PageTransitions.player),
|
||||
reason: 'player route must avoid horizontal motion on Android',
|
||||
);
|
||||
expect(transitionPage.transitionDuration, PageTransitions.playerForward);
|
||||
expect(
|
||||
transitionPage.reverseTransitionDuration,
|
||||
PageTransitions.playerReverse,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeBuildContext extends Fake implements BuildContext {}
|
||||
@@ -0,0 +1,160 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
test('Android launcher forwards args and pins the FVP SDK', () async {
|
||||
final project = await Directory.systemTemp.createTemp('smplayer-android-');
|
||||
addTearDown(() => project.delete(recursive: true));
|
||||
await File(
|
||||
'${project.path}/pubspec.yaml',
|
||||
).writeAsString('version: 1.2.3\n');
|
||||
final staleFvpSdk = await _writeStaleFvpAndroidSdk(project);
|
||||
|
||||
final bin = Directory('${project.path}/bin')..createSync();
|
||||
final log = File('${project.path}/flutter.log');
|
||||
await _writeFakeFlutter(bin, log);
|
||||
|
||||
final environment = Map<String, String>.of(Platform.environment)
|
||||
..['PATH'] =
|
||||
'${bin.path}${Platform.isWindows ? ';' : ':'}'
|
||||
'${Platform.environment['PATH'] ?? ''}'
|
||||
..['ANDROID_HOME'] = ''
|
||||
..['ANDROID_SDK_ROOT'] = ''
|
||||
..['LOCALAPPDATA'] = project.path
|
||||
..['SMPLAYER_TEST_LOG'] = log.path
|
||||
..['FVP_DEPS_LATEST'] = 'stale';
|
||||
final result = await Process.run(_dartExecutable, [
|
||||
File('scripts/dev_android.dart').absolute.path,
|
||||
'--project-root',
|
||||
project.path,
|
||||
'--force',
|
||||
'--offline',
|
||||
'--',
|
||||
'--release',
|
||||
'--dart-define',
|
||||
'SMPLAYER_VERSION=9.9.9',
|
||||
], environment: environment);
|
||||
|
||||
expect(result.exitCode, 0, reason: '${result.stderr}');
|
||||
expect('${result.stdout}', contains('test-device'));
|
||||
expect('${result.stdout}', contains('App version: 1.2.3'));
|
||||
final calls = await log.readAsString();
|
||||
expect(calls, contains('ARGS:pub get --offline'));
|
||||
expect(
|
||||
calls,
|
||||
contains(
|
||||
'ARGS:run -d test-device --no-pub --release '
|
||||
'--dart-define SMPLAYER_VERSION=9.9.9',
|
||||
),
|
||||
);
|
||||
expect(calls, contains('VERSION_CODE:1002003'));
|
||||
expect(calls, contains('FVP:$_fvpDepsUrl'));
|
||||
expect(calls, isNot(contains('LATEST:stale')));
|
||||
expect(staleFvpSdk.sdkDirectory.existsSync(), isFalse);
|
||||
expect(staleFvpSdk.sdkArchive.existsSync(), isFalse);
|
||||
expect(await staleFvpSdk.versionMarker.readAsString(), '0.37.0');
|
||||
if (!Platform.isWindows) {
|
||||
expect(calls, contains('STORAGE:https://storage.flutter-io.cn'));
|
||||
expect(calls, contains('PUB:https://pub.flutter-io.cn'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<({File sdkArchive, Directory sdkDirectory, File versionMarker})>
|
||||
_writeStaleFvpAndroidSdk(Directory project) async {
|
||||
final fvpDirectory = Directory('${project.path}/fvp')..createSync();
|
||||
final fvpRootUriWithoutTrailingSlash = fvpDirectory.uri
|
||||
.toString()
|
||||
.replaceFirst(RegExp(r'/$'), '');
|
||||
final fvpAndroidDirectory = Directory('${fvpDirectory.path}/android')
|
||||
..createSync();
|
||||
final sdkDirectory = Directory(
|
||||
'${fvpAndroidDirectory.path}/mdk-sdk/lib/cmake',
|
||||
)..createSync(recursive: true);
|
||||
await File('${sdkDirectory.path}/FindMDK.cmake').writeAsString('stale');
|
||||
final sdkArchive = File('${fvpAndroidDirectory.path}/mdk-sdk-android.7z');
|
||||
await sdkArchive.writeAsString('stale');
|
||||
final versionMarker = File(
|
||||
'${fvpAndroidDirectory.path}/.smplayer-mdk-version',
|
||||
);
|
||||
await versionMarker.writeAsString('0.36.0');
|
||||
|
||||
final packageConfigDirectory = Directory('${project.path}/.dart_tool')
|
||||
..createSync();
|
||||
await File(
|
||||
'${packageConfigDirectory.path}/package_config.json',
|
||||
).writeAsString('''
|
||||
{
|
||||
"configVersion": 2,
|
||||
"packages": [
|
||||
{
|
||||
"name": "fvp",
|
||||
"rootUri": ${_jsonString(fvpRootUriWithoutTrailingSlash)},
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
''');
|
||||
|
||||
return (
|
||||
sdkArchive: sdkArchive,
|
||||
sdkDirectory: Directory('${fvpAndroidDirectory.path}/mdk-sdk'),
|
||||
versionMarker: versionMarker,
|
||||
);
|
||||
}
|
||||
|
||||
String _jsonString(String value) => '"${value.replaceAll('"', r'\"')}"';
|
||||
|
||||
Future<void> _writeFakeFlutter(Directory bin, File log) async {
|
||||
if (Platform.isWindows) {
|
||||
await File('${bin.path}/flutter.bat').writeAsString('''@echo off
|
||||
if "%~1 %~2"=="devices --machine" (
|
||||
echo [{"id":"test-device","targetPlatform":"android-arm64"}]
|
||||
exit /b 0
|
||||
)
|
||||
>>"%SMPLAYER_TEST_LOG%" echo ARGS:%*
|
||||
>>"%SMPLAYER_TEST_LOG%" echo FVP:%FVP_DEPS_URL%
|
||||
>>"%SMPLAYER_TEST_LOG%" echo LATEST:%FVP_DEPS_LATEST%
|
||||
>>"%SMPLAYER_TEST_LOG%" echo STORAGE:%FLUTTER_STORAGE_BASE_URL%
|
||||
>>"%SMPLAYER_TEST_LOG%" echo PUB:%PUB_HOSTED_URL%
|
||||
>>"%SMPLAYER_TEST_LOG%" echo VERSION_CODE:%SMPLAYER_ANDROID_VERSION_CODE%
|
||||
>>"%SMPLAYER_TEST_LOG%" echo REQUIRE_SIGNING:%SMPLAYER_REQUIRE_RELEASE_SIGNING%
|
||||
''');
|
||||
return;
|
||||
}
|
||||
|
||||
final flutter = File('${bin.path}/flutter');
|
||||
await flutter.writeAsString(r'''#!/usr/bin/env bash
|
||||
if [[ "$1 $2" == 'devices --machine' ]]; then
|
||||
printf '[{"id":"test-device","targetPlatform":"android-arm64"}]\n'
|
||||
exit 0
|
||||
fi
|
||||
printf 'ARGS:%s\n' "$*" >> "$SMPLAYER_TEST_LOG"
|
||||
printf 'FVP:%s\n' "$FVP_DEPS_URL" >> "$SMPLAYER_TEST_LOG"
|
||||
printf 'LATEST:%s\n' "${FVP_DEPS_LATEST-}" >> "$SMPLAYER_TEST_LOG"
|
||||
printf 'STORAGE:%s\n' "$FLUTTER_STORAGE_BASE_URL" >> "$SMPLAYER_TEST_LOG"
|
||||
printf 'PUB:%s\n' "$PUB_HOSTED_URL" >> "$SMPLAYER_TEST_LOG"
|
||||
printf 'VERSION_CODE:%s\n' "$SMPLAYER_ANDROID_VERSION_CODE" >> "$SMPLAYER_TEST_LOG"
|
||||
printf 'REQUIRE_SIGNING:%s\n' "$SMPLAYER_REQUIRE_RELEASE_SIGNING" >> "$SMPLAYER_TEST_LOG"
|
||||
''');
|
||||
await Process.run('chmod', ['+x', flutter.path]);
|
||||
}
|
||||
|
||||
const _fvpDepsUrl =
|
||||
'https://github.com/wang-bin/mdk-sdk/releases/download/v0.37.0';
|
||||
|
||||
String get _dartExecutable {
|
||||
final currentExecutable = File(Platform.resolvedExecutable);
|
||||
if (!currentExecutable.path.toLowerCase().contains('flutter_tester')) {
|
||||
return currentExecutable.path;
|
||||
}
|
||||
|
||||
final flutterCacheDirectory = currentExecutable.parent.parent.parent.parent;
|
||||
final executableName = Platform.isWindows ? 'dart.exe' : 'dart';
|
||||
return File(
|
||||
'${flutterCacheDirectory.path}${Platform.pathSeparator}dart-sdk'
|
||||
'${Platform.pathSeparator}bin${Platform.pathSeparator}$executableName',
|
||||
).path;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'version CLI resolves tags and pubspec fallback',
|
||||
() async {
|
||||
final project = await Directory.systemTemp.createTemp(
|
||||
'smplayer-version-',
|
||||
);
|
||||
addTearDown(() => project.delete(recursive: true));
|
||||
await File(
|
||||
'${project.path}/pubspec.yaml',
|
||||
).writeAsString('version: 1.2.3+4\n');
|
||||
|
||||
await _git(project.path, ['init', '-q']);
|
||||
await _git(project.path, ['config', 'user.email', 'test@example.com']);
|
||||
await _git(project.path, ['config', 'user.name', 'Version Test']);
|
||||
await _git(project.path, ['add', 'pubspec.yaml']);
|
||||
await _git(project.path, ['commit', '-qm', 'initial']);
|
||||
await _git(project.path, ['tag', 'v1.9.9']);
|
||||
await _git(project.path, ['tag', 'v1.10.0']);
|
||||
|
||||
expect(await _run(project.path, ['current', 'pc']), '1.10.0');
|
||||
expect(await _run(project.path, ['current', 'macos']), '1.10.0');
|
||||
expect(await _run(project.path, ['current', 'android']), '1.2.3');
|
||||
expect(
|
||||
await _run(project.path, ['next', 'windows']),
|
||||
'windows 1.10.1 v1.10.0',
|
||||
);
|
||||
expect(
|
||||
await _run(project.path, ['next', 'android']),
|
||||
'android 1.2.3 pubspec:1.2.3',
|
||||
);
|
||||
expect(await _run(project.path, ['build-number', '1.2.3+4']), '1002003');
|
||||
expect(
|
||||
await _run(project.path, ['tag', 'both', '--dry-run']),
|
||||
'[windows] v1.10.0 -> v1.10.1\n'
|
||||
' DryRun: skip create/push.\n'
|
||||
'[android] pubspec:1.2.3 -> android-v1.2.3\n'
|
||||
' DryRun: skip create/push.',
|
||||
);
|
||||
},
|
||||
timeout: const Timeout(Duration(minutes: 2)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> _run(String projectRoot, List<String> arguments) async {
|
||||
final result = await Process.run(_dartExecutablePath(), [
|
||||
File('scripts/version.dart').absolute.path,
|
||||
...arguments,
|
||||
'--project-root',
|
||||
projectRoot,
|
||||
]);
|
||||
expect(result.exitCode, 0, reason: '${result.stderr}');
|
||||
return '${result.stdout}'.trim();
|
||||
}
|
||||
|
||||
String _dartExecutablePath() {
|
||||
final resolvedExecutable = File(Platform.resolvedExecutable);
|
||||
final executableName = resolvedExecutable.uri.pathSegments.last.toLowerCase();
|
||||
if (!executableName.startsWith('flutter_tester')) {
|
||||
return resolvedExecutable.path;
|
||||
}
|
||||
|
||||
var flutterRoot = resolvedExecutable.parent;
|
||||
for (var parentLevel = 0; parentLevel < 5; parentLevel++) {
|
||||
flutterRoot = flutterRoot.parent;
|
||||
}
|
||||
final executableFileName = Platform.isWindows ? 'dart.exe' : 'dart';
|
||||
return File(
|
||||
'${flutterRoot.path}${Platform.pathSeparator}bin${Platform.pathSeparator}'
|
||||
'cache${Platform.pathSeparator}dart-sdk${Platform.pathSeparator}bin'
|
||||
'${Platform.pathSeparator}$executableFileName',
|
||||
).path;
|
||||
}
|
||||
|
||||
Future<void> _git(String projectRoot, List<String> arguments) async {
|
||||
final result = await Process.run(
|
||||
'git',
|
||||
arguments,
|
||||
workingDirectory: projectRoot,
|
||||
);
|
||||
expect(result.exitCode, 0, reason: '${result.stderr}');
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/contracts/library.dart';
|
||||
import 'package:smplayer/shared/mappers/media_image_url.dart';
|
||||
|
||||
const _baseUrl = 'https://emby.example.com';
|
||||
const _token = 'test-token';
|
||||
|
||||
EmbyRawItem _makeItem({
|
||||
String id = 'item-1',
|
||||
String? type,
|
||||
String? primaryImageTag,
|
||||
Map<String, String>? imageTags,
|
||||
List<String>? backdropImageTags,
|
||||
Map<String, dynamic> extra = const {},
|
||||
}) {
|
||||
return EmbyRawItem(
|
||||
Id: id,
|
||||
Name: 'Test',
|
||||
Type: type,
|
||||
PrimaryImageTag: primaryImageTag,
|
||||
ImageTags: imageTags,
|
||||
BackdropImageTags: backdropImageTags,
|
||||
extra: extra,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('EmbyImageUrl.landscapeCard fallback chain', () {
|
||||
test('returns Primary when PrimaryImageTag is present', () {
|
||||
final item = _makeItem(primaryImageTag: 'primary-tag-1');
|
||||
|
||||
final url = EmbyImageUrl.landscapeCard(
|
||||
baseUrl: _baseUrl,
|
||||
token: _token,
|
||||
item: item,
|
||||
);
|
||||
|
||||
expect(url, isNotNull);
|
||||
expect(url, contains('/Images/Primary'));
|
||||
expect(url, contains('tag=primary-tag-1'));
|
||||
});
|
||||
|
||||
test('returns Primary from ImageTags map when PrimaryImageTag is null', () {
|
||||
final item = _makeItem(imageTags: {'Primary': 'map-primary-tag'});
|
||||
|
||||
final url = EmbyImageUrl.landscapeCard(
|
||||
baseUrl: _baseUrl,
|
||||
token: _token,
|
||||
item: item,
|
||||
);
|
||||
|
||||
expect(url, isNotNull);
|
||||
expect(url, contains('/Images/Primary'));
|
||||
expect(url, contains('tag=map-primary-tag'));
|
||||
});
|
||||
|
||||
test('falls back to Thumb when Primary is missing', () {
|
||||
final item = _makeItem(imageTags: {'Thumb': 'thumb-tag-1'});
|
||||
|
||||
final url = EmbyImageUrl.landscapeCard(
|
||||
baseUrl: _baseUrl,
|
||||
token: _token,
|
||||
item: item,
|
||||
);
|
||||
|
||||
expect(url, isNotNull);
|
||||
expect(url, contains('/Images/Thumb'));
|
||||
expect(url, contains('tag=thumb-tag-1'));
|
||||
});
|
||||
|
||||
test('falls back to Backdrop when Primary and Thumb are missing', () {
|
||||
final item = _makeItem(backdropImageTags: ['backdrop-tag-1']);
|
||||
|
||||
final url = EmbyImageUrl.landscapeCard(
|
||||
baseUrl: _baseUrl,
|
||||
token: _token,
|
||||
item: item,
|
||||
);
|
||||
|
||||
expect(url, isNotNull);
|
||||
expect(url, contains('/Images/Backdrop'));
|
||||
expect(url, contains('tag=backdrop-tag-1'));
|
||||
});
|
||||
|
||||
test('falls back to parent Thumb from extra JSON', () {
|
||||
final item = _makeItem(
|
||||
extra: {
|
||||
'ParentThumbItemId': 'parent-1',
|
||||
'ParentThumbImageTag': 'parent-thumb-tag',
|
||||
},
|
||||
);
|
||||
|
||||
final url = EmbyImageUrl.landscapeCard(
|
||||
baseUrl: _baseUrl,
|
||||
token: _token,
|
||||
item: item,
|
||||
);
|
||||
|
||||
expect(url, isNotNull);
|
||||
expect(url, contains('/Items/parent-1/Images/Thumb'));
|
||||
expect(url, contains('tag=parent-thumb-tag'));
|
||||
});
|
||||
|
||||
test('falls back to parent Backdrop from extra JSON', () {
|
||||
final item = _makeItem(
|
||||
extra: {
|
||||
'ParentBackdropItemId': 'parent-2',
|
||||
'ParentBackdropImageTags': ['parent-backdrop-tag'],
|
||||
},
|
||||
);
|
||||
|
||||
final url = EmbyImageUrl.landscapeCard(
|
||||
baseUrl: _baseUrl,
|
||||
token: _token,
|
||||
item: item,
|
||||
);
|
||||
|
||||
expect(url, isNotNull);
|
||||
expect(url, contains('/Items/parent-2/Images/Backdrop'));
|
||||
expect(url, contains('tag=parent-backdrop-tag'));
|
||||
});
|
||||
|
||||
test('falls back to series Primary from extra JSON as last resort', () {
|
||||
final item = _makeItem(
|
||||
extra: {
|
||||
'SeriesId': 'series-1',
|
||||
'SeriesPrimaryImageTag': 'series-primary-tag',
|
||||
},
|
||||
);
|
||||
|
||||
final url = EmbyImageUrl.landscapeCard(
|
||||
baseUrl: _baseUrl,
|
||||
token: _token,
|
||||
item: item,
|
||||
);
|
||||
|
||||
expect(url, isNotNull);
|
||||
expect(url, contains('/Items/series-1/Images/Primary'));
|
||||
expect(url, contains('tag=series-primary-tag'));
|
||||
});
|
||||
|
||||
test('returns null when all image sources are absent', () {
|
||||
final item = _makeItem();
|
||||
|
||||
final url = EmbyImageUrl.landscapeCard(
|
||||
baseUrl: _baseUrl,
|
||||
token: _token,
|
||||
item: item,
|
||||
);
|
||||
|
||||
expect(url, isNull);
|
||||
});
|
||||
|
||||
test('respects priority: Primary beats Thumb beats Backdrop', () {
|
||||
final item = _makeItem(
|
||||
primaryImageTag: 'primary-tag',
|
||||
imageTags: {'Primary': 'primary-tag', 'Thumb': 'thumb-tag'},
|
||||
backdropImageTags: ['backdrop-tag'],
|
||||
);
|
||||
|
||||
final url = EmbyImageUrl.landscapeCard(
|
||||
baseUrl: _baseUrl,
|
||||
token: _token,
|
||||
item: item,
|
||||
);
|
||||
|
||||
expect(url, contains('/Images/Primary'));
|
||||
expect(url, contains('tag=primary-tag'));
|
||||
});
|
||||
|
||||
test('parent Thumb takes priority over parent Backdrop', () {
|
||||
final item = _makeItem(
|
||||
extra: {
|
||||
'ParentThumbItemId': 'parent-1',
|
||||
'ParentThumbImageTag': 'parent-thumb-tag',
|
||||
'ParentBackdropItemId': 'parent-2',
|
||||
'ParentBackdropImageTags': ['parent-backdrop-tag'],
|
||||
'SeriesId': 'series-1',
|
||||
'SeriesPrimaryImageTag': 'series-primary-tag',
|
||||
},
|
||||
);
|
||||
|
||||
final url = EmbyImageUrl.landscapeCard(
|
||||
baseUrl: _baseUrl,
|
||||
token: _token,
|
||||
item: item,
|
||||
);
|
||||
|
||||
expect(url, contains('/Items/parent-1/Images/Thumb'));
|
||||
});
|
||||
|
||||
test('forwards maxWidth and quality parameters', () {
|
||||
final item = _makeItem(primaryImageTag: 'tag');
|
||||
|
||||
final url = EmbyImageUrl.landscapeCard(
|
||||
baseUrl: _baseUrl,
|
||||
token: _token,
|
||||
item: item,
|
||||
maxWidth: 500,
|
||||
quality: 80,
|
||||
);
|
||||
|
||||
expect(url, contains('maxWidth=500'));
|
||||
expect(url, contains('quality=80'));
|
||||
});
|
||||
});
|
||||
|
||||
group('EmbyImageUrl.landscapeCard movie-specific ordering', () {
|
||||
test('movie prefers Backdrop over Primary', () {
|
||||
final item = _makeItem(
|
||||
type: 'Movie',
|
||||
primaryImageTag: 'poster-tag',
|
||||
backdropImageTags: ['backdrop-tag'],
|
||||
);
|
||||
|
||||
final url = EmbyImageUrl.landscapeCard(
|
||||
baseUrl: _baseUrl,
|
||||
token: _token,
|
||||
item: item,
|
||||
);
|
||||
|
||||
expect(url, contains('/Images/Backdrop'));
|
||||
expect(url, contains('tag=backdrop-tag'));
|
||||
});
|
||||
|
||||
test('movie falls back to Thumb when Backdrop is missing', () {
|
||||
final item = _makeItem(
|
||||
type: 'Movie',
|
||||
primaryImageTag: 'poster-tag',
|
||||
imageTags: {'Primary': 'poster-tag', 'Thumb': 'thumb-tag'},
|
||||
);
|
||||
|
||||
final url = EmbyImageUrl.landscapeCard(
|
||||
baseUrl: _baseUrl,
|
||||
token: _token,
|
||||
item: item,
|
||||
);
|
||||
|
||||
expect(url, contains('/Images/Thumb'));
|
||||
expect(url, contains('tag=thumb-tag'));
|
||||
});
|
||||
|
||||
test('movie falls back to Primary (poster) as last resort', () {
|
||||
final item = _makeItem(type: 'Movie', primaryImageTag: 'poster-tag');
|
||||
|
||||
final url = EmbyImageUrl.landscapeCard(
|
||||
baseUrl: _baseUrl,
|
||||
token: _token,
|
||||
item: item,
|
||||
);
|
||||
|
||||
expect(url, contains('/Images/Primary'));
|
||||
expect(url, contains('tag=poster-tag'));
|
||||
});
|
||||
|
||||
test('movie returns tagless Primary URL when all tag sources absent', () {
|
||||
final item = _makeItem(type: 'Movie');
|
||||
|
||||
final url = EmbyImageUrl.landscapeCard(
|
||||
baseUrl: _baseUrl,
|
||||
token: _token,
|
||||
item: item,
|
||||
);
|
||||
|
||||
expect(url, isNotNull);
|
||||
expect(url, contains('/Items/item-1/Images/Primary'));
|
||||
expect(url, isNot(contains('tag=')));
|
||||
});
|
||||
|
||||
test('episode still prefers Primary over Backdrop', () {
|
||||
final item = _makeItem(
|
||||
type: 'Episode',
|
||||
primaryImageTag: 'screenshot-tag',
|
||||
backdropImageTags: ['backdrop-tag'],
|
||||
);
|
||||
|
||||
final url = EmbyImageUrl.landscapeCard(
|
||||
baseUrl: _baseUrl,
|
||||
token: _token,
|
||||
item: item,
|
||||
);
|
||||
|
||||
expect(url, contains('/Images/Primary'));
|
||||
expect(url, contains('tag=screenshot-tag'));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/shared/widgets/version_grouping.dart';
|
||||
import 'package:smplayer/shared/widgets/version_source_card.dart';
|
||||
|
||||
void main() {
|
||||
VersionSourceCardData createEntry({
|
||||
required String serverName,
|
||||
required String sourceKey,
|
||||
String? serverKey,
|
||||
int? sizeBytes = 1000,
|
||||
String resolutionLabel = '4K',
|
||||
bool isCurrent = false,
|
||||
bool selected = false,
|
||||
}) {
|
||||
return VersionSourceCardData(
|
||||
topLabel: '$resolutionLabel SDR',
|
||||
serverName: serverName,
|
||||
serverKey: serverKey ?? sourceKey.split('|').first,
|
||||
sourceKey: sourceKey,
|
||||
sizeBytes: sizeBytes,
|
||||
resolutionLabel: resolutionLabel,
|
||||
isCurrent: isCurrent,
|
||||
selected: selected,
|
||||
);
|
||||
}
|
||||
|
||||
group('groupVersionEntries', () {
|
||||
test('merges equal size and resolution in source order', () {
|
||||
final firstEntry = createEntry(
|
||||
serverName: 'Server A',
|
||||
sourceKey: 'server-a|source-a',
|
||||
);
|
||||
final secondEntry = createEntry(
|
||||
serverName: 'Server B',
|
||||
sourceKey: 'server-b|source-b',
|
||||
);
|
||||
|
||||
final groups = groupVersionEntries([firstEntry, secondEntry]);
|
||||
|
||||
expect(groups, hasLength(1));
|
||||
expect(groups.single.representative, same(firstEntry));
|
||||
expect(groups.single.members, [firstEntry, secondEntry]);
|
||||
expect(groups.single.serverNames, ['Server A', 'Server B']);
|
||||
});
|
||||
|
||||
test('does not merge sources without a positive file size', () {
|
||||
final missingSizeEntry = createEntry(
|
||||
serverName: 'Server A',
|
||||
sourceKey: 'server-a|source-a',
|
||||
sizeBytes: null,
|
||||
);
|
||||
final zeroSizeEntry = createEntry(
|
||||
serverName: 'Server B',
|
||||
sourceKey: 'server-b|source-b',
|
||||
sizeBytes: 0,
|
||||
);
|
||||
|
||||
final groups = groupVersionEntries([missingSizeEntry, zeroSizeEntry]);
|
||||
|
||||
expect(groups, hasLength(2));
|
||||
expect(groups[0].members, [missingSizeEntry]);
|
||||
expect(groups[1].members, [zeroSizeEntry]);
|
||||
});
|
||||
|
||||
test('keeps different resolution labels in separate groups', () {
|
||||
final ultraHdEntry = createEntry(
|
||||
serverName: 'Server A',
|
||||
sourceKey: 'server-a|source-a',
|
||||
);
|
||||
final fullHdEntry = createEntry(
|
||||
serverName: 'Server B',
|
||||
sourceKey: 'server-b|source-b',
|
||||
resolutionLabel: '1080P',
|
||||
);
|
||||
|
||||
final groups = groupVersionEntries([ultraHdEntry, fullHdEntry]);
|
||||
|
||||
expect(groups, hasLength(2));
|
||||
});
|
||||
|
||||
test('does not merge duplicate files from the same server', () {
|
||||
final firstEntry = createEntry(
|
||||
serverName: 'Server A',
|
||||
sourceKey: 'server-a|source-a',
|
||||
);
|
||||
final secondEntry = createEntry(
|
||||
serverName: 'Server A',
|
||||
sourceKey: 'server-a|source-b',
|
||||
);
|
||||
|
||||
final groups = groupVersionEntries([firstEntry, secondEntry]);
|
||||
|
||||
expect(groups, hasLength(2));
|
||||
});
|
||||
|
||||
test(
|
||||
'uses selected member as visible server without moving representative',
|
||||
() {
|
||||
final representative = createEntry(
|
||||
serverName: 'Server A',
|
||||
sourceKey: 'server-a|source-a',
|
||||
);
|
||||
final selectedMember = createEntry(
|
||||
serverName: 'Server B',
|
||||
sourceKey: 'server-b|source-b',
|
||||
selected: true,
|
||||
);
|
||||
|
||||
final group = groupVersionEntries([
|
||||
representative,
|
||||
selectedMember,
|
||||
]).single;
|
||||
|
||||
expect(group.representative, same(representative));
|
||||
expect(group.activeMember, same(selectedMember));
|
||||
expect(group.displayData.serverName, 'Server B');
|
||||
expect(group.displayData.sourceKey, 'server-b|source-b');
|
||||
expect(group.displayData.selected, isTrue);
|
||||
expect(group.displayData.mergedServerNames, ['Server A', 'Server B']);
|
||||
},
|
||||
);
|
||||
|
||||
test('propagates current state from any member to collapsed card', () {
|
||||
final representative = createEntry(
|
||||
serverName: 'Server A',
|
||||
sourceKey: 'server-a|source-a',
|
||||
);
|
||||
final currentMember = createEntry(
|
||||
serverName: 'Server B',
|
||||
sourceKey: 'server-b|source-b',
|
||||
isCurrent: true,
|
||||
);
|
||||
|
||||
final group = groupVersionEntries([representative, currentMember]).single;
|
||||
|
||||
expect(group.hasCurrentMember, isTrue);
|
||||
expect(group.displayData.isCurrent, isTrue);
|
||||
expect(group.displayData.serverName, 'Server B');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
import 'package:smplayer/shared/widgets/setting_select.dart';
|
||||
|
||||
void main() {
|
||||
Widget host({
|
||||
required String value,
|
||||
required ValueChanged<String?> onChanged,
|
||||
}) {
|
||||
return MaterialApp(
|
||||
home: FTheme(
|
||||
data: FThemes.zinc.light.desktop,
|
||||
child: Scaffold(
|
||||
body: Center(
|
||||
child: SettingSelect<String>(
|
||||
value: value,
|
||||
options: const ['a', 'b', 'c'],
|
||||
labelOf: (v) => 'label-$v',
|
||||
onChanged: onChanged,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('触发行显示选中项 label', (tester) async {
|
||||
await tester.pumpWidget(host(value: 'b', onChanged: (_) {}));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('label-b'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('选另一项触发 onChanged', (tester) async {
|
||||
String? picked;
|
||||
await tester.pumpWidget(host(value: 'a', onChanged: (v) => picked = v));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byType(SettingSelect<String>));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('label-c').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(picked, 'c');
|
||||
});
|
||||
|
||||
testWidgets('Select 可放在 Row 的 trailing 位置', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: FTheme(
|
||||
data: FThemes.zinc.light.desktop,
|
||||
child: Scaffold(
|
||||
body: SizedBox(
|
||||
width: 480,
|
||||
child: Row(
|
||||
children: [
|
||||
const Expanded(child: Text('设置项')),
|
||||
SettingSelect<String>(
|
||||
value: 'b',
|
||||
options: const ['a', 'b', 'c'],
|
||||
labelOf: (v) => 'label-$v',
|
||||
onChanged: (_) {},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('label-b'), findsWidgets);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user