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
@@ -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);
});
}