1114291313
Fastlane supply config (android/fastlane) with de-DE metadata structure, integration_test-driven screenshot automation (screenshot_test.dart, test_driver, tool/screenshots.sh) using stable keys on the login form, refreshed materials/screenshots (phone + 7-inch) and app banner/symbol. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
216 lines
8.1 KiB
Dart
216 lines
8.1 KiB
Dart
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<void> 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<void> _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<SettingsCubit>().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<void> _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<void> _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<void> _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<bool> _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<bool> _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;
|
||
}
|