Files
plezy/test/profiles/profile_avatar_test.dart
edde746 5d83d4833c fix(images): stop decoding artwork nothing ever displays
The image pipeline funnels through `MediaImageHelper.serverArtworkProvider`, which
sizes the request, bounds the decode and namespaces the disk key. Five places
escaped that funnel, and on the reduced tier they compete for a 64 MiB image cache.

- Jellyfin trickplay prefetch built a bare `CachedNetworkImageProvider` and called
  `resolve()` on it, fully decoding a sprite sheet -- roughly 22 MiB of RGBA for a
  10x10 grid of 320-wide tiles. None of it was ever painted: the render path wraps
  the same provider in `ResizeImage`, so it decodes again under a different cache
  key. The comment already said the intent was only to warm the disk cache, so it
  now does exactly that through the cache manager and resolves no image stream.
- `CyclingMediaBackdrop` re-fetched and re-decoded a full-screen backdrop every
  10 s forever, with no `DevicePerformance` term in `_canRotate` even though its
  own fade duration is tier-gated. At the reduced tier's 1280x720 art cap that is
  3.52 MiB per rotation, ~7 MiB live across the crossfade, churning while the
  viewer sits still and evicting the rail posters behind it. The fade is already
  zero-duration there, so the rotation bought variety and nothing else.
- Catalog detail passed `item.posterUrl`/`backdropUrl` with no client, which
  `getOptimizedImageUrl` deliberately returns unmodified -- so a 40x60 slot pulled
  a 600x900 TMDB asset and a 320-tall backdrop pulled 1920x800. `posterFor`/
  `backdropFor` already existed for this; the sites now use them.
- The season strip, the Live TV programme sheet and the profile avatar built
  `CachedNetworkImage` directly, keeping the sized request but losing the
  `plex_optimized_<sha1>` disk key, so the same artwork was cached twice and
  re-downloaded on detail open. The season strip also discarded its height bound.

Sources without published variants (Trakt, AniList, MAL, MDBList) still fall back
to the base URL.
2026-08-23 17:51:27 +02:00

144 lines
4.8 KiB
Dart

import 'package:cached_network_image_ce/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:plezy/profiles/profile.dart';
import 'package:plezy/profiles/profile_avatar.dart';
import 'package:plezy/utils/initials_palette.dart';
void main() {
Future<void> pumpAvatar(
WidgetTester tester, {
required Profile profile,
String? avatarUrl,
double size = 40,
double devicePixelRatio = 1,
}) {
return tester.pumpWidget(
MaterialApp(
theme: ThemeData(),
home: MediaQuery(
data: MediaQueryData(devicePixelRatio: devicePixelRatio),
child: Center(
child: ProfileAvatar(profile: profile, avatarUrl: avatarUrl, size: size),
),
),
),
);
}
Profile localProfile({String? avatarThumbUrl, String? pinHash}) {
return Profile.local(
id: 'local-owner',
displayName: 'Owner',
avatarThumbUrl: avatarThumbUrl,
pinHash: pinHash,
createdAt: DateTime(2026, 1, 1),
);
}
// Avatars now go through MediaImageHelper.serverArtworkProvider, so they
// share the `plex_optimized_<sha1>` disk-key namespace with every other
// artwork surface instead of being cached a second time under the raw URL.
// That means the assertions are on the resolved provider, not on a
// CachedNetworkImage widget.
ResizeImage avatarResizeImage(WidgetTester tester) {
final image = tester.widget<Image>(find.byType(Image));
return image.image as ResizeImage;
}
String avatarUrlOf(WidgetTester tester) {
final inner = avatarResizeImage(tester).imageProvider;
return (inner as CachedNetworkImageProvider).url;
}
testWidgets('avatarUrl renders the derived network image', (tester) async {
const avatarUrl = 'https://jellyfin.example/Users/user-1/Images/Primary?tag=derived';
await pumpAvatar(tester, profile: localProfile(), avatarUrl: avatarUrl);
expect(avatarUrlOf(tester), avatarUrl);
});
testWidgets('avatarUrl takes precedence over the profile thumbnail', (tester) async {
const derivedUrl = 'https://jellyfin.example/Users/user-1/Images/Primary?tag=derived';
const profileThumbUrl = 'https://plex.example/profile-thumb.jpg';
await pumpAvatar(
tester,
profile: localProfile(avatarThumbUrl: profileThumbUrl),
avatarUrl: derivedUrl,
);
expect(avatarUrlOf(tester), derivedUrl);
});
testWidgets('a null avatarUrl preserves the profile thumbnail fallback', (tester) async {
const profileThumbUrl = 'https://plex.example/profile-thumb.jpg';
await pumpAvatar(tester, profile: localProfile(avatarThumbUrl: profileThumbUrl));
expect(avatarUrlOf(tester), profileThumbUrl);
});
testWidgets('a profile without a picture renders its display-name initial', (tester) async {
final profile = localProfile();
await pumpAvatar(tester, profile: profile);
expect(find.byType(Image), findsNothing);
expect(find.text(initialOf(profile.displayName)), findsOneWidget);
});
testWidgets('an empty avatarUrl falls through to the profile picture rather than suppressing it', (tester) async {
const plexThumb = 'https://plex.tv/users/abc/avatar';
// An empty override means "nothing derived". Treating it as a value would
// blank out a Plex Home profile that owns a perfectly good thumb.
await pumpAvatar(
tester,
profile: localProfile(avatarThumbUrl: plexThumb),
avatarUrl: '',
);
expect(avatarUrlOf(tester), plexThumb);
});
testWidgets('an empty avatarUrl renders initials instead of requesting an empty URL', (tester) async {
final profile = localProfile();
await pumpAvatar(tester, profile: profile, avatarUrl: '');
expect(find.byType(Image), findsNothing);
expect(find.text(initialOf(profile.displayName)), findsOneWidget);
});
testWidgets('network image decoding is bounded by the physical avatar size', (tester) async {
const size = 44.0;
const devicePixelRatio = 2.5;
await pumpAvatar(
tester,
profile: localProfile(),
avatarUrl: 'https://jellyfin.example/Users/user-1/Images/Primary?tag=large-original',
size: size,
devicePixelRatio: devicePixelRatio,
);
final resize = avatarResizeImage(tester);
final expectedDecodeSize = (size * devicePixelRatio).round();
expect(resize.width, expectedDecodeSize);
expect(resize.height, expectedDecodeSize);
});
testWidgets('a derived avatar keeps the PIN lock badge visible', (tester) async {
await pumpAvatar(
tester,
profile: localProfile(pinHash: 'stored-pin-hash'),
avatarUrl: 'https://jellyfin.example/Users/user-1/Images/Primary?tag=derived',
);
expect(find.byType(Image), findsOneWidget);
expect(find.byIcon(Symbols.lock_rounded), findsOneWidget);
});
}