feat(explore): surface the catalog data providers already return
Explore shelf cards drew a poster, a title and a year. An audit of all six catalog sources found the rest was lost at two boundaries — the wire-to-DTO mapping and the DTO-to-CatalogItem mapping — and then simply not drawn: the grid card fell through every branch of buildMetadataSubtitle to the year-only case, while the list card used by search already composed certification, runtime and rating from fields the synthesized MediaItem already held. Extend CatalogItem with the neutral facts every provider had been dropping: attributed rating sources, leaderboard ranks that keep their season window, audience counters that keep their timeframe, broadcast slots, next-episode air times, server availability and request state, exact release dates, alternate titles, format, source material, studios, countries, languages, credits, tags, links, artwork variants, play state, gallery art and background prose. Replace fetchCast and fetchRelated with one fetchDetail returning the enriched item, its cast, its recommendations and labelled franchise relations without adding a request: sources needing two calls keep two and run them concurrently with isolated failures. Map those fields in all six sources, widening only field selections that cost no extra round trip — MAL's fields list, AniList's selection set and a bounded row cast that lets detail skip its character call, Trakt's guest stars, Seerr's language parameter and TMDB size ladder, and Plex's includeUserState. Plex hub artwork widens only on TV, where the spotlight is its only consumer, because it doubles the payload. Render them: a rating-first caption and bounded badges on the shelf card, labelled sections on the detail screen, provider hub styles and result counts on shelves, and logo, banner and accent art in the TV spotlight. Verified against live Plex, AniList, Simkl and MAL responses, and on a Pixel 7.
This commit is contained in:
@@ -3,6 +3,7 @@ import 'dart:convert';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/models/catalog/catalog_item.dart';
|
||||
import 'package:plezy/models/catalog/catalog_metadata.dart';
|
||||
import 'package:plezy/utils/external_ids.dart';
|
||||
|
||||
void main() {
|
||||
@@ -137,5 +138,54 @@ void main() {
|
||||
expect(decoded.altTitles, isEmpty);
|
||||
expect(decoded.season, isNull);
|
||||
});
|
||||
|
||||
test('enrichedWith unions audience counters instead of replacing them', () {
|
||||
// A Simkl trending row supplies windowed viewers and planning; its
|
||||
// detail body supplies only a drop rate. Replacing the object wholesale
|
||||
// silently dropped the row's counters.
|
||||
const row = CatalogItem(
|
||||
source: CatalogSourceId.simkl,
|
||||
kind: MediaKind.show,
|
||||
title: 'House of the Dragon',
|
||||
ids: CatalogItemIds(simkl: 1197910),
|
||||
audience: CatalogAudience(viewers: 7603, viewersPeriod: CatalogAudiencePeriod.week, planning: 8422),
|
||||
);
|
||||
const detail = CatalogItem(
|
||||
source: CatalogSourceId.simkl,
|
||||
kind: MediaKind.show,
|
||||
title: 'House of the Dragon',
|
||||
ids: CatalogItemIds(simkl: 1197910),
|
||||
audience: CatalogAudience(dropRate: 0.031),
|
||||
);
|
||||
|
||||
final merged = row.enrichedWith(detail).audience!;
|
||||
expect(merged.viewers, 7603);
|
||||
expect(merged.viewersPeriod, CatalogAudiencePeriod.week);
|
||||
expect(merged.planning, 8422);
|
||||
expect(merged.dropRate, 0.031);
|
||||
});
|
||||
|
||||
test('enrichedWith lets detail replace a row value and merges ids per key', () {
|
||||
const row = CatalogItem(
|
||||
source: CatalogSourceId.seerr,
|
||||
kind: MediaKind.movie,
|
||||
title: 'The Matrix',
|
||||
ids: CatalogItemIds(imdb: 'tt0133093'),
|
||||
ranks: [CatalogRank(rank: 3, scope: CatalogRankScope.trending, allTime: false)],
|
||||
);
|
||||
const detail = CatalogItem(
|
||||
source: CatalogSourceId.seerr,
|
||||
kind: MediaKind.movie,
|
||||
title: 'The Matrix',
|
||||
overview: 'A full synopsis the row never carried.',
|
||||
ids: CatalogItemIds(tmdb: 603),
|
||||
);
|
||||
|
||||
final merged = row.enrichedWith(detail);
|
||||
expect(merged.overview, 'A full synopsis the row never carried.');
|
||||
expect(merged.ids.imdb, 'tt0133093', reason: 'row-only id must survive');
|
||||
expect(merged.ids.tmdb, 603, reason: 'detail id must be adopted');
|
||||
expect(merged.ranks?.single.rank, 3, reason: 'a rank is row context a detail body cannot know');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/i18n/strings.g.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/models/catalog/catalog_cast_member.dart';
|
||||
import 'package:plezy/models/catalog/catalog_item.dart';
|
||||
import 'package:plezy/providers/catalog_sources_provider.dart';
|
||||
import 'package:plezy/providers/explore_provider.dart';
|
||||
@@ -67,9 +66,8 @@ class _FakeSource implements CatalogSource {
|
||||
@override
|
||||
Future<List<CatalogItem>> search(String query, {int limit = 30}) async => const [];
|
||||
@override
|
||||
Future<List<CatalogCastMember>> fetchCast(CatalogItem item, {int limit = 20}) async => const [];
|
||||
@override
|
||||
Future<List<CatalogItem>> fetchRelated(CatalogItem item, {int limit = 20}) async => const [];
|
||||
Future<CatalogDetail> fetchDetail(CatalogItem item, {int castLimit = 20, int relatedLimit = 20}) async =>
|
||||
CatalogDetail(item: item);
|
||||
@override
|
||||
Future<void> ensureWatchlistLoaded() async {}
|
||||
@override
|
||||
@@ -91,6 +89,8 @@ class _FakeHubSource extends _FakeSource implements CatalogHubSource {
|
||||
int hubFetches = 0;
|
||||
int hubFailuresRemaining = 0;
|
||||
bool returnEmptyHubs = false;
|
||||
CatalogHubStyle? hubStyle;
|
||||
int? hubTotalResults;
|
||||
|
||||
CatalogItem _hubItem(String title) => CatalogItem(
|
||||
source: id,
|
||||
@@ -111,7 +111,8 @@ class _FakeHubSource extends _FakeSource implements CatalogHubSource {
|
||||
CatalogHub(
|
||||
id: 'trending-plex',
|
||||
title: 'Trending on Plex',
|
||||
page: CatalogPage(items: [_hubItem('Initial Recommendation')], hasMore: true),
|
||||
style: hubStyle,
|
||||
page: CatalogPage(items: [_hubItem('Initial Recommendation')], hasMore: true, totalResults: hubTotalResults),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -208,6 +209,50 @@ void main() {
|
||||
expect(allItems.map((item) => item.title), ['Recommendation Page 1', 'Recommendation Page 2']);
|
||||
});
|
||||
|
||||
test('null and shelf styles retain the existing shelf appearance', () async {
|
||||
final source = _FakeHubSource(CatalogSourceId.plex);
|
||||
addTearDown(source.dispose);
|
||||
sources.setActive(source);
|
||||
await _pumpMicrotasks();
|
||||
|
||||
final unstyled = explore.rowHubs.last;
|
||||
expect(unstyled.style, isNull);
|
||||
|
||||
source.hubStyle = CatalogHubStyle.shelf;
|
||||
await explore.load();
|
||||
final shelf = explore.rowHubs.last;
|
||||
|
||||
expect(shelf.style, CatalogHubStyle.shelf);
|
||||
expect(shelf.hub.title, unstyled.hub.title);
|
||||
expect(shelf.hub.type, unstyled.hub.type);
|
||||
expect(shelf.hub.items.map((item) => item.title), unstyled.hub.items.map((item) => item.title));
|
||||
expect(shelf.hub.more, unstyled.hub.more);
|
||||
});
|
||||
|
||||
test('availability platform hubs are skipped instead of rendered as title posters', () async {
|
||||
final source = _FakeHubSource(CatalogSourceId.plex)..hubStyle = CatalogHubStyle.availabilityPlatforms;
|
||||
addTearDown(source.dispose);
|
||||
sources.setActive(source);
|
||||
await _pumpMicrotasks();
|
||||
|
||||
expect(explore.rowHubs, hasLength(1));
|
||||
expect(explore.rowHubs.single.row, CatalogRowId.watchlist);
|
||||
expect(explore.rowHubs.single.hub.items.single.title, 'plex:watchlist');
|
||||
expect(explore.rowHubs.where((hub) => hub.providerHubId != null), isEmpty);
|
||||
});
|
||||
|
||||
test('provider totalResults reaches the rendered hub without replacing its loaded items', () async {
|
||||
final source = _FakeHubSource(CatalogSourceId.plex)..hubTotalResults = 347;
|
||||
addTearDown(source.dispose);
|
||||
sources.setActive(source);
|
||||
await _pumpMicrotasks();
|
||||
|
||||
final providerHub = explore.rowHubs.last;
|
||||
expect(providerHub.totalResults, 347);
|
||||
expect(providerHub.hub.size, 347);
|
||||
expect(providerHub.hub.items, hasLength(1));
|
||||
});
|
||||
|
||||
test('mutation during the initial load is caught up by ensureFresh', () async {
|
||||
final source = _FakeSource(CatalogSourceId.trakt)..gate = true;
|
||||
addTearDown(source.dispose);
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/focus/focusable_action_bar.dart';
|
||||
import 'package:plezy/i18n/strings.g.dart';
|
||||
@@ -9,6 +10,7 @@ import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
import 'package:plezy/models/catalog/catalog_cast_member.dart';
|
||||
import 'package:plezy/models/catalog/catalog_item.dart';
|
||||
import 'package:plezy/models/catalog/catalog_metadata.dart';
|
||||
import 'package:plezy/providers/catalog_sources_provider.dart';
|
||||
import 'package:plezy/screens/catalog_item_detail_screen.dart';
|
||||
import 'package:plezy/services/catalog/catalog_source.dart';
|
||||
@@ -19,6 +21,7 @@ import 'package:plezy/theme/mono_theme.dart';
|
||||
import 'package:plezy/utils/platform_detector.dart';
|
||||
import 'package:plezy/widgets/overlay_sheet.dart';
|
||||
import 'package:plezy/widgets/media_card.dart';
|
||||
import 'package:plezy/widgets/optimized_media_image.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../test_helpers/media_items.dart';
|
||||
@@ -27,7 +30,7 @@ import '../test_helpers/prefs.dart';
|
||||
|
||||
class _FakeCatalogSource implements CatalogSource {
|
||||
final WatchlistChangeNotifier _watchlistChanges = WatchlistChangeNotifier();
|
||||
_FakeCatalogSource({bool watchlistLoading = false})
|
||||
_FakeCatalogSource({bool watchlistLoading = false, this.detail, this.detailError, this.detailCompleter})
|
||||
: _watchlistValue = watchlistLoading ? null : false,
|
||||
_watchlistLoad = watchlistLoading ? Completer<void>() : null;
|
||||
|
||||
@@ -35,6 +38,10 @@ class _FakeCatalogSource implements CatalogSource {
|
||||
final Completer<void>? _watchlistLoad;
|
||||
int addToWatchlistCalls = 0;
|
||||
|
||||
final CatalogDetail? detail;
|
||||
final Object? detailError;
|
||||
final Completer<CatalogDetail>? detailCompleter;
|
||||
int fetchDetailCalls = 0;
|
||||
@override
|
||||
CatalogSourceId get id => CatalogSourceId.trakt;
|
||||
|
||||
@@ -48,20 +55,29 @@ class _FakeCatalogSource implements CatalogSource {
|
||||
Listenable get watchlistChanges => _watchlistChanges;
|
||||
|
||||
@override
|
||||
Future<List<CatalogCastMember>> fetchCast(CatalogItem item, {int limit = 20}) async => const [
|
||||
CatalogCastMember(name: 'First Actor', secondary: 'Lead'),
|
||||
CatalogCastMember(name: 'Second Actor', secondary: 'Support'),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<CatalogItem>> fetchRelated(CatalogItem item, {int limit = 20}) async => const [
|
||||
CatalogItem(
|
||||
source: CatalogSourceId.trakt,
|
||||
kind: MediaKind.movie,
|
||||
title: 'Related Movie',
|
||||
ids: CatalogItemIds(tmdb: 2),
|
||||
),
|
||||
];
|
||||
Future<CatalogDetail> fetchDetail(CatalogItem item, {int castLimit = 20, int relatedLimit = 20}) async {
|
||||
fetchDetailCalls++;
|
||||
final completer = detailCompleter;
|
||||
if (completer != null) return completer.future;
|
||||
final error = detailError;
|
||||
if (error != null) throw error;
|
||||
return detail ??
|
||||
CatalogDetail(
|
||||
item: item,
|
||||
cast: const [
|
||||
CatalogCastMember(name: 'First Actor', secondary: 'Lead'),
|
||||
CatalogCastMember(name: 'Second Actor', secondary: 'Support'),
|
||||
],
|
||||
related: const [
|
||||
CatalogItem(
|
||||
source: CatalogSourceId.trakt,
|
||||
kind: MediaKind.movie,
|
||||
title: 'Related Movie',
|
||||
ids: CatalogItemIds(tmdb: 2),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> ensureWatchlistLoaded() async {
|
||||
@@ -123,6 +139,7 @@ Future<void> _pumpDetail(
|
||||
_FakeCatalogSource source, {
|
||||
List<MediaItem> matches = const [],
|
||||
bool pushedRoute = false,
|
||||
CatalogItem item = _item,
|
||||
}) async {
|
||||
final sources = _FakeCatalogSourcesProvider(source);
|
||||
final serverManager = MultiServerManager();
|
||||
@@ -148,12 +165,12 @@ Future<void> _pumpDetail(
|
||||
body: TextButton(
|
||||
onPressed: () => Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute<void>(builder: (_) => const CatalogItemDetailScreen(item: _item))),
|
||||
).push(MaterialPageRoute<void>(builder: (_) => CatalogItemDetailScreen(item: item))),
|
||||
child: const Text('Open catalog'),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const CatalogItemDetailScreen(item: _item),
|
||||
: CatalogItemDetailScreen(item: item),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -168,8 +185,10 @@ Future<void> _pumpDetail(
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUpAll(() {
|
||||
setUpAll(() async {
|
||||
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||
// The facts section formats dates; `main.dart` does this at startup.
|
||||
await initializeDateFormatting('en');
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
@@ -183,6 +202,382 @@ void main() {
|
||||
TvDetectionService.debugSetAppleTVOverride(null);
|
||||
});
|
||||
|
||||
testWidgets('fetchDetail replaces the opening item with its enriched item once loaded', (tester) async {
|
||||
final detailCompleter = Completer<CatalogDetail>();
|
||||
final source = _FakeCatalogSource(detailCompleter: detailCompleter);
|
||||
|
||||
await _pumpDetail(tester, source);
|
||||
expect(find.text('Catalog Movie'), findsOneWidget);
|
||||
expect(find.text('Enriched overview'), findsNothing);
|
||||
|
||||
detailCompleter.complete(
|
||||
const CatalogDetail(
|
||||
item: CatalogItem(
|
||||
source: CatalogSourceId.trakt,
|
||||
kind: MediaKind.movie,
|
||||
title: 'Enriched Catalog Movie',
|
||||
overview: 'Enriched overview',
|
||||
ids: CatalogItemIds(tmdb: 1),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(source.fetchDetailCalls, 1);
|
||||
expect(find.text('Enriched Catalog Movie'), findsOneWidget);
|
||||
expect(find.text('Enriched overview'), findsOneWidget);
|
||||
expect(find.text('Catalog Movie'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('fetchDetail failure leaves the opening item rendered', (tester) async {
|
||||
final source = _FakeCatalogSource(detailError: StateError('detail unavailable'));
|
||||
|
||||
await _pumpDetail(tester, source);
|
||||
|
||||
expect(source.fetchDetailCalls, 1);
|
||||
expect(find.text('Catalog Movie'), findsOneWidget);
|
||||
expect(find.text('Overview'), findsOneWidget);
|
||||
expect(find.text(t.explore.cast), findsNothing);
|
||||
expect(find.text(t.discover.moreLikeThis), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('spoiler tags stay hidden until the focusable reveal action is pressed', (tester) async {
|
||||
const item = CatalogItem(
|
||||
source: CatalogSourceId.trakt,
|
||||
kind: MediaKind.movie,
|
||||
title: 'Tagged Movie',
|
||||
ids: CatalogItemIds(tmdb: 10),
|
||||
tags: [
|
||||
CatalogTag(name: 'Found family', rank: 80),
|
||||
CatalogTag(name: 'Secret identity', rank: 95, isSpoiler: true),
|
||||
],
|
||||
);
|
||||
final source = _FakeCatalogSource(detail: const CatalogDetail(item: item));
|
||||
|
||||
await _pumpDetail(tester, source, item: item);
|
||||
|
||||
expect(find.text('Found family'), findsOneWidget);
|
||||
expect(find.text('Secret identity'), findsNothing);
|
||||
expect(find.text(t.explore.detail.revealSpoilerTags), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text(t.explore.detail.revealSpoilerTags));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Secret identity'), findsOneWidget);
|
||||
expect(find.text(t.explore.detail.revealSpoilerTags), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('ratings row labels every supported score source and its vote count', (tester) async {
|
||||
const item = CatalogItem(
|
||||
source: CatalogSourceId.trakt,
|
||||
kind: MediaKind.movie,
|
||||
title: 'Rated Movie',
|
||||
ids: CatalogItemIds(tmdb: 11),
|
||||
ratings: [
|
||||
CatalogRatingSource(source: 'simkl', value: 8.1, votes: 11),
|
||||
CatalogRatingSource(source: 'imdb', value: 7.9, votes: 12),
|
||||
CatalogRatingSource(source: 'mal', value: 8.3, votes: 13),
|
||||
CatalogRatingSource(source: 'critic', value: 7.2, votes: 14),
|
||||
CatalogRatingSource(source: 'audience', value: 8.8, votes: 15),
|
||||
],
|
||||
);
|
||||
final source = _FakeCatalogSource(detail: const CatalogDetail(item: item));
|
||||
|
||||
await _pumpDetail(tester, source, item: item);
|
||||
|
||||
expect(find.text(t.explore.detail.ratings), findsOneWidget);
|
||||
expect(find.text('Simkl 8.1 (11 votes)'), findsOneWidget);
|
||||
expect(find.text('IMDb 7.9 (12 votes)'), findsOneWidget);
|
||||
expect(find.text('MyAnimeList 8.3 (13 votes)'), findsOneWidget);
|
||||
expect(find.text('Critics 7.2 (14 votes)'), findsOneWidget);
|
||||
expect(find.text('Audience 8.8 (15 votes)'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('seasonal rank keeps its season window instead of claiming all-time rank', (tester) async {
|
||||
const item = CatalogItem(
|
||||
source: CatalogSourceId.trakt,
|
||||
kind: MediaKind.show,
|
||||
title: 'Seasonal Show',
|
||||
ids: CatalogItemIds(tmdb: 12),
|
||||
ranks: [
|
||||
CatalogRank(
|
||||
rank: 7,
|
||||
scope: CatalogRankScope.popular,
|
||||
allTime: false,
|
||||
year: 2025,
|
||||
season: CatalogSeasonName.fall,
|
||||
),
|
||||
],
|
||||
);
|
||||
final source = _FakeCatalogSource(detail: const CatalogDetail(item: item));
|
||||
|
||||
await _pumpDetail(tester, source, item: item);
|
||||
|
||||
expect(find.text('#7 in Fall 2025'), findsOneWidget);
|
||||
expect(find.text('#7 popular'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('windowed viewers render only when their period is present', (tester) async {
|
||||
const missingPeriod = CatalogItem(
|
||||
source: CatalogSourceId.trakt,
|
||||
kind: MediaKind.movie,
|
||||
title: 'Missing Period',
|
||||
ids: CatalogItemIds(tmdb: 13),
|
||||
audience: CatalogAudience(listed: 3, viewers: 42),
|
||||
);
|
||||
final firstSource = _FakeCatalogSource(detail: const CatalogDetail(item: missingPeriod));
|
||||
await _pumpDetail(tester, firstSource, item: missingPeriod);
|
||||
|
||||
expect(find.text('3 listed'), findsOneWidget);
|
||||
expect(find.textContaining('42'), findsNothing);
|
||||
|
||||
const weekly = CatalogItem(
|
||||
source: CatalogSourceId.trakt,
|
||||
kind: MediaKind.movie,
|
||||
title: 'Weekly Viewers',
|
||||
ids: CatalogItemIds(tmdb: 14),
|
||||
audience: CatalogAudience(viewers: 42, viewersPeriod: CatalogAudiencePeriod.week),
|
||||
);
|
||||
// Unmount first: pumping a second detail screen at the same tree position
|
||||
// would reuse the existing State, so `initState` would never re-run and
|
||||
// the screen would keep the previous item.
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
final secondSource = _FakeCatalogSource(detail: const CatalogDetail(item: weekly));
|
||||
await _pumpDetail(tester, secondSource, item: weekly);
|
||||
|
||||
expect(find.text('42 watched this week'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('trailer action appears only after an item supplies a trailer URL', (tester) async {
|
||||
final detailCompleter = Completer<CatalogDetail>();
|
||||
final source = _FakeCatalogSource(detailCompleter: detailCompleter);
|
||||
|
||||
await _pumpDetail(tester, source);
|
||||
expect(find.byTooltip(t.explore.detail.watchTrailer), findsNothing);
|
||||
|
||||
detailCompleter.complete(
|
||||
const CatalogDetail(
|
||||
item: CatalogItem(
|
||||
source: CatalogSourceId.trakt,
|
||||
kind: MediaKind.movie,
|
||||
title: 'Catalog Movie',
|
||||
trailerUrl: 'https://example.com/trailer',
|
||||
ids: CatalogItemIds(tmdb: 1),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byTooltip(t.explore.detail.watchTrailer), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('gallery and background sections render when populated', (tester) async {
|
||||
const galleryUrls = [
|
||||
'https://cdn.myanimelist.net/images/anime/gallery-1.jpg',
|
||||
'https://cdn.myanimelist.net/images/anime/gallery-2.jpg',
|
||||
];
|
||||
const item = CatalogItem(
|
||||
source: CatalogSourceId.trakt,
|
||||
kind: MediaKind.movie,
|
||||
title: 'Production Movie',
|
||||
ids: CatalogItemIds(tmdb: 15),
|
||||
gallery: galleryUrls,
|
||||
background: 'Filmed over three winters.',
|
||||
);
|
||||
final source = _FakeCatalogSource(detail: const CatalogDetail(item: item));
|
||||
|
||||
await _pumpDetail(tester, source, item: item);
|
||||
|
||||
expect(find.text(t.explore.detail.background), findsOneWidget);
|
||||
expect(find.text('Filmed over three winters.'), findsOneWidget);
|
||||
expect(find.text(t.explore.detail.gallery), findsOneWidget);
|
||||
final galleryFinder = find.byKey(const Key('catalog_detail_gallery'));
|
||||
expect(galleryFinder, findsOneWidget);
|
||||
expect(tester.widget<ListView>(galleryFinder).scrollDirection, Axis.horizontal);
|
||||
final images = tester
|
||||
.widgetList<OptimizedMediaImage>(find.descendant(of: galleryFinder, matching: find.byType(OptimizedMediaImage)))
|
||||
.map((image) => image.imagePath);
|
||||
expect(images, galleryUrls);
|
||||
});
|
||||
|
||||
testWidgets('all-null metadata renders without an empty optional section header', (tester) async {
|
||||
const item = CatalogItem(
|
||||
source: CatalogSourceId.trakt,
|
||||
kind: MediaKind.movie,
|
||||
title: 'Bare Movie',
|
||||
ids: CatalogItemIds(tmdb: 15),
|
||||
);
|
||||
final source = _FakeCatalogSource(detail: const CatalogDetail(item: item));
|
||||
|
||||
await _pumpDetail(tester, source, item: item);
|
||||
|
||||
expect(find.text('Bare Movie'), findsOneWidget);
|
||||
expect(find.text(t.explore.detail.ratings), findsNothing);
|
||||
expect(find.text(t.explore.detail.schedule), findsNothing);
|
||||
expect(find.text(t.explore.detail.crew), findsNothing);
|
||||
expect(find.text(t.explore.detail.tags), findsNothing);
|
||||
expect(find.text(t.explore.detail.links), findsNothing);
|
||||
expect(find.text(t.explore.detail.watchOn), findsNothing);
|
||||
expect(find.text(t.explore.cast), findsNothing);
|
||||
expect(find.text(t.discover.moreLikeThis), findsNothing);
|
||||
expect(find.text(t.explore.detail.gallery), findsNothing);
|
||||
expect(find.text(t.explore.detail.background), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('franchise relations keep their labelled shelf separate from recommendations', (tester) async {
|
||||
const relationItem = CatalogItem(
|
||||
source: CatalogSourceId.trakt,
|
||||
kind: MediaKind.movie,
|
||||
title: 'The Sequel',
|
||||
ids: CatalogItemIds(tmdb: 17),
|
||||
);
|
||||
const recommendation = CatalogItem(
|
||||
source: CatalogSourceId.trakt,
|
||||
kind: MediaKind.movie,
|
||||
title: 'A Similar Movie',
|
||||
ids: CatalogItemIds(tmdb: 18),
|
||||
);
|
||||
final source = _FakeCatalogSource(
|
||||
detail: const CatalogDetail(
|
||||
item: _item,
|
||||
related: [recommendation],
|
||||
relations: [
|
||||
CatalogRelation(type: CatalogRelationType.sequel, items: [relationItem]),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
await _pumpDetail(tester, source);
|
||||
|
||||
expect(find.text(t.explore.relation.sequel), findsOneWidget);
|
||||
expect(find.text(t.discover.moreLikeThis), findsOneWidget);
|
||||
expect(find.text('The Sequel'), findsOneWidget);
|
||||
expect(find.text('A Similar Movie'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('social recommendation keeps its person, reason, and note', (tester) async {
|
||||
const item = CatalogItem(
|
||||
source: CatalogSourceId.trakt,
|
||||
kind: MediaKind.movie,
|
||||
title: 'Social Movie',
|
||||
ids: CatalogItemIds(tmdb: 19),
|
||||
recommenders: [
|
||||
CatalogRecommender(
|
||||
username: 'pat',
|
||||
name: 'Pat',
|
||||
note: 'A thoughtful recommendation.',
|
||||
reason: CatalogRecommendationReason.recommended,
|
||||
),
|
||||
],
|
||||
);
|
||||
final source = _FakeCatalogSource(detail: const CatalogDetail(item: item));
|
||||
|
||||
await _pumpDetail(tester, source, item: item);
|
||||
|
||||
expect(find.text('Recommended by Pat'), findsOneWidget);
|
||||
expect(find.text('A thoughtful recommendation.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('extended facts render in their labelled sections with localized values', (tester) async {
|
||||
final item = CatalogItem(
|
||||
source: CatalogSourceId.trakt,
|
||||
kind: MediaKind.show,
|
||||
title: 'Fact-rich Show',
|
||||
ids: CatalogItemIds(tmdb: 20),
|
||||
broadcastSeason: CatalogSeasonInfo(name: CatalogSeasonName.fall, year: 2025),
|
||||
format: CatalogFormat.ova,
|
||||
sourceMaterial: CatalogSourceMaterial.lightNovel,
|
||||
studios: ['Studio One'],
|
||||
countries: ['US'],
|
||||
languages: ['ja'],
|
||||
credits: [
|
||||
CatalogCredit(name: 'A. Director', role: CatalogCreditRole.director),
|
||||
CatalogCredit(name: 'W. Writer', role: CatalogCreditRole.writer),
|
||||
],
|
||||
broadcast: CatalogBroadcast(weekday: DateTime.tuesday, time: '21:00', timezone: 'Asia/Tokyo'),
|
||||
nextEpisode: CatalogNextEpisode(episode: 4, airsAt: DateTime.utc(2100)),
|
||||
serverState: CatalogServerState(
|
||||
availability: CatalogAvailability.available,
|
||||
request: CatalogRequestState.pending,
|
||||
availableSeasons: 2,
|
||||
totalSeasons: 3,
|
||||
),
|
||||
audience: CatalogAudience(dropRate: 0.25),
|
||||
releaseDate: DateTime.utc(2024, 1, 2),
|
||||
physicalReleaseDate: DateTime.utc(2024, 4, 5),
|
||||
endDate: DateTime.utc(2025, 6, 7),
|
||||
addedAt: DateTime.utc(2024, 2, 3),
|
||||
userRating: 9,
|
||||
originalTitle: 'Original Fact Title',
|
||||
altTitles: ['Alternate Fact Title'],
|
||||
contentAdvisory: 'Suitable for older teens.',
|
||||
budget: 1000000,
|
||||
revenue: 2500000,
|
||||
links: [
|
||||
CatalogLink(label: 'StreamCo', url: 'https://example.com/watch', isStreaming: true),
|
||||
CatalogLink(label: 'Official Site', url: 'https://example.com'),
|
||||
],
|
||||
);
|
||||
final source = _FakeCatalogSource(detail: CatalogDetail(item: item));
|
||||
|
||||
await _pumpDetail(tester, source, item: item);
|
||||
|
||||
expect(find.text('Fall 2025'), findsOneWidget);
|
||||
expect(find.text('OVA'), findsOneWidget);
|
||||
expect(find.text('Light novel'), findsOneWidget);
|
||||
expect(find.text('25% dropped it'), findsOneWidget);
|
||||
expect(find.text('Available'), findsOneWidget);
|
||||
expect(find.text('Pending approval'), findsOneWidget);
|
||||
expect(find.text('2/3 seasons'), findsOneWidget);
|
||||
expect(find.text('Airs Tuesday at 21:00 Asia/Tokyo'), findsOneWidget);
|
||||
expect(find.textContaining('Ep 4 in'), findsOneWidget);
|
||||
expect(find.text('United States'), findsOneWidget);
|
||||
expect(find.text('Japanese'), findsOneWidget);
|
||||
expect(find.text(t.explore.detail.crew), findsOneWidget);
|
||||
expect(find.text('A. Director'), findsOneWidget);
|
||||
expect(find.text('W. Writer'), findsOneWidget);
|
||||
expect(find.text(t.explore.detail.watchOn), findsOneWidget);
|
||||
expect(find.text(t.explore.detail.links), findsOneWidget);
|
||||
expect(find.text('Open on StreamCo'), findsOneWidget);
|
||||
expect(find.text('Open on Official Site'), findsOneWidget);
|
||||
expect(find.text('Original Fact Title'), findsOneWidget);
|
||||
expect(find.text('Alternate Fact Title'), findsOneWidget);
|
||||
expect(find.text('Suitable for older teens.'), findsOneWidget);
|
||||
expect(find.textContaining('1,000,000'), findsOneWidget);
|
||||
expect(find.textContaining('2,500,000'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('D-pad includes spoiler reveal and outbound links after the main action bar', (tester) async {
|
||||
const item = CatalogItem(
|
||||
source: CatalogSourceId.trakt,
|
||||
kind: MediaKind.movie,
|
||||
title: 'Interactive Movie',
|
||||
ids: CatalogItemIds(tmdb: 21),
|
||||
trailerUrl: 'https://example.com/trailer',
|
||||
tags: [CatalogTag(name: 'Spoiler', isSpoiler: true)],
|
||||
links: [
|
||||
CatalogLink(label: 'StreamCo', url: 'https://example.com/watch', isStreaming: true),
|
||||
CatalogLink(label: 'Official Site', url: 'https://example.com'),
|
||||
],
|
||||
);
|
||||
final source = _FakeCatalogSource(detail: const CatalogDetail(item: item));
|
||||
|
||||
await _pumpDetail(tester, source, item: item);
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[0]');
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
await tester.pumpAndSettle();
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'catalog_spoiler_tags');
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.select);
|
||||
await tester.pumpAndSettle();
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'catalog_external_link_0');
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
await tester.pumpAndSettle();
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'catalog_external_link_1');
|
||||
});
|
||||
|
||||
testWidgets('D-pad traverses from actions through cast and back from recommendations', (tester) async {
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(1280, 720);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/i18n/strings.g.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/models/catalog/catalog_item.dart';
|
||||
import 'package:plezy/models/catalog/catalog_cast_member.dart';
|
||||
import 'package:plezy/models/catalog/catalog_metadata.dart';
|
||||
import 'package:plezy/providers/catalog_sources_provider.dart';
|
||||
import 'package:plezy/screens/catalog_item_detail_screen.dart';
|
||||
import 'package:plezy/screens/catalog_search_screen.dart';
|
||||
@@ -24,6 +25,7 @@ import '../test_helpers/prefs.dart';
|
||||
class _FakeSearchSource implements CatalogSource {
|
||||
final queries = <String>[];
|
||||
bool failNext = false;
|
||||
CatalogItem? result;
|
||||
|
||||
@override
|
||||
CatalogSourceId get id => CatalogSourceId.trakt;
|
||||
@@ -42,15 +44,14 @@ class _FakeSearchSource implements CatalogSource {
|
||||
throw Exception('boom');
|
||||
}
|
||||
return [
|
||||
CatalogItem(source: id, kind: MediaKind.movie, title: 'result: $query', ids: const CatalogItemIds(tmdb: 1)),
|
||||
result ??
|
||||
CatalogItem(source: id, kind: MediaKind.movie, title: 'result: $query', ids: const CatalogItemIds(tmdb: 1)),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CatalogCastMember>> fetchCast(CatalogItem item, {int limit = 20}) async => const [];
|
||||
|
||||
@override
|
||||
Future<List<CatalogItem>> fetchRelated(CatalogItem item, {int limit = 20}) async => const [];
|
||||
Future<CatalogDetail> fetchDetail(CatalogItem item, {int castLimit = 20, int relatedLimit = 20}) async =>
|
||||
CatalogDetail(item: item);
|
||||
|
||||
@override
|
||||
void dispose() {}
|
||||
@@ -73,8 +74,10 @@ Future<void> _pump(WidgetTester tester, _FakeSearchSource source) async {
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUpAll(() {
|
||||
setUpAll(() async {
|
||||
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||
// Catalog result cards format dates; `main.dart` does this at startup.
|
||||
await initializeDateFormatting('en');
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
@@ -166,6 +169,30 @@ void main() {
|
||||
expect(_appMenuList(), findsNothing);
|
||||
expect(find.byType(CatalogItemDetailScreen), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('catalog result menu exposes trailer and provider links', (tester) async {
|
||||
final source = _FakeSearchSource()
|
||||
..result = const CatalogItem(
|
||||
source: CatalogSourceId.trakt,
|
||||
kind: MediaKind.movie,
|
||||
title: 'result: menu',
|
||||
ids: CatalogItemIds(tmdb: 1),
|
||||
trailerUrl: 'https://example.com/trailer',
|
||||
links: [
|
||||
CatalogLink(label: 'Trakt', url: 'https://example.com/trakt'),
|
||||
CatalogLink(label: 'Stream Co', url: 'https://example.com/watch', isStreaming: true),
|
||||
],
|
||||
);
|
||||
await _pumpMenuSearch(tester, source, platform: TargetPlatform.macOS);
|
||||
await _searchForMenuResult(tester);
|
||||
|
||||
tester.state<MediaCardState>(find.byType(MediaCard)).showContextMenu();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text(t.explore.detail.watchTrailer), findsOneWidget);
|
||||
expect(find.text(t.explore.detail.openOn(site: 'Trakt')), findsOneWidget);
|
||||
expect(find.text(t.explore.detail.openOn(site: 'Stream Co')), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
dynamic _state(WidgetTester tester) => tester.state<State<CatalogSearchScreen>>(find.byType(CatalogSearchScreen));
|
||||
|
||||
@@ -2,10 +2,12 @@ import 'dart:ui' show SemanticsAction;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/i18n/strings.g.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/models/catalog/catalog_item.dart';
|
||||
import 'package:plezy/models/catalog/catalog_metadata.dart';
|
||||
import 'package:plezy/providers/catalog_sources_provider.dart';
|
||||
import 'package:plezy/providers/explore_provider.dart';
|
||||
import 'package:plezy/screens/explore_screen.dart';
|
||||
@@ -15,12 +17,23 @@ import 'package:plezy/theme/mono_theme.dart';
|
||||
import 'package:plezy/utils/platform_detector.dart';
|
||||
import 'package:plezy/widgets/catalog_source_logo.dart';
|
||||
import 'package:plezy/widgets/search_input_field.dart';
|
||||
import 'package:plezy/widgets/fitting_title_text.dart';
|
||||
import 'package:plezy/widgets/optimized_media_image.dart' show ClearLogoImage;
|
||||
import 'package:plezy/widgets/tv_spotlight_background.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../test_helpers/prefs.dart';
|
||||
|
||||
class _FakeCatalogSource implements CatalogSource, CatalogHubSource {
|
||||
_FakeCatalogSource(this.id, this.displayName, this.itemId, {this.providerHubTitle});
|
||||
_FakeCatalogSource(
|
||||
this.id,
|
||||
this.displayName,
|
||||
this.itemId, {
|
||||
this.providerHubTitle,
|
||||
this.providerHubStyle,
|
||||
this.rowItem,
|
||||
this.rowTotalResults,
|
||||
});
|
||||
|
||||
@override
|
||||
final CatalogSourceId id;
|
||||
@@ -30,6 +43,9 @@ class _FakeCatalogSource implements CatalogSource, CatalogHubSource {
|
||||
|
||||
final int? itemId;
|
||||
final String? providerHubTitle;
|
||||
final CatalogHubStyle? providerHubStyle;
|
||||
final CatalogItem? rowItem;
|
||||
final int? rowTotalResults;
|
||||
final WatchlistChangeNotifier _watchlistChanges = WatchlistChangeNotifier();
|
||||
|
||||
/// Search bookkeeping: [searchTitles] overrides the single default hit so a
|
||||
@@ -49,16 +65,20 @@ class _FakeCatalogSource implements CatalogSource, CatalogHubSource {
|
||||
|
||||
@override
|
||||
Future<CatalogPage> fetchRow(CatalogRowId row, {int page = 1, int limit = 25}) async {
|
||||
final item =
|
||||
rowItem ??
|
||||
(itemId == null
|
||||
? null
|
||||
: CatalogItem(
|
||||
source: id,
|
||||
kind: MediaKind.movie,
|
||||
title: '$displayName Movie',
|
||||
ids: CatalogItemIds(tmdb: itemId),
|
||||
));
|
||||
return CatalogPage(
|
||||
items: [
|
||||
if (itemId case final itemId?)
|
||||
CatalogItem(
|
||||
source: id,
|
||||
kind: MediaKind.movie,
|
||||
title: '$displayName Movie',
|
||||
ids: CatalogItemIds(tmdb: itemId),
|
||||
),
|
||||
],
|
||||
items: [?item],
|
||||
hasMore: rowTotalResults != null && rowTotalResults! > 1,
|
||||
totalResults: rowTotalResults,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -85,6 +105,7 @@ class _FakeCatalogSource implements CatalogSource, CatalogHubSource {
|
||||
CatalogHub(
|
||||
id: 'plex-recommendation',
|
||||
title: title,
|
||||
style: providerHubStyle,
|
||||
page: CatalogPage(
|
||||
items: [
|
||||
CatalogItem(
|
||||
@@ -139,6 +160,9 @@ Future<_FakeCatalogSourcesProvider> _pumpExplore(
|
||||
int? traktItemId = 1,
|
||||
int? malItemId = 2,
|
||||
bool? tv,
|
||||
CatalogItem? traktItem,
|
||||
int? traktTotalResults,
|
||||
CatalogHubStyle? plexHubStyle,
|
||||
}) async {
|
||||
if (tv != null) TvDetectionService.debugSetAppleTVOverride(tv);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
@@ -146,11 +170,23 @@ Future<_FakeCatalogSourcesProvider> _pumpExplore(
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
|
||||
final trakt = _FakeCatalogSource(CatalogSourceId.trakt, 'Trakt', traktItemId);
|
||||
final trakt = _FakeCatalogSource(
|
||||
CatalogSourceId.trakt,
|
||||
'Trakt',
|
||||
traktItemId,
|
||||
rowItem: traktItem,
|
||||
rowTotalResults: traktTotalResults,
|
||||
);
|
||||
final mal = _FakeCatalogSource(CatalogSourceId.mal, 'MyAnimeList', malItemId);
|
||||
final anilist = _FakeCatalogSource(CatalogSourceId.anilist, 'AniList', 3);
|
||||
final simkl = _FakeCatalogSource(CatalogSourceId.simkl, 'Simkl', 4);
|
||||
final plex = _FakeCatalogSource(CatalogSourceId.plex, 'Plex', 5, providerHubTitle: 'Trending on Plex');
|
||||
final plex = _FakeCatalogSource(
|
||||
CatalogSourceId.plex,
|
||||
'Plex',
|
||||
5,
|
||||
providerHubTitle: 'Trending on Plex',
|
||||
providerHubStyle: plexHubStyle,
|
||||
);
|
||||
final seerr = _FakeCatalogSource(CatalogSourceId.seerr, 'Seerr', 6);
|
||||
final sources = _FakeCatalogSourcesProvider([trakt, mal, anilist, simkl, plex, seerr]);
|
||||
final explore = ExploreProvider(sources);
|
||||
@@ -184,8 +220,10 @@ _FakeCatalogSource _fakeSource(_FakeCatalogSourcesProvider sources, CatalogSourc
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUpAll(() {
|
||||
setUpAll(() async {
|
||||
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||
// The spotlight countdown formats dates; `main.dart` does this at startup.
|
||||
await initializeDateFormatting('en');
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
@@ -273,7 +311,7 @@ void main() {
|
||||
expect(find.text('AniList Movie'), findsAtLeast(1));
|
||||
});
|
||||
|
||||
testWidgets('Plex provider-defined hub renders as an Explore shelf', (tester) async {
|
||||
testWidgets('a null-style Plex provider hub keeps the existing Explore shelf', (tester) async {
|
||||
final sources = await _pumpExplore(tester);
|
||||
|
||||
await sources.setActiveSource(CatalogSourceId.plex);
|
||||
@@ -284,6 +322,123 @@ void main() {
|
||||
expect(find.text('Plex Recommendation'), findsAtLeast(1));
|
||||
});
|
||||
|
||||
testWidgets('an explicit shelf-style Plex hub keeps the existing Explore shelf', (tester) async {
|
||||
final sources = await _pumpExplore(tester, plexHubStyle: CatalogHubStyle.shelf);
|
||||
|
||||
await sources.setActiveSource(CatalogSourceId.plex);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Trending on Plex'), findsOneWidget);
|
||||
expect(find.text('Plex Recommendation'), findsAtLeast(1));
|
||||
});
|
||||
|
||||
testWidgets('an availability-platforms hub is not rendered as a title shelf', (tester) async {
|
||||
final sources = await _pumpExplore(tester, tv: false, plexHubStyle: CatalogHubStyle.availabilityPlatforms);
|
||||
|
||||
await sources.setActiveSource(CatalogSourceId.plex);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Plex Movie'), findsAtLeast(1));
|
||||
expect(find.text('Trending on Plex'), findsNothing);
|
||||
expect(find.text('Plex Recommendation'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a provider total result count reaches the existing shelf header', (tester) async {
|
||||
await _pumpExplore(tester, tv: false, traktTotalResults: 87);
|
||||
|
||||
expect(find.text(t.explore.totalResults(n: 87)), findsOneWidget);
|
||||
});
|
||||
|
||||
group('TV catalog spotlight', () {
|
||||
testWidgets('prefers logo and banner art, applies the accent tint, and shows the next episode', (tester) async {
|
||||
final item = CatalogItem(
|
||||
source: CatalogSourceId.trakt,
|
||||
kind: MediaKind.show,
|
||||
title: 'Logo Series',
|
||||
ids: const CatalogItemIds(tmdb: 101),
|
||||
logoUrl: 'https://images.example/logo.png',
|
||||
bannerUrl: 'https://images.example/banner.jpg',
|
||||
backdropUrl: 'https://images.example/default.jpg',
|
||||
backdropVariants: const {1920: 'https://images.example/backdrop-1920.jpg'},
|
||||
accentColor: '#336699',
|
||||
nextEpisode: CatalogNextEpisode(episode: 8, airsAt: DateTime.now().add(const Duration(days: 2))),
|
||||
);
|
||||
|
||||
await _pumpExplore(tester, traktItem: item);
|
||||
|
||||
final spotlightFinder = find.byType(TvSpotlightBackground);
|
||||
final spotlight = tester.widget<TvSpotlightBackground>(spotlightFinder);
|
||||
expect(spotlight.item?.clearLogoPath, 'https://images.example/logo.png');
|
||||
expect(spotlight.item?.artPath, 'https://images.example/banner.jpg');
|
||||
expect(find.descendant(of: spotlightFinder, matching: find.byType(ClearLogoImage)), findsOneWidget);
|
||||
// Scoped to the spotlight: the shelf card underneath renders its own
|
||||
// countdown badge from the same item, so an unscoped finder matches two.
|
||||
expect(
|
||||
find.descendant(
|
||||
of: spotlightFinder,
|
||||
matching: find.byWidgetPredicate(
|
||||
(widget) => widget is Text && (widget.data?.startsWith('Ep 8 in ') ?? false),
|
||||
),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
final baseColor = monoTheme(dark: true).scaffoldBackgroundColor;
|
||||
final expectedTint = Color.alphaBlend(const Color(0xff336699).withValues(alpha: 0.18), baseColor);
|
||||
expect(Theme.of(tester.element(spotlightFinder)).scaffoldBackgroundColor, expectedTint);
|
||||
});
|
||||
|
||||
testWidgets('falls back to the plain text title when a catalog logo is absent', (tester) async {
|
||||
final item = CatalogItem(
|
||||
source: CatalogSourceId.trakt,
|
||||
kind: MediaKind.movie,
|
||||
title: 'Plain Title',
|
||||
ids: const CatalogItemIds(tmdb: 102),
|
||||
backdropUrl: 'https://images.example/backdrop.jpg',
|
||||
);
|
||||
|
||||
await _pumpExplore(tester, traktItem: item);
|
||||
|
||||
final spotlightFinder = find.byType(TvSpotlightBackground);
|
||||
final spotlight = tester.widget<TvSpotlightBackground>(spotlightFinder);
|
||||
expect(spotlight.item?.clearLogoPath, isNull);
|
||||
expect(find.descendant(of: spotlightFinder, matching: find.byType(FittingTitleText)), findsOneWidget);
|
||||
expect(find.descendant(of: spotlightFinder, matching: find.text('Plain Title')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('selects backdrop variants from logical width and device pixel ratio', (tester) async {
|
||||
final item = CatalogItem(
|
||||
source: CatalogSourceId.trakt,
|
||||
kind: MediaKind.movie,
|
||||
title: 'Variant Movie',
|
||||
ids: const CatalogItemIds(tmdb: 103),
|
||||
backdropUrl: 'https://images.example/default.jpg',
|
||||
backdropVariants: const {
|
||||
900: 'https://images.example/backdrop-900.jpg',
|
||||
1500: 'https://images.example/backdrop-1500.jpg',
|
||||
2500: 'https://images.example/backdrop-2500.jpg',
|
||||
},
|
||||
);
|
||||
|
||||
await _pumpExplore(tester, traktItem: item);
|
||||
String? selectedBackdrop() =>
|
||||
tester.widget<TvSpotlightBackground>(find.byType(TvSpotlightBackground)).item?.artPath;
|
||||
|
||||
tester.view.physicalSize = const Size(800, 720);
|
||||
await tester.pump();
|
||||
expect(selectedBackdrop(), 'https://images.example/backdrop-900.jpg');
|
||||
|
||||
tester.view.physicalSize = const Size(1200, 720);
|
||||
await tester.pump();
|
||||
expect(selectedBackdrop(), 'https://images.example/backdrop-1500.jpg');
|
||||
|
||||
tester.view.devicePixelRatio = 2;
|
||||
tester.view.physicalSize = const Size(2400, 1440);
|
||||
await tester.pump();
|
||||
expect(selectedBackdrop(), 'https://images.example/backdrop-2500.jpg');
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('TV source switcher remains focused when the active source has no rows', (tester) async {
|
||||
final sources = await _pumpExplore(tester, traktItemId: null);
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import 'package:http/testing.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/models/anilist/anilist_media.dart';
|
||||
import 'package:plezy/models/catalog/catalog_item.dart';
|
||||
import 'package:plezy/models/catalog/catalog_metadata.dart';
|
||||
import 'package:plezy/models/catalog/catalog_cast_member.dart';
|
||||
import 'package:plezy/models/trackers/fribb_mapping_row.dart';
|
||||
import 'package:plezy/services/catalog/anilist_catalog_source.dart';
|
||||
import 'package:plezy/services/catalog/catalog_source.dart';
|
||||
@@ -57,26 +59,60 @@ Map<String, dynamic> _media({
|
||||
}) => {
|
||||
'id': id,
|
||||
'idMal': ?idMal,
|
||||
'title': {'english': title, 'romaji': 'Shingeki no Kyojin', 'userPreferred': 'Preferred'},
|
||||
'title': {'english': title, 'romaji': 'Shingeki no Kyojin', 'native': '進撃の巨人', 'userPreferred': 'Preferred'},
|
||||
'synonyms': ['The Advancing Giants', 'Preferred'],
|
||||
'format': format,
|
||||
'status': status,
|
||||
'episodes': 25,
|
||||
'duration': 24,
|
||||
'description': '<b>Humanity</b><br>fights & survives.',
|
||||
'averageScore': 84,
|
||||
'meanScore': 82,
|
||||
'popularity': 812345,
|
||||
'favourites': 54321,
|
||||
'trending': 987,
|
||||
'season': 'SPRING',
|
||||
'seasonYear': 2013,
|
||||
'startDate': {'year': 2013},
|
||||
'startDate': {'year': 2013, 'month': 4, 'day': 7},
|
||||
'endDate': {'year': 2013, 'month': 9, 'day': 29},
|
||||
'genres': ['Action', 'Drama'],
|
||||
'isAdult': isAdult,
|
||||
'coverImage': {'extraLarge': 'https://img.anilist.co/poster/$id.jpg'},
|
||||
'source': 'MANGA',
|
||||
'countryOfOrigin': 'JP',
|
||||
'coverImage': {'extraLarge': 'https://img.anilist.co/poster/$id.jpg', 'color': '#D88932'},
|
||||
'bannerImage': 'https://img.anilist.co/banner/$id.jpg',
|
||||
'studios': {
|
||||
'nodes': [
|
||||
{'name': 'Wit Studio'},
|
||||
{'name': 'Production I.G'},
|
||||
],
|
||||
},
|
||||
'trailer': {'id': 'abc123', 'site': 'youtube'},
|
||||
'nextAiringEpisode': {'episode': 8, 'airingAt': 2000000000, 'timeUntilAiring': 86400},
|
||||
'rankings': [
|
||||
{'rank': 1, 'type': 'POPULAR', 'format': 'TV', 'allTime': true, 'context': 'Most Popular All Time'},
|
||||
{
|
||||
'rank': 3,
|
||||
'type': 'RATED',
|
||||
'format': 'TV',
|
||||
'year': 2013,
|
||||
'season': 'SPRING',
|
||||
'allTime': false,
|
||||
'context': 'Highest Rated Spring 2013',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
Map<String, dynamic> _characters() => {
|
||||
'edges': [
|
||||
{
|
||||
'role': 'MAIN',
|
||||
'node': {
|
||||
'name': {'full': 'Mikasa Ackerman'},
|
||||
'image': {'large': 'https://img.anilist.co/mikasa.jpg'},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
http.Response _data(Map<String, dynamic> data, {int status = 200, Map<String, String>? headers}) =>
|
||||
@@ -109,19 +145,46 @@ void main() {
|
||||
);
|
||||
|
||||
group('AnilistMedia', () {
|
||||
test('parses requested fields and strips AniList HTML', () {
|
||||
final media = AnilistMedia.fromJson(_media(id: 1, idMal: 16498));
|
||||
test('parses requested fields and honors the user-preferred title', () {
|
||||
final media = AnilistMedia.fromJson(_media(id: 1, idMal: 16498)..['characters'] = _characters());
|
||||
|
||||
expect(media.displayTitle, 'Attack on Titan');
|
||||
expect(media.displayTitle, 'Preferred');
|
||||
expect(media.alternateTitles, ['Attack on Titan', 'Shingeki no Kyojin', 'The Advancing Giants']);
|
||||
expect(media.description, 'Humanity\nfights & survives.');
|
||||
expect(media.year, 2013);
|
||||
expect(media.releaseDate, DateTime.utc(2013, 4, 7));
|
||||
expect(media.finalEpisodeDate, DateTime.utc(2013, 9, 29));
|
||||
expect(media.posterUrl, 'https://img.anilist.co/poster/1.jpg');
|
||||
expect(media.backdropUrl, 'https://img.anilist.co/banner/1.jpg');
|
||||
expect(media.rating, 8.4);
|
||||
expect(media.meanRating, 8.2);
|
||||
expect(media.runtimeMinutes, 24);
|
||||
expect(media.network, 'Wit Studio');
|
||||
expect(media.mainStudios, ['Wit Studio', 'Production I.G']);
|
||||
expect(media.trailerUrl, 'https://www.youtube.com/watch?v=abc123');
|
||||
expect(media.isMovie, isFalse);
|
||||
expect(media.characters?.single.name, 'Mikasa Ackerman');
|
||||
expect(media.characters?.single.role, 'MAIN');
|
||||
expect(media.characters?.single.imageUrl, 'https://img.anilist.co/mikasa.jpg');
|
||||
});
|
||||
|
||||
test('title fallbacks and missing optional metadata remain nullable', () {
|
||||
final media = AnilistMedia.fromJson({
|
||||
'id': 1,
|
||||
'title': {'english': 'English', 'romaji': 'Romaji', 'userPreferred': ' '},
|
||||
'streamingEpisodes': <Map<String, dynamic>>[],
|
||||
});
|
||||
|
||||
expect(media.displayTitle, 'English');
|
||||
expect(media.alternateTitles, ['Romaji']);
|
||||
expect(media.nextAiringEpisode, isNull);
|
||||
expect(media.rankings, isNull);
|
||||
expect(media.mainStudios, isNull);
|
||||
expect(media.coverImageColor, isNull);
|
||||
expect(media.streamingEpisodes, isNull);
|
||||
expect(media.releaseDate, isNull);
|
||||
expect(media.finalEpisodeDate, isNull);
|
||||
expect(media.characters, isNull);
|
||||
});
|
||||
|
||||
test('stripHtml handles line breaks, tags, entities, and empty input', () {
|
||||
@@ -188,7 +251,7 @@ void main() {
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('trending query clamps page size and enriches every external id', () async {
|
||||
test('trending row maps rich metadata while clamping the page size', () async {
|
||||
responder = (request) {
|
||||
final body = _requestBody(request);
|
||||
final variables = body['variables'] as Map<String, dynamic>;
|
||||
@@ -198,7 +261,7 @@ void main() {
|
||||
return _data({
|
||||
'Page': {
|
||||
'pageInfo': {'hasNextPage': true},
|
||||
'media': [_media(id: 16498, idMal: 16498)],
|
||||
'media': [_media(id: 16498, idMal: 16498)..['characters'] = _characters()],
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -216,6 +279,38 @@ void main() {
|
||||
expect(item.overview, 'Humanity\nfights & survives.');
|
||||
expect(item.airStatus, CatalogAirStatus.airing);
|
||||
expect(item.episodeCount, 25);
|
||||
expect(item.title, 'Preferred');
|
||||
expect(item.originalTitle, '進撃の巨人');
|
||||
expect(item.altTitles, ['Attack on Titan', 'Shingeki no Kyojin', '進撃の巨人', 'The Advancing Giants']);
|
||||
expect(item.format, CatalogFormat.tv);
|
||||
expect(item.studios, ['Wit Studio', 'Production I.G']);
|
||||
expect(item.network, 'Wit Studio');
|
||||
expect(item.broadcastSeason?.name, CatalogSeasonName.spring);
|
||||
expect(item.broadcastSeason?.year, 2013);
|
||||
expect(item.accentColor, '#d88932');
|
||||
expect(item.releaseDate, DateTime.utc(2013, 4, 7));
|
||||
expect(item.endDate, DateTime.utc(2013, 9, 29));
|
||||
expect(item.sourceMaterial, CatalogSourceMaterial.manga);
|
||||
expect(item.countries, ['JP']);
|
||||
expect(item.ratings?.single.source, 'anilist');
|
||||
expect(item.ratings?.single.value, 8.2);
|
||||
expect(item.audience?.listed, 812345);
|
||||
expect(item.audience?.favorited, 54321);
|
||||
expect(item.audience?.trendingActivity, 987);
|
||||
expect(item.nextEpisode?.episode, 8);
|
||||
expect(item.nextEpisode?.airsAt, DateTime.fromMillisecondsSinceEpoch(2000000000000, isUtc: true));
|
||||
expect(item.ranks, hasLength(2));
|
||||
expect(item.ranks?[0].scope, CatalogRankScope.popular);
|
||||
expect(item.ranks?[0].allTime, isTrue);
|
||||
expect(item.ranks?[0].year, isNull);
|
||||
expect(item.ranks?[1].scope, CatalogRankScope.rated);
|
||||
expect(item.ranks?[1].allTime, isFalse);
|
||||
expect(item.ranks?[1].year, 2013);
|
||||
expect(item.ranks?[1].season, CatalogSeasonName.spring);
|
||||
expect(item.cast, hasLength(1));
|
||||
expect(item.cast?.single.name, 'Mikasa Ackerman');
|
||||
expect(item.cast?.single.secondary, 'MAIN');
|
||||
expect(item.cast?.single.imageUrl, 'https://img.anilist.co/mikasa.jpg');
|
||||
});
|
||||
|
||||
test('sequel entries preserve alternate-title order and both Fribb season numbers', () async {
|
||||
@@ -241,8 +336,11 @@ void main() {
|
||||
|
||||
final item = (await source.fetchRow(CatalogRowId.trendingAnime)).items.single;
|
||||
|
||||
expect(item.title, 'Attack on Titan Season 3');
|
||||
expect(item.altTitles, ['Preferred Season 3', 'Shingeki no Kyojin Season 3', '進撃の巨人 Season 3', 'AoT 3']);
|
||||
// The display title is now `userPreferred` (AniList honours the viewer's
|
||||
// title-language setting). The English title stays in `altTitles`, so the
|
||||
// reverse library lookup still has it to match on.
|
||||
expect(item.title, 'Preferred Season 3');
|
||||
expect(item.altTitles, ['Attack on Titan Season 3', 'Shingeki no Kyojin Season 3', '進撃の巨人 Season 3', 'AoT 3']);
|
||||
expect(item.season, const ExternalSeasonRef(tvdb: 3, tmdb: 2));
|
||||
});
|
||||
|
||||
@@ -322,6 +420,86 @@ void main() {
|
||||
expect(query, isNot(contains(r'id\nidMal')));
|
||||
});
|
||||
|
||||
test('row and detail documents select metadata on the deliberate request path', () async {
|
||||
responder = (request) {
|
||||
final query = _requestBody(request)['query'] as String;
|
||||
if (query.contains('Page(')) {
|
||||
return _data({
|
||||
'Page': {
|
||||
'pageInfo': {'hasNextPage': false},
|
||||
'media': <Map<String, dynamic>>[],
|
||||
},
|
||||
});
|
||||
}
|
||||
return _data({'Media': <String, dynamic>{}});
|
||||
};
|
||||
|
||||
await client.getTrendingAnime();
|
||||
final rowQuery = _requestBody(requests.single)['query'] as String;
|
||||
expect(rowQuery, contains('nextAiringEpisode {'));
|
||||
expect(rowQuery, contains('rankings {'));
|
||||
expect(rowQuery, contains('episode'));
|
||||
expect(rowQuery, contains('airingAt'));
|
||||
expect(rowQuery, contains('timeUntilAiring'));
|
||||
expect(rowQuery, contains('rank'));
|
||||
expect(rowQuery, contains('type'));
|
||||
expect(rowQuery, contains('format'));
|
||||
expect(rowQuery, contains('year'));
|
||||
expect(rowQuery, contains('season'));
|
||||
expect(rowQuery, contains('allTime'));
|
||||
expect(rowQuery, contains('context'));
|
||||
expect(rowQuery, contains('meanScore'));
|
||||
expect(rowQuery, contains('popularity'));
|
||||
expect(rowQuery, contains('favourites'));
|
||||
expect(rowQuery, contains('trending'));
|
||||
expect(rowQuery, contains('native'));
|
||||
expect(rowQuery, contains('synonyms'));
|
||||
expect(rowQuery, contains('source'));
|
||||
expect(rowQuery, contains('countryOfOrigin'));
|
||||
expect(rowQuery, contains('endDate {'));
|
||||
expect(rowQuery, contains('color'));
|
||||
expect(rowQuery, contains('month'));
|
||||
expect(rowQuery, contains('day'));
|
||||
expect(rowQuery, isNot(contains('externalLinks {')));
|
||||
expect(rowQuery, isNot(contains('streamingEpisodes {')));
|
||||
expect(rowQuery, contains('characters('));
|
||||
expect(rowQuery, contains('perPage: 6'));
|
||||
expect(rowQuery, contains('sort: [ROLE, RELEVANCE]'));
|
||||
expect(rowQuery, contains('role'));
|
||||
expect(rowQuery, contains('full'));
|
||||
expect(rowQuery, contains('large'));
|
||||
expect(rowQuery, contains('medium'));
|
||||
expect(rowQuery, isNot(contains('voiceActors')));
|
||||
expect(rowQuery, isNot(contains('mediaConnection')));
|
||||
expect(rowQuery, isNot(contains('relations(')));
|
||||
expect(rowQuery, isNot(contains('staff(')));
|
||||
expect(rowQuery, isNot(contains('tags {')));
|
||||
|
||||
requests.clear();
|
||||
await client.getAnimeDetail(16498, castLimit: 200, relatedLimit: 0);
|
||||
final detailBody = _requestBody(requests.single);
|
||||
final detailQuery = detailBody['query'] as String;
|
||||
final variables = detailBody['variables'] as Map<String, dynamic>;
|
||||
expect(detailQuery, contains('tags {'));
|
||||
expect(detailQuery, contains('externalLinks {'));
|
||||
expect(detailQuery, contains('streamingEpisodes {'));
|
||||
expect(detailQuery, contains('name'));
|
||||
expect(detailQuery, contains('rank'));
|
||||
expect(detailQuery, contains('isMediaSpoiler'));
|
||||
expect(detailQuery, contains('site'));
|
||||
expect(detailQuery, contains('url'));
|
||||
expect(detailQuery, contains('title'));
|
||||
expect(detailQuery, contains('thumbnail'));
|
||||
expect(detailQuery, contains('staff(page: 1, perPage: \$staffPerPage)'));
|
||||
expect(detailQuery, contains('characters(page: 1, perPage: \$castPerPage, sort: [ROLE, RELEVANCE])'));
|
||||
expect(detailQuery, contains('recommendations(page: 1, perPage: \$relatedPerPage)'));
|
||||
expect(detailQuery, contains('relations(page: 1, perPage: \$relatedPerPage)'));
|
||||
expect(detailQuery, contains('relationType(version: 2)'));
|
||||
expect(variables['castPerPage'], 50);
|
||||
expect(variables['relatedPerPage'], 1);
|
||||
expect(variables['staffPerPage'], 8);
|
||||
});
|
||||
|
||||
test('unsupported rows throw instead of silently returning empty', () {
|
||||
expect(() => source.fetchRow(CatalogRowId.recommendedMovies), throwsA(isA<ArgumentError>()));
|
||||
});
|
||||
@@ -331,10 +509,13 @@ void main() {
|
||||
expect(empty, isEmpty);
|
||||
expect(requests, isEmpty);
|
||||
|
||||
await source.search(' a ');
|
||||
final results = await source.search(' a ');
|
||||
expect(requests, hasLength(1));
|
||||
final variables = _requestBody(requests.single)['variables'] as Map<String, dynamic>;
|
||||
expect(results.single.cast, isNull);
|
||||
final body = _requestBody(requests.single);
|
||||
final variables = body['variables'] as Map<String, dynamic>;
|
||||
expect(variables['search'], 'a');
|
||||
expect(body['query'] as String, isNot(contains('characters(')));
|
||||
});
|
||||
|
||||
test('watchlist snapshot matches the MAL identity form alone', () async {
|
||||
@@ -438,34 +619,132 @@ void main() {
|
||||
expect(source.isOnWatchlist(MediaKind.show, const CatalogItemIds(anilist: 16498)), isTrue);
|
||||
});
|
||||
|
||||
test('cast and related map characters and enriched recommendations', () async {
|
||||
test('fetchDetail without cached cast requests live characters with the consolidated detail', () async {
|
||||
responder = (request) {
|
||||
final query = _requestBody(request)['query'] as String;
|
||||
if (query.contains('characters(')) {
|
||||
return _data({
|
||||
'Media': {
|
||||
'characters': {
|
||||
'edges': [
|
||||
{
|
||||
'role': 'MAIN',
|
||||
'node': {
|
||||
'name': {'full': 'Mikasa Ackerman'},
|
||||
'image': {'large': 'https://img.anilist.co/mikasa.jpg'},
|
||||
},
|
||||
},
|
||||
],
|
||||
final media = _media(id: 16498, idMal: 16498)
|
||||
..addAll({
|
||||
'tags': [
|
||||
{'name': 'Military', 'rank': 88, 'isMediaSpoiler': false},
|
||||
{'name': 'Hidden Identity', 'rank': 72, 'isMediaSpoiler': true},
|
||||
],
|
||||
'externalLinks': [
|
||||
{'site': 'AniList', 'url': 'https://anilist.co/anime/16498'},
|
||||
{'site': 'Unsafe', 'url': 'file:///tmp/not-opened'},
|
||||
],
|
||||
'streamingEpisodes': [
|
||||
{
|
||||
'title': 'Episode 1',
|
||||
'thumbnail': 'https://img.example/episode-1.jpg',
|
||||
'url': 'https://crunchyroll.example/episode-1',
|
||||
'site': 'Crunchyroll',
|
||||
},
|
||||
],
|
||||
'staff': {
|
||||
'edges': [
|
||||
{
|
||||
'role': 'Director',
|
||||
'node': {
|
||||
'name': {'full': 'Tetsuro Araki'},
|
||||
},
|
||||
},
|
||||
{
|
||||
'role': 'Series Composition',
|
||||
'node': {
|
||||
'name': {'full': 'Yasuko Kobayashi'},
|
||||
},
|
||||
},
|
||||
{
|
||||
'role': 'Music',
|
||||
'node': {
|
||||
'name': {'full': 'Hiroyuki Sawano'},
|
||||
},
|
||||
},
|
||||
{
|
||||
'role': 'Character Design',
|
||||
'node': {
|
||||
'name': {'full': 'Kyoji Asano'},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'characters': {
|
||||
'edges': [
|
||||
{
|
||||
'role': 'MAIN',
|
||||
'node': {
|
||||
'name': {'full': 'Mikasa Ackerman'},
|
||||
'image': {'large': 'https://img.anilist.co/mikasa.jpg'},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
return _data({
|
||||
'Media': {
|
||||
'recommendations': {
|
||||
'nodes': [
|
||||
{'mediaRecommendation': _media(id: 21519, idMal: 32281, title: 'Your Name.', format: 'MOVIE')},
|
||||
{'mediaRecommendation': _media(id: 999, title: 'Adult', isAdult: true)},
|
||||
],
|
||||
},
|
||||
'relations': {
|
||||
'edges': [
|
||||
{'relationType': 'SEQUEL', 'node': _media(id: 35760, idMal: 35760, title: 'Attack on Titan Season 3')},
|
||||
{'relationType': 'SOURCE', 'node': _media(id: 1000, title: 'Attack on Titan Manga')},
|
||||
],
|
||||
},
|
||||
});
|
||||
return _data({'Media': media});
|
||||
};
|
||||
const item = CatalogItem(
|
||||
source: CatalogSourceId.anilist,
|
||||
kind: MediaKind.show,
|
||||
title: 'Row title',
|
||||
ids: CatalogItemIds(anilist: 16498),
|
||||
ranks: [CatalogRank(rank: 99, scope: CatalogRankScope.trending)],
|
||||
);
|
||||
|
||||
final detail = await source.fetchDetail(item);
|
||||
|
||||
expect(requests, hasLength(1));
|
||||
expect(detail.item.title, 'Preferred');
|
||||
expect(detail.item.ranks?.single.rank, 99);
|
||||
expect(detail.cast.single.name, 'Mikasa Ackerman');
|
||||
expect(detail.cast.single.secondary, 'MAIN');
|
||||
final detailBody = _requestBody(requests.single);
|
||||
final detailQuery = detailBody['query'] as String;
|
||||
final detailVariables = detailBody['variables'] as Map<String, dynamic>;
|
||||
expect(detailQuery, contains('characters(page: 1, perPage: \$castPerPage, sort: [ROLE, RELEVANCE])'));
|
||||
expect(detailVariables['castPerPage'], 20);
|
||||
expect(detail.related, hasLength(1));
|
||||
expect(detail.related.single.kind, MediaKind.movie);
|
||||
expect(detail.related.single.ids.tmdb, 372058);
|
||||
expect(detail.relations, hasLength(2));
|
||||
expect(detail.relations[0].type, CatalogRelationType.sequel);
|
||||
expect(detail.relations[0].items.single.ids.anilist, 35760);
|
||||
expect(detail.relations[1].type, CatalogRelationType.other);
|
||||
expect(detail.relations[1].items.single.ids.anilist, 1000);
|
||||
expect(detail.item.credits?.map((credit) => credit.role), [
|
||||
CatalogCreditRole.director,
|
||||
CatalogCreditRole.writer,
|
||||
CatalogCreditRole.composer,
|
||||
]);
|
||||
expect(detail.item.tags, hasLength(2));
|
||||
expect(detail.item.tags?[1].isSpoiler, isTrue);
|
||||
expect(detail.item.links, hasLength(2));
|
||||
expect(detail.item.links?[0].label, 'AniList');
|
||||
expect(detail.item.links?[0].isStreaming, isFalse);
|
||||
expect(detail.item.links?[1].label, 'Crunchyroll');
|
||||
expect(detail.item.links?[1].isStreaming, isTrue);
|
||||
});
|
||||
|
||||
test('fetchDetail serves cached row cast without selecting characters', () async {
|
||||
responder = (request) {
|
||||
final body = _requestBody(request);
|
||||
expect(body['query'] as String, isNot(contains('characters(')));
|
||||
final variables = body['variables'] as Map<String, dynamic>;
|
||||
expect(variables.containsKey('castPerPage'), isFalse);
|
||||
return _data({
|
||||
'Media': {
|
||||
'id': 16498,
|
||||
'title': {'userPreferred': 'Attack on Titan'},
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -474,16 +753,62 @@ void main() {
|
||||
kind: MediaKind.show,
|
||||
title: 'Attack on Titan',
|
||||
ids: CatalogItemIds(anilist: 16498),
|
||||
cast: [
|
||||
CatalogCastMember(name: 'Mikasa Ackerman', secondary: 'MAIN', imageUrl: 'https://img.anilist.co/mikasa.jpg'),
|
||||
],
|
||||
);
|
||||
|
||||
final cast = await source.fetchCast(item);
|
||||
final related = await source.fetchRelated(item);
|
||||
final detail = await source.fetchDetail(item);
|
||||
|
||||
expect(cast.single.name, 'Mikasa Ackerman');
|
||||
expect(cast.single.secondary, 'MAIN');
|
||||
expect(related, hasLength(1));
|
||||
expect(related.single.kind, MediaKind.movie);
|
||||
expect(related.single.ids.tmdb, 372058);
|
||||
expect(requests, hasLength(1));
|
||||
expect(detail.cast, hasLength(1));
|
||||
expect(detail.cast.single.name, 'Mikasa Ackerman');
|
||||
expect(detail.cast.single.secondary, 'MAIN');
|
||||
expect(detail.cast.single.imageUrl, 'https://img.anilist.co/mikasa.jpg');
|
||||
});
|
||||
|
||||
test('fetchDetail treats absent and empty optional collections as normal', () async {
|
||||
responder = (_) => _data({
|
||||
'Media': {
|
||||
'id': 16498,
|
||||
'title': {'userPreferred': 'Attack on Titan'},
|
||||
'externalLinks': <Map<String, dynamic>>[],
|
||||
'streamingEpisodes': <Map<String, dynamic>>[],
|
||||
},
|
||||
});
|
||||
const item = CatalogItem(
|
||||
source: CatalogSourceId.anilist,
|
||||
kind: MediaKind.show,
|
||||
title: 'Attack on Titan',
|
||||
ids: CatalogItemIds(anilist: 16498),
|
||||
);
|
||||
|
||||
final detail = await source.fetchDetail(item);
|
||||
|
||||
expect(detail.item.links, isNull);
|
||||
expect(detail.item.tags, isNull);
|
||||
expect(detail.item.credits, isNull);
|
||||
expect(detail.cast, isEmpty);
|
||||
expect(detail.related, isEmpty);
|
||||
expect(detail.relations, isEmpty);
|
||||
expect(requests, hasLength(1));
|
||||
});
|
||||
|
||||
test('fetchDetail needs no request when the item has no AniList id', () async {
|
||||
const item = CatalogItem(
|
||||
source: CatalogSourceId.anilist,
|
||||
kind: MediaKind.show,
|
||||
title: 'Unmapped',
|
||||
ids: CatalogItemIds(mal: 1),
|
||||
);
|
||||
|
||||
final detail = await source.fetchDetail(item);
|
||||
|
||||
expect(detail.item, same(item));
|
||||
expect(detail.cast, isEmpty);
|
||||
expect(detail.related, isEmpty);
|
||||
expect(detail.relations, isEmpty);
|
||||
expect(requests, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/models/catalog/catalog_item.dart';
|
||||
import 'package:plezy/models/catalog/catalog_metadata.dart';
|
||||
import 'package:plezy/models/trackers/fribb_mapping_row.dart';
|
||||
import 'package:plezy/services/catalog/catalog_source.dart';
|
||||
import 'package:plezy/services/catalog/mal_catalog_source.dart';
|
||||
@@ -51,6 +52,7 @@ Map<String, dynamic> _node({
|
||||
List<String>? synonyms,
|
||||
String mediaType = 'tv',
|
||||
String status = 'finished_airing',
|
||||
Map<String, dynamic> extra = const {},
|
||||
}) => {
|
||||
'id': id,
|
||||
'title': title,
|
||||
@@ -63,18 +65,43 @@ Map<String, dynamic> _node({
|
||||
'studios': [
|
||||
{'id': 858, 'name': 'Wit Studio'},
|
||||
],
|
||||
...extra,
|
||||
};
|
||||
|
||||
Map<String, dynamic> _pageBody(List<Map<String, dynamic>> nodes, {bool hasMore = false}) => {
|
||||
Map<String, dynamic> _pageBody(
|
||||
List<Map<String, dynamic>> nodes, {
|
||||
bool hasMore = false,
|
||||
List<int?> rankingRanks = const [],
|
||||
}) => {
|
||||
'data': [
|
||||
for (final node in nodes) {'node': node},
|
||||
for (var i = 0; i < nodes.length; i++)
|
||||
{
|
||||
'node': nodes[i],
|
||||
if (i < rankingRanks.length && rankingRanks[i] != null) 'ranking': {'rank': rankingRanks[i]},
|
||||
},
|
||||
],
|
||||
'paging': {if (hasMore) 'next': 'https://api.myanimelist.net/v2/whatever?offset=2'},
|
||||
};
|
||||
|
||||
const _expectedCatalogFields =
|
||||
'id,title,main_picture,alternative_titles,start_date,synopsis,mean,'
|
||||
'genres,media_type,rating,num_episodes,average_episode_duration,start_season,'
|
||||
'status,studios,num_scoring_users,broadcast,popularity,num_list_users,rank,'
|
||||
'nsfw,source,end_date';
|
||||
const _expectedDetailFields =
|
||||
'$_expectedCatalogFields,recommendations{$_expectedCatalogFields},'
|
||||
'related_anime{$_expectedCatalogFields},statistics,pictures,background';
|
||||
|
||||
void main() {
|
||||
// Attack on Titan: split-cour show — one Fribb row per season, same tvdb id.
|
||||
const aotSeason1 = FribbMappingRow(malId: 16498, tvdbId: 267440, tvdbSeason: 1, imdbIds: ['tt2560140']);
|
||||
const aotSeason1 = FribbMappingRow(
|
||||
malId: 16498,
|
||||
anilistId: 16498,
|
||||
simklId: 43665,
|
||||
tvdbId: 267440,
|
||||
tvdbSeason: 1,
|
||||
imdbIds: ['tt2560140'],
|
||||
);
|
||||
const aotSeason3 = FribbMappingRow(
|
||||
malId: 35760,
|
||||
tvdbId: 267440,
|
||||
@@ -103,7 +130,23 @@ void main() {
|
||||
return http.Response(
|
||||
json.encode(
|
||||
_pageBody([
|
||||
_node(id: 16498, title: 'Shingeki no Kyojin', en: 'Attack on Titan'),
|
||||
_node(
|
||||
id: 16498,
|
||||
title: 'Shingeki no Kyojin',
|
||||
en: 'Attack on Titan',
|
||||
ja: '進撃の巨人',
|
||||
synonyms: const ['AoT'],
|
||||
extra: const {
|
||||
'start_season': {'year': 2013, 'season': 'spring'},
|
||||
'broadcast': {'day_of_the_week': 'sunday', 'start_time': '23:30'},
|
||||
'popularity': 1,
|
||||
'num_list_users': 4100000,
|
||||
'rank': 2,
|
||||
'nsfw': 'black',
|
||||
'source': 'manga',
|
||||
'end_date': '2021-04-19',
|
||||
},
|
||||
),
|
||||
_node(id: 32281, title: 'Kimi no Na wa.', en: 'Your Name.', mediaType: 'movie'),
|
||||
]),
|
||||
),
|
||||
@@ -126,7 +169,7 @@ void main() {
|
||||
final request = requests.single;
|
||||
expect(request.url.path, '/v2/users/@me/animelist');
|
||||
expect(request.url.queryParameters['status'], 'plan_to_watch');
|
||||
expect(request.url.queryParameters['fields'], contains('alternative_titles'));
|
||||
expect(request.url.queryParameters['fields'], _expectedCatalogFields);
|
||||
|
||||
expect(page.items, hasLength(2));
|
||||
final show = page.items[0];
|
||||
@@ -135,6 +178,8 @@ void main() {
|
||||
expect(show.ids.mal, 16498);
|
||||
expect(show.ids.tvdb, 267440);
|
||||
expect(show.ids.imdb, 'tt2560140');
|
||||
expect(show.ids.anilist, 16498);
|
||||
expect(show.ids.simkl, 43665);
|
||||
expect(show.source, CatalogSourceId.mal);
|
||||
|
||||
// List-endpoint metadata flows through to the item.
|
||||
@@ -142,6 +187,22 @@ void main() {
|
||||
expect(show.episodeCount, 25);
|
||||
expect(show.votes, 2326268);
|
||||
expect(show.network, 'Wit Studio');
|
||||
expect(show.originalTitle, 'Shingeki no Kyojin');
|
||||
expect(show.altTitles, ['Shingeki no Kyojin', '進撃の巨人', 'AoT']);
|
||||
expect(show.broadcastSeason?.name, CatalogSeasonName.spring);
|
||||
expect(show.broadcastSeason?.year, 2013);
|
||||
expect(show.broadcast?.weekday, DateTime.sunday);
|
||||
expect(show.broadcast?.time, '23:30');
|
||||
expect(show.broadcast?.timezone, 'Asia/Tokyo');
|
||||
expect(show.isAdult, isTrue);
|
||||
expect(show.sourceMaterial, CatalogSourceMaterial.manga);
|
||||
expect(show.endDate, DateTime(2021, 4, 19));
|
||||
expect(show.audience?.listed, 4100000);
|
||||
expect(show.ranks, hasLength(2));
|
||||
expect(show.ranks?[0].scope, CatalogRankScope.popular);
|
||||
expect(show.ranks?[0].rank, 1);
|
||||
expect(show.ranks?[1].scope, CatalogRankScope.rated);
|
||||
expect(show.ranks?[1].rank, 2);
|
||||
|
||||
final movie = page.items[1];
|
||||
expect(movie.kind, MediaKind.movie);
|
||||
@@ -151,6 +212,13 @@ void main() {
|
||||
// finished_airing on a movie is noise, and movies have no episode chip.
|
||||
expect(movie.airStatus, isNull);
|
||||
expect(movie.episodeCount, isNull);
|
||||
expect(movie.season, isNull);
|
||||
expect(movie.broadcast, isNull);
|
||||
expect(movie.isAdult, isNull);
|
||||
expect(movie.sourceMaterial, isNull);
|
||||
expect(movie.endDate, isNull);
|
||||
expect(movie.ranks, isNull);
|
||||
expect(movie.audience, isNull);
|
||||
});
|
||||
|
||||
test('sequel entries preserve alternate-title order and both Fribb season numbers', () async {
|
||||
@@ -179,64 +247,46 @@ void main() {
|
||||
expect(item.season, const ExternalSeasonRef(tvdb: 3, tmdb: 2));
|
||||
});
|
||||
|
||||
test('fetchCast maps MAL characters with joined names and roles', () async {
|
||||
test('ranking sidecars map to the scope implied by each row', () async {
|
||||
handlers.add(
|
||||
(request) => http.Response(
|
||||
json.encode({
|
||||
'data': [
|
||||
{
|
||||
'node': {
|
||||
'id': 11,
|
||||
'first_name': 'Edward',
|
||||
'last_name': 'Elric',
|
||||
'main_picture': {'medium': 'https://cdn.myanimelist.net/images/characters/9/72533.jpg'},
|
||||
},
|
||||
'role': 'Main',
|
||||
},
|
||||
{
|
||||
'node': {'id': 63, 'first_name': '', 'last_name': 'Winry'},
|
||||
'role': 'Supporting',
|
||||
},
|
||||
{
|
||||
'node': {'id': 99}, // nameless — skipped
|
||||
'role': 'Supporting',
|
||||
},
|
||||
],
|
||||
'paging': <String, dynamic>{},
|
||||
}),
|
||||
json.encode(
|
||||
_pageBody([_node(id: 16498, title: 'Shingeki no Kyojin')], hasMore: true, rankingRanks: const [12]),
|
||||
),
|
||||
200,
|
||||
),
|
||||
);
|
||||
handlers.add(
|
||||
(request) => http.Response(
|
||||
json.encode(
|
||||
_pageBody(
|
||||
[_node(id: 32281, title: 'Kimi no Na wa.', mediaType: 'movie')],
|
||||
rankingRanks: const [7],
|
||||
),
|
||||
),
|
||||
200,
|
||||
),
|
||||
);
|
||||
|
||||
final cast = await source.fetchCast(
|
||||
const CatalogItem(
|
||||
source: CatalogSourceId.mal,
|
||||
kind: MediaKind.show,
|
||||
title: 'Fullmetal Alchemist: Brotherhood',
|
||||
ids: CatalogItemIds(mal: 5114),
|
||||
),
|
||||
);
|
||||
final airing = await source.fetchRow(CatalogRowId.airingAnime, page: 3, limit: 50);
|
||||
final popular = await source.fetchRow(CatalogRowId.popularAnime);
|
||||
|
||||
final request = requests.single;
|
||||
expect(request.url.path, '/v2/anime/5114/characters');
|
||||
expect(request.url.queryParameters['fields'], contains('first_name'));
|
||||
expect(cast, hasLength(2));
|
||||
expect(cast[0].name, 'Edward Elric');
|
||||
expect(cast[0].secondary, 'Main');
|
||||
expect(cast[0].imageUrl, 'https://cdn.myanimelist.net/images/characters/9/72533.jpg');
|
||||
expect(cast[1].name, 'Winry');
|
||||
});
|
||||
expect(requests[0].url.path, '/v2/anime/ranking');
|
||||
expect(requests[0].url.queryParameters['ranking_type'], 'airing');
|
||||
expect(requests[0].url.queryParameters['limit'], '50');
|
||||
expect(requests[0].url.queryParameters['offset'], '100');
|
||||
expect(requests[0].url.queryParameters['fields'], _expectedCatalogFields);
|
||||
expect(airing.hasMore, isTrue);
|
||||
expect(airing.items.single.ranks, hasLength(1));
|
||||
expect(airing.items.single.ranks?.single.rank, 12);
|
||||
expect(airing.items.single.ranks?.single.scope, CatalogRankScope.airing);
|
||||
expect(airing.items.single.ranks?.single.allTime, isTrue);
|
||||
|
||||
test('fetchRow(airingAnime) hits the ranking endpoint and pages by offset', () async {
|
||||
handlers.add((request) => http.Response(json.encode(_pageBody([], hasMore: true)), 200));
|
||||
final page = await source.fetchRow(CatalogRowId.airingAnime, page: 3, limit: 50);
|
||||
|
||||
final request = requests.single;
|
||||
expect(request.url.path, '/v2/anime/ranking');
|
||||
expect(request.url.queryParameters['ranking_type'], 'airing');
|
||||
expect(request.url.queryParameters['limit'], '50');
|
||||
expect(request.url.queryParameters['offset'], '100');
|
||||
expect(page.hasMore, isTrue);
|
||||
expect(requests[1].url.queryParameters['ranking_type'], 'bypopularity');
|
||||
expect(popular.items.single.ranks, hasLength(1));
|
||||
expect(popular.items.single.ranks?.single.rank, 7);
|
||||
expect(popular.items.single.ranks?.single.scope, CatalogRankScope.popular);
|
||||
expect(popular.items.single.ranks?.single.allTime, isTrue);
|
||||
});
|
||||
|
||||
test('fetchRow throws on rows MAL does not serve', () {
|
||||
@@ -346,44 +396,189 @@ void main() {
|
||||
expect(requests, isEmpty);
|
||||
});
|
||||
|
||||
test('fetchRelated reads the nested recommendations field and enriches via Fribb', () async {
|
||||
handlers.add((request) {
|
||||
expect(request.url.path, '/v2/anime/16498');
|
||||
expect(request.url.queryParameters['fields'], startsWith('recommendations{'));
|
||||
test('fetchDetail uses two requests and maps enrichment, cast, recommendations, and relations', () async {
|
||||
http.Response respond(http.Request request) {
|
||||
if (request.url.path.endsWith('/characters')) {
|
||||
return http.Response(
|
||||
json.encode({
|
||||
'data': [
|
||||
{
|
||||
'node': {
|
||||
'id': 11,
|
||||
'first_name': 'Edward',
|
||||
'last_name': 'Elric',
|
||||
'main_picture': {'medium': 'https://cdn.myanimelist.net/images/characters/9/72533.jpg'},
|
||||
},
|
||||
'role': 'Main',
|
||||
},
|
||||
{
|
||||
'node': {'id': 63, 'first_name': '', 'last_name': 'Winry'},
|
||||
'role': 'Supporting',
|
||||
},
|
||||
{
|
||||
'node': {'id': 99},
|
||||
'role': 'Supporting',
|
||||
},
|
||||
],
|
||||
'paging': <String, dynamic>{},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response(
|
||||
json.encode({
|
||||
'id': 16498,
|
||||
..._node(
|
||||
id: 16498,
|
||||
title: 'Shingeki no Kyojin',
|
||||
en: 'Attack on Titan',
|
||||
extra: const {'synopsis': 'Full detail synopsis', 'num_list_users': 4100000},
|
||||
),
|
||||
'recommendations': [
|
||||
{
|
||||
'node': _node(id: 32281, title: 'Kimi no Na wa.', en: 'Your Name.', mediaType: 'movie'),
|
||||
'num_recommendations': 42,
|
||||
},
|
||||
],
|
||||
'related_anime': [
|
||||
{
|
||||
'node': _node(id: 35760, title: 'Shingeki no Kyojin Season 3'),
|
||||
'relation_type': 'sequel',
|
||||
'relation_type_formatted': 'Sequel',
|
||||
},
|
||||
],
|
||||
'statistics': {
|
||||
'num_list_users': 4100000,
|
||||
'status': {
|
||||
'watching': '120000',
|
||||
'completed': '3500000',
|
||||
'on_hold': '40000',
|
||||
'dropped': '90000',
|
||||
'plan_to_watch': '350000',
|
||||
},
|
||||
},
|
||||
'pictures': [
|
||||
{
|
||||
'medium': 'https://cdn.myanimelist.net/images/anime/16498-poster-medium.jpg',
|
||||
'large': 'https://cdn.myanimelist.net/images/anime/16498.jpg',
|
||||
},
|
||||
{
|
||||
'medium': 'https://cdn.myanimelist.net/images/anime/16498-gallery-medium.jpg',
|
||||
'large': 'https://cdn.myanimelist.net/images/anime/16498-gallery-large.jpg',
|
||||
},
|
||||
{'medium': 'https://cdn.myanimelist.net/images/anime/16498-medium-only.jpg'},
|
||||
{'medium': 'https://cdn.myanimelist.net/images/anime/16498-fallback-medium.jpg', 'large': ''},
|
||||
{'medium': '', 'large': ''},
|
||||
],
|
||||
'background': 'Created from the original manga.',
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
final item = CatalogItem(
|
||||
handlers
|
||||
..add(respond)
|
||||
..add(respond);
|
||||
const item = CatalogItem(
|
||||
source: CatalogSourceId.mal,
|
||||
kind: MediaKind.show,
|
||||
title: 'Attack on Titan',
|
||||
ids: const CatalogItemIds(mal: 16498),
|
||||
overview: 'Row synopsis',
|
||||
ids: CatalogItemIds(mal: 16498),
|
||||
ranks: [CatalogRank(rank: 12, scope: CatalogRankScope.airing)],
|
||||
);
|
||||
final related = await source.fetchRelated(item);
|
||||
expect(related.single.title, 'Your Name.');
|
||||
expect(related.single.kind, MediaKind.movie);
|
||||
expect(related.single.ids.tmdb, 372058);
|
||||
|
||||
final detail = await source.fetchDetail(item);
|
||||
|
||||
expect(requests, hasLength(2));
|
||||
final detailRequest = requests.singleWhere((request) => request.url.path == '/v2/anime/16498');
|
||||
final castRequest = requests.singleWhere((request) => request.url.path.endsWith('/characters'));
|
||||
expect(detailRequest.url.queryParameters['fields'], _expectedDetailFields);
|
||||
expect(castRequest.url.queryParameters['limit'], '20');
|
||||
expect(castRequest.url.queryParameters['fields'], contains('first_name'));
|
||||
|
||||
expect(detail.item.overview, 'Full detail synopsis');
|
||||
expect(detail.item.ranks?.single.rank, 12);
|
||||
expect(detail.item.audience?.listed, 4100000);
|
||||
expect(detail.item.audience?.watching, 120000);
|
||||
expect(detail.item.audience?.completed, 3500000);
|
||||
expect(detail.item.audience?.onHold, 40000);
|
||||
expect(detail.item.audience?.dropped, 90000);
|
||||
expect(detail.item.audience?.planning, 350000);
|
||||
expect(detail.item.gallery, [
|
||||
'https://cdn.myanimelist.net/images/anime/16498-gallery-large.jpg',
|
||||
'https://cdn.myanimelist.net/images/anime/16498-medium-only.jpg',
|
||||
'https://cdn.myanimelist.net/images/anime/16498-fallback-medium.jpg',
|
||||
]);
|
||||
expect(detail.item.background, 'Created from the original manga.');
|
||||
expect(detail.item.posterVariants, isNull);
|
||||
|
||||
expect(detail.cast, hasLength(2));
|
||||
expect(detail.cast[0].name, 'Edward Elric');
|
||||
expect(detail.cast[0].secondary, 'Main');
|
||||
expect(detail.cast[0].imageUrl, 'https://cdn.myanimelist.net/images/characters/9/72533.jpg');
|
||||
expect(detail.cast[1].name, 'Winry');
|
||||
|
||||
expect(detail.related, hasLength(1));
|
||||
expect(detail.related.single.title, 'Your Name.');
|
||||
expect(detail.related.single.kind, MediaKind.movie);
|
||||
expect(detail.related.single.ids.tmdb, 372058);
|
||||
expect(detail.related.single.recommendationCount, 42);
|
||||
|
||||
expect(detail.relations, hasLength(1));
|
||||
expect(detail.relations.single.type, CatalogRelationType.sequel);
|
||||
expect(detail.relations.single.items.single.ids.mal, 35760);
|
||||
expect(detail.relations.single.items.single.title, 'Shingeki no Kyojin Season 3');
|
||||
});
|
||||
|
||||
test('fetchRelated without a mal id returns empty without a request', () async {
|
||||
final item = CatalogItem(
|
||||
test('fetchDetail normalizes a blank background and pictures without URLs to null', () async {
|
||||
http.Response respond(http.Request request) {
|
||||
if (request.url.path.endsWith('/characters')) {
|
||||
return http.Response(json.encode({'data': <Object>[], 'paging': <String, dynamic>{}}), 200);
|
||||
}
|
||||
return http.Response(
|
||||
json.encode({
|
||||
..._node(id: 16498, title: 'Shingeki no Kyojin'),
|
||||
'pictures': [
|
||||
<String, dynamic>{},
|
||||
{'medium': ' ', 'large': ''},
|
||||
],
|
||||
'background': ' \n\t ',
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
handlers
|
||||
..add(respond)
|
||||
..add(respond);
|
||||
const item = CatalogItem(
|
||||
source: CatalogSourceId.mal,
|
||||
kind: MediaKind.show,
|
||||
title: 'Attack on Titan',
|
||||
ids: CatalogItemIds(mal: 16498),
|
||||
);
|
||||
|
||||
final detail = await source.fetchDetail(item);
|
||||
|
||||
expect(detail.item.gallery, isNull);
|
||||
expect(detail.item.background, isNull);
|
||||
expect(detail.item.posterVariants, isNull);
|
||||
});
|
||||
|
||||
test('fetchDetail without a mal id returns the unchanged item without a request', () async {
|
||||
const item = CatalogItem(
|
||||
source: CatalogSourceId.mal,
|
||||
kind: MediaKind.show,
|
||||
title: 'Unknown',
|
||||
ids: const CatalogItemIds(tmdb: 1),
|
||||
ids: CatalogItemIds(tmdb: 1),
|
||||
);
|
||||
expect(await source.fetchRelated(item), isEmpty);
|
||||
|
||||
final detail = await source.fetchDetail(item);
|
||||
|
||||
expect(identical(detail.item, item), isTrue);
|
||||
expect(detail.cast, isEmpty);
|
||||
expect(detail.related, isEmpty);
|
||||
expect(detail.relations, isEmpty);
|
||||
expect(requests, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/models/catalog/catalog_item.dart';
|
||||
import 'package:plezy/models/catalog/catalog_metadata.dart';
|
||||
import 'package:plezy/services/catalog/catalog_source.dart';
|
||||
import 'package:plezy/services/catalog/plex_catalog_source.dart';
|
||||
import 'package:plezy/services/plex_discover_client.dart';
|
||||
@@ -43,11 +44,12 @@ Map<String, Object?> _metadata({
|
||||
|
||||
/// Discover answers `/hubs/sections/<section>` with placeholders only — the
|
||||
/// shelf identity, never its items.
|
||||
Map<String, Object?> _placeholderHub(String id, String title, {String type = 'mixed'}) => {
|
||||
Map<String, Object?> _placeholderHub(String id, String title, {String type = 'mixed', String? style}) => {
|
||||
'hubIdentifier': id,
|
||||
'key': '/hubs/sections/home/${id.split('.').last}?source=home',
|
||||
'title': title,
|
||||
'type': type,
|
||||
'style': ?style,
|
||||
'placeholder': true,
|
||||
'size': 0,
|
||||
'more': true,
|
||||
@@ -85,6 +87,7 @@ void main() {
|
||||
expect(captured.headers['X-Plex-Token'], 'profile-token');
|
||||
expect(captured.headers['X-Plex-Client-Identifier'], 'client-id');
|
||||
expect(page.hasMore, isTrue);
|
||||
expect(page.totalResults, 27);
|
||||
|
||||
final item = page.items.single;
|
||||
expect(item.source, CatalogSourceId.plex);
|
||||
@@ -97,6 +100,77 @@ void main() {
|
||||
expect(item.genres, ['Science Fiction']);
|
||||
});
|
||||
|
||||
test('maps every attributed score and leaves absent optional metadata null', () async {
|
||||
final source = PlexCatalogSource(
|
||||
PlexDiscoverClient(
|
||||
_session,
|
||||
httpClient: MockClient(
|
||||
(_) async => jsonResponse({
|
||||
'MediaContainer': {
|
||||
'totalSize': 2,
|
||||
'Metadata': [
|
||||
{
|
||||
..._metadata(),
|
||||
'rating': 9.4,
|
||||
'ratingImage': 'rottentomatoes://image.rating.ripe',
|
||||
'audienceRating': 9.7,
|
||||
'audienceRatingImage': 'rottentomatoes://image.rating.upright',
|
||||
'imdbRatingCount': 250858,
|
||||
'Rating': [
|
||||
{'image': 'imdb://image.rating', 'type': 'audience', 'value': 8.5},
|
||||
{'image': 'rottentomatoes://image.rating.ripe', 'type': 'critic', 'value': 94},
|
||||
{'image': 'rottentomatoes://image.rating.upright', 'type': 'audience', 'value': 9.7},
|
||||
{'image': 'themoviedb://image.rating', 'type': 'audience', 'value': 8},
|
||||
],
|
||||
'originallyAvailableAt': '2010-07-16',
|
||||
'originalTitle': 'Origine',
|
||||
'tagline': 'Your mind is the scene of the crime.',
|
||||
'banner': 'https://metadata-static.plex.tv/banner.jpg',
|
||||
'budget': 160000000,
|
||||
'revenue': '839000000',
|
||||
},
|
||||
{
|
||||
// Distinct external ids: the mapper dedupes on identity,
|
||||
// and reusing the default imdb/tmdb would collapse this
|
||||
// row into the scored one above.
|
||||
..._metadata(ratingKey: 'plex-movie-2', title: 'No Score', imdb: 'tt0000002', tmdb: 2),
|
||||
'rating': null,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
addTearDown(source.dispose);
|
||||
|
||||
final page = await source.fetchRow(CatalogRowId.watchlist);
|
||||
final item = page.items.first;
|
||||
final ratings = {for (final rating in item.ratings!) rating.source: (value: rating.value, votes: rating.votes)};
|
||||
|
||||
expect(item.rating, 9.4);
|
||||
expect(ratings, {
|
||||
'rottenTomatoesCritic': (value: 9.4, votes: null),
|
||||
'rottenTomatoesAudience': (value: 9.7, votes: null),
|
||||
'imdb': (value: 8.5, votes: 250858),
|
||||
'tmdb': (value: 8.0, votes: null),
|
||||
});
|
||||
expect(item.releaseDate, DateTime(2010, 7, 16));
|
||||
expect(item.originalTitle, 'Origine');
|
||||
expect(item.tagline, 'Your mind is the scene of the crime.');
|
||||
expect(item.bannerUrl, 'https://metadata-static.plex.tv/banner.jpg');
|
||||
expect(item.budget, 160000000);
|
||||
expect(item.revenue, 839000000);
|
||||
|
||||
final absent = page.items.last;
|
||||
expect(absent.rating, isNull);
|
||||
expect(absent.ratings, isNull);
|
||||
expect(absent.releaseDate, isNull);
|
||||
expect(absent.playState, isNull);
|
||||
expect(absent.posterVariants, isNull);
|
||||
expect(absent.backdropVariants, isNull);
|
||||
});
|
||||
|
||||
test('home shelves are hydrated from their placeholder keys', () async {
|
||||
final requests = <http.Request>[];
|
||||
final source = PlexCatalogSource(
|
||||
@@ -111,10 +185,11 @@ void main() {
|
||||
return jsonResponse({
|
||||
'MediaContainer': {
|
||||
'Hub': [
|
||||
_placeholderHub('home.trending-plex', 'Trending on Plex'),
|
||||
_placeholderHub('home.trending-plex', 'Trending on Plex', style: 'shelf'),
|
||||
_placeholderHub('home.genres', 'Browse by Genre', type: 'directory'),
|
||||
_placeholderHub('home.new-trailers', 'New Trailers', type: 'clip'),
|
||||
_placeholderHub('home.people', 'People'),
|
||||
_placeholderHub('home.platforms', 'Available On', style: 'availabilityPlatforms'),
|
||||
_placeholderHub('home.chris-nolan', 'The Films of Sir Christopher Nolan'),
|
||||
],
|
||||
},
|
||||
@@ -124,7 +199,11 @@ void main() {
|
||||
'MediaContainer': {
|
||||
'Metadata': [
|
||||
_metadata(),
|
||||
_metadata(ratingKey: 'plex-show-1', type: 'show', title: 'Severance'),
|
||||
{
|
||||
..._metadata(ratingKey: 'plex-show-1', type: 'show', title: 'Severance'),
|
||||
'isContinuingSeries': true,
|
||||
'nextEpisodeOriginallyAvailableAt': '2026-08-04',
|
||||
},
|
||||
_metadata(ratingKey: 'plex-movie-2', title: 'Interstellar'),
|
||||
],
|
||||
},
|
||||
@@ -137,6 +216,19 @@ void main() {
|
||||
],
|
||||
},
|
||||
});
|
||||
case '/hubs/sections/home/platforms':
|
||||
return jsonResponse({
|
||||
'MediaContainer': {
|
||||
'Metadata': [
|
||||
{
|
||||
..._metadata(ratingKey: 'platform-1', title: 'A Platform Title'),
|
||||
'viewCount': 2,
|
||||
'viewOffset': 12345,
|
||||
'viewedLeafCount': 7,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
case '/hubs/sections/home/chris-nolan':
|
||||
return jsonResponse({
|
||||
'MediaContainer': {
|
||||
@@ -158,22 +250,69 @@ void main() {
|
||||
'/hubs/sections/home',
|
||||
'/hubs/sections/home/trending-plex',
|
||||
'/hubs/sections/home/people',
|
||||
'/hubs/sections/home/platforms',
|
||||
'/hubs/sections/home/chris-nolan',
|
||||
]);
|
||||
expect(requests[1].url.queryParameters, containsPair('limit', '3'));
|
||||
expect(requests[1].url.queryParameters, containsPair('includeMeta', '1'));
|
||||
expect(requests[1].url.queryParameters, containsPair('source', 'home'));
|
||||
final shelfRequest = requests.singleWhere((request) => request.url.path == '/hubs/sections/home/trending-plex');
|
||||
final platformRequest = requests.singleWhere((request) => request.url.path == '/hubs/sections/home/platforms');
|
||||
expect(shelfRequest.url.queryParameters, containsPair('limit', '3'));
|
||||
expect(shelfRequest.url.queryParameters, containsPair('includeMeta', '1'));
|
||||
expect(shelfRequest.url.queryParameters, containsPair('includeUserState', '1'));
|
||||
expect(shelfRequest.url.queryParameters, containsPair('source', 'home'));
|
||||
expect(shelfRequest.url.queryParameters, containsPair('excludeElements', 'Media,Image'));
|
||||
expect(platformRequest.url.queryParameters, containsPair('excludeElements', 'Media,Image'));
|
||||
|
||||
// The people-only shelf maps to nothing and drops out; provider order
|
||||
// and titles survive for the rest.
|
||||
expect(hubs.map((hub) => hub.id), ['home.trending-plex', 'home.chris-nolan']);
|
||||
// The people-only shelf maps to nothing and drops out. Explicit styles
|
||||
// survive, while an absent hint remains null instead of becoming shelf.
|
||||
expect(hubs.map((hub) => hub.id), ['home.trending-plex', 'home.platforms', 'home.chris-nolan']);
|
||||
expect(hubs.map((hub) => hub.style), [CatalogHubStyle.shelf, CatalogHubStyle.availabilityPlatforms, null]);
|
||||
expect(hubs.first.title, 'Trending on Plex');
|
||||
expect(hubs.first.page.items.map((item) => item.title), ['Inception', 'Severance']);
|
||||
expect(hubs.first.page.hasMore, isTrue);
|
||||
final show = hubs.first.page.items.last;
|
||||
expect(show.airStatus, CatalogAirStatus.airing);
|
||||
expect(show.nextEpisode?.airsAt, DateTime(2026, 8, 4));
|
||||
expect(show.endDate, isNull);
|
||||
|
||||
final platformItem = hubs[1].page.items.single;
|
||||
expect(platformItem.playState?.viewCount, 2);
|
||||
expect(platformItem.playState?.viewOffsetMs, 12345);
|
||||
expect(platformItem.playState?.viewedLeafCount, 7);
|
||||
expect(hubs.last.page.items.single.title, 'The Prestige');
|
||||
expect(hubs.last.page.hasMore, isFalse);
|
||||
});
|
||||
|
||||
test('source forwards the explicit hub Image opt-in', () async {
|
||||
final requests = <http.Request>[];
|
||||
final source = PlexCatalogSource(
|
||||
PlexDiscoverClient(
|
||||
_session,
|
||||
httpClient: MockClient((request) async {
|
||||
requests.add(request);
|
||||
if (request.url.path == '/hubs/sections/home') {
|
||||
return jsonResponse({
|
||||
'MediaContainer': {
|
||||
'Hub': [_placeholderHub('home.spotlight', 'Spotlight')],
|
||||
},
|
||||
});
|
||||
}
|
||||
return jsonResponse({
|
||||
'MediaContainer': {
|
||||
'Metadata': [_metadata()],
|
||||
},
|
||||
});
|
||||
}),
|
||||
),
|
||||
includeImageVariants: true,
|
||||
);
|
||||
addTearDown(source.dispose);
|
||||
|
||||
await source.fetchHubs();
|
||||
|
||||
expect(requests.last.url.queryParameters, containsPair('includeUserState', '1'));
|
||||
expect(requests.last.url.queryParameters, containsPair('excludeElements', 'Media'));
|
||||
});
|
||||
|
||||
test('View All takes a shelf in one request because Discover ignores offsets', () async {
|
||||
final requests = <http.Request>[];
|
||||
final source = PlexCatalogSource(
|
||||
@@ -286,8 +425,8 @@ void main() {
|
||||
'SearchResults': [
|
||||
{
|
||||
'SearchResult': [
|
||||
{'Metadata': _metadata()},
|
||||
{'Metadata': _metadata()},
|
||||
{'score': 0.91, 'Metadata': _metadata()},
|
||||
{'score': 0.42, 'Metadata': _metadata()},
|
||||
{
|
||||
'Metadata': {'ratingKey': 'person-1', 'type': 'person', 'title': 'A Person'},
|
||||
},
|
||||
@@ -310,6 +449,7 @@ void main() {
|
||||
expect(captured.url.queryParameters, containsPair('searchProviders', 'discover'));
|
||||
expect(results, hasLength(1));
|
||||
expect(results.single.ids.plex, 'plex-movie-1');
|
||||
expect(results.single.relevance, 0.91);
|
||||
});
|
||||
|
||||
test('watchlist snapshot and mutation use the advertised action endpoint', () async {
|
||||
@@ -375,44 +515,42 @@ void main() {
|
||||
|
||||
expect(requests.map((request) => request.url.path), ['/library/metadata/matches', '/actions/addToWatchlist']);
|
||||
});
|
||||
test('external-id matching enables cast and related detail flows', () async {
|
||||
test('external-id matching and fetchDetail return enriched item, cast, and related', () async {
|
||||
final requests = <http.Request>[];
|
||||
final metadataResponse = Completer<http.Response>();
|
||||
var relatedRequested = false;
|
||||
final source = PlexCatalogSource(
|
||||
PlexDiscoverClient(
|
||||
_session,
|
||||
httpClient: MockClient((request) async {
|
||||
httpClient: MockClient((request) {
|
||||
requests.add(request);
|
||||
switch (request.url.path) {
|
||||
case '/library/metadata/matches':
|
||||
expect(request.url.queryParameters['guid'], 'imdb://tt1375666');
|
||||
return jsonResponse({
|
||||
'MediaContainer': {
|
||||
'Metadata': [_metadata(type: 'show')],
|
||||
},
|
||||
});
|
||||
return Future.value(
|
||||
jsonResponse({
|
||||
'MediaContainer': {
|
||||
'Metadata': [_metadata(type: 'show')],
|
||||
},
|
||||
}),
|
||||
);
|
||||
case '/library/metadata/plex-movie-1':
|
||||
return jsonResponse({
|
||||
'MediaContainer': {
|
||||
'Metadata': [
|
||||
{
|
||||
..._metadata(type: 'show'),
|
||||
'Role': [
|
||||
{'tag': 'Ken Watanabe', 'role': 'Saito', 'thumb': 'https://images.plex.tv/ken.jpg'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
return metadataResponse.future;
|
||||
case '/library/metadata/plex-movie-1/related':
|
||||
return jsonResponse({
|
||||
'MediaContainer': {
|
||||
'Hub': [
|
||||
{
|
||||
'Metadata': [_metadata(ratingKey: 'related-1', title: 'Interstellar')],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
relatedRequested = true;
|
||||
return Future.value(
|
||||
jsonResponse({
|
||||
'MediaContainer': {
|
||||
'Hub': [
|
||||
{
|
||||
'Metadata': [_metadata(ratingKey: 'related-1', title: 'Interstellar')],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
return jsonResponse({'error': 'unexpected'}, status: 500);
|
||||
return Future.value(jsonResponse({'error': 'unexpected'}, status: 500));
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -426,14 +564,201 @@ void main() {
|
||||
source: CatalogSourceId.plex,
|
||||
kind: MediaKind.show,
|
||||
title: 'Inception',
|
||||
overview: 'Row overview.',
|
||||
ids: CatalogItemIds(plex: 'plex-movie-1'),
|
||||
relevance: 0.73,
|
||||
);
|
||||
final detailFuture = source.fetchDetail(item);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(relatedRequested, isTrue, reason: 'metadata and related requests must start concurrently');
|
||||
|
||||
metadataResponse.complete(
|
||||
jsonResponse({
|
||||
'MediaContainer': {
|
||||
'Metadata': [
|
||||
{
|
||||
..._metadata(type: 'show'),
|
||||
'summary': 'Short summary.',
|
||||
'Summary': [
|
||||
{'type': 'default', 'tag': 'A complete and much longer summary from detail metadata.'},
|
||||
],
|
||||
'Role': [
|
||||
{'tag': 'Ken Watanabe', 'role': 'Saito', 'thumb': 'https://images.plex.tv/ken.jpg'},
|
||||
],
|
||||
'Director': [
|
||||
{'tag': 'Christopher Nolan'},
|
||||
],
|
||||
'Writer': [
|
||||
{'tag': 'Jonathan Nolan'},
|
||||
],
|
||||
'Producer': [
|
||||
{'tag': 'Emma Thomas'},
|
||||
],
|
||||
'Country': [
|
||||
{'tag': 'United Kingdom'},
|
||||
{'tag': 'United States of America'},
|
||||
],
|
||||
'Studio': [
|
||||
{'tag': 'Warner Bros.'},
|
||||
],
|
||||
'Genre': [
|
||||
{'tag': 'Science Fiction'},
|
||||
{'tag': 'Thriller'},
|
||||
],
|
||||
'Rating': [
|
||||
{'image': 'imdb://image.rating', 'type': 'audience', 'value': 8.5},
|
||||
],
|
||||
'imdbRatingCount': 250858,
|
||||
'CommonSenseMedia': [
|
||||
{
|
||||
'oneLiner': 'Complex themes and sustained peril.',
|
||||
'AgeRating': [
|
||||
{'age': 15, 'rating': 5, 'type': 'official'},
|
||||
],
|
||||
},
|
||||
],
|
||||
'originallyAvailableAt': '2010-07-16',
|
||||
'originalTitle': 'Origine',
|
||||
'tagline': 'Your mind is the scene of the crime.',
|
||||
'isContinuingSeries': false,
|
||||
'lastEpisodeOriginallyAvailableAt': '2010-12-01',
|
||||
'thumb': null,
|
||||
'art': null,
|
||||
'banner': null,
|
||||
'Image': [
|
||||
{
|
||||
'type': 'clearLogoWide',
|
||||
'alt': 'Inception',
|
||||
'url': 'https://metadata-static.plex.tv/inception-logo.png',
|
||||
},
|
||||
{
|
||||
'type': 'coverPoster',
|
||||
'alt': 'Inception',
|
||||
'url': 'https://metadata-static.plex.tv/inception-poster.jpg',
|
||||
},
|
||||
{
|
||||
'type': 'background',
|
||||
'alt': 'Inception',
|
||||
'url': 'https://metadata-static.plex.tv/inception-background.jpg',
|
||||
},
|
||||
{'type': 'banner', 'alt': 'Inception', 'url': 'https://assets.fanart.tv/inception-banner.jpg'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
final detail = await detailFuture;
|
||||
|
||||
expect(detail.item.overview, 'A complete and much longer summary from detail metadata.');
|
||||
expect(detail.item.relevance, 0.73);
|
||||
expect(detail.item.genres, ['Science Fiction', 'Thriller']);
|
||||
expect(detail.item.studios, ['Warner Bros.']);
|
||||
expect(detail.item.countries, ['GB', 'US']);
|
||||
expect(
|
||||
{for (final credit in detail.item.credits!) credit.role: credit.name},
|
||||
{
|
||||
CatalogCreditRole.director: 'Christopher Nolan',
|
||||
CatalogCreditRole.writer: 'Jonathan Nolan',
|
||||
CatalogCreditRole.producer: 'Emma Thomas',
|
||||
},
|
||||
);
|
||||
expect(detail.item.contentAdvisory, '15+ · Complex themes and sustained peril.');
|
||||
expect(detail.item.releaseDate, DateTime(2010, 7, 16));
|
||||
expect(detail.item.endDate, DateTime(2010, 12, 1));
|
||||
expect(detail.item.airStatus, CatalogAirStatus.ended);
|
||||
expect(detail.item.originalTitle, 'Origine');
|
||||
expect(detail.item.tagline, 'Your mind is the scene of the crime.');
|
||||
expect(detail.item.logoUrl, 'https://metadata-static.plex.tv/inception-logo.png');
|
||||
expect(detail.item.posterUrl, 'https://metadata-static.plex.tv/inception-poster.jpg');
|
||||
expect(detail.item.backdropUrl, 'https://metadata-static.plex.tv/inception-background.jpg');
|
||||
expect(detail.item.bannerUrl, 'https://assets.fanart.tv/inception-banner.jpg');
|
||||
expect(detail.item.posterVariants, isNull);
|
||||
expect(detail.item.backdropVariants, isNull);
|
||||
expect(
|
||||
detail.item.ratings,
|
||||
contains(
|
||||
isA<CatalogRatingSource>()
|
||||
.having((rating) => rating.source, 'source', 'imdb')
|
||||
.having((rating) => rating.value, 'value', 8.5)
|
||||
.having((rating) => rating.votes, 'votes', 250858),
|
||||
),
|
||||
);
|
||||
expect(detail.cast.single.name, 'Ken Watanabe');
|
||||
expect(detail.cast.single.secondary, 'Saito');
|
||||
expect(detail.related.single.title, 'Interstellar');
|
||||
expect(requests.where((request) => request.url.path == '/library/metadata/plex-movie-1'), hasLength(1));
|
||||
expect(requests.where((request) => request.url.path == '/library/metadata/plex-movie-1/related'), hasLength(1));
|
||||
});
|
||||
|
||||
test('fetchDetail keeps enrichment and cast when the related call fails', () async {
|
||||
final source = PlexCatalogSource(
|
||||
PlexDiscoverClient(
|
||||
_session,
|
||||
httpClient: MockClient((request) async {
|
||||
if (request.url.path.endsWith('/related')) {
|
||||
return jsonResponse({'error': 'related unavailable'}, status: 503);
|
||||
}
|
||||
return jsonResponse({
|
||||
'MediaContainer': {
|
||||
'Metadata': [
|
||||
{
|
||||
..._metadata(),
|
||||
'summary': 'Detailed overview.',
|
||||
'Director': [
|
||||
{'tag': 'Christopher Nolan'},
|
||||
],
|
||||
'Role': [
|
||||
{'tag': 'Ken Watanabe', 'role': 'Saito'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
addTearDown(source.dispose);
|
||||
const item = CatalogItem(
|
||||
source: CatalogSourceId.plex,
|
||||
kind: MediaKind.movie,
|
||||
title: 'Inception',
|
||||
ids: CatalogItemIds(plex: 'plex-movie-1'),
|
||||
);
|
||||
final cast = await source.fetchCast(item);
|
||||
final related = await source.fetchRelated(item);
|
||||
|
||||
expect(cast.single.name, 'Ken Watanabe');
|
||||
expect(cast.single.secondary, 'Saito');
|
||||
expect(related.single.title, 'Interstellar');
|
||||
final detail = await source.fetchDetail(item);
|
||||
|
||||
expect(detail.item.overview, 'Detailed overview.');
|
||||
expect(detail.item.credits?.single.name, 'Christopher Nolan');
|
||||
expect(detail.cast.single.name, 'Ken Watanabe');
|
||||
expect(detail.related, isEmpty);
|
||||
});
|
||||
|
||||
test('fetchDetail without a Plex rating key returns the row without requests', () async {
|
||||
var requested = false;
|
||||
final source = PlexCatalogSource(
|
||||
PlexDiscoverClient(
|
||||
_session,
|
||||
httpClient: MockClient((_) async {
|
||||
requested = true;
|
||||
return jsonResponse({'error': 'unexpected'}, status: 500);
|
||||
}),
|
||||
),
|
||||
);
|
||||
addTearDown(source.dispose);
|
||||
const item = CatalogItem(
|
||||
source: CatalogSourceId.plex,
|
||||
kind: MediaKind.movie,
|
||||
title: 'Inception',
|
||||
ids: CatalogItemIds(imdb: 'tt1375666'),
|
||||
);
|
||||
|
||||
final detail = await source.fetchDetail(item);
|
||||
|
||||
expect(identical(detail.item, item), isTrue);
|
||||
expect(detail.cast, isEmpty);
|
||||
expect(detail.related, isEmpty);
|
||||
expect(requested, isFalse);
|
||||
});
|
||||
|
||||
test('Discover requests have a bounded duration', () async {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/models/catalog/catalog_item.dart';
|
||||
import 'package:plezy/models/catalog/catalog_metadata.dart';
|
||||
import 'package:plezy/models/seerr/seerr_session.dart';
|
||||
import 'package:plezy/services/catalog/catalog_source.dart';
|
||||
import 'package:plezy/services/catalog/seerr_catalog_source.dart';
|
||||
@@ -37,26 +40,37 @@ SeerrCatalogSource _source(MockClient mock) {
|
||||
|
||||
void main() {
|
||||
group('SeerrCatalogSource', () {
|
||||
test('trending row keeps movies and shows, drops people, maps TMDB images', () async {
|
||||
test('trending maps row metadata, total results, and the responsive TMDB image ladders', () async {
|
||||
final source = _source(
|
||||
MockClient((request) async {
|
||||
expect(request.url.path, '/api/v1/discover/trending');
|
||||
return jsonResponse({
|
||||
'page': 1,
|
||||
'totalPages': 3,
|
||||
'totalResults': 41,
|
||||
'results': [
|
||||
{
|
||||
'id': 603,
|
||||
'mediaType': 'movie',
|
||||
'title': 'The Matrix',
|
||||
'originalTitle': 'Matrix, The',
|
||||
'originalLanguage': 'en',
|
||||
'releaseDate': '1999-03-30',
|
||||
'posterPath': '/matrix.jpg',
|
||||
'backdropPath': '/matrix-backdrop.jpg',
|
||||
'voteAverage': 8.2,
|
||||
'voteCount': 26000,
|
||||
'popularity': 42.5,
|
||||
'adult': false,
|
||||
},
|
||||
{'id': 9, 'mediaType': 'person', 'name': 'Keanu Reeves'},
|
||||
{'id': 1396, 'mediaType': 'tv', 'name': 'Breaking Bad', 'firstAirDate': '2008-01-20'},
|
||||
{
|
||||
'id': 1396,
|
||||
'mediaType': 'tv',
|
||||
'name': 'Breaking Bad',
|
||||
'firstAirDate': '2008-01-20',
|
||||
'originCountry': ['us', 'GB'],
|
||||
},
|
||||
],
|
||||
});
|
||||
}),
|
||||
@@ -64,6 +78,7 @@ void main() {
|
||||
|
||||
final page = await source.fetchRow(CatalogRowId.trending);
|
||||
expect(page.hasMore, isTrue);
|
||||
expect(page.totalResults, 41);
|
||||
expect(page.items, hasLength(2));
|
||||
|
||||
final matrix = page.items.first;
|
||||
@@ -71,21 +86,168 @@ void main() {
|
||||
expect(matrix.kind, MediaKind.movie);
|
||||
expect(matrix.title, 'The Matrix');
|
||||
expect(matrix.year, 1999);
|
||||
expect(matrix.releaseDate, DateTime(1999, 3, 30));
|
||||
expect(matrix.rating, 8.2);
|
||||
expect(matrix.votes, 26000);
|
||||
expect(matrix.ids.tmdb, 603);
|
||||
expect(matrix.originalTitle, 'Matrix, The');
|
||||
expect(matrix.languages, ['en']);
|
||||
expect(matrix.isAdult, isFalse);
|
||||
expect(matrix.relevance, 42.5);
|
||||
expect(matrix.posterUrl, 'https://image.tmdb.org/t/p/w600_and_h900_bestv2/matrix.jpg');
|
||||
expect(matrix.backdropUrl, 'https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/matrix-backdrop.jpg');
|
||||
expect(matrix.posterVariants?.keys, [92, 154, 185, 342, 500, 600, 780]);
|
||||
expect(matrix.posterVariants?[342], 'https://image.tmdb.org/t/p/w342/matrix.jpg');
|
||||
expect(matrix.posterVariants?[600], matrix.posterUrl);
|
||||
expect(matrix.backdropVariants?.keys, [300, 780, 1280, 1920]);
|
||||
expect(matrix.backdropVariants?[1920], matrix.backdropUrl);
|
||||
|
||||
expect(page.items.last.kind, MediaKind.show);
|
||||
expect(page.items.last.title, 'Breaking Bad');
|
||||
expect(page.items.last.countries, ['US', 'GB']);
|
||||
expect(page.items.last.posterVariants, isNull);
|
||||
expect(page.items.last.serverState, isNull);
|
||||
});
|
||||
|
||||
test('maps availability and request state independently with explicit pending semantics', () async {
|
||||
final source = _source(
|
||||
MockClient(
|
||||
(request) async => jsonResponse({
|
||||
'page': 1,
|
||||
'totalPages': 1,
|
||||
'results': [
|
||||
{
|
||||
'id': 1,
|
||||
'mediaType': 'movie',
|
||||
'title': 'Available',
|
||||
'mediaInfo': {'status': 5},
|
||||
},
|
||||
{
|
||||
'id': 2,
|
||||
'mediaType': 'tv',
|
||||
'name': 'Partial',
|
||||
'mediaInfo': {
|
||||
'status': 4,
|
||||
'seasons': [
|
||||
{'seasonNumber': 0, 'status': 5},
|
||||
{'seasonNumber': 1, 'status': 5},
|
||||
{'seasonNumber': 2, 'status': 4},
|
||||
{'seasonNumber': 3, 'status': 1},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
'id': 3,
|
||||
'mediaType': 'movie',
|
||||
'title': 'HD available, 4K pending',
|
||||
'mediaInfo': {
|
||||
'status': 5,
|
||||
'status4k': 2,
|
||||
'requests': [
|
||||
{'id': 31, 'status': 1, 'is4k': true},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
'id': 4,
|
||||
'mediaType': 'movie',
|
||||
'title': 'Requested',
|
||||
'mediaInfo': {'status': 2},
|
||||
},
|
||||
{
|
||||
'id': 5,
|
||||
'mediaType': 'movie',
|
||||
'title': 'Pending approval',
|
||||
'mediaInfo': {
|
||||
'status': 1,
|
||||
'requests': [
|
||||
{'id': 51, 'status': 1, 'is4k': false},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
'id': 6,
|
||||
'mediaType': 'movie',
|
||||
'title': 'Declined',
|
||||
'mediaInfo': {
|
||||
'status': 1,
|
||||
'requests': [
|
||||
{'id': 61, 'status': 3, 'is4k': false},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
'id': 7,
|
||||
'mediaType': 'movie',
|
||||
'title': 'Processing',
|
||||
'mediaInfo': {
|
||||
'status': 3,
|
||||
'requests': [
|
||||
{'id': 71, 'status': 2, 'is4k': false},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
final page = await source.fetchRow(CatalogRowId.trending);
|
||||
final byTitle = {for (final item in page.items) item.title: item.serverState};
|
||||
|
||||
expect(byTitle['Available']?.availability, CatalogAvailability.available);
|
||||
expect(byTitle['Available']?.request, isNull);
|
||||
|
||||
expect(byTitle['Partial']?.availability, CatalogAvailability.partiallyAvailable);
|
||||
expect(byTitle['Partial']?.availableSeasons, 1);
|
||||
expect(byTitle['Partial']?.totalSeasons, 3);
|
||||
|
||||
final pending4k = byTitle['HD available, 4K pending'];
|
||||
expect(pending4k?.availability, CatalogAvailability.available);
|
||||
expect(pending4k?.availability4k, CatalogAvailability.unavailable);
|
||||
expect(pending4k?.request, isNull);
|
||||
expect(pending4k?.request4k, CatalogRequestState.pending);
|
||||
|
||||
// MediaInfo.status=2 is acquisition-pipeline "pending", not approval
|
||||
// pending. Only requests[].status=1 maps to CatalogRequestState.pending.
|
||||
expect(byTitle['Requested']?.availability, CatalogAvailability.unavailable);
|
||||
expect(byTitle['Requested']?.request, CatalogRequestState.approved);
|
||||
expect(byTitle['Pending approval']?.availability, isNull);
|
||||
expect(byTitle['Pending approval']?.request, CatalogRequestState.pending);
|
||||
expect(byTitle['Declined']?.request, CatalogRequestState.declined);
|
||||
expect(byTitle['Processing']?.availability, CatalogAvailability.unavailable);
|
||||
expect(byTitle['Processing']?.request, CatalogRequestState.processing);
|
||||
});
|
||||
|
||||
test('preserves full dates and retains the coarse year fallback for malformed input', () async {
|
||||
final source = _source(
|
||||
MockClient(
|
||||
(request) async => jsonResponse({
|
||||
'page': 1,
|
||||
'totalPages': 1,
|
||||
'results': [
|
||||
{'id': 1, 'title': 'Valid', 'releaseDate': '2027-04-09'},
|
||||
{'id': 2, 'title': 'Malformed', 'releaseDate': '2028-not-a-date'},
|
||||
{'id': 3, 'title': 'Empty', 'releaseDate': ''},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
final page = await source.fetchRow(CatalogRowId.upcomingMovies);
|
||||
expect(page.items[0].releaseDate, DateTime(2027, 4, 9));
|
||||
expect(page.items[0].year, 2027);
|
||||
expect(page.items[1].releaseDate, isNull);
|
||||
expect(page.items[1].year, 2028);
|
||||
expect(page.items[2].releaseDate, isNull);
|
||||
expect(page.items[2].year, isNull);
|
||||
});
|
||||
|
||||
test('single-type rows hit their endpoint and coerce the kind', () async {
|
||||
final paths = <String>[];
|
||||
late Uri uri;
|
||||
final source = _source(
|
||||
MockClient((request) async {
|
||||
paths.add('${request.url.path}?${request.url.query}');
|
||||
uri = request.url;
|
||||
return jsonResponse({
|
||||
'page': 2,
|
||||
'totalPages': 2,
|
||||
@@ -97,9 +259,12 @@ void main() {
|
||||
);
|
||||
|
||||
final page = await source.fetchRow(CatalogRowId.upcomingMovies, page: 2);
|
||||
expect(paths.single, '/api/v1/discover/movies/upcoming?page=2');
|
||||
expect(uri.path, '/api/v1/discover/movies/upcoming');
|
||||
expect(uri.queryParameters['page'], '2');
|
||||
expect(uri.queryParameters['language'], isNotEmpty);
|
||||
expect(page.items.single.kind, MediaKind.movie);
|
||||
expect(page.hasMore, isFalse);
|
||||
expect(page.totalResults, isNull);
|
||||
});
|
||||
|
||||
test('rows Seerr does not serve throw', () {
|
||||
@@ -116,36 +281,112 @@ void main() {
|
||||
expect(await source.resolveItemIds(MediaKind.movie, const ExternalIds(imdb: 'tt0133093')), isNull);
|
||||
});
|
||||
|
||||
test('fetchCast reads credits off the detail endpoint', () async {
|
||||
test('fetchDetail runs both GETs concurrently and enriches TV metadata, cast, and recommendations', () async {
|
||||
final started = <String>{};
|
||||
final bothStarted = Completer<void>();
|
||||
final source = _source(
|
||||
MockClient((request) async {
|
||||
started.add(request.url.path);
|
||||
if (!bothStarted.isCompleted && started.length == 2) bothStarted.complete();
|
||||
await bothStarted.future;
|
||||
|
||||
if (request.url.path == '/api/v1/tv/1396/recommendations') {
|
||||
return jsonResponse({
|
||||
'page': 1,
|
||||
'totalPages': 1,
|
||||
'results': [
|
||||
{'id': 60059, 'name': 'Better Call Saul', 'firstAirDate': '2015-02-08'},
|
||||
],
|
||||
});
|
||||
}
|
||||
expect(request.url.path, '/api/v1/tv/1396');
|
||||
return jsonResponse({
|
||||
'id': 1396,
|
||||
'name': 'Breaking Bad',
|
||||
'originalName': 'Breaking Bad Original',
|
||||
'originalLanguage': 'en',
|
||||
'languages': ['en', 'es'],
|
||||
'originCountry': ['gb'],
|
||||
'firstAirDate': '2008-01-20',
|
||||
'lastAirDate': '2013-09-29',
|
||||
'episodeRunTime': [47],
|
||||
'numberOfEpisodes': 62,
|
||||
'genres': [
|
||||
{'id': 18, 'name': 'Drama'},
|
||||
{'id': 80, 'name': 'Crime'},
|
||||
],
|
||||
'networks': [
|
||||
{'id': 174, 'name': 'AMC'},
|
||||
],
|
||||
'productionCompanies': [
|
||||
{'id': 11073, 'name': 'Sony Pictures Television'},
|
||||
],
|
||||
'productionCountries': [
|
||||
{'iso_3166_1': 'us', 'name': 'United States of America'},
|
||||
],
|
||||
'status': 'Ended',
|
||||
'tagline': 'All bad things must come to an end.',
|
||||
'contentRatings': {
|
||||
'results': [
|
||||
{'iso_3166_1': 'US', 'rating': 'TV-14'},
|
||||
],
|
||||
},
|
||||
'externalIds': {'imdbId': 'tt0903747', 'tvdbId': 81189},
|
||||
'createdBy': [
|
||||
{'id': 1, 'name': 'Vince Gilligan'},
|
||||
],
|
||||
'keywords': [
|
||||
{'id': 1, 'name': 'new mexico'},
|
||||
],
|
||||
'credits': {
|
||||
'cast': [
|
||||
{'name': 'Bryan Cranston', 'character': 'Walter White', 'profilePath': '/bc.jpg'},
|
||||
{'name': '', 'character': 'nobody'},
|
||||
{'name': 'Aaron Paul', 'character': 'Jesse Pinkman'},
|
||||
],
|
||||
'crew': [
|
||||
{'name': 'Michelle MacLaren', 'job': 'Director', 'department': 'Directing'},
|
||||
],
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
final item = CatalogItem(
|
||||
final rowItem = CatalogItem(
|
||||
source: CatalogSourceId.seerr,
|
||||
kind: MediaKind.show,
|
||||
title: 'Breaking Bad',
|
||||
title: 'Row title',
|
||||
ids: const CatalogItemIds(tmdb: 1396),
|
||||
);
|
||||
final cast = await source.fetchCast(item);
|
||||
expect(cast, hasLength(2));
|
||||
expect(cast.first.name, 'Bryan Cranston');
|
||||
expect(cast.first.secondary, 'Walter White');
|
||||
expect(cast.first.imageUrl, 'https://image.tmdb.org/t/p/w300/bc.jpg');
|
||||
expect(cast.last.imageUrl, isNull);
|
||||
final detail = await source.fetchDetail(rowItem);
|
||||
|
||||
expect(started, {'/api/v1/tv/1396', '/api/v1/tv/1396/recommendations'});
|
||||
expect(detail.item.title, 'Breaking Bad');
|
||||
expect(detail.item.runtimeMinutes, 47);
|
||||
expect(detail.item.genres, ['Drama', 'Crime']);
|
||||
expect(detail.item.certification, 'TV-14');
|
||||
expect(detail.item.airStatus, CatalogAirStatus.ended);
|
||||
expect(detail.item.episodeCount, 62);
|
||||
expect(detail.item.network, 'AMC');
|
||||
expect(detail.item.ids.imdb, 'tt0903747');
|
||||
expect(detail.item.ids.tvdb, 81189);
|
||||
expect(detail.item.releaseDate, DateTime(2008, 1, 20));
|
||||
expect(detail.item.endDate, DateTime(2013, 9, 29));
|
||||
expect(detail.item.originalTitle, 'Breaking Bad Original');
|
||||
expect(detail.item.tagline, 'All bad things must come to an end.');
|
||||
expect(detail.item.studios, ['Sony Pictures Television']);
|
||||
expect(detail.item.countries, ['GB', 'US']);
|
||||
expect(detail.item.languages, ['en', 'es']);
|
||||
expect(detail.item.credits?.map((credit) => credit.name), containsAll(['Vince Gilligan', 'Michelle MacLaren']));
|
||||
expect(detail.item.tags?.single.name, 'new mexico');
|
||||
|
||||
expect(detail.cast, hasLength(2));
|
||||
expect(detail.cast.first.name, 'Bryan Cranston');
|
||||
expect(detail.cast.first.secondary, 'Walter White');
|
||||
expect(detail.cast.first.imageUrl, 'https://image.tmdb.org/t/p/w300/bc.jpg');
|
||||
expect(detail.cast.last.imageUrl, isNull);
|
||||
expect(detail.related.single.title, 'Better Call Saul');
|
||||
expect(detail.related.single.kind, MediaKind.show);
|
||||
});
|
||||
|
||||
test('search proxies /search and filters persons', () async {
|
||||
@@ -168,9 +409,97 @@ void main() {
|
||||
expect(items.single.ids.tmdb, 603);
|
||||
});
|
||||
|
||||
test('fetchRelated proxies the recommendations endpoint and coerces the kind', () async {
|
||||
test('fetchDetail keeps movie enrichment when recommendations fail', () async {
|
||||
final source = _source(
|
||||
MockClient((request) async {
|
||||
if (request.url.path == '/api/v1/movie/603/recommendations') {
|
||||
return jsonResponse({'message': 'recommendations unavailable'}, status: 500);
|
||||
}
|
||||
expect(request.url.path, '/api/v1/movie/603');
|
||||
return jsonResponse({
|
||||
'id': 603,
|
||||
'imdbId': 'tt0133093',
|
||||
'title': 'The Matrix',
|
||||
'originalTitle': 'The Matrix Original',
|
||||
'originalLanguage': 'en',
|
||||
'releaseDate': '1999-03-30',
|
||||
'runtime': 136,
|
||||
'adult': true,
|
||||
'budget': 63000000,
|
||||
'revenue': 466000000,
|
||||
'genres': [
|
||||
{'id': 28, 'name': 'Action'},
|
||||
{'id': 878, 'name': 'Science Fiction'},
|
||||
],
|
||||
'productionCompanies': [
|
||||
{'id': 79, 'name': 'Village Roadshow Pictures'},
|
||||
],
|
||||
'productionCountries': [
|
||||
{'name': 'United States of America'},
|
||||
],
|
||||
'spokenLanguages': [
|
||||
{'iso_639_1': 'en', 'name': 'English'},
|
||||
],
|
||||
'status': 'Released',
|
||||
'tagline': 'Welcome to the Real World.',
|
||||
'releases': {
|
||||
'results': [
|
||||
{
|
||||
'iso_3166_1': 'CA',
|
||||
'release_dates': [
|
||||
{'certification': '14A'},
|
||||
],
|
||||
},
|
||||
{
|
||||
'iso_3166_1': 'US',
|
||||
'release_dates': [
|
||||
{'certification': 'R'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'relatedVideos': [
|
||||
{'site': 'YouTube', 'key': 'clip', 'type': 'Clip'},
|
||||
{'site': 'YouTube', 'key': 'm8e-FF8MsqU', 'type': 'Trailer'},
|
||||
],
|
||||
'credits': {
|
||||
'crew': [
|
||||
{'name': 'Lana Wachowski', 'job': 'Director', 'department': 'Directing'},
|
||||
],
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
final item = CatalogItem(
|
||||
source: CatalogSourceId.seerr,
|
||||
kind: MediaKind.movie,
|
||||
title: 'Row title',
|
||||
ids: const CatalogItemIds(tmdb: 603),
|
||||
);
|
||||
|
||||
final detail = await source.fetchDetail(item);
|
||||
expect(detail.related, isEmpty);
|
||||
expect(detail.item.title, 'The Matrix');
|
||||
expect(detail.item.runtimeMinutes, 136);
|
||||
expect(detail.item.certification, 'R');
|
||||
expect(detail.item.trailerUrl, 'https://www.youtube.com/watch?v=m8e-FF8MsqU');
|
||||
expect(detail.item.airStatus, isNull);
|
||||
expect(detail.item.ids.imdb, 'tt0133093');
|
||||
expect(detail.item.isAdult, isTrue);
|
||||
expect(detail.item.budget, 63000000);
|
||||
expect(detail.item.revenue, 466000000);
|
||||
expect(detail.item.studios, ['Village Roadshow Pictures']);
|
||||
expect(detail.item.countries, ['US']);
|
||||
expect(detail.item.languages, ['en']);
|
||||
expect(detail.item.credits?.single.role, CatalogCreditRole.director);
|
||||
});
|
||||
|
||||
test('fetchDetail keeps recommendations when the detail GET fails', () async {
|
||||
final source = _source(
|
||||
MockClient((request) async {
|
||||
if (request.url.path == '/api/v1/movie/603') {
|
||||
return jsonResponse({'message': 'detail unavailable'}, status: 500);
|
||||
}
|
||||
expect(request.url.path, '/api/v1/movie/603/recommendations');
|
||||
return jsonResponse({
|
||||
'page': 1,
|
||||
@@ -187,9 +516,12 @@ void main() {
|
||||
title: 'The Matrix',
|
||||
ids: const CatalogItemIds(tmdb: 603),
|
||||
);
|
||||
final related = await source.fetchRelated(item);
|
||||
expect(related.single.title, 'The Matrix Reloaded');
|
||||
expect(related.single.kind, MediaKind.movie);
|
||||
|
||||
final detail = await source.fetchDetail(item);
|
||||
expect(detail.item, same(item));
|
||||
expect(detail.cast, isEmpty);
|
||||
expect(detail.related.single.title, 'The Matrix Reloaded');
|
||||
expect(detail.related.single.kind, MediaKind.movie);
|
||||
});
|
||||
|
||||
test('canRequest honors the per-kind permission split', () {
|
||||
@@ -205,5 +537,34 @@ void main() {
|
||||
expect(source.isOnWatchlist(MediaKind.movie, const CatalogItemIds(tmdb: 603)), isNull);
|
||||
expect(() => source.addToWatchlist(MediaKind.movie, const CatalogItemIds(tmdb: 603)), throwsUnsupportedError);
|
||||
});
|
||||
|
||||
test('an untranslated locale degrades to the original title instead of blanking', () async {
|
||||
// TMDB answers `language=` for a title it has no translation of with an
|
||||
// empty string, not a missing field. Sending the app locale must not
|
||||
// turn a populated row into a blank one.
|
||||
final source = _source(
|
||||
MockClient((request) async {
|
||||
return jsonResponse({
|
||||
'page': 1,
|
||||
'totalPages': 1,
|
||||
'totalResults': 1,
|
||||
'results': [
|
||||
{
|
||||
'id': 603,
|
||||
'mediaType': 'movie',
|
||||
'title': '',
|
||||
'originalTitle': 'The Matrix',
|
||||
'overview': '',
|
||||
'releaseDate': '1999-03-30',
|
||||
},
|
||||
],
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
final item = (await source.fetchRow(CatalogRowId.trending)).items.single;
|
||||
expect(item.title, 'The Matrix');
|
||||
expect(item.overview, isNull, reason: 'a blank overview must not render as an empty block');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/models/catalog/catalog_item.dart';
|
||||
import 'package:plezy/models/catalog/catalog_metadata.dart';
|
||||
import 'package:plezy/models/simkl/simkl_trending_item.dart';
|
||||
import 'package:plezy/services/catalog/catalog_source.dart';
|
||||
import 'package:plezy/services/catalog/simkl_catalog_source.dart';
|
||||
@@ -31,25 +32,41 @@ http.Response _json(Object? body, {int status = 200, Map<String, String>? header
|
||||
|
||||
Map<String, dynamic> _trending({required int simkl, String title = 'Inception', String? animeType}) => {
|
||||
'title': title,
|
||||
'title_romaji': animeType == null ? null : 'Inception no Yume',
|
||||
'alt_titles': [
|
||||
{'name': title},
|
||||
{'name': 'Le rêve'},
|
||||
],
|
||||
'url': '/${animeType == null ? 'movies' : 'anime'}/$simkl/inception',
|
||||
'poster': '12/posterhash',
|
||||
'fanart': '34/fanarthash',
|
||||
'release_date': '07/16/2010',
|
||||
'rank': 7,
|
||||
'drop_rate': '2.5%',
|
||||
'watched': 321,
|
||||
'plan_to_watch': 654,
|
||||
'runtime': '2h 37m',
|
||||
'status': 'ended',
|
||||
'overview': 'Dreams within dreams.',
|
||||
'genres': ['Action', 'Science Fiction'],
|
||||
'trailer': 'YoHD9XEInc0',
|
||||
'country': 'us',
|
||||
'original_language': 'en',
|
||||
'dvd_date': '12/03/2010',
|
||||
'theater': '07/15/2010',
|
||||
'total_episodes': animeType == null ? null : 12,
|
||||
'anime_type': ?animeType,
|
||||
'ids': {'simkl_id': simkl, 'slug': 'inception', 'imdb': 'tt1375666', 'tmdb': '27205', 'tvdb': '123'},
|
||||
'ratings': {
|
||||
'simkl': {'rating': 8.8, 'votes': 1234},
|
||||
if (animeType == null) 'imdb': {'rating': 8.7, 'votes': 2345} else 'mal': {'rating': 8.6, 'votes': 3456},
|
||||
},
|
||||
};
|
||||
|
||||
Map<String, dynamic> _best(int simkl) => {
|
||||
Map<String, dynamic> _best(int simkl, {int? watched}) => {
|
||||
'title': 'Best $simkl',
|
||||
'year': 2020,
|
||||
'watched': ?watched,
|
||||
'ids': {'simkl': simkl},
|
||||
};
|
||||
|
||||
@@ -69,6 +86,9 @@ Map<String, dynamic> _allItemsBody() => {
|
||||
'shows': [
|
||||
{
|
||||
'status': 'plantowatch',
|
||||
'added_to_watchlist_at': '2025-04-03T02:01:00Z',
|
||||
'user_rating': 9,
|
||||
'not_aired_episodes_count': 4,
|
||||
'total_episodes_count': 62,
|
||||
'show': {
|
||||
'title': 'Breaking Bad',
|
||||
@@ -92,6 +112,51 @@ Map<String, dynamic> _allItemsBody() => {
|
||||
],
|
||||
};
|
||||
|
||||
Map<String, dynamic> _detailBody() => {
|
||||
'title': 'One Piece',
|
||||
'year': 1999,
|
||||
'type': 'anime',
|
||||
'anime_type': 'tv',
|
||||
'poster': '15/detailposter',
|
||||
'fanart': '87/detailfanart',
|
||||
'first_aired': '1999-10-20T14:15:00Z',
|
||||
'last_aired': null,
|
||||
'airs': {'day': 'Sunday', 'time': '11:15 PM', 'timezone': 'Asia/Tokyo'},
|
||||
'runtime': 25,
|
||||
'certification': 'PG-13',
|
||||
'overview': 'The full detail overview.',
|
||||
'genres': ['Action', 'Adventure'],
|
||||
'country': 'jp',
|
||||
'total_episodes': 1176,
|
||||
'status': 'airing',
|
||||
'network': 'Fuji TV',
|
||||
'season_name_year': 'Fall 1999',
|
||||
'studios': [
|
||||
{'id': 74, 'name': 'Toei Animation'},
|
||||
],
|
||||
'ratings': {
|
||||
'simkl': {'rating': 8.9, 'votes': 8037},
|
||||
'imdb': {'rating': 8.3, 'votes': 15720},
|
||||
'mal': {'rating': 8.7, 'votes': 1548013},
|
||||
},
|
||||
'trailers': [
|
||||
{'youtube': 'tnj5YOZpCyo', 'size': 1080},
|
||||
],
|
||||
'ids': {'simkl': 3, 'mal': 21, 'anilist': 21},
|
||||
'users_recommendations': [
|
||||
{
|
||||
'title': 'Fairy Tail',
|
||||
'year': 2009,
|
||||
'poster': '64/related',
|
||||
'type': 'anime',
|
||||
'anime_type': 'tv',
|
||||
'users_percent': '19%',
|
||||
'users_count': 76,
|
||||
'ids': {'simkl': 4},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
void main() {
|
||||
group('Simkl models', () {
|
||||
test('parses runtime strings and rejects malformed values', () {
|
||||
@@ -151,6 +216,24 @@ void main() {
|
||||
expect(first.items.single.runtimeMinutes, 157);
|
||||
expect(first.items.single.posterUrl, 'https://simkl.in/posters/12/posterhash_m.webp');
|
||||
expect(first.items.single.backdropUrl, 'https://simkl.in/fanart/34/fanarthash_medium.webp');
|
||||
final item = first.items.single;
|
||||
expect(item.posterVariants, containsPair(170, 'https://simkl.in/posters/12/posterhash_c.webp'));
|
||||
expect(item.posterVariants, containsPair(340, item.posterUrl));
|
||||
expect(item.backdropVariants, containsPair(960, 'https://simkl.in/fanart/34/fanarthash_mobile.webp'));
|
||||
expect(item.backdropVariants, containsPair(1920, item.backdropUrl));
|
||||
expect(item.ratings?.map((rating) => rating.source), ['simkl', 'imdb']);
|
||||
expect(item.ratings?.map((rating) => rating.votes), [1234, 2345]);
|
||||
expect(item.audience?.viewers, 321);
|
||||
expect(item.audience?.viewersPeriod, CatalogAudiencePeriod.week);
|
||||
expect(item.audience?.planning, 654);
|
||||
expect(item.audience?.dropRate, 0.025);
|
||||
expect(item.ranks?.single.scope, CatalogRankScope.trending);
|
||||
expect(item.ranks?.single.allTime, isFalse);
|
||||
expect(item.releaseDate, DateTime(2010, 7, 15));
|
||||
expect(item.physicalReleaseDate, DateTime(2010, 12, 3));
|
||||
expect(item.countries, ['US']);
|
||||
expect(item.languages, ['en']);
|
||||
expect(item.links?.single.url, 'https://simkl.com/movies/1/inception');
|
||||
expect(second.items.single.title, 'Second');
|
||||
expect(second.hasMore, isFalse);
|
||||
});
|
||||
@@ -181,6 +264,15 @@ void main() {
|
||||
expect(page.hasMore, isFalse);
|
||||
});
|
||||
|
||||
test('best watched counts are monthly viewers', () async {
|
||||
responder = (request) => _json([_best(1, watched: 987)]);
|
||||
|
||||
final page = await source.fetchRow(CatalogRowId.popularShows);
|
||||
|
||||
expect(page.items.single.audience?.viewers, 987);
|
||||
expect(page.items.single.audience?.viewersPeriod, CatalogAudiencePeriod.month);
|
||||
});
|
||||
|
||||
test('best rows expose all cached pages without refetching', () async {
|
||||
responder = (request) {
|
||||
expect(request.url.path, '/tv/best/watched');
|
||||
@@ -219,9 +311,35 @@ void main() {
|
||||
expect(movie.runtimeMinutes, 148);
|
||||
expect(first.items.last.kind, MediaKind.movie);
|
||||
expect(first.items.last.ids.anilist, 21519);
|
||||
final show = first.items[1];
|
||||
expect(show.addedAt, DateTime.parse('2025-04-03T02:01:00Z'));
|
||||
expect(show.userRating, 9);
|
||||
expect(show.unairedEpisodeCount, 4);
|
||||
expect(second.items, hasLength(3));
|
||||
});
|
||||
|
||||
test('absent optional row fields stay absent', () async {
|
||||
responder = (request) => _json([
|
||||
{
|
||||
'title': 'Sparse',
|
||||
'ids': {'simkl_id': 99},
|
||||
},
|
||||
]);
|
||||
|
||||
final page = await source.fetchRow(CatalogRowId.trendingMovies);
|
||||
final item = page.items.single;
|
||||
|
||||
expect(item.ratings, isNull);
|
||||
expect(item.audience, isNull);
|
||||
expect(item.ranks, isNull);
|
||||
expect(item.posterUrl, isNull);
|
||||
expect(item.posterVariants, isNull);
|
||||
expect(item.backdropVariants, isNull);
|
||||
expect(item.originalTitle, isNull);
|
||||
expect(item.altTitles, isEmpty);
|
||||
expect(item.links, isNull);
|
||||
});
|
||||
|
||||
test('watchlist row and membership snapshot share one full-library download', () async {
|
||||
responder = (request) {
|
||||
expect(request.url.queryParameters['extended'], 'full');
|
||||
@@ -337,47 +455,59 @@ void main() {
|
||||
expect(results.map((item) => item.kind), [MediaKind.movie, MediaKind.show, MediaKind.movie]);
|
||||
});
|
||||
|
||||
test('fetchCast performs no requests', () async {
|
||||
test('fetchDetail without a Simkl id returns the row unchanged without a request', () async {
|
||||
const item = CatalogItem(
|
||||
source: CatalogSourceId.simkl,
|
||||
kind: MediaKind.movie,
|
||||
title: 'Inception',
|
||||
ids: CatalogItemIds(simkl: 1),
|
||||
ids: CatalogItemIds(imdb: 'tt1375666'),
|
||||
);
|
||||
|
||||
expect(await source.fetchCast(item), isEmpty);
|
||||
final detail = await source.fetchDetail(item);
|
||||
|
||||
expect(detail.item, same(item));
|
||||
expect(detail.cast, isEmpty);
|
||||
expect(detail.related, isEmpty);
|
||||
expect(detail.relations, isEmpty);
|
||||
expect(requests, isEmpty);
|
||||
});
|
||||
|
||||
test('related retries anime when the kind endpoint returns an empty array', () async {
|
||||
test('fetchDetail uses one request for an enriched item and recommendations', () async {
|
||||
responder = (request) {
|
||||
expect(request.url.queryParameters, isNot(contains('extended')));
|
||||
if (request.url.path == '/tv/3') return _json([]);
|
||||
if (request.url.host == 'data.simkl.in') {
|
||||
return _json([_trending(simkl: 3, animeType: 'tv')]);
|
||||
}
|
||||
expect(request.url.path, '/anime/3');
|
||||
return _json({
|
||||
'users_recommendations': [
|
||||
{
|
||||
'title': 'A Silent Voice',
|
||||
'year': 2016,
|
||||
'poster': '1/related',
|
||||
'type': 'anime',
|
||||
'ids': {'simkl': 4, 'mal': 28851},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(request.url.queryParameters, isNot(contains('extended')));
|
||||
return _json(_detailBody());
|
||||
};
|
||||
const item = CatalogItem(
|
||||
source: CatalogSourceId.simkl,
|
||||
kind: MediaKind.show,
|
||||
title: 'Anime',
|
||||
ids: CatalogItemIds(simkl: 3),
|
||||
);
|
||||
final row = await source.fetchRow(CatalogRowId.trendingAnime);
|
||||
final item = row.items.single;
|
||||
requests.clear();
|
||||
|
||||
final related = await source.fetchRelated(item);
|
||||
final detail = await source.fetchDetail(item);
|
||||
|
||||
expect(requests.map((request) => request.url.path), ['/tv/3', '/anime/3']);
|
||||
expect(related.single.title, 'A Silent Voice');
|
||||
expect(related.single.ids.mal, 28851);
|
||||
expect(requests, hasLength(1));
|
||||
expect(detail.cast, isEmpty);
|
||||
expect(detail.relations, isEmpty);
|
||||
expect(detail.item.title, 'One Piece');
|
||||
expect(detail.item.overview, 'The full detail overview.');
|
||||
expect(detail.item.certification, 'PG-13');
|
||||
expect(detail.item.runtimeMinutes, 25);
|
||||
expect(detail.item.ratings?.map((rating) => rating.source), ['simkl', 'imdb', 'mal']);
|
||||
expect(detail.item.ranks, same(item.ranks));
|
||||
expect(detail.item.posterVariants, containsPair(170, 'https://simkl.in/posters/15/detailposter_c.webp'));
|
||||
expect(detail.item.backdropVariants, containsPair(960, 'https://simkl.in/fanart/87/detailfanart_mobile.webp'));
|
||||
expect(detail.item.trailerUrl, 'https://www.youtube.com/watch?v=tnj5YOZpCyo');
|
||||
expect(detail.item.broadcast?.weekday, DateTime.sunday);
|
||||
expect(detail.item.broadcast?.time, '23:15');
|
||||
expect(detail.item.countries, ['JP']);
|
||||
expect(detail.item.studios, ['Toei Animation']);
|
||||
expect(detail.related, hasLength(1));
|
||||
expect(detail.related.single.title, 'Fairy Tail');
|
||||
expect(detail.related.single.recommendationCount, 76);
|
||||
expect(detail.related.single.recommendationPercent, 0.19);
|
||||
expect(detail.related.single.posterVariants, isNotNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -6,6 +7,7 @@ import 'package:http/testing.dart';
|
||||
import 'package:plezy/media/catalog_item_ref.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/models/catalog/catalog_item.dart';
|
||||
import 'package:plezy/models/catalog/catalog_metadata.dart';
|
||||
import 'package:plezy/services/catalog/catalog_source.dart';
|
||||
import 'package:plezy/services/catalog/trakt_catalog_source.dart';
|
||||
import 'package:plezy/services/trackers/tracker_session.dart';
|
||||
@@ -27,11 +29,14 @@ Map<String, dynamic> _watchlistBody() => {
|
||||
'entries': [
|
||||
{
|
||||
'rank': 1,
|
||||
'listed_at': '2026-01-03T12:34:56.000Z',
|
||||
'type': 'movie',
|
||||
'movie': {
|
||||
'title': 'The Matrix',
|
||||
'year': 1999,
|
||||
'ids': {'trakt': 1, 'imdb': 'tt0133093', 'tmdb': 603},
|
||||
'trailer': 'https://youtube.com/watch?v=m8e-FF8MsqU',
|
||||
'released': '1999-03-31T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -46,6 +51,18 @@ Map<String, dynamic> _watchlistBody() => {
|
||||
'aired_episodes': 19,
|
||||
'votes': 7294,
|
||||
'rating': 8.5,
|
||||
'comment_count': 432,
|
||||
'tagline': 'Your innie has a life of its own.',
|
||||
'original_title': 'Severance',
|
||||
'first_aired': '2022-02-18T00:00:00.000Z',
|
||||
'language': 'en',
|
||||
'languages': ['en'],
|
||||
'available_translations': ['es', 'fr'],
|
||||
'country': 'us',
|
||||
'airs': {'day': 'Tuesday', 'time': '21:00', 'timezone': 'America/New_York'},
|
||||
'images': {
|
||||
'logo': ['walter-r2.trakt.tv/images/shows/logos/severance.webp'],
|
||||
},
|
||||
},
|
||||
},
|
||||
// Episode entries are not Explore rows and must be skipped.
|
||||
@@ -88,10 +105,15 @@ void main() {
|
||||
});
|
||||
|
||||
test('fetchRow(watchlist) maps mixed entries and skips non-movie/show types', () async {
|
||||
handlers.add(
|
||||
(request) =>
|
||||
http.Response(json.encode(_watchlistBody()['entries']), 200, headers: {'x-pagination-item-count': '3'}),
|
||||
);
|
||||
final page = await source.fetchRow(CatalogRowId.watchlist);
|
||||
|
||||
expect(requests.single.url.path, '/sync/watchlist');
|
||||
expect(page.items, hasLength(2));
|
||||
expect(page.totalResults, 3);
|
||||
expect(page.items[0].kind, MediaKind.movie);
|
||||
expect(page.items[0].identityKey, 'movie/imdb:tt0133093');
|
||||
expect(page.items[1].kind, MediaKind.show);
|
||||
@@ -103,6 +125,22 @@ void main() {
|
||||
expect(show.episodeCount, 19);
|
||||
expect(show.votes, 7294);
|
||||
expect(show.rating, 8.5);
|
||||
expect(show.audience?.comments, 432);
|
||||
expect(show.broadcast?.weekday, DateTime.tuesday);
|
||||
expect(show.broadcast?.time, '21:00');
|
||||
expect(show.broadcast?.timezone, 'America/New_York');
|
||||
expect(show.tagline, 'Your innie has a life of its own.');
|
||||
expect(show.originalTitle, 'Severance');
|
||||
expect(show.releaseDate, DateTime.utc(2022, 2, 18));
|
||||
expect(show.languages, ['en', 'es', 'fr']);
|
||||
expect(show.logoUrl, 'https://walter-r2.trakt.tv/images/shows/logos/severance.webp');
|
||||
expect(show.countries, ['US']);
|
||||
expect(page.items[0].trailerUrl, 'https://youtube.com/watch?v=m8e-FF8MsqU');
|
||||
expect(page.items[0].releaseDate, DateTime.utc(1999, 3, 31));
|
||||
expect(page.items[1].addedAt, isNull);
|
||||
expect(page.items[0].addedAt, DateTime.utc(2026, 1, 3, 12, 34, 56));
|
||||
expect(page.items[0].audience, isNull);
|
||||
expect(page.items[0].broadcast, isNull);
|
||||
expect(page.items[0].airStatus, isNull);
|
||||
|
||||
final rendered = page.items[0].toMediaItem();
|
||||
@@ -150,48 +188,179 @@ void main() {
|
||||
expect(source.isOnWatchlist(MediaKind.show, const CatalogItemIds(tmdb: 1396)), isTrue);
|
||||
});
|
||||
|
||||
test('fetchCast maps people to cast members with https-prefixed headshots', () async {
|
||||
handlers.add(
|
||||
(request) => http.Response(
|
||||
json.encode({
|
||||
'cast': [
|
||||
{
|
||||
'characters': ['Walter White'],
|
||||
'person': {
|
||||
'name': 'Bryan Cranston',
|
||||
'images': {
|
||||
'headshot': ['media.trakt.tv/images/people/headshots/medium/25eb34a2d5.jpg.webp'],
|
||||
test('fetchDetail appends bounded guest stars and maps cast metadata, crew, and related titles', () async {
|
||||
http.Response detailResponse(http.Request request) {
|
||||
if (request.url.path == '/shows/1388/people') {
|
||||
return http.Response(
|
||||
json.encode({
|
||||
'cast': [
|
||||
{
|
||||
'characters': ['Walter White', 'Heisenberg'],
|
||||
'episode_count': 62,
|
||||
'person': {
|
||||
'name': 'Bryan Cranston',
|
||||
'images': {
|
||||
'headshot': ['media.trakt.tv/images/people/headshots/medium/25eb34a2d5.jpg.webp'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
'guest_stars': [
|
||||
{
|
||||
'characters': ['Tuco Salamanca'],
|
||||
'episode_count': 4,
|
||||
'person': {'name': 'Raymond Cruz'},
|
||||
},
|
||||
{
|
||||
'characters': ['Gale Boetticher'],
|
||||
'episode_count': 7,
|
||||
'person': {'name': 'David Costabile'},
|
||||
},
|
||||
],
|
||||
'crew': {
|
||||
'directing': [
|
||||
{
|
||||
'jobs': ['Director'],
|
||||
'person': {'name': 'Vince Gilligan'},
|
||||
},
|
||||
],
|
||||
'writing': [
|
||||
{
|
||||
'jobs': ['Writer', 'Screenplay'],
|
||||
'person': {'name': 'Peter Gould'},
|
||||
},
|
||||
],
|
||||
'production': [
|
||||
{
|
||||
'jobs': ['Executive Producer'],
|
||||
'person': {'name': 'Mark Johnson'},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
'characters': <String>[],
|
||||
'person': {'name': 'Aaron Paul'},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
expect(request.url.path, '/shows/1388/related');
|
||||
return http.Response(
|
||||
json.encode([
|
||||
{
|
||||
'title': 'Better Call Saul',
|
||||
'year': 2015,
|
||||
'ids': {'trakt': 5},
|
||||
},
|
||||
]),
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
handlers
|
||||
..add(detailResponse)
|
||||
..add(detailResponse);
|
||||
final item = CatalogItem(
|
||||
source: CatalogSourceId.trakt,
|
||||
kind: MediaKind.show,
|
||||
title: 'Breaking Bad',
|
||||
tagline: 'All bad things must come to an end.',
|
||||
ids: const CatalogItemIds(trakt: 1388, slug: 'breaking-bad'),
|
||||
);
|
||||
final detail = await source.fetchDetail(item, castLimit: 2, relatedLimit: 7);
|
||||
|
||||
expect(requests.map((request) => request.url.path).toSet(), {'/shows/1388/people', '/shows/1388/related'});
|
||||
final peopleRequest = requests.singleWhere((request) => request.url.path.endsWith('/people'));
|
||||
expect(peopleRequest.url.queryParameters['extended'], 'full,images,guest_stars');
|
||||
final relatedRequest = requests.singleWhere((request) => request.url.path.endsWith('/related'));
|
||||
expect(relatedRequest.url.queryParameters['limit'], '7');
|
||||
expect(detail.cast, hasLength(2));
|
||||
expect(detail.cast[0].name, 'Bryan Cranston');
|
||||
expect(detail.cast[0].secondary, 'Walter White, Heisenberg · 62 eps');
|
||||
expect(detail.cast[0].imageUrl, 'https://media.trakt.tv/images/people/headshots/medium/25eb34a2d5.jpg.webp');
|
||||
expect(detail.cast[1].name, 'Raymond Cruz');
|
||||
expect(detail.cast[1].secondary, 'Tuco Salamanca · 4 eps');
|
||||
expect(detail.item.tagline, item.tagline);
|
||||
expect(detail.item.credits, [
|
||||
isA<CatalogCredit>()
|
||||
.having((credit) => credit.name, 'name', 'Vince Gilligan')
|
||||
.having((credit) => credit.role, 'role', CatalogCreditRole.director),
|
||||
isA<CatalogCredit>()
|
||||
.having((credit) => credit.name, 'name', 'Peter Gould')
|
||||
.having((credit) => credit.role, 'role', CatalogCreditRole.writer),
|
||||
isA<CatalogCredit>()
|
||||
.having((credit) => credit.name, 'name', 'Mark Johnson')
|
||||
.having((credit) => credit.role, 'role', CatalogCreditRole.producer),
|
||||
]);
|
||||
expect(detail.related.single.title, 'Better Call Saul');
|
||||
expect(detail.related.single.kind, MediaKind.show);
|
||||
});
|
||||
|
||||
test('trending watchers reach audience and pagination count reaches totalResults', () async {
|
||||
handlers.add(
|
||||
(request) => http.Response(
|
||||
json.encode([
|
||||
{
|
||||
'watchers': 120,
|
||||
'movie': {
|
||||
'title': 'The Matrix',
|
||||
'ids': {'trakt': 1},
|
||||
},
|
||||
{'characters': <String>[]}, // no person — skipped
|
||||
],
|
||||
'crew': <String, dynamic>{},
|
||||
}),
|
||||
},
|
||||
]),
|
||||
200,
|
||||
headers: {'x-pagination-item-count': '987'},
|
||||
),
|
||||
);
|
||||
|
||||
final page = await source.fetchRow(CatalogRowId.trendingMovies);
|
||||
|
||||
expect(page.items.single.audience?.watchingNow, 120);
|
||||
expect(page.items.single.recommenders, isNull);
|
||||
expect(page.totalResults, 987);
|
||||
});
|
||||
|
||||
test('recommendation users and their notes reach row-only provenance', () async {
|
||||
handlers.add(
|
||||
(request) => http.Response(
|
||||
json.encode([
|
||||
{
|
||||
'title': 'The Matrix',
|
||||
'ids': {'trakt': 1},
|
||||
'favorited_by': [
|
||||
{'username': 'alice', 'name': 'Alice', 'notes': 'A forever favorite.'},
|
||||
],
|
||||
'recommended_by': [
|
||||
{'username': 'bob', 'name': null, 'notes': 'The lobby scene.'},
|
||||
],
|
||||
},
|
||||
]),
|
||||
200,
|
||||
),
|
||||
);
|
||||
|
||||
final cast = await source.fetchCast(
|
||||
const CatalogItem(
|
||||
source: CatalogSourceId.trakt,
|
||||
kind: MediaKind.show,
|
||||
title: 'Breaking Bad',
|
||||
ids: CatalogItemIds(trakt: 1388, slug: 'breaking-bad'),
|
||||
),
|
||||
);
|
||||
final page = await source.fetchRow(CatalogRowId.recommendedMovies);
|
||||
|
||||
expect(requests.single.url.path, '/shows/1388/people');
|
||||
expect(cast, hasLength(2));
|
||||
expect(cast[0].name, 'Bryan Cranston');
|
||||
expect(cast[0].secondary, 'Walter White');
|
||||
expect(cast[0].imageUrl, 'https://media.trakt.tv/images/people/headshots/medium/25eb34a2d5.jpg.webp');
|
||||
expect(cast[1].imageUrl, isNull);
|
||||
expect(cast[1].secondary, isNull);
|
||||
expect(page.items.single.recommendationCount, isNull);
|
||||
expect(page.items.single.recommenders, [
|
||||
isA<CatalogRecommender>()
|
||||
.having((recommender) => recommender.username, 'username', 'alice')
|
||||
.having((recommender) => recommender.name, 'name', 'Alice')
|
||||
.having((recommender) => recommender.note, 'note', 'A forever favorite.')
|
||||
.having((recommender) => recommender.reason, 'reason', CatalogRecommendationReason.favorited),
|
||||
isA<CatalogRecommender>()
|
||||
.having((recommender) => recommender.username, 'username', 'bob')
|
||||
.having((recommender) => recommender.note, 'note', 'The lobby scene.')
|
||||
.having((recommender) => recommender.reason, 'reason', CatalogRecommendationReason.recommended),
|
||||
]);
|
||||
});
|
||||
|
||||
test('weekday mapping covers every Trakt day name and rejects unknown values', () {
|
||||
expect(TraktCatalogSource.weekdayFor('Monday'), DateTime.monday);
|
||||
expect(TraktCatalogSource.weekdayFor('Tuesday'), DateTime.tuesday);
|
||||
expect(TraktCatalogSource.weekdayFor('Wednesday'), DateTime.wednesday);
|
||||
expect(TraktCatalogSource.weekdayFor('Thursday'), DateTime.thursday);
|
||||
expect(TraktCatalogSource.weekdayFor('Friday'), DateTime.friday);
|
||||
expect(TraktCatalogSource.weekdayFor('Saturday'), DateTime.saturday);
|
||||
expect(TraktCatalogSource.weekdayFor('Sunday'), DateTime.sunday);
|
||||
expect(TraktCatalogSource.weekdayFor('Someday'), isNull);
|
||||
});
|
||||
|
||||
test('air status normalization covers the Trakt vocabulary', () {
|
||||
@@ -287,30 +456,68 @@ void main() {
|
||||
expect(requests, isEmpty);
|
||||
});
|
||||
|
||||
test('fetchRelated hits /related and keeps the item kind', () async {
|
||||
handlers.add((request) {
|
||||
expect(request.url.path, '/shows/2/related');
|
||||
return http.Response(
|
||||
json.encode([
|
||||
{
|
||||
'title': 'Dark',
|
||||
'year': 2017,
|
||||
'ids': {'trakt': 5, 'tmdb': 70523},
|
||||
},
|
||||
]),
|
||||
200,
|
||||
);
|
||||
test('fetchDetail starts people and related concurrently and isolates related failure', () async {
|
||||
final peopleStarted = Completer<void>();
|
||||
final relatedStarted = Completer<void>();
|
||||
final peopleResponse = Completer<http.Response>();
|
||||
final relatedResponse = Completer<http.Response>();
|
||||
final concurrentClient = TraktClient(
|
||||
_session(),
|
||||
onSessionInvalidated: () => fail('should not invalidate'),
|
||||
httpClient: MockClient((request) {
|
||||
if (request.url.path.endsWith('/people')) {
|
||||
peopleStarted.complete();
|
||||
return peopleResponse.future;
|
||||
}
|
||||
if (request.url.path.endsWith('/related')) {
|
||||
relatedStarted.complete();
|
||||
return relatedResponse.future;
|
||||
}
|
||||
return Future.value(http.Response('not found', 404));
|
||||
}),
|
||||
);
|
||||
final concurrentSource = TraktCatalogSource(concurrentClient);
|
||||
addTearDown(() {
|
||||
concurrentSource.dispose();
|
||||
concurrentClient.dispose();
|
||||
});
|
||||
|
||||
final item = CatalogItem(
|
||||
source: CatalogSourceId.trakt,
|
||||
kind: MediaKind.show,
|
||||
title: 'Severance',
|
||||
ids: const CatalogItemIds(trakt: 2),
|
||||
);
|
||||
final related = await source.fetchRelated(item);
|
||||
expect(related.single.title, 'Dark');
|
||||
expect(related.single.kind, MediaKind.show);
|
||||
|
||||
final detailFuture = concurrentSource.fetchDetail(item);
|
||||
await Future.wait([peopleStarted.future, relatedStarted.future]).timeout(const Duration(seconds: 1));
|
||||
peopleResponse.complete(
|
||||
http.Response(
|
||||
json.encode({
|
||||
'cast': [
|
||||
{
|
||||
'characters': ['Mark Scout'],
|
||||
'person': {'name': 'Adam Scott'},
|
||||
},
|
||||
],
|
||||
'crew': {
|
||||
'directing': [
|
||||
{
|
||||
'jobs': ['Director'],
|
||||
'person': {'name': 'Ben Stiller'},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
200,
|
||||
),
|
||||
);
|
||||
relatedResponse.complete(http.Response('upstream failure', 500));
|
||||
|
||||
final detail = await detailFuture;
|
||||
expect(detail.cast.single.name, 'Adam Scott');
|
||||
expect(detail.item.credits?.single.name, 'Ben Stiller');
|
||||
expect(detail.item.credits?.single.role, CatalogCreditRole.director);
|
||||
expect(detail.related, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'dart:convert';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:plezy/i18n/app_locale_utils.dart';
|
||||
import 'package:plezy/i18n/strings.g.dart';
|
||||
import 'package:plezy/models/seerr/seerr_page.dart';
|
||||
import 'package:plezy/models/seerr/seerr_request.dart';
|
||||
import 'package:plezy/models/seerr/seerr_session.dart';
|
||||
@@ -281,6 +283,7 @@ void main() {
|
||||
return _json({
|
||||
'page': 1,
|
||||
'totalPages': 1,
|
||||
'totalResults': 37,
|
||||
'results': [
|
||||
{'id': 4, 'title': 'Dune', 'releaseDate': '2021-09-15'},
|
||||
],
|
||||
@@ -291,6 +294,48 @@ void main() {
|
||||
final page = await client.getPopularMovies();
|
||||
expect(page.items.single.isMovie, isTrue);
|
||||
expect(page.hasMore, isFalse);
|
||||
expect(page.totalResults, 37);
|
||||
});
|
||||
|
||||
test('adds the current Plezy locale to every catalog GET', () async {
|
||||
final urls = <Uri>[];
|
||||
final client = clientWith(
|
||||
MockClient((request) async {
|
||||
urls.add(request.url);
|
||||
if (request.url.path == '/api/v1/movie/4' || request.url.path == '/api/v1/tv/4') {
|
||||
return _json({});
|
||||
}
|
||||
return _json({'page': 1, 'totalPages': 1, 'results': []});
|
||||
}),
|
||||
);
|
||||
|
||||
await client.getPopularMovies();
|
||||
await client.getPopularTv();
|
||||
await client.getUpcomingMovies();
|
||||
await client.getUpcomingTv();
|
||||
await client.getTrending();
|
||||
await client.search('dune');
|
||||
await client.getMovieRecommendations(4);
|
||||
await client.getTvRecommendations(4);
|
||||
await client.getMovie(4);
|
||||
await client.getTv(4);
|
||||
|
||||
expect(urls.map((url) => url.path).toSet(), {
|
||||
'/api/v1/discover/movies',
|
||||
'/api/v1/discover/tv',
|
||||
'/api/v1/discover/movies/upcoming',
|
||||
'/api/v1/discover/tv/upcoming',
|
||||
'/api/v1/discover/trending',
|
||||
'/api/v1/search',
|
||||
'/api/v1/movie/4/recommendations',
|
||||
'/api/v1/tv/4/recommendations',
|
||||
'/api/v1/movie/4',
|
||||
'/api/v1/tv/4',
|
||||
});
|
||||
final expectedLanguage = LocaleSettings.currentLocale.plexLanguageCode;
|
||||
for (final url in urls) {
|
||||
expect(url.queryParameters['language'], expectedLanguage, reason: url.path);
|
||||
}
|
||||
});
|
||||
|
||||
test('createRequest posts the movie payload without seasons', () async {
|
||||
@@ -341,7 +386,7 @@ void main() {
|
||||
group('SeerrPage', () {
|
||||
test('parses the pageInfo pagination shape', () {
|
||||
final page = SeerrPage<int>.fromJson({
|
||||
'pageInfo': {'page': 2, 'pages': 2},
|
||||
'pageInfo': {'page': 2, 'pages': 2, 'totalResults': 55},
|
||||
'results': [
|
||||
{'id': 1},
|
||||
],
|
||||
@@ -349,6 +394,7 @@ void main() {
|
||||
|
||||
expect(page.hasMore, isFalse);
|
||||
expect(page.items, [1]);
|
||||
expect(page.totalResults, 55);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -24,21 +24,42 @@ TrackerSession _session() {
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _movieJson({int trakt = 1, String? posterUrl = 'walter-r2.trakt.tv/images/movies/p.webp'}) {
|
||||
Map<String, dynamic> _movieJson({
|
||||
int trakt = 1,
|
||||
String? posterUrl = 'walter-r2.trakt.tv/images/movies/p.webp',
|
||||
bool withRecommenders = false,
|
||||
}) {
|
||||
return {
|
||||
'title': 'The Matrix',
|
||||
'year': 1999,
|
||||
'ids': {'trakt': trakt, 'slug': 'the-matrix-1999', 'imdb': 'tt0133093', 'tmdb': 603},
|
||||
'overview': 'A hacker learns the truth.',
|
||||
'tagline': 'Welcome to the Real World.',
|
||||
'original_title': 'The Matrix',
|
||||
'released': '1999-03-31T00:00:00.000Z',
|
||||
'runtime': 136,
|
||||
'rating': 8.7,
|
||||
'votes': 42000,
|
||||
'genres': ['action', 'sci-fi'],
|
||||
'certification': 'R',
|
||||
'trailer': 'https://youtube.com/watch?v=m8e-FF8MsqU',
|
||||
'comment_count': 1234,
|
||||
'language': 'en',
|
||||
'languages': ['en'],
|
||||
'available_translations': ['es', 'fr'],
|
||||
'country': 'us',
|
||||
if (withRecommenders) ...{
|
||||
'favorited_by': [
|
||||
{'username': 'alice', 'name': 'Alice', 'notes': 'A forever favorite.'},
|
||||
],
|
||||
'recommended_by': [
|
||||
{'username': 'bob', 'name': null, 'notes': 'The lobby scene.'},
|
||||
],
|
||||
},
|
||||
'images': {
|
||||
'poster': [?posterUrl],
|
||||
'fanart': ['walter-r2.trakt.tv/images/movies/f.webp'],
|
||||
'logo': ['walter-r2.trakt.tv/images/movies/logo.webp'],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -51,6 +72,8 @@ Map<String, dynamic> _showJson() {
|
||||
'overview': 'Work-life balance, surgically.',
|
||||
'runtime': 50,
|
||||
'rating': 8.9,
|
||||
'first_aired': '2022-02-18T00:00:00.000Z',
|
||||
'airs': {'day': 'Friday', 'time': '09:30', 'timezone': 'America/New_York'},
|
||||
'images': <String, dynamic>{},
|
||||
};
|
||||
}
|
||||
@@ -73,6 +96,7 @@ void main() {
|
||||
'poster': ['walter-r2.trakt.tv/images/movies/p.webp'],
|
||||
});
|
||||
expect(images.primaryPoster, 'https://walter-r2.trakt.tv/images/movies/p.webp');
|
||||
expect(images.primaryLogo, isNull);
|
||||
});
|
||||
|
||||
test('keeps absolute URLs and falls back fanart -> thumb for backdrop', () {
|
||||
@@ -82,6 +106,7 @@ void main() {
|
||||
});
|
||||
expect(images.primaryPoster, 'https://example.com/p.webp');
|
||||
expect(images.primaryBackdrop, 'https://walter-r2.trakt.tv/t.webp');
|
||||
expect(images.primaryLogo, isNull);
|
||||
});
|
||||
|
||||
test('returns null for missing or empty image arrays', () {
|
||||
@@ -137,6 +162,7 @@ void main() {
|
||||
expect(page.page, 1);
|
||||
expect(page.pageCount, 1);
|
||||
expect(page.hasMore, isFalse);
|
||||
expect(page.itemCount, isNull);
|
||||
|
||||
client.dispose();
|
||||
});
|
||||
@@ -159,6 +185,7 @@ void main() {
|
||||
expect(requests.single.url.queryParameters['limit'], '10');
|
||||
expect(page.items.single.watchers, 120);
|
||||
expect(page.items.single.media?.title, 'The Matrix');
|
||||
expect(page.itemCount, isNull);
|
||||
|
||||
client.dispose();
|
||||
});
|
||||
@@ -174,6 +201,11 @@ void main() {
|
||||
expect(requests.single.url.path, '/shows/popular');
|
||||
expect(page.items.single, isA<TraktCatalogMedia>());
|
||||
expect(page.items.single.title, 'Severance');
|
||||
expect(page.items.single.firstAired, '2022-02-18T00:00:00.000Z');
|
||||
expect(page.items.single.airs?.day, 'Friday');
|
||||
expect(page.items.single.airs?.time, '09:30');
|
||||
expect(page.items.single.airs?.timezone, 'America/New_York');
|
||||
expect(page.items.single.commentCount, isNull);
|
||||
|
||||
client.dispose();
|
||||
});
|
||||
@@ -196,6 +228,75 @@ void main() {
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('getRecommended parses recommendation provenance without reducing it to a count', () async {
|
||||
final client = _client((request) async {
|
||||
return http.Response(json.encode([_movieJson(withRecommenders: true)]), 200);
|
||||
});
|
||||
|
||||
final items = await client.getRecommended(TraktCatalogType.movies, limit: 15);
|
||||
|
||||
final item = items.single;
|
||||
expect(item.favoritedBy?.single.username, 'alice');
|
||||
expect(item.favoritedBy?.single.name, 'Alice');
|
||||
expect(item.favoritedBy?.single.notes, 'A forever favorite.');
|
||||
expect(item.recommendedBy?.single.username, 'bob');
|
||||
expect(item.recommendedBy?.single.notes, 'The lobby scene.');
|
||||
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('getPeople widens show people for guest stars and parses cast and crew metadata', () async {
|
||||
final requests = <http.Request>[];
|
||||
final client = _client(requests: requests, (request) async {
|
||||
return http.Response(
|
||||
json.encode({
|
||||
'cast': [
|
||||
{
|
||||
'characters': ['Mark Scout', 'Mark S.'],
|
||||
'episode_count': 19,
|
||||
'person': {'name': 'Adam Scott'},
|
||||
},
|
||||
],
|
||||
'guest_stars': [
|
||||
{
|
||||
'characters': ['June'],
|
||||
'episode_count': 1,
|
||||
'person': {'name': 'Guest Actor'},
|
||||
},
|
||||
],
|
||||
'crew': {
|
||||
'directing': [
|
||||
{
|
||||
'jobs': ['Director'],
|
||||
'person': {'name': 'Ben Stiller'},
|
||||
},
|
||||
],
|
||||
'production': [
|
||||
{
|
||||
'job': 'Executive Producer',
|
||||
'person': {'name': 'Jackie Cohn'},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
final people = await client.getPeople(TraktCatalogType.shows, 'severance');
|
||||
|
||||
expect(requests.single.url.path, '/shows/severance/people');
|
||||
expect(requests.single.url.queryParameters['extended'], 'full,images,guest_stars');
|
||||
expect(people.cast.single.characters, ['Mark Scout', 'Mark S.']);
|
||||
expect(people.cast.single.episodeCount, 19);
|
||||
expect(people.guestStars.single.person?.name, 'Guest Actor');
|
||||
expect(people.crew, hasLength(2));
|
||||
expect(people.crew.first.jobs, ['Director']);
|
||||
expect(people.crew.last.job, 'Executive Producer');
|
||||
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('addToWatchlist accepts 201 and posts the ids body untouched', () async {
|
||||
final requests = <http.Request>[];
|
||||
final client = _client(requests: requests, (request) async => http.Response('{"added":{"movies":1}}', 201));
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:plezy/focus/input_mode_tracker.dart';
|
||||
import 'package:plezy/focus/locked_hub_controller.dart';
|
||||
import 'package:plezy/i18n/strings.g.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_hub.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
@@ -21,6 +22,7 @@ void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() async {
|
||||
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||
resetSharedPreferencesForTest();
|
||||
SettingsService.resetForTesting();
|
||||
await SettingsService.getInstance();
|
||||
@@ -133,6 +135,30 @@ void main() {
|
||||
expect(outerPadding.padding.resolve(TextDirection.ltr).bottom, 0);
|
||||
});
|
||||
|
||||
testWidgets('shows a provider result count in the existing hub header only when supplied', (tester) async {
|
||||
final item = testMediaItem(
|
||||
id: 'counted_item',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.movie,
|
||||
title: 'Counted Movie',
|
||||
);
|
||||
final hub = MediaHub(id: 'counted_hub', title: 'Popular', type: 'mixed', items: [item], size: 237, more: true);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_TestApp(
|
||||
child: HubSection(hub: hub, focusMemory: HubFocusMemory(), icon: Symbols.movie_rounded),
|
||||
),
|
||||
);
|
||||
expect(find.text(t.explore.totalResults(n: 237)), findsNothing);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_TestApp(
|
||||
child: HubSection(hub: hub, focusMemory: HubFocusMemory(), icon: Symbols.movie_rounded, totalResults: 237),
|
||||
),
|
||||
);
|
||||
expect(find.text(t.explore.totalResults(n: 237)), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('restores within one owner but resets for a fresh owner', (tester) async {
|
||||
final items = [
|
||||
for (var index = 0; index < 3; index++)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -8,8 +9,11 @@ import 'package:plezy/focus/focus_theme.dart';
|
||||
import 'package:plezy/focus/input_mode_tracker.dart';
|
||||
import 'package:plezy/i18n/strings.g.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/models/catalog/catalog_item.dart';
|
||||
import 'package:plezy/models/catalog/catalog_metadata.dart';
|
||||
import 'package:plezy/services/settings_service.dart';
|
||||
import 'package:plezy/theme/mono_theme.dart';
|
||||
import 'package:plezy/utils/layout_constants.dart';
|
||||
@@ -17,6 +21,7 @@ import 'package:plezy/utils/platform_detector.dart';
|
||||
import 'package:plezy/widgets/focusable_media_card.dart';
|
||||
import 'package:plezy/widgets/media_card.dart';
|
||||
import 'package:plezy/widgets/media_grid_delegate.dart';
|
||||
import 'package:plezy/widgets/optimized_media_image.dart';
|
||||
|
||||
import '../test_helpers/prefs.dart';
|
||||
import '../test_helpers/media_items.dart';
|
||||
@@ -24,6 +29,10 @@ import '../test_helpers/media_items.dart';
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUpAll(() {
|
||||
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
resetSharedPreferencesForTest();
|
||||
SettingsService.resetForTesting();
|
||||
@@ -114,6 +123,219 @@ void main() {
|
||||
expect(find.text('Visible Movie'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('catalog grid metadata leads with the rating and omits certification', (tester) async {
|
||||
// Measured on a Pixel 7: at shelf width the full list composition renders
|
||||
// `PG-13 • 2006 • 2h 10mi…` and ellipsizes the rating away — the one value
|
||||
// this line exists to surface. The grid therefore leads with the rating
|
||||
// and drops certification, votes and genres; the list keeps them.
|
||||
final item = CatalogItem(
|
||||
source: CatalogSourceId.trakt,
|
||||
kind: MediaKind.movie,
|
||||
title: 'Catalog Movie',
|
||||
year: 2024,
|
||||
runtimeMinutes: 125,
|
||||
rating: 8.6,
|
||||
votes: 12345,
|
||||
genres: const ['Drama', 'Mystery'],
|
||||
certification: 'PG-13',
|
||||
ids: const CatalogItemIds(tmdb: 1),
|
||||
).toMediaItem();
|
||||
|
||||
await tester.pumpWidget(_catalogGridHarness(item));
|
||||
|
||||
final metadata = tester.widget<Text>(
|
||||
find.byWidgetPredicate((widget) => widget is Text && (widget.data?.contains('8.6★') ?? false)),
|
||||
);
|
||||
expect(metadata.maxLines, 1);
|
||||
expect(metadata.overflow, TextOverflow.ellipsis);
|
||||
expect(metadata.data, '8.6★ • 2024 • 2h 5min');
|
||||
expect(metadata.data, isNot(contains('PG-13')), reason: 'certification is detail/search only');
|
||||
expect(metadata.data, isNot(contains('Drama')), reason: 'genres do not fit a shelf caption');
|
||||
expect(metadata.data, isNot(contains('(')), reason: 'vote counts do not fit a shelf caption');
|
||||
});
|
||||
|
||||
testWidgets('plain library grid metadata remains year only', (tester) async {
|
||||
final item = testMediaItem(
|
||||
id: 'library-movie',
|
||||
kind: MediaKind.movie,
|
||||
title: 'Library Movie',
|
||||
year: 2024,
|
||||
contentRating: 'PG-13',
|
||||
durationMs: const Duration(minutes: 125).inMilliseconds,
|
||||
rating: 8.6,
|
||||
genres: const ['Drama'],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_catalogGridHarness(item));
|
||||
|
||||
expect(find.text('2024'), findsOneWidget);
|
||||
expect(find.textContaining('PG-13'), findsNothing);
|
||||
expect(find.textContaining('2h 5m'), findsNothing);
|
||||
expect(find.textContaining('8.6★'), findsNothing);
|
||||
expect(find.textContaining('Drama'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('seasonal rank badge names its season instead of claiming all-time rank', (tester) async {
|
||||
final item = CatalogItem(
|
||||
source: CatalogSourceId.anilist,
|
||||
kind: MediaKind.show,
|
||||
title: 'Seasonal Show',
|
||||
ids: const CatalogItemIds(anilist: 1),
|
||||
ranks: const [
|
||||
CatalogRank(
|
||||
rank: 3,
|
||||
scope: CatalogRankScope.popular,
|
||||
allTime: false,
|
||||
year: 2026,
|
||||
season: CatalogSeasonName.summer,
|
||||
),
|
||||
],
|
||||
).toMediaItem();
|
||||
final season = t.explore.season.withYear(season: t.explore.season.summer, year: 2026);
|
||||
|
||||
await tester.pumpWidget(_catalogGridHarness(item));
|
||||
|
||||
expect(find.text(t.explore.badge.rankSeasonal(n: 3, season: season)), findsOneWidget);
|
||||
expect(find.text(t.explore.badge.rankPopular(n: 3)), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('windowed viewers render only when their period is present', (tester) async {
|
||||
final withoutPeriod = CatalogItem(
|
||||
source: CatalogSourceId.simkl,
|
||||
kind: MediaKind.show,
|
||||
title: 'No Viewer Window',
|
||||
ids: const CatalogItemIds(simkl: 1),
|
||||
audience: const CatalogAudience(viewers: 37),
|
||||
).toMediaItem();
|
||||
|
||||
await tester.pumpWidget(_catalogGridHarness(withoutPeriod, key: const ValueKey('viewer-card')));
|
||||
expect(find.textContaining('37'), findsNothing);
|
||||
|
||||
final withPeriod = CatalogItem(
|
||||
source: CatalogSourceId.simkl,
|
||||
kind: MediaKind.show,
|
||||
title: 'Viewer Window',
|
||||
ids: const CatalogItemIds(simkl: 1),
|
||||
audience: const CatalogAudience(viewers: 37, viewersPeriod: CatalogAudiencePeriod.week),
|
||||
).toMediaItem();
|
||||
await tester.pumpWidget(_catalogGridHarness(withPeriod, key: const ValueKey('viewer-card')));
|
||||
|
||||
expect(find.text(t.explore.stats.viewersWeek(n: '37')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('HD availability and pending 4K request render as independent badges', (tester) async {
|
||||
final item = CatalogItem(
|
||||
source: CatalogSourceId.seerr,
|
||||
kind: MediaKind.movie,
|
||||
title: 'Server Movie',
|
||||
ids: const CatalogItemIds(tmdb: 1),
|
||||
serverState: const CatalogServerState(
|
||||
availability: CatalogAvailability.available,
|
||||
request4k: CatalogRequestState.pending,
|
||||
),
|
||||
).toMediaItem();
|
||||
|
||||
await tester.pumpWidget(_catalogGridHarness(item));
|
||||
|
||||
expect(find.text(t.explore.badge.available), findsOneWidget);
|
||||
expect(find.text(t.explore.badge.pendingApproval), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('catalog poster badges are capped at three by priority', (tester) async {
|
||||
final item = CatalogItem(
|
||||
source: CatalogSourceId.seerr,
|
||||
kind: MediaKind.show,
|
||||
title: 'Busy Show',
|
||||
ids: const CatalogItemIds(tmdb: 1),
|
||||
isAdult: true,
|
||||
serverState: const CatalogServerState(
|
||||
availability: CatalogAvailability.available,
|
||||
request4k: CatalogRequestState.pending,
|
||||
),
|
||||
nextEpisode: CatalogNextEpisode(airsAt: DateTime.now().add(const Duration(days: 1)), episode: 4),
|
||||
ranks: const [CatalogRank(rank: 2, scope: CatalogRankScope.popular)],
|
||||
audience: const CatalogAudience(watchingNow: 200),
|
||||
).toMediaItem();
|
||||
|
||||
await tester.pumpWidget(_catalogGridHarness(item));
|
||||
|
||||
final badgeTexts = find.descendant(of: find.byKey(const Key('catalog-badges')), matching: find.byType(Text));
|
||||
expect(badgeTexts, findsNWidgets(3));
|
||||
expect(find.text(t.explore.badge.available), findsOneWidget);
|
||||
expect(find.text(t.explore.badge.pendingApproval), findsOneWidget);
|
||||
expect(find.text(t.explore.badge.adult), findsOneWidget);
|
||||
expect(find.text(t.explore.badge.rankPopular(n: 2)), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('catalog item is rehydrated once across rebuilds of the same item identity', (tester) async {
|
||||
final catalog = CatalogItem(
|
||||
source: CatalogSourceId.trakt,
|
||||
kind: MediaKind.movie,
|
||||
title: 'Cached Catalog Movie',
|
||||
ids: const CatalogItemIds(tmdb: 1),
|
||||
ranks: const [CatalogRank(rank: 1, scope: CatalogRankScope.trending)],
|
||||
);
|
||||
final rawCatalog = _ReadCountingMap(catalog.toJson());
|
||||
final mediaItem = testMediaItem(
|
||||
id: 'catalog:cached',
|
||||
kind: MediaKind.movie,
|
||||
title: catalog.title,
|
||||
raw: {CatalogItem.rawKey: rawCatalog},
|
||||
);
|
||||
const cardKey = ValueKey('cached-catalog-card');
|
||||
|
||||
await tester.pumpWidget(_catalogGridHarness(mediaItem, key: cardKey));
|
||||
final readsAfterFirstBuild = rawCatalog.reads;
|
||||
expect(readsAfterFirstBuild, greaterThan(0));
|
||||
|
||||
await tester.pumpWidget(_catalogGridHarness(mediaItem, key: cardKey));
|
||||
expect(rawCatalog.reads, readsAfterFirstBuild);
|
||||
});
|
||||
|
||||
testWidgets('catalog poster selection covers the card width at device pixel ratio', (tester) async {
|
||||
tester.view.devicePixelRatio = 2;
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final item = CatalogItem(
|
||||
source: CatalogSourceId.seerr,
|
||||
kind: MediaKind.movie,
|
||||
title: 'Sharp Poster',
|
||||
ids: const CatalogItemIds(tmdb: 1),
|
||||
posterUrl: 'https://example.com/default.jpg',
|
||||
posterVariants: const {200: 'https://example.com/200.jpg', 500: 'https://example.com/500.jpg'},
|
||||
).toMediaItem();
|
||||
|
||||
await tester.pumpWidget(_catalogGridHarness(item, width: 150));
|
||||
|
||||
final image = tester.widget<OptimizedMediaImage>(find.byType(OptimizedMediaImage));
|
||||
expect(image.imagePath, 'https://example.com/500.jpg');
|
||||
});
|
||||
|
||||
testWidgets('recommendation badge prefers a user count and falls back to viewer percentage', (tester) async {
|
||||
final withCount = CatalogItem(
|
||||
source: CatalogSourceId.simkl,
|
||||
kind: MediaKind.movie,
|
||||
title: 'Counted Recommendation',
|
||||
ids: const CatalogItemIds(simkl: 1),
|
||||
recommendationCount: 19,
|
||||
recommendationPercent: 0.42,
|
||||
).toMediaItem();
|
||||
|
||||
await tester.pumpWidget(_catalogGridHarness(withCount, key: const ValueKey('recommendation-card')));
|
||||
expect(find.text(t.explore.detail.recommendedByUsers(n: 19)), findsOneWidget);
|
||||
expect(find.text(t.explore.detail.recommendedByPercent(percent: '42%')), findsNothing);
|
||||
|
||||
final withPercent = CatalogItem(
|
||||
source: CatalogSourceId.simkl,
|
||||
kind: MediaKind.movie,
|
||||
title: 'Percentage Recommendation',
|
||||
ids: const CatalogItemIds(simkl: 2),
|
||||
recommendationPercent: 0.42,
|
||||
).toMediaItem();
|
||||
await tester.pumpWidget(_catalogGridHarness(withPercent, key: const ValueKey('recommendation-card')));
|
||||
|
||||
expect(find.text(t.explore.detail.recommendedByPercent(percent: '42%')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('full bleed flag does not hide list media card text', (tester) async {
|
||||
final item = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'List Movie');
|
||||
|
||||
@@ -130,6 +352,37 @@ void main() {
|
||||
expect(find.text('List Movie'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('catalog list metadata keeps certification, votes and genres', (tester) async {
|
||||
// The counterpart to the grid contract above: search uses the list card,
|
||||
// which is wide enough for the full composition and must not be trimmed
|
||||
// by the grid's compact mode.
|
||||
final item = CatalogItem(
|
||||
source: CatalogSourceId.trakt,
|
||||
kind: MediaKind.movie,
|
||||
title: 'Catalog Movie',
|
||||
year: 2024,
|
||||
runtimeMinutes: 125,
|
||||
rating: 8.6,
|
||||
votes: 12345,
|
||||
genres: const ['Drama', 'Mystery'],
|
||||
certification: 'PG-13',
|
||||
ids: const CatalogItemIds(tmdb: 1),
|
||||
).toMediaItem();
|
||||
|
||||
await tester.pumpWidget(
|
||||
_TestApp(
|
||||
child: SizedBox(width: 420, height: 160, child: MediaCard(item: item, forceListMode: true, isOffline: true)),
|
||||
),
|
||||
);
|
||||
|
||||
final metadata = tester.widget<Text>(
|
||||
find.byWidgetPredicate((widget) => widget is Text && (widget.data?.contains('8.6★') ?? false)),
|
||||
);
|
||||
expect(metadata.data, startsWith('PG-13 • 2024 • 2h 5m'));
|
||||
expect(metadata.data, contains('8.6★ (12.3K)'));
|
||||
expect(metadata.data, contains('Drama, Mystery'));
|
||||
});
|
||||
|
||||
testWidgets('full bleed focusable media card lifts the glow into an overlay above siblings', (tester) async {
|
||||
TvDetectionService.debugSetAppleTVOverride(true);
|
||||
final focusNode = FocusNode(debugLabel: 'full_bleed_card');
|
||||
@@ -529,6 +782,51 @@ Widget _fullCardHarness({required FocusNode focusNode, required bool fullBleed})
|
||||
);
|
||||
}
|
||||
|
||||
Widget _catalogGridHarness(MediaItem item, {Key? key, double width = 220}) {
|
||||
return _TestApp(
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
height: 330,
|
||||
child: MediaCard(
|
||||
key: key,
|
||||
item: item,
|
||||
width: width,
|
||||
height: 280,
|
||||
forceGridMode: true,
|
||||
isOffline: true,
|
||||
onTap: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _ReadCountingMap extends MapBase<String, Object?> {
|
||||
final Map<String, Object?> _values;
|
||||
int reads = 0;
|
||||
|
||||
_ReadCountingMap(this._values);
|
||||
|
||||
@override
|
||||
Object? operator [](Object? key) {
|
||||
reads++;
|
||||
return _values[key];
|
||||
}
|
||||
|
||||
@override
|
||||
void operator []=(String key, Object? value) {
|
||||
_values[key] = value;
|
||||
}
|
||||
|
||||
@override
|
||||
void clear() => _values.clear();
|
||||
|
||||
@override
|
||||
Iterable<String> get keys => _values.keys;
|
||||
|
||||
@override
|
||||
Object? remove(Object? key) => _values.remove(key);
|
||||
}
|
||||
|
||||
class _TestApp extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user