Initial commit
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/contracts/library.dart';
|
||||
import 'package:smplayer/features/aggregated/aggregated_grouping.dart';
|
||||
|
||||
GlobalSearchServerResult _server(
|
||||
String id,
|
||||
String name,
|
||||
List<EmbyRawItem> items,
|
||||
) => GlobalSearchServerResult(
|
||||
serverId: id,
|
||||
serverName: name,
|
||||
serverUrl: 'http://$id',
|
||||
token: 'tok-$id',
|
||||
userId: 'user-$id',
|
||||
items: items,
|
||||
);
|
||||
|
||||
EmbyRawItem _item(
|
||||
String id,
|
||||
String name, {
|
||||
String? type,
|
||||
Map<String, dynamic>? providerIds,
|
||||
String? lastPlayedDate,
|
||||
int? playbackPositionTicks,
|
||||
int? runtimeTicks,
|
||||
bool? played,
|
||||
}) => EmbyRawItem(
|
||||
Id: id,
|
||||
Name: name,
|
||||
Type: type,
|
||||
RunTimeTicks: runtimeTicks,
|
||||
UserData: playbackPositionTicks != null || played != null
|
||||
? EmbyRawUserData(
|
||||
PlaybackPositionTicks: playbackPositionTicks,
|
||||
Played: played,
|
||||
)
|
||||
: null,
|
||||
extra: {
|
||||
if (providerIds != null) 'ProviderIds': providerIds,
|
||||
if (lastPlayedDate != null) 'UserData': {'LastPlayedDate': lastPlayedDate},
|
||||
},
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('flatten – default behavior (dedup off)', () {
|
||||
test('returns all items from all servers', () {
|
||||
final results = [
|
||||
_server('s1', 'S1', [_item('a', 'Movie A')]),
|
||||
_server('s2', 'S2', [_item('b', 'Movie B')]),
|
||||
];
|
||||
final entries = flatten(results);
|
||||
expect(entries.length, 2);
|
||||
});
|
||||
|
||||
test('respects serverFilter', () {
|
||||
final results = [
|
||||
_server('s1', 'S1', [_item('a', 'A')]),
|
||||
_server('s2', 'S2', [_item('b', 'B')]),
|
||||
];
|
||||
final entries = flatten(results, serverFilter: 's1');
|
||||
expect(entries.length, 1);
|
||||
expect(entries.first.source.serverId, 's1');
|
||||
});
|
||||
});
|
||||
|
||||
group('flatten – dedup on, serverFilter null', () {
|
||||
test('collapses same TMDB Movie to newest LastPlayedDate', () {
|
||||
final results = [
|
||||
_server('s1', 'S1', [
|
||||
_item(
|
||||
'a',
|
||||
'Movie',
|
||||
type: 'Movie',
|
||||
providerIds: {'Tmdb': '100'},
|
||||
lastPlayedDate: '2026-06-20T10:00:00Z',
|
||||
),
|
||||
]),
|
||||
_server('s2', 'S2', [
|
||||
_item(
|
||||
'b',
|
||||
'Movie',
|
||||
type: 'Movie',
|
||||
providerIds: {'Tmdb': '100'},
|
||||
lastPlayedDate: '2026-06-24T15:00:00Z',
|
||||
),
|
||||
]),
|
||||
];
|
||||
final entries = flatten(results, deduplicateAcrossServers: true);
|
||||
expect(entries.length, 1);
|
||||
expect(entries.first.source.serverId, 's2');
|
||||
expect(entries.first.sources.map((source) => source.source.serverId), [
|
||||
's2',
|
||||
's1',
|
||||
]);
|
||||
});
|
||||
|
||||
test('preserves each server item and its independent playback state', () {
|
||||
final results = [
|
||||
_server('s1', 'S1', [
|
||||
_item(
|
||||
'a',
|
||||
'Movie',
|
||||
type: 'Movie',
|
||||
providerIds: {'Tmdb': '100'},
|
||||
lastPlayedDate: '2026-06-20T10:00:00Z',
|
||||
playbackPositionTicks: 100,
|
||||
runtimeTicks: 1000,
|
||||
),
|
||||
]),
|
||||
_server('s2', 'S2', [
|
||||
_item(
|
||||
'b',
|
||||
'Movie',
|
||||
type: 'Movie',
|
||||
providerIds: {'Tmdb': '100'},
|
||||
lastPlayedDate: '2026-06-24T15:00:00Z',
|
||||
playbackPositionTicks: 800,
|
||||
runtimeTicks: 1000,
|
||||
),
|
||||
]),
|
||||
];
|
||||
|
||||
final entry = flatten(results, deduplicateAcrossServers: true).single;
|
||||
|
||||
expect(entry.sources, hasLength(2));
|
||||
expect(entry.sources[0].item.UserData?.PlaybackPositionTicks, 800);
|
||||
expect(entry.sources[1].item.UserData?.PlaybackPositionTicks, 100);
|
||||
expect(entry.sources[0].item.RunTimeTicks, 1000);
|
||||
expect(entry.sources[1].item.RunTimeTicks, 1000);
|
||||
});
|
||||
|
||||
test('keeps input order when sources have equal LastPlayedDate', () {
|
||||
final results = [
|
||||
_server('s1', 'S1', [
|
||||
_item(
|
||||
'a',
|
||||
'Movie',
|
||||
type: 'Movie',
|
||||
providerIds: {'Tmdb': '100'},
|
||||
lastPlayedDate: '2026-06-24T15:00:00Z',
|
||||
),
|
||||
]),
|
||||
_server('s2', 'S2', [
|
||||
_item(
|
||||
'b',
|
||||
'Movie',
|
||||
type: 'Movie',
|
||||
providerIds: {'Tmdb': '100'},
|
||||
lastPlayedDate: '2026-06-24T15:00:00Z',
|
||||
),
|
||||
]),
|
||||
];
|
||||
|
||||
final entry = flatten(results, deduplicateAcrossServers: true).single;
|
||||
|
||||
expect(entry.sources.map((source) => source.source.serverId), [
|
||||
's1',
|
||||
's2',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
group('flatten – dedup on + serverFilter non-null', () {
|
||||
test('skips dedup when server filter is active', () {
|
||||
final results = [
|
||||
_server('s1', 'S1', [
|
||||
_item('a', 'Movie', type: 'Movie', providerIds: {'Tmdb': '100'}),
|
||||
]),
|
||||
_server('s2', 'S2', [
|
||||
_item('b', 'Movie', type: 'Movie', providerIds: {'Tmdb': '100'}),
|
||||
]),
|
||||
];
|
||||
final entries = flatten(
|
||||
results,
|
||||
serverFilter: 's1',
|
||||
deduplicateAcrossServers: true,
|
||||
);
|
||||
expect(entries.length, 1);
|
||||
expect(entries.first.source.serverId, 's1');
|
||||
expect(entries.first.sources, hasLength(1));
|
||||
});
|
||||
});
|
||||
|
||||
group('no ProviderIds – never deduped', () {
|
||||
test('items without ProviderIds stay separate', () {
|
||||
final results = [
|
||||
_server('s1', 'S1', [_item('a', 'Same Name', type: 'Movie')]),
|
||||
_server('s2', 'S2', [_item('b', 'Same Name', type: 'Movie')]),
|
||||
];
|
||||
final entries = flatten(results, deduplicateAcrossServers: true);
|
||||
expect(entries.length, 2);
|
||||
});
|
||||
});
|
||||
|
||||
group('Type namespace isolation', () {
|
||||
test('Movie and Series with same tmdb id stay separate', () {
|
||||
final results = [
|
||||
_server('s1', 'S1', [
|
||||
_item('a', 'Title', type: 'Movie', providerIds: {'Tmdb': '100'}),
|
||||
]),
|
||||
_server('s2', 'S2', [
|
||||
_item('b', 'Title', type: 'Series', providerIds: {'Tmdb': '100'}),
|
||||
]),
|
||||
];
|
||||
final entries = flatten(results, deduplicateAcrossServers: true);
|
||||
expect(entries.length, 2);
|
||||
});
|
||||
});
|
||||
|
||||
group('ProviderIds key case insensitivity', () {
|
||||
test('Tmdb vs tmdb vs TMDB all match', () {
|
||||
final results = [
|
||||
_server('s1', 'S1', [
|
||||
_item(
|
||||
'a',
|
||||
'M',
|
||||
type: 'Movie',
|
||||
providerIds: {'Tmdb': '99'},
|
||||
lastPlayedDate: '2026-06-20T00:00:00Z',
|
||||
),
|
||||
]),
|
||||
_server('s2', 'S2', [
|
||||
_item(
|
||||
'b',
|
||||
'M',
|
||||
type: 'Movie',
|
||||
providerIds: {'tmdb': '99'},
|
||||
lastPlayedDate: '2026-06-21T00:00:00Z',
|
||||
),
|
||||
]),
|
||||
_server('s3', 'S3', [
|
||||
_item(
|
||||
'c',
|
||||
'M',
|
||||
type: 'Movie',
|
||||
providerIds: {'TMDB': '99'},
|
||||
lastPlayedDate: '2026-06-22T00:00:00Z',
|
||||
),
|
||||
]),
|
||||
];
|
||||
final entries = flatten(results, deduplicateAcrossServers: true);
|
||||
expect(entries.length, 1);
|
||||
expect(entries.first.source.serverId, 's3');
|
||||
});
|
||||
});
|
||||
|
||||
group('null LastPlayedDate handling', () {
|
||||
test('null loses to valid date', () {
|
||||
final results = [
|
||||
_server('s1', 'S1', [
|
||||
_item('a', 'M', type: 'Movie', providerIds: {'Tmdb': '1'}),
|
||||
]),
|
||||
_server('s2', 'S2', [
|
||||
_item(
|
||||
'b',
|
||||
'M',
|
||||
type: 'Movie',
|
||||
providerIds: {'Tmdb': '1'},
|
||||
lastPlayedDate: '2026-06-01T00:00:00Z',
|
||||
),
|
||||
]),
|
||||
];
|
||||
final entries = flatten(results, deduplicateAcrossServers: true);
|
||||
expect(entries.length, 1);
|
||||
expect(entries.first.source.serverId, 's2');
|
||||
});
|
||||
|
||||
test('both null keeps first encountered', () {
|
||||
final results = [
|
||||
_server('s1', 'S1', [
|
||||
_item('a', 'M', type: 'Movie', providerIds: {'Tmdb': '1'}),
|
||||
]),
|
||||
_server('s2', 'S2', [
|
||||
_item('b', 'M', type: 'Movie', providerIds: {'Tmdb': '1'}),
|
||||
]),
|
||||
];
|
||||
final entries = flatten(results, deduplicateAcrossServers: true);
|
||||
expect(entries.length, 1);
|
||||
expect(entries.first.source.serverId, 's1');
|
||||
});
|
||||
});
|
||||
|
||||
group('transitive matching', () {
|
||||
test('A(tmdb:1) B(tmdb:1,imdb:tt2) C(imdb:tt2) collapse to one', () {
|
||||
final results = [
|
||||
_server('s1', 'S1', [
|
||||
_item(
|
||||
'a',
|
||||
'M',
|
||||
type: 'Movie',
|
||||
providerIds: {'Tmdb': '1'},
|
||||
lastPlayedDate: '2026-06-20T00:00:00Z',
|
||||
),
|
||||
]),
|
||||
_server('s2', 'S2', [
|
||||
_item(
|
||||
'b',
|
||||
'M',
|
||||
type: 'Movie',
|
||||
providerIds: {'Tmdb': '1', 'Imdb': 'tt2'},
|
||||
lastPlayedDate: '2026-06-22T00:00:00Z',
|
||||
),
|
||||
]),
|
||||
_server('s3', 'S3', [
|
||||
_item(
|
||||
'c',
|
||||
'M',
|
||||
type: 'Movie',
|
||||
providerIds: {'Imdb': 'tt2'},
|
||||
lastPlayedDate: '2026-06-21T00:00:00Z',
|
||||
),
|
||||
]),
|
||||
];
|
||||
final entries = flatten(results, deduplicateAcrossServers: true);
|
||||
expect(entries.length, 1);
|
||||
expect(entries.first.source.serverId, 's2');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/contracts/library.dart';
|
||||
import 'package:smplayer/features/aggregated/widgets/aggregated_media_card.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('multi-source badge does not trigger the whole card', (
|
||||
tester,
|
||||
) async {
|
||||
var cardTapCount = 0;
|
||||
var sourceBadgeTapCount = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
child: MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: SizedBox(
|
||||
width: 420,
|
||||
child: AggregatedMediaCard(
|
||||
item: const EmbyRawItem(Id: 'movie-1', Name: 'Movie'),
|
||||
imageUrl: null,
|
||||
title: 'Movie',
|
||||
serverName: 'Alpha',
|
||||
serverSourceCount: 2,
|
||||
onTap: () => cardTapCount++,
|
||||
onServerSourcesTap: () => sourceBadgeTapCount++,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Alpha 2服'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.keyboard_arrow_down_rounded), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Alpha 2服'));
|
||||
await tester.pump();
|
||||
|
||||
expect(sourceBadgeTapCount, 1);
|
||||
expect(cardTapCount, 0);
|
||||
|
||||
await tester.tap(find.text('Movie'));
|
||||
await tester.pump();
|
||||
|
||||
expect(cardTapCount, 1);
|
||||
});
|
||||
|
||||
testWidgets('single-source badge keeps the existing passive appearance', (
|
||||
tester,
|
||||
) async {
|
||||
var cardTapCount = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
child: MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: SizedBox(
|
||||
width: 420,
|
||||
child: AggregatedMediaCard(
|
||||
item: const EmbyRawItem(Id: 'movie-1', Name: 'Movie'),
|
||||
imageUrl: null,
|
||||
title: 'Movie',
|
||||
serverName: 'Alpha',
|
||||
onTap: () => cardTapCount++,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Alpha'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.keyboard_arrow_down_rounded), findsNothing);
|
||||
|
||||
await tester.tap(find.text('Alpha'));
|
||||
await tester.pump();
|
||||
|
||||
expect(cardTapCount, 1);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user