Files
plezy/test/profiles/profile_avatar_test.dart
T
edde746 860ce1e11a feat(profiles): show the first linked connection's user picture
A local profile had no picture of its own and always fell back to
initials. It now borrows the user picture of the connection it was
linked to first — oldest Connection.createdAt, ties broken by
connection id, since the join table carries no creation time.

Jellyfin links resolve to /Users/{id}/Images/Primary, keyed by the
PrimaryImageTag now captured at authentication and refreshed from the
/Users/Me body checkHealth already fetches. That endpoint is anonymous
on every Jellyfin release, so the URL carries no api_key and the access
token stays out of the image cache key. Plex links resolve the Home
user the link points at against PlexHomeService's live cache, so no
account-level lookup is needed and the picture tracks Plex's own
refresh.

The picture is derived per snapshot and never written back onto a
Profile: ProfileDetailScreen upserts the model it holds, so a
persisted URL would go stale and outlive the connection it came from.
Plex Home profiles are untouched, including one whose Plex avatar is
unset — it keeps its initials rather than borrowing a lent connection's
picture.

close #1667
2026-08-04 02:22:44 +02:00

133 lines
4.6 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),
);
}
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);
final image = tester.widget<CachedNetworkImage>(find.byType(CachedNetworkImage));
expect(image.imageUrl, 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,
);
final image = tester.widget<CachedNetworkImage>(find.byType(CachedNetworkImage));
expect(image.imageUrl, 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));
final image = tester.widget<CachedNetworkImage>(find.byType(CachedNetworkImage));
expect(image.imageUrl, 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(CachedNetworkImage), 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(tester.widget<CachedNetworkImage>(find.byType(CachedNetworkImage)).imageUrl, 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(CachedNetworkImage), 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 image = tester.widget<CachedNetworkImage>(find.byType(CachedNetworkImage));
final expectedDecodeSize = (size * devicePixelRatio).round();
expect(image.memCacheWidth, expectedDecodeSize);
expect(image.memCacheHeight, 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(CachedNetworkImage), findsOneWidget);
expect(find.byIcon(Symbols.lock_rounded), findsOneWidget);
});
}