Initial commit

This commit is contained in:
admin1
2026-07-14 11:11:36 +08:00
commit 656499cf94
604 changed files with 119518 additions and 0 deletions
+23
View File
@@ -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 时用 TranscodingUrlplayMethod=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('内封文本轨生成服务端抽取 deliveryUrlisExternal 仍为 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 原名 passthroughwebvtt 归一化为 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('内封位图轨不生成抽取 URLdeliveryUrl 为 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('AndroidisAndroid=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);
});
});
}