feat(ratings): show every rating source the server already sent

Plezy rendered exactly one score per item. MediaRatingBadge._ratingDataFor
took `rating` and fell back to `audienceRating` only when it was null, so a
Plex movie carrying four attributed scores surfaced one, and which one was
whatever the server happened to put in the scalar slot. #1755 asked for a
setting to choose the source; showing all of them answers it without one.

The data was already on the wire and being thrown away. `/library/metadata/
{id}` returns a `Rating[]` child array — IMDb, both Rotten Tomatoes panels,
TMDB — with no extra query parameter, but PlexMetadataDto declared no field
for it, so json_serializable dropped the key. The identical parse already
existed in plex_catalog_source for the Explore tab and had simply never been
wired to library items.

Model the scores as a list rather than widening the scalar pair. The neutral
MediaItem gains `ratings`; PlexMediaItem loses audienceRating, ratingImage
and audienceRatingImage, which the list subsumes — Plex sends those images
on listings too, so the same field covers both response shapes and no caller
narrows to a backend type to read a score any more. CatalogRatingSource is
promoted to lib/media as MediaRatingSource instead of growing a second
near-identical type beside it, and plex_catalog_source's _ratingsFor becomes
the shared plexRatingSources so one implementation serves both paths. There
is no persistence to migrate: MediaItem.toJson has no production caller, the
offline path re-parses raw Plex JSON through the same mapper, and Plex's
audienceRating sort is server-supplied data, not a model read.

Cards and the dashboard still show fewer scores than detail screens, and
that part is a real Plex limit rather than a shortcut. Section listings send
only the scalar pair; includeRatings, includeElements=Rating,
includeFields=Rating, includeChildren and includeExtras were each probed
against a live server and none surfaced the array, while includeGuids=1
demonstrably does add Guid[] — the probe works, the parameter does not
exist. Hydrating every card would be one request per row, so listings render
whatever their own response carried, which is one or two attributed scores
rather than the single one they showed before.

Jellyfin has no per-source array at all: the server collapses whatever its
fetchers found into CommunityRating and CriticRating. CommunityRating's
provenance is unknowable from the DTO — TMDB vote_average, IMDb via OMDb or
a local NFO, last writer wins — so it stays the generic `audience` source
with no brand mark. CriticRating is the Rotten Tomatoes Tomatometer as a
0-100 percent and is divided by ten explicitly rather than folded by
magnitude, because a Tomatometer of 9 means 9% and range-sniffing would have
promoted a rotten score to fresh. Photo rows are skipped, since Jellyfin
reuses CommunityRating for the EXIF 0-5 star.

The badges share one slot on every surface. On the phone hero the scores go
in a single pill because that chip row is a height-clipped Wrap and a chip
per source would push year, certification and runtime out of the visible
band on short heroes; on the TV detail line and the dashboard spotlight the
group occupies the one metadata slot so bullet separators do not multiply.
The group announces itself as a single semantics node naming each source,
because a bare row of four percentages tells a screen reader nothing about
which score is which. rating_utils drops parseRatingImage and
isRottenTomatoes — the URI vocabulary now lives only in the Plex mapper —
and the source-key resolver and label map, previously private to the Explore
detail screen, become the shared pair both screens use. The label strings
move from explore.ratingSource to common.ratingSource accordingly, which
costs no translations because every non-English value was empty; running
clean_translations also scaffolds startup.quitPlezy and
startup.restartRequiredBody, which were already drifted.

Verified against the live server the probes came from: a detail response now
yields TMDB 83%, IMDb 8.3 and Rotten Tomatoes audience 96% through the
production mapper and badge resolver, and the listing response for the same
title yields TMDB 83% alone. Both payloads are pinned verbatim as fixtures.
Coverage adds mapper ordering, dedupe against the array's repeat of the
scalar, out-of-range rejection, the Jellyfin scale and photo guard, the
CatalogItem conversion that feeds Explore's dashboard hubs, and the three
render surfaces including the semantics announcement.

close #1755
This commit is contained in:
edde746
2026-08-02 03:59:56 +02:00
parent 395798f28e
commit 2cb2c3eb95
50 changed files with 1546 additions and 1068 deletions
+14 -12
View File
@@ -3,6 +3,7 @@ import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_part.dart';
import 'package:plezy/media/media_rating.dart';
import 'package:plezy/media/media_role.dart';
import 'package:plezy/media/media_version.dart';
import '../test_helpers/media_items.dart';
@@ -476,9 +477,10 @@ void main() {
kind: MediaKind.movie,
title: 'Old',
editionTitle: 'Director Cut',
audienceRating: 8.9,
ratingImage: 'rottentomatoes://rating',
audienceRatingImage: 'rottentomatoes://audience',
ratings: [
MediaRatingSource(source: 'rottenTomatoesCritic', value: 9.4),
MediaRatingSource(source: 'imdb', value: 8.9, votes: 1200),
],
subtitleLanguage: 'eng',
subtitleMode: 1,
trailerKey: '/library/metadata/1',
@@ -492,9 +494,8 @@ void main() {
expect(copy.title, 'New');
expect(copy.editionTitle, 'Director Cut');
expect(copy.audienceRating, 8.9);
expect(copy.ratingImage, 'rottentomatoes://rating');
expect(copy.audienceRatingImage, 'rottentomatoes://audience');
expect(copy.ratings?.map((rating) => rating.source), ['rottenTomatoesCritic', 'imdb']);
expect(copy.ratings?.last.votes, 1200);
expect(copy.subtitleLanguage, 'eng');
expect(copy.subtitleMode, 1);
expect(copy.trailerKey, '/library/metadata/1');
@@ -536,9 +537,10 @@ void main() {
kind: MediaKind.movie,
title: 'Movie',
editionTitle: 'Theatrical',
audienceRating: 9.1,
ratingImage: 'rottentomatoes://rating',
audienceRatingImage: 'rottentomatoes://audience',
ratings: [
MediaRatingSource(source: 'rottenTomatoesCritic', value: 9.1),
MediaRatingSource(source: 'imdb', value: 8.4, votes: 250858),
],
genres: ['Drama'],
roles: [MediaRole(id: '1', tag: 'Actor', role: 'Lead', thumbPath: '/photo')],
mediaVersions: [
@@ -566,9 +568,9 @@ void main() {
expect(decoded, isA<PlexMediaItem>());
final plex = decoded as PlexMediaItem;
expect(plex.editionTitle, 'Theatrical');
expect(plex.audienceRating, 9.1);
expect(plex.ratingImage, 'rottentomatoes://rating');
expect(plex.audienceRatingImage, 'rottentomatoes://audience');
expect(plex.ratings?.map((rating) => rating.source), ['rottenTomatoesCritic', 'imdb']);
expect(plex.ratings?.first.value, 9.1);
expect(plex.ratings?.last.votes, 250858);
expect(plex.genres, ['Drama']);
expect(plex.roles?.single.tag, 'Actor');
expect(plex.mediaVersions?.single.parts.single.streamPath, '/stream');
+23
View File
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_rating.dart';
import 'package:plezy/models/catalog/catalog_item.dart';
import 'package:plezy/models/catalog/catalog_metadata.dart';
import 'package:plezy/utils/external_ids.dart';
@@ -111,6 +112,28 @@ void main() {
expect(decoded.ids.entryKey, 'mal:63832');
});
test('carries every attributed rating onto the synthesized MediaItem', () {
// Explore's dashboard hubs render through this conversion, so a rating
// dropped here means the TV spotlight silently falls back to one score.
const rated = CatalogItem(
source: CatalogSourceId.simkl,
kind: MediaKind.movie,
title: 'Rated',
ids: CatalogItemIds(tmdb: 603),
rating: 8.1,
ratings: [
MediaRatingSource(source: 'simkl', value: 8.1, votes: 11),
MediaRatingSource(source: 'imdb', value: 7.9, votes: 12),
],
);
final rendered = rated.toMediaItem();
expect(rendered.rating, 8.1);
expect(rendered.ratings?.map((rating) => rating.source), ['simkl', 'imdb']);
expect(rendered.ratings?.last.votes, 12);
});
test('survives an encode/decode cycle that erases the static map types', () {
// Persisted/transport JSON comes back as Map<String, dynamic> and
// List<dynamic>; the nested season object must not depend on its
@@ -8,6 +8,7 @@ import 'package:flutter_svg/flutter_svg.dart';
import 'package:plezy/focus/focusable_action_bar.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_rating.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';
@@ -328,10 +329,10 @@ void main() {
title: 'Rated Movie',
ids: CatalogItemIds(tmdb: 11),
ratings: [
CatalogRatingSource(source: 'simkl', value: 8.1, votes: 11),
CatalogRatingSource(source: 'mal', value: 8.3, votes: 13),
CatalogRatingSource(source: 'critic', value: 7.2, votes: 14),
CatalogRatingSource(source: 'audience', value: 8.8, votes: 15),
MediaRatingSource(source: 'simkl', value: 8.1, votes: 11),
MediaRatingSource(source: 'mal', value: 8.3, votes: 13),
MediaRatingSource(source: 'critic', value: 7.2, votes: 14),
MediaRatingSource(source: 'audience', value: 8.8, votes: 15),
],
);
final source = _FakeCatalogSource(detail: const CatalogDetail(item: item));
@@ -352,10 +353,10 @@ void main() {
title: 'Attributed Movie',
ids: CatalogItemIds(tmdb: 21),
ratings: [
CatalogRatingSource(source: 'rottenTomatoesCritic', value: 8.4),
CatalogRatingSource(source: 'rottenTomatoesAudience', value: 4.1),
CatalogRatingSource(source: 'imdb', value: 7.9, votes: 12),
CatalogRatingSource(source: 'tmdb', value: 7.5),
MediaRatingSource(source: 'rottenTomatoesCritic', value: 8.4),
MediaRatingSource(source: 'rottenTomatoesAudience', value: 4.1),
MediaRatingSource(source: 'imdb', value: 7.9, votes: 12),
MediaRatingSource(source: 'tmdb', value: 7.5),
],
);
final source = _FakeCatalogSource(detail: const CatalogDetail(item: item));
@@ -381,7 +382,7 @@ void main() {
expect(find.text('41%'), findsOneWidget);
expect(find.text('7.9 (12 votes)'), findsOneWidget);
expect(find.text('75%'), findsOneWidget);
expect(find.text('${t.explore.ratingSource.rottenTomatoesCritic} 8.4'), findsNothing);
expect(find.text('${t.common.ratingSource.rottenTomatoesCritic} 8.4'), findsNothing);
});
testWidgets('seasonal rank keeps its season window instead of claiming all-time rank', (tester) async {
+15 -8
View File
@@ -16,6 +16,7 @@ import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_hub.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_rating.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/media/server_capabilities.dart';
import 'package:plezy/providers/download_provider.dart';
@@ -175,7 +176,7 @@ void main() {
expect(tester.widget<AnimatedOpacity>(revealGate).opacity, 1);
});
testWidgets('TV detail shows Rotten Tomatoes rating badge in metadata line', (tester) async {
testWidgets('TV detail metadata line shows every rating source the item carries', (tester) async {
await SettingsService.getInstance();
tester.view.physicalSize = const Size(1280, 720);
tester.view.devicePixelRatio = 1;
@@ -185,10 +186,14 @@ void main() {
const movie = MediaItem.plex(
id: 'movie_1',
kind: MediaKind.movie,
title: 'Rotten Tomatoes Movie',
summary: 'The TV detail metadata line should use the rating source badge.',
title: 'Multi Source Movie',
summary: 'The TV detail metadata line should badge each attributed score.',
rating: 6.2,
ratingImage: 'rottentomatoes://image.rating.ripe',
ratings: [
MediaRatingSource(source: 'rottenTomatoesCritic', value: 6.2),
MediaRatingSource(source: 'rottenTomatoesAudience', value: 8.7),
MediaRatingSource(source: 'imdb', value: 7.4),
],
);
await tester.pumpWidget(
@@ -205,24 +210,26 @@ void main() {
await tester.pump(const Duration(milliseconds: 200));
expect(find.text('62%'), findsOneWidget);
expect(find.byType(SvgPicture), findsOneWidget);
expect(find.text('87%'), findsOneWidget);
expect(find.text('7.4'), findsOneWidget);
expect(find.byType(SvgPicture), findsNWidgets(3));
expect(find.textContaining('★ 6.2', findRichText: true), findsNothing);
});
testWidgets('TV detail falls back to Rotten Tomatoes audience rating in metadata line', (tester) async {
testWidgets('TV detail metadata line still renders a single available rating', (tester) async {
await SettingsService.getInstance();
tester.view.physicalSize = const Size(1280, 720);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
// What a hub listing yields when the server sent only the audience scalar.
const movie = MediaItem.plex(
id: 'movie_1',
kind: MediaKind.movie,
title: 'Audience Rating Movie',
summary: 'The TV detail metadata line should use the available audience source badge.',
audienceRating: 8.7,
audienceRatingImage: 'rottentomatoes://image.rating.upright',
ratings: [MediaRatingSource(source: 'rottenTomatoesAudience', value: 8.7)],
);
await tester.pumpWidget(
@@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_rating.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';
@@ -747,7 +748,7 @@ void main() {
expect(
detail.item.ratings,
contains(
isA<CatalogRatingSource>()
isA<MediaRatingSource>()
.having((rating) => rating.source, 'source', 'imdb')
.having((rating) => rating.value, 'value', 8.5)
.having((rating) => rating.votes, 'votes', 250858),
+38
View File
@@ -23,6 +23,7 @@ void main() {
'PremiereDate': '2010-07-16T00:00:00.0000000Z',
'OfficialRating': 'PG-13',
'CommunityRating': 8.8,
'CriticRating': 88,
'Genres': ['Action', 'Sci-Fi'],
'People': [
{'Type': 'Actor', 'Name': 'Leo', 'Id': 'p1', 'PrimaryImageTag': 'tag1', 'Role': 'Cobb'},
@@ -63,6 +64,9 @@ void main() {
expect(item.contentRating, 'PG-13');
expect(item.studio, 'Warner Bros');
expect(item.rating, 8.8);
expect(item.ratings?.map((rating) => rating.source).toList(), ['audience', 'rottenTomatoesCritic']);
// CriticRating is the 0-100 Tomatometer; the neutral scale is 0-10.
expect(item.ratings?.map((rating) => rating.value).toList(), [8.8, 8.8]);
expect(item.genres, ['Action', 'Sci-Fi']);
expect(item.directors, ['Christopher Nolan']);
expect(item.countries, ['United States']);
@@ -92,6 +96,40 @@ void main() {
expect(item.serverName, 'Home');
});
test('divides the Tomatometer rather than range-sniffing it', () {
// A CriticRating of 9 means 9%, not 9.0/10 — folding by magnitude would
// silently promote a rotten score to fresh.
final item = JellyfinMappers.mediaItem(
{'Id': 'movie-rotten', 'Type': 'Movie', 'CriticRating': 9},
serverId: ServerId(_serverId),
absolutizer: null,
)!;
expect(item.ratings?.single.source, 'rottenTomatoesCritic');
expect(item.ratings?.single.value, 0.9);
});
test('omits ratings for photos, whose CommunityRating is an EXIF 0-5 star', () {
final item = JellyfinMappers.mediaItem(
{'Id': 'photo-1', 'Type': 'Photo', 'CommunityRating': 4},
serverId: ServerId(_serverId),
absolutizer: null,
)!;
expect(item.kind, MediaKind.photo);
expect(item.ratings, isNull);
});
test('reports no ratings when the server sent neither score', () {
final item = JellyfinMappers.mediaItem(
{'Id': 'movie-bare', 'Type': 'Movie'},
serverId: ServerId(_serverId),
absolutizer: null,
)!;
expect(item.ratings, isNull);
});
test('preserves backdrop indices, deduplicates tags, and absolutizes every valid path', () {
const absolutizer = JellyfinImageAbsolutizer(baseUrl: 'https://jellyfin.example', accessToken: 'secret');
final item = JellyfinMappers.mediaItem(
+63 -3
View File
@@ -210,6 +210,14 @@ void main() {
'librarySectionTitle': 'Movies',
'ratingImage': 'rottentomatoes://image.rating.ripe',
'audienceRatingImage': 'rottentomatoes://image.rating.upright',
'imdbRatingCount': 250858,
// The detail endpoint adds the multi-source array; the TMDB entry
// duplicates the audienceRating scalar and must not appear twice.
'Rating': [
{'image': 'imdb://image.rating', 'type': 'audience', 'value': 8.4},
{'image': 'rottentomatoes://image.rating.ripe', 'type': 'critic', 'value': 8.8},
{'image': 'themoviedb://image.rating', 'type': 'audience', 'value': 9.1},
],
'Genre': [
{'tag': 'Action'},
{'tag': 'Sci-Fi'},
@@ -248,10 +256,20 @@ void main() {
expect(item.originallyAvailableAt, '2010-07-16');
expect(item.contentRating, 'PG-13');
expect(item.rating, 8.8);
expect(item.audienceRating, 9.1);
expect(item.userRating, 9.5);
expect(item.ratingImage, 'rottentomatoes://image.rating.ripe');
expect(item.audienceRatingImage, 'rottentomatoes://image.rating.upright');
// Headline scalar first, then the array, deduped on (source, value):
// the RT critic entry repeats `rating`/`ratingImage`, and the TMDB entry
// is a distinct source so it survives alongside the RT audience scalar.
expect(item.ratings?.map((rating) => rating.source).toList(), [
'rottenTomatoesCritic',
'rottenTomatoesAudience',
'imdb',
'tmdb',
]);
expect(item.ratings?.map((rating) => rating.value).toList(), [8.8, 9.1, 8.4, 9.1]);
// Vote counts are IMDb-only; Plex reports none for the other sources.
expect(item.ratings?.map((rating) => rating.votes).toList(), [null, null, 250858, null]);
// Plex stores all temporal fields in milliseconds — pass-through.
expect(item.durationMs, 8880000);
@@ -290,6 +308,48 @@ void main() {
expect(item.serverName, _serverName);
});
test('a section listing yields the scalar pair alone', () {
// Plex sends no `Rating[]` outside /library/metadata/{id}, and no query
// parameter adds it, so listing-backed cards get one or two scores.
final item = PlexMappers.mediaItemFromJson({
'ratingKey': 'listing-1',
'type': 'movie',
'rating': 9.4,
'ratingImage': 'rottentomatoes://image.rating.ripe',
'audienceRating': 8.3,
'audienceRatingImage': 'themoviedb://image.rating',
}, serverId: ServerId(_serverId));
expect(item.ratings?.map((rating) => rating.source).toList(), ['rottenTomatoesCritic', 'tmdb']);
expect(item.ratings?.map((rating) => rating.value).toList(), [9.4, 8.3]);
});
test('an unrated item reports no ratings rather than an empty list', () {
final item = PlexMappers.mediaItemFromJson({
'ratingKey': 'unrated-1',
'type': 'movie',
}, serverId: ServerId(_serverId));
expect(item.rating, isNull);
expect(item.ratings, isNull);
});
test('scores outside the reportable range are dropped, not folded', () {
final item = PlexMappers.mediaItemFromJson({
'ratingKey': 'noisy-1',
'type': 'movie',
'rating': 140,
'audienceRating': -1,
'Rating': [
{'image': 'imdb://image.rating', 'type': 'audience', 'value': '8.5'},
],
}, serverId: ServerId(_serverId));
// Only the IMDb entry survives, and its string value still parses.
expect(item.ratings?.single.source, 'imdb');
expect(item.ratings?.single.value, 8.5);
});
test('normalizes aggregate watch counts off leaf items', () {
final item = PlexMappers.mediaItemFromJson({
'ratingKey': 'leaf-with-counts',
@@ -0,0 +1,50 @@
// Live-payload regression: the exact `/library/metadata/{id}` and
// `/library/sections/{id}/all` shapes a real Plex Media Server returned
// (PMS 1.x, `tv.plex.agents.series`), captured verbatim. Plex sends the
// multi-source `Rating[]` array only on the metadata endpoint — probing
// `includeRatings`, `includeElements=Rating`, and `includeFields=Rating`
// against the listing endpoint all came back without it, while
// `includeGuids=1` demonstrably does add `Guid[]`. That asymmetry is why the
// dashboard shows fewer scores than the detail screen, and this suite pins it.
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/services/plex_mappers.dart';
const _serverId = 'plex-machine-1';
void main() {
group('live Plex payloads', () {
test('detail response surfaces IMDb alongside Rotten Tomatoes and TMDB', () {
final item = PlexMappers.mediaItemFromJson({
'ratingKey': '2254',
'type': 'show',
'title': '【OSHI NO KO】',
'audienceRating': 8.3,
'audienceRatingImage': 'themoviedb://image.rating',
'Rating': [
{'image': 'imdb://image.rating', 'value': 8.3, 'type': 'audience'},
{'image': 'rottentomatoes://image.rating.upright', 'value': 9.6, 'type': 'audience'},
{'image': 'themoviedb://image.rating', 'value': 8.3, 'type': 'audience'},
],
}, serverId: ServerId(_serverId));
// The headline scalar leads; the array's TMDB entry repeats it and is
// deduped; IMDb — the score issue #1755 asked for — survives.
expect(item.ratings?.map((rating) => rating.source).toList(), ['tmdb', 'imdb', 'rottenTomatoesAudience']);
expect(item.ratings?.map((rating) => rating.value).toList(), [8.3, 8.3, 9.6]);
});
test('listing response carries only the scalar the server chose', () {
final item = PlexMappers.mediaItemFromJson({
'ratingKey': '2254',
'type': 'show',
'title': '【OSHI NO KO】',
'audienceRating': 8.3,
'audienceRatingImage': 'themoviedb://image.rating',
}, serverId: ServerId(_serverId));
expect(item.ratings?.single.source, 'tmdb');
expect(item.ratings?.single.value, 8.3);
});
});
}
+57 -54
View File
@@ -1,88 +1,91 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/utils/rating_utils.dart';
void main() {
group('parseRatingImage - null/missing', () {
test('returns null when imageUri is null', () {
expect(parseRatingImage(null, 7.5), isNull);
});
setUpAll(() => LocaleSettings.setLocaleRaw('en'));
test('returns null when value is null', () {
expect(parseRatingImage('imdb://title/tt123', null), isNull);
});
test('returns null for unknown scheme', () {
expect(parseRatingImage('unknown://foo', 5.0), isNull);
});
});
group('parseRatingImage - Rotten Tomatoes', () {
test('ripe maps to rt_fresh with percent', () {
final info = parseRatingImage('rottentomatoes://image.rating.ripe', 7.5);
expect(info, isNotNull);
group('ratingInfoForSource - Rotten Tomatoes', () {
test('critic at or above the 60% tomatometer is fresh', () {
final info = ratingInfoForSource('rottenTomatoesCritic', 6.0);
expect(info!.assetPath, 'assets/rating_icons/rt_fresh.svg');
expect(info.formattedValue, '75%');
expect(info.formattedValue, '60%');
});
test('rotten maps to rt_rotten', () {
final info = parseRatingImage('rottentomatoes://image.rating.rotten', 3.2);
expect(info, isNotNull);
test('critic below the tomatometer is rotten', () {
final info = ratingInfoForSource('rottenTomatoesCritic', 5.9);
expect(info!.assetPath, 'assets/rating_icons/rt_rotten.svg');
expect(info.formattedValue, '32%');
expect(info.formattedValue, '59%');
});
test('upright maps to rt_upright', () {
final info = parseRatingImage('rottentomatoes://image.rating.upright', 8.8);
expect(info!.assetPath, 'assets/rating_icons/rt_upright.svg');
expect(info.formattedValue, '88%');
test('audience uses the popcorn pair on the same threshold', () {
expect(ratingInfoForSource('rottenTomatoesAudience', 6.0)!.assetPath, 'assets/rating_icons/rt_upright.svg');
expect(ratingInfoForSource('rottenTomatoesAudience', 5.9)!.assetPath, 'assets/rating_icons/rt_spilled.svg');
});
test('spilled maps to rt_spilled', () {
final info = parseRatingImage('rottentomatoes://image.rating.spilled', 2.0);
expect(info!.assetPath, 'assets/rating_icons/rt_spilled.svg');
expect(info.formattedValue, '20%');
test('the unsplit key follows the critic pair', () {
expect(ratingInfoForSource('rottenTomatoes', 9.2)!.assetPath, 'assets/rating_icons/rt_fresh.svg');
});
test('unknown RT suffix returns null', () {
expect(parseRatingImage('rottentomatoes://image.rating.green', 5.0), isNull);
});
test('percent rounds to whole number', () {
final info = parseRatingImage('rottentomatoes://image.rating.ripe', 7.57);
expect(info!.formattedValue, '76%');
test('percent rounds to a whole number', () {
expect(ratingInfoForSource('rottenTomatoesCritic', 7.57)!.formattedValue, '76%');
});
});
group('parseRatingImage - IMDb', () {
test('formats with one decimal', () {
final info = parseRatingImage('imdb://title/tt123', 7.5);
group('ratingInfoForSource - branded scales', () {
test('IMDb keeps its 0-10 decimal', () {
final info = ratingInfoForSource('imdb', 7.5);
expect(info!.assetPath, 'assets/rating_icons/imdb.svg');
expect(info.formattedValue, '7.5');
});
});
group('parseRatingImage - TMDB', () {
test('converts value*10 to percent', () {
final info = parseRatingImage('themoviedb://foo', 6.8);
test('TMDB renders as a percentage', () {
final info = ratingInfoForSource('tmdb', 6.8);
expect(info!.assetPath, 'assets/rating_icons/tmdb.svg');
expect(info.formattedValue, '68%');
});
});
group('isRottenTomatoes', () {
test('matches rottentomatoes:// scheme', () {
expect(isRottenTomatoes('rottentomatoes://image.rating.ripe'), isTrue);
expect(isRottenTomatoes('rottentomatoes://anything'), isTrue);
group('ratingInfoForSource - unbranded sources', () {
test('sources without a logo get no badge so they stay label-only', () {
for (final source in ['critic', 'audience', 'simkl', 'mal', 'anilist', 'trakt']) {
expect(ratingInfoForSource(source, 8.0), isNull, reason: source);
}
});
test('false for null', () {
expect(isRottenTomatoes(null), isFalse);
test('an unknown key gets no badge', () {
expect(ratingInfoForSource('letterboxd', 8.0), isNull);
expect(ratingInfoForSource('', 8.0), isNull);
});
});
group('ratingSourceLabel', () {
test('names every source the mappers can emit', () {
const sources = [
'critic',
'audience',
'imdb',
'tmdb',
'rottenTomatoes',
'rottenTomatoesCritic',
'rottenTomatoesAudience',
'simkl',
'mal',
'anilist',
'trakt',
];
for (final source in sources) {
expect(ratingSourceLabel(source), isNotEmpty, reason: source);
}
});
test('false for other schemes', () {
expect(isRottenTomatoes('imdb://title'), isFalse);
expect(isRottenTomatoes('themoviedb://foo'), isFalse);
expect(isRottenTomatoes(''), isFalse);
test('keeps the Rotten Tomatoes panels distinguishable', () {
expect(ratingSourceLabel('rottenTomatoesCritic'), isNot(ratingSourceLabel('rottenTomatoesAudience')));
});
test('returns null for an unknown key so it can be dropped rather than shown raw', () {
expect(ratingSourceLabel('letterboxd'), isNull);
expect(ratingSourceLabel(''), isNull);
});
});
}
+134
View File
@@ -0,0 +1,134 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.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/media/media_rating.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/tv_spotlight_background.dart';
import '../test_helpers/prefs.dart';
/// The TV dashboard spotlight (Discover, Explore, and the library recommended
/// tab all render through [TvSpotlightBackground]) shows every score the hub
/// listing already returned. Listings carry the scalar rating pair, so this is
/// normally one or two entries — never a reason to re-fetch an item.
Future<void> _pumpSpotlight(WidgetTester tester, MediaItem item) async {
await SettingsService.getInstance();
tester.view.physicalSize = const Size(1920, 1080);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
TranslationProvider(
child: MaterialApp(
home: Scaffold(
// TvSpotlightScaffold fills the screen with the background; a loose
// Scaffold body would leave its bottom-anchored info block unbounded.
body: SizedBox.expand(
child: TvSpotlightBackground(
item: item,
client: null,
allowNetwork: false,
showPrimaryAction: false,
compact: true,
contentTop: 80,
contentBottom: 200,
),
),
),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
TvDetectionService.debugSetAppleTVOverride(true);
LocaleSettings.setLocaleSync(AppLocale.en);
});
tearDown(() => TvDetectionService.debugSetAppleTVOverride(null));
testWidgets('dashboard spotlight badges every rating the listing carried', (tester) async {
await _pumpSpotlight(
tester,
const MediaItem.plex(
id: 'movie_1',
kind: MediaKind.movie,
title: 'Spotlight Movie',
rating: 9.2,
ratings: [
MediaRatingSource(source: 'rottenTomatoesCritic', value: 9.2),
MediaRatingSource(source: 'rottenTomatoesAudience', value: 8.5),
],
),
);
expect(find.text('92%'), findsOneWidget);
expect(find.text('85%'), findsOneWidget);
expect(find.byType(SvgPicture), findsNWidgets(2));
});
testWidgets('dashboard spotlight still shows one badge for a single-score listing', (tester) async {
await _pumpSpotlight(
tester,
const MediaItem.jellyfin(
id: 'movie_2',
kind: MediaKind.movie,
title: 'Community Only',
rating: 8.3,
ratings: [MediaRatingSource(source: 'audience', value: 8.3)],
),
);
// No brand logo exists for an unattributed community score, so it keeps
// the generic icon and the neutral 0-10 rendering.
expect(find.text('8.3'), findsOneWidget);
expect(find.byType(SvgPicture), findsNothing);
});
testWidgets('dashboard spotlight omits the rating slot when the item has no score', (tester) async {
await _pumpSpotlight(
tester,
const MediaItem.plex(id: 'movie_3', kind: MediaKind.movie, title: 'Unrated', year: 2024),
);
expect(find.text('2024'), findsOneWidget);
expect(find.byType(SvgPicture), findsNothing);
});
testWidgets('dashboard spotlight announces each rating with its source name', (tester) async {
final semantics = tester.ensureSemantics();
await _pumpSpotlight(
tester,
const MediaItem.plex(
id: 'movie_4',
kind: MediaKind.movie,
title: 'Announced Movie',
ratings: [
MediaRatingSource(source: 'rottenTomatoesCritic', value: 9.2),
MediaRatingSource(source: 'imdb', value: 7.4),
],
),
);
// Without the group's own label a reader would hear "92%, 7.4" with no
// way to tell which score belongs to which source.
expect(
find.bySemanticsLabel('${t.common.ratingSource.rottenTomatoesCritic} 92%, ${t.common.ratingSource.imdb} 7.4'),
findsOneWidget,
);
semantics.dispose();
});
}