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