320 lines
8.9 KiB
Dart
320 lines
8.9 KiB
Dart
|
|
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');
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
}
|