fix: eliminate cross-app consistency drift

This commit is contained in:
edde746
2026-07-13 23:13:53 +02:00
parent ec249d2ed4
commit d5f3e581df
142 changed files with 4751 additions and 1075 deletions
+4 -3
View File
@@ -3,6 +3,7 @@ import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:plezy/focus/focusable_text_field.dart';
import 'package:plezy/services/gamepad_service.dart';
import 'package:plezy/utils/platform_detector.dart';
@@ -713,7 +714,7 @@ void main() {
rebuild(() => onNavigateDown = () => navigateDownCalls++);
await tester.pump();
await tester.tap(_tvKeyboardDoneKey(Icons.search_rounded));
await tester.tap(_tvKeyboardDoneKey(Symbols.search_rounded));
await tester.pumpAndSettle();
expect(navigateDownCalls, 1);
@@ -765,7 +766,7 @@ void main() {
});
await tester.pump();
await tester.tap(_tvKeyboardDoneKey(Icons.search_rounded));
await tester.tap(_tvKeyboardDoneKey(Symbols.search_rounded));
await tester.pumpAndSettle();
expect(submitted, 'query');
@@ -799,7 +800,7 @@ void main() {
await tester.pumpAndSettle();
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget);
await tester.tap(_tvKeyboardDoneKey(Icons.search_rounded));
await tester.tap(_tvKeyboardDoneKey(Symbols.search_rounded));
await tester.pumpAndSettle();
await tester.pump();
+269
View File
@@ -11,6 +11,7 @@ import 'package:plezy/connection/connection.dart';
import 'package:plezy/connection/connection_registry.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/navigation/profile_navigation_scope.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/library_query.dart';
import 'package:plezy/media/media_backend.dart';
@@ -27,12 +28,19 @@ import 'package:plezy/profiles/active_profile_provider.dart';
import 'package:plezy/profiles/plex_home_service.dart';
import 'package:plezy/profiles/profile_connection_registry.dart';
import 'package:plezy/profiles/profile_registry.dart';
import 'package:plezy/providers/download_provider.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/music/album_detail_screen.dart';
import 'package:plezy/screens/music/artist_detail_screen.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/download_manager_service.dart';
import 'package:plezy/services/download_storage_service.dart';
import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/services/jellyfin_api_cache.dart';
import 'package:plezy/services/music/music_playback_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/services/plex_client.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/media_server_http_client.dart';
@@ -40,6 +48,7 @@ import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/media_context_menu.dart';
import 'package:provider/provider.dart';
import '../test_helpers/media_items.dart';
import '../test_helpers/prefs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -303,6 +312,102 @@ void main() {
expect(find.text(t.playlists.selectPlaylist), findsNothing);
expect(find.text('picker target'), findsOneWidget);
});
testWidgets('track album action uses the profile navigator from the sibling menu overlay', (tester) async {
final track = testMediaItem(
id: 'track-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.track,
title: 'Track',
parentId: 'album-1',
parentTitle: 'Album',
grandparentId: 'artist-1',
grandparentTitle: 'Artist',
serverId: 'srv-1',
);
final album = testMediaItem(
id: 'album-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.album,
title: 'Album',
parentId: 'artist-1',
parentTitle: 'Artist',
serverId: 'srv-1',
);
final harness = await _pumpSiblingMusicMenu(tester, item: track, relatedItems: [album]);
await _selectSiblingMusicMenuAction(tester, harness, t.music.goToAlbum);
expect(find.byType(AlbumDetailScreen), findsOneWidget);
expect(harness.profileNavigatorKey.currentState!.canPop(), isTrue);
expect(harness.rootNavigatorKey.currentState!.canPop(), isFalse);
expect(
Provider.of<MusicPlaybackService>(tester.element(find.byType(AlbumDetailScreen)), listen: false),
same(harness.music),
);
});
testWidgets('album artist action uses the profile navigator from the sibling menu overlay', (tester) async {
final album = testMediaItem(
id: 'album-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.album,
title: 'Album',
parentId: 'artist-1',
parentTitle: 'Artist',
serverId: 'srv-1',
);
final artist = testMediaItem(
id: 'artist-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.artist,
title: 'Artist',
serverId: 'srv-1',
);
final harness = await _pumpSiblingMusicMenu(tester, item: album, relatedItems: [artist]);
await _selectSiblingMusicMenuAction(tester, harness, t.music.goToArtist);
expect(find.byType(ArtistDetailScreen), findsOneWidget);
expect(harness.profileNavigatorKey.currentState!.canPop(), isTrue);
expect(harness.rootNavigatorKey.currentState!.canPop(), isFalse);
expect(
Provider.of<MusicPlaybackService>(tester.element(find.byType(ArtistDetailScreen)), listen: false),
same(harness.music),
);
});
testWidgets('track artist action uses the profile navigator from the sibling menu overlay', (tester) async {
final track = testMediaItem(
id: 'track-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.track,
title: 'Track',
parentId: 'album-1',
parentTitle: 'Album',
grandparentId: 'artist-1',
grandparentTitle: 'Artist',
serverId: 'srv-1',
);
final artist = testMediaItem(
id: 'artist-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.artist,
title: 'Artist',
serverId: 'srv-1',
);
final harness = await _pumpSiblingMusicMenu(tester, item: track, relatedItems: [artist]);
await _selectSiblingMusicMenuAction(tester, harness, t.music.goToArtist);
expect(find.byType(ArtistDetailScreen), findsOneWidget);
expect(harness.profileNavigatorKey.currentState!.canPop(), isTrue);
expect(harness.rootNavigatorKey.currentState!.canPop(), isFalse);
expect(
Provider.of<MusicPlaybackService>(tester.element(find.byType(ArtistDetailScreen)), listen: false),
same(harness.music),
);
});
});
}
@@ -467,6 +572,170 @@ class _RecordingMusicPlaybackService extends StubMusicPlaybackService {
}
}
class _RelatedMusicClient implements MediaServerClient {
_RelatedMusicClient(Iterable<MediaItem> items) : _items = {for (final item in items) item.id: item};
final Map<String, MediaItem> _items;
@override
ServerId get serverId => ServerId('srv-1');
@override
String? get serverName => 'Server';
@override
MediaBackend get backend => MediaBackend.jellyfin;
@override
ServerCapabilities get capabilities => ServerCapabilities.jellyfin;
@override
Future<MediaItem?> fetchItem(String id) async => _items[id];
@override
Future<List<MediaItem>> fetchAlbumTracks(String albumId) async => const [];
@override
Future<List<MediaItem>> fetchArtistAlbums(String artistId) async => const [];
@override
void close() {}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _SiblingMusicMenuHarness {
const _SiblingMusicMenuHarness({
required this.rootNavigatorKey,
required this.profileNavigatorKey,
required this.menuKey,
required this.music,
});
final GlobalKey<NavigatorState> rootNavigatorKey;
final GlobalKey<NavigatorState> profileNavigatorKey;
final GlobalKey<MediaContextMenuState> menuKey;
final _RecordingMusicPlaybackService music;
}
Future<_SiblingMusicMenuHarness> _pumpSiblingMusicMenu(
WidgetTester tester, {
required MediaItem item,
required List<MediaItem> relatedItems,
}) async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
await SettingsService.getInstance();
LocaleSettings.setLocaleSync(AppLocale.en);
final db = AppDatabase.forTesting(NativeDatabase.memory());
JellyfinApiCache.initialize(db);
final downloadManager = DownloadManagerService(
database: db,
storageService: DownloadStorageService.instance,
clientResolver: (serverId, {clientScopeId}) => null,
);
downloadManager.recoveryFuture = Future<void>.value();
final downloadProvider = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await downloadProvider.ensureInitialized();
final client = _RelatedMusicClient(relatedItems);
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
final connections = ConnectionRegistry(db);
final profileConnections = ProfileConnectionRegistry(db);
final plexHome = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
plexHomeUserFetcher: (_) async => const [],
);
final activeProfileProvider = ActiveProfileProvider(
registry: ProfileRegistry(db),
plexHome: plexHome,
connections: connections,
);
final music = _RecordingMusicPlaybackService();
final rootNavigatorKey = GlobalKey<NavigatorState>();
final profileNavigatorKey = GlobalKey<NavigatorState>();
final menuKey = GlobalKey<MediaContextMenuState>();
addTearDown(() async {
downloadProvider.dispose();
downloadManager.dispose();
activeProfileProvider.dispose();
await plexHome.dispose();
music.dispose();
multiServerProvider.dispose();
manager.dispose();
await db.close();
});
await tester.pumpWidget(
TranslationProvider(
child: MaterialApp(
navigatorKey: rootNavigatorKey,
theme: monoTheme(dark: true).copyWith(platform: TargetPlatform.macOS),
home: MultiProvider(
providers: [
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider),
ChangeNotifierProvider<DownloadProvider>.value(value: downloadProvider),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfileProvider),
ChangeNotifierProvider<MusicPlaybackService>.value(value: music),
],
child: ProfileNavigationScope(
navigatorKey: profileNavigatorKey,
routeObserver: RouteObserver<PageRoute<dynamic>>(),
mainScaffoldMessengerKey: GlobalKey<ScaffoldMessengerState>(),
child: Stack(
fit: StackFit.expand,
children: [
Navigator(
key: profileNavigatorKey,
onGenerateRoute: (_) => MaterialPageRoute<void>(
builder: (_) => const Scaffold(body: Center(child: Text('profile content'))),
),
),
Align(
alignment: Alignment.bottomCenter,
child: Material(
child: MediaContextMenu(
key: menuKey,
item: item,
child: const SizedBox(
width: 180,
height: 64,
child: Center(child: Text('mini-player menu target')),
),
),
),
),
],
),
),
),
),
),
);
return _SiblingMusicMenuHarness(
rootNavigatorKey: rootNavigatorKey,
profileNavigatorKey: profileNavigatorKey,
menuKey: menuKey,
music: music,
);
}
Future<void> _selectSiblingMusicMenuAction(
WidgetTester tester,
_SiblingMusicMenuHarness harness,
String actionLabel,
) async {
harness.menuKey.currentState!.showContextMenu(tester.element(find.text('mini-player menu target')));
await tester.pumpAndSettle();
await tester.tap(find.text(actionLabel));
await tester.pumpAndSettle();
}
PlexHomeUser _homeUser({required bool admin}) {
return PlexHomeUser(
id: 0,
+14 -6
View File
@@ -292,8 +292,11 @@ void main() {
await tester.sendKeyUpEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(find.text(t.common.play), findsOneWidget);
final menuSurface = find.byWidgetPredicate((widget) => widget is Material && widget.elevation == 3);
final playMenuIcon = find.byWidgetPredicate(
(widget) => widget is AppIcon && widget.icon == Symbols.play_arrow_rounded,
);
expect(playMenuIcon, findsOneWidget);
final menuSurface = find.ancestor(of: playMenuIcon, matching: find.byType(BottomSheet));
expect(menuSurface, findsOneWidget);
expect(tester.getCenter(menuSurface).dx, closeTo(cardRect.center.dx, 0.1));
@@ -301,18 +304,23 @@ void main() {
await tester.pumpAndSettle();
});
testWidgets('stays hidden while the route observer suppresses it', (tester) async {
testWidgets('stays hidden while a named suppressing route is active', (tester) async {
final service = _FakeMusicService(track: _track);
final observer = MusicUiRouteObserver();
observer.suppress.value = true;
await tester.pumpWidget(wrap(service: service, observer: observer));
await tester.pumpAndSettle();
expect(find.text('Dawn'), findsOneWidget);
final route = MaterialPageRoute<void>(
settings: const RouteSettings(name: kNowPlayingRouteName),
builder: (_) => const SizedBox.shrink(),
);
observer.didPush(route, null);
await tester.pumpAndSettle();
expect(find.text('Dawn'), findsNothing);
// Suppression lifting brings it back without a rebuild from the service.
observer.suppress.value = false;
observer.didRemove(route, null);
await tester.pumpAndSettle();
expect(find.text('Dawn'), findsOneWidget);
});
+43 -11
View File
@@ -9,28 +9,49 @@ import 'package:plezy/utils/media_image_helper.dart';
import 'package:plezy/widgets/optimized_media_image.dart';
void main() {
testWidgets('network images use decode resize without disk cache resize', (tester) async {
testWidgets('network widget matches the shared server artwork provider', (tester) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetPhysicalSize);
const imageUrl = 'https://example.invalid/episode-thumb.jpg';
await tester.pumpWidget(
const MaterialApp(
home: SizedBox(
width: 160,
height: 90,
child: OptimizedMediaImage.thumb(
imagePath: 'https://example.invalid/episode-thumb.jpg',
width: 160,
height: 90,
),
child: OptimizedMediaImage.thumb(imagePath: imageUrl, width: 160, height: 90),
),
),
);
final image = tester.widget<Image>(find.byType(Image));
final resizeProvider = image.image as ResizeImage;
final cachedProvider = resizeProvider.imageProvider as CachedNetworkImageProvider;
final widgetProvider = image.image as ResizeImage;
final widgetCached = widgetProvider.imageProvider as CachedNetworkImageProvider;
final sharedProvider =
MediaImageHelper.serverArtworkProvider(imageUrl: imageUrl, memWidth: 200, memHeight: 180) as ResizeImage;
final sharedCached = sharedProvider.imageProvider as CachedNetworkImageProvider;
expect(resizeProvider.height, isNotNull);
expect(cachedProvider.maxHeight, isNull);
expect(cachedProvider.maxWidth, isNull);
expect(widgetCached, sharedCached);
expect(widgetProvider.width, sharedProvider.width);
expect(widgetProvider.width, 200);
expect(widgetProvider.height, sharedProvider.height);
expect(widgetProvider.height, 180);
expect(widgetProvider.policy, sharedProvider.policy);
expect(widgetProvider.policy, ResizeImagePolicy.fit);
expect(widgetProvider.allowUpscaling, sharedProvider.allowUpscaling);
expect(widgetProvider.allowUpscaling, isFalse);
expect(widgetCached.url, sharedCached.url);
expect(widgetCached.url, imageUrl);
expect(widgetCached.cacheKey, sharedCached.cacheKey);
expect(widgetCached.cacheKey, isNotNull);
expect(widgetCached.headers, sharedCached.headers);
expect(widgetCached.headers, const {'User-Agent': 'Plezy'});
expect(widgetCached.maxWidth, sharedCached.maxWidth);
expect(widgetCached.maxWidth, isNull);
expect(widgetCached.maxHeight, sharedCached.maxHeight);
expect(widgetCached.maxHeight, isNull);
});
testWidgets('failed image placeholders keep explicit dimensions in loose layouts', (tester) async {
@@ -69,6 +90,10 @@ void main() {
});
testWidgets('same local artwork path re-resolves after the file appears', (tester) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetPhysicalSize);
final directory = Directory.systemTemp.createTempSync('plezy-image-test');
addTearDown(() => directory.deleteSync(recursive: true));
final file = File('${directory.path}/poster.png');
@@ -110,6 +135,13 @@ void main() {
expect(file.existsSync(), isTrue);
expect(find.byIcon(Symbols.image_not_supported_rounded), findsNothing);
expect(find.byType(Image), findsOneWidget);
final localImage = tester.widget<Image>(find.byType(Image));
final localResize = localImage.image as ResizeImage;
final localProvider = localResize.imageProvider as FileImage;
expect(localResize.width, 120);
expect(localResize.height, 180);
expect(localResize.policy, ResizeImagePolicy.fit);
expect(localProvider.file.path, file.path);
await tester.pumpWidget(const SizedBox.shrink());
});
}
+3 -2
View File
@@ -3,6 +3,7 @@ import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:plezy/screens/profile/pin_entry_dialog.dart';
import 'package:plezy/utils/platform_detector.dart';
@@ -197,8 +198,8 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('1'), findsOneWidget);
expect(find.byIcon(Icons.close_rounded), findsOneWidget);
expect(find.byIcon(Icons.backspace_outlined), findsOneWidget);
expect(find.byIcon(Symbols.close_rounded), findsOneWidget);
expect(find.byIcon(Symbols.backspace_rounded), findsOneWidget);
expect(find.byType(TextField), findsNothing);
expect(find.widgetWithText(TextButton, 'Cancel'), findsNothing);
expect(find.byType(FilledButton), findsNothing);
@@ -7,6 +7,7 @@ import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/providers/companion_remote_provider.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/widgets/companion_remote/remote_session_dialog.dart';
import 'package:provider/provider.dart';
@@ -106,6 +107,7 @@ Future<void> _pumpDialogLauncher(WidgetTester tester, CompanionRemoteProvider pr
ChangeNotifierProvider<CompanionRemoteProvider>.value(
value: provider,
child: MaterialApp(
theme: monoTheme(dark: true),
home: Builder(
builder: (context) {
return Scaffold(
+3 -2
View File
@@ -3,6 +3,7 @@ import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:plezy/focus/dpad_navigator.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/tv_virtual_keyboard.dart';
@@ -153,9 +154,9 @@ void main() {
await _pumpKeyboard(tester, controller: controller);
expect(find.text('='), findsOneWidget);
expect(find.byIcon(Icons.functions_rounded), findsOneWidget);
expect(find.byIcon(Symbols.functions_rounded), findsOneWidget);
await tester.tap(find.byIcon(Icons.functions_rounded));
await tester.tap(find.byIcon(Symbols.functions_rounded));
await tester.pumpAndSettle();
expect(find.text('ABC'), findsOneWidget);
+53 -2
View File
@@ -1,9 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/mpv/models.dart';
import 'package:plezy/mpv/player/player.dart';
import 'package:plezy/mpv/player/player_state.dart';
import 'package:plezy/mpv/player/player_streams.dart';
import 'package:plezy/screens/settings/subtitle_styling_screen.dart';
import 'package:plezy/services/sleep_timer_service.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_tokens.dart';
import 'package:plezy/widgets/video_controls/sheets/video_settings_sheet.dart';
@@ -30,12 +33,24 @@ const _testTokens = MonoTokens(
);
void main() {
setUpAll(() async {
await LocaleSettings.setLocale(AppLocale.ru);
LocaleSettings.setLocaleSync(AppLocale.en);
});
setUp(() async {
LocaleSettings.setLocaleSync(AppLocale.en);
SleepTimerService().cancelTimer();
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
await SettingsService.getInstance();
});
tearDown(() {
SleepTimerService().cancelTimer();
LocaleSettings.setLocaleSync(AppLocale.en);
});
testWidgets('shows audio passthrough on supported TV-style surfaces', (tester) async {
await _pumpSheet(tester);
@@ -43,9 +58,45 @@ void main() {
expect(find.text('Audio Passthrough'), findsOneWidget);
});
testWidgets('localizes Off, Normal, and Active video setting values', (tester) async {
LocaleSettings.setLocaleSync(AppLocale.ru);
await _pumpSheet(tester, canControl: true);
expect(find.text('Обычная'), findsOneWidget);
expect(find.text('Выкл.'), findsOneWidget);
final sleepTimer = SleepTimerService();
sleepTimer.startTimer(const Duration(hours: 1), () {});
try {
await tester.pump();
expect(find.textContaining('Активен ('), findsOneWidget);
} finally {
sleepTimer.cancelTimer();
}
});
testWidgets('localizes every ASS subtitle override enum label', (tester) async {
LocaleSettings.setLocaleSync(AppLocale.ru);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(extensions: const [_testTokens]),
home: const SubtitleStylingScreen(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Переопределение ASS'));
await tester.pumpAndSettle();
final dialog = find.byType(AlertDialog);
for (final label in ['Нет', 'Да', 'Масштабировать', 'Принудительно', 'Удалить стили']) {
expect(find.descendant(of: dialog, matching: find.text(label)), findsOneWidget);
}
});
}
Future<void> _pumpSheet(WidgetTester tester) async {
Future<void> _pumpSheet(WidgetTester tester, {bool canControl = false}) async {
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(extensions: const [_testTokens]),
@@ -57,7 +108,7 @@ Future<void> _pumpSheet(WidgetTester tester) async {
player: _FakeSettingsPlayer(),
audioSyncOffset: 0,
subtitleSyncOffset: 0,
canControl: false,
canControl: canControl,
),
),
),