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.
667 lines
25 KiB
Dart
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);
|
|
}
|