From 244b5f84c0a1e5cecea04a0f15503ff40c8b7c1f Mon Sep 17 00:00:00 2001 From: "Hermes (Server)" Date: Sat, 29 Aug 2026 15:05:38 +0200 Subject: [PATCH] Integrationstests: kompletter Zyklus gegen echtes HomeShell + State-Persistenz-Nachweis --- test/player/home_shell_expansion_test.dart | 26 +++ .../home_shell_real_expansion_test.dart | 174 ++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 test/player/home_shell_real_expansion_test.dart diff --git a/test/player/home_shell_expansion_test.dart b/test/player/home_shell_expansion_test.dart index 2a6edbf..220b28a 100644 --- a/test/player/home_shell_expansion_test.dart +++ b/test/player/home_shell_expansion_test.dart @@ -142,4 +142,30 @@ void main() { await tester.tap(find.byType(GestureDetector), warnIfMissed: false); expect(getappt, isFalse); }); + + testWidgets( + 'Kompletter Zyklus: Ziehen öffnet, Blur erscheint, Zurück-Taste schließt', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: _TestShell())); + final ctx = tester.element(find.byType(_TestShell)); + final controller = + tester.state<_TestShellState>(find.byType(_TestShell))._controller; + + controller.dragBy(-250); + await tester.pump(); + expect(controller.progress, closeTo(250 / oeffnenReferenzHoehe, 0.01)); + expect(find.byType(BackdropFilter), findsOneWidget); + + controller.dragEnd(ctx, -250, 0); + await tester.pumpAndSettle(); + expect(controller.progress, 1.0); + + // pageBack() sucht einen sichtbaren Zurück-Button, den es hier ohne + // zweite Route nicht gibt (siehe Plan-Hinweis für den Umsetzer) — + // Navigator.maybePop() löst denselben PopScope-Callback aus. + await Navigator.maybePop(ctx); + await tester.pumpAndSettle(); + expect(controller.progress, 0.0); + expect(find.byType(BackdropFilter), findsNothing); + }); } diff --git a/test/player/home_shell_real_expansion_test.dart b/test/player/home_shell_real_expansion_test.dart new file mode 100644 index 0000000..0f0a63f --- /dev/null +++ b/test/player/home_shell_real_expansion_test.dart @@ -0,0 +1,174 @@ +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/library/category_service.dart'; +import 'package:melo/library/database.dart'; +import 'package:melo/library/library_service.dart'; +import 'package:melo/library/playlist_service.dart'; +import 'package:melo/main.dart'; +import 'package:melo/player/audio_handler.dart'; +import 'package:melo/player/mini_player.dart'; +import 'package:melo/player/player_expansion_controller.dart'; +import 'package:melo/services/baka_auth.dart'; +import 'package:melo/services/download_service.dart'; +import 'package:melo/services/melo_cloud_service.dart'; +import 'package:melo/services/navidrome_service.dart'; +import 'package:melo/services/offline_mode.dart'; +import 'package:melo/services/sync_service.dart'; +import 'package:melo/services/yt_download_service.dart'; +import 'package:melo/services/yt_search_service.dart'; +import 'package:melo/settings/app_settings.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +// Dieselbe _MemorySpeicher/Provider-Verdrahtung wie test/home_shell_test.dart +// — von dort übernommen, nicht neu erfunden. +class _MemorySpeicher implements TokenSpeicher { + final Map werte = {}; + @override + Future lesen(String key) async => werte[key]; + @override + Future schreiben(String key, String wert) async => werte[key] = wert; + @override + Future loeschen(String key) async => werte.remove(key); +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets( + 'Echtes HomeShell: Blur-Layer reagiert live auf progress, nicht erst ' + 'beim nächsten fremden Rebuild', (tester) async { + final db = MeloDb(NativeDatabase.memory()); + final lib = LibraryService(db); + final playlists = PlaylistService(db); + final handler = MeloAudioHandler(db: db); + final auth = BakaAuth(speicher: _MemorySpeicher()); + + await tester.pumpWidget(MultiProvider( + providers: [ + Provider.value(value: db), + ChangeNotifierProvider.value(value: lib), + ChangeNotifierProvider.value(value: playlists), + Provider.value(value: handler), + ChangeNotifierProvider(create: (_) => CategoryService(db)), + ChangeNotifierProvider(create: (_) => AppSettings()), + ChangeNotifierProvider(create: (_) => OfflineMode()), + ChangeNotifierProvider(create: (_) => auth), + ChangeNotifierProvider( + create: (_) => YtDownloadService(auth: auth)), + ChangeNotifierProvider( + create: (_) => YtSearchService(auth: auth)), + ChangeNotifierProvider( + create: (_) => DownloadService(db: db, navidrome: NavidromeService())), + ChangeNotifierProvider( + create: (_) => + SyncService(db: db, cloud: MeloCloudService(auth: auth))), + ], + child: const MaterialApp(home: HomeShell()), + )); + await tester.pump(); + + expect(find.byType(BackdropFilter), findsNothing); + + // PlayerExpansionController wird von HomeShell selbst per + // ChangeNotifierProvider INNERHALB seines eigenen build() bereitgestellt + // — der Provider sitzt also unterhalb (nicht oberhalb) des HomeShell- + // Elements im Baum. Provider.of von einem HomeShell-Element aus würde + // deshalb ins Leere laufen (Ancestor-Lookup findet nur Vorfahren, nicht + // die eigenen Nachfahren) — stattdessen ein Element unterhalb nehmen, + // z.B. MiniPlayer, das immer im Stack steckt, auch ohne laufenden Titel. + final descendantContext = tester.element(find.byType(MiniPlayer)); + final controller = Provider.of( + descendantContext, + listen: false); + controller.open(descendantContext); + // Absichtlich NUR EINEN kurzen Zwischenschritt pumpen (nicht + // pumpAndSettle) — das ist der entscheidende Unterschied zum + // `_TestShell`-Test: er beweist, dass der Blur-Layer OHNE einen + // zusätzlichen, fremden setState()-Trigger (z.B. Tab-Wechsel) live + // erscheint. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + expect(find.byType(BackdropFilter), findsOneWidget); + + await tester.pumpAndSettle(); + + // Baum abbauen, bevor der Test endet — derselbe Grund wie in + // test/home_shell_test.dart (offener Positions-Timer des Audio-Handlers). + await tester.pumpWidget(const SizedBox()); + handler.dispose(); + await tester.pump(const Duration(milliseconds: 1)); + }); + + testWidgets( + 'State-Persistenz: _CoverGrundState bleibt über einen ' + 'open()/close()/open()-Zyklus erhalten (Spec-Vorteil ggü. dem früheren ' + 'Navigator.push, das die Route bei jedem Schließen disposte)', + (tester) async { + final db = MeloDb(NativeDatabase.memory()); + final lib = LibraryService(db); + final playlists = PlaylistService(db); + final handler = MeloAudioHandler(db: db); + final auth = BakaAuth(speicher: _MemorySpeicher()); + + await tester.pumpWidget(MultiProvider( + providers: [ + Provider.value(value: db), + ChangeNotifierProvider.value(value: lib), + ChangeNotifierProvider.value(value: playlists), + Provider.value(value: handler), + ChangeNotifierProvider(create: (_) => CategoryService(db)), + ChangeNotifierProvider(create: (_) => AppSettings()), + ChangeNotifierProvider(create: (_) => OfflineMode()), + ChangeNotifierProvider(create: (_) => auth), + ChangeNotifierProvider( + create: (_) => YtDownloadService(auth: auth)), + ChangeNotifierProvider( + create: (_) => YtSearchService(auth: auth)), + ChangeNotifierProvider( + create: (_) => DownloadService(db: db, navidrome: NavidromeService())), + ChangeNotifierProvider( + create: (_) => + SyncService(db: db, cloud: MeloCloudService(auth: auth))), + ], + child: const MaterialApp(home: HomeShell()), + )); + await tester.pump(); + + // _CoverGrund ist privat (now_playing_screen.dart) — von außen per + // runtimeType-Namen gefunden statt per find.byType, das den Typ + // importierbar/öffentlich voraussetzen würde. skipOffstage: false ist + // hier nötig: bei progress == 0 steckt NowPlayingScreen in einem + // Visibility(visible: false, maintainState: true) — genau das soll + // dieser Test ja beweisen —, und Flutters Standard-Finder überspringen + // Offstage-Elemente per Default (per Debug-Ausgabe verifiziert). + final coverGrundFinder = find.byWidgetPredicate( + (w) => w.runtimeType.toString() == '_CoverGrund', + skipOffstage: false); + expect(coverGrundFinder, findsOneWidget, + reason: 'NowPlayingScreen bleibt dank Visibility(maintainState: ' + 'true) (Task 5) von Anfang an im Baum, auch bei progress == 0.'); + final zustandVorher = tester.state(coverGrundFinder); + + final descendantContext = tester.element(find.byType(MiniPlayer)); + final controller = Provider.of( + descendantContext, + listen: false); + controller.open(descendantContext); + await tester.pumpAndSettle(); + controller.close(descendantContext); + await tester.pumpAndSettle(); + controller.open(descendantContext); + await tester.pumpAndSettle(); + + final zustandNachher = tester.state(coverGrundFinder); + expect(identical(zustandVorher, zustandNachher), isTrue, + reason: '_CoverGrundState wäre beim früheren Navigator.push-Ansatz ' + 'bei jedem Schließen disposed und neu erzeugt worden.'); + + await tester.pumpWidget(const SizedBox()); + handler.dispose(); + await tester.pump(const Duration(milliseconds: 1)); + }); +}