Files
plezy/test/services/trackers/anime_episode_progress_resolver_test.dart
T

257 lines
8.9 KiB
Dart

import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/models/trackers/anime_lists_mapping.dart';
import 'package:plezy/services/trackers/anime_episode_progress_resolver.dart';
import '../../test_helpers/media_items.dart';
class _FakeMediaServerClient implements MediaServerClient {
final Map<String, List<MediaItem>> childrenByParent;
final Map<String, List<MediaItem>> playableByParent;
Object? throwOnFetchChildren;
int fetchChildrenCalls = 0;
int fetchPlayableDescendantsCalls = 0;
_FakeMediaServerClient(this.childrenByParent, {this.playableByParent = const {}});
@override
Future<List<MediaItem>> fetchChildren(String parentId) async {
fetchChildrenCalls++;
final error = throwOnFetchChildren;
if (error != null) throw error;
return childrenByParent[parentId] ?? const [];
}
@override
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async {
fetchPlayableDescendantsCalls++;
return playableByParent[parentId] ?? const [];
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
MediaItem _season(int number, {int? watched, int? total}) => testMediaItem(
id: 'season-$number',
backend: MediaBackend.plex,
kind: MediaKind.season,
title: 'Season $number',
index: number,
leafCount: total,
viewedLeafCount: watched,
);
MediaItem _episode({int season = 2, int number = 6, String showId = 'show-1', int? viewCount}) => testMediaItem(
id: 'episode-$season-$number',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Episode $number',
grandparentId: showId,
parentIndex: season,
index: number,
viewCount: viewCount,
);
AnimeEpisodeMatch _match({required int anidbId, required int serverEpisode, required int animeEpisode}) =>
AnimeEpisodeMatch(
anidbId: anidbId,
anidbSeason: 1,
anidbEpisode: animeEpisode,
provider: AnimeListProvider.tvdb,
externalSeason: 1,
externalEpisode: serverEpisode,
kind: AnimeListMatchKind.range,
);
void main() {
group('AnimeEpisodeProgressResolver', () {
test('show scope sums watched counts across regular seasons', () async {
final seasons = [_season(1, watched: 45), _season(2, watched: 10)];
final resolver = AnimeEpisodeProgressResolver(_FakeMediaServerClient({'show-1': seasons}));
final result = await resolver.resolve(_episode(), scope: AnimeProgressScope.show);
expect(result?.progress, 56);
});
test('show scope ignores specials season', () async {
final resolver = AnimeEpisodeProgressResolver(
_FakeMediaServerClient({
'show-1': [_season(0, watched: 999), _season(1, watched: 5)],
}),
);
final result = await resolver.resolve(_episode(season: 1, number: 6), scope: AnimeProgressScope.show);
expect(result?.progress, 6);
});
test('season scope uses only current season watched count', () async {
final resolver = AnimeEpisodeProgressResolver(
_FakeMediaServerClient({
'show-1': [_season(1, watched: 100), _season(2, watched: 5)],
}),
);
final result = await resolver.resolve(_episode(season: 2, number: 6), scope: AnimeProgressScope.season);
expect(result?.progress, 6);
});
test('season scope caps progress at known season total', () async {
final resolver = AnimeEpisodeProgressResolver(
_FakeMediaServerClient({
'show-1': [_season(2, watched: 12, total: 12)],
}),
);
final result = await resolver.resolve(_episode(season: 2, number: 12), scope: AnimeProgressScope.season);
expect(result?.progress, 12);
});
test('show scope caps progress at known show total', () async {
final resolver = AnimeEpisodeProgressResolver(
_FakeMediaServerClient({
'show-1': [_season(1, watched: 12, total: 12), _season(2, watched: 12, total: 12)],
}),
);
final result = await resolver.resolve(_episode(season: 2, number: 12), scope: AnimeProgressScope.show);
expect(result?.progress, 24);
});
test('unknown total still returns progress', () async {
final resolver = AnimeEpisodeProgressResolver(
_FakeMediaServerClient({
'show-1': [_season(1, watched: 11)],
}),
);
final result = await resolver.resolve(_episode(season: 1, number: 12), scope: AnimeProgressScope.season);
expect(result?.progress, 12);
});
test('already watched current episode does not add one', () async {
final resolver = AnimeEpisodeProgressResolver(
_FakeMediaServerClient({
'show-1': [_season(1, watched: 5)],
}),
);
final result = await resolver.resolve(
_episode(season: 1, number: 5, viewCount: 1),
scope: AnimeProgressScope.season,
);
expect(result?.progress, 5);
});
test('missing viewedLeafCount returns null', () async {
final resolver = AnimeEpisodeProgressResolver(
_FakeMediaServerClient({
'show-1': [_season(1, total: 12)],
}),
);
final result = await resolver.resolve(_episode(season: 1, number: 1), scope: AnimeProgressScope.season);
expect(result, isNull);
});
test('returns null instead of throwing when season fetch fails', () async {
final client = _FakeMediaServerClient(const {});
client.throwOnFetchChildren = StateError('offline');
final resolver = AnimeEpisodeProgressResolver(client);
final result = await resolver.resolve(_episode(season: 2, number: 1), scope: AnimeProgressScope.show);
expect(result, isNull);
});
test('in-flight load is reused for concurrent episodes in the same show', () async {
final client = _FakeMediaServerClient({
'show-1': [_season(1, watched: 10), _season(2, watched: 5)],
});
final resolver = AnimeEpisodeProgressResolver(client);
final first = resolver.resolve(_episode(season: 2, number: 6), scope: AnimeProgressScope.show);
final second = resolver.resolve(_episode(season: 2, number: 7), scope: AnimeProgressScope.show);
expect((await first)?.progress, 16);
expect((await second)?.progress, 16);
expect(client.fetchChildrenCalls, 1);
});
test('sequential loads refetch watched counts', () async {
final client = _FakeMediaServerClient({
'show-1': [_season(1, watched: 5)],
});
final resolver = AnimeEpisodeProgressResolver(client);
expect((await resolver.resolve(_episode(season: 1, number: 6), scope: AnimeProgressScope.season))?.progress, 6);
client.childrenByParent['show-1'] = [_season(1, watched: 6)];
expect((await resolver.resolve(_episode(season: 1, number: 7), scope: AnimeProgressScope.season))?.progress, 7);
expect(client.fetchChildrenCalls, 2);
});
test('mapped scope counts only watched episodes in the selected anime entry', () async {
final client = _FakeMediaServerClient(
const {},
playableByParent: {
'show-1': [
_episode(season: 1, number: 12, viewCount: 1),
_episode(season: 1, number: 13, viewCount: 1),
_episode(season: 1, number: 14),
],
},
);
final resolver = AnimeEpisodeProgressResolver(client);
final result = await resolver.resolve(
_episode(season: 1, number: 14),
scope: AnimeProgressScope.mapped,
animeMatch: _match(anidbId: 2, serverEpisode: 14, animeEpisode: 2),
episodeMatcher: (episode) async => switch (episode.index) {
12 => _match(anidbId: 1, serverEpisode: 12, animeEpisode: 12),
13 => _match(anidbId: 2, serverEpisode: 13, animeEpisode: 1),
14 => _match(anidbId: 2, serverEpisode: 14, animeEpisode: 2),
_ => null,
},
);
expect(result?.progress, 2);
expect(client.fetchPlayableDescendantsCalls, 1);
});
test('mapped scope can exclude the current episode for unwatch progress', () async {
final client = _FakeMediaServerClient(
const {},
playableByParent: {
'show-1': [_episode(season: 1, number: 13, viewCount: 1), _episode(season: 1, number: 14)],
},
);
final resolver = AnimeEpisodeProgressResolver(client);
final result = await resolver.resolve(
_episode(season: 1, number: 14),
scope: AnimeProgressScope.mapped,
animeMatch: _match(anidbId: 2, serverEpisode: 14, animeEpisode: 2),
includeCurrentEpisode: false,
episodeMatcher: (episode) async => switch (episode.index) {
13 => _match(anidbId: 2, serverEpisode: 13, animeEpisode: 1),
14 => _match(anidbId: 2, serverEpisode: 14, animeEpisode: 2),
_ => null,
},
);
expect(result?.progress, 1);
});
});
}