Files
plezy/test/screens/profile/profile_switch_screen_test.dart
edde746 26dbce0277 fix(profiles): name the Plex user and account in one translated chip
A Plex account connection labels itself with the account owner's name.
Under a profile tile that reads as being signed in as the owner: the
Plex Home tile showed the owner beneath the Home user's own name, and a
local profile that borrowed a Home user out of someone else's account
showed only the lender.

Both halves of the relation now go through a single translated string,
so a locale orders them itself instead of inheriting the English
"user via account" — az, hu, ja, kk, ko, tr, uz, zh and zh-Hant put the
account first. When the Home cache cannot resolve the connection's uuid
the chip names the account alone rather than falling back to a bare
name. ProfilesView carries the Home user cache that resolution needs,
and chip labels ellipsize now that an account label can be an email.
2026-08-05 13:05:15 +02:00

667 lines
25 KiB
Dart

import 'dart:async';
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/connection/connection.dart';
import 'package:plezy/connection/connection_registry.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/focus/focusable_wrapper.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/models/plex/plex_home_user.dart';
import 'package:plezy/profiles/active_profile_provider.dart';
import 'package:plezy/profiles/plex_home_service.dart';
import 'package:plezy/profiles/profile.dart';
import 'package:plezy/profiles/profile_avatar.dart';
import 'package:plezy/profiles/profile_connection.dart';
import 'package:plezy/profiles/profile_connection_registry.dart';
import 'package:plezy/profiles/profile_registry.dart';
import 'package:plezy/screens/profile/profile_switch_screen.dart';
import 'package:plezy/services/storage_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:provider/provider.dart';
import '../../test_helpers/prefs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
resetSharedPreferencesForTest();
LocaleSettings.setLocaleSync(AppLocale.en);
});
testWidgets('D-pad can focus profile actions and open the manage menu', (tester) async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
final profile = Profile.local(id: 'local-owner', displayName: 'Owner', createdAt: DateTime(2026, 1, 1));
final profiles = _FakeProfileRegistry(db, [profile]);
final connections = _FakeConnectionRegistry(db);
final profileConnections = _FakeProfileConnectionRegistry(db);
final storage = await StorageService.getInstance();
final plexHome = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
storage: storage,
plexHomeUserFetcher: (_) async => const [],
);
final activeProfile = ActiveProfileProvider(
registry: profiles,
plexHome: plexHome,
connections: connections,
profileConnections: profileConnections,
storage: storage,
);
addTearDown(() async {
activeProfile.dispose();
await plexHome.dispose();
await db.close();
});
await tester.pumpWidget(
TranslationProvider(
child: MultiProvider(
providers: [
Provider<StorageService>.value(value: storage),
Provider<ProfileRegistry>.value(value: profiles),
Provider<ProfileConnectionRegistry>.value(value: profileConnections),
Provider<ConnectionRegistry>.value(value: connections),
Provider<PlexHomeService>.value(value: plexHome),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfile),
],
child: MaterialApp(theme: monoTheme(dark: true), home: const ProfileSwitchScreen()),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Owner'), findsOneWidget);
expect(FocusManager.instance.primaryFocus?.debugLabel, 'ProfileTile:local-owner');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'ProfileActions:local-owner');
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(find.text(t.profiles.manage), findsOneWidget);
expect(find.text(t.profiles.delete), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
});
testWidgets('orders profiles by recent usage from storage', (tester) async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
final profiles = _FakeProfileRegistry(db, [
Profile.local(id: 'local-owner', displayName: 'Owner', createdAt: DateTime(2026, 1, 1)),
Profile.local(id: 'local-kids', displayName: 'Kids', createdAt: DateTime(2026, 1, 2)),
]);
final connections = _FakeConnectionRegistry(db);
final profileConnections = _FakeProfileConnectionRegistry(db);
final storage = await StorageService.getInstance();
await storage.markProfileUsed('local-kids', DateTime(2026, 1, 3));
final plexHome = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
storage: storage,
plexHomeUserFetcher: (_) async => const [],
);
final activeProfile = ActiveProfileProvider(
registry: profiles,
plexHome: plexHome,
connections: connections,
profileConnections: profileConnections,
storage: storage,
);
addTearDown(() async {
activeProfile.dispose();
await plexHome.dispose();
await db.close();
});
await tester.pumpWidget(
TranslationProvider(
child: MultiProvider(
providers: [
Provider<StorageService>.value(value: storage),
Provider<ProfileRegistry>.value(value: profiles),
Provider<ProfileConnectionRegistry>.value(value: profileConnections),
Provider<ConnectionRegistry>.value(value: connections),
Provider<PlexHomeService>.value(value: plexHome),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfile),
],
child: MaterialApp(theme: monoTheme(dark: true), home: const ProfileSwitchScreen()),
),
),
);
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text('Kids')).dy, lessThan(tester.getTopLeft(find.text('Owner')).dy));
});
testWidgets('passes derived Jellyfin avatar URLs only to linked profile tiles', (tester) async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
final linkedProfile = Profile.local(id: 'local-linked', displayName: 'Linked', createdAt: DateTime(2026, 1, 1));
final unlinkedProfile = Profile.local(
id: 'local-unlinked',
displayName: 'Unlinked',
createdAt: DateTime(2026, 1, 2),
);
final jellyfin = JellyfinConnection(
id: 'jf-machine/jf-user',
baseUrl: 'https://jellyfin.example',
serverName: 'Jellyfin',
serverMachineId: 'jf-machine',
userId: 'jf-user',
userName: 'Linked',
accessToken: 'secret-token',
deviceId: 'device-1',
primaryImageTag: 'primary-tag',
createdAt: DateTime(2025, 12, 1),
);
final link = ProfileConnection(
profileId: linkedProfile.id,
connectionId: jellyfin.id,
userIdentifier: jellyfin.userId,
);
final profiles = _FakeProfileRegistry(db, [linkedProfile, unlinkedProfile]);
final connections = _FakeConnectionRegistry(db, [jellyfin]);
final profileConnections = _FakeProfileConnectionRegistry(db, [link]);
final storage = await StorageService.getInstance();
final plexHome = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
storage: storage,
plexHomeUserFetcher: (_) async => const [],
);
final activeProfile = ActiveProfileProvider(
registry: profiles,
plexHome: plexHome,
connections: connections,
profileConnections: profileConnections,
storage: storage,
);
addTearDown(() async {
activeProfile.dispose();
await plexHome.dispose();
await db.close();
});
await tester.pumpWidget(
TranslationProvider(
child: MultiProvider(
providers: [
Provider<StorageService>.value(value: storage),
Provider<ProfileRegistry>.value(value: profiles),
Provider<ProfileConnectionRegistry>.value(value: profileConnections),
Provider<ConnectionRegistry>.value(value: connections),
Provider<PlexHomeService>.value(value: plexHome),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfile),
],
child: MaterialApp(theme: monoTheme(dark: true), home: const ProfileSwitchScreen()),
),
),
);
await tester.pumpAndSettle();
final linkedAvatar = tester.widget<ProfileAvatar>(
find.byWidgetPredicate((widget) => widget is ProfileAvatar && widget.profile?.id == linkedProfile.id),
);
final unlinkedAvatar = tester.widget<ProfileAvatar>(
find.byWidgetPredicate((widget) => widget is ProfileAvatar && widget.profile?.id == unlinkedProfile.id),
);
expect(linkedAvatar.avatarUrl, isNotNull);
expect(
linkedAvatar.avatarUrl,
'https://jellyfin.example/Users/jf-user/Images/Primary?tag=primary-tag&maxWidth=240&maxHeight=240',
);
expect(unlinkedAvatar.avatarUrl, isNull);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
});
testWidgets('paints the recency order on the first frame that shows profiles', (tester) async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
final profiles = _FakeProfileRegistry(db, [
Profile.local(id: 'local-owner', displayName: 'Owner', createdAt: DateTime(2026, 1, 1)),
Profile.local(id: 'local-kids', displayName: 'Kids', createdAt: DateTime(2026, 1, 2)),
]);
final connections = _FakeConnectionRegistry(db);
final profileConnections = _FakeProfileConnectionRegistry(db);
final storage = await StorageService.getInstance();
await storage.markProfileUsed('local-kids', DateTime(2026, 1, 3));
final plexHome = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
storage: storage,
plexHomeUserFetcher: (_) async => const [],
);
final activeProfile = ActiveProfileProvider(
registry: profiles,
plexHome: plexHome,
connections: connections,
profileConnections: profileConnections,
storage: storage,
);
addTearDown(() async {
activeProfile.dispose();
await plexHome.dispose();
await db.close();
});
await tester.pumpWidget(
TranslationProvider(
child: MultiProvider(
providers: [
Provider<StorageService>.value(value: storage),
Provider<ProfileRegistry>.value(value: profiles),
Provider<ProfileConnectionRegistry>.value(value: profileConnections),
Provider<ConnectionRegistry>.value(value: connections),
Provider<PlexHomeService>.value(value: plexHome),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfile),
],
child: MaterialApp(theme: monoTheme(dark: true), home: const ProfileSwitchScreen()),
),
),
);
// Frame-by-frame, not pumpAndSettle: the regression was an intermediate
// order that only existed for one frame, which a settled assertion cannot
// see (#1792).
final paintedOrders = <String>[];
for (var frame = 0; frame < 6; frame++) {
await tester.pump(const Duration(milliseconds: 16));
final order = _visibleProfileNames(tester);
if (order.isNotEmpty) paintedOrders.add(order.join(','));
}
expect(paintedOrders, isNotEmpty, reason: 'the picker never painted a profile');
expect(paintedOrders.toSet(), {'Kids,Owner'}, reason: 'the recency sort must not arrive a frame late');
expect(FocusManager.instance.primaryFocus?.debugLabel, 'ProfileTile:local-kids');
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
});
testWidgets('keeps the focused tile highlighted when the list re-sorts after first paint', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
PlatformDetector.debugSetIsDesktopOSOverride(false);
addTearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
PlatformDetector.debugSetIsDesktopOSOverride(null);
});
final db = AppDatabase.forTesting(NativeDatabase.memory());
final owner = Profile.local(id: 'local-owner', displayName: 'Owner', createdAt: DateTime(2026, 1, 1));
final kids = Profile.local(id: 'local-kids', displayName: 'Kids', createdAt: DateTime(2026, 1, 2));
final profiles = _MutableProfileRegistry(db, [owner, kids]);
final connections = _FakeConnectionRegistry(db);
final profileConnections = _FakeProfileConnectionRegistry(db);
final storage = await StorageService.getInstance();
final plexHome = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
storage: storage,
plexHomeUserFetcher: (_) async => const [],
);
final activeProfile = ActiveProfileProvider(
registry: profiles,
plexHome: plexHome,
connections: connections,
profileConnections: profileConnections,
storage: storage,
);
addTearDown(() async {
activeProfile.dispose();
await plexHome.dispose();
await profiles.close();
await db.close();
});
await tester.pumpWidget(
InputModeTracker(
child: TranslationProvider(
child: MultiProvider(
providers: [
Provider<StorageService>.value(value: storage),
Provider<ProfileRegistry>.value(value: profiles),
Provider<ProfileConnectionRegistry>.value(value: profileConnections),
Provider<ConnectionRegistry>.value(value: connections),
Provider<PlexHomeService>.value(value: plexHome),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfile),
],
child: MaterialApp(theme: monoTheme(dark: true), home: const ProfileSwitchScreen()),
),
),
),
);
await tester.pumpAndSettle();
expect(_visibleProfileNames(tester), ['Owner', 'Kids']);
final focused = FocusManager.instance.primaryFocus;
expect(focused?.debugLabel, 'ProfileTile:local-owner');
expect(_tileIsHighlighted(tester, 'Owner'), isTrue, reason: 'the focused tile starts highlighted');
// A refreshed profile source can re-sort the list after first paint. The
// tile must move with its focus node instead of the node being rebound to
// whatever profile now occupies its index.
profiles.emit([
owner,
Profile.local(
id: 'local-kids',
displayName: 'Kids',
createdAt: DateTime(2026, 1, 2),
lastUsedAt: DateTime(2026, 1, 3),
),
]);
await tester.pumpAndSettle();
expect(_visibleProfileNames(tester), ['Kids', 'Owner'], reason: 'the emission should have re-sorted the list');
expect(FocusManager.instance.primaryFocus, same(focused), reason: 'the reorder must not move focus off the tile');
expect(_tileIsHighlighted(tester, 'Owner'), isTrue, reason: 'the focused tile must still draw focus chrome');
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
});
testWidgets('names the Plex Home user and the account together, not the account alone', (tester) async {
await _pumpPicker(
tester,
profiles: [
Profile.plexHome(
id: 'home-alice',
displayName: 'Alice',
parentConnectionId: 'plex-account',
plexHomeUserUuid: 'alice-uuid',
createdAt: DateTime(2026, 1, 1),
),
],
connections: [_plexAccount('Bob')],
);
expect(find.text('Alice'), findsOneWidget);
expect(
_relationChip(tester, tile: _tileFor('Alice'), first: 'Alice', second: 'Bob'),
t.profiles.plexAccountUserChip(user: 'Alice', account: 'Bob'),
);
expect(find.text('Bob'), findsNothing, reason: 'the owner name alone reads as being signed in as the owner');
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
});
testWidgets('names the borrowed Home user and the account it came from', (tester) async {
await _pumpPicker(
tester,
profiles: [Profile.local(id: 'local-alice', displayName: 'Alice', createdAt: DateTime(2026, 1, 1))],
connections: [_plexAccount('Bob')],
profileConnections: const [
ProfileConnection(
profileId: 'local-alice',
connectionId: 'plex-account',
userToken: 'borrowed-token',
userIdentifier: 'charlie-uuid',
),
],
homeUsers: [_homeUser('charlie-uuid', 'Charlie')],
);
expect(
_relationChip(tester, tile: _tileFor('Alice'), first: 'Charlie', second: 'Bob'),
t.profiles.plexAccountUserChip(user: 'Charlie', account: 'Bob'),
);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
});
testWidgets('falls back to naming the account when the Home user cannot be resolved', (tester) async {
await _pumpPicker(
tester,
profiles: [Profile.local(id: 'local-alice', displayName: 'Alice', createdAt: DateTime(2026, 1, 1))],
connections: [_plexAccount('Bob')],
profileConnections: const [
ProfileConnection(
profileId: 'local-alice',
connectionId: 'plex-account',
userToken: 'borrowed-token',
userIdentifier: 'departed-uuid',
),
],
);
expect(find.text(t.profiles.plexAccountChip(account: 'Bob')), findsOneWidget);
expect(find.text('Bob'), findsNothing);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
});
testWidgets('lets a locale put the account before the user', (tester) async {
// ja renders the relation as `${account}経由の${user}`. Splitting the two
// halves across widgets — or across two translated fragments — would pin
// them to English order; this is the assertion that keeps them in one
// translated string.
// `runAsync`: a deferred locale library loads on the real event loop,
// which the widget tester's fake async would otherwise never pump.
await tester.runAsync(() => LocaleSettings.setLocale(AppLocale.ja));
addTearDown(() => LocaleSettings.setLocaleSync(AppLocale.en));
await _pumpPicker(
tester,
profiles: [
Profile.plexHome(
id: 'home-alice',
displayName: 'Alice',
parentConnectionId: 'plex-account',
plexHomeUserUuid: 'alice-uuid',
createdAt: DateTime(2026, 1, 1),
),
],
connections: [_plexAccount('Bob')],
);
final label = _relationChip(tester, tile: _tileFor('Alice'), first: 'Alice', second: 'Bob');
expect(label, t.profiles.plexAccountUserChip(user: 'Alice', account: 'Bob'));
expect(label.indexOf('Bob'), lessThan(label.indexOf('Alice')));
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
});
}
/// Boots the picker over a fixed profile/connection set. [homeUsers] seeds the
/// live Plex Home cache, which is what turns a borrowed connection's
/// `userIdentifier` uuid into a name.
Future<void> _pumpPicker(
WidgetTester tester, {
required List<Profile> profiles,
required List<Connection> connections,
List<ProfileConnection> profileConnections = const [],
List<PlexHomeUser> homeUsers = const [],
}) async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
final profileRegistry = _FakeProfileRegistry(db, profiles);
final connectionRegistry = _FakeConnectionRegistry(db, connections);
final profileConnectionRegistry = _FakeProfileConnectionRegistry(db, profileConnections);
final storage = await StorageService.getInstance();
final plexHome = PlexHomeService(
connections: connectionRegistry,
profileConnections: profileConnectionRegistry,
storage: storage,
plexHomeUserFetcher: (_) async => homeUsers,
);
final activeProfile = ActiveProfileProvider(
registry: profileRegistry,
plexHome: plexHome,
connections: connectionRegistry,
profileConnections: profileConnectionRegistry,
storage: storage,
);
addTearDown(() async {
activeProfile.dispose();
await plexHome.dispose();
await db.close();
});
if (homeUsers.isNotEmpty) {
for (final connection in connections.whereType<PlexAccountConnection>()) {
expect(await plexHome.refresh(connection), isTrue);
}
}
await tester.pumpWidget(
TranslationProvider(
child: MultiProvider(
providers: [
Provider<StorageService>.value(value: storage),
Provider<ProfileRegistry>.value(value: profileRegistry),
Provider<ProfileConnectionRegistry>.value(value: profileConnectionRegistry),
Provider<ConnectionRegistry>.value(value: connectionRegistry),
Provider<PlexHomeService>.value(value: plexHome),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfile),
],
child: MaterialApp(theme: monoTheme(dark: true), home: const ProfileSwitchScreen()),
),
),
);
await tester.pumpAndSettle();
}
PlexAccountConnection _plexAccount(String accountLabel) {
return PlexAccountConnection(
id: 'plex-account',
accountToken: 'token',
clientIdentifier: 'client',
accountLabel: accountLabel,
createdAt: DateTime(2026, 1, 1),
);
}
PlexHomeUser _homeUser(String uuid, String title) {
return PlexHomeUser(
id: 1,
uuid: uuid,
title: title,
thumb: '',
hasPassword: false,
restricted: false,
updatedAt: null,
admin: false,
guest: false,
protected: false,
);
}
Finder _tileFor(String displayName) {
return find.ancestor(of: find.text(displayName), matching: find.byType(FocusableWrapper));
}
/// The single chip label inside [tile] that names both [first] and [second].
///
/// Asserting that exactly one text node carries both halves is the point: two
/// nodes, or one node plus a neighbouring label, would mean the relation is
/// assembled in Dart and no locale could reorder it.
String _relationChip(WidgetTester tester, {required Finder tile, required String first, required String second}) {
final labels = tester
.widgetList<Text>(find.descendant(of: tile, matching: find.byType(Text)))
.map((text) => text.data)
.whereType<String>()
.where((label) => label.contains(first) && label.contains(second))
.toList();
expect(labels, hasLength(1), reason: 'both halves must render as one translated string');
return labels.single;
}
/// Tile labels in painted order. Only the two names the reorder tests seed are
/// considered, so surrounding chrome text cannot pollute the sequence.
List<String> _visibleProfileNames(WidgetTester tester) {
return tester
.widgetList<Text>(find.byType(Text))
.map((text) => text.data)
.where((label) => label == 'Owner' || label == 'Kids')
.cast<String>()
.toList();
}
/// Whether the tile showing [name] draws its focus border.
///
/// [FocusableWrapper] paints an opaque border only while it believes it holds
/// focus, so this catches the state where primary focus is correct but the
/// wrapper's chrome was reset by a rebuilt element.
bool _tileIsHighlighted(WidgetTester tester, String name) {
final wrapper = find.ancestor(of: find.text(name), matching: find.byType(FocusableWrapper));
final containers = tester.widgetList<AnimatedContainer>(
find.descendant(of: wrapper, matching: find.byType(AnimatedContainer)),
);
return containers.any((container) {
final border = (container.decoration as BoxDecoration?)?.border?.top;
return border != null && border.style != BorderStyle.none && border.color.a > 0;
});
}
class _FakeProfileRegistry extends ProfileRegistry {
final List<Profile> _profiles;
_FakeProfileRegistry(super.db, this._profiles);
@override
Stream<List<Profile>> watchProfiles() => Stream.value(_profiles);
@override
Future<List<Profile>> list() async => _profiles;
}
/// Profile registry whose stream keeps emitting, so a test can re-sort the
/// list after first paint the way a refreshed profile source does.
class _MutableProfileRegistry extends ProfileRegistry {
_MutableProfileRegistry(super.db, this._profiles);
List<Profile> _profiles;
final StreamController<List<Profile>> _controller = StreamController<List<Profile>>.broadcast();
@override
Stream<List<Profile>> watchProfiles() async* {
yield _profiles;
yield* _controller.stream;
}
@override
Future<List<Profile>> list() async => _profiles;
void emit(List<Profile> profiles) {
_profiles = profiles;
_controller.add(profiles);
}
Future<void> close() => _controller.close();
}
class _FakeConnectionRegistry extends ConnectionRegistry {
final List<Connection> _connections;
_FakeConnectionRegistry(super.db, [this._connections = const []]);
@override
Stream<List<Connection>> watchConnections() => Stream.value(_connections);
@override
Future<List<Connection>> list() async => _connections;
@override
Future<Connection?> get(String id) async => _connections.where((conn) => conn.id == id).firstOrNull;
}
class _FakeProfileConnectionRegistry extends ProfileConnectionRegistry {
final List<ProfileConnection> _profileConnections;
_FakeProfileConnectionRegistry(super.db, [this._profileConnections = const []]);
@override
Stream<List<ProfileConnection>> watchAll() => Stream.value(_profileConnections);
}