import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:marianum_mobile/app.dart'; import 'package:marianum_mobile/main.dart' as app; import 'package:marianum_mobile/routing/app_routes.dart'; import 'package:marianum_mobile/state/app/modules/app_modules.dart'; import 'package:marianum_mobile/state/app/modules/settings/bloc/settings_cubit.dart'; import 'package:marianum_mobile/view/pages/talk/widgets/chat_tile.dart'; /// Marketing-Screenshot-Lauf für die Stores. Loggt sich über den Demo-Login /// (`demo@…`, siehe DemoMode) ein und nimmt einen kuratierten Satz Screens auf: /// Stundenplan (hell + dunkel), Talk-Liste, Talk-Chat, Dateien, „Mehr"-Bereich /// und Einstellungen. Ausgeführt über `flutter drive` mit /// test_driver/integration_test.dart, orchestriert von tool/screenshots.sh /// (Phone / 7″ / 10″). /// /// Kein Netzwerk: der Demo-Modus beantwortet allen Backend-Verkehr aus /// Fixtures, daher ist der Lauf deterministisch. Future main() async { final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); testWidgets('Store-Screenshots über den Demo-Login', (tester) async { // Der integration_test-Harness installiert eigene Error-Handler. app() // überschreibt sie in main() durch die produktiven (ClientErrorReporter), // was die End-of-Test-Buchführung des Harness sprengt (_pendingExceptionDetails). // Daher vorher sichern und nach main() zurücksetzen. final harnessFlutterOnError = FlutterError.onError; final harnessDispatcherOnError = PlatformDispatcher.instance.onError; _log('main() starten'); await app.main(); FlutterError.onError = harnessFlutterOnError; PlatformDispatcher.instance.onError = harnessDispatcherOnError; await tester.pump(const Duration(seconds: 1)); _log('Login'); await _login(tester); _log('warte auf App-Shell'); await _pumpUntil( tester, find.byType(App), timeout: const Duration(seconds: 20), ); _log('warte auf Post-Login-Splash-Ende'); await _pumpUntilGone( tester, find.byKey(const ValueKey('post-login-splash')), timeout: const Duration(seconds: 12), ); await tester.pump(const Duration(seconds: 1)); // Screenshots auf Android brauchen eine einmalige Umwandlung der // Flutter-Surface in eine lesbare Image-Texture. Erst hier – nach Login und // Splash –, damit der sichtbare Screen erst kurz vor der Aufnahme „einfriert". if (Platform.isAndroid) { _log('convertFlutterSurfaceToImage'); await binding.convertFlutterSurfaceToImage(); } // 1 + 2: Stundenplan in beiden Themes. await _goTo(tester, Modules.timetable); _setTheme(tester, ThemeMode.light); await _capture(tester, binding, '1_stundenplan_hell'); _setTheme(tester, ThemeMode.dark); await _capture(tester, binding, '2_stundenplan_dunkel'); // Restlicher Satz einheitlich im hellen Theme. _setTheme(tester, ThemeMode.light); // 3: Talk-Liste. await _goTo(tester, Modules.talk); await _capture(tester, binding, '3_talk_liste'); // 4: Talk-Chat (ersten Chat der Liste öffnen). await tester.tap(find.byType(ChatTile).first); await _capture(tester, binding, '4_talk_chat'); _popPushedPage(tester); await tester.pump(const Duration(milliseconds: 500)); // 5: Dateien. await _goTo(tester, Modules.files); await _capture(tester, binding, '5_dateien'); // 6: „Mehr"-Bereich (letzter Tab, hinter den Modul-Tabs). _goToMore(tester); await _capture(tester, binding, '6_mehr'); // 7: Einstellungen (Vollbild-Push). AppRoutes.openSettings(tester.element(find.byType(App))); await _capture(tester, binding, '7_einstellungen'); _popPushedPage(tester); _log('fertig'); }); } void _log(String message) => debugPrint('SHOTS: $message'); Future _login(WidgetTester tester) async { final loginVisible = await _pumpUntil( tester, find.byKey(const Key('login-username-field')), ); if (!loginVisible) { _log('kein Login-Screen sichtbar – bereits angemeldet, überspringe Login'); return; } await tester.enterText( find.byKey(const Key('login-username-field')), 'demo@screenshots', ); await tester.enterText(find.byKey(const Key('login-password-field')), 'demo'); await tester.pump(); await tester.tap(find.byKey(const Key('login-submit-button'))); await tester.pump(const Duration(milliseconds: 500)); } /// Setzt das App-Theme über den [SettingsCubit] (MaterialApp folgt /// `settings.appTheme`). `val(write: true)` plant den Emit als Microtask. void _setTheme(WidgetTester tester, ThemeMode mode) { final context = tester.element(find.byType(App)); context.read().val(write: true).appTheme = mode; } /// Navigiert zu [module] – über den Bottom-Tab, wenn er in der Leiste liegt, /// sonst als Vollbild-Push. Größenunabhängig, da die Tab-Anzahl je Gerät variiert. Future _goTo(WidgetTester tester, Modules module) async { final appFinder = find.byType(App); if (appFinder.evaluate().isEmpty) { _log('WARN: App-Shell fehlt, kann $module nicht ansteuern'); return; } final context = tester.element(appFinder); if (!AppRoutes.goToTab(context, module)) { final resolved = AppModule.modules(context)[module]; if (resolved != null) AppRoutes.openModule(context, resolved); } } /// Springt auf den „Mehr"-Tab. Der liegt hinter den Modul-Tabs, sein Index ist /// also die Anzahl der Bottom-Bar-Module. void _goToMore(WidgetTester tester) { final context = tester.element(find.byType(App)); final moreIndex = AppModule.getBottomBarModules(context).length; app.Main.bottomNavigator.jumpToTab(moreIndex); } /// Schließt einen per [AppRoutes] gepushten Vollbild-Screen wieder. void _popPushedPage(WidgetTester tester) { final navigator = AppRoutes.rootNavigatorKey.currentState; if (navigator != null && navigator.canPop()) navigator.pop(); } /// Lässt die Oberfläche zur Ruhe kommen und legt den Screenshot ab. Future _capture( WidgetTester tester, IntegrationTestWidgetsFlutterBinding binding, String name, ) async { // Feste Setzzeit statt „warte auf Spinner-Ende": die Demo-Fixtures kommen // synchron (keine künstlichen Delays), aber Module wie Dateien/Chat laden erst // beim Navigieren nach. Eine globale CircularProgressIndicator-Prüfung taugt // nicht, weil die PersistentTabView alle Tabs am Leben hält und // Avatar-Platzhalter dauerhaft einen Spinner zeigen. In 500ms-Schritten // pumpen, damit Timer/Microtasks (u.a. der Theme-Emit) durchlaufen. await _settle(tester, const Duration(seconds: 6)); _log('-> $name: takeScreenshot'); await binding.takeScreenshot(name); } /// Pumpt [duration] in 500ms-Schritten ab, damit die App async Fixtures laden /// und ihre Frames rendern kann (Ersatz für pumpAndSettle, das an den /// Dauer-Animationen hängen bleibt). Future _settle(WidgetTester tester, Duration duration) async { final steps = duration.inMilliseconds ~/ 500; for (var i = 0; i < steps; i++) { await tester.pump(const Duration(milliseconds: 500)); } } /// Pumpt in kurzen Schritten, bis [finder] erscheint oder [timeout] abläuft. /// Gibt zurück, ob [finder] gefunden wurde. Future _pumpUntil( WidgetTester tester, Finder finder, { Duration timeout = const Duration(seconds: 10), }) async { final end = DateTime.now().add(timeout); while (DateTime.now().isBefore(end)) { await tester.pump(const Duration(milliseconds: 200)); if (finder.evaluate().isNotEmpty) return true; } return false; } /// Gegenstück zu [_pumpUntil]: pumpt, bis [finder] nichts mehr trifft. Future _pumpUntilGone( WidgetTester tester, Finder finder, { Duration timeout = const Duration(seconds: 10), }) async { final end = DateTime.now().add(timeout); while (DateTime.now().isBefore(end)) { await tester.pump(const Duration(milliseconds: 200)); if (finder.evaluate().isEmpty) return true; } return false; }