- Handy: Chat gleitet federnd über die Chatliste (Liste bleibt aufgebaut, Scrollposition erhalten), beim Zurück wieder hinaus; Liste schon während des Hinausgleitens bedienbar. Mitgliederleiste gleitet herein. - Neue/gesendete Nachrichten ploppen weich auf (nur live, nicht beim Öffnen oder Nachladen), Reaktionen ploppen auf und hüpfen beim Hochzählen, Ungelesen-Zähler hüpft, Scroll-nach-unten-Knopf federt (Handy größer), Antwort-Leiste klappt federnd auf/zu, Senden-Pfeil ploppt, Vollansicht zoomt auf. Alles skaliert mit Einstellung „Animationen“ (0 % = aus). - Fix: Beim Raumwechsel blieben Antwort-Ziel, Auswahl und getippter Text am neuen Raum hängen (Antwort auf Raum A konnte in Raum B rausgehen). Jetzt eigenes Eingabefeld je Raum mit Entwurf im Speicher. - Antwort-Leiste: größeres Schließen-X auf dem Handy. - Tests: test/spring_motion_test.dart, UI-Durchlauf mit Übergängen. Co-Authored-By: Claude Opus 5.5 <[email protected]>
313 lines
12 KiB
Dart
313 lines
12 KiB
Dart
import 'dart:io';
|
||
import 'dart:ui' as ui;
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/rendering.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:integration_test/integration_test.dart';
|
||
import 'package:pyramid/core/app_state.dart';
|
||
import 'package:pyramid/core/appearance.dart';
|
||
import 'package:pyramid/core/theme.dart';
|
||
import 'package:pyramid/main.dart' as app;
|
||
import 'package:pyramid/widgets/spring_press.dart';
|
||
|
||
/// UI-Durchlauf im HANDY-Format mit der echten App (Windows-Build) und einem
|
||
/// TEST-Profil – erzeugt Screenshots unter build/ui_screens/.
|
||
///
|
||
/// Läuft nur mit gesetztem PYRAMID_PROFILE_DIR (eingeloggtes Testprofil, z. B.
|
||
/// pyramidtest1 unter %USERPROFILE%\.pyramid-autopilot\profile1) – nie gegen
|
||
/// Bernds echtes Profil. Testinhalte vorher mit
|
||
/// `test/live_ui_fixture_test.dart` anlegen (DM + Gruppe mit pyramidtest2).
|
||
///
|
||
/// $env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1"
|
||
/// flutter test integration_test/mobile_ui_screens_test.dart -d windows
|
||
///
|
||
/// Achtung: Keine zweite Pyramid-Instanz auf demselben Profil laufen lassen
|
||
/// (zwei Clients auf einer olm-DB). Ansichten (Darstellung, Farbmodus, Raum)
|
||
/// werden nur über den App-Zustand umgeschaltet – nichts wird gespeichert.
|
||
///
|
||
/// Simuliert wird ein typisches Handy: 1080×2340 px, Dichte 2,625
|
||
/// (≈ 411×891 dp), Statusleiste oben 24 dp, Gestenleiste unten 16 dp.
|
||
const _otherUser = 'pyramidtest2';
|
||
const _groupName = 'UI-Test Gruppe';
|
||
|
||
void main() {
|
||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||
final profile = Platform.environment['PYRAMID_PROFILE_DIR'];
|
||
|
||
testWidgets(
|
||
'Handy-UI: Chatliste, Chat, Bild-Vollansicht, Gruppe, Einstellungen',
|
||
(tester) async {
|
||
tester.view
|
||
..physicalSize = const Size(1080, 2340)
|
||
..devicePixelRatio = 2.625
|
||
..padding = const FakeViewPadding(top: 63, bottom: 42)
|
||
..viewPadding = const FakeViewPadding(top: 63, bottom: 42);
|
||
addTearDown(tester.view.reset);
|
||
final size = tester.view.physicalSize / tester.view.devicePixelRatio;
|
||
|
||
app.main();
|
||
await _pumpUntil(
|
||
tester,
|
||
find.text(_otherUser),
|
||
timeout: const Duration(seconds: 90),
|
||
);
|
||
final container = ProviderScope.containerOf(
|
||
tester.element(find.byType(MaterialApp).first),
|
||
);
|
||
await _shot(tester, '01_chatliste');
|
||
|
||
// ── Direktnachricht: Sprechblasen (Handy-Standard) und Liste ──
|
||
await tester.tap(find.text(_otherUser).first);
|
||
// Chat gleitet federnd über die Liste.
|
||
await tester.pump(const Duration(milliseconds: 90));
|
||
await _shot(tester, '01b_chat_gleitet_herein');
|
||
await _pumpFor(tester, const Duration(seconds: 6));
|
||
await _scrollToNewest(tester);
|
||
await _shot(tester, '02_chat');
|
||
|
||
// Kontakt-Banner aufklappen (Tipp auf den Namen im Kopf) und wieder zu.
|
||
await tester.tap(find.text(_otherUser).first);
|
||
await _pumpFor(tester, const Duration(seconds: 1));
|
||
await _shot(tester, '02c_dm_kopf_aufgeklappt');
|
||
await tester.tap(find.text(_otherUser).first);
|
||
await _pumpFor(tester, const Duration(seconds: 1));
|
||
|
||
// Antwort-Zitat antippen → springt zur zitierten Nachricht.
|
||
final quote = find.byWidgetPredicate(
|
||
(w) => w is SpringPress && w.pressedScale == 0.97,
|
||
);
|
||
await tester.scrollUntilVisible(
|
||
quote.first,
|
||
300,
|
||
scrollable: find.byType(Scrollable).first,
|
||
maxScrolls: 40,
|
||
);
|
||
await _pumpFor(tester, const Duration(milliseconds: 600));
|
||
await _shot(tester, '02e_zitat_vor_dem_tippen');
|
||
await tester.tap(quote.first, warnIfMissed: false);
|
||
await _pumpFor(tester, const Duration(milliseconds: 900));
|
||
await _shot(tester, '02f_sprung_zum_zitat');
|
||
await _scrollToNewest(tester);
|
||
|
||
// ⋮-Menü mit Suchen/Mitglieder.
|
||
await tester.tap(find.byIcon(Icons.more_vert_rounded).first);
|
||
await _pumpFor(tester, const Duration(seconds: 1));
|
||
expect(find.text('Mitglieder'), findsWidgets);
|
||
await _shot(tester, '02d_menue');
|
||
await tester.tapAt(Offset(size.width / 2, size.height / 2));
|
||
await _pumpFor(tester, const Duration(seconds: 1));
|
||
|
||
// Antworten → Antwort-Leiste federt auf. (Wischen zum Antworten gibt
|
||
// es nur auf echten Handys; hier über Lange-Drücken → Antworten.)
|
||
// Neben die eigene Sprechblase drücken (nicht in den Text – dort
|
||
// greift am PC die Textauswahl).
|
||
final ownMsg = tester.getCenter(find.text('Danke, sieht gut aus!').first);
|
||
await tester.longPressAt(Offset(size.width * 0.15, ownMsg.dy));
|
||
await _pumpFor(tester, const Duration(milliseconds: 500));
|
||
await tester.tap(find.byIcon(Icons.reply_rounded).first);
|
||
await tester.pump(const Duration(milliseconds: 60));
|
||
await _shot(tester, '02g_antwort_leiste_federt');
|
||
await _pumpFor(tester, const Duration(milliseconds: 800));
|
||
await _shot(tester, '02h_antwort_leiste');
|
||
final closeReply = find.byIcon(Icons.close_rounded);
|
||
if (closeReply.evaluate().isNotEmpty) {
|
||
await tester.tap(closeReply.first);
|
||
await _pumpFor(tester, const Duration(milliseconds: 600));
|
||
}
|
||
|
||
container.read(messageStyleProvider.notifier).state = MessageStyle.list;
|
||
await _pumpFor(tester, const Duration(seconds: 2));
|
||
await _shot(tester, '02b_chat_liste');
|
||
container.read(messageStyleProvider.notifier).state = MessageStyle.auto;
|
||
await _pumpFor(tester, const Duration(seconds: 2));
|
||
|
||
// ── Bild-Vollansicht ──
|
||
// Größtes Bild im Chat = eines der Testbilder; erst sichtbar scrollen.
|
||
Future<void> openViewer() async {
|
||
final image = _largestImage(tester);
|
||
expect(image, isNotNull, reason: 'kein Bild im Test-Chat gefunden');
|
||
await tester.ensureVisible(image!);
|
||
await _pumpFor(tester, const Duration(milliseconds: 600));
|
||
await tester.tap(image);
|
||
await _pumpFor(tester, const Duration(seconds: 2));
|
||
expect(
|
||
find.byType(InteractiveViewer),
|
||
findsOneWidget,
|
||
reason: 'Antippen des Bildes hat die Vollansicht nicht geöffnet',
|
||
);
|
||
}
|
||
|
||
await openViewer();
|
||
await _shot(tester, '03_vollansicht');
|
||
|
||
// Tippen NEBEN das Bild (ganz unten, unter dem Bild) schließt.
|
||
await tester.tapAt(Offset(size.width / 2, size.height - 30));
|
||
await _pumpFor(tester, const Duration(seconds: 2));
|
||
await _shot(tester, '04_nach_tippen_daneben');
|
||
expect(
|
||
find.byType(InteractiveViewer),
|
||
findsNothing,
|
||
reason: 'Tippen neben das Bild hat die Vollansicht nicht geschlossen',
|
||
);
|
||
|
||
// Tippen AUFS Bild schließt NICHT (blendet nur die Leiste aus).
|
||
await openViewer();
|
||
await tester.tapAt(Offset(size.width / 2, size.height / 2));
|
||
await _pumpFor(tester, const Duration(seconds: 1));
|
||
expect(
|
||
find.byType(InteractiveViewer),
|
||
findsOneWidget,
|
||
reason: 'Tippen aufs Bild darf die Vollansicht nicht schließen',
|
||
);
|
||
|
||
// Doppeltipp zoomt hinein, nochmal doppelt wieder heraus.
|
||
Future<void> doubleTapCenter() async {
|
||
await tester.tapAt(Offset(size.width / 2, size.height / 2));
|
||
await tester.pump(const Duration(milliseconds: 80));
|
||
await tester.tapAt(Offset(size.width / 2, size.height / 2));
|
||
await _pumpFor(tester, const Duration(seconds: 1));
|
||
}
|
||
|
||
await doubleTapCenter();
|
||
final viewer = tester.widget<InteractiveViewer>(
|
||
find.byType(InteractiveViewer),
|
||
);
|
||
expect(
|
||
viewer.transformationController!.value.getMaxScaleOnAxis(),
|
||
greaterThan(1.5),
|
||
reason: 'Doppeltipp hat nicht gezoomt',
|
||
);
|
||
await _shot(tester, '05_doppeltipp_zoom');
|
||
await doubleTapCenter();
|
||
|
||
// Wischen nach unten schließt.
|
||
await tester.dragFrom(
|
||
Offset(size.width / 2, size.height / 2),
|
||
const Offset(0, 320),
|
||
);
|
||
await _pumpFor(tester, const Duration(seconds: 2));
|
||
expect(
|
||
find.byType(InteractiveViewer),
|
||
findsNothing,
|
||
reason: 'Wischen nach unten hat die Vollansicht nicht geschlossen',
|
||
);
|
||
await _shot(tester, '06_nach_wischen');
|
||
|
||
// ── Farbmodus Hell (nur Zustand) ──
|
||
final savedVariant = container.read(themeVariantProvider);
|
||
container.read(themeVariantProvider.notifier).state = ThemeVariant.light;
|
||
await _pumpFor(tester, const Duration(seconds: 2));
|
||
await _shot(tester, '07_chat_hell');
|
||
container.read(themeVariantProvider.notifier).state = savedVariant;
|
||
await _pumpFor(tester, const Duration(seconds: 1));
|
||
|
||
// ── Gruppenraum (Namen + Avatare in den Sprechblasen) ──
|
||
container.read(activeRoomIdProvider.notifier).state = null;
|
||
container.read(activeSpaceIdProvider.notifier).state = 'rooms';
|
||
await _pumpUntil(
|
||
tester,
|
||
find.text(_groupName),
|
||
timeout: const Duration(seconds: 30),
|
||
);
|
||
await tester.tap(find.text(_groupName).first);
|
||
await _pumpFor(tester, const Duration(seconds: 5));
|
||
await _scrollToNewest(tester);
|
||
await _shot(tester, '08_gruppe');
|
||
|
||
// ── Einstellungen → Erscheinungsbild ──
|
||
container.read(activeModalProvider.notifier).state = ModalKind.settings;
|
||
await _pumpFor(tester, const Duration(seconds: 2));
|
||
await tester.tap(find.text('Erscheinungsbild').first);
|
||
await _pumpFor(tester, const Duration(seconds: 2));
|
||
await _shot(tester, '09_einstellungen_erscheinungsbild');
|
||
await tester.drag(
|
||
find.text('Schriftgröße').first,
|
||
const Offset(0, -500),
|
||
warnIfMissed: false,
|
||
);
|
||
await _pumpFor(tester, const Duration(seconds: 1));
|
||
await _shot(tester, '10_einstellungen_text_chat');
|
||
container.read(activeModalProvider.notifier).state = ModalKind.none;
|
||
await _pumpFor(tester, const Duration(seconds: 1));
|
||
|
||
// ── Zurück zur Chatliste: Chat gleitet hinaus, Liste bleibt erhalten ──
|
||
container.read(activeRoomIdProvider.notifier).state = null;
|
||
await tester.pump(const Duration(milliseconds: 90));
|
||
await _shot(tester, '11_zurueck_gleitet_hinaus');
|
||
await _pumpFor(tester, const Duration(seconds: 1));
|
||
expect(find.text(_groupName), findsWidgets);
|
||
await _shot(tester, '12_liste_nach_zurueck');
|
||
},
|
||
skip: profile == null,
|
||
timeout: const Timeout(Duration(minutes: 6)),
|
||
);
|
||
}
|
||
|
||
Finder? _largestImage(WidgetTester tester) {
|
||
Element? best;
|
||
var bestArea = 150.0 * 150.0;
|
||
for (final element in find.byType(Image).evaluate()) {
|
||
final box = element.renderObject;
|
||
if (box is! RenderBox || !box.hasSize) continue;
|
||
final area = box.size.width * box.size.height;
|
||
if (area > bestArea) {
|
||
bestArea = area;
|
||
best = element;
|
||
}
|
||
}
|
||
return best == null ? null : find.byElementPredicate((e) => e == best);
|
||
}
|
||
|
||
/// Ganz nach unten zu den neuesten Nachrichten scrollen.
|
||
Future<void> _scrollToNewest(WidgetTester tester) async {
|
||
for (var i = 0; i < 6; i++) {
|
||
final lists = find.byType(Scrollable);
|
||
if (lists.evaluate().isEmpty) return;
|
||
await tester.fling(lists.first, const Offset(0, -1500), 3000,
|
||
warnIfMissed: false);
|
||
await _pumpFor(tester, const Duration(milliseconds: 500));
|
||
}
|
||
await _pumpFor(tester, const Duration(seconds: 2));
|
||
}
|
||
|
||
Future<void> _pumpFor(WidgetTester tester, Duration duration) async {
|
||
final end = DateTime.now().add(duration);
|
||
while (DateTime.now().isBefore(end)) {
|
||
await tester.pump(const Duration(milliseconds: 100));
|
||
}
|
||
}
|
||
|
||
Future<void> _pumpUntil(
|
||
WidgetTester tester,
|
||
Finder finder, {
|
||
required Duration timeout,
|
||
}) async {
|
||
final end = DateTime.now().add(timeout);
|
||
while (DateTime.now().isBefore(end)) {
|
||
await tester.pump(const Duration(milliseconds: 200));
|
||
if (finder.evaluate().isNotEmpty) return;
|
||
}
|
||
fail('Zeitüberschreitung: $finder nicht gefunden');
|
||
}
|
||
|
||
/// Speichert, was gerade auf dem (simulierten) Bildschirm ist.
|
||
Future<void> _shot(WidgetTester tester, String name) async {
|
||
await tester.pump();
|
||
final renderView = tester.binding.renderViews.first;
|
||
final layer = renderView.debugLayer! as OffsetLayer;
|
||
final physical = tester.view.physicalSize;
|
||
final image = await tester.binding.runAsync(
|
||
() => layer.toImage(Offset.zero & physical),
|
||
);
|
||
final bytes = await tester.binding.runAsync(
|
||
() => image!.toByteData(format: ui.ImageByteFormat.png),
|
||
);
|
||
final file = File('build/ui_screens/$name.png');
|
||
file.parent.createSync(recursive: true);
|
||
file.writeAsBytesSync(bytes!.buffer.asUint8List());
|
||
// ignore: avoid_print
|
||
print('Screenshot: ${file.path}');
|
||
}
|